mirror of
https://github.com/nicotsx/ironmount.git
synced 2025-12-10 12:10:51 +01:00
Compare commits
85 Commits
v0.7.0-alp
...
e85cc35b1a
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e85cc35b1a | ||
|
|
321dc4cdf7 | ||
|
|
0f7bd1e042 | ||
|
|
08d8a47352 | ||
|
|
1e20fb225e | ||
|
|
b8e30e298c | ||
|
|
ce07c588ad | ||
|
|
e7f0a2828d | ||
|
|
7ff38f0128 | ||
|
|
33e6f3773b | ||
|
|
a91dede086 | ||
|
|
9b46737852 | ||
|
|
999850dab8 | ||
|
|
dbd9ae2241 | ||
|
|
0287bca4bb | ||
|
|
9a9991eb9b | ||
|
|
03b898f84c | ||
|
|
6fbb11fefe | ||
|
|
3bf3b22b96 | ||
|
|
58708cf35d | ||
|
|
1d4e7100ab | ||
|
|
0dfe000148 | ||
|
|
7d9d3d5d3d | ||
|
|
8e90c4ace1 | ||
|
|
803eb1cd76 | ||
|
|
673827f9f3 | ||
|
|
4328607cc1 | ||
|
|
bedd325a60 | ||
|
|
b26a062648 | ||
|
|
d190d9c8cd | ||
|
|
f8363a6c71 | ||
|
|
59b2b53837 | ||
|
|
e99487eed9 | ||
|
|
8d4e5d2d4e | ||
|
|
daea3e64e4 | ||
|
|
70df79079f | ||
|
|
f1096220dd | ||
|
|
2418870284 | ||
|
|
43dfe6b190 | ||
|
|
8c4939af4e | ||
|
|
a622b5e689 | ||
|
|
6c30e7e357 | ||
|
|
043f73ea87 | ||
|
|
518700eef6 | ||
|
|
a250c442f8 | ||
|
|
6981600ad7 | ||
|
|
cb0d23fd52 | ||
|
|
0e4c302620 | ||
|
|
ef87ca816d | ||
|
|
70e4c782ff | ||
|
|
c726c6fc72 | ||
|
|
4d48d7be58 | ||
|
|
df6b70c96f | ||
|
|
94423bd0a5 | ||
|
|
ed2a625fa7 | ||
|
|
a3e027694a | ||
|
|
0d36484c04 | ||
|
|
67b1accbd0 | ||
|
|
98924ea59d | ||
|
|
e5435969be | ||
|
|
c0bef7f65e | ||
|
|
29c96c9fc6 | ||
|
|
2c0f22af59 | ||
|
|
3ff6a04f8e | ||
|
|
54ee02deb9 | ||
|
|
b83881c189 | ||
|
|
d78b4adfd9 | ||
|
|
4d3ec524e2 | ||
|
|
681cf5dff1 | ||
|
|
31da747c2d | ||
|
|
b86081b2e8 | ||
|
|
3622fd57ef | ||
|
|
5b1d7eff17 | ||
|
|
2b3d8dffc5 | ||
|
|
f517438a8e | ||
|
|
1ddd4d701b | ||
|
|
9a1797b8b2 | ||
|
|
52046c88cc | ||
|
|
951d9d970c | ||
|
|
ffc821af2b | ||
|
|
cfeff643c4 | ||
|
|
c898e1ce07 | ||
|
|
c179a16d15 | ||
|
|
00916a1fd2 | ||
|
|
18f863cbac |
1
.github/copilot-instructions.md
vendored
Normal file
1
.github/copilot-instructions.md
vendored
Normal 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.
|
||||
11
.github/workflows/release.yml
vendored
11
.github/workflows/release.yml
vendored
@@ -54,7 +54,7 @@ jobs:
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ghcr.io/${{ github.repository_owner }}/ironmount
|
||||
images: ghcr.io/${{ github.repository_owner }}/zerobyte
|
||||
tags: |
|
||||
type=semver,pattern={{version}},prefix=v
|
||||
type=semver,pattern={{major}},prefix=v,enable=${{ needs.determine-release-type.outputs.release_type == 'release' }}
|
||||
@@ -62,8 +62,8 @@ jobs:
|
||||
type=semver,pattern={{major}}.{{minor}}.{{patch}},prefix=v,enable=${{ needs.determine-release-type.outputs.release_type == 'release' }}
|
||||
flavor: |
|
||||
latest=${{ needs.determine-release-type.outputs.release_type == 'release' }}
|
||||
cache-from: type=registry,ref=ghcr.io/nicotsx/ironmount:buildcache
|
||||
cache-to: type=registry,ref=ghcr.io/nicotsx/ironmount:buildcache,mode=max
|
||||
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
|
||||
uses: docker/build-push-action@v6
|
||||
@@ -74,10 +74,13 @@ jobs:
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
build-args: |
|
||||
APP_VERSION=${{ needs.determine-release-type.outputs.tagname }}
|
||||
|
||||
publish-release:
|
||||
runs-on: ubuntu-latest
|
||||
needs: [build-images]
|
||||
needs: [build-images, determine-release-type]
|
||||
if: needs.determine-release-type.outputs.release_type == 'release'
|
||||
outputs:
|
||||
id: ${{ steps.create_release.outputs.id }}
|
||||
steps:
|
||||
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -9,3 +9,6 @@
|
||||
.env
|
||||
.turbo
|
||||
CLAUDE.md
|
||||
|
||||
mutagen.yml.lock
|
||||
notes.md
|
||||
|
||||
267
AGENTS.md
Normal file
267
AGENTS.md
Normal 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
|
||||
167
CONTRIBUTING.md
Normal file
167
CONTRIBUTING.md
Normal file
@@ -0,0 +1,167 @@
|
||||
# Contributing to Zerobyte
|
||||
|
||||
Thank you for your interest in contributing to Zerobyte! We welcome contributions from the community and are grateful for your support in making this project better.
|
||||
|
||||
## Table of Contents
|
||||
|
||||
- [Getting Started](#getting-started)
|
||||
- [Contributor License Agreement (CLA)](#contributor-license-agreement-cla)
|
||||
- [How to Contribute](#how-to-contribute)
|
||||
- [Development Setup](#development-setup)
|
||||
- [Submission Guidelines](#submission-guidelines)
|
||||
- [Code Standards](#code-standards)
|
||||
- [Community Guidelines](#community-guidelines)
|
||||
|
||||
## Getting Started
|
||||
|
||||
Before you begin:
|
||||
|
||||
1. Check the [issues](https://github.com/nicotsx/zerobyte/issues) to see if someone is already working on what you have in mind
|
||||
2. For major changes, please open an issue first to discuss what you would like to change
|
||||
3. Make sure you have read and agreed to our Contributor License Agreement (CLA)
|
||||
|
||||
## Contributor License Agreement (CLA)
|
||||
|
||||
### What is a CLA?
|
||||
|
||||
A Contributor License Agreement (CLA) is a legal document in which you state you are entitled to contribute the code/documentation/translation to the project you're contributing to and are willing to have it used in distributions and derivative works. This means you grant us permission to use your contributions under our project's license terms.
|
||||
|
||||
### Why do we need a CLA?
|
||||
|
||||
We require a CLA for several important reasons:
|
||||
|
||||
1. **License Flexibility**: It allows the project to evolve its licensing model if needed in the future without requiring re-approval from all past contributors, ensuring Zerobyte can continue to operate and adapt to changing needs of the community.
|
||||
|
||||
2. **Patent Protection**: The CLA includes a patent license grant, which protects the project and its users from potential patent claims related to your contributions.
|
||||
|
||||
3. **Protecting Your Rights**: While you grant us rights to use your contributions, you retain ownership of your work and can use it for any other purpose.
|
||||
|
||||
### How to Sign the CLA
|
||||
|
||||
When you submit your first pull request, our CLA Assistant will automatically prompt you to sign the agreement via GitHub. The process is simple:
|
||||
|
||||
1. Create your pull request
|
||||
2. The CLA Assistant bot will comment on your PR
|
||||
3. Follow the link provided to review and sign the CLA electronically
|
||||
4. Once signed, the bot will update your PR status
|
||||
|
||||
You only need to sign the CLA once, and it will cover all your future contributions to Zerobyte.
|
||||
|
||||
### Key Points of Our CLA
|
||||
|
||||
- You grant us a non-exclusive, royalty-free license to use your contributions
|
||||
- You retain ownership and all rights to your contributions
|
||||
- You confirm that you have the right to make the contribution (it's your original work or you have permission)
|
||||
- You're not required to provide support for your contributions
|
||||
- The CLA does not guarantee that your contribution will be accepted or kept into the project
|
||||
|
||||
For the complete CLA text, please see the [CLA document](https://cla-assistant.io/nicotsx/zerobyte).
|
||||
|
||||
## How to Contribute
|
||||
|
||||
There are many ways to contribute to Zerobyte:
|
||||
|
||||
### Reporting Bugs
|
||||
|
||||
If you find a bug, please open an issue with:
|
||||
- A clear, descriptive title
|
||||
- Steps to reproduce the issue
|
||||
- Expected vs. actual behavior
|
||||
- Your environment (OS, Docker version, Zerobyte version)
|
||||
- Any relevant logs or screenshots
|
||||
|
||||
### Suggesting Features
|
||||
|
||||
When suggesting a feature:
|
||||
- Check if it's already been suggested
|
||||
- Clearly describe the feature and its use case
|
||||
- Explain why it would be valuable to other users
|
||||
- Consider the scope and complexity
|
||||
|
||||
### Contributing Code
|
||||
|
||||
1. **Fork the repository** and create your branch from `main`
|
||||
2. **Make your changes** following our code standards
|
||||
3. **Test your changes** thoroughly
|
||||
4. **Update documentation** if needed
|
||||
5. **Commit your changes** with clear, descriptive commit messages
|
||||
6. **Push to your fork** and submit a pull request
|
||||
|
||||
### Improving Documentation
|
||||
|
||||
Documentation improvements are always welcome! This includes:
|
||||
- Fixing typos or clarifying existing docs
|
||||
- Adding examples or use cases
|
||||
- Writing guides or tutorials
|
||||
- Improving README or other documentation files
|
||||
|
||||
### Translations
|
||||
|
||||
We welcome translations to make Zerobyte accessible to more users worldwide. Please open an issue to discuss translation efforts before starting.
|
||||
|
||||
## Development Setup
|
||||
|
||||
1. **Clone your fork**:
|
||||
```bash
|
||||
git clone https://github.com/your-username/zerobyte.git
|
||||
cd zerobyte
|
||||
```
|
||||
|
||||
2. **Set up your development environment**:
|
||||
```bash
|
||||
bun run start:dev
|
||||
```
|
||||
|
||||
3. **Create a feature branch**:
|
||||
```bash
|
||||
git checkout -b feature/your-feature-name
|
||||
```
|
||||
|
||||
4. **Make your changes and test them**
|
||||
|
||||
5. **Commit your changes**:
|
||||
```bash
|
||||
git add .
|
||||
git commit -m "Add your descriptive commit message"
|
||||
```
|
||||
|
||||
## Submission Guidelines
|
||||
|
||||
### Pull Request Process
|
||||
|
||||
1. **Update your branch** with the latest changes from main before submitting
|
||||
2. **Ensure all tests pass** and your code builds successfully
|
||||
3. **Write a clear PR description** that explains:
|
||||
- What changes you made
|
||||
- Why you made them
|
||||
- Any breaking changes or migration notes
|
||||
- Link to related issues
|
||||
|
||||
4. **Be responsive** to feedback and review comments
|
||||
5. **Keep PRs focused** - one feature or fix per PR when possible
|
||||
|
||||
## Code Standards
|
||||
|
||||
- Follow the existing code style and conventions
|
||||
- Write clear, self-documenting code. No unless comments are necessary
|
||||
- Ensure your code is properly formatted
|
||||
- Keep security in mind - never commit sensitive data like passwords or API keys
|
||||
|
||||
## Community Guidelines
|
||||
|
||||
- Be respectful and constructive in all interactions
|
||||
- Welcome newcomers and help them get started
|
||||
- Assume good intentions
|
||||
- Focus on what is best for the community and the project
|
||||
- Show empathy towards other community members
|
||||
|
||||
## Questions?
|
||||
|
||||
If you have questions about contributing, feel free to:
|
||||
- Open an issue with your question
|
||||
- Check existing issues and discussions
|
||||
- Reach out to the maintainers
|
||||
|
||||
---
|
||||
|
||||
Thank you for contributing to Zerobyte!
|
||||
21
Dockerfile
21
Dockerfile
@@ -1,8 +1,8 @@
|
||||
ARG BUN_VERSION="1.3.1"
|
||||
ARG BUN_VERSION="1.3.3"
|
||||
|
||||
FROM oven/bun:${BUN_VERSION}-alpine AS base
|
||||
|
||||
RUN apk add --no-cache davfs2=1.6.1-r2
|
||||
RUN apk add --no-cache davfs2=1.6.1-r2 openssh-client
|
||||
|
||||
|
||||
# ------------------------------
|
||||
@@ -14,24 +14,27 @@ WORKDIR /deps
|
||||
|
||||
ARG TARGETARCH
|
||||
ARG RESTIC_VERSION="0.18.1"
|
||||
ARG SHOUTRRR_VERSION="0.12.1"
|
||||
ENV TARGETARCH=${TARGETARCH}
|
||||
|
||||
RUN apk add --no-cache curl bzip2
|
||||
RUN apk add --no-cache curl bzip2 unzip tar
|
||||
|
||||
RUN echo "Building for ${TARGETARCH}"
|
||||
RUN if [ "${TARGETARCH}" = "arm64" ]; then \
|
||||
curl -L -o restic.bz2 "https://github.com/restic/restic/releases/download/v$RESTIC_VERSION/restic_$RESTIC_VERSION"_linux_arm64.bz2; \
|
||||
curl -O https://downloads.rclone.org/rclone-current-linux-arm64.zip; \
|
||||
unzip rclone-current-linux-arm64.zip; \
|
||||
curl -L -o shoutrrr.tar.gz "https://github.com/nicholas-fedor/shoutrrr/releases/download/v$SHOUTRRR_VERSION/shoutrrr_linux_arm64v8_${SHOUTRRR_VERSION}.tar.gz"; \
|
||||
elif [ "${TARGETARCH}" = "amd64" ]; then \
|
||||
curl -L -o restic.bz2 "https://github.com/restic/restic/releases/download/v$RESTIC_VERSION/restic_$RESTIC_VERSION"_linux_amd64.bz2; \
|
||||
curl -O https://downloads.rclone.org/rclone-current-linux-amd64.zip; \
|
||||
unzip rclone-current-linux-amd64.zip; \
|
||||
curl -L -o shoutrrr.tar.gz "https://github.com/nicholas-fedor/shoutrrr/releases/download/v$SHOUTRRR_VERSION/shoutrrr_linux_amd64_${SHOUTRRR_VERSION}.tar.gz"; \
|
||||
fi
|
||||
|
||||
RUN bzip2 -d restic.bz2 && chmod +x restic
|
||||
RUN mv rclone-*-linux-*/rclone /deps/rclone && chmod +x /deps/rclone
|
||||
|
||||
RUN tar -xzf shoutrrr.tar.gz && chmod +x shoutrrr
|
||||
|
||||
# ------------------------------
|
||||
# DEVELOPMENT
|
||||
@@ -44,6 +47,8 @@ WORKDIR /app
|
||||
|
||||
COPY --from=deps /deps/restic /usr/local/bin/restic
|
||||
COPY --from=deps /deps/rclone /usr/local/bin/rclone
|
||||
COPY --from=deps /deps/shoutrrr /usr/local/bin/shoutrrr
|
||||
|
||||
COPY ./package.json ./bun.lock ./
|
||||
|
||||
RUN bun install --frozen-lockfile
|
||||
@@ -59,6 +64,8 @@ CMD ["bun", "run", "dev"]
|
||||
# ------------------------------
|
||||
FROM oven/bun:${BUN_VERSION} AS builder
|
||||
|
||||
ARG APP_VERSION=dev
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY ./package.json ./bun.lock ./
|
||||
@@ -66,6 +73,9 @@ RUN bun install --frozen-lockfile
|
||||
|
||||
COPY . .
|
||||
|
||||
RUN touch .env
|
||||
RUN echo "VITE_APP_VERSION=${APP_VERSION}" >> .env
|
||||
|
||||
RUN bun run build
|
||||
|
||||
FROM base AS production
|
||||
@@ -75,10 +85,11 @@ ENV NODE_ENV="production"
|
||||
WORKDIR /app
|
||||
|
||||
COPY --from=builder /app/package.json ./
|
||||
RUN bun install --production --frozen-lockfile
|
||||
RUN bun install --production --frozen-lockfile --verbose
|
||||
|
||||
COPY --from=deps /deps/restic /usr/local/bin/restic
|
||||
COPY --from=deps /deps/rclone /usr/local/bin/rclone
|
||||
COPY --from=deps /deps/shoutrrr /usr/local/bin/shoutrrr
|
||||
COPY --from=builder /app/dist/client ./dist/client
|
||||
COPY --from=builder /app/dist/server ./dist/server
|
||||
COPY --from=builder /app/app/drizzle ./assets/migrations
|
||||
|
||||
148
README.md
148
README.md
@@ -1,12 +1,12 @@
|
||||
<div align="center">
|
||||
<h1>Ironmount</h1>
|
||||
<h1>Zerobyte</h1>
|
||||
<h3>Powerful backup automation for your remote storage<br />Encrypt, compress, and protect your data with ease</h3>
|
||||
<a href="https://github.com/nicotsx/ironmount/blob/main/LICENSE">
|
||||
<img src="https://img.shields.io/github/license/nicotsx/ironmount" />
|
||||
<a href="https://github.com/nicotsx/zerobyte/blob/main/LICENSE">
|
||||
<img src="https://img.shields.io/github/license/nicotsx/zerobyte" />
|
||||
</a>
|
||||
<br />
|
||||
<figure>
|
||||
<img src="https://github.com/nicotsx/ironmount/blob/main/screenshots/volume-details.png?raw=true" alt="Demo" />
|
||||
<img src="https://github.com/nicotsx/zerobyte/blob/main/screenshots/backup-details.webp?raw=true" alt="Demo" />
|
||||
<figcaption>
|
||||
<p align="center">
|
||||
Backup management with scheduling and monitoring
|
||||
@@ -16,11 +16,15 @@
|
||||
</div>
|
||||
|
||||
> [!WARNING]
|
||||
> Ironmount 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
|
||||
|
||||
Ironmount 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.
|
||||
|
||||
### Features
|
||||
|
||||
@@ -31,24 +35,31 @@ Ironmount is a backup automation tool that helps you save your data across multi
|
||||
|
||||
## Installation
|
||||
|
||||
In order to run Ironmount, you need to have Docker and Docker Compose installed on your server. Then, you can use the provided `docker-compose.yml` file to start the application.
|
||||
In order to run Zerobyte, you need to have Docker and Docker Compose installed on your server. Then, you can use the provided `docker-compose.yml` file to start the application.
|
||||
|
||||
```yaml
|
||||
services:
|
||||
ironmount:
|
||||
image: ghcr.io/nicotsx/ironmount:v0.6
|
||||
container_name: ironmount
|
||||
zerobyte:
|
||||
image: ghcr.io/nicotsx/zerobyte:v0.15
|
||||
container_name: zerobyte
|
||||
restart: unless-stopped
|
||||
privileged: true
|
||||
cap_add:
|
||||
- SYS_ADMIN
|
||||
ports:
|
||||
- "4096:4096"
|
||||
devices:
|
||||
- /dev/fuse:/dev/fuse
|
||||
environment:
|
||||
- TZ=Europe/Paris # Set your timezone here
|
||||
volumes:
|
||||
- /var/lib/ironmount:/var/lib/ironmount
|
||||
- /etc/localtime:/etc/localtime:ro
|
||||
- /var/lib/zerobyte:/var/lib/zerobyte
|
||||
```
|
||||
|
||||
Then, run the following command to start Ironmount:
|
||||
> [!WARNING]
|
||||
> Do not try to point `/var/lib/zerobyte` on a network share. You will face permission issues and strong performance degradation.
|
||||
|
||||
Then, run the following command to start Zerobyte:
|
||||
|
||||
```bash
|
||||
docker compose up -d
|
||||
@@ -58,17 +69,17 @@ Once the container is running, you can access the web interface at `http://<your
|
||||
|
||||
## Adding your first volume
|
||||
|
||||
Ironmount supports multiple volume backends including NFS, SMB, WebDAV, and local directories. A volume represents the source data you want to back up and monitor.
|
||||
Zerobyte supports multiple volume backends including NFS, SMB, WebDAV, and local directories. A volume represents the source data you want to back up and monitor.
|
||||
|
||||
To add your first volume, navigate to the "Volumes" section in the web interface and click on "Create volume". Fill in the required details such as volume name, type, and connection settings.
|
||||
|
||||
If you want to track a local directory on the same server where Ironmount is running, you'll first need to mount that directory into the Ironmount container. You can do this by adding a volume mapping in your `docker-compose.yml` file. For example, to mount `/path/to/your/directory` from the host to `/mydata` in the container, you would add the following line under the `volumes` section:
|
||||
If you want to track a local directory on the same server where Zerobyte is running, you'll first need to mount that directory into the Zerobyte container. You can do this by adding a volume mapping in your `docker-compose.yml` file. For example, to mount `/path/to/your/directory` from the host to `/mydata` in the container, you would add the following line under the `volumes` section:
|
||||
|
||||
```diff
|
||||
services:
|
||||
ironmount:
|
||||
image: ghcr.io/nicotsx/ironmount:v0.6
|
||||
container_name: ironmount
|
||||
zerobyte:
|
||||
image: ghcr.io/nicotsx/zerobyte:v0.15
|
||||
container_name: zerobyte
|
||||
restart: unless-stopped
|
||||
cap_add:
|
||||
- SYS_ADMIN
|
||||
@@ -76,27 +87,30 @@ services:
|
||||
- "4096:4096"
|
||||
devices:
|
||||
- /dev/fuse:/dev/fuse
|
||||
environment:
|
||||
- TZ=Europe/Paris
|
||||
volumes:
|
||||
- /var/lib/ironmount:/var/lib/ironmount
|
||||
- /etc/localtime:/etc/localtime:ro
|
||||
- /var/lib/zerobyte:/var/lib/zerobyte
|
||||
+ - /path/to/your/directory:/mydata
|
||||
```
|
||||
|
||||
After updating the `docker-compose.yml` file, restart the Ironmount container to apply the changes:
|
||||
After updating the `docker-compose.yml` file, restart the Zerobyte container to apply the changes:
|
||||
|
||||
```bash
|
||||
docker compose down
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
Now, when adding a new volume in the Ironmount web interface, you can select "Directory" as the volume type and search for your mounted path (e.g., `/mydata`) as the source path.
|
||||
Now, when adding a new volume in the Zerobyte web interface, you can select "Directory" as the volume type and search for your mounted path (e.g., `/mydata`) as the source path.
|
||||
|
||||

|
||||

|
||||
|
||||
## Creating a repository
|
||||
|
||||
A repository is where your backups will be securely stored encrypted. Ironmount supports multiple storage backends for your backup repositories:
|
||||
A repository is where your backups will be securely stored encrypted. Zerobyte supports multiple storage backends for your backup repositories:
|
||||
|
||||
- **Local directories** - Store backups on local disk at `/var/lib/ironmount/repositories/<repository-name>`
|
||||
- **Local directories** - Store backups on local disk at `/var/lib/zerobyte/repositories/<repository-name>`
|
||||
- **S3-compatible storage** - Amazon S3, MinIO, Wasabi, DigitalOcean Spaces, etc.
|
||||
- **Google Cloud Storage** - Google's cloud storage service
|
||||
- **Azure Blob Storage** - Microsoft Azure storage
|
||||
@@ -108,7 +122,7 @@ To create a repository, navigate to the "Repositories" section in the web interf
|
||||
|
||||
### Using rclone for cloud storage
|
||||
|
||||
Ironmount can use [rclone](https://rclone.org/) to support 40+ cloud storage providers including Google Drive, Dropbox, OneDrive, Box, pCloud, Mega, and many more. This gives you the flexibility to store your backups on virtually any cloud storage service.
|
||||
Zerobyte can use [rclone](https://rclone.org/) to support 40+ cloud storage providers including Google Drive, Dropbox, OneDrive, Box, pCloud, Mega, and many more. This gives you the flexibility to store your backups on virtually any cloud storage service.
|
||||
|
||||
**Setup instructions:**
|
||||
|
||||
@@ -128,33 +142,37 @@ Ironmount can use [rclone](https://rclone.org/) to support 40+ cloud storage pro
|
||||
rclone listremotes
|
||||
```
|
||||
|
||||
4. **Mount the rclone config into the Ironmount container** by updating your `docker-compose.yml`:
|
||||
4. **Mount the rclone config into the Zerobyte container** by updating your `docker-compose.yml`:
|
||||
```diff
|
||||
services:
|
||||
ironmount:
|
||||
image: ghcr.io/nicotsx/ironmount:v0.6
|
||||
container_name: ironmount
|
||||
zerobyte:
|
||||
image: ghcr.io/nicotsx/zerobyte:v0.15
|
||||
container_name: zerobyte
|
||||
restart: unless-stopped
|
||||
privileged: true
|
||||
cap_add:
|
||||
- SYS_ADMIN
|
||||
ports:
|
||||
- "4096:4096"
|
||||
devices:
|
||||
- /dev/fuse:/dev/fuse
|
||||
environment:
|
||||
- TZ=Europe/Paris
|
||||
volumes:
|
||||
- /var/lib/ironmount:/var/lib/ironmount
|
||||
- /etc/localtime:/etc/localtime:ro
|
||||
- /var/lib/zerobyte:/var/lib/zerobyte
|
||||
+ - ~/.config/rclone:/root/.config/rclone
|
||||
```
|
||||
|
||||
5. **Restart the Ironmount container**:
|
||||
5. **Restart the Zerobyte container**:
|
||||
```bash
|
||||
docker compose down
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
6. **Create a repository** in Ironmount:
|
||||
6. **Create a repository** in Zerobyte:
|
||||
- Select "rclone" as the repository type
|
||||
- Choose your configured remote from the dropdown
|
||||
- Specify the path within your remote (e.g., `backups/ironmount`)
|
||||
- Specify the path within your remote (e.g., `backups/zerobyte`)
|
||||
|
||||
For a complete list of supported providers, see the [rclone documentation](https://rclone.org/).
|
||||
|
||||
@@ -167,39 +185,42 @@ When creating a backup job, you can specify the following settings:
|
||||
- **Retention Policy**: Set rules for how long backups should be retained (e.g., keep daily backups for 7 days, weekly backups for 4 weeks)
|
||||
- **Paths**: Specify which files or directories to include in the backup
|
||||
|
||||
After configuring the backup job, save it and Ironmount will automatically execute the backup according to the defined schedule.
|
||||
After configuring the backup job, save it and Zerobyte will automatically execute the backup according to the defined schedule.
|
||||
You can monitor the progress and status of your backup jobs in the "Backups" section of the web interface.
|
||||
|
||||

|
||||

|
||||
|
||||
## Restoring data
|
||||
|
||||
Ironmount allows you to easily restore your data from backups. To restore data, navigate to the "Backups" section and select the backup job from which you want to restore data. You can then choose a specific backup snapshot and select the files or directories you wish to restore. The data you select will be restored to their original location.
|
||||
Zerobyte allows you to easily restore your data from backups. To restore data, navigate to the "Backups" section and select the backup job from which you want to restore data. You can then choose a specific backup snapshot and select the files or directories you wish to restore. The data you select will be restored to their original location.
|
||||
|
||||

|
||||

|
||||
|
||||
## Propagating mounts to host
|
||||
|
||||
Ironmount is capable of propagating mounted volumes from within the container to the host system. This is particularly useful when you want to access the mounted data directly from the host to use it with other applications or services.
|
||||
Zerobyte is capable of propagating mounted volumes from within the container to the host system. This is particularly useful when you want to access the mounted data directly from the host to use it with other applications or services.
|
||||
|
||||
In order to enable this feature, you need to change your bind mount `/var/lib/ironmount` to use the `:rshared` flag. Here is an example of how to set this up in your `docker-compose.yml` file:
|
||||
In order to enable this feature, you need to change your bind mount `/var/lib/zerobyte` to use the `:rshared` flag. Here is an example of how to set this up in your `docker-compose.yml` file:
|
||||
|
||||
```diff
|
||||
services:
|
||||
ironmount:
|
||||
image: ghcr.io/nicotsx/ironmount:v0.6
|
||||
container_name: ironmount
|
||||
zerobyte:
|
||||
image: ghcr.io/nicotsx/zerobyte:v0.15
|
||||
container_name: zerobyte
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "4096:4096"
|
||||
devices:
|
||||
- /dev/fuse:/dev/fuse
|
||||
environment:
|
||||
- TZ=Europe/Paris
|
||||
volumes:
|
||||
- - /var/lib/ironmount:/var/lib/ironmount
|
||||
+ - /var/lib/ironmount:/var/lib/ironmount:rshared
|
||||
- /etc/localtime:/etc/localtime:ro
|
||||
- - /var/lib/zerobyte:/var/lib/zerobyte
|
||||
+ - /var/lib/zerobyte:/var/lib/zerobyte:rshared
|
||||
```
|
||||
|
||||
Restart the Ironmount container to apply the changes:
|
||||
Restart the Zerobyte container to apply the changes:
|
||||
|
||||
```bash
|
||||
docker compose down
|
||||
@@ -208,15 +229,15 @@ docker compose up -d
|
||||
|
||||
## Docker plugin
|
||||
|
||||
Ironmount can also be used as a Docker volume plugin, allowing you to mount your volumes directly into other Docker containers. This enables seamless integration with your containerized applications.
|
||||
Zerobyte can also be used as a Docker volume plugin, allowing you to mount your volumes directly into other Docker containers. This enables seamless integration with your containerized applications.
|
||||
|
||||
In order to enable this feature, you need to run Ironmount with several items shared from the host. Here is an example of how to set this up in your `docker-compose.yml` file:
|
||||
In order to enable this feature, you need to run Zerobyte with several items shared from the host. Here is an example of how to set this up in your `docker-compose.yml` file:
|
||||
|
||||
```diff
|
||||
services:
|
||||
ironmount:
|
||||
image: ghcr.io/nicotsx/ironmount:v0.6
|
||||
container_name: ironmount
|
||||
zerobyte:
|
||||
image: ghcr.io/nicotsx/zerobyte:v0.15
|
||||
container_name: zerobyte
|
||||
restart: unless-stopped
|
||||
cap_add:
|
||||
- SYS_ADMIN
|
||||
@@ -224,24 +245,27 @@ services:
|
||||
- "4096:4096"
|
||||
devices:
|
||||
- /dev/fuse:/dev/fuse
|
||||
environment:
|
||||
- TZ=Europe/Paris
|
||||
volumes:
|
||||
- - /var/lib/ironmount:/var/lib/ironmount
|
||||
+ - /var/lib/ironmount:/var/lib/ironmount:rshared
|
||||
- /etc/localtime:/etc/localtime:ro
|
||||
- - /var/lib/zerobyte:/var/lib/zerobyte
|
||||
+ - /var/lib/zerobyte:/var/lib/zerobyte:rshared
|
||||
+ - /run/docker/plugins:/run/docker/plugins
|
||||
+ - /var/run/docker.sock:/var/run/docker.sock
|
||||
```
|
||||
|
||||
Restart the Ironmount container to apply the changes:
|
||||
Restart the Zerobyte container to apply the changes:
|
||||
|
||||
```bash
|
||||
docker compose down
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
Your Ironmount 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
|
||||
docker run -v im-nfs:/path/in/container nginx:latest
|
||||
docker run -v zb-abc12:/path/in/container nginx:latest
|
||||
```
|
||||
|
||||
Or using Docker Compose:
|
||||
@@ -251,13 +275,13 @@ services:
|
||||
myservice:
|
||||
image: nginx:latest
|
||||
volumes:
|
||||
- im-nfs:/path/in/container
|
||||
- zb-abc12:/path/in/container
|
||||
volumes:
|
||||
im-nfs:
|
||||
zb-abc12:
|
||||
external: true
|
||||
```
|
||||
|
||||
The volume name format is `im-<volume-name>` where `<volume-name>` is the name you assigned to the volume in Ironmount. You can verify that the volume is available by running:
|
||||
The volume name format is `zb-<short-id>` where `<short-id>` is the unique identifier shown on the volume's Docker tab in Zerobyte. This short ID remains stable even if you rename the volume. You can verify that the volume is available by running:
|
||||
|
||||
```bash
|
||||
docker volume ls
|
||||
@@ -269,7 +293,7 @@ This project includes the following third-party software components:
|
||||
|
||||
### Restic
|
||||
|
||||
Ironmount includes [Restic](https://github.com/restic/restic) for backup functionality.
|
||||
Zerobyte includes [Restic](https://github.com/restic/restic) for backup functionality.
|
||||
|
||||
- **License**: BSD 2-Clause License
|
||||
- **Copyright**: Copyright (c) 2014, Alexander Neumann <alexander@bumpern.de>
|
||||
@@ -277,3 +301,7 @@ Ironmount includes [Restic](https://github.com/restic/restic) for backup functio
|
||||
- **License Text**: See [LICENSES/BSD-2-Clause-Restic.txt](LICENSES/BSD-2-Clause-Restic.txt)
|
||||
|
||||
For a complete list of third-party software licenses and attributions, please refer to the [NOTICES.md](NOTICES.md) file.
|
||||
|
||||
## Contributing
|
||||
|
||||
Contributions by anyone are welcome! If you find a bug or have a feature request, please open an issue on GitHub. If you want to contribute code, feel free to fork the repository and submit a pull request. We require that all contributors sign a Contributor License Agreement (CLA) before we can accept your contributions. This is to protect both you and the project. Please see the [CONTRIBUTING.md](CONTRIBUTING.md) file for more details.
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,7 +1,7 @@
|
||||
// This file is auto-generated by @hey-api/openapi-ts
|
||||
|
||||
import { type ClientOptions, type Config, createClient, createConfig } from "./client";
|
||||
import type { ClientOptions as ClientOptions2 } from "./types.gen";
|
||||
import { type ClientOptions, type Config, createClient, createConfig } from './client';
|
||||
import type { ClientOptions as ClientOptions2 } from './types.gen';
|
||||
|
||||
/**
|
||||
* The `createClientConfig()` function will be called on client initialization
|
||||
@@ -11,12 +11,6 @@ import type { ClientOptions as ClientOptions2 } from "./types.gen";
|
||||
* `setConfig()`. This is useful for example if you're using Next.js
|
||||
* to ensure your client always has the correct values.
|
||||
*/
|
||||
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>({
|
||||
baseUrl: "http://192.168.2.42:4096",
|
||||
}),
|
||||
);
|
||||
export const client = createClient(createConfig<ClientOptions2>({ baseUrl: 'http://192.168.2.42:4096' }));
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
// This file is auto-generated by @hey-api/openapi-ts
|
||||
|
||||
import { createSseClient } from "../core/serverSentEvents.gen";
|
||||
import type { HttpMethod } from "../core/types.gen";
|
||||
import { getValidRequestBody } from "../core/utils.gen";
|
||||
import type { Client, Config, RequestOptions, ResolvedRequestOptions } from "./types.gen";
|
||||
import { createSseClient } from '../core/serverSentEvents.gen';
|
||||
import type { HttpMethod } from '../core/types.gen';
|
||||
import { getValidRequestBody } from '../core/utils.gen';
|
||||
import type {
|
||||
Client,
|
||||
Config,
|
||||
RequestOptions,
|
||||
ResolvedRequestOptions,
|
||||
} from './types.gen';
|
||||
import {
|
||||
buildUrl,
|
||||
createConfig,
|
||||
@@ -12,9 +17,9 @@ import {
|
||||
mergeConfigs,
|
||||
mergeHeaders,
|
||||
setAuthParams,
|
||||
} from "./utils.gen";
|
||||
} from './utils.gen';
|
||||
|
||||
type ReqInit = Omit<RequestInit, "body" | "headers"> & {
|
||||
type ReqInit = Omit<RequestInit, 'body' | 'headers'> & {
|
||||
body?: any;
|
||||
headers: ReturnType<typeof mergeHeaders>;
|
||||
};
|
||||
@@ -29,7 +34,12 @@ export const createClient = (config: Config = {}): Client => {
|
||||
return getConfig();
|
||||
};
|
||||
|
||||
const interceptors = createInterceptors<Request, Response, unknown, ResolvedRequestOptions>();
|
||||
const interceptors = createInterceptors<
|
||||
Request,
|
||||
Response,
|
||||
unknown,
|
||||
ResolvedRequestOptions
|
||||
>();
|
||||
|
||||
const beforeRequest = async (options: RequestOptions) => {
|
||||
const opts = {
|
||||
@@ -56,8 +66,8 @@ export const createClient = (config: Config = {}): Client => {
|
||||
}
|
||||
|
||||
// remove Content-Type header if body is empty to avoid sending invalid requests
|
||||
if (opts.body === undefined || opts.serializedBody === "") {
|
||||
opts.headers.delete("Content-Type");
|
||||
if (opts.body === undefined || opts.serializedBody === '') {
|
||||
opts.headers.delete('Content-Type');
|
||||
}
|
||||
|
||||
const url = buildUrl(opts);
|
||||
@@ -65,11 +75,11 @@ export const createClient = (config: Config = {}): Client => {
|
||||
return { opts, url };
|
||||
};
|
||||
|
||||
const request: Client["request"] = async (options) => {
|
||||
const request: Client['request'] = async (options) => {
|
||||
// @ts-expect-error
|
||||
const { opts, url } = await beforeRequest(options);
|
||||
const requestInit: ReqInit = {
|
||||
redirect: "follow",
|
||||
redirect: 'follow',
|
||||
...opts,
|
||||
body: getValidRequestBody(opts),
|
||||
};
|
||||
@@ -95,7 +105,12 @@ export const createClient = (config: Config = {}): Client => {
|
||||
|
||||
for (const fn of interceptors.error.fns) {
|
||||
if (fn) {
|
||||
finalError = (await fn(error, undefined as any, request, opts)) as unknown;
|
||||
finalError = (await fn(
|
||||
error,
|
||||
undefined as any,
|
||||
request,
|
||||
opts,
|
||||
)) as unknown;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -106,7 +121,7 @@ export const createClient = (config: Config = {}): Client => {
|
||||
}
|
||||
|
||||
// Return error response
|
||||
return opts.responseStyle === "data"
|
||||
return opts.responseStyle === 'data'
|
||||
? undefined
|
||||
: {
|
||||
error: finalError,
|
||||
@@ -128,28 +143,33 @@ export const createClient = (config: Config = {}): Client => {
|
||||
|
||||
if (response.ok) {
|
||||
const parseAs =
|
||||
(opts.parseAs === "auto" ? getParseAs(response.headers.get("Content-Type")) : opts.parseAs) ?? "json";
|
||||
(opts.parseAs === 'auto'
|
||||
? getParseAs(response.headers.get('Content-Type'))
|
||||
: opts.parseAs) ?? 'json';
|
||||
|
||||
if (response.status === 204 || response.headers.get("Content-Length") === "0") {
|
||||
if (
|
||||
response.status === 204 ||
|
||||
response.headers.get('Content-Length') === '0'
|
||||
) {
|
||||
let emptyData: any;
|
||||
switch (parseAs) {
|
||||
case "arrayBuffer":
|
||||
case "blob":
|
||||
case "text":
|
||||
case 'arrayBuffer':
|
||||
case 'blob':
|
||||
case 'text':
|
||||
emptyData = await response[parseAs]();
|
||||
break;
|
||||
case "formData":
|
||||
case 'formData':
|
||||
emptyData = new FormData();
|
||||
break;
|
||||
case "stream":
|
||||
case 'stream':
|
||||
emptyData = response.body;
|
||||
break;
|
||||
case "json":
|
||||
case 'json':
|
||||
default:
|
||||
emptyData = {};
|
||||
break;
|
||||
}
|
||||
return opts.responseStyle === "data"
|
||||
return opts.responseStyle === 'data'
|
||||
? emptyData
|
||||
: {
|
||||
data: emptyData,
|
||||
@@ -159,15 +179,15 @@ export const createClient = (config: Config = {}): Client => {
|
||||
|
||||
let data: any;
|
||||
switch (parseAs) {
|
||||
case "arrayBuffer":
|
||||
case "blob":
|
||||
case "formData":
|
||||
case "json":
|
||||
case "text":
|
||||
case 'arrayBuffer':
|
||||
case 'blob':
|
||||
case 'formData':
|
||||
case 'json':
|
||||
case 'text':
|
||||
data = await response[parseAs]();
|
||||
break;
|
||||
case "stream":
|
||||
return opts.responseStyle === "data"
|
||||
case 'stream':
|
||||
return opts.responseStyle === 'data'
|
||||
? response.body
|
||||
: {
|
||||
data: response.body,
|
||||
@@ -175,7 +195,7 @@ export const createClient = (config: Config = {}): Client => {
|
||||
};
|
||||
}
|
||||
|
||||
if (parseAs === "json") {
|
||||
if (parseAs === 'json') {
|
||||
if (opts.responseValidator) {
|
||||
await opts.responseValidator(data);
|
||||
}
|
||||
@@ -185,7 +205,7 @@ export const createClient = (config: Config = {}): Client => {
|
||||
}
|
||||
}
|
||||
|
||||
return opts.responseStyle === "data"
|
||||
return opts.responseStyle === 'data'
|
||||
? data
|
||||
: {
|
||||
data,
|
||||
@@ -218,7 +238,7 @@ export const createClient = (config: Config = {}): Client => {
|
||||
}
|
||||
|
||||
// TODO: we probably want to return error and improve types
|
||||
return opts.responseStyle === "data"
|
||||
return opts.responseStyle === 'data'
|
||||
? undefined
|
||||
: {
|
||||
error: finalError,
|
||||
@@ -226,9 +246,12 @@ export const createClient = (config: Config = {}): Client => {
|
||||
};
|
||||
};
|
||||
|
||||
const makeMethodFn = (method: Uppercase<HttpMethod>) => (options: RequestOptions) => request({ ...options, method });
|
||||
const makeMethodFn =
|
||||
(method: Uppercase<HttpMethod>) => (options: RequestOptions) =>
|
||||
request({ ...options, method });
|
||||
|
||||
const makeSseFn = (method: Uppercase<HttpMethod>) => async (options: RequestOptions) => {
|
||||
const makeSseFn =
|
||||
(method: Uppercase<HttpMethod>) => async (options: RequestOptions) => {
|
||||
const { opts, url } = await beforeRequest(options);
|
||||
return createSseClient({
|
||||
...opts,
|
||||
@@ -250,29 +273,29 @@ export const createClient = (config: Config = {}): Client => {
|
||||
|
||||
return {
|
||||
buildUrl,
|
||||
connect: makeMethodFn("CONNECT"),
|
||||
delete: makeMethodFn("DELETE"),
|
||||
get: makeMethodFn("GET"),
|
||||
connect: makeMethodFn('CONNECT'),
|
||||
delete: makeMethodFn('DELETE'),
|
||||
get: makeMethodFn('GET'),
|
||||
getConfig,
|
||||
head: makeMethodFn("HEAD"),
|
||||
head: makeMethodFn('HEAD'),
|
||||
interceptors,
|
||||
options: makeMethodFn("OPTIONS"),
|
||||
patch: makeMethodFn("PATCH"),
|
||||
post: makeMethodFn("POST"),
|
||||
put: makeMethodFn("PUT"),
|
||||
options: makeMethodFn('OPTIONS'),
|
||||
patch: makeMethodFn('PATCH'),
|
||||
post: makeMethodFn('POST'),
|
||||
put: makeMethodFn('PUT'),
|
||||
request,
|
||||
setConfig,
|
||||
sse: {
|
||||
connect: makeSseFn("CONNECT"),
|
||||
delete: makeSseFn("DELETE"),
|
||||
get: makeSseFn("GET"),
|
||||
head: makeSseFn("HEAD"),
|
||||
options: makeSseFn("OPTIONS"),
|
||||
patch: makeSseFn("PATCH"),
|
||||
post: makeSseFn("POST"),
|
||||
put: makeSseFn("PUT"),
|
||||
trace: makeSseFn("TRACE"),
|
||||
connect: makeSseFn('CONNECT'),
|
||||
delete: makeSseFn('DELETE'),
|
||||
get: makeSseFn('GET'),
|
||||
head: makeSseFn('HEAD'),
|
||||
options: makeSseFn('OPTIONS'),
|
||||
patch: makeSseFn('PATCH'),
|
||||
post: makeSseFn('POST'),
|
||||
put: makeSseFn('PUT'),
|
||||
trace: makeSseFn('TRACE'),
|
||||
},
|
||||
trace: makeMethodFn("TRACE"),
|
||||
trace: makeMethodFn('TRACE'),
|
||||
} as Client;
|
||||
};
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
// This file is auto-generated by @hey-api/openapi-ts
|
||||
|
||||
export type { Auth } from "../core/auth.gen";
|
||||
export type { QuerySerializerOptions } from "../core/bodySerializer.gen";
|
||||
export type { Auth } from '../core/auth.gen';
|
||||
export type { QuerySerializerOptions } from '../core/bodySerializer.gen';
|
||||
export {
|
||||
formDataBodySerializer,
|
||||
jsonBodySerializer,
|
||||
urlSearchParamsBodySerializer,
|
||||
} from "../core/bodySerializer.gen";
|
||||
export { buildClientParams } from "../core/params.gen";
|
||||
export { serializeQueryKeyValue } from "../core/queryKeySerializer.gen";
|
||||
export { createClient } from "./client.gen";
|
||||
} from '../core/bodySerializer.gen';
|
||||
export { buildClientParams } from '../core/params.gen';
|
||||
export { serializeQueryKeyValue } from '../core/queryKeySerializer.gen';
|
||||
export { createClient } from './client.gen';
|
||||
export type {
|
||||
Client,
|
||||
ClientOptions,
|
||||
@@ -21,5 +21,5 @@ export type {
|
||||
ResolvedRequestOptions,
|
||||
ResponseStyle,
|
||||
TDataShape,
|
||||
} from "./types.gen";
|
||||
export { createConfig, mergeHeaders } from "./utils.gen";
|
||||
} from './types.gen';
|
||||
export { createConfig, mergeHeaders } from './utils.gen';
|
||||
|
||||
@@ -1,19 +1,25 @@
|
||||
// This file is auto-generated by @hey-api/openapi-ts
|
||||
|
||||
import type { Auth } from "../core/auth.gen";
|
||||
import type { ServerSentEventsOptions, ServerSentEventsResult } from "../core/serverSentEvents.gen";
|
||||
import type { Client as CoreClient, Config as CoreConfig } from "../core/types.gen";
|
||||
import type { Middleware } from "./utils.gen";
|
||||
import type { Auth } from '../core/auth.gen';
|
||||
import type {
|
||||
ServerSentEventsOptions,
|
||||
ServerSentEventsResult,
|
||||
} from '../core/serverSentEvents.gen';
|
||||
import type {
|
||||
Client as CoreClient,
|
||||
Config as CoreConfig,
|
||||
} from '../core/types.gen';
|
||||
import type { Middleware } from './utils.gen';
|
||||
|
||||
export type ResponseStyle = "data" | "fields";
|
||||
export type ResponseStyle = 'data' | 'fields';
|
||||
|
||||
export interface Config<T extends ClientOptions = ClientOptions>
|
||||
extends Omit<RequestInit, "body" | "headers" | "method">,
|
||||
extends Omit<RequestInit, 'body' | 'headers' | 'method'>,
|
||||
CoreConfig {
|
||||
/**
|
||||
* Base URL for all requests made by this client.
|
||||
*/
|
||||
baseUrl?: T["baseUrl"];
|
||||
baseUrl?: T['baseUrl'];
|
||||
/**
|
||||
* Fetch API implementation. You can use this option to provide a custom
|
||||
* fetch instance.
|
||||
@@ -36,7 +42,14 @@ export interface Config<T extends ClientOptions = ClientOptions>
|
||||
*
|
||||
* @default 'auto'
|
||||
*/
|
||||
parseAs?: "arrayBuffer" | "auto" | "blob" | "formData" | "json" | "stream" | "text";
|
||||
parseAs?:
|
||||
| 'arrayBuffer'
|
||||
| 'auto'
|
||||
| 'blob'
|
||||
| 'formData'
|
||||
| 'json'
|
||||
| 'stream'
|
||||
| 'text';
|
||||
/**
|
||||
* Should we return only data or multiple fields (data, error, response, etc.)?
|
||||
*
|
||||
@@ -48,12 +61,12 @@ export interface Config<T extends ClientOptions = ClientOptions>
|
||||
*
|
||||
* @default false
|
||||
*/
|
||||
throwOnError?: T["throwOnError"];
|
||||
throwOnError?: T['throwOnError'];
|
||||
}
|
||||
|
||||
export interface RequestOptions<
|
||||
TData = unknown,
|
||||
TResponseStyle extends ResponseStyle = "fields",
|
||||
TResponseStyle extends ResponseStyle = 'fields',
|
||||
ThrowOnError extends boolean = boolean,
|
||||
Url extends string = string,
|
||||
> extends Config<{
|
||||
@@ -62,7 +75,11 @@ export interface RequestOptions<
|
||||
}>,
|
||||
Pick<
|
||||
ServerSentEventsOptions<TData>,
|
||||
"onSseError" | "onSseEvent" | "sseDefaultRetryDelay" | "sseMaxRetryAttempts" | "sseMaxRetryDelay"
|
||||
| 'onSseError'
|
||||
| 'onSseEvent'
|
||||
| 'sseDefaultRetryDelay'
|
||||
| 'sseMaxRetryAttempts'
|
||||
| 'sseMaxRetryDelay'
|
||||
> {
|
||||
/**
|
||||
* Any body that you want to add to your request.
|
||||
@@ -80,7 +97,7 @@ export interface RequestOptions<
|
||||
}
|
||||
|
||||
export interface ResolvedRequestOptions<
|
||||
TResponseStyle extends ResponseStyle = "fields",
|
||||
TResponseStyle extends ResponseStyle = 'fields',
|
||||
ThrowOnError extends boolean = boolean,
|
||||
Url extends string = string,
|
||||
> extends RequestOptions<unknown, TResponseStyle, ThrowOnError, Url> {
|
||||
@@ -91,30 +108,40 @@ export type RequestResult<
|
||||
TData = unknown,
|
||||
TError = unknown,
|
||||
ThrowOnError extends boolean = boolean,
|
||||
TResponseStyle extends ResponseStyle = "fields",
|
||||
TResponseStyle extends ResponseStyle = 'fields',
|
||||
> = ThrowOnError extends true
|
||||
? Promise<
|
||||
TResponseStyle extends "data"
|
||||
TResponseStyle extends 'data'
|
||||
? TData extends Record<string, unknown>
|
||||
? TData[keyof TData]
|
||||
: TData
|
||||
: {
|
||||
data: TData extends Record<string, unknown> ? TData[keyof TData] : TData;
|
||||
data: TData extends Record<string, unknown>
|
||||
? TData[keyof TData]
|
||||
: TData;
|
||||
request: Request;
|
||||
response: Response;
|
||||
}
|
||||
>
|
||||
: Promise<
|
||||
TResponseStyle extends "data"
|
||||
? (TData extends Record<string, unknown> ? TData[keyof TData] : TData) | undefined
|
||||
TResponseStyle extends 'data'
|
||||
?
|
||||
| (TData extends Record<string, unknown>
|
||||
? TData[keyof TData]
|
||||
: TData)
|
||||
| undefined
|
||||
: (
|
||||
| {
|
||||
data: TData extends Record<string, unknown> ? TData[keyof TData] : TData;
|
||||
data: TData extends Record<string, unknown>
|
||||
? TData[keyof TData]
|
||||
: TData;
|
||||
error: undefined;
|
||||
}
|
||||
| {
|
||||
data: undefined;
|
||||
error: TError extends Record<string, unknown> ? TError[keyof TError] : TError;
|
||||
error: TError extends Record<string, unknown>
|
||||
? TError[keyof TError]
|
||||
: TError;
|
||||
}
|
||||
) & {
|
||||
request: Request;
|
||||
@@ -132,28 +159,31 @@ type MethodFn = <
|
||||
TData = unknown,
|
||||
TError = unknown,
|
||||
ThrowOnError extends boolean = false,
|
||||
TResponseStyle extends ResponseStyle = "fields",
|
||||
TResponseStyle extends ResponseStyle = 'fields',
|
||||
>(
|
||||
options: Omit<RequestOptions<TData, TResponseStyle, ThrowOnError>, "method">,
|
||||
options: Omit<RequestOptions<TData, TResponseStyle, ThrowOnError>, 'method'>,
|
||||
) => RequestResult<TData, TError, ThrowOnError, TResponseStyle>;
|
||||
|
||||
type SseFn = <
|
||||
TData = unknown,
|
||||
TError = unknown,
|
||||
ThrowOnError extends boolean = false,
|
||||
TResponseStyle extends ResponseStyle = "fields",
|
||||
TResponseStyle extends ResponseStyle = 'fields',
|
||||
>(
|
||||
options: Omit<RequestOptions<TData, TResponseStyle, ThrowOnError>, "method">,
|
||||
options: Omit<RequestOptions<TData, TResponseStyle, ThrowOnError>, 'method'>,
|
||||
) => Promise<ServerSentEventsResult<TData, TError>>;
|
||||
|
||||
type RequestFn = <
|
||||
TData = unknown,
|
||||
TError = unknown,
|
||||
ThrowOnError extends boolean = false,
|
||||
TResponseStyle extends ResponseStyle = "fields",
|
||||
TResponseStyle extends ResponseStyle = 'fields',
|
||||
>(
|
||||
options: Omit<RequestOptions<TData, TResponseStyle, ThrowOnError>, "method"> &
|
||||
Pick<Required<RequestOptions<TData, TResponseStyle, ThrowOnError>>, "method">,
|
||||
options: Omit<RequestOptions<TData, TResponseStyle, ThrowOnError>, 'method'> &
|
||||
Pick<
|
||||
Required<RequestOptions<TData, TResponseStyle, ThrowOnError>>,
|
||||
'method'
|
||||
>,
|
||||
) => RequestResult<TData, TError, ThrowOnError, TResponseStyle>;
|
||||
|
||||
type BuildUrlFn = <
|
||||
@@ -167,7 +197,13 @@ type BuildUrlFn = <
|
||||
options: TData & Options<TData>,
|
||||
) => string;
|
||||
|
||||
export type Client = CoreClient<RequestFn, Config, MethodFn, BuildUrlFn, SseFn> & {
|
||||
export type Client = CoreClient<
|
||||
RequestFn,
|
||||
Config,
|
||||
MethodFn,
|
||||
BuildUrlFn,
|
||||
SseFn
|
||||
> & {
|
||||
interceptors: Middleware<Request, Response, unknown, ResolvedRequestOptions>;
|
||||
};
|
||||
|
||||
@@ -197,6 +233,9 @@ export type Options<
|
||||
TData extends TDataShape = TDataShape,
|
||||
ThrowOnError extends boolean = boolean,
|
||||
TResponse = unknown,
|
||||
TResponseStyle extends ResponseStyle = "fields",
|
||||
> = OmitKeys<RequestOptions<TResponse, TResponseStyle, ThrowOnError>, "body" | "path" | "query" | "url"> &
|
||||
([TData] extends [never] ? unknown : Omit<TData, "url">);
|
||||
TResponseStyle extends ResponseStyle = 'fields',
|
||||
> = OmitKeys<
|
||||
RequestOptions<TResponse, TResponseStyle, ThrowOnError>,
|
||||
'body' | 'path' | 'query' | 'url'
|
||||
> &
|
||||
([TData] extends [never] ? unknown : Omit<TData, 'url'>);
|
||||
|
||||
@@ -1,16 +1,23 @@
|
||||
// This file is auto-generated by @hey-api/openapi-ts
|
||||
|
||||
import { getAuthToken } from "../core/auth.gen";
|
||||
import type { QuerySerializerOptions } from "../core/bodySerializer.gen";
|
||||
import { jsonBodySerializer } from "../core/bodySerializer.gen";
|
||||
import { serializeArrayParam, serializeObjectParam, serializePrimitiveParam } from "../core/pathSerializer.gen";
|
||||
import { getUrl } from "../core/utils.gen";
|
||||
import type { Client, ClientOptions, Config, RequestOptions } from "./types.gen";
|
||||
import { getAuthToken } from '../core/auth.gen';
|
||||
import type { QuerySerializerOptions } from '../core/bodySerializer.gen';
|
||||
import { jsonBodySerializer } from '../core/bodySerializer.gen';
|
||||
import {
|
||||
serializeArrayParam,
|
||||
serializeObjectParam,
|
||||
serializePrimitiveParam,
|
||||
} from '../core/pathSerializer.gen';
|
||||
import { getUrl } from '../core/utils.gen';
|
||||
import type { Client, ClientOptions, Config, RequestOptions } from './types.gen';
|
||||
|
||||
export const createQuerySerializer = <T = unknown>({ parameters = {}, ...args }: QuerySerializerOptions = {}) => {
|
||||
export const createQuerySerializer = <T = unknown>({
|
||||
parameters = {},
|
||||
...args
|
||||
}: QuerySerializerOptions = {}) => {
|
||||
const querySerializer = (queryParams: T) => {
|
||||
const search: string[] = [];
|
||||
if (queryParams && typeof queryParams === "object") {
|
||||
if (queryParams && typeof queryParams === 'object') {
|
||||
for (const name in queryParams) {
|
||||
const value = queryParams[name];
|
||||
|
||||
@@ -25,17 +32,17 @@ export const createQuerySerializer = <T = unknown>({ parameters = {}, ...args }:
|
||||
allowReserved: options.allowReserved,
|
||||
explode: true,
|
||||
name,
|
||||
style: "form",
|
||||
style: 'form',
|
||||
value,
|
||||
...options.array,
|
||||
});
|
||||
if (serializedArray) search.push(serializedArray);
|
||||
} else if (typeof value === "object") {
|
||||
} else if (typeof value === 'object') {
|
||||
const serializedObject = serializeObjectParam({
|
||||
allowReserved: options.allowReserved,
|
||||
explode: true,
|
||||
name,
|
||||
style: "deepObject",
|
||||
style: 'deepObject',
|
||||
value: value as Record<string, unknown>,
|
||||
...options.object,
|
||||
});
|
||||
@@ -50,7 +57,7 @@ export const createQuerySerializer = <T = unknown>({ parameters = {}, ...args }:
|
||||
}
|
||||
}
|
||||
}
|
||||
return search.join("&");
|
||||
return search.join('&');
|
||||
};
|
||||
return querySerializer;
|
||||
};
|
||||
@@ -58,40 +65,49 @@ export const createQuerySerializer = <T = unknown>({ parameters = {}, ...args }:
|
||||
/**
|
||||
* Infers parseAs value from provided Content-Type header.
|
||||
*/
|
||||
export const getParseAs = (contentType: string | null): Exclude<Config["parseAs"], "auto"> => {
|
||||
export const getParseAs = (
|
||||
contentType: string | null,
|
||||
): Exclude<Config['parseAs'], 'auto'> => {
|
||||
if (!contentType) {
|
||||
// If no Content-Type header is provided, the best we can do is return the raw response body,
|
||||
// which is effectively the same as the 'stream' option.
|
||||
return "stream";
|
||||
return 'stream';
|
||||
}
|
||||
|
||||
const cleanContent = contentType.split(";")[0]?.trim();
|
||||
const cleanContent = contentType.split(';')[0]?.trim();
|
||||
|
||||
if (!cleanContent) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (cleanContent.startsWith("application/json") || cleanContent.endsWith("+json")) {
|
||||
return "json";
|
||||
if (
|
||||
cleanContent.startsWith('application/json') ||
|
||||
cleanContent.endsWith('+json')
|
||||
) {
|
||||
return 'json';
|
||||
}
|
||||
|
||||
if (cleanContent === "multipart/form-data") {
|
||||
return "formData";
|
||||
if (cleanContent === 'multipart/form-data') {
|
||||
return 'formData';
|
||||
}
|
||||
|
||||
if (["application/", "audio/", "image/", "video/"].some((type) => cleanContent.startsWith(type))) {
|
||||
return "blob";
|
||||
if (
|
||||
['application/', 'audio/', 'image/', 'video/'].some((type) =>
|
||||
cleanContent.startsWith(type),
|
||||
)
|
||||
) {
|
||||
return 'blob';
|
||||
}
|
||||
|
||||
if (cleanContent.startsWith("text/")) {
|
||||
return "text";
|
||||
if (cleanContent.startsWith('text/')) {
|
||||
return 'text';
|
||||
}
|
||||
|
||||
return;
|
||||
};
|
||||
|
||||
const checkForExistence = (
|
||||
options: Pick<RequestOptions, "auth" | "query"> & {
|
||||
options: Pick<RequestOptions, 'auth' | 'query'> & {
|
||||
headers: Headers;
|
||||
},
|
||||
name?: string,
|
||||
@@ -99,7 +115,11 @@ const checkForExistence = (
|
||||
if (!name) {
|
||||
return false;
|
||||
}
|
||||
if (options.headers.has(name) || options.query?.[name] || options.headers.get("Cookie")?.includes(`${name}=`)) {
|
||||
if (
|
||||
options.headers.has(name) ||
|
||||
options.query?.[name] ||
|
||||
options.headers.get('Cookie')?.includes(`${name}=`)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
@@ -108,8 +128,8 @@ const checkForExistence = (
|
||||
export const setAuthParams = async ({
|
||||
security,
|
||||
...options
|
||||
}: Pick<Required<RequestOptions>, "security"> &
|
||||
Pick<RequestOptions, "auth" | "query"> & {
|
||||
}: Pick<Required<RequestOptions>, 'security'> &
|
||||
Pick<RequestOptions, 'auth' | 'query'> & {
|
||||
headers: Headers;
|
||||
}) => {
|
||||
for (const auth of security) {
|
||||
@@ -123,19 +143,19 @@ export const setAuthParams = async ({
|
||||
continue;
|
||||
}
|
||||
|
||||
const name = auth.name ?? "Authorization";
|
||||
const name = auth.name ?? 'Authorization';
|
||||
|
||||
switch (auth.in) {
|
||||
case "query":
|
||||
case 'query':
|
||||
if (!options.query) {
|
||||
options.query = {};
|
||||
}
|
||||
options.query[name] = token;
|
||||
break;
|
||||
case "cookie":
|
||||
options.headers.append("Cookie", `${name}=${token}`);
|
||||
case 'cookie':
|
||||
options.headers.append('Cookie', `${name}=${token}`);
|
||||
break;
|
||||
case "header":
|
||||
case 'header':
|
||||
default:
|
||||
options.headers.set(name, token);
|
||||
break;
|
||||
@@ -143,13 +163,13 @@ export const setAuthParams = async ({
|
||||
}
|
||||
};
|
||||
|
||||
export const buildUrl: Client["buildUrl"] = (options) =>
|
||||
export const buildUrl: Client['buildUrl'] = (options) =>
|
||||
getUrl({
|
||||
baseUrl: options.baseUrl as string,
|
||||
path: options.path,
|
||||
query: options.query,
|
||||
querySerializer:
|
||||
typeof options.querySerializer === "function"
|
||||
typeof options.querySerializer === 'function'
|
||||
? options.querySerializer
|
||||
: createQuerySerializer(options.querySerializer),
|
||||
url: options.url,
|
||||
@@ -157,7 +177,7 @@ export const buildUrl: Client["buildUrl"] = (options) =>
|
||||
|
||||
export const mergeConfigs = (a: Config, b: Config): Config => {
|
||||
const config = { ...a, ...b };
|
||||
if (config.baseUrl?.endsWith("/")) {
|
||||
if (config.baseUrl?.endsWith('/')) {
|
||||
config.baseUrl = config.baseUrl.substring(0, config.baseUrl.length - 1);
|
||||
}
|
||||
config.headers = mergeHeaders(a.headers, b.headers);
|
||||
@@ -172,14 +192,19 @@ const headersEntries = (headers: Headers): Array<[string, string]> => {
|
||||
return entries;
|
||||
};
|
||||
|
||||
export const mergeHeaders = (...headers: Array<Required<Config>["headers"] | undefined>): Headers => {
|
||||
export const mergeHeaders = (
|
||||
...headers: Array<Required<Config>['headers'] | undefined>
|
||||
): Headers => {
|
||||
const mergedHeaders = new Headers();
|
||||
for (const header of headers) {
|
||||
if (!header) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const iterator = header instanceof Headers ? headersEntries(header) : Object.entries(header);
|
||||
const iterator =
|
||||
header instanceof Headers
|
||||
? headersEntries(header)
|
||||
: Object.entries(header);
|
||||
|
||||
for (const [key, value] of iterator) {
|
||||
if (value === null) {
|
||||
@@ -191,7 +216,10 @@ export const mergeHeaders = (...headers: Array<Required<Config>["headers"] | und
|
||||
} else if (value !== undefined) {
|
||||
// assume object headers are meant to be JSON stringified, i.e. their
|
||||
// content value in OpenAPI specification is 'application/json'
|
||||
mergedHeaders.set(key, typeof value === "object" ? JSON.stringify(value) : (value as string));
|
||||
mergedHeaders.set(
|
||||
key,
|
||||
typeof value === 'object' ? JSON.stringify(value) : (value as string),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -205,9 +233,16 @@ type ErrInterceptor<Err, Res, Req, Options> = (
|
||||
options: Options,
|
||||
) => Err | Promise<Err>;
|
||||
|
||||
type ReqInterceptor<Req, Options> = (request: Req, options: Options) => Req | Promise<Req>;
|
||||
type ReqInterceptor<Req, Options> = (
|
||||
request: Req,
|
||||
options: Options,
|
||||
) => Req | Promise<Req>;
|
||||
|
||||
type ResInterceptor<Res, Req, Options> = (response: Res, request: Req, options: Options) => Res | Promise<Res>;
|
||||
type ResInterceptor<Res, Req, Options> = (
|
||||
response: Res,
|
||||
request: Req,
|
||||
options: Options,
|
||||
) => Res | Promise<Res>;
|
||||
|
||||
class Interceptors<Interceptor> {
|
||||
fns: Array<Interceptor | null> = [];
|
||||
@@ -229,13 +264,16 @@ class Interceptors<Interceptor> {
|
||||
}
|
||||
|
||||
getInterceptorIndex(id: number | Interceptor): number {
|
||||
if (typeof id === "number") {
|
||||
if (typeof id === 'number') {
|
||||
return this.fns[id] ? id : -1;
|
||||
}
|
||||
return this.fns.indexOf(id);
|
||||
}
|
||||
|
||||
update(id: number | Interceptor, fn: Interceptor): number | Interceptor | false {
|
||||
update(
|
||||
id: number | Interceptor,
|
||||
fn: Interceptor,
|
||||
): number | Interceptor | false {
|
||||
const index = this.getInterceptorIndex(id);
|
||||
if (this.fns[index]) {
|
||||
this.fns[index] = fn;
|
||||
@@ -256,7 +294,12 @@ export interface Middleware<Req, Res, Err, Options> {
|
||||
response: Interceptors<ResInterceptor<Res, Req, Options>>;
|
||||
}
|
||||
|
||||
export const createInterceptors = <Req, Res, Err, Options>(): Middleware<Req, Res, Err, Options> => ({
|
||||
export const createInterceptors = <Req, Res, Err, Options>(): Middleware<
|
||||
Req,
|
||||
Res,
|
||||
Err,
|
||||
Options
|
||||
> => ({
|
||||
error: new Interceptors<ErrInterceptor<Err, Res, Req, Options>>(),
|
||||
request: new Interceptors<ReqInterceptor<Req, Options>>(),
|
||||
response: new Interceptors<ResInterceptor<Res, Req, Options>>(),
|
||||
@@ -266,16 +309,16 @@ const defaultQuerySerializer = createQuerySerializer({
|
||||
allowReserved: false,
|
||||
array: {
|
||||
explode: true,
|
||||
style: "form",
|
||||
style: 'form',
|
||||
},
|
||||
object: {
|
||||
explode: true,
|
||||
style: "deepObject",
|
||||
style: 'deepObject',
|
||||
},
|
||||
});
|
||||
|
||||
const defaultHeaders = {
|
||||
"Content-Type": "application/json",
|
||||
'Content-Type': 'application/json',
|
||||
};
|
||||
|
||||
export const createConfig = <T extends ClientOptions = ClientOptions>(
|
||||
@@ -283,7 +326,7 @@ export const createConfig = <T extends ClientOptions = ClientOptions>(
|
||||
): Config<Omit<ClientOptions, keyof T> & T> => ({
|
||||
...jsonBodySerializer,
|
||||
headers: defaultHeaders,
|
||||
parseAs: "auto",
|
||||
parseAs: 'auto',
|
||||
querySerializer: defaultQuerySerializer,
|
||||
...override,
|
||||
});
|
||||
|
||||
@@ -8,32 +8,33 @@ export interface Auth {
|
||||
*
|
||||
* @default 'header'
|
||||
*/
|
||||
in?: "header" | "query" | "cookie";
|
||||
in?: 'header' | 'query' | 'cookie';
|
||||
/**
|
||||
* Header or query parameter name.
|
||||
*
|
||||
* @default 'Authorization'
|
||||
*/
|
||||
name?: string;
|
||||
scheme?: "basic" | "bearer";
|
||||
type: "apiKey" | "http";
|
||||
scheme?: 'basic' | 'bearer';
|
||||
type: 'apiKey' | 'http';
|
||||
}
|
||||
|
||||
export const getAuthToken = async (
|
||||
auth: Auth,
|
||||
callback: ((auth: Auth) => Promise<AuthToken> | AuthToken) | AuthToken,
|
||||
): Promise<string | undefined> => {
|
||||
const token = typeof callback === "function" ? await callback(auth) : callback;
|
||||
const token =
|
||||
typeof callback === 'function' ? await callback(auth) : callback;
|
||||
|
||||
if (!token) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (auth.scheme === "bearer") {
|
||||
if (auth.scheme === 'bearer') {
|
||||
return `Bearer ${token}`;
|
||||
}
|
||||
|
||||
if (auth.scheme === "basic") {
|
||||
if (auth.scheme === 'basic') {
|
||||
return `Basic ${btoa(token)}`;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
// This file is auto-generated by @hey-api/openapi-ts
|
||||
|
||||
import type { ArrayStyle, ObjectStyle, SerializerOptions } from "./pathSerializer.gen";
|
||||
import type {
|
||||
ArrayStyle,
|
||||
ObjectStyle,
|
||||
SerializerOptions,
|
||||
} from './pathSerializer.gen';
|
||||
|
||||
export type QuerySerializer = (query: Record<string, unknown>) => string;
|
||||
|
||||
@@ -20,8 +24,12 @@ export type QuerySerializerOptions = QuerySerializerOptionsObject & {
|
||||
parameters?: Record<string, QuerySerializerOptionsObject>;
|
||||
};
|
||||
|
||||
const serializeFormDataPair = (data: FormData, key: string, value: unknown): void => {
|
||||
if (typeof value === "string" || value instanceof Blob) {
|
||||
const serializeFormDataPair = (
|
||||
data: FormData,
|
||||
key: string,
|
||||
value: unknown,
|
||||
): void => {
|
||||
if (typeof value === 'string' || value instanceof Blob) {
|
||||
data.append(key, value);
|
||||
} else if (value instanceof Date) {
|
||||
data.append(key, value.toISOString());
|
||||
@@ -30,8 +38,12 @@ const serializeFormDataPair = (data: FormData, key: string, value: unknown): voi
|
||||
}
|
||||
};
|
||||
|
||||
const serializeUrlSearchParamsPair = (data: URLSearchParams, key: string, value: unknown): void => {
|
||||
if (typeof value === "string") {
|
||||
const serializeUrlSearchParamsPair = (
|
||||
data: URLSearchParams,
|
||||
key: string,
|
||||
value: unknown,
|
||||
): void => {
|
||||
if (typeof value === 'string') {
|
||||
data.append(key, value);
|
||||
} else {
|
||||
data.append(key, JSON.stringify(value));
|
||||
@@ -39,7 +51,9 @@ const serializeUrlSearchParamsPair = (data: URLSearchParams, key: string, value:
|
||||
};
|
||||
|
||||
export const formDataBodySerializer = {
|
||||
bodySerializer: <T extends Record<string, any> | Array<Record<string, any>>>(body: T): FormData => {
|
||||
bodySerializer: <T extends Record<string, any> | Array<Record<string, any>>>(
|
||||
body: T,
|
||||
): FormData => {
|
||||
const data = new FormData();
|
||||
|
||||
Object.entries(body).forEach(([key, value]) => {
|
||||
@@ -59,11 +73,15 @@ export const formDataBodySerializer = {
|
||||
|
||||
export const jsonBodySerializer = {
|
||||
bodySerializer: <T>(body: T): string =>
|
||||
JSON.stringify(body, (_key, value) => (typeof value === "bigint" ? value.toString() : value)),
|
||||
JSON.stringify(body, (_key, value) =>
|
||||
typeof value === 'bigint' ? value.toString() : value,
|
||||
),
|
||||
};
|
||||
|
||||
export const urlSearchParamsBodySerializer = {
|
||||
bodySerializer: <T extends Record<string, any> | Array<Record<string, any>>>(body: T): string => {
|
||||
bodySerializer: <T extends Record<string, any> | Array<Record<string, any>>>(
|
||||
body: T,
|
||||
): string => {
|
||||
const data = new URLSearchParams();
|
||||
|
||||
Object.entries(body).forEach(([key, value]) => {
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
// This file is auto-generated by @hey-api/openapi-ts
|
||||
|
||||
type Slot = "body" | "headers" | "path" | "query";
|
||||
type Slot = 'body' | 'headers' | 'path' | 'query';
|
||||
|
||||
export type Field =
|
||||
| {
|
||||
in: Exclude<Slot, "body">;
|
||||
in: Exclude<Slot, 'body'>;
|
||||
/**
|
||||
* Field name. This is the name we want the user to see and use.
|
||||
*/
|
||||
@@ -16,7 +16,7 @@ export type Field =
|
||||
map?: string;
|
||||
}
|
||||
| {
|
||||
in: Extract<Slot, "body">;
|
||||
in: Extract<Slot, 'body'>;
|
||||
/**
|
||||
* Key isn't required for bodies.
|
||||
*/
|
||||
@@ -43,10 +43,10 @@ export interface Fields {
|
||||
export type FieldsConfig = ReadonlyArray<Field | Fields>;
|
||||
|
||||
const extraPrefixesMap: Record<string, Slot> = {
|
||||
$body_: "body",
|
||||
$headers_: "headers",
|
||||
$path_: "path",
|
||||
$query_: "query",
|
||||
$body_: 'body',
|
||||
$headers_: 'headers',
|
||||
$path_: 'path',
|
||||
$query_: 'query',
|
||||
};
|
||||
const extraPrefixes = Object.entries(extraPrefixesMap);
|
||||
|
||||
@@ -68,14 +68,14 @@ const buildKeyMap = (fields: FieldsConfig, map?: KeyMap): KeyMap => {
|
||||
}
|
||||
|
||||
for (const config of fields) {
|
||||
if ("in" in config) {
|
||||
if ('in' in config) {
|
||||
if (config.key) {
|
||||
map.set(config.key, {
|
||||
in: config.in,
|
||||
map: config.map,
|
||||
});
|
||||
}
|
||||
} else if ("key" in config) {
|
||||
} else if ('key' in config) {
|
||||
map.set(config.key, {
|
||||
map: config.map,
|
||||
});
|
||||
@@ -96,13 +96,16 @@ interface Params {
|
||||
|
||||
const stripEmptySlots = (params: Params) => {
|
||||
for (const [slot, value] of Object.entries(params)) {
|
||||
if (value && typeof value === "object" && !Object.keys(value).length) {
|
||||
if (value && typeof value === 'object' && !Object.keys(value).length) {
|
||||
delete params[slot as Slot];
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export const buildClientParams = (args: ReadonlyArray<unknown>, fields: FieldsConfig) => {
|
||||
export const buildClientParams = (
|
||||
args: ReadonlyArray<unknown>,
|
||||
fields: FieldsConfig,
|
||||
) => {
|
||||
const params: Params = {
|
||||
body: {},
|
||||
headers: {},
|
||||
@@ -123,7 +126,7 @@ export const buildClientParams = (args: ReadonlyArray<unknown>, fields: FieldsCo
|
||||
continue;
|
||||
}
|
||||
|
||||
if ("in" in config) {
|
||||
if ('in' in config) {
|
||||
if (config.key) {
|
||||
const field = map.get(config.key)!;
|
||||
const name = field.map || config.key;
|
||||
@@ -145,12 +148,16 @@ export const buildClientParams = (args: ReadonlyArray<unknown>, fields: FieldsCo
|
||||
params[field.map] = value;
|
||||
}
|
||||
} else {
|
||||
const extra = extraPrefixes.find(([prefix]) => key.startsWith(prefix));
|
||||
const extra = extraPrefixes.find(([prefix]) =>
|
||||
key.startsWith(prefix),
|
||||
);
|
||||
|
||||
if (extra) {
|
||||
const [prefix, slot] = extra;
|
||||
(params[slot] as Record<string, unknown>)[key.slice(prefix.length)] = value;
|
||||
} else if ("allowExtra" in config && config.allowExtra) {
|
||||
(params[slot] as Record<string, unknown>)[
|
||||
key.slice(prefix.length)
|
||||
] = value;
|
||||
} else if ('allowExtra' in config && config.allowExtra) {
|
||||
for (const [slot, allowed] of Object.entries(config.allowExtra)) {
|
||||
if (allowed) {
|
||||
(params[slot as Slot] as Record<string, unknown>)[key] = value;
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
// This file is auto-generated by @hey-api/openapi-ts
|
||||
|
||||
interface SerializeOptions<T> extends SerializePrimitiveOptions, SerializerOptions<T> {}
|
||||
interface SerializeOptions<T>
|
||||
extends SerializePrimitiveOptions,
|
||||
SerializerOptions<T> {}
|
||||
|
||||
interface SerializePrimitiveOptions {
|
||||
allowReserved?: boolean;
|
||||
@@ -15,10 +17,10 @@ export interface SerializerOptions<T> {
|
||||
style: T;
|
||||
}
|
||||
|
||||
export type ArrayStyle = "form" | "spaceDelimited" | "pipeDelimited";
|
||||
export type ArrayStyle = 'form' | 'spaceDelimited' | 'pipeDelimited';
|
||||
export type ArraySeparatorStyle = ArrayStyle | MatrixStyle;
|
||||
type MatrixStyle = "label" | "matrix" | "simple";
|
||||
export type ObjectStyle = "form" | "deepObject";
|
||||
type MatrixStyle = 'label' | 'matrix' | 'simple';
|
||||
export type ObjectStyle = 'form' | 'deepObject';
|
||||
type ObjectSeparatorStyle = ObjectStyle | MatrixStyle;
|
||||
|
||||
interface SerializePrimitiveParam extends SerializePrimitiveOptions {
|
||||
@@ -27,40 +29,40 @@ interface SerializePrimitiveParam extends SerializePrimitiveOptions {
|
||||
|
||||
export const separatorArrayExplode = (style: ArraySeparatorStyle) => {
|
||||
switch (style) {
|
||||
case "label":
|
||||
return ".";
|
||||
case "matrix":
|
||||
return ";";
|
||||
case "simple":
|
||||
return ",";
|
||||
case 'label':
|
||||
return '.';
|
||||
case 'matrix':
|
||||
return ';';
|
||||
case 'simple':
|
||||
return ',';
|
||||
default:
|
||||
return "&";
|
||||
return '&';
|
||||
}
|
||||
};
|
||||
|
||||
export const separatorArrayNoExplode = (style: ArraySeparatorStyle) => {
|
||||
switch (style) {
|
||||
case "form":
|
||||
return ",";
|
||||
case "pipeDelimited":
|
||||
return "|";
|
||||
case "spaceDelimited":
|
||||
return "%20";
|
||||
case 'form':
|
||||
return ',';
|
||||
case 'pipeDelimited':
|
||||
return '|';
|
||||
case 'spaceDelimited':
|
||||
return '%20';
|
||||
default:
|
||||
return ",";
|
||||
return ',';
|
||||
}
|
||||
};
|
||||
|
||||
export const separatorObjectExplode = (style: ObjectSeparatorStyle) => {
|
||||
switch (style) {
|
||||
case "label":
|
||||
return ".";
|
||||
case "matrix":
|
||||
return ";";
|
||||
case "simple":
|
||||
return ",";
|
||||
case 'label':
|
||||
return '.';
|
||||
case 'matrix':
|
||||
return ';';
|
||||
case 'simple':
|
||||
return ',';
|
||||
default:
|
||||
return "&";
|
||||
return '&';
|
||||
}
|
||||
};
|
||||
|
||||
@@ -74,15 +76,15 @@ export const serializeArrayParam = ({
|
||||
value: unknown[];
|
||||
}) => {
|
||||
if (!explode) {
|
||||
const joinedValues = (allowReserved ? value : value.map((v) => encodeURIComponent(v as string))).join(
|
||||
separatorArrayNoExplode(style),
|
||||
);
|
||||
const joinedValues = (
|
||||
allowReserved ? value : value.map((v) => encodeURIComponent(v as string))
|
||||
).join(separatorArrayNoExplode(style));
|
||||
switch (style) {
|
||||
case "label":
|
||||
case 'label':
|
||||
return `.${joinedValues}`;
|
||||
case "matrix":
|
||||
case 'matrix':
|
||||
return `;${name}=${joinedValues}`;
|
||||
case "simple":
|
||||
case 'simple':
|
||||
return joinedValues;
|
||||
default:
|
||||
return `${name}=${joinedValues}`;
|
||||
@@ -92,7 +94,7 @@ export const serializeArrayParam = ({
|
||||
const separator = separatorArrayExplode(style);
|
||||
const joinedValues = value
|
||||
.map((v) => {
|
||||
if (style === "label" || style === "simple") {
|
||||
if (style === 'label' || style === 'simple') {
|
||||
return allowReserved ? v : encodeURIComponent(v as string);
|
||||
}
|
||||
|
||||
@@ -103,17 +105,23 @@ export const serializeArrayParam = ({
|
||||
});
|
||||
})
|
||||
.join(separator);
|
||||
return style === "label" || style === "matrix" ? separator + joinedValues : joinedValues;
|
||||
return style === 'label' || style === 'matrix'
|
||||
? separator + joinedValues
|
||||
: joinedValues;
|
||||
};
|
||||
|
||||
export const serializePrimitiveParam = ({ allowReserved, name, value }: SerializePrimitiveParam) => {
|
||||
export const serializePrimitiveParam = ({
|
||||
allowReserved,
|
||||
name,
|
||||
value,
|
||||
}: SerializePrimitiveParam) => {
|
||||
if (value === undefined || value === null) {
|
||||
return "";
|
||||
return '';
|
||||
}
|
||||
|
||||
if (typeof value === "object") {
|
||||
if (typeof value === 'object') {
|
||||
throw new Error(
|
||||
"Deeply-nested arrays/objects aren’t supported. Provide your own `querySerializer()` to handle these.",
|
||||
'Deeply-nested arrays/objects aren’t supported. Provide your own `querySerializer()` to handle these.',
|
||||
);
|
||||
}
|
||||
|
||||
@@ -135,18 +143,22 @@ export const serializeObjectParam = ({
|
||||
return valueOnly ? value.toISOString() : `${name}=${value.toISOString()}`;
|
||||
}
|
||||
|
||||
if (style !== "deepObject" && !explode) {
|
||||
if (style !== 'deepObject' && !explode) {
|
||||
let values: string[] = [];
|
||||
Object.entries(value).forEach(([key, v]) => {
|
||||
values = [...values, key, allowReserved ? (v as string) : encodeURIComponent(v as string)];
|
||||
values = [
|
||||
...values,
|
||||
key,
|
||||
allowReserved ? (v as string) : encodeURIComponent(v as string),
|
||||
];
|
||||
});
|
||||
const joinedValues = values.join(",");
|
||||
const joinedValues = values.join(',');
|
||||
switch (style) {
|
||||
case "form":
|
||||
case 'form':
|
||||
return `${name}=${joinedValues}`;
|
||||
case "label":
|
||||
case 'label':
|
||||
return `.${joinedValues}`;
|
||||
case "matrix":
|
||||
case 'matrix':
|
||||
return `;${name}=${joinedValues}`;
|
||||
default:
|
||||
return joinedValues;
|
||||
@@ -158,10 +170,12 @@ export const serializeObjectParam = ({
|
||||
.map(([key, v]) =>
|
||||
serializePrimitiveParam({
|
||||
allowReserved,
|
||||
name: style === "deepObject" ? `${name}[${key}]` : key,
|
||||
name: style === 'deepObject' ? `${name}[${key}]` : key,
|
||||
value: v as string,
|
||||
}),
|
||||
)
|
||||
.join(separator);
|
||||
return style === "label" || style === "matrix" ? separator + joinedValues : joinedValues;
|
||||
return style === 'label' || style === 'matrix'
|
||||
? separator + joinedValues
|
||||
: joinedValues;
|
||||
};
|
||||
|
||||
@@ -3,16 +3,26 @@
|
||||
/**
|
||||
* JSON-friendly union that mirrors what Pinia Colada can hash.
|
||||
*/
|
||||
export type JsonValue = null | string | number | boolean | JsonValue[] | { [key: string]: JsonValue };
|
||||
export type JsonValue =
|
||||
| null
|
||||
| string
|
||||
| number
|
||||
| boolean
|
||||
| JsonValue[]
|
||||
| { [key: string]: JsonValue };
|
||||
|
||||
/**
|
||||
* Replacer that converts non-JSON values (bigint, Date, etc.) to safe substitutes.
|
||||
*/
|
||||
export const queryKeyJsonReplacer = (_key: string, value: unknown) => {
|
||||
if (value === undefined || typeof value === "function" || typeof value === "symbol") {
|
||||
if (
|
||||
value === undefined ||
|
||||
typeof value === 'function' ||
|
||||
typeof value === 'symbol'
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
if (typeof value === "bigint") {
|
||||
if (typeof value === 'bigint') {
|
||||
return value.toString();
|
||||
}
|
||||
if (value instanceof Date) {
|
||||
@@ -40,7 +50,7 @@ export const stringifyToJsonValue = (input: unknown): JsonValue | undefined => {
|
||||
* Detects plain objects (including objects with a null prototype).
|
||||
*/
|
||||
const isPlainObject = (value: unknown): value is Record<string, unknown> => {
|
||||
if (value === null || typeof value !== "object") {
|
||||
if (value === null || typeof value !== 'object') {
|
||||
return false;
|
||||
}
|
||||
const prototype = Object.getPrototypeOf(value as object);
|
||||
@@ -51,7 +61,9 @@ const isPlainObject = (value: unknown): value is Record<string, unknown> => {
|
||||
* Turns URLSearchParams into a sorted JSON object for deterministic keys.
|
||||
*/
|
||||
const serializeSearchParams = (params: URLSearchParams): JsonValue => {
|
||||
const entries = Array.from(params.entries()).sort(([a], [b]) => a.localeCompare(b));
|
||||
const entries = Array.from(params.entries()).sort(([a], [b]) =>
|
||||
a.localeCompare(b),
|
||||
);
|
||||
const result: Record<string, JsonValue> = {};
|
||||
|
||||
for (const [key, value] of entries) {
|
||||
@@ -74,20 +86,30 @@ const serializeSearchParams = (params: URLSearchParams): JsonValue => {
|
||||
/**
|
||||
* Normalizes any accepted value into a JSON-friendly shape for query keys.
|
||||
*/
|
||||
export const serializeQueryKeyValue = (value: unknown): JsonValue | undefined => {
|
||||
export const serializeQueryKeyValue = (
|
||||
value: unknown,
|
||||
): JsonValue | undefined => {
|
||||
if (value === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") {
|
||||
if (
|
||||
typeof value === 'string' ||
|
||||
typeof value === 'number' ||
|
||||
typeof value === 'boolean'
|
||||
) {
|
||||
return value;
|
||||
}
|
||||
|
||||
if (value === undefined || typeof value === "function" || typeof value === "symbol") {
|
||||
if (
|
||||
value === undefined ||
|
||||
typeof value === 'function' ||
|
||||
typeof value === 'symbol'
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (typeof value === "bigint") {
|
||||
if (typeof value === 'bigint') {
|
||||
return value.toString();
|
||||
}
|
||||
|
||||
@@ -99,7 +121,10 @@ export const serializeQueryKeyValue = (value: unknown): JsonValue | undefined =>
|
||||
return stringifyToJsonValue(value);
|
||||
}
|
||||
|
||||
if (typeof URLSearchParams !== "undefined" && value instanceof URLSearchParams) {
|
||||
if (
|
||||
typeof URLSearchParams !== 'undefined' &&
|
||||
value instanceof URLSearchParams
|
||||
) {
|
||||
return serializeSearchParams(value);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
// This file is auto-generated by @hey-api/openapi-ts
|
||||
|
||||
import type { Config } from "./types.gen";
|
||||
import type { Config } from './types.gen';
|
||||
|
||||
export type ServerSentEventsOptions<TData = unknown> = Omit<RequestInit, "method"> &
|
||||
Pick<Config, "method" | "responseTransformer" | "responseValidator"> & {
|
||||
export type ServerSentEventsOptions<TData = unknown> = Omit<
|
||||
RequestInit,
|
||||
'method'
|
||||
> &
|
||||
Pick<Config, 'method' | 'responseTransformer' | 'responseValidator'> & {
|
||||
/**
|
||||
* Fetch API implementation. You can use this option to provide a custom
|
||||
* fetch instance.
|
||||
@@ -32,7 +35,7 @@ export type ServerSentEventsOptions<TData = unknown> = Omit<RequestInit, "method
|
||||
* @returns Nothing (void).
|
||||
*/
|
||||
onSseEvent?: (event: StreamEvent<TData>) => void;
|
||||
serializedBody?: RequestInit["body"];
|
||||
serializedBody?: RequestInit['body'];
|
||||
/**
|
||||
* Default retry delay in milliseconds.
|
||||
*
|
||||
@@ -71,8 +74,16 @@ export interface StreamEvent<TData = unknown> {
|
||||
retry?: number;
|
||||
}
|
||||
|
||||
export type ServerSentEventsResult<TData = unknown, TReturn = void, TNext = unknown> = {
|
||||
stream: AsyncGenerator<TData extends Record<string, unknown> ? TData[keyof TData] : TData, TReturn, TNext>;
|
||||
export type ServerSentEventsResult<
|
||||
TData = unknown,
|
||||
TReturn = void,
|
||||
TNext = unknown,
|
||||
> = {
|
||||
stream: AsyncGenerator<
|
||||
TData extends Record<string, unknown> ? TData[keyof TData] : TData,
|
||||
TReturn,
|
||||
TNext
|
||||
>;
|
||||
};
|
||||
|
||||
export const createSseClient = <TData = unknown>({
|
||||
@@ -90,7 +101,9 @@ export const createSseClient = <TData = unknown>({
|
||||
}: ServerSentEventsOptions): ServerSentEventsResult<TData> => {
|
||||
let lastEventId: string | undefined;
|
||||
|
||||
const sleep = sseSleepFn ?? ((ms: number) => new Promise((resolve) => setTimeout(resolve, ms)));
|
||||
const sleep =
|
||||
sseSleepFn ??
|
||||
((ms: number) => new Promise((resolve) => setTimeout(resolve, ms)));
|
||||
|
||||
const createStream = async function* () {
|
||||
let retryDelay: number = sseDefaultRetryDelay ?? 3000;
|
||||
@@ -108,12 +121,12 @@ export const createSseClient = <TData = unknown>({
|
||||
: new Headers(options.headers as Record<string, string> | undefined);
|
||||
|
||||
if (lastEventId !== undefined) {
|
||||
headers.set("Last-Event-ID", lastEventId);
|
||||
headers.set('Last-Event-ID', lastEventId);
|
||||
}
|
||||
|
||||
try {
|
||||
const requestInit: RequestInit = {
|
||||
redirect: "follow",
|
||||
redirect: 'follow',
|
||||
...options,
|
||||
body: options.serializedBody,
|
||||
headers,
|
||||
@@ -128,13 +141,18 @@ export const createSseClient = <TData = unknown>({
|
||||
const _fetch = options.fetch ?? globalThis.fetch;
|
||||
const response = await _fetch(request);
|
||||
|
||||
if (!response.ok) throw new Error(`SSE failed: ${response.status} ${response.statusText}`);
|
||||
if (!response.ok)
|
||||
throw new Error(
|
||||
`SSE failed: ${response.status} ${response.statusText}`,
|
||||
);
|
||||
|
||||
if (!response.body) throw new Error("No body in SSE response");
|
||||
if (!response.body) throw new Error('No body in SSE response');
|
||||
|
||||
const reader = response.body.pipeThrough(new TextDecoderStream()).getReader();
|
||||
const reader = response.body
|
||||
.pipeThrough(new TextDecoderStream())
|
||||
.getReader();
|
||||
|
||||
let buffer = "";
|
||||
let buffer = '';
|
||||
|
||||
const abortHandler = () => {
|
||||
try {
|
||||
@@ -144,7 +162,7 @@ export const createSseClient = <TData = unknown>({
|
||||
}
|
||||
};
|
||||
|
||||
signal.addEventListener("abort", abortHandler);
|
||||
signal.addEventListener('abort', abortHandler);
|
||||
|
||||
try {
|
||||
while (true) {
|
||||
@@ -152,23 +170,26 @@ export const createSseClient = <TData = unknown>({
|
||||
if (done) break;
|
||||
buffer += value;
|
||||
|
||||
const chunks = buffer.split("\n\n");
|
||||
buffer = chunks.pop() ?? "";
|
||||
const chunks = buffer.split('\n\n');
|
||||
buffer = chunks.pop() ?? '';
|
||||
|
||||
for (const chunk of chunks) {
|
||||
const lines = chunk.split("\n");
|
||||
const lines = chunk.split('\n');
|
||||
const dataLines: Array<string> = [];
|
||||
let eventName: string | undefined;
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.startsWith("data:")) {
|
||||
dataLines.push(line.replace(/^data:\s*/, ""));
|
||||
} else if (line.startsWith("event:")) {
|
||||
eventName = line.replace(/^event:\s*/, "");
|
||||
} else if (line.startsWith("id:")) {
|
||||
lastEventId = line.replace(/^id:\s*/, "");
|
||||
} else if (line.startsWith("retry:")) {
|
||||
const parsed = Number.parseInt(line.replace(/^retry:\s*/, ""), 10);
|
||||
if (line.startsWith('data:')) {
|
||||
dataLines.push(line.replace(/^data:\s*/, ''));
|
||||
} else if (line.startsWith('event:')) {
|
||||
eventName = line.replace(/^event:\s*/, '');
|
||||
} else if (line.startsWith('id:')) {
|
||||
lastEventId = line.replace(/^id:\s*/, '');
|
||||
} else if (line.startsWith('retry:')) {
|
||||
const parsed = Number.parseInt(
|
||||
line.replace(/^retry:\s*/, ''),
|
||||
10,
|
||||
);
|
||||
if (!Number.isNaN(parsed)) {
|
||||
retryDelay = parsed;
|
||||
}
|
||||
@@ -179,7 +200,7 @@ export const createSseClient = <TData = unknown>({
|
||||
let parsedJson = false;
|
||||
|
||||
if (dataLines.length) {
|
||||
const rawData = dataLines.join("\n");
|
||||
const rawData = dataLines.join('\n');
|
||||
try {
|
||||
data = JSON.parse(rawData);
|
||||
parsedJson = true;
|
||||
@@ -211,7 +232,7 @@ export const createSseClient = <TData = unknown>({
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
signal.removeEventListener("abort", abortHandler);
|
||||
signal.removeEventListener('abort', abortHandler);
|
||||
reader.releaseLock();
|
||||
}
|
||||
|
||||
@@ -220,12 +241,18 @@ export const createSseClient = <TData = unknown>({
|
||||
// connection failed or aborted; retry after delay
|
||||
onSseError?.(error);
|
||||
|
||||
if (sseMaxRetryAttempts !== undefined && attempt >= sseMaxRetryAttempts) {
|
||||
if (
|
||||
sseMaxRetryAttempts !== undefined &&
|
||||
attempt >= sseMaxRetryAttempts
|
||||
) {
|
||||
break; // stop after firing error
|
||||
}
|
||||
|
||||
// exponential backoff: double retry each attempt, cap at 30s
|
||||
const backoff = Math.min(retryDelay * 2 ** (attempt - 1), sseMaxRetryDelay ?? 30000);
|
||||
const backoff = Math.min(
|
||||
retryDelay * 2 ** (attempt - 1),
|
||||
sseMaxRetryDelay ?? 30000,
|
||||
);
|
||||
await sleep(backoff);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,30 @@
|
||||
// This file is auto-generated by @hey-api/openapi-ts
|
||||
|
||||
import type { Auth, AuthToken } from "./auth.gen";
|
||||
import type { BodySerializer, QuerySerializer, QuerySerializerOptions } from "./bodySerializer.gen";
|
||||
import type { Auth, AuthToken } from './auth.gen';
|
||||
import type {
|
||||
BodySerializer,
|
||||
QuerySerializer,
|
||||
QuerySerializerOptions,
|
||||
} from './bodySerializer.gen';
|
||||
|
||||
export type HttpMethod = "connect" | "delete" | "get" | "head" | "options" | "patch" | "post" | "put" | "trace";
|
||||
export type HttpMethod =
|
||||
| 'connect'
|
||||
| 'delete'
|
||||
| 'get'
|
||||
| 'head'
|
||||
| 'options'
|
||||
| 'patch'
|
||||
| 'post'
|
||||
| 'put'
|
||||
| 'trace';
|
||||
|
||||
export type Client<RequestFn = never, Config = unknown, MethodFn = never, BuildUrlFn = never, SseFn = never> = {
|
||||
export type Client<
|
||||
RequestFn = never,
|
||||
Config = unknown,
|
||||
MethodFn = never,
|
||||
BuildUrlFn = never,
|
||||
SseFn = never,
|
||||
> = {
|
||||
/**
|
||||
* Returns the final request URL.
|
||||
*/
|
||||
@@ -15,7 +34,9 @@ export type Client<RequestFn = never, Config = unknown, MethodFn = never, BuildU
|
||||
setConfig: (config: Config) => Config;
|
||||
} & {
|
||||
[K in HttpMethod]: MethodFn;
|
||||
} & ([SseFn] extends [never] ? { sse?: never } : { sse: { [K in HttpMethod]: SseFn } });
|
||||
} & ([SseFn] extends [never]
|
||||
? { sse?: never }
|
||||
: { sse: { [K in HttpMethod]: SseFn } });
|
||||
|
||||
export interface Config {
|
||||
/**
|
||||
@@ -35,8 +56,17 @@ export interface Config {
|
||||
* {@link https://developer.mozilla.org/docs/Web/API/Headers/Headers#init See more}
|
||||
*/
|
||||
headers?:
|
||||
| RequestInit["headers"]
|
||||
| Record<string, string | number | boolean | (string | number | boolean)[] | null | undefined | unknown>;
|
||||
| RequestInit['headers']
|
||||
| Record<
|
||||
string,
|
||||
| string
|
||||
| number
|
||||
| boolean
|
||||
| (string | number | boolean)[]
|
||||
| null
|
||||
| undefined
|
||||
| unknown
|
||||
>;
|
||||
/**
|
||||
* The request method.
|
||||
*
|
||||
@@ -82,5 +112,7 @@ type IsExactlyNeverOrNeverUndefined<T> = [T] extends [never]
|
||||
: false;
|
||||
|
||||
export type OmitNever<T extends Record<string, unknown>> = {
|
||||
[K in keyof T as IsExactlyNeverOrNeverUndefined<T[K]> extends true ? never : K]: T[K];
|
||||
[K in keyof T as IsExactlyNeverOrNeverUndefined<T[K]> extends true
|
||||
? never
|
||||
: K]: T[K];
|
||||
};
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
// This file is auto-generated by @hey-api/openapi-ts
|
||||
|
||||
import type { BodySerializer, QuerySerializer } from "./bodySerializer.gen";
|
||||
import type { BodySerializer, QuerySerializer } from './bodySerializer.gen';
|
||||
import {
|
||||
type ArraySeparatorStyle,
|
||||
serializeArrayParam,
|
||||
serializeObjectParam,
|
||||
serializePrimitiveParam,
|
||||
} from "./pathSerializer.gen";
|
||||
} from './pathSerializer.gen';
|
||||
|
||||
export interface PathSerializer {
|
||||
path: Record<string, unknown>;
|
||||
@@ -22,19 +22,19 @@ export const defaultPathSerializer = ({ path, url: _url }: PathSerializer) => {
|
||||
for (const match of matches) {
|
||||
let explode = false;
|
||||
let name = match.substring(1, match.length - 1);
|
||||
let style: ArraySeparatorStyle = "simple";
|
||||
let style: ArraySeparatorStyle = 'simple';
|
||||
|
||||
if (name.endsWith("*")) {
|
||||
if (name.endsWith('*')) {
|
||||
explode = true;
|
||||
name = name.substring(0, name.length - 1);
|
||||
}
|
||||
|
||||
if (name.startsWith(".")) {
|
||||
if (name.startsWith('.')) {
|
||||
name = name.substring(1);
|
||||
style = "label";
|
||||
} else if (name.startsWith(";")) {
|
||||
style = 'label';
|
||||
} else if (name.startsWith(';')) {
|
||||
name = name.substring(1);
|
||||
style = "matrix";
|
||||
style = 'matrix';
|
||||
}
|
||||
|
||||
const value = path[name];
|
||||
@@ -44,11 +44,14 @@ export const defaultPathSerializer = ({ path, url: _url }: PathSerializer) => {
|
||||
}
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
url = url.replace(match, serializeArrayParam({ explode, name, style, value }));
|
||||
url = url.replace(
|
||||
match,
|
||||
serializeArrayParam({ explode, name, style, value }),
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (typeof value === "object") {
|
||||
if (typeof value === 'object') {
|
||||
url = url.replace(
|
||||
match,
|
||||
serializeObjectParam({
|
||||
@@ -62,7 +65,7 @@ export const defaultPathSerializer = ({ path, url: _url }: PathSerializer) => {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (style === "matrix") {
|
||||
if (style === 'matrix') {
|
||||
url = url.replace(
|
||||
match,
|
||||
`;${serializePrimitiveParam({
|
||||
@@ -73,7 +76,9 @@ export const defaultPathSerializer = ({ path, url: _url }: PathSerializer) => {
|
||||
continue;
|
||||
}
|
||||
|
||||
const replaceValue = encodeURIComponent(style === "label" ? `.${value as string}` : (value as string));
|
||||
const replaceValue = encodeURIComponent(
|
||||
style === 'label' ? `.${value as string}` : (value as string),
|
||||
);
|
||||
url = url.replace(match, replaceValue);
|
||||
}
|
||||
}
|
||||
@@ -93,13 +98,13 @@ export const getUrl = ({
|
||||
querySerializer: QuerySerializer;
|
||||
url: string;
|
||||
}) => {
|
||||
const pathUrl = _url.startsWith("/") ? _url : `/${_url}`;
|
||||
let url = (baseUrl ?? "") + pathUrl;
|
||||
const pathUrl = _url.startsWith('/') ? _url : `/${_url}`;
|
||||
let url = (baseUrl ?? '') + pathUrl;
|
||||
if (path) {
|
||||
url = defaultPathSerializer({ path, url });
|
||||
}
|
||||
let search = query ? querySerializer(query) : "";
|
||||
if (search.startsWith("?")) {
|
||||
let search = query ? querySerializer(query) : '';
|
||||
if (search.startsWith('?')) {
|
||||
search = search.substring(1);
|
||||
}
|
||||
if (search) {
|
||||
@@ -117,14 +122,15 @@ export function getValidRequestBody(options: {
|
||||
const isSerializedBody = hasBody && options.bodySerializer;
|
||||
|
||||
if (isSerializedBody) {
|
||||
if ("serializedBody" in options) {
|
||||
const hasSerializedBody = options.serializedBody !== undefined && options.serializedBody !== "";
|
||||
if ('serializedBody' in options) {
|
||||
const hasSerializedBody =
|
||||
options.serializedBody !== undefined && options.serializedBody !== '';
|
||||
|
||||
return hasSerializedBody ? options.serializedBody : null;
|
||||
}
|
||||
|
||||
// not all clients implement a serializedBody property (i.e. client-axios)
|
||||
return options.body !== "" ? options.body : null;
|
||||
return options.body !== '' ? options.body : null;
|
||||
}
|
||||
|
||||
// plain/text body
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// This file is auto-generated by @hey-api/openapi-ts
|
||||
|
||||
export type * from "./types.gen";
|
||||
export * from "./sdk.gen";
|
||||
export type * from './types.gen';
|
||||
export * from './sdk.gen';
|
||||
|
||||
@@ -1,95 +1,10 @@
|
||||
// This file is auto-generated by @hey-api/openapi-ts
|
||||
|
||||
import type { Client, Options as Options2, TDataShape } from "./client";
|
||||
import { client } from "./client.gen";
|
||||
import type {
|
||||
BrowseFilesystemData,
|
||||
BrowseFilesystemResponses,
|
||||
ChangePasswordData,
|
||||
ChangePasswordResponses,
|
||||
CreateBackupScheduleData,
|
||||
CreateBackupScheduleResponses,
|
||||
CreateRepositoryData,
|
||||
CreateRepositoryResponses,
|
||||
CreateVolumeData,
|
||||
CreateVolumeResponses,
|
||||
DeleteBackupScheduleData,
|
||||
DeleteBackupScheduleResponses,
|
||||
DeleteRepositoryData,
|
||||
DeleteRepositoryResponses,
|
||||
DeleteVolumeData,
|
||||
DeleteVolumeResponses,
|
||||
DoctorRepositoryData,
|
||||
DoctorRepositoryResponses,
|
||||
DownloadResticPasswordData,
|
||||
DownloadResticPasswordResponses,
|
||||
GetBackupScheduleData,
|
||||
GetBackupScheduleForVolumeData,
|
||||
GetBackupScheduleForVolumeResponses,
|
||||
GetBackupScheduleResponses,
|
||||
GetContainersUsingVolumeData,
|
||||
GetContainersUsingVolumeErrors,
|
||||
GetContainersUsingVolumeResponses,
|
||||
GetMeData,
|
||||
GetMeResponses,
|
||||
GetRepositoryData,
|
||||
GetRepositoryResponses,
|
||||
GetSnapshotDetailsData,
|
||||
GetSnapshotDetailsResponses,
|
||||
GetStatusData,
|
||||
GetStatusResponses,
|
||||
GetSystemInfoData,
|
||||
GetSystemInfoResponses,
|
||||
GetVolumeData,
|
||||
GetVolumeErrors,
|
||||
GetVolumeResponses,
|
||||
HealthCheckVolumeData,
|
||||
HealthCheckVolumeErrors,
|
||||
HealthCheckVolumeResponses,
|
||||
ListBackupSchedulesData,
|
||||
ListBackupSchedulesResponses,
|
||||
ListFilesData,
|
||||
ListFilesResponses,
|
||||
ListRcloneRemotesData,
|
||||
ListRcloneRemotesResponses,
|
||||
ListRepositoriesData,
|
||||
ListRepositoriesResponses,
|
||||
ListSnapshotFilesData,
|
||||
ListSnapshotFilesResponses,
|
||||
ListSnapshotsData,
|
||||
ListSnapshotsResponses,
|
||||
ListVolumesData,
|
||||
ListVolumesResponses,
|
||||
LoginData,
|
||||
LoginResponses,
|
||||
LogoutData,
|
||||
LogoutResponses,
|
||||
MountVolumeData,
|
||||
MountVolumeResponses,
|
||||
RegisterData,
|
||||
RegisterResponses,
|
||||
RestoreSnapshotData,
|
||||
RestoreSnapshotResponses,
|
||||
RunBackupNowData,
|
||||
RunBackupNowResponses,
|
||||
StopBackupData,
|
||||
StopBackupErrors,
|
||||
StopBackupResponses,
|
||||
TestConnectionData,
|
||||
TestConnectionResponses,
|
||||
UnmountVolumeData,
|
||||
UnmountVolumeResponses,
|
||||
UpdateBackupScheduleData,
|
||||
UpdateBackupScheduleResponses,
|
||||
UpdateVolumeData,
|
||||
UpdateVolumeErrors,
|
||||
UpdateVolumeResponses,
|
||||
} from "./types.gen";
|
||||
import type { Client, Options as Options2, TDataShape } from './client';
|
||||
import { client } from './client.gen';
|
||||
import type { BrowseFilesystemData, BrowseFilesystemResponses, ChangePasswordData, ChangePasswordResponses, CreateBackupScheduleData, CreateBackupScheduleResponses, CreateNotificationDestinationData, CreateNotificationDestinationResponses, CreateRepositoryData, CreateRepositoryResponses, CreateVolumeData, CreateVolumeResponses, DeleteBackupScheduleData, DeleteBackupScheduleResponses, DeleteNotificationDestinationData, DeleteNotificationDestinationErrors, DeleteNotificationDestinationResponses, DeleteRepositoryData, DeleteRepositoryResponses, DeleteSnapshotData, DeleteSnapshotResponses, DeleteVolumeData, DeleteVolumeResponses, DoctorRepositoryData, DoctorRepositoryResponses, DownloadResticPasswordData, DownloadResticPasswordResponses, GetBackupScheduleData, GetBackupScheduleForVolumeData, GetBackupScheduleForVolumeResponses, GetBackupScheduleResponses, GetContainersUsingVolumeData, GetContainersUsingVolumeErrors, GetContainersUsingVolumeResponses, GetMeData, GetMeResponses, 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> & {
|
||||
/**
|
||||
* You can provide a client instance returned by `createClient()` instead of
|
||||
* individual options. This might be also useful if you want to implement a
|
||||
@@ -106,483 +21,371 @@ export type Options<TData extends TDataShape = TDataShape, ThrowOnError extends
|
||||
/**
|
||||
* Register a new user
|
||||
*/
|
||||
export const register = <ThrowOnError extends boolean = false>(options?: Options<RegisterData, ThrowOnError>) => {
|
||||
return (options?.client ?? client).post<RegisterResponses, unknown, ThrowOnError>({
|
||||
url: "/api/v1/auth/register",
|
||||
export const register = <ThrowOnError extends boolean = false>(options?: Options<RegisterData, ThrowOnError>) => (options?.client ?? client).post<RegisterResponses, unknown, ThrowOnError>({
|
||||
url: '/api/v1/auth/register',
|
||||
...options,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...options?.headers,
|
||||
},
|
||||
});
|
||||
};
|
||||
'Content-Type': 'application/json',
|
||||
...options?.headers
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Login with username and password
|
||||
*/
|
||||
export const login = <ThrowOnError extends boolean = false>(options?: Options<LoginData, ThrowOnError>) => {
|
||||
return (options?.client ?? client).post<LoginResponses, unknown, ThrowOnError>({
|
||||
url: "/api/v1/auth/login",
|
||||
export const login = <ThrowOnError extends boolean = false>(options?: Options<LoginData, ThrowOnError>) => (options?.client ?? client).post<LoginResponses, unknown, ThrowOnError>({
|
||||
url: '/api/v1/auth/login',
|
||||
...options,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...options?.headers,
|
||||
},
|
||||
});
|
||||
};
|
||||
'Content-Type': 'application/json',
|
||||
...options?.headers
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Logout current user
|
||||
*/
|
||||
export const logout = <ThrowOnError extends boolean = false>(options?: Options<LogoutData, ThrowOnError>) => {
|
||||
return (options?.client ?? client).post<LogoutResponses, unknown, ThrowOnError>({
|
||||
url: "/api/v1/auth/logout",
|
||||
...options,
|
||||
});
|
||||
};
|
||||
export const logout = <ThrowOnError extends boolean = false>(options?: Options<LogoutData, ThrowOnError>) => (options?.client ?? client).post<LogoutResponses, unknown, ThrowOnError>({ url: '/api/v1/auth/logout', ...options });
|
||||
|
||||
/**
|
||||
* Get current authenticated user
|
||||
*/
|
||||
export const getMe = <ThrowOnError extends boolean = false>(options?: Options<GetMeData, ThrowOnError>) => {
|
||||
return (options?.client ?? client).get<GetMeResponses, unknown, ThrowOnError>({
|
||||
url: "/api/v1/auth/me",
|
||||
...options,
|
||||
});
|
||||
};
|
||||
export const getMe = <ThrowOnError extends boolean = false>(options?: Options<GetMeData, ThrowOnError>) => (options?.client ?? client).get<GetMeResponses, unknown, ThrowOnError>({ url: '/api/v1/auth/me', ...options });
|
||||
|
||||
/**
|
||||
* Get authentication system status
|
||||
*/
|
||||
export const getStatus = <ThrowOnError extends boolean = false>(options?: Options<GetStatusData, ThrowOnError>) => {
|
||||
return (options?.client ?? client).get<GetStatusResponses, unknown, ThrowOnError>({
|
||||
url: "/api/v1/auth/status",
|
||||
...options,
|
||||
});
|
||||
};
|
||||
export const getStatus = <ThrowOnError extends boolean = false>(options?: Options<GetStatusData, ThrowOnError>) => (options?.client ?? client).get<GetStatusResponses, unknown, ThrowOnError>({ url: '/api/v1/auth/status', ...options });
|
||||
|
||||
/**
|
||||
* Change current user password
|
||||
*/
|
||||
export const changePassword = <ThrowOnError extends boolean = false>(
|
||||
options?: Options<ChangePasswordData, ThrowOnError>,
|
||||
) => {
|
||||
return (options?.client ?? client).post<ChangePasswordResponses, unknown, ThrowOnError>({
|
||||
url: "/api/v1/auth/change-password",
|
||||
export const changePassword = <ThrowOnError extends boolean = false>(options?: Options<ChangePasswordData, ThrowOnError>) => (options?.client ?? client).post<ChangePasswordResponses, unknown, ThrowOnError>({
|
||||
url: '/api/v1/auth/change-password',
|
||||
...options,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...options?.headers,
|
||||
},
|
||||
});
|
||||
};
|
||||
'Content-Type': 'application/json',
|
||||
...options?.headers
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* List all volumes
|
||||
*/
|
||||
export const listVolumes = <ThrowOnError extends boolean = false>(options?: Options<ListVolumesData, ThrowOnError>) => {
|
||||
return (options?.client ?? client).get<ListVolumesResponses, unknown, ThrowOnError>({
|
||||
url: "/api/v1/volumes",
|
||||
...options,
|
||||
});
|
||||
};
|
||||
export const listVolumes = <ThrowOnError extends boolean = false>(options?: Options<ListVolumesData, ThrowOnError>) => (options?.client ?? client).get<ListVolumesResponses, unknown, ThrowOnError>({ url: '/api/v1/volumes', ...options });
|
||||
|
||||
/**
|
||||
* Create a new volume
|
||||
*/
|
||||
export const createVolume = <ThrowOnError extends boolean = false>(
|
||||
options?: Options<CreateVolumeData, ThrowOnError>,
|
||||
) => {
|
||||
return (options?.client ?? client).post<CreateVolumeResponses, unknown, ThrowOnError>({
|
||||
url: "/api/v1/volumes",
|
||||
export const createVolume = <ThrowOnError extends boolean = false>(options?: Options<CreateVolumeData, ThrowOnError>) => (options?.client ?? client).post<CreateVolumeResponses, unknown, ThrowOnError>({
|
||||
url: '/api/v1/volumes',
|
||||
...options,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...options?.headers,
|
||||
},
|
||||
});
|
||||
};
|
||||
'Content-Type': 'application/json',
|
||||
...options?.headers
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Test connection to backend
|
||||
*/
|
||||
export const testConnection = <ThrowOnError extends boolean = false>(
|
||||
options?: Options<TestConnectionData, ThrowOnError>,
|
||||
) => {
|
||||
return (options?.client ?? client).post<TestConnectionResponses, unknown, ThrowOnError>({
|
||||
url: "/api/v1/volumes/test-connection",
|
||||
export const testConnection = <ThrowOnError extends boolean = false>(options?: Options<TestConnectionData, ThrowOnError>) => (options?.client ?? client).post<TestConnectionResponses, unknown, ThrowOnError>({
|
||||
url: '/api/v1/volumes/test-connection',
|
||||
...options,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...options?.headers,
|
||||
},
|
||||
});
|
||||
};
|
||||
'Content-Type': 'application/json',
|
||||
...options?.headers
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Delete a volume
|
||||
*/
|
||||
export const deleteVolume = <ThrowOnError extends boolean = false>(
|
||||
options: Options<DeleteVolumeData, ThrowOnError>,
|
||||
) => {
|
||||
return (options.client ?? client).delete<DeleteVolumeResponses, unknown, ThrowOnError>({
|
||||
url: "/api/v1/volumes/{name}",
|
||||
...options,
|
||||
});
|
||||
};
|
||||
export const deleteVolume = <ThrowOnError extends boolean = false>(options: Options<DeleteVolumeData, ThrowOnError>) => (options.client ?? client).delete<DeleteVolumeResponses, unknown, ThrowOnError>({ url: '/api/v1/volumes/{name}', ...options });
|
||||
|
||||
/**
|
||||
* Get a volume by name
|
||||
*/
|
||||
export const getVolume = <ThrowOnError extends boolean = false>(options: Options<GetVolumeData, ThrowOnError>) => {
|
||||
return (options.client ?? client).get<GetVolumeResponses, GetVolumeErrors, ThrowOnError>({
|
||||
url: "/api/v1/volumes/{name}",
|
||||
...options,
|
||||
});
|
||||
};
|
||||
export const getVolume = <ThrowOnError extends boolean = false>(options: Options<GetVolumeData, ThrowOnError>) => (options.client ?? client).get<GetVolumeResponses, GetVolumeErrors, ThrowOnError>({ url: '/api/v1/volumes/{name}', ...options });
|
||||
|
||||
/**
|
||||
* Update a volume's configuration
|
||||
*/
|
||||
export const updateVolume = <ThrowOnError extends boolean = false>(
|
||||
options: Options<UpdateVolumeData, ThrowOnError>,
|
||||
) => {
|
||||
return (options.client ?? client).put<UpdateVolumeResponses, UpdateVolumeErrors, ThrowOnError>({
|
||||
url: "/api/v1/volumes/{name}",
|
||||
export const updateVolume = <ThrowOnError extends boolean = false>(options: Options<UpdateVolumeData, ThrowOnError>) => (options.client ?? client).put<UpdateVolumeResponses, UpdateVolumeErrors, ThrowOnError>({
|
||||
url: '/api/v1/volumes/{name}',
|
||||
...options,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...options.headers,
|
||||
},
|
||||
});
|
||||
};
|
||||
'Content-Type': 'application/json',
|
||||
...options.headers
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Get containers using a volume by name
|
||||
*/
|
||||
export const getContainersUsingVolume = <ThrowOnError extends boolean = false>(
|
||||
options: Options<GetContainersUsingVolumeData, ThrowOnError>,
|
||||
) => {
|
||||
return (options.client ?? client).get<
|
||||
GetContainersUsingVolumeResponses,
|
||||
GetContainersUsingVolumeErrors,
|
||||
ThrowOnError
|
||||
>({
|
||||
url: "/api/v1/volumes/{name}/containers",
|
||||
...options,
|
||||
});
|
||||
};
|
||||
export const getContainersUsingVolume = <ThrowOnError extends boolean = false>(options: Options<GetContainersUsingVolumeData, ThrowOnError>) => (options.client ?? client).get<GetContainersUsingVolumeResponses, GetContainersUsingVolumeErrors, ThrowOnError>({ url: '/api/v1/volumes/{name}/containers', ...options });
|
||||
|
||||
/**
|
||||
* Mount a volume
|
||||
*/
|
||||
export const mountVolume = <ThrowOnError extends boolean = false>(options: Options<MountVolumeData, ThrowOnError>) => {
|
||||
return (options.client ?? client).post<MountVolumeResponses, unknown, ThrowOnError>({
|
||||
url: "/api/v1/volumes/{name}/mount",
|
||||
...options,
|
||||
});
|
||||
};
|
||||
export const mountVolume = <ThrowOnError extends boolean = false>(options: Options<MountVolumeData, ThrowOnError>) => (options.client ?? client).post<MountVolumeResponses, unknown, ThrowOnError>({ url: '/api/v1/volumes/{name}/mount', ...options });
|
||||
|
||||
/**
|
||||
* Unmount a volume
|
||||
*/
|
||||
export const unmountVolume = <ThrowOnError extends boolean = false>(
|
||||
options: Options<UnmountVolumeData, ThrowOnError>,
|
||||
) => {
|
||||
return (options.client ?? client).post<UnmountVolumeResponses, unknown, ThrowOnError>({
|
||||
url: "/api/v1/volumes/{name}/unmount",
|
||||
...options,
|
||||
});
|
||||
};
|
||||
export const unmountVolume = <ThrowOnError extends boolean = false>(options: Options<UnmountVolumeData, ThrowOnError>) => (options.client ?? client).post<UnmountVolumeResponses, unknown, ThrowOnError>({ url: '/api/v1/volumes/{name}/unmount', ...options });
|
||||
|
||||
/**
|
||||
* Perform a health check on a volume
|
||||
*/
|
||||
export const healthCheckVolume = <ThrowOnError extends boolean = false>(
|
||||
options: Options<HealthCheckVolumeData, ThrowOnError>,
|
||||
) => {
|
||||
return (options.client ?? client).post<HealthCheckVolumeResponses, HealthCheckVolumeErrors, ThrowOnError>({
|
||||
url: "/api/v1/volumes/{name}/health-check",
|
||||
...options,
|
||||
});
|
||||
};
|
||||
export const healthCheckVolume = <ThrowOnError extends boolean = false>(options: Options<HealthCheckVolumeData, ThrowOnError>) => (options.client ?? client).post<HealthCheckVolumeResponses, HealthCheckVolumeErrors, ThrowOnError>({ url: '/api/v1/volumes/{name}/health-check', ...options });
|
||||
|
||||
/**
|
||||
* List files in a volume directory
|
||||
*/
|
||||
export const listFiles = <ThrowOnError extends boolean = false>(options: Options<ListFilesData, ThrowOnError>) => {
|
||||
return (options.client ?? client).get<ListFilesResponses, unknown, ThrowOnError>({
|
||||
url: "/api/v1/volumes/{name}/files",
|
||||
...options,
|
||||
});
|
||||
};
|
||||
export const listFiles = <ThrowOnError extends boolean = false>(options: Options<ListFilesData, ThrowOnError>) => (options.client ?? client).get<ListFilesResponses, unknown, ThrowOnError>({ url: '/api/v1/volumes/{name}/files', ...options });
|
||||
|
||||
/**
|
||||
* Browse directories on the host filesystem
|
||||
*/
|
||||
export const browseFilesystem = <ThrowOnError extends boolean = false>(
|
||||
options?: Options<BrowseFilesystemData, ThrowOnError>,
|
||||
) => {
|
||||
return (options?.client ?? client).get<BrowseFilesystemResponses, unknown, ThrowOnError>({
|
||||
url: "/api/v1/volumes/filesystem/browse",
|
||||
...options,
|
||||
});
|
||||
};
|
||||
export const browseFilesystem = <ThrowOnError extends boolean = false>(options?: Options<BrowseFilesystemData, ThrowOnError>) => (options?.client ?? client).get<BrowseFilesystemResponses, unknown, ThrowOnError>({ url: '/api/v1/volumes/filesystem/browse', ...options });
|
||||
|
||||
/**
|
||||
* List all repositories
|
||||
*/
|
||||
export const listRepositories = <ThrowOnError extends boolean = false>(
|
||||
options?: Options<ListRepositoriesData, ThrowOnError>,
|
||||
) => {
|
||||
return (options?.client ?? client).get<ListRepositoriesResponses, unknown, ThrowOnError>({
|
||||
url: "/api/v1/repositories",
|
||||
...options,
|
||||
});
|
||||
};
|
||||
export const listRepositories = <ThrowOnError extends boolean = false>(options?: Options<ListRepositoriesData, ThrowOnError>) => (options?.client ?? client).get<ListRepositoriesResponses, unknown, ThrowOnError>({ url: '/api/v1/repositories', ...options });
|
||||
|
||||
/**
|
||||
* Create a new restic repository
|
||||
*/
|
||||
export const createRepository = <ThrowOnError extends boolean = false>(
|
||||
options?: Options<CreateRepositoryData, ThrowOnError>,
|
||||
) => {
|
||||
return (options?.client ?? client).post<CreateRepositoryResponses, unknown, ThrowOnError>({
|
||||
url: "/api/v1/repositories",
|
||||
export const createRepository = <ThrowOnError extends boolean = false>(options?: Options<CreateRepositoryData, ThrowOnError>) => (options?.client ?? client).post<CreateRepositoryResponses, unknown, ThrowOnError>({
|
||||
url: '/api/v1/repositories',
|
||||
...options,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...options?.headers,
|
||||
},
|
||||
});
|
||||
};
|
||||
'Content-Type': 'application/json',
|
||||
...options?.headers
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* List all configured rclone remotes on the host system
|
||||
*/
|
||||
export const listRcloneRemotes = <ThrowOnError extends boolean = false>(
|
||||
options?: Options<ListRcloneRemotesData, ThrowOnError>,
|
||||
) => {
|
||||
return (options?.client ?? client).get<ListRcloneRemotesResponses, unknown, ThrowOnError>({
|
||||
url: "/api/v1/repositories/rclone-remotes",
|
||||
...options,
|
||||
});
|
||||
};
|
||||
export const listRcloneRemotes = <ThrowOnError extends boolean = false>(options?: Options<ListRcloneRemotesData, ThrowOnError>) => (options?.client ?? client).get<ListRcloneRemotesResponses, unknown, ThrowOnError>({ url: '/api/v1/repositories/rclone-remotes', ...options });
|
||||
|
||||
/**
|
||||
* Delete a repository
|
||||
*/
|
||||
export const deleteRepository = <ThrowOnError extends boolean = false>(
|
||||
options: Options<DeleteRepositoryData, ThrowOnError>,
|
||||
) => {
|
||||
return (options.client ?? client).delete<DeleteRepositoryResponses, unknown, ThrowOnError>({
|
||||
url: "/api/v1/repositories/{name}",
|
||||
...options,
|
||||
});
|
||||
};
|
||||
export const deleteRepository = <ThrowOnError extends boolean = false>(options: Options<DeleteRepositoryData, ThrowOnError>) => (options.client ?? client).delete<DeleteRepositoryResponses, unknown, ThrowOnError>({ url: '/api/v1/repositories/{name}', ...options });
|
||||
|
||||
/**
|
||||
* Get a single repository by name
|
||||
*/
|
||||
export const getRepository = <ThrowOnError extends boolean = false>(
|
||||
options: Options<GetRepositoryData, ThrowOnError>,
|
||||
) => {
|
||||
return (options.client ?? client).get<GetRepositoryResponses, unknown, ThrowOnError>({
|
||||
url: "/api/v1/repositories/{name}",
|
||||
export const getRepository = <ThrowOnError extends boolean = false>(options: Options<GetRepositoryData, ThrowOnError>) => (options.client ?? client).get<GetRepositoryResponses, unknown, ThrowOnError>({ url: '/api/v1/repositories/{name}', ...options });
|
||||
|
||||
/**
|
||||
* Update a repository's name or settings
|
||||
*/
|
||||
export const updateRepository = <ThrowOnError extends boolean = false>(options: Options<UpdateRepositoryData, ThrowOnError>) => (options.client ?? client).patch<UpdateRepositoryResponses, UpdateRepositoryErrors, ThrowOnError>({
|
||||
url: '/api/v1/repositories/{name}',
|
||||
...options,
|
||||
});
|
||||
};
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...options.headers
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* List all snapshots in a repository
|
||||
*/
|
||||
export const listSnapshots = <ThrowOnError extends boolean = false>(
|
||||
options: Options<ListSnapshotsData, ThrowOnError>,
|
||||
) => {
|
||||
return (options.client ?? client).get<ListSnapshotsResponses, unknown, ThrowOnError>({
|
||||
url: "/api/v1/repositories/{name}/snapshots",
|
||||
...options,
|
||||
});
|
||||
};
|
||||
export const listSnapshots = <ThrowOnError extends boolean = false>(options: Options<ListSnapshotsData, ThrowOnError>) => (options.client ?? client).get<ListSnapshotsResponses, unknown, ThrowOnError>({ url: '/api/v1/repositories/{name}/snapshots', ...options });
|
||||
|
||||
/**
|
||||
* Delete a specific snapshot from a repository
|
||||
*/
|
||||
export const deleteSnapshot = <ThrowOnError extends boolean = false>(options: Options<DeleteSnapshotData, ThrowOnError>) => (options.client ?? client).delete<DeleteSnapshotResponses, unknown, ThrowOnError>({ url: '/api/v1/repositories/{name}/snapshots/{snapshotId}', ...options });
|
||||
|
||||
/**
|
||||
* Get details of a specific snapshot
|
||||
*/
|
||||
export const getSnapshotDetails = <ThrowOnError extends boolean = false>(
|
||||
options: Options<GetSnapshotDetailsData, ThrowOnError>,
|
||||
) => {
|
||||
return (options.client ?? client).get<GetSnapshotDetailsResponses, unknown, ThrowOnError>({
|
||||
url: "/api/v1/repositories/{name}/snapshots/{snapshotId}",
|
||||
...options,
|
||||
});
|
||||
};
|
||||
export const getSnapshotDetails = <ThrowOnError extends boolean = false>(options: Options<GetSnapshotDetailsData, ThrowOnError>) => (options.client ?? client).get<GetSnapshotDetailsResponses, unknown, ThrowOnError>({ url: '/api/v1/repositories/{name}/snapshots/{snapshotId}', ...options });
|
||||
|
||||
/**
|
||||
* List files and directories in a snapshot
|
||||
*/
|
||||
export const listSnapshotFiles = <ThrowOnError extends boolean = false>(
|
||||
options: Options<ListSnapshotFilesData, ThrowOnError>,
|
||||
) => {
|
||||
return (options.client ?? client).get<ListSnapshotFilesResponses, unknown, ThrowOnError>({
|
||||
url: "/api/v1/repositories/{name}/snapshots/{snapshotId}/files",
|
||||
...options,
|
||||
});
|
||||
};
|
||||
export const listSnapshotFiles = <ThrowOnError extends boolean = false>(options: Options<ListSnapshotFilesData, ThrowOnError>) => (options.client ?? client).get<ListSnapshotFilesResponses, unknown, ThrowOnError>({ url: '/api/v1/repositories/{name}/snapshots/{snapshotId}/files', ...options });
|
||||
|
||||
/**
|
||||
* Restore a snapshot to a target path on the filesystem
|
||||
*/
|
||||
export const restoreSnapshot = <ThrowOnError extends boolean = false>(
|
||||
options: Options<RestoreSnapshotData, ThrowOnError>,
|
||||
) => {
|
||||
return (options.client ?? client).post<RestoreSnapshotResponses, unknown, ThrowOnError>({
|
||||
url: "/api/v1/repositories/{name}/restore",
|
||||
export const restoreSnapshot = <ThrowOnError extends boolean = false>(options: Options<RestoreSnapshotData, ThrowOnError>) => (options.client ?? client).post<RestoreSnapshotResponses, unknown, ThrowOnError>({
|
||||
url: '/api/v1/repositories/{name}/restore',
|
||||
...options,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...options.headers,
|
||||
},
|
||||
});
|
||||
};
|
||||
'Content-Type': 'application/json',
|
||||
...options.headers
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Run doctor operations on a repository to fix common issues (unlock, check, repair index). Use this when the repository is locked or has errors.
|
||||
*/
|
||||
export const doctorRepository = <ThrowOnError extends boolean = false>(
|
||||
options: Options<DoctorRepositoryData, ThrowOnError>,
|
||||
) => {
|
||||
return (options.client ?? client).post<DoctorRepositoryResponses, unknown, ThrowOnError>({
|
||||
url: "/api/v1/repositories/{name}/doctor",
|
||||
...options,
|
||||
});
|
||||
};
|
||||
export const doctorRepository = <ThrowOnError extends boolean = false>(options: Options<DoctorRepositoryData, ThrowOnError>) => (options.client ?? client).post<DoctorRepositoryResponses, unknown, ThrowOnError>({ url: '/api/v1/repositories/{name}/doctor', ...options });
|
||||
|
||||
/**
|
||||
* List all backup schedules
|
||||
*/
|
||||
export const listBackupSchedules = <ThrowOnError extends boolean = false>(
|
||||
options?: Options<ListBackupSchedulesData, ThrowOnError>,
|
||||
) => {
|
||||
return (options?.client ?? client).get<ListBackupSchedulesResponses, unknown, ThrowOnError>({
|
||||
url: "/api/v1/backups",
|
||||
...options,
|
||||
});
|
||||
};
|
||||
export const listBackupSchedules = <ThrowOnError extends boolean = false>(options?: Options<ListBackupSchedulesData, ThrowOnError>) => (options?.client ?? client).get<ListBackupSchedulesResponses, unknown, ThrowOnError>({ url: '/api/v1/backups', ...options });
|
||||
|
||||
/**
|
||||
* Create a new backup schedule for a volume
|
||||
*/
|
||||
export const createBackupSchedule = <ThrowOnError extends boolean = false>(
|
||||
options?: Options<CreateBackupScheduleData, ThrowOnError>,
|
||||
) => {
|
||||
return (options?.client ?? client).post<CreateBackupScheduleResponses, unknown, ThrowOnError>({
|
||||
url: "/api/v1/backups",
|
||||
export const createBackupSchedule = <ThrowOnError extends boolean = false>(options?: Options<CreateBackupScheduleData, ThrowOnError>) => (options?.client ?? client).post<CreateBackupScheduleResponses, unknown, ThrowOnError>({
|
||||
url: '/api/v1/backups',
|
||||
...options,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...options?.headers,
|
||||
},
|
||||
});
|
||||
};
|
||||
'Content-Type': 'application/json',
|
||||
...options?.headers
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Delete a backup schedule
|
||||
*/
|
||||
export const deleteBackupSchedule = <ThrowOnError extends boolean = false>(
|
||||
options: Options<DeleteBackupScheduleData, ThrowOnError>,
|
||||
) => {
|
||||
return (options.client ?? client).delete<DeleteBackupScheduleResponses, unknown, ThrowOnError>({
|
||||
url: "/api/v1/backups/{scheduleId}",
|
||||
...options,
|
||||
});
|
||||
};
|
||||
export const deleteBackupSchedule = <ThrowOnError extends boolean = false>(options: Options<DeleteBackupScheduleData, ThrowOnError>) => (options.client ?? client).delete<DeleteBackupScheduleResponses, unknown, ThrowOnError>({ url: '/api/v1/backups/{scheduleId}', ...options });
|
||||
|
||||
/**
|
||||
* Get a backup schedule by ID
|
||||
*/
|
||||
export const getBackupSchedule = <ThrowOnError extends boolean = false>(
|
||||
options: Options<GetBackupScheduleData, ThrowOnError>,
|
||||
) => {
|
||||
return (options.client ?? client).get<GetBackupScheduleResponses, unknown, ThrowOnError>({
|
||||
url: "/api/v1/backups/{scheduleId}",
|
||||
...options,
|
||||
});
|
||||
};
|
||||
export const getBackupSchedule = <ThrowOnError extends boolean = false>(options: Options<GetBackupScheduleData, ThrowOnError>) => (options.client ?? client).get<GetBackupScheduleResponses, unknown, ThrowOnError>({ url: '/api/v1/backups/{scheduleId}', ...options });
|
||||
|
||||
/**
|
||||
* Update a backup schedule
|
||||
*/
|
||||
export const updateBackupSchedule = <ThrowOnError extends boolean = false>(
|
||||
options: Options<UpdateBackupScheduleData, ThrowOnError>,
|
||||
) => {
|
||||
return (options.client ?? client).patch<UpdateBackupScheduleResponses, unknown, ThrowOnError>({
|
||||
url: "/api/v1/backups/{scheduleId}",
|
||||
export const updateBackupSchedule = <ThrowOnError extends boolean = false>(options: Options<UpdateBackupScheduleData, ThrowOnError>) => (options.client ?? client).patch<UpdateBackupScheduleResponses, unknown, ThrowOnError>({
|
||||
url: '/api/v1/backups/{scheduleId}',
|
||||
...options,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...options.headers,
|
||||
},
|
||||
});
|
||||
};
|
||||
'Content-Type': 'application/json',
|
||||
...options.headers
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Get a backup schedule for a specific volume
|
||||
*/
|
||||
export const getBackupScheduleForVolume = <ThrowOnError extends boolean = false>(
|
||||
options: Options<GetBackupScheduleForVolumeData, ThrowOnError>,
|
||||
) => {
|
||||
return (options.client ?? client).get<GetBackupScheduleForVolumeResponses, unknown, ThrowOnError>({
|
||||
url: "/api/v1/backups/volume/{volumeId}",
|
||||
...options,
|
||||
});
|
||||
};
|
||||
export const getBackupScheduleForVolume = <ThrowOnError extends boolean = false>(options: Options<GetBackupScheduleForVolumeData, ThrowOnError>) => (options.client ?? client).get<GetBackupScheduleForVolumeResponses, unknown, ThrowOnError>({ url: '/api/v1/backups/volume/{volumeId}', ...options });
|
||||
|
||||
/**
|
||||
* Trigger a backup immediately for a schedule
|
||||
*/
|
||||
export const runBackupNow = <ThrowOnError extends boolean = false>(
|
||||
options: Options<RunBackupNowData, ThrowOnError>,
|
||||
) => {
|
||||
return (options.client ?? client).post<RunBackupNowResponses, unknown, ThrowOnError>({
|
||||
url: "/api/v1/backups/{scheduleId}/run",
|
||||
...options,
|
||||
});
|
||||
};
|
||||
export const runBackupNow = <ThrowOnError extends boolean = false>(options: Options<RunBackupNowData, ThrowOnError>) => (options.client ?? client).post<RunBackupNowResponses, unknown, ThrowOnError>({ url: '/api/v1/backups/{scheduleId}/run', ...options });
|
||||
|
||||
/**
|
||||
* Stop a backup that is currently in progress
|
||||
*/
|
||||
export const stopBackup = <ThrowOnError extends boolean = false>(options: Options<StopBackupData, ThrowOnError>) => {
|
||||
return (options.client ?? client).post<StopBackupResponses, StopBackupErrors, ThrowOnError>({
|
||||
url: "/api/v1/backups/{scheduleId}/stop",
|
||||
export const stopBackup = <ThrowOnError extends boolean = false>(options: Options<StopBackupData, ThrowOnError>) => (options.client ?? client).post<StopBackupResponses, StopBackupErrors, ThrowOnError>({ url: '/api/v1/backups/{scheduleId}/stop', ...options });
|
||||
|
||||
/**
|
||||
* Manually apply retention policy to clean up old snapshots
|
||||
*/
|
||||
export const runForget = <ThrowOnError extends boolean = false>(options: Options<RunForgetData, ThrowOnError>) => (options.client ?? client).post<RunForgetResponses, unknown, ThrowOnError>({ url: '/api/v1/backups/{scheduleId}/forget', ...options });
|
||||
|
||||
/**
|
||||
* Get notification assignments for a backup schedule
|
||||
*/
|
||||
export const getScheduleNotifications = <ThrowOnError extends boolean = false>(options: Options<GetScheduleNotificationsData, ThrowOnError>) => (options.client ?? client).get<GetScheduleNotificationsResponses, unknown, ThrowOnError>({ url: '/api/v1/backups/{scheduleId}/notifications', ...options });
|
||||
|
||||
/**
|
||||
* Update notification assignments for a backup schedule
|
||||
*/
|
||||
export const updateScheduleNotifications = <ThrowOnError extends boolean = false>(options: Options<UpdateScheduleNotificationsData, ThrowOnError>) => (options.client ?? client).put<UpdateScheduleNotificationsResponses, unknown, ThrowOnError>({
|
||||
url: '/api/v1/backups/{scheduleId}/notifications',
|
||||
...options,
|
||||
});
|
||||
};
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...options.headers
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Get mirror repository assignments for a backup schedule
|
||||
*/
|
||||
export const getScheduleMirrors = <ThrowOnError extends boolean = false>(options: Options<GetScheduleMirrorsData, ThrowOnError>) => (options.client ?? client).get<GetScheduleMirrorsResponses, unknown, ThrowOnError>({ url: '/api/v1/backups/{scheduleId}/mirrors', ...options });
|
||||
|
||||
/**
|
||||
* Update mirror repository assignments for a backup schedule
|
||||
*/
|
||||
export const updateScheduleMirrors = <ThrowOnError extends boolean = false>(options: Options<UpdateScheduleMirrorsData, ThrowOnError>) => (options.client ?? client).put<UpdateScheduleMirrorsResponses, unknown, ThrowOnError>({
|
||||
url: '/api/v1/backups/{scheduleId}/mirrors',
|
||||
...options,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...options.headers
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Get mirror compatibility info for all repositories relative to a backup schedule's primary repository
|
||||
*/
|
||||
export const getMirrorCompatibility = <ThrowOnError extends boolean = false>(options: Options<GetMirrorCompatibilityData, ThrowOnError>) => (options.client ?? client).get<GetMirrorCompatibilityResponses, unknown, ThrowOnError>({ url: '/api/v1/backups/{scheduleId}/mirrors/compatibility', ...options });
|
||||
|
||||
/**
|
||||
* List all notification destinations
|
||||
*/
|
||||
export const listNotificationDestinations = <ThrowOnError extends boolean = false>(options?: Options<ListNotificationDestinationsData, ThrowOnError>) => (options?.client ?? client).get<ListNotificationDestinationsResponses, unknown, ThrowOnError>({ url: '/api/v1/notifications/destinations', ...options });
|
||||
|
||||
/**
|
||||
* Create a new notification destination
|
||||
*/
|
||||
export const createNotificationDestination = <ThrowOnError extends boolean = false>(options?: Options<CreateNotificationDestinationData, ThrowOnError>) => (options?.client ?? client).post<CreateNotificationDestinationResponses, unknown, ThrowOnError>({
|
||||
url: '/api/v1/notifications/destinations',
|
||||
...options,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...options?.headers
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Delete a notification destination
|
||||
*/
|
||||
export const deleteNotificationDestination = <ThrowOnError extends boolean = false>(options: Options<DeleteNotificationDestinationData, ThrowOnError>) => (options.client ?? client).delete<DeleteNotificationDestinationResponses, DeleteNotificationDestinationErrors, ThrowOnError>({ url: '/api/v1/notifications/destinations/{id}', ...options });
|
||||
|
||||
/**
|
||||
* Get a notification destination by ID
|
||||
*/
|
||||
export const getNotificationDestination = <ThrowOnError extends boolean = false>(options: Options<GetNotificationDestinationData, ThrowOnError>) => (options.client ?? client).get<GetNotificationDestinationResponses, GetNotificationDestinationErrors, ThrowOnError>({ url: '/api/v1/notifications/destinations/{id}', ...options });
|
||||
|
||||
/**
|
||||
* Update a notification destination
|
||||
*/
|
||||
export const updateNotificationDestination = <ThrowOnError extends boolean = false>(options: Options<UpdateNotificationDestinationData, ThrowOnError>) => (options.client ?? client).patch<UpdateNotificationDestinationResponses, UpdateNotificationDestinationErrors, ThrowOnError>({
|
||||
url: '/api/v1/notifications/destinations/{id}',
|
||||
...options,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...options.headers
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Test a notification destination by sending a test message
|
||||
*/
|
||||
export const testNotificationDestination = <ThrowOnError extends boolean = false>(options: Options<TestNotificationDestinationData, ThrowOnError>) => (options.client ?? client).post<TestNotificationDestinationResponses, TestNotificationDestinationErrors, ThrowOnError>({ url: '/api/v1/notifications/destinations/{id}/test', ...options });
|
||||
|
||||
/**
|
||||
* Get system information including available capabilities
|
||||
*/
|
||||
export const getSystemInfo = <ThrowOnError extends boolean = false>(
|
||||
options?: Options<GetSystemInfoData, ThrowOnError>,
|
||||
) => {
|
||||
return (options?.client ?? client).get<GetSystemInfoResponses, unknown, ThrowOnError>({
|
||||
url: "/api/v1/system/info",
|
||||
...options,
|
||||
});
|
||||
};
|
||||
export const getSystemInfo = <ThrowOnError extends boolean = false>(options?: Options<GetSystemInfoData, ThrowOnError>) => (options?.client ?? client).get<GetSystemInfoResponses, unknown, ThrowOnError>({ url: '/api/v1/system/info', ...options });
|
||||
|
||||
/**
|
||||
* Download the Restic password file for backup recovery. Requires password re-authentication.
|
||||
*/
|
||||
export const downloadResticPassword = <ThrowOnError extends boolean = false>(
|
||||
options?: Options<DownloadResticPasswordData, ThrowOnError>,
|
||||
) => {
|
||||
return (options?.client ?? client).post<DownloadResticPasswordResponses, unknown, ThrowOnError>({
|
||||
url: "/api/v1/system/restic-password",
|
||||
export const downloadResticPassword = <ThrowOnError extends boolean = false>(options?: Options<DownloadResticPasswordData, ThrowOnError>) => (options?.client ?? client).post<DownloadResticPasswordResponses, unknown, ThrowOnError>({
|
||||
url: '/api/v1/system/restic-password',
|
||||
...options,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...options?.headers,
|
||||
},
|
||||
});
|
||||
};
|
||||
'Content-Type': 'application/json',
|
||||
...options?.headers
|
||||
}
|
||||
});
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,8 +1,9 @@
|
||||
import { CalendarClock, Database, HardDrive, Mountain, Settings } from "lucide-react";
|
||||
import { Bell, CalendarClock, Database, HardDrive, Settings } from "lucide-react";
|
||||
import { Link, NavLink } from "react-router";
|
||||
import {
|
||||
Sidebar,
|
||||
SidebarContent,
|
||||
SidebarFooter,
|
||||
SidebarGroup,
|
||||
SidebarGroupContent,
|
||||
SidebarHeader,
|
||||
@@ -13,6 +14,7 @@ import {
|
||||
} from "~/client/components/ui/sidebar";
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "~/client/components/ui/tooltip";
|
||||
import { cn } from "~/client/lib/utils";
|
||||
import { APP_VERSION } from "~/client/lib/version";
|
||||
|
||||
const items = [
|
||||
{
|
||||
@@ -30,6 +32,11 @@ const items = [
|
||||
url: "/backups",
|
||||
icon: CalendarClock,
|
||||
},
|
||||
{
|
||||
title: "Notifications",
|
||||
url: "/notifications",
|
||||
icon: Bell,
|
||||
},
|
||||
{
|
||||
title: "Settings",
|
||||
url: "/settings",
|
||||
@@ -44,13 +51,13 @@ export function AppSidebar() {
|
||||
<Sidebar variant="inset" collapsible="icon" className="p-0">
|
||||
<SidebarHeader className="bg-card-header border-b border-border/50 hidden md:flex h-[65px] flex-row items-center p-4">
|
||||
<Link to="/volumes" className="flex items-center gap-3 font-semibold pl-2">
|
||||
<Mountain className="size-5 text-strong-accent" />
|
||||
<img src="/images/zerobyte.png" alt="Zerobyte Logo" className={cn("h-8 w-8 shrink-0 object-contain -ml-2")} />
|
||||
<span
|
||||
className={cn("text-base transition-all duration-200", {
|
||||
className={cn("text-base transition-all duration-200 -ml-1", {
|
||||
"opacity-0 w-0 overflow-hidden ": state === "collapsed",
|
||||
})}
|
||||
>
|
||||
Ironmount
|
||||
Zerobyte
|
||||
</span>
|
||||
</Link>
|
||||
</SidebarHeader>
|
||||
@@ -85,6 +92,15 @@ export function AppSidebar() {
|
||||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
</SidebarContent>
|
||||
<SidebarFooter className="p-4 border-r border-t border-border/50">
|
||||
<div
|
||||
className={cn("text-xs text-muted-foreground transition-all duration-200", {
|
||||
"opacity-0 w-0 overflow-hidden": state === "collapsed",
|
||||
})}
|
||||
>
|
||||
{APP_VERSION}
|
||||
</div>
|
||||
</SidebarFooter>
|
||||
</Sidebar>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { Mountain } from "lucide-react";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
type AuthLayoutProps = {
|
||||
@@ -13,8 +12,8 @@ export function AuthLayout({ title, description, children }: AuthLayoutProps) {
|
||||
<div className="flex flex-1 items-center justify-center bg-background p-8">
|
||||
<div className="w-full max-w-md space-y-8">
|
||||
<div className="flex items-center gap-3">
|
||||
<Mountain className="size-5 text-strong-accent" />
|
||||
<span className="text-lg font-semibold">Ironmount</span>
|
||||
<img src="/images/zerobyte.png" alt="Zerobyte Logo" className="h-5 w-5 object-contain" />
|
||||
<span className="text-lg font-semibold">Zerobyte</span>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
|
||||
@@ -1,66 +0,0 @@
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { Plus } from "lucide-react";
|
||||
import { useId } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { parseError } from "~/client/lib/errors";
|
||||
import { CreateRepositoryForm } from "./create-repository-form";
|
||||
import { Button } from "./ui/button";
|
||||
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from "./ui/dialog";
|
||||
import { ScrollArea } from "./ui/scroll-area";
|
||||
import { createRepositoryMutation } from "../api-client/@tanstack/react-query.gen";
|
||||
|
||||
type Props = {
|
||||
open: boolean;
|
||||
setOpen: (open: boolean) => void;
|
||||
};
|
||||
|
||||
export const CreateRepositoryDialog = ({ open, setOpen }: Props) => {
|
||||
const formId = useId();
|
||||
|
||||
const create = useMutation({
|
||||
...createRepositoryMutation(),
|
||||
onSuccess: () => {
|
||||
toast.success("Repository created successfully");
|
||||
setOpen(false);
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error("Failed to create repository", {
|
||||
description: parseError(error)?.message,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button>
|
||||
<Plus size={16} className="mr-2" />
|
||||
Create Repository
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<ScrollArea className="h-[500px] p-4">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Create repository</DialogTitle>
|
||||
</DialogHeader>
|
||||
<CreateRepositoryForm
|
||||
className="mt-4"
|
||||
mode="create"
|
||||
formId={formId}
|
||||
onSubmit={(values) => {
|
||||
create.mutate({ body: { config: values, name: values.name, compressionMode: values.compressionMode } });
|
||||
}}
|
||||
/>
|
||||
<DialogFooter className="mt-4">
|
||||
<Button type="button" variant="secondary" onClick={() => setOpen(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" form={formId} disabled={create.isPending}>
|
||||
Create
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</ScrollArea>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -1,6 +1,6 @@
|
||||
import { arktypeResolver } from "@hookform/resolvers/arktype";
|
||||
import { type } from "arktype";
|
||||
import { useEffect } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { cn, slugify } from "~/client/lib/utils";
|
||||
import { deepClean } from "~/utils/object";
|
||||
@@ -10,11 +10,24 @@ import { Input } from "./ui/input";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Alert, AlertDescription } from "./ui/alert";
|
||||
import { ExternalLink } from "lucide-react";
|
||||
import { ExternalLink, AlertTriangle } from "lucide-react";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip";
|
||||
import { useSystemInfo } from "~/client/hooks/use-system-info";
|
||||
import { COMPRESSION_MODES, repositoryConfigSchema } from "~/schemas/restic";
|
||||
import { listRcloneRemotesOptions } from "../api-client/@tanstack/react-query.gen";
|
||||
import { Checkbox } from "./ui/checkbox";
|
||||
import { DirectoryBrowser } from "./directory-browser";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "./ui/alert-dialog";
|
||||
import { Textarea } from "./ui/textarea";
|
||||
|
||||
export const formSchema = type({
|
||||
name: "2<=string<=32",
|
||||
@@ -36,9 +49,12 @@ type Props = {
|
||||
const defaultValuesForType = {
|
||||
local: { backend: "local" as const, compressionMode: "auto" as const },
|
||||
s3: { backend: "s3" as const, compressionMode: "auto" as const },
|
||||
r2: { backend: "r2" as const, compressionMode: "auto" as const },
|
||||
gcs: { backend: "gcs" as const, compressionMode: "auto" as const },
|
||||
azure: { backend: "azure" as const, compressionMode: "auto" as const },
|
||||
rclone: { backend: "rclone" as const, compressionMode: "auto" as const },
|
||||
rest: { backend: "rest" as const, compressionMode: "auto" as const },
|
||||
sftp: { backend: "sftp" as const, compressionMode: "auto" as const, port: 22 },
|
||||
};
|
||||
|
||||
export const CreateRepositoryForm = ({
|
||||
@@ -58,23 +74,31 @@ export const CreateRepositoryForm = ({
|
||||
},
|
||||
});
|
||||
|
||||
const { watch } = form;
|
||||
const { watch, setValue } = form;
|
||||
|
||||
const watchedBackend = watch("backend");
|
||||
const watchedIsExistingRepository = watch("isExistingRepository");
|
||||
|
||||
const [passwordMode, setPasswordMode] = useState<"default" | "custom">("default");
|
||||
const [showPathBrowser, setShowPathBrowser] = useState(false);
|
||||
const [showPathWarning, setShowPathWarning] = useState(false);
|
||||
|
||||
const { capabilities } = useSystemInfo();
|
||||
|
||||
const { data: rcloneRemotes, isLoading: isLoadingRemotes } = useQuery({
|
||||
...listRcloneRemotesOptions(),
|
||||
enabled: capabilities.rclone,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
form.reset({
|
||||
name: form.getValues().name,
|
||||
isExistingRepository: form.getValues().isExistingRepository,
|
||||
customPassword: form.getValues().customPassword,
|
||||
...defaultValuesForType[watchedBackend as keyof typeof defaultValuesForType],
|
||||
});
|
||||
}, [watchedBackend, form]);
|
||||
|
||||
const { capabilities } = useSystemInfo();
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form id={formId} onSubmit={form.handleSubmit(onSubmit)} className={cn("space-y-4", className)}>
|
||||
@@ -91,8 +115,6 @@ export const CreateRepositoryForm = ({
|
||||
onChange={(e) => field.onChange(slugify(e.target.value))}
|
||||
max={32}
|
||||
min={2}
|
||||
disabled={mode === "update"}
|
||||
className={mode === "update" ? "bg-gray-50" : ""}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>Unique identifier for the repository.</FormDescription>
|
||||
@@ -115,8 +137,11 @@ export const CreateRepositoryForm = ({
|
||||
<SelectContent>
|
||||
<SelectItem value="local">Local</SelectItem>
|
||||
<SelectItem value="s3">S3</SelectItem>
|
||||
<SelectItem value="r2">Cloudflare R2</SelectItem>
|
||||
<SelectItem value="gcs">Google Cloud Storage</SelectItem>
|
||||
<SelectItem value="azure">Azure Blob Storage</SelectItem>
|
||||
<SelectItem value="rest">REST Server</SelectItem>
|
||||
<SelectItem value="sftp">SFTP</SelectItem>
|
||||
<Tooltip>
|
||||
<TooltipTrigger>
|
||||
<SelectItem disabled={!capabilities.rclone} value="rclone">
|
||||
@@ -149,10 +174,8 @@ export const CreateRepositoryForm = ({
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
<SelectItem value="off">Off</SelectItem>
|
||||
<SelectItem value="auto">Auto</SelectItem>
|
||||
<SelectItem value="fastest">Fastest</SelectItem>
|
||||
<SelectItem value="better">Better</SelectItem>
|
||||
<SelectItem value="max">Max</SelectItem>
|
||||
<SelectItem value="auto">Auto (fast)</SelectItem>
|
||||
<SelectItem value="max">Max (slower, better compression)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormDescription>Compression mode for backups stored in this repository.</FormDescription>
|
||||
@@ -161,6 +184,150 @@ export const CreateRepositoryForm = ({
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="isExistingRepository"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex flex-row items-center space-x-3">
|
||||
<FormControl>
|
||||
<Checkbox
|
||||
checked={field.value}
|
||||
onCheckedChange={(checked) => {
|
||||
field.onChange(checked);
|
||||
if (!checked) {
|
||||
setPasswordMode("default");
|
||||
setValue("customPassword", undefined);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
<div className="space-y-1">
|
||||
<FormLabel>Import existing repository</FormLabel>
|
||||
<FormDescription>Check this if the repository already exists at the specified location</FormDescription>
|
||||
</div>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
{watchedIsExistingRepository && (
|
||||
<>
|
||||
<FormItem>
|
||||
<FormLabel>Repository Password</FormLabel>
|
||||
<Select
|
||||
onValueChange={(value) => {
|
||||
setPasswordMode(value as "default" | "custom");
|
||||
if (value === "default") {
|
||||
setValue("customPassword", undefined);
|
||||
}
|
||||
}}
|
||||
defaultValue={passwordMode}
|
||||
value={passwordMode}
|
||||
>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select password option" />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
<SelectItem value="default">Use Zerobyte's password</SelectItem>
|
||||
<SelectItem value="custom">Enter password manually</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormDescription>
|
||||
Choose whether to use Zerobyte's master password or enter a custom password for the existing repository.
|
||||
</FormDescription>
|
||||
</FormItem>
|
||||
|
||||
{passwordMode === "custom" && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="customPassword"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Repository Password</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="password" placeholder="Enter repository password" {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
The password used to encrypt this repository. It will be stored securely.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{watchedBackend === "local" && (
|
||||
<>
|
||||
<FormItem>
|
||||
<FormLabel>Repository Directory</FormLabel>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex-1 text-sm font-mono bg-muted px-3 py-2 rounded-md border">
|
||||
{form.watch("path") || "/var/lib/zerobyte/repositories"}
|
||||
</div>
|
||||
<Button type="button" variant="outline" onClick={() => setShowPathWarning(true)} size="sm">
|
||||
Change
|
||||
</Button>
|
||||
</div>
|
||||
<FormDescription>The directory where the repository will be stored.</FormDescription>
|
||||
</FormItem>
|
||||
|
||||
<AlertDialog open={showPathWarning} onOpenChange={setShowPathWarning}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle className="flex items-center gap-2">
|
||||
<AlertTriangle className="h-5 w-5 text-yellow-500" />
|
||||
Important: Host Mount Required
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription className="space-y-3">
|
||||
<p>When selecting a custom path, ensure it is mounted from the host machine into the container.</p>
|
||||
<p className="font-medium">
|
||||
If the path is not a host mount, you will lose your repository data when the container restarts.
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
The default path <code className="bg-muted px-1 rounded">/var/lib/zerobyte/repositories</code> is
|
||||
already mounted from the host and is safe to use.
|
||||
</p>
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={() => {
|
||||
setShowPathBrowser(true);
|
||||
setShowPathWarning(false);
|
||||
}}
|
||||
>
|
||||
I Understand, Continue
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
|
||||
<AlertDialog open={showPathBrowser} onOpenChange={setShowPathBrowser}>
|
||||
<AlertDialogContent className="max-w-2xl">
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Select Repository Directory</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
Choose a directory from the filesystem to store the repository.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<div className="py-4">
|
||||
<DirectoryBrowser
|
||||
onSelectPath={(path) => form.setValue("path", path)}
|
||||
selectedPath={form.watch("path") || "/var/lib/zerobyte/repositories"}
|
||||
/>
|
||||
</div>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={() => setShowPathBrowser(false)}>Done</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</>
|
||||
)}
|
||||
|
||||
{watchedBackend === "s3" && (
|
||||
<>
|
||||
<FormField
|
||||
@@ -222,6 +389,69 @@ export const CreateRepositoryForm = ({
|
||||
</>
|
||||
)}
|
||||
|
||||
{watchedBackend === "r2" && (
|
||||
<>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="endpoint"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Endpoint</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="<account-id>.r2.cloudflarestorage.com" {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
R2 endpoint (without https://). Find in R2 dashboard under bucket settings.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="bucket"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Bucket</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="my-backup-bucket" {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>R2 bucket name for storing backups.</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="accessKeyId"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Access Key ID</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="Access Key ID from R2 API tokens" {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>R2 API token Access Key ID (create in Cloudflare R2 dashboard).</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="secretAccessKey"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Secret Access Key</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="password" placeholder="••••••••" {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>R2 API token Secret Access Key (shown once when creating token).</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{watchedBackend === "gcs" && (
|
||||
<>
|
||||
<FormField
|
||||
@@ -389,7 +619,7 @@ export const CreateRepositoryForm = ({
|
||||
<FormItem>
|
||||
<FormLabel>Path</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="backups/ironmount" {...field} />
|
||||
<Input placeholder="backups/zerobyte" {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>Path within the remote where backups will be stored.</FormDescription>
|
||||
<FormMessage />
|
||||
@@ -399,6 +629,150 @@ export const CreateRepositoryForm = ({
|
||||
</>
|
||||
))}
|
||||
|
||||
{watchedBackend === "rest" && (
|
||||
<>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="url"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>REST Server URL</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="http://192.168.1.30:8000" {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>URL of the REST server.</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="path"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Repository Path (Optional)</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="my-backup-repo" {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>Path to the repository on the REST server (leave empty for root).</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="username"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Username (Optional)</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="username" {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>Username for REST server authentication.</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="password"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Password (Optional)</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="password" placeholder="••••••••" {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>Password for REST server authentication.</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{watchedBackend === "sftp" && (
|
||||
<>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="host"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Host</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="192.168.1.100" {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>SFTP server hostname or IP address.</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="port"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Port</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="number"
|
||||
placeholder="22"
|
||||
{...field}
|
||||
onChange={(e) => field.onChange(parseInt(e.target.value, 10))}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>SSH port (default: 22).</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="user"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>User</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="backup-user" {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>SSH username for authentication.</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="path"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Path</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="backups/ironmount" {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>Repository path on the SFTP server. </FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="privateKey"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>SSH Private Key</FormLabel>
|
||||
<FormControl>
|
||||
<Textarea
|
||||
{...field}
|
||||
placeholder="-----BEGIN OPENSSH PRIVATE KEY----- ... -----END OPENSSH PRIVATE KEY-----"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>Paste the contents of your SSH private key.</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{mode === "update" && (
|
||||
<Button type="submit" className="w-full" loading={loading}>
|
||||
Save Changes
|
||||
|
||||
@@ -1,66 +0,0 @@
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { Plus } from "lucide-react";
|
||||
import { useId } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { parseError } from "~/client/lib/errors";
|
||||
import { CreateVolumeForm } from "./create-volume-form";
|
||||
import { Button } from "./ui/button";
|
||||
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from "./ui/dialog";
|
||||
import { ScrollArea } from "./ui/scroll-area";
|
||||
import { createVolumeMutation } from "../api-client/@tanstack/react-query.gen";
|
||||
|
||||
type Props = {
|
||||
open: boolean;
|
||||
setOpen: (open: boolean) => void;
|
||||
};
|
||||
|
||||
export const CreateVolumeDialog = ({ open, setOpen }: Props) => {
|
||||
const formId = useId();
|
||||
|
||||
const create = useMutation({
|
||||
...createVolumeMutation(),
|
||||
onSuccess: () => {
|
||||
toast.success("Volume created successfully");
|
||||
setOpen(false);
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error("Failed to create volume", {
|
||||
description: parseError(error)?.message,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button>
|
||||
<Plus size={16} className="mr-2" />
|
||||
Create volume
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<ScrollArea className="h-[500px] p-4">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Create volume</DialogTitle>
|
||||
</DialogHeader>
|
||||
<CreateVolumeForm
|
||||
className="mt-4"
|
||||
mode="create"
|
||||
formId={formId}
|
||||
onSubmit={(values) => {
|
||||
create.mutate({ body: { config: values, name: values.name } });
|
||||
}}
|
||||
/>
|
||||
<DialogFooter className="mt-4">
|
||||
<Button type="button" variant="secondary" onClick={() => setOpen(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" form={formId} disabled={create.isPending}>
|
||||
Create
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</ScrollArea>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -104,8 +104,6 @@ export const CreateVolumeForm = ({ onSubmit, mode = "create", initialValues, for
|
||||
onChange={(e) => field.onChange(slugify(e.target.value))}
|
||||
max={32}
|
||||
min={1}
|
||||
disabled={mode === "update"}
|
||||
className={mode === "update" ? "bg-gray-50" : ""}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>Unique identifier for the volume.</FormDescription>
|
||||
@@ -207,7 +205,12 @@ export const CreateVolumeForm = ({ onSubmit, mode = "create", initialValues, for
|
||||
<FormItem>
|
||||
<FormLabel>Port</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="number" placeholder="2049" {...field} />
|
||||
<Input
|
||||
type="number"
|
||||
placeholder="2049"
|
||||
{...field}
|
||||
onChange={(e) => field.onChange(parseInt(e.target.value, 10) || undefined)}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>NFS server port (default: 2049).</FormDescription>
|
||||
<FormMessage />
|
||||
@@ -332,7 +335,12 @@ export const CreateVolumeForm = ({ onSubmit, mode = "create", initialValues, for
|
||||
<FormItem>
|
||||
<FormLabel>Port</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="number" placeholder="80" {...field} />
|
||||
<Input
|
||||
type="number"
|
||||
placeholder="80"
|
||||
{...field}
|
||||
onChange={(e) => field.onChange(parseInt(e.target.value, 10) || undefined)}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>WebDAV server port (default: 80 for HTTP, 443 for HTTPS).</FormDescription>
|
||||
<FormMessage />
|
||||
@@ -536,6 +544,7 @@ export const CreateVolumeForm = ({ onSubmit, mode = "create", initialValues, for
|
||||
</>
|
||||
)}
|
||||
|
||||
{watchedBackend && watchedBackend !== "directory" && (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
@@ -572,6 +581,7 @@ export const CreateVolumeForm = ({ onSubmit, mode = "create", initialValues, for
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{mode === "update" && (
|
||||
<Button type="submit" className="w-full" loading={loading}>
|
||||
Save Changes
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { FileTree, type FileEntry } from "./file-tree";
|
||||
import { FileTree } from "./file-tree";
|
||||
import { ScrollArea } from "./ui/scroll-area";
|
||||
import { browseFilesystemOptions } from "../api-client/@tanstack/react-query.gen";
|
||||
import { useFileBrowser } from "../hooks/use-file-browser";
|
||||
|
||||
type Props = {
|
||||
onSelectPath: (path: string) => void;
|
||||
@@ -11,82 +11,23 @@ type Props = {
|
||||
|
||||
export const DirectoryBrowser = ({ onSelectPath, selectedPath }: Props) => {
|
||||
const queryClient = useQueryClient();
|
||||
const [expandedFolders, setExpandedFolders] = useState<Set<string>>(new Set());
|
||||
const [fetchedFolders, setFetchedFolders] = useState<Set<string>>(new Set(["/"]));
|
||||
const [loadingFolders, setLoadingFolders] = useState<Set<string>>(new Set());
|
||||
const [allFiles, setAllFiles] = useState<Map<string, FileEntry>>(new Map());
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
...browseFilesystemOptions({ query: { path: "/" } }),
|
||||
});
|
||||
|
||||
useMemo(() => {
|
||||
if (data?.directories) {
|
||||
setAllFiles((prev) => {
|
||||
const next = new Map(prev);
|
||||
for (const dir of data.directories) {
|
||||
next.set(dir.path, { name: dir.name, path: dir.path, type: "folder" });
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}
|
||||
}, [data]);
|
||||
|
||||
const fileArray = useMemo(() => Array.from(allFiles.values()), [allFiles]);
|
||||
|
||||
const handleFolderExpand = useCallback(
|
||||
async (folderPath: string) => {
|
||||
setExpandedFolders((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.add(folderPath);
|
||||
return next;
|
||||
});
|
||||
|
||||
if (!fetchedFolders.has(folderPath)) {
|
||||
setLoadingFolders((prev) => new Set(prev).add(folderPath));
|
||||
|
||||
try {
|
||||
const result = await queryClient.ensureQueryData(
|
||||
browseFilesystemOptions({
|
||||
query: { path: folderPath },
|
||||
}),
|
||||
);
|
||||
|
||||
if (result.directories) {
|
||||
setAllFiles((prev) => {
|
||||
const next = new Map(prev);
|
||||
for (const dir of result.directories) {
|
||||
next.set(dir.path, { name: dir.name, path: dir.path, type: "folder" });
|
||||
}
|
||||
return next;
|
||||
});
|
||||
|
||||
setFetchedFolders((prev) => new Set(prev).add(folderPath));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch folder contents:", error);
|
||||
} finally {
|
||||
setLoadingFolders((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.delete(folderPath);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
}
|
||||
const fileBrowser = useFileBrowser({
|
||||
initialData: data,
|
||||
isLoading,
|
||||
fetchFolder: async (path) => {
|
||||
return await queryClient.ensureQueryData(browseFilesystemOptions({ query: { path } }));
|
||||
},
|
||||
[fetchedFolders, queryClient],
|
||||
);
|
||||
|
||||
const handleFolderHover = useCallback(
|
||||
(folderPath: string) => {
|
||||
if (!fetchedFolders.has(folderPath) && !loadingFolders.has(folderPath)) {
|
||||
queryClient.prefetchQuery(browseFilesystemOptions({ query: { path: folderPath } }));
|
||||
}
|
||||
prefetchFolder: (path) => {
|
||||
queryClient.prefetchQuery(browseFilesystemOptions({ query: { path } }));
|
||||
},
|
||||
[fetchedFolders, loadingFolders, queryClient],
|
||||
);
|
||||
});
|
||||
|
||||
if (isLoading && fileArray.length === 0) {
|
||||
if (fileBrowser.isLoading) {
|
||||
return (
|
||||
<div className="border rounded-lg overflow-hidden">
|
||||
<ScrollArea className="h-64">
|
||||
@@ -96,7 +37,7 @@ export const DirectoryBrowser = ({ onSelectPath, selectedPath }: Props) => {
|
||||
);
|
||||
}
|
||||
|
||||
if (fileArray.length === 0) {
|
||||
if (fileBrowser.isEmpty) {
|
||||
return (
|
||||
<div className="border rounded-lg overflow-hidden">
|
||||
<ScrollArea className="h-64">
|
||||
@@ -110,11 +51,11 @@ export const DirectoryBrowser = ({ onSelectPath, selectedPath }: Props) => {
|
||||
<div className="border rounded-lg overflow-hidden">
|
||||
<ScrollArea className="h-64">
|
||||
<FileTree
|
||||
files={fileArray}
|
||||
onFolderExpand={handleFolderExpand}
|
||||
onFolderHover={handleFolderHover}
|
||||
expandedFolders={expandedFolders}
|
||||
loadingFolders={loadingFolders}
|
||||
files={fileBrowser.fileArray}
|
||||
onFolderExpand={fileBrowser.handleFolderExpand}
|
||||
onFolderHover={fileBrowser.handleFolderHover}
|
||||
expandedFolders={fileBrowser.expandedFolders}
|
||||
loadingFolders={fileBrowser.loadingFolders}
|
||||
foldersOnly
|
||||
selectableFolders
|
||||
selectedFolder={selectedPath}
|
||||
|
||||
@@ -59,7 +59,7 @@ export default function Layout({ loaderData }: Route.ComponentProps) {
|
||||
</Button>
|
||||
<Button variant="default" size="sm" className="relative overflow-hidden hidden lg:inline-flex">
|
||||
<a
|
||||
href="https://github.com/nicotsx/ironmount/issues/new"
|
||||
href="https://github.com/nicotsx/zerobyte/issues/new"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="flex items-center gap-2"
|
||||
|
||||
39
app/client/components/path-selector.tsx
Normal file
39
app/client/components/path-selector.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Database, HardDrive, Cloud } from "lucide-react";
|
||||
import { Database, HardDrive, Cloud, Server } from "lucide-react";
|
||||
import type { RepositoryBackend } from "~/schemas/restic";
|
||||
|
||||
type Props = {
|
||||
@@ -14,6 +14,9 @@ export const RepositoryIcon = ({ backend, className = "h-4 w-4" }: Props) => {
|
||||
return <Cloud className={className} />;
|
||||
case "gcs":
|
||||
return <Cloud className={className} />;
|
||||
case "rest":
|
||||
case "sftp":
|
||||
return <Server className={className} />;
|
||||
default:
|
||||
return <Database className={className} />;
|
||||
}
|
||||
|
||||
325
app/client/components/restore-form.tsx
Normal file
325
app/client/components/restore-form.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -1,39 +1,94 @@
|
||||
import { Calendar, Clock, Database, FolderTree, HardDrive } from "lucide-react";
|
||||
import { useNavigate } from "react-router";
|
||||
import { useState } from "react";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { Calendar, Clock, Database, FolderTree, HardDrive, Trash2 } from "lucide-react";
|
||||
import { Link, useNavigate } from "react-router";
|
||||
import { toast } from "sonner";
|
||||
import { ByteSize } from "~/client/components/bytes-size";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "~/client/components/ui/table";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "~/client/components/ui/tooltip";
|
||||
import { Button } from "~/client/components/ui/button";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "~/client/components/ui/alert-dialog";
|
||||
import { formatDuration } from "~/utils/utils";
|
||||
import type { ListSnapshotsResponse } from "../api-client";
|
||||
|
||||
type Snapshot = ListSnapshotsResponse[number];
|
||||
import { deleteSnapshotMutation } from "~/client/api-client/@tanstack/react-query.gen";
|
||||
import { parseError } from "~/client/lib/errors";
|
||||
import type { BackupSchedule, Snapshot } from "../lib/types";
|
||||
|
||||
type Props = {
|
||||
snapshots: Snapshot[];
|
||||
backups: BackupSchedule[];
|
||||
repositoryName: string;
|
||||
};
|
||||
|
||||
export const SnapshotsTable = ({ snapshots, repositoryName }: Props) => {
|
||||
export const SnapshotsTable = ({ snapshots, repositoryName, backups }: Props) => {
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
|
||||
const [snapshotToDelete, setSnapshotToDelete] = useState<string | null>(null);
|
||||
|
||||
const deleteSnapshot = useMutation({
|
||||
...deleteSnapshotMutation(),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["listSnapshots"] });
|
||||
setShowDeleteConfirm(false);
|
||||
setSnapshotToDelete(null);
|
||||
},
|
||||
});
|
||||
|
||||
const handleDeleteClick = (e: React.MouseEvent, snapshotId: string) => {
|
||||
e.stopPropagation();
|
||||
setSnapshotToDelete(snapshotId);
|
||||
setShowDeleteConfirm(true);
|
||||
};
|
||||
|
||||
const handleConfirmDelete = () => {
|
||||
if (snapshotToDelete) {
|
||||
toast.promise(
|
||||
deleteSnapshot.mutateAsync({
|
||||
path: { name: repositoryName, snapshotId: snapshotToDelete },
|
||||
}),
|
||||
{
|
||||
loading: "Deleting snapshot...",
|
||||
success: "Snapshot deleted successfully",
|
||||
error: (error) => parseError(error)?.message || "Failed to delete snapshot",
|
||||
},
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRowClick = (snapshotId: string) => {
|
||||
navigate(`/repositories/${repositoryName}/${snapshotId}`);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="overflow-x-auto">
|
||||
<Table className="border-t">
|
||||
<TableHeader className="bg-card-header">
|
||||
<TableRow>
|
||||
<TableHead className="uppercase">Snapshot ID</TableHead>
|
||||
<TableHead className="uppercase">Schedule</TableHead>
|
||||
<TableHead className="uppercase">Date & Time</TableHead>
|
||||
<TableHead className="uppercase">Size</TableHead>
|
||||
<TableHead className="uppercase hidden md:table-cell text-right">Duration</TableHead>
|
||||
<TableHead className="uppercase hidden text-right lg:table-cell">Paths</TableHead>
|
||||
<TableHead className="uppercase text-right">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<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
|
||||
key={snapshot.short_id}
|
||||
className="hover:bg-accent/50 cursor-pointer"
|
||||
@@ -45,6 +100,21 @@ export const SnapshotsTable = ({ snapshots, repositoryName }: Props) => {
|
||||
<span className="text-strong-accent">{snapshot.short_id}</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-2">
|
||||
<Link
|
||||
hidden={!backup}
|
||||
to={backup ? `/backups/${backup.id}` : "#"}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="hover:underline"
|
||||
>
|
||||
<span className="text-sm">{backup ? backup.id : "-"}</span>
|
||||
</Link>
|
||||
<span hidden={!!backup} className="text-sm text-muted-foreground">
|
||||
-
|
||||
</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-2">
|
||||
<Calendar className="h-4 w-4 text-muted-foreground" />
|
||||
@@ -86,10 +156,44 @@ export const SnapshotsTable = ({ snapshots, repositoryName }: Props) => {
|
||||
</Tooltip>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={(e) => handleDeleteClick(e, snapshot.short_id)}
|
||||
disabled={deleteSnapshot.isPending}
|
||||
>
|
||||
<Trash2 className="h-4 w-4 text-destructive" />
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
<AlertDialog open={showDeleteConfirm} onOpenChange={setShowDeleteConfirm}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Delete snapshot?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
This action cannot be undone. This will permanently delete the snapshot and all its data from the
|
||||
repository.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={handleConfirmDelete}
|
||||
disabled={deleteSnapshot.isPending}
|
||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
>
|
||||
Delete snapshot
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,36 +1,48 @@
|
||||
import type { VolumeStatus } from "~/client/lib/types";
|
||||
import { cn } from "~/client/lib/utils";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip";
|
||||
|
||||
export const StatusDot = ({ status }: { status: VolumeStatus }) => {
|
||||
type StatusVariant = "success" | "neutral" | "error" | "warning" | "info";
|
||||
|
||||
interface StatusDotProps {
|
||||
variant: StatusVariant;
|
||||
label: string;
|
||||
animated?: boolean;
|
||||
}
|
||||
|
||||
export const StatusDot = ({ variant, label, animated }: StatusDotProps) => {
|
||||
const statusMapping = {
|
||||
mounted: {
|
||||
success: {
|
||||
color: "bg-green-500",
|
||||
colorLight: "bg-emerald-400",
|
||||
animated: true,
|
||||
animated: animated ?? true,
|
||||
},
|
||||
unmounted: {
|
||||
neutral: {
|
||||
color: "bg-gray-500",
|
||||
colorLight: "bg-gray-400",
|
||||
animated: false,
|
||||
animated: animated ?? false,
|
||||
},
|
||||
error: {
|
||||
color: "bg-red-500",
|
||||
colorLight: "bg-amber-700",
|
||||
animated: true,
|
||||
colorLight: "bg-red-400",
|
||||
animated: animated ?? true,
|
||||
},
|
||||
unknown: {
|
||||
warning: {
|
||||
color: "bg-yellow-500",
|
||||
colorLight: "bg-yellow-400",
|
||||
animated: true,
|
||||
animated: animated ?? true,
|
||||
},
|
||||
}[status];
|
||||
info: {
|
||||
color: "bg-blue-500",
|
||||
colorLight: "bg-blue-400",
|
||||
animated: animated ?? true,
|
||||
},
|
||||
}[variant];
|
||||
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger>
|
||||
<span className="relative flex size-3 mx-auto">
|
||||
{statusMapping.animated && (
|
||||
{statusMapping?.animated && (
|
||||
<span
|
||||
className={cn(
|
||||
"absolute inline-flex h-full w-full animate-ping rounded-full opacity-75",
|
||||
@@ -38,11 +50,11 @@ export const StatusDot = ({ status }: { status: VolumeStatus }) => {
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
<span className={cn("relative inline-flex size-3 rounded-full", `${statusMapping.color}`)} />
|
||||
<span className={cn("relative inline-flex size-3 rounded-full", `${statusMapping?.color}`)} />
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p className="capitalize">{status}</p>
|
||||
<p>{label}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
|
||||
@@ -1,16 +1,8 @@
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { FolderOpen } from "lucide-react";
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { FileTree } from "~/client/components/file-tree";
|
||||
import { listFilesOptions } from "../api-client/@tanstack/react-query.gen";
|
||||
|
||||
interface FileEntry {
|
||||
name: string;
|
||||
path: string;
|
||||
type: "file" | "directory";
|
||||
size?: number;
|
||||
modifiedAt?: number;
|
||||
}
|
||||
import { useFileBrowser } from "../hooks/use-file-browser";
|
||||
|
||||
type VolumeFileBrowserProps = {
|
||||
volumeName: string;
|
||||
@@ -36,89 +28,34 @@ export const VolumeFileBrowser = ({
|
||||
emptyDescription,
|
||||
}: VolumeFileBrowserProps) => {
|
||||
const queryClient = useQueryClient();
|
||||
const [expandedFolders, setExpandedFolders] = useState<Set<string>>(new Set());
|
||||
const [fetchedFolders, setFetchedFolders] = useState<Set<string>>(new Set(["/"]));
|
||||
const [loadingFolders, setLoadingFolders] = useState<Set<string>>(new Set());
|
||||
const [allFiles, setAllFiles] = useState<Map<string, FileEntry>>(new Map());
|
||||
|
||||
const { data, isLoading, error } = useQuery({
|
||||
...listFilesOptions({ path: { name: volumeName } }),
|
||||
enabled,
|
||||
});
|
||||
|
||||
useMemo(() => {
|
||||
if (data?.files) {
|
||||
setAllFiles((prev) => {
|
||||
const next = new Map(prev);
|
||||
for (const file of data.files) {
|
||||
next.set(file.path, file);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}
|
||||
}, [data]);
|
||||
|
||||
const fileArray = useMemo(() => Array.from(allFiles.values()), [allFiles]);
|
||||
|
||||
const handleFolderExpand = useCallback(
|
||||
async (folderPath: string) => {
|
||||
setExpandedFolders((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.add(folderPath);
|
||||
return next;
|
||||
});
|
||||
|
||||
if (!fetchedFolders.has(folderPath)) {
|
||||
setLoadingFolders((prev) => new Set(prev).add(folderPath));
|
||||
|
||||
try {
|
||||
const result = await queryClient.ensureQueryData(
|
||||
const fileBrowser = useFileBrowser({
|
||||
initialData: data,
|
||||
isLoading,
|
||||
fetchFolder: async (path) => {
|
||||
return await queryClient.ensureQueryData(
|
||||
listFilesOptions({
|
||||
path: { name: volumeName },
|
||||
query: { path: folderPath },
|
||||
query: { path },
|
||||
}),
|
||||
);
|
||||
|
||||
if (result.files) {
|
||||
setAllFiles((prev) => {
|
||||
const next = new Map(prev);
|
||||
for (const file of result.files) {
|
||||
next.set(file.path, file);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
|
||||
setFetchedFolders((prev) => new Set(prev).add(folderPath));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch folder contents:", error);
|
||||
} finally {
|
||||
setLoadingFolders((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.delete(folderPath);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
[volumeName, fetchedFolders, queryClient.ensureQueryData],
|
||||
);
|
||||
|
||||
const handleFolderHover = useCallback(
|
||||
(folderPath: string) => {
|
||||
if (!fetchedFolders.has(folderPath) && !loadingFolders.has(folderPath)) {
|
||||
prefetchFolder: (path) => {
|
||||
queryClient.prefetchQuery(
|
||||
listFilesOptions({
|
||||
path: { name: volumeName },
|
||||
query: { path: folderPath },
|
||||
query: { path },
|
||||
}),
|
||||
);
|
||||
}
|
||||
},
|
||||
[volumeName, fetchedFolders, loadingFolders, queryClient],
|
||||
);
|
||||
});
|
||||
|
||||
if (isLoading && fileArray.length === 0) {
|
||||
if (fileBrowser.isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full min-h-[200px]">
|
||||
<p className="text-muted-foreground">Loading files...</p>
|
||||
@@ -134,7 +71,7 @@ export const VolumeFileBrowser = ({
|
||||
);
|
||||
}
|
||||
|
||||
if (fileArray.length === 0) {
|
||||
if (fileBrowser.isEmpty) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center h-full text-center p-8 min-h-[200px]">
|
||||
<FolderOpen className="mb-4 h-12 w-12 text-muted-foreground" />
|
||||
@@ -147,11 +84,11 @@ export const VolumeFileBrowser = ({
|
||||
return (
|
||||
<div className={className}>
|
||||
<FileTree
|
||||
files={fileArray}
|
||||
onFolderExpand={handleFolderExpand}
|
||||
onFolderHover={handleFolderHover}
|
||||
expandedFolders={expandedFolders}
|
||||
loadingFolders={loadingFolders}
|
||||
files={fileBrowser.fileArray}
|
||||
onFolderExpand={fileBrowser.handleFolderExpand}
|
||||
onFolderHover={fileBrowser.handleFolderHover}
|
||||
expandedFolders={fileBrowser.expandedFolders}
|
||||
loadingFolders={fileBrowser.loadingFolders}
|
||||
withCheckboxes={withCheckboxes}
|
||||
selectedPaths={selectedPaths}
|
||||
onSelectionChange={onSelectionChange}
|
||||
|
||||
@@ -3,7 +3,6 @@ import type { BackendType } from "~/schemas/volumes";
|
||||
|
||||
type VolumeIconProps = {
|
||||
backend: BackendType;
|
||||
size?: number;
|
||||
};
|
||||
|
||||
const getIconAndColor = (backend: BackendType) => {
|
||||
@@ -41,12 +40,12 @@ const getIconAndColor = (backend: BackendType) => {
|
||||
}
|
||||
};
|
||||
|
||||
export const VolumeIcon = ({ backend, size = 10 }: VolumeIconProps) => {
|
||||
export const VolumeIcon = ({ backend }: VolumeIconProps) => {
|
||||
const { icon: Icon, label } = getIconAndColor(backend);
|
||||
|
||||
return (
|
||||
<span className={`flex items-center gap-2 rounded-md px-2 py-1`}>
|
||||
<Icon size={size} />
|
||||
<Icon className="h-4 w-4" />
|
||||
{label}
|
||||
</span>
|
||||
);
|
||||
|
||||
135
app/client/hooks/use-file-browser.ts
Normal file
135
app/client/hooks/use-file-browser.ts
Normal file
@@ -0,0 +1,135 @@
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import type { FileEntry } from "../components/file-tree";
|
||||
|
||||
type FetchFolderFn = (
|
||||
path: string,
|
||||
) => Promise<{ files?: FileEntry[]; directories?: Array<{ name: string; path: string }> }>;
|
||||
|
||||
type PathTransformFns = {
|
||||
strip?: (path: string) => string;
|
||||
add?: (path: string) => string;
|
||||
};
|
||||
|
||||
type UseFileBrowserOptions = {
|
||||
initialData?: { files?: FileEntry[]; directories?: Array<{ name: string; path: string }> };
|
||||
isLoading?: boolean;
|
||||
fetchFolder: FetchFolderFn;
|
||||
prefetchFolder?: (path: string) => void;
|
||||
pathTransform?: PathTransformFns;
|
||||
rootPath?: string;
|
||||
};
|
||||
|
||||
export const useFileBrowser = (props: UseFileBrowserOptions) => {
|
||||
const { initialData, isLoading, fetchFolder, prefetchFolder, pathTransform, rootPath = "/" } = props;
|
||||
const [expandedFolders, setExpandedFolders] = useState<Set<string>>(new Set());
|
||||
const [fetchedFolders, setFetchedFolders] = useState<Set<string>>(new Set([rootPath]));
|
||||
const [loadingFolders, setLoadingFolders] = useState<Set<string>>(new Set());
|
||||
const [allFiles, setAllFiles] = useState<Map<string, FileEntry>>(new Map());
|
||||
|
||||
const stripPath = pathTransform?.strip;
|
||||
const addPath = pathTransform?.add;
|
||||
|
||||
useMemo(() => {
|
||||
if (initialData?.files) {
|
||||
const files = initialData.files;
|
||||
setAllFiles((prev) => {
|
||||
const next = new Map(prev);
|
||||
for (const file of files) {
|
||||
const path = stripPath ? stripPath(file.path) : file.path;
|
||||
if (path !== rootPath) {
|
||||
next.set(path, { ...file, path });
|
||||
}
|
||||
}
|
||||
return next;
|
||||
});
|
||||
if (rootPath) {
|
||||
setFetchedFolders((prev) => new Set(prev).add(rootPath));
|
||||
}
|
||||
} else if (initialData?.directories) {
|
||||
const directories = initialData.directories;
|
||||
setAllFiles((prev) => {
|
||||
const next = new Map(prev);
|
||||
for (const dir of directories) {
|
||||
next.set(dir.path, { name: dir.name, path: dir.path, type: "folder" });
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}
|
||||
}, [initialData, stripPath, rootPath]);
|
||||
|
||||
const fileArray = useMemo(() => Array.from(allFiles.values()), [allFiles]);
|
||||
|
||||
const handleFolderExpand = useCallback(
|
||||
async (folderPath: string) => {
|
||||
setExpandedFolders((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.add(folderPath);
|
||||
return next;
|
||||
});
|
||||
|
||||
if (!fetchedFolders.has(folderPath)) {
|
||||
setLoadingFolders((prev) => new Set(prev).add(folderPath));
|
||||
|
||||
try {
|
||||
const pathToFetch = addPath ? addPath(folderPath) : folderPath;
|
||||
const result = await fetchFolder(pathToFetch);
|
||||
|
||||
if (result.files) {
|
||||
const files = result.files;
|
||||
setAllFiles((prev) => {
|
||||
const next = new Map(prev);
|
||||
for (const file of files) {
|
||||
const strippedPath = stripPath ? stripPath(file.path) : file.path;
|
||||
// Skip the directory itself
|
||||
if (strippedPath !== folderPath) {
|
||||
next.set(strippedPath, { ...file, path: strippedPath });
|
||||
}
|
||||
}
|
||||
return next;
|
||||
});
|
||||
} else if (result.directories) {
|
||||
const directories = result.directories;
|
||||
setAllFiles((prev) => {
|
||||
const next = new Map(prev);
|
||||
for (const dir of directories) {
|
||||
next.set(dir.path, { name: dir.name, path: dir.path, type: "folder" });
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
setFetchedFolders((prev) => new Set(prev).add(folderPath));
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch folder contents:", error);
|
||||
} finally {
|
||||
setLoadingFolders((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.delete(folderPath);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
[fetchedFolders, fetchFolder, stripPath, addPath],
|
||||
);
|
||||
|
||||
const handleFolderHover = useCallback(
|
||||
(folderPath: string) => {
|
||||
if (!fetchedFolders.has(folderPath) && !loadingFolders.has(folderPath) && prefetchFolder) {
|
||||
const pathToPrefetch = addPath ? addPath(folderPath) : folderPath;
|
||||
prefetchFolder(pathToPrefetch);
|
||||
}
|
||||
},
|
||||
[fetchedFolders, loadingFolders, prefetchFolder, addPath],
|
||||
);
|
||||
|
||||
return {
|
||||
fileArray,
|
||||
expandedFolders,
|
||||
loadingFolders,
|
||||
handleFolderExpand,
|
||||
handleFolderHover,
|
||||
isLoading: isLoading && fileArray.length === 0,
|
||||
isEmpty: fileArray.length === 0 && !isLoading,
|
||||
};
|
||||
};
|
||||
@@ -9,7 +9,9 @@ type ServerEventType =
|
||||
| "backup:completed"
|
||||
| "volume:mounted"
|
||||
| "volume:unmounted"
|
||||
| "volume:updated";
|
||||
| "volume:updated"
|
||||
| "mirror:started"
|
||||
| "mirror:completed";
|
||||
|
||||
export interface BackupEvent {
|
||||
scheduleId: number;
|
||||
@@ -35,6 +37,14 @@ export interface VolumeEvent {
|
||||
volumeName: string;
|
||||
}
|
||||
|
||||
export interface MirrorEvent {
|
||||
scheduleId: number;
|
||||
repositoryId: string;
|
||||
repositoryName: string;
|
||||
status?: "success" | "error";
|
||||
error?: string;
|
||||
}
|
||||
|
||||
type EventHandler = (data: unknown) => void;
|
||||
|
||||
/**
|
||||
@@ -125,6 +135,27 @@ export function useServerEvents() {
|
||||
});
|
||||
});
|
||||
|
||||
eventSource.addEventListener("mirror:started", (e) => {
|
||||
const data = JSON.parse(e.data) as MirrorEvent;
|
||||
console.log("[SSE] Mirror copy started:", data);
|
||||
|
||||
handlersRef.current.get("mirror:started")?.forEach((handler) => {
|
||||
handler(data);
|
||||
});
|
||||
});
|
||||
|
||||
eventSource.addEventListener("mirror:completed", (e) => {
|
||||
const data = JSON.parse(e.data) as MirrorEvent;
|
||||
console.log("[SSE] Mirror copy completed:", data);
|
||||
|
||||
// Invalidate queries to refresh mirror status in the UI
|
||||
queryClient.invalidateQueries();
|
||||
|
||||
handlersRef.current.get("mirror:completed")?.forEach((handler) => {
|
||||
handler(data);
|
||||
});
|
||||
});
|
||||
|
||||
eventSource.onerror = (error) => {
|
||||
console.error("[SSE] Connection error:", error);
|
||||
};
|
||||
|
||||
@@ -3,6 +3,7 @@ import type {
|
||||
GetMeResponse,
|
||||
GetRepositoryResponse,
|
||||
GetVolumeResponse,
|
||||
ListNotificationDestinationsResponse,
|
||||
ListSnapshotsResponse,
|
||||
} from "../api-client";
|
||||
|
||||
@@ -17,3 +18,5 @@ export type Repository = GetRepositoryResponse;
|
||||
export type BackupSchedule = GetBackupScheduleResponse;
|
||||
|
||||
export type Snapshot = ListSnapshotsResponse[number];
|
||||
|
||||
export type NotificationDestination = ListNotificationDestinationsResponse[number];
|
||||
|
||||
1
app/client/lib/version.ts
Normal file
1
app/client/lib/version.ts
Normal file
@@ -0,0 +1 @@
|
||||
export const APP_VERSION = import.meta.env.VITE_APP_VERSION || "dev";
|
||||
@@ -16,7 +16,7 @@ export const clientMiddleware = [authMiddleware];
|
||||
|
||||
export function meta(_: Route.MetaArgs) {
|
||||
return [
|
||||
{ title: "Download Recovery Key" },
|
||||
{ title: "Zerobyte - Download Recovery Key" },
|
||||
{
|
||||
name: "description",
|
||||
content: "Download your backup recovery key to ensure you can restore your data.",
|
||||
|
||||
@@ -16,10 +16,10 @@ export const clientMiddleware = [authMiddleware];
|
||||
|
||||
export function meta(_: Route.MetaArgs) {
|
||||
return [
|
||||
{ title: "Login" },
|
||||
{ title: "Zerobyte - Login" },
|
||||
{
|
||||
name: "description",
|
||||
content: "Sign in to your Ironmount account.",
|
||||
content: "Sign in to your Zerobyte account.",
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
@@ -24,10 +24,10 @@ export const clientMiddleware = [authMiddleware];
|
||||
|
||||
export function meta(_: Route.MetaArgs) {
|
||||
return [
|
||||
{ title: "Onboarding" },
|
||||
{ title: "Zerobyte - Onboarding" },
|
||||
{
|
||||
name: "description",
|
||||
content: "Welcome to Ironmount. Create your admin account to get started.",
|
||||
content: "Welcome to Zerobyte. Create your admin account to get started.",
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -82,7 +82,7 @@ export default function OnboardingPage() {
|
||||
};
|
||||
|
||||
return (
|
||||
<AuthLayout title="Welcome to Ironmount" description="Create the admin user to get started">
|
||||
<AuthLayout title="Welcome to Zerobyte" description="Create the admin user to get started">
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
|
||||
<FormField
|
||||
|
||||
@@ -1,7 +1,4 @@
|
||||
import { cn } from "~/client/lib/utils";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "~/client/components/ui/tooltip";
|
||||
|
||||
type BackupStatus = "active" | "paused" | "error" | "in_progress";
|
||||
import { StatusDot } from "~/client/components/status-dot";
|
||||
|
||||
export const BackupStatusDot = ({
|
||||
enabled,
|
||||
@@ -12,60 +9,22 @@ export const BackupStatusDot = ({
|
||||
hasError?: boolean;
|
||||
isInProgress?: boolean;
|
||||
}) => {
|
||||
let status: BackupStatus = "paused";
|
||||
let variant: "success" | "neutral" | "error" | "info";
|
||||
let label: string;
|
||||
|
||||
if (isInProgress) {
|
||||
status = "in_progress";
|
||||
variant = "info";
|
||||
label = "Backup in progress";
|
||||
} else if (hasError) {
|
||||
status = "error";
|
||||
variant = "error";
|
||||
label = "Error";
|
||||
} else if (enabled) {
|
||||
status = "active";
|
||||
variant = "success";
|
||||
label = "Active";
|
||||
} else {
|
||||
variant = "neutral";
|
||||
label = "Paused";
|
||||
}
|
||||
|
||||
const statusMapping = {
|
||||
active: {
|
||||
color: "bg-green-500",
|
||||
colorLight: "bg-emerald-400",
|
||||
animated: true,
|
||||
label: "Active",
|
||||
},
|
||||
paused: {
|
||||
color: "bg-gray-500",
|
||||
colorLight: "bg-gray-400",
|
||||
animated: false,
|
||||
label: "Paused",
|
||||
},
|
||||
error: {
|
||||
color: "bg-red-500",
|
||||
colorLight: "bg-red-400",
|
||||
animated: true,
|
||||
label: "Error",
|
||||
},
|
||||
in_progress: {
|
||||
color: "bg-blue-500",
|
||||
colorLight: "bg-blue-400",
|
||||
animated: true,
|
||||
label: "Backup in progress",
|
||||
},
|
||||
}[status];
|
||||
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger>
|
||||
<span className="relative flex size-3 mx-auto">
|
||||
{statusMapping.animated && (
|
||||
<span
|
||||
className={cn(
|
||||
"absolute inline-flex h-full w-full animate-ping rounded-full opacity-75",
|
||||
`${statusMapping.colorLight}`,
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
<span className={cn("relative inline-flex size-3 rounded-full", `${statusMapping.color}`)} />
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>{statusMapping.label}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
return <StatusDot variant={variant} label={label} />;
|
||||
};
|
||||
|
||||
@@ -23,8 +23,11 @@ import type { BackupSchedule, Volume } from "~/client/lib/types";
|
||||
import { deepClean } from "~/utils/object";
|
||||
|
||||
const internalFormSchema = type({
|
||||
name: "1 <= string <= 32",
|
||||
repositoryId: "string",
|
||||
excludePatternsText: "string?",
|
||||
excludeIfPresentText: "string?",
|
||||
includePatternsText: "string?",
|
||||
includePatterns: "string[]?",
|
||||
frequency: "string",
|
||||
dailyTime: "string?",
|
||||
@@ -50,8 +53,12 @@ export const weeklyDays = [
|
||||
|
||||
type InternalFormValues = typeof internalFormSchema.infer;
|
||||
|
||||
export type BackupScheduleFormValues = Omit<InternalFormValues, "excludePatternsText"> & {
|
||||
export type BackupScheduleFormValues = Omit<
|
||||
InternalFormValues,
|
||||
"excludePatternsText" | "excludeIfPresentText" | "includePatternsText"
|
||||
> & {
|
||||
excludePatterns?: string[];
|
||||
excludeIfPresent?: string[];
|
||||
};
|
||||
|
||||
type Props = {
|
||||
@@ -79,13 +86,21 @@ const backupScheduleToFormValues = (schedule?: BackupSchedule): InternalFormValu
|
||||
|
||||
const weeklyDay = frequency === "weekly" ? dayOfWeekPart : undefined;
|
||||
|
||||
const patterns = schedule.includePatterns || [];
|
||||
const isGlobPattern = (p: string) => /[*?[\]]/.test(p);
|
||||
const fileBrowserPaths = patterns.filter((p) => !isGlobPattern(p));
|
||||
const textPatterns = patterns.filter(isGlobPattern);
|
||||
|
||||
return {
|
||||
name: schedule.name,
|
||||
repositoryId: schedule.repositoryId,
|
||||
frequency,
|
||||
dailyTime,
|
||||
weeklyDay,
|
||||
includePatterns: schedule.includePatterns || undefined,
|
||||
includePatterns: fileBrowserPaths.length > 0 ? fileBrowserPaths : undefined,
|
||||
includePatternsText: textPatterns.length > 0 ? textPatterns.join("\n") : undefined,
|
||||
excludePatternsText: schedule.excludePatterns?.join("\n") || undefined,
|
||||
excludeIfPresentText: schedule.excludeIfPresent?.join("\n") || undefined,
|
||||
...schedule.retentionPolicy,
|
||||
};
|
||||
};
|
||||
@@ -98,18 +113,40 @@ export const CreateScheduleForm = ({ initialValues, formId, onSubmit, volume }:
|
||||
|
||||
const handleSubmit = useCallback(
|
||||
(data: InternalFormValues) => {
|
||||
// Convert excludePatternsText string to excludePatterns array
|
||||
const { excludePatternsText, ...rest } = data;
|
||||
const {
|
||||
excludePatternsText,
|
||||
excludeIfPresentText,
|
||||
includePatternsText,
|
||||
includePatterns: fileBrowserPatterns,
|
||||
...rest
|
||||
} = data;
|
||||
const excludePatterns = excludePatternsText
|
||||
? excludePatternsText
|
||||
.split("\n")
|
||||
.map((p) => p.trim())
|
||||
.filter(Boolean)
|
||||
: undefined;
|
||||
: [];
|
||||
|
||||
const excludeIfPresent = excludeIfPresentText
|
||||
? excludeIfPresentText
|
||||
.split("\n")
|
||||
.map((p) => p.trim())
|
||||
.filter(Boolean)
|
||||
: [];
|
||||
|
||||
const textPatterns = includePatternsText
|
||||
? includePatternsText
|
||||
.split("\n")
|
||||
.map((p) => p.trim())
|
||||
.filter(Boolean)
|
||||
: [];
|
||||
const includePatterns = [...(fileBrowserPatterns || []), ...textPatterns];
|
||||
|
||||
onSubmit({
|
||||
...rest,
|
||||
includePatterns: includePatterns.length > 0 ? includePatterns : [],
|
||||
excludePatterns,
|
||||
excludeIfPresent,
|
||||
});
|
||||
},
|
||||
[onSubmit],
|
||||
@@ -148,6 +185,21 @@ export const CreateScheduleForm = ({ initialValues, formId, onSubmit, volume }:
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="grid gap-6 md:grid-cols-2">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem className="md:col-span-2">
|
||||
<FormLabel>Backup name</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="My backup" {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>A unique name to identify this backup schedule.</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="repositoryId"
|
||||
@@ -254,17 +306,18 @@ export const CreateScheduleForm = ({ initialValues, formId, onSubmit, volume }:
|
||||
<CardHeader>
|
||||
<CardTitle>Backup paths</CardTitle>
|
||||
<CardDescription>
|
||||
Select which folders to include in the backup. If no paths are selected, the entire volume will be
|
||||
backed up.
|
||||
Select which folders or files to include in the backup. If no paths are selected, the entire volume will
|
||||
be backed up.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<VolumeFileBrowser
|
||||
key={volume.id}
|
||||
volumeName={volume.name}
|
||||
selectedPaths={selectedPaths}
|
||||
onSelectionChange={handleSelectionChange}
|
||||
withCheckboxes={true}
|
||||
foldersOnly={true}
|
||||
foldersOnly={false}
|
||||
className="flex-1 border rounded-md bg-card p-2 min-h-[300px] max-h-[400px] overflow-auto"
|
||||
/>
|
||||
{selectedPaths.size > 0 && (
|
||||
@@ -279,6 +332,27 @@ export const CreateScheduleForm = ({ initialValues, formId, onSubmit, volume }:
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="includePatternsText"
|
||||
render={({ field }) => (
|
||||
<FormItem className="mt-6">
|
||||
<FormLabel>Additional include patterns</FormLabel>
|
||||
<FormControl>
|
||||
<Textarea
|
||||
{...field}
|
||||
placeholder="/data/** /config/*.json *.db"
|
||||
className="font-mono text-sm min-h-[100px]"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Optionally add custom include patterns using glob syntax. Enter one pattern per line. These will
|
||||
be combined with the paths selected above.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -320,6 +394,28 @@ export const CreateScheduleForm = ({ initialValues, formId, onSubmit, volume }:
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="excludeIfPresentText"
|
||||
render={({ field }) => (
|
||||
<FormItem className="mt-6">
|
||||
<FormLabel>Exclude if file present</FormLabel>
|
||||
<FormControl>
|
||||
<Textarea
|
||||
{...field}
|
||||
placeholder=".nobackup .exclude-from-backup CACHEDIR.TAG"
|
||||
className="font-mono text-sm min-h-20"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Exclude folders containing a file with the specified name. Enter one filename per line. For
|
||||
example, use <code className="bg-muted px-1 rounded">.nobackup</code> to skip any folder
|
||||
containing a <code className="bg-muted px-1 rounded">.nobackup</code> file.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -482,18 +578,27 @@ export const CreateScheduleForm = ({ initialValues, formId, onSubmit, volume }:
|
||||
{repositoriesData?.find((r) => r.id === formValues.repositoryId)?.name || "—"}
|
||||
</p>
|
||||
</div>
|
||||
{formValues.includePatterns && formValues.includePatterns.length > 0 && (
|
||||
{(formValues.includePatterns && formValues.includePatterns.length > 0) ||
|
||||
formValues.includePatternsText ? (
|
||||
<div>
|
||||
<p className="text-xs uppercase text-muted-foreground">Include paths</p>
|
||||
<p className="text-xs uppercase text-muted-foreground">Include paths/patterns</p>
|
||||
<div className="flex flex-col gap-1">
|
||||
{formValues.includePatterns.map((path) => (
|
||||
{formValues.includePatterns?.map((path) => (
|
||||
<span key={path} className="text-xs font-mono bg-accent px-1.5 py-0.5 rounded">
|
||||
{path}
|
||||
</span>
|
||||
))}
|
||||
{formValues.includePatternsText
|
||||
?.split("\n")
|
||||
.filter(Boolean)
|
||||
.map((pattern) => (
|
||||
<span key={pattern} className="text-xs font-mono bg-accent px-1.5 py-0.5 rounded">
|
||||
{pattern.trim()}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
) : null}
|
||||
{formValues.excludePatternsText && (
|
||||
<div>
|
||||
<p className="text-xs uppercase text-muted-foreground">Exclude patterns</p>
|
||||
@@ -509,6 +614,21 @@ export const CreateScheduleForm = ({ initialValues, formId, onSubmit, volume }:
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{formValues.excludeIfPresentText && (
|
||||
<div>
|
||||
<p className="text-xs uppercase text-muted-foreground">Exclude if present</p>
|
||||
<div className="flex flex-col gap-1">
|
||||
{formValues.excludeIfPresentText
|
||||
.split("\n")
|
||||
.filter(Boolean)
|
||||
.map((filename) => (
|
||||
<span key={filename} className="text-xs font-mono bg-accent px-1.5 py-0.5 rounded">
|
||||
{filename.trim()}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<p className="text-xs uppercase text-muted-foreground">Retention</p>
|
||||
<p className="font-medium">
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,267 @@
|
||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
import { Bell, Plus, Trash2 } from "lucide-react";
|
||||
import { useEffect, 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 {
|
||||
getScheduleNotificationsOptions,
|
||||
updateScheduleNotificationsMutation,
|
||||
} from "~/client/api-client/@tanstack/react-query.gen";
|
||||
import { parseError } from "~/client/lib/errors";
|
||||
import type { NotificationDestination } from "~/client/lib/types";
|
||||
|
||||
type Props = {
|
||||
scheduleId: number;
|
||||
destinations: NotificationDestination[];
|
||||
};
|
||||
|
||||
type NotificationAssignment = {
|
||||
destinationId: number;
|
||||
notifyOnStart: boolean;
|
||||
notifyOnSuccess: boolean;
|
||||
notifyOnFailure: boolean;
|
||||
};
|
||||
|
||||
export const ScheduleNotificationsConfig = ({ scheduleId, destinations }: Props) => {
|
||||
const [assignments, setAssignments] = useState<Map<number, NotificationAssignment>>(new Map());
|
||||
const [hasChanges, setHasChanges] = useState(false);
|
||||
const [isAddingNew, setIsAddingNew] = useState(false);
|
||||
|
||||
const { data: currentAssignments } = useQuery({
|
||||
...getScheduleNotificationsOptions({ path: { scheduleId: scheduleId.toString() } }),
|
||||
});
|
||||
|
||||
const updateNotifications = useMutation({
|
||||
...updateScheduleNotificationsMutation(),
|
||||
onSuccess: () => {
|
||||
toast.success("Notification settings saved successfully");
|
||||
setHasChanges(false);
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error("Failed to save notification settings", {
|
||||
description: parseError(error)?.message,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (currentAssignments) {
|
||||
const map = new Map<number, NotificationAssignment>();
|
||||
for (const assignment of currentAssignments) {
|
||||
map.set(assignment.destinationId, {
|
||||
destinationId: assignment.destinationId,
|
||||
notifyOnStart: assignment.notifyOnStart,
|
||||
notifyOnSuccess: assignment.notifyOnSuccess,
|
||||
notifyOnFailure: assignment.notifyOnFailure,
|
||||
});
|
||||
}
|
||||
|
||||
setAssignments(map);
|
||||
}
|
||||
}, [currentAssignments]);
|
||||
|
||||
const addDestination = (destinationId: string) => {
|
||||
const id = Number.parseInt(destinationId, 10);
|
||||
const newAssignments = new Map(assignments);
|
||||
newAssignments.set(id, {
|
||||
destinationId: id,
|
||||
notifyOnStart: false,
|
||||
notifyOnSuccess: false,
|
||||
notifyOnFailure: true,
|
||||
});
|
||||
|
||||
setAssignments(newAssignments);
|
||||
setHasChanges(true);
|
||||
setIsAddingNew(false);
|
||||
};
|
||||
|
||||
const removeDestination = (destinationId: number) => {
|
||||
const newAssignments = new Map(assignments);
|
||||
newAssignments.delete(destinationId);
|
||||
setAssignments(newAssignments);
|
||||
setHasChanges(true);
|
||||
};
|
||||
|
||||
const toggleEvent = (destinationId: number, event: "notifyOnStart" | "notifyOnSuccess" | "notifyOnFailure") => {
|
||||
const assignment = assignments.get(destinationId);
|
||||
if (!assignment) return;
|
||||
|
||||
const newAssignments = new Map(assignments);
|
||||
newAssignments.set(destinationId, {
|
||||
...assignment,
|
||||
[event]: !assignment[event],
|
||||
});
|
||||
|
||||
setAssignments(newAssignments);
|
||||
setHasChanges(true);
|
||||
};
|
||||
|
||||
const handleSave = () => {
|
||||
const assignmentsList = Array.from(assignments.values());
|
||||
updateNotifications.mutate({
|
||||
path: { scheduleId: scheduleId.toString() },
|
||||
body: {
|
||||
assignments: assignmentsList,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleReset = () => {
|
||||
if (currentAssignments) {
|
||||
const map = new Map<number, NotificationAssignment>();
|
||||
for (const assignment of currentAssignments) {
|
||||
map.set(assignment.destinationId, {
|
||||
destinationId: assignment.destinationId,
|
||||
notifyOnStart: assignment.notifyOnStart,
|
||||
notifyOnSuccess: assignment.notifyOnSuccess,
|
||||
notifyOnFailure: assignment.notifyOnFailure,
|
||||
});
|
||||
}
|
||||
setAssignments(map);
|
||||
setHasChanges(false);
|
||||
}
|
||||
};
|
||||
|
||||
const getDestinationById = (id: number) => {
|
||||
return destinations?.find((d) => d.id === id);
|
||||
};
|
||||
|
||||
const availableDestinations = destinations?.filter((d) => !assignments.has(d.id)) || [];
|
||||
const assignedDestinations = Array.from(assignments.keys())
|
||||
.map((id) => getDestinationById(id))
|
||||
.filter((d) => d !== undefined);
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Bell className="h-5 w-5" />
|
||||
Notifications
|
||||
</CardTitle>
|
||||
<CardDescription>Configure which notifications to send for this backup schedule</CardDescription>
|
||||
</div>
|
||||
{!isAddingNew && availableDestinations.length > 0 && (
|
||||
<Button variant="outline" size="sm" onClick={() => setIsAddingNew(true)}>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
Add notification
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{isAddingNew && (
|
||||
<div className="mb-6 flex items-center gap-2 max-w-md">
|
||||
<Select onValueChange={addDestination}>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder="Select a notification destination..." />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{availableDestinations.map((destination) => (
|
||||
<SelectItem key={destination.id} value={destination.id.toString()}>
|
||||
<div className="flex items-center gap-2">
|
||||
<span>{destination.name}</span>
|
||||
<span className="text-xs uppercase text-muted-foreground">({destination.type})</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button variant="ghost" size="sm" onClick={() => setIsAddingNew(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{assignedDestinations.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-8 text-center text-muted-foreground">
|
||||
<Bell className="h-8 w-8 mb-2 opacity-20" />
|
||||
<p className="text-sm">No notifications configured for this schedule.</p>
|
||||
<p className="text-xs mt-1">Click "Add notification" to get started.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-md border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Destination</TableHead>
|
||||
<TableHead className="text-center w-[100px]">Start</TableHead>
|
||||
<TableHead className="text-center w-[100px]">Success</TableHead>
|
||||
<TableHead className="text-center w-[100px]">Failure</TableHead>
|
||||
<TableHead className="w-[50px]"></TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{assignedDestinations.map((destination) => {
|
||||
const assignment = assignments.get(destination.id);
|
||||
if (!assignment) return null;
|
||||
|
||||
return (
|
||||
<TableRow key={destination.id}>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium">{destination.name}</span>
|
||||
<Badge variant="outline" className="text-[10px] align-middle">
|
||||
{destination.type}
|
||||
</Badge>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-center">
|
||||
<Switch
|
||||
className="align-middle"
|
||||
checked={assignment.notifyOnStart}
|
||||
onCheckedChange={() => toggleEvent(destination.id, "notifyOnStart")}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className="text-center">
|
||||
<Switch
|
||||
className="align-middle"
|
||||
checked={assignment.notifyOnSuccess}
|
||||
onCheckedChange={() => toggleEvent(destination.id, "notifyOnSuccess")}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className="text-center">
|
||||
<Switch
|
||||
className="align-middle"
|
||||
checked={assignment.notifyOnFailure}
|
||||
onCheckedChange={() => toggleEvent(destination.id, "notifyOnFailure")}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => removeDestination(destination.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={updateNotifications.isPending}>
|
||||
Save changes
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Pencil, Play, Square, Trash2 } from "lucide-react";
|
||||
import { Database, Eraser, HardDrive, Pencil, Play, Square, Trash2 } from "lucide-react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { OnOff } from "~/client/components/onoff";
|
||||
import { Button } from "~/client/components/ui/button";
|
||||
@@ -14,6 +14,11 @@ import {
|
||||
} from "~/client/components/ui/alert-dialog";
|
||||
import type { BackupSchedule } from "~/client/lib/types";
|
||||
import { BackupProgressCard } from "./backup-progress-card";
|
||||
import { runForgetMutation } from "~/client/api-client/@tanstack/react-query.gen";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
import { parseError } from "~/client/lib/errors";
|
||||
import { Link } from "react-router";
|
||||
|
||||
type Props = {
|
||||
schedule: BackupSchedule;
|
||||
@@ -28,6 +33,17 @@ export const ScheduleSummary = (props: Props) => {
|
||||
const { schedule, handleToggleEnabled, handleRunBackupNow, handleStopBackup, handleDeleteSchedule, setIsEditMode } =
|
||||
props;
|
||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
|
||||
const [showForgetConfirm, setShowForgetConfirm] = useState(false);
|
||||
|
||||
const runForget = useMutation({
|
||||
...runForgetMutation(),
|
||||
onSuccess: () => {
|
||||
toast.success("Retention policy applied successfully");
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error("Failed to apply retention policy", { description: parseError(error)?.message });
|
||||
},
|
||||
});
|
||||
|
||||
const summary = useMemo(() => {
|
||||
const scheduleLabel = schedule ? schedule.cronExpression : "-";
|
||||
@@ -56,16 +72,28 @@ export const ScheduleSummary = (props: Props) => {
|
||||
handleDeleteSchedule();
|
||||
};
|
||||
|
||||
const handleConfirmForget = () => {
|
||||
setShowForgetConfirm(false);
|
||||
runForget.mutate({ path: { scheduleId: schedule.id.toString() } });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<Card>
|
||||
<CardHeader className="space-y-4">
|
||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
||||
<div>
|
||||
<CardTitle>Backup schedule</CardTitle>
|
||||
<CardDescription>
|
||||
Automated backup configuration for volume
|
||||
<strong className="text-strong-accent">{schedule.volume.name}</strong>
|
||||
<CardTitle>{schedule.name}</CardTitle>
|
||||
<CardDescription className="mt-1">
|
||||
<Link to={`/volumes/${schedule.volume.name}`} className="hover:underline">
|
||||
<HardDrive className="inline h-4 w-4 mr-2" />
|
||||
<span>{schedule.volume.name}</span>
|
||||
</Link>
|
||||
<span className="mx-2">→</span>
|
||||
<Link to={`/repositories/${schedule.repository.name}`} className="hover:underline">
|
||||
<Database className="inline h-4 w-4 mr-2 text-strong-accent" />
|
||||
<span className="text-strong-accent">{schedule.repository.name}</span>
|
||||
</Link>
|
||||
</CardDescription>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 justify-between sm:justify-start">
|
||||
@@ -89,6 +117,18 @@ export const ScheduleSummary = (props: Props) => {
|
||||
<span className="sm:inline">Backup now</span>
|
||||
</Button>
|
||||
)}
|
||||
{schedule.retentionPolicy && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
loading={runForget.isPending}
|
||||
onClick={() => setShowForgetConfirm(true)}
|
||||
className="w-full sm:w-auto"
|
||||
>
|
||||
<Eraser className="h-4 w-4 mr-2" />
|
||||
<span className="sm:inline">Run cleanup</span>
|
||||
</Button>
|
||||
)}
|
||||
<Button variant="outline" size="sm" onClick={() => setIsEditMode(true)} className="w-full sm:w-auto">
|
||||
<Pencil className="h-4 w-4 mr-2" />
|
||||
<span className="sm:inline">Edit schedule</span>
|
||||
@@ -132,10 +172,20 @@ export const ScheduleSummary = (props: Props) => {
|
||||
{schedule.lastBackupStatus === "success" && "✓ Success"}
|
||||
{schedule.lastBackupStatus === "error" && "✗ Error"}
|
||||
{schedule.lastBackupStatus === "in_progress" && "⟳ in progress..."}
|
||||
{schedule.lastBackupStatus === "warning" && "! Warning"}
|
||||
{!schedule.lastBackupStatus && "—"}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{schedule.lastBackupStatus === "warning" && (
|
||||
<div className="md:col-span-2 lg:col-span-4">
|
||||
<p className="text-xs uppercase text-muted-foreground">Warning Details</p>
|
||||
<p className="font-mono text-sm text-yellow-600 whitespace-pre-wrap break-all">
|
||||
Last backup completed with warnings. Check your container logs for more details.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{schedule.lastBackupError && (
|
||||
<div className="md:col-span-2 lg:col-span-4">
|
||||
<p className="text-xs uppercase text-muted-foreground">Error Details</p>
|
||||
@@ -167,6 +217,22 @@ export const ScheduleSummary = (props: Props) => {
|
||||
</div>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
|
||||
<AlertDialog open={showForgetConfirm} onOpenChange={setShowForgetConfirm}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Run retention policy cleanup?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
This will apply the retention policy and permanently delete old snapshots according to the configured
|
||||
rules ({summary.retentionLabel}). This action cannot be undone.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<div className="flex gap-3 justify-end">
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={handleConfirmForget}>Run cleanup</AlertDialogAction>
|
||||
</div>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,45 +1,26 @@
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useCallback } from "react";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { FileIcon } from "lucide-react";
|
||||
import { FileTree, type FileEntry } from "~/client/components/file-tree";
|
||||
import { Link } from "react-router";
|
||||
import { FileTree } from "~/client/components/file-tree";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "~/client/components/ui/card";
|
||||
import { Button } from "~/client/components/ui/button";
|
||||
import { Checkbox } from "~/client/components/ui/checkbox";
|
||||
import { Label } from "~/client/components/ui/label";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "~/client/components/ui/alert-dialog";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "~/client/components/ui/tooltip";
|
||||
import type { Snapshot, Volume } from "~/client/lib/types";
|
||||
import { toast } from "sonner";
|
||||
import { listSnapshotFilesOptions, restoreSnapshotMutation } from "~/client/api-client/@tanstack/react-query.gen";
|
||||
import { Button, buttonVariants } from "~/client/components/ui/button";
|
||||
import type { Snapshot } from "~/client/lib/types";
|
||||
import { listSnapshotFilesOptions } from "~/client/api-client/@tanstack/react-query.gen";
|
||||
import { useFileBrowser } from "~/client/hooks/use-file-browser";
|
||||
|
||||
interface Props {
|
||||
snapshot: Snapshot;
|
||||
repositoryName: string;
|
||||
volume?: Volume;
|
||||
backupId?: string;
|
||||
onDeleteSnapshot?: (snapshotId: string) => void;
|
||||
isDeletingSnapshot?: boolean;
|
||||
}
|
||||
|
||||
export const SnapshotFileBrowser = (props: Props) => {
|
||||
const { snapshot, repositoryName, volume } = props;
|
||||
|
||||
const isReadOnly = volume?.config && "readOnly" in volume.config && volume.config.readOnly === true;
|
||||
const { snapshot, repositoryName, backupId, onDeleteSnapshot, isDeletingSnapshot } = props;
|
||||
|
||||
const queryClient = useQueryClient();
|
||||
const [expandedFolders, setExpandedFolders] = useState<Set<string>>(new Set());
|
||||
const [fetchedFolders, setFetchedFolders] = useState<Set<string>>(new Set());
|
||||
const [loadingFolders, setLoadingFolders] = useState<Set<string>>(new Set());
|
||||
const [allFiles, setAllFiles] = useState<Map<string, FileEntry>>(new Map());
|
||||
const [selectedPaths, setSelectedPaths] = useState<Set<string>>(new Set());
|
||||
const [showRestoreDialog, setShowRestoreDialog] = useState(false);
|
||||
const [deleteExtraFiles, setDeleteExtraFiles] = useState(false);
|
||||
|
||||
const volumeBasePath = snapshot.paths[0]?.match(/^(.*?_data)(\/|$)/)?.[1] || "/";
|
||||
|
||||
@@ -65,130 +46,40 @@ export const SnapshotFileBrowser = (props: Props) => {
|
||||
|
||||
const addBasePath = useCallback(
|
||||
(displayPath: string): string => {
|
||||
if (!volumeBasePath) return displayPath;
|
||||
if (displayPath === "/") return volumeBasePath;
|
||||
return `${volumeBasePath}${displayPath}`;
|
||||
const vbp = volumeBasePath === "/" ? "" : volumeBasePath;
|
||||
|
||||
if (!vbp) return displayPath;
|
||||
if (displayPath === "/") return vbp;
|
||||
return `${vbp}${displayPath}`;
|
||||
},
|
||||
[volumeBasePath],
|
||||
);
|
||||
|
||||
useMemo(() => {
|
||||
if (filesData?.files) {
|
||||
setAllFiles((prev) => {
|
||||
const next = new Map(prev);
|
||||
for (const file of filesData.files) {
|
||||
const strippedPath = stripBasePath(file.path);
|
||||
if (strippedPath !== "/") {
|
||||
next.set(strippedPath, { ...file, path: strippedPath });
|
||||
}
|
||||
}
|
||||
return next;
|
||||
});
|
||||
setFetchedFolders((prev) => new Set(prev).add("/"));
|
||||
}
|
||||
}, [filesData, stripBasePath]);
|
||||
|
||||
const fileArray = useMemo(() => Array.from(allFiles.values()), [allFiles]);
|
||||
|
||||
const handleFolderExpand = useCallback(
|
||||
async (folderPath: string) => {
|
||||
setExpandedFolders((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.add(folderPath);
|
||||
return next;
|
||||
});
|
||||
|
||||
if (!fetchedFolders.has(folderPath)) {
|
||||
setLoadingFolders((prev) => new Set(prev).add(folderPath));
|
||||
|
||||
try {
|
||||
const fullPath = addBasePath(folderPath);
|
||||
|
||||
const result = await queryClient.ensureQueryData(
|
||||
const fileBrowser = useFileBrowser({
|
||||
initialData: filesData,
|
||||
isLoading: filesLoading,
|
||||
fetchFolder: async (path) => {
|
||||
return await queryClient.ensureQueryData(
|
||||
listSnapshotFilesOptions({
|
||||
path: { name: repositoryName, snapshotId: snapshot.short_id },
|
||||
query: { path: fullPath },
|
||||
query: { path },
|
||||
}),
|
||||
);
|
||||
|
||||
if (result.files) {
|
||||
setAllFiles((prev) => {
|
||||
const next = new Map(prev);
|
||||
for (const file of result.files) {
|
||||
const strippedPath = stripBasePath(file.path);
|
||||
// Skip the directory itself
|
||||
if (strippedPath !== folderPath) {
|
||||
next.set(strippedPath, { ...file, path: strippedPath });
|
||||
}
|
||||
}
|
||||
return next;
|
||||
});
|
||||
|
||||
setFetchedFolders((prev) => new Set(prev).add(folderPath));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch folder contents:", error);
|
||||
} finally {
|
||||
setLoadingFolders((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.delete(folderPath);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
[repositoryName, snapshot, fetchedFolders, queryClient, stripBasePath, addBasePath],
|
||||
);
|
||||
|
||||
const handleFolderHover = useCallback(
|
||||
(folderPath: string) => {
|
||||
if (!fetchedFolders.has(folderPath) && !loadingFolders.has(folderPath)) {
|
||||
const fullPath = addBasePath(folderPath);
|
||||
|
||||
queryClient.prefetchQuery({
|
||||
...listSnapshotFilesOptions({
|
||||
prefetchFolder: (path) => {
|
||||
queryClient.prefetchQuery(
|
||||
listSnapshotFilesOptions({
|
||||
path: { name: repositoryName, snapshotId: snapshot.short_id },
|
||||
query: { path: fullPath },
|
||||
query: { path },
|
||||
}),
|
||||
});
|
||||
}
|
||||
},
|
||||
[repositoryName, snapshot, fetchedFolders, loadingFolders, queryClient, 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.`,
|
||||
});
|
||||
setSelectedPaths(new Set());
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error("Restore failed", { description: error.message || "Failed to restore snapshot" });
|
||||
pathTransform: {
|
||||
strip: stripBasePath,
|
||||
add: addBasePath,
|
||||
},
|
||||
});
|
||||
|
||||
const handleRestoreClick = useCallback(() => {
|
||||
setShowRestoreDialog(true);
|
||||
}, []);
|
||||
|
||||
const handleConfirmRestore = useCallback(() => {
|
||||
const pathsArray = Array.from(selectedPaths);
|
||||
const includePaths = pathsArray.map((path) => addBasePath(path));
|
||||
|
||||
restoreSnapshot({
|
||||
path: { name: repositoryName },
|
||||
body: {
|
||||
snapshotId: snapshot.short_id,
|
||||
include: includePaths,
|
||||
delete: deleteExtraFiles,
|
||||
},
|
||||
});
|
||||
|
||||
setShowRestoreDialog(false);
|
||||
}, [selectedPaths, addBasePath, repositoryName, snapshot.short_id, restoreSnapshot, deleteExtraFiles]);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<Card className="h-[600px] flex flex-col">
|
||||
@@ -198,91 +89,59 @@ export const SnapshotFileBrowser = (props: Props) => {
|
||||
<CardTitle>File Browser</CardTitle>
|
||||
<CardDescription>{`Viewing snapshot from ${new Date(snapshot?.time ?? 0).toLocaleString()}`}</CardDescription>
|
||||
</div>
|
||||
{selectedPaths.size > 0 && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span tabIndex={isReadOnly ? 0 : undefined}>
|
||||
<Button
|
||||
onClick={handleRestoreClick}
|
||||
variant="primary"
|
||||
size="sm"
|
||||
disabled={isRestoring || isReadOnly}
|
||||
<div className="flex gap-2">
|
||||
<Link
|
||||
to={
|
||||
backupId
|
||||
? `/backups/${backupId}/${snapshot.short_id}/restore`
|
||||
: `/repositories/${repositoryName}/${snapshot.short_id}/restore`
|
||||
}
|
||||
className={buttonVariants({ variant: "primary", size: "sm" })}
|
||||
>
|
||||
{isRestoring
|
||||
? "Restoring..."
|
||||
: `Restore ${selectedPaths.size} selected ${selectedPaths.size === 1 ? "item" : "items"}`}
|
||||
Restore
|
||||
</Link>
|
||||
{onDeleteSnapshot && (
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
onClick={() => onDeleteSnapshot(snapshot.short_id)}
|
||||
disabled={isDeletingSnapshot}
|
||||
loading={isDeletingSnapshot}
|
||||
>
|
||||
{isDeletingSnapshot ? "Deleting..." : "Delete Snapshot"}
|
||||
</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>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="flex-1 overflow-hidden flex flex-col p-0">
|
||||
{filesLoading && fileArray.length === 0 && (
|
||||
{fileBrowser.isLoading && (
|
||||
<div className="flex items-center justify-center flex-1">
|
||||
<p className="text-muted-foreground">Loading files...</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{fileArray.length === 0 && !filesLoading && (
|
||||
{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>
|
||||
)}
|
||||
|
||||
{fileArray.length > 0 && (
|
||||
{!fileBrowser.isLoading && !fileBrowser.isEmpty && (
|
||||
<div className="overflow-auto flex-1 border border-border rounded-md bg-card m-4">
|
||||
<FileTree
|
||||
files={fileArray}
|
||||
onFolderExpand={handleFolderExpand}
|
||||
onFolderHover={handleFolderHover}
|
||||
expandedFolders={expandedFolders}
|
||||
loadingFolders={loadingFolders}
|
||||
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>
|
||||
|
||||
<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="flex items-center space-x-2 py-4">
|
||||
<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>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={handleConfirmRestore}>Confirm</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -3,6 +3,16 @@ import { useQuery, useMutation } from "@tanstack/react-query";
|
||||
import { redirect, useNavigate } from "react-router";
|
||||
import { toast } from "sonner";
|
||||
import { Button } from "~/client/components/ui/button";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "~/client/components/ui/alert-dialog";
|
||||
import {
|
||||
getBackupScheduleOptions,
|
||||
runBackupNowMutation,
|
||||
@@ -10,6 +20,7 @@ import {
|
||||
listSnapshotsOptions,
|
||||
updateBackupScheduleMutation,
|
||||
stopBackupMutation,
|
||||
deleteSnapshotMutation,
|
||||
} from "~/client/api-client/@tanstack/react-query.gen";
|
||||
import { parseError } from "~/client/lib/errors";
|
||||
import { getCronExpression } from "~/utils/utils";
|
||||
@@ -18,18 +29,21 @@ import { ScheduleSummary } from "../components/schedule-summary";
|
||||
import type { Route } from "./+types/backup-details";
|
||||
import { SnapshotFileBrowser } from "../components/snapshot-file-browser";
|
||||
import { SnapshotTimeline } from "../components/snapshot-timeline";
|
||||
import { getBackupSchedule } from "~/client/api-client";
|
||||
import { getBackupSchedule, listNotificationDestinations, listRepositories } from "~/client/api-client";
|
||||
import { ScheduleNotificationsConfig } from "../components/schedule-notifications-config";
|
||||
import { ScheduleMirrorsConfig } from "../components/schedule-mirrors-config";
|
||||
import { cn } from "~/client/lib/utils";
|
||||
|
||||
export const handle = {
|
||||
breadcrumb: (match: Route.MetaArgs) => [
|
||||
{ label: "Backups", href: "/backups" },
|
||||
{ label: `Schedule #${match.params.id}` },
|
||||
],
|
||||
breadcrumb: (match: Route.MetaArgs) => {
|
||||
const data = match.loaderData;
|
||||
return [{ label: "Backups", href: "/backups" }, { label: data.schedule.name }];
|
||||
},
|
||||
};
|
||||
|
||||
export function meta(_: Route.MetaArgs) {
|
||||
return [
|
||||
{ title: "Backup Job Details" },
|
||||
{ title: "Zerobyte - Backup Job Details" },
|
||||
{
|
||||
name: "description",
|
||||
content: "View and manage backup job configuration, schedule, and snapshots.",
|
||||
@@ -38,11 +52,13 @@ export function meta(_: Route.MetaArgs) {
|
||||
}
|
||||
|
||||
export const clientLoader = async ({ params }: Route.LoaderArgs) => {
|
||||
const { data } = await getBackupSchedule({ path: { scheduleId: params.id } });
|
||||
const schedule = await getBackupSchedule({ path: { scheduleId: params.id } });
|
||||
const notifs = await listNotificationDestinations();
|
||||
const repos = await listRepositories();
|
||||
|
||||
if (!data) return redirect("/backups");
|
||||
if (!schedule.data) return redirect("/backups");
|
||||
|
||||
return data;
|
||||
return { schedule: schedule.data, notifs: notifs.data, repos: repos.data };
|
||||
};
|
||||
|
||||
export default function ScheduleDetailsPage({ params, loaderData }: Route.ComponentProps) {
|
||||
@@ -50,12 +66,12 @@ export default function ScheduleDetailsPage({ params, loaderData }: Route.Compon
|
||||
const [isEditMode, setIsEditMode] = useState(false);
|
||||
const formId = useId();
|
||||
const [selectedSnapshotId, setSelectedSnapshotId] = useState<string>();
|
||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
|
||||
const [snapshotToDelete, setSnapshotToDelete] = useState<string | null>(null);
|
||||
|
||||
const { data: schedule } = useQuery({
|
||||
...getBackupScheduleOptions({ path: { scheduleId: params.id } }),
|
||||
initialData: loaderData,
|
||||
refetchInterval: 10000,
|
||||
refetchOnWindowFocus: true,
|
||||
initialData: loaderData.schedule,
|
||||
});
|
||||
|
||||
const {
|
||||
@@ -110,6 +126,17 @@ export default function ScheduleDetailsPage({ params, loaderData }: Route.Compon
|
||||
},
|
||||
});
|
||||
|
||||
const deleteSnapshot = useMutation({
|
||||
...deleteSnapshotMutation(),
|
||||
onSuccess: () => {
|
||||
setShowDeleteConfirm(false);
|
||||
setSnapshotToDelete(null);
|
||||
if (selectedSnapshotId === snapshotToDelete) {
|
||||
setSelectedSnapshotId(undefined);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const handleSubmit = (formValues: BackupScheduleFormValues) => {
|
||||
if (!schedule) return;
|
||||
|
||||
@@ -126,12 +153,14 @@ export default function ScheduleDetailsPage({ params, loaderData }: Route.Compon
|
||||
updateSchedule.mutate({
|
||||
path: { scheduleId: schedule.id.toString() },
|
||||
body: {
|
||||
name: formValues.name,
|
||||
repositoryId: formValues.repositoryId,
|
||||
enabled: schedule.enabled,
|
||||
cronExpression,
|
||||
retentionPolicy: Object.keys(retentionPolicy).length > 0 ? retentionPolicy : undefined,
|
||||
includePatterns: formValues.includePatterns,
|
||||
excludePatterns: formValues.excludePatterns,
|
||||
excludeIfPresent: formValues.excludeIfPresent,
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -144,12 +173,33 @@ export default function ScheduleDetailsPage({ params, loaderData }: Route.Compon
|
||||
enabled,
|
||||
cronExpression: schedule.cronExpression,
|
||||
retentionPolicy: schedule.retentionPolicy || undefined,
|
||||
includePatterns: schedule.includePatterns || undefined,
|
||||
excludePatterns: schedule.excludePatterns || undefined,
|
||||
includePatterns: schedule.includePatterns || [],
|
||||
excludePatterns: schedule.excludePatterns || [],
|
||||
excludeIfPresent: schedule.excludeIfPresent || [],
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleDeleteSnapshot = (snapshotId: string) => {
|
||||
setSnapshotToDelete(snapshotId);
|
||||
setShowDeleteConfirm(true);
|
||||
};
|
||||
|
||||
const handleConfirmDelete = () => {
|
||||
if (snapshotToDelete) {
|
||||
toast.promise(
|
||||
deleteSnapshot.mutateAsync({
|
||||
path: { name: schedule.repository.name, snapshotId: snapshotToDelete },
|
||||
}),
|
||||
{
|
||||
loading: "Deleting snapshot...",
|
||||
success: "Snapshot deleted successfully",
|
||||
error: (error) => parseError(error)?.message || "Failed to delete snapshot",
|
||||
},
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
if (isEditMode) {
|
||||
return (
|
||||
<div>
|
||||
@@ -178,6 +228,16 @@ export default function ScheduleDetailsPage({ params, loaderData }: Route.Compon
|
||||
setIsEditMode={setIsEditMode}
|
||||
schedule={schedule}
|
||||
/>
|
||||
<div className={cn({ hidden: !loaderData.notifs?.length })}>
|
||||
<ScheduleNotificationsConfig scheduleId={schedule.id} destinations={loaderData.notifs ?? []} />
|
||||
</div>
|
||||
<div className={cn({ hidden: !loaderData.repos?.length || loaderData.repos.length < 2 })}>
|
||||
<ScheduleMirrorsConfig
|
||||
scheduleId={schedule.id}
|
||||
primaryRepositoryId={schedule.repositoryId}
|
||||
repositories={loaderData.repos ?? []}
|
||||
/>
|
||||
</div>
|
||||
<SnapshotTimeline
|
||||
loading={isLoading}
|
||||
snapshots={snapshots ?? []}
|
||||
@@ -190,9 +250,33 @@ export default function ScheduleDetailsPage({ params, loaderData }: Route.Compon
|
||||
key={selectedSnapshot?.short_id}
|
||||
snapshot={selectedSnapshot}
|
||||
repositoryName={schedule.repository.name}
|
||||
volume={schedule.volume}
|
||||
backupId={schedule.id.toString()}
|
||||
onDeleteSnapshot={handleDeleteSnapshot}
|
||||
isDeletingSnapshot={deleteSnapshot.isPending}
|
||||
/>
|
||||
)}
|
||||
|
||||
<AlertDialog open={showDeleteConfirm} onOpenChange={setShowDeleteConfirm}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Delete snapshot?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
This action cannot be undone. This will permanently delete the snapshot and all its data from the
|
||||
repository.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={handleConfirmDelete}
|
||||
disabled={deleteSnapshot.isPending}
|
||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
>
|
||||
Delete snapshot
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@ export const handle = {
|
||||
|
||||
export function meta(_: Route.MetaArgs) {
|
||||
return [
|
||||
{ title: "Backup Jobs" },
|
||||
{ title: "Zerobyte - Backup Jobs" },
|
||||
{
|
||||
name: "description",
|
||||
content: "Automate volume backups with scheduled jobs and retention policies.",
|
||||
@@ -33,8 +33,6 @@ export default function Backups({ loaderData }: Route.ComponentProps) {
|
||||
const { data: schedules, isLoading } = useQuery({
|
||||
...listBackupSchedulesOptions(),
|
||||
initialData: loaderData,
|
||||
refetchInterval: 10000,
|
||||
refetchOnWindowFocus: true,
|
||||
});
|
||||
|
||||
if (isLoading) {
|
||||
@@ -69,13 +67,11 @@ export default function Backups({ loaderData }: Route.ComponentProps) {
|
||||
{schedules.map((schedule) => (
|
||||
<Link key={schedule.id} to={`/backups/${schedule.id}`}>
|
||||
<Card key={schedule.id} className="flex flex-col h-full">
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="flex items-center gap-2 flex-1 min-w-0">
|
||||
<HardDrive className="h-5 w-5 text-muted-foreground shrink-0" />
|
||||
<CardTitle className="text-lg truncate">
|
||||
Volume <span className="text-strong-accent">{schedule.volume.name}</span>
|
||||
</CardTitle>
|
||||
<CardHeader className="pb-3 overflow-hidden">
|
||||
<div className="flex items-center justify-between gap-2 w-full">
|
||||
<div className="flex items-center gap-2 flex-1 min-w-0 w-0">
|
||||
<CalendarClock className="h-5 w-5 text-muted-foreground shrink-0" />
|
||||
<CardTitle className="text-lg truncate">{schedule.name}</CardTitle>
|
||||
</div>
|
||||
<BackupStatusDot
|
||||
enabled={schedule.enabled}
|
||||
@@ -83,9 +79,12 @@ export default function Backups({ loaderData }: Route.ComponentProps) {
|
||||
isInProgress={schedule.lastBackupStatus === "in_progress"}
|
||||
/>
|
||||
</div>
|
||||
<CardDescription className="flex items-center gap-2 mt-2">
|
||||
<Database className="h-4 w-4" />
|
||||
<span className="truncate">{schedule.repository.name}</span>
|
||||
<CardDescription className="ml-0.5 flex items-center gap-2 text-xs">
|
||||
<HardDrive className="h-3.5 w-3.5" />
|
||||
<span className="truncate">{schedule.volume.name}</span>
|
||||
<span className="text-muted-foreground">→</span>
|
||||
<Database className="h-3.5 w-3.5 text-strong-accent" />
|
||||
<span className="truncate text-strong-accent">{schedule.repository.name}</span>
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="flex-1 space-y-4">
|
||||
|
||||
@@ -24,7 +24,7 @@ export const handle = {
|
||||
|
||||
export function meta(_: Route.MetaArgs) {
|
||||
return [
|
||||
{ title: "Create Backup Job" },
|
||||
{ title: "Zerobyte - Create Backup Job" },
|
||||
{
|
||||
name: "description",
|
||||
content: "Create a new automated backup job for your volumes.",
|
||||
@@ -83,6 +83,7 @@ export default function CreateBackup({ loaderData }: Route.ComponentProps) {
|
||||
|
||||
createSchedule.mutate({
|
||||
body: {
|
||||
name: formValues.name,
|
||||
volumeId: selectedVolumeId,
|
||||
repositoryId: formValues.repositoryId,
|
||||
enabled: true,
|
||||
@@ -90,6 +91,7 @@ export default function CreateBackup({ loaderData }: Route.ComponentProps) {
|
||||
retentionPolicy: Object.keys(retentionPolicy).length > 0 ? retentionPolicy : undefined,
|
||||
includePatterns: formValues.includePatterns,
|
||||
excludePatterns: formValues.excludePatterns,
|
||||
excludeIfPresent: formValues.excludeIfPresent,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
54
app/client/modules/backups/routes/restore-snapshot.tsx
Normal file
54
app/client/modules/backups/routes/restore-snapshot.tsx
Normal 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}`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,689 @@
|
||||
import { arktypeResolver } from "@hookform/resolvers/arktype";
|
||||
import { type } from "arktype";
|
||||
import { useEffect } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { cn, slugify } from "~/client/lib/utils";
|
||||
import { deepClean } from "~/utils/object";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "~/client/components/ui/form";
|
||||
import { Input } from "~/client/components/ui/input";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "~/client/components/ui/select";
|
||||
import { Checkbox } from "~/client/components/ui/checkbox";
|
||||
import { notificationConfigSchema } from "~/schemas/notifications";
|
||||
|
||||
export const formSchema = type({
|
||||
name: "2<=string<=32",
|
||||
}).and(notificationConfigSchema);
|
||||
const cleanSchema = type.pipe((d) => formSchema(deepClean(d)));
|
||||
|
||||
export type NotificationFormValues = typeof formSchema.inferIn;
|
||||
|
||||
type Props = {
|
||||
onSubmit: (values: NotificationFormValues) => void;
|
||||
mode?: "create" | "update";
|
||||
initialValues?: Partial<NotificationFormValues>;
|
||||
formId?: string;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
const defaultValuesForType = {
|
||||
email: {
|
||||
type: "email" as const,
|
||||
smtpHost: "",
|
||||
smtpPort: 587,
|
||||
username: "",
|
||||
password: "",
|
||||
from: "",
|
||||
to: [],
|
||||
useTLS: true,
|
||||
},
|
||||
slack: {
|
||||
type: "slack" as const,
|
||||
webhookUrl: "",
|
||||
},
|
||||
discord: {
|
||||
type: "discord" as const,
|
||||
webhookUrl: "",
|
||||
},
|
||||
gotify: {
|
||||
type: "gotify" as const,
|
||||
serverUrl: "",
|
||||
token: "",
|
||||
priority: 5,
|
||||
},
|
||||
ntfy: {
|
||||
type: "ntfy" as const,
|
||||
topic: "",
|
||||
priority: "default" as const,
|
||||
},
|
||||
pushover: {
|
||||
type: "pushover" as const,
|
||||
userKey: "",
|
||||
apiToken: "",
|
||||
priority: 0 as const,
|
||||
},
|
||||
telegram: {
|
||||
type: "telegram" as const,
|
||||
botToken: "",
|
||||
chatId: "",
|
||||
},
|
||||
custom: {
|
||||
type: "custom" as const,
|
||||
shoutrrrUrl: "",
|
||||
},
|
||||
};
|
||||
|
||||
export const CreateNotificationForm = ({ onSubmit, mode = "create", initialValues, formId, className }: Props) => {
|
||||
const form = useForm<NotificationFormValues>({
|
||||
resolver: arktypeResolver(cleanSchema as unknown as typeof formSchema),
|
||||
defaultValues: initialValues,
|
||||
resetOptions: {
|
||||
keepDefaultValues: true,
|
||||
keepDirtyValues: false,
|
||||
},
|
||||
});
|
||||
|
||||
const { watch } = form;
|
||||
const watchedType = watch("type");
|
||||
|
||||
useEffect(() => {
|
||||
if (!initialValues) {
|
||||
form.reset({
|
||||
name: form.getValues().name,
|
||||
...defaultValuesForType[watchedType as keyof typeof defaultValuesForType],
|
||||
});
|
||||
}
|
||||
}, [watchedType, form, initialValues]);
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form id={formId} onSubmit={form.handleSubmit(onSubmit)} className={cn("space-y-4", className)}>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Name</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
placeholder="My notification"
|
||||
onChange={(e) => field.onChange(slugify(e.target.value))}
|
||||
max={32}
|
||||
min={2}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>Unique identifier for this notification destination.</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="type"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Type</FormLabel>
|
||||
<Select
|
||||
onValueChange={field.onChange}
|
||||
defaultValue={field.value}
|
||||
value={field.value}
|
||||
disabled={mode === "update"}
|
||||
>
|
||||
<FormControl>
|
||||
<SelectTrigger className={mode === "update" ? "bg-gray-50" : ""}>
|
||||
<SelectValue placeholder="Select notification type" />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
<SelectItem value="email">Email (SMTP)</SelectItem>
|
||||
<SelectItem value="slack">Slack</SelectItem>
|
||||
<SelectItem value="discord">Discord</SelectItem>
|
||||
<SelectItem value="gotify">Gotify</SelectItem>
|
||||
<SelectItem value="ntfy">Ntfy</SelectItem>
|
||||
<SelectItem value="pushover">Pushover</SelectItem>
|
||||
<SelectItem value="telegram">Telegram</SelectItem>
|
||||
<SelectItem value="custom">Custom (Shoutrrr URL)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormDescription>Choose the notification delivery method.</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{watchedType === "email" && (
|
||||
<>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="smtpHost"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>SMTP Host</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} placeholder="smtp.example.com" />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="smtpPort"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>SMTP Port</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
type="number"
|
||||
placeholder="587"
|
||||
onChange={(e) => field.onChange(Number(e.target.value))}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="username"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Username</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} placeholder="user@example.com" />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="password"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Password</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} type="password" placeholder="••••••••" />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="from"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>From Address</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} placeholder="noreply@example.com" />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="to"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>To Addresses</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
placeholder="user@example.com, admin@example.com"
|
||||
value={Array.isArray(field.value) ? field.value.join(", ") : ""}
|
||||
onChange={(e) => field.onChange(e.target.value.split(",").map((email) => email.trim()))}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>Comma-separated list of recipient email addresses.</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="useTLS"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex flex-row items-center space-x-3">
|
||||
<FormControl>
|
||||
<Checkbox checked={field.value} onCheckedChange={field.onChange} />
|
||||
</FormControl>
|
||||
<div className="space-y-1 leading-none">
|
||||
<FormLabel>Use TLS</FormLabel>
|
||||
<FormDescription>Enable TLS encryption for SMTP connection.</FormDescription>
|
||||
</div>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{watchedType === "slack" && (
|
||||
<>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="webhookUrl"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Webhook URL</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
placeholder="https://hooks.slack.com/services/T00000000/B00000000/XXXXXXXXXXXXXXXXXXXX"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>Get this from your Slack app's Incoming Webhooks settings.</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="channel"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Channel (Optional)</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} placeholder="#backups" />
|
||||
</FormControl>
|
||||
<FormDescription>Override the default channel (use # for channels, @ for users).</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="username"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Bot Username (Optional)</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} placeholder="Zerobyte" />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="iconEmoji"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Icon Emoji (Optional)</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} placeholder=":floppy_disk:" />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{watchedType === "discord" && (
|
||||
<>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="webhookUrl"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Webhook URL</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} placeholder="https://discord.com/api/webhooks/WEBHOOK_ID/WEBHOOK_TOKEN" />
|
||||
</FormControl>
|
||||
<FormDescription>Get this from your Discord server's Integrations settings.</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="username"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Bot Username (Optional)</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} placeholder="Zerobyte" />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="avatarUrl"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Avatar URL (Optional)</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} placeholder="https://example.com/avatar.png" />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="threadId"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Thread ID (Optional)</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
ID of the thread to post messages in. Leave empty to post in the main channel.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{watchedType === "gotify" && (
|
||||
<>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="serverUrl"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Server URL</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} placeholder="https://gotify.example.com" />
|
||||
</FormControl>
|
||||
<FormDescription>Your self-hosted Gotify server URL.</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="token"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>App Token</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} type="password" placeholder="••••••••" />
|
||||
</FormControl>
|
||||
<FormDescription>Application token from Gotify.</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="priority"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Priority</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
type="number"
|
||||
min={0}
|
||||
max={10}
|
||||
onChange={(e) => field.onChange(Number(e.target.value))}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>Priority level (0-10, where 10 is highest).</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="path"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Path (Optional)</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} placeholder="/custom/path" />
|
||||
</FormControl>
|
||||
<FormDescription>Custom path on the Gotify server, if applicable.</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{watchedType === "ntfy" && (
|
||||
<>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="serverUrl"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Server URL (Optional)</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} placeholder="https://ntfy.example.com" />
|
||||
</FormControl>
|
||||
<FormDescription>Leave empty to use ntfy.sh public service.</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="topic"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Topic</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} placeholder="ironmount-backups" />
|
||||
</FormControl>
|
||||
<FormDescription>The ntfy topic name to publish to.</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="username"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Username (Optional)</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} placeholder="username" />
|
||||
</FormControl>
|
||||
<FormDescription>Username for server authentication, if required.</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="password"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Password (Optional)</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} type="password" placeholder="••••••••" />
|
||||
</FormControl>
|
||||
<FormDescription>Password for server authentication, if required.</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="priority"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Priority</FormLabel>
|
||||
<Select onValueChange={field.onChange} defaultValue={String(field.value)} value={String(field.value)}>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select priority" />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
<SelectItem value="max">Max (5)</SelectItem>
|
||||
<SelectItem value="high">High (4)</SelectItem>
|
||||
<SelectItem value="default">Default (3)</SelectItem>
|
||||
<SelectItem value="low">Low (2)</SelectItem>
|
||||
<SelectItem value="min">Min (1)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{watchedType === "pushover" && (
|
||||
<>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="userKey"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>User Key</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} placeholder="uQiRzpo4DXghDmr9QzzfQu27cmVRsG" />
|
||||
</FormControl>
|
||||
<FormDescription>Your Pushover user key from the dashboard.</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="apiToken"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>API Token</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} type="password" placeholder="••••••••" />
|
||||
</FormControl>
|
||||
<FormDescription>Application API token from your Pushover application.</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="devices"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Devices (Optional)</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} placeholder="iphone,android" />
|
||||
</FormControl>
|
||||
<FormDescription>Comma-separated list of device names. Leave empty for all devices.</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="priority"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Priority</FormLabel>
|
||||
<Select
|
||||
onValueChange={(value) => field.onChange(Number(value))}
|
||||
defaultValue={String(field.value)}
|
||||
value={String(field.value)}
|
||||
>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select priority" />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
<SelectItem value="-1">Low (-1)</SelectItem>
|
||||
<SelectItem value="0">Normal (0)</SelectItem>
|
||||
<SelectItem value="1">High (1)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormDescription>Message priority level.</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{watchedType === "telegram" && (
|
||||
<>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="botToken"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Bot Token</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} type="password" placeholder="123456789:ABC-DEF1234ghIkl-zyx57W2v1u123ew11" />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Telegram bot token. Get this from BotFather when you create your bot.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="chatId"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Chat ID</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} placeholder="-1231234567890" />
|
||||
</FormControl>
|
||||
<FormDescription>Telegram chat ID to send notifications to.</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{watchedType === "custom" && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="shoutrrrUrl"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Shoutrrr URL</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
placeholder="smtp://user:pass@smtp.gmail.com:587/?from=you@gmail.com&to=recipient@example.com"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Direct Shoutrrr URL for power users. See
|
||||
<a
|
||||
href="https://shoutrrr.nickfedor.com/v0.12.0/services/overview/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-strong-accent hover:underline"
|
||||
>
|
||||
Shoutrrr documentation
|
||||
</a>
|
||||
for supported services and URL formats.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</form>
|
||||
</Form>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,78 @@
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { Bell } from "lucide-react";
|
||||
import { useId } from "react";
|
||||
import { useNavigate } from "react-router";
|
||||
import { toast } from "sonner";
|
||||
import { createNotificationDestinationMutation } from "~/client/api-client/@tanstack/react-query.gen";
|
||||
import { Button } from "~/client/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "~/client/components/ui/card";
|
||||
import { parseError } from "~/client/lib/errors";
|
||||
import type { Route } from "./+types/create-notification";
|
||||
import { Alert, AlertDescription } from "~/client/components/ui/alert";
|
||||
import { CreateNotificationForm, type NotificationFormValues } from "../components/create-notification-form";
|
||||
|
||||
export const handle = {
|
||||
breadcrumb: () => [{ label: "Notifications", href: "/notifications" }, { label: "Create" }],
|
||||
};
|
||||
|
||||
export function meta(_: Route.MetaArgs) {
|
||||
return [
|
||||
{ title: "Zerobyte - Create Notification" },
|
||||
{
|
||||
name: "description",
|
||||
content: "Create a new notification destination for backup alerts.",
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
export default function CreateNotification() {
|
||||
const navigate = useNavigate();
|
||||
const formId = useId();
|
||||
|
||||
const createNotification = useMutation({
|
||||
...createNotificationDestinationMutation(),
|
||||
onSuccess: () => {
|
||||
toast.success("Notification destination created successfully");
|
||||
navigate(`/notifications`);
|
||||
},
|
||||
});
|
||||
|
||||
const handleSubmit = (values: NotificationFormValues) => {
|
||||
createNotification.mutate({ body: { name: values.name, config: values } });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="container mx-auto space-y-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex items-center justify-center w-10 h-10 rounded-lg bg-primary/10">
|
||||
<Bell className="w-5 h-5 text-primary" />
|
||||
</div>
|
||||
<CardTitle>Create Notification Destination</CardTitle>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
{createNotification.isError && (
|
||||
<Alert variant="destructive">
|
||||
<AlertDescription>
|
||||
<strong>Failed to create notification destination:</strong>
|
||||
<br />
|
||||
{parseError(createNotification.error)?.message}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
<CreateNotificationForm mode="create" formId={formId} onSubmit={handleSubmit} />
|
||||
<div className="flex justify-end gap-2 pt-4 border-t">
|
||||
<Button type="button" variant="secondary" onClick={() => navigate("/notifications")}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" form={formId} loading={createNotification.isPending}>
|
||||
Create Destination
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
200
app/client/modules/notifications/routes/notification-details.tsx
Normal file
200
app/client/modules/notifications/routes/notification-details.tsx
Normal file
@@ -0,0 +1,200 @@
|
||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
import { redirect, useNavigate } from "react-router";
|
||||
import { toast } from "sonner";
|
||||
import { useState, useId } from "react";
|
||||
import {
|
||||
deleteNotificationDestinationMutation,
|
||||
getNotificationDestinationOptions,
|
||||
testNotificationDestinationMutation,
|
||||
updateNotificationDestinationMutation,
|
||||
} from "~/client/api-client/@tanstack/react-query.gen";
|
||||
import { Button } from "~/client/components/ui/button";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "~/client/components/ui/alert-dialog";
|
||||
import { parseError } from "~/client/lib/errors";
|
||||
import { getNotificationDestination } from "~/client/api-client/sdk.gen";
|
||||
import type { Route } from "./+types/notification-details";
|
||||
import { cn } from "~/client/lib/utils";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "~/client/components/ui/card";
|
||||
import { Bell, TestTube2 } from "lucide-react";
|
||||
import { Alert, AlertDescription } from "~/client/components/ui/alert";
|
||||
import { CreateNotificationForm, type NotificationFormValues } from "../components/create-notification-form";
|
||||
|
||||
export const handle = {
|
||||
breadcrumb: (match: Route.MetaArgs) => [
|
||||
{ label: "Notifications", href: "/notifications" },
|
||||
{ label: match.params.id },
|
||||
],
|
||||
};
|
||||
|
||||
export function meta({ params }: Route.MetaArgs) {
|
||||
return [
|
||||
{ title: `Zerobyte - Notification ${params.id}` },
|
||||
{
|
||||
name: "description",
|
||||
content: "View and edit notification destination settings.",
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
export const clientLoader = async ({ params }: Route.ClientLoaderArgs) => {
|
||||
const destination = await getNotificationDestination({ path: { id: params.id ?? "" } });
|
||||
if (destination.data) return destination.data;
|
||||
|
||||
return redirect("/notifications");
|
||||
};
|
||||
|
||||
export default function NotificationDetailsPage({ loaderData }: Route.ComponentProps) {
|
||||
const navigate = useNavigate();
|
||||
const formId = useId();
|
||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
|
||||
|
||||
const { data } = useQuery({
|
||||
...getNotificationDestinationOptions({ path: { id: String(loaderData.id) } }),
|
||||
initialData: loaderData,
|
||||
});
|
||||
|
||||
const deleteDestination = useMutation({
|
||||
...deleteNotificationDestinationMutation(),
|
||||
onSuccess: () => {
|
||||
toast.success("Notification destination deleted successfully");
|
||||
navigate("/notifications");
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error("Failed to delete notification destination", {
|
||||
description: parseError(error)?.message,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const updateDestination = useMutation({
|
||||
...updateNotificationDestinationMutation(),
|
||||
onSuccess: () => {
|
||||
toast.success("Notification destination updated successfully");
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error("Failed to update notification destination", {
|
||||
description: parseError(error)?.message,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const testDestination = useMutation({
|
||||
...testNotificationDestinationMutation(),
|
||||
onSuccess: () => {
|
||||
toast.success("Test notification sent successfully");
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error("Failed to send test notification", {
|
||||
description: parseError(error)?.message,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const handleConfirmDelete = () => {
|
||||
setShowDeleteConfirm(false);
|
||||
deleteDestination.mutate({ path: { id: String(data.id) } });
|
||||
};
|
||||
|
||||
const handleSubmit = (values: NotificationFormValues) => {
|
||||
updateDestination.mutate({
|
||||
path: { id: String(data.id) },
|
||||
body: {
|
||||
name: values.name,
|
||||
config: values,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleTest = () => {
|
||||
testDestination.mutate({ path: { id: String(data.id) } });
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="text-sm font-semibold text-muted-foreground flex items-center gap-2">
|
||||
<span
|
||||
className={cn("inline-flex items-center gap-2 px-2 py-1 rounded-md text-xs bg-gray-500/10 text-gray-500", {
|
||||
"bg-green-500/10 text-green-500": data.enabled,
|
||||
"bg-red-500/10 text-red-500": !data.enabled,
|
||||
})}
|
||||
>
|
||||
{data.enabled ? "Enabled" : "Disabled"}
|
||||
</span>
|
||||
<span className="text-xs bg-primary/10 rounded-md px-2 py-1 capitalize">{data.type}</span>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
onClick={handleTest}
|
||||
disabled={testDestination.isPending || !data.enabled}
|
||||
variant="outline"
|
||||
loading={testDestination.isPending}
|
||||
>
|
||||
<TestTube2 className="h-4 w-4 mr-2" />
|
||||
Test
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => setShowDeleteConfirm(true)}
|
||||
variant="destructive"
|
||||
loading={deleteDestination.isPending}
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex items-center justify-center w-10 h-10 rounded-lg bg-primary/10">
|
||||
<Bell className="w-5 h-5 text-primary" />
|
||||
</div>
|
||||
<CardTitle>{data.name}</CardTitle>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
{updateDestination.isError && (
|
||||
<Alert variant="destructive">
|
||||
<AlertDescription>
|
||||
<strong>Failed to update notification destination:</strong>
|
||||
<br />
|
||||
{parseError(updateDestination.error)?.message}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
<CreateNotificationForm mode="update" formId={formId} onSubmit={handleSubmit} initialValues={data.config} />
|
||||
<div className="flex justify-end gap-2 pt-4 border-t">
|
||||
<Button type="submit" form={formId} loading={updateDestination.isPending}>
|
||||
Save Changes
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<AlertDialog open={showDeleteConfirm} onOpenChange={setShowDeleteConfirm}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Delete Notification Destination</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
Are you sure you want to delete the notification destination "{data.name}"? This action cannot be undone
|
||||
and will remove this destination from all backup schedules.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={handleConfirmDelete}>Delete</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
179
app/client/modules/notifications/routes/notifications.tsx
Normal file
179
app/client/modules/notifications/routes/notifications.tsx
Normal file
@@ -0,0 +1,179 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Bell, Plus, RotateCcw } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { useNavigate } from "react-router";
|
||||
import { EmptyState } from "~/client/components/empty-state";
|
||||
import { StatusDot } from "~/client/components/status-dot";
|
||||
import { Button } from "~/client/components/ui/button";
|
||||
import { Card } from "~/client/components/ui/card";
|
||||
import { Input } from "~/client/components/ui/input";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "~/client/components/ui/select";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "~/client/components/ui/table";
|
||||
import type { Route } from "./+types/notifications";
|
||||
import { listNotificationDestinations } from "~/client/api-client";
|
||||
import { listNotificationDestinationsOptions } from "~/client/api-client/@tanstack/react-query.gen";
|
||||
|
||||
export const handle = {
|
||||
breadcrumb: () => [{ label: "Notifications" }],
|
||||
};
|
||||
|
||||
export function meta(_: Route.MetaArgs) {
|
||||
return [
|
||||
{ title: "Zerobyte - Notifications" },
|
||||
{
|
||||
name: "description",
|
||||
content: "Manage notification destinations for backup alerts.",
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
export const clientLoader = async () => {
|
||||
const result = await listNotificationDestinations();
|
||||
if (result.data) return result.data;
|
||||
return [];
|
||||
};
|
||||
|
||||
export default function Notifications({ loaderData }: Route.ComponentProps) {
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [typeFilter, setTypeFilter] = useState("");
|
||||
const [statusFilter, setStatusFilter] = useState("");
|
||||
|
||||
const clearFilters = () => {
|
||||
setSearchQuery("");
|
||||
setTypeFilter("");
|
||||
setStatusFilter("");
|
||||
};
|
||||
|
||||
const navigate = useNavigate();
|
||||
|
||||
const { data } = useQuery({
|
||||
...listNotificationDestinationsOptions(),
|
||||
initialData: loaderData,
|
||||
});
|
||||
|
||||
const filteredNotifications =
|
||||
data?.filter((notification) => {
|
||||
const matchesSearch = notification.name.toLowerCase().includes(searchQuery.toLowerCase());
|
||||
const matchesType = !typeFilter || notification.type === typeFilter;
|
||||
const matchesStatus =
|
||||
!statusFilter || (statusFilter === "enabled" ? notification.enabled : !notification.enabled);
|
||||
return matchesSearch && matchesType && matchesStatus;
|
||||
}) || [];
|
||||
|
||||
const hasNoNotifications = data.length === 0;
|
||||
const hasNoFilteredNotifications = filteredNotifications.length === 0 && !hasNoNotifications;
|
||||
|
||||
if (hasNoNotifications) {
|
||||
return (
|
||||
<EmptyState
|
||||
icon={Bell}
|
||||
title="No notification destinations"
|
||||
description="Set up notification channels to receive alerts when your backups complete or fail."
|
||||
button={
|
||||
<Button onClick={() => navigate("/notifications/create")}>
|
||||
<Plus size={16} className="mr-2" />
|
||||
Create Destination
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="p-0 gap-0">
|
||||
<div className="flex flex-col lg:flex-row items-stretch lg:items-center gap-2 md:justify-between p-4 bg-card-header py-4">
|
||||
<span className="flex flex-col sm:flex-row items-stretch md:items-center gap-0 flex-wrap ">
|
||||
<Input
|
||||
className="w-full lg:w-[180px] min-w-[180px] -mr-px -mt-px"
|
||||
placeholder="Search destinations…"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
/>
|
||||
<Select value={typeFilter} onValueChange={setTypeFilter}>
|
||||
<SelectTrigger className="w-full lg:w-[180px] min-w-[180px] -mr-px -mt-px">
|
||||
<SelectValue placeholder="All types" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="email">Email</SelectItem>
|
||||
<SelectItem value="slack">Slack</SelectItem>
|
||||
<SelectItem value="discord">Discord</SelectItem>
|
||||
<SelectItem value="gotify">Gotify</SelectItem>
|
||||
<SelectItem value="ntfy">Ntfy</SelectItem>
|
||||
<SelectItem value="pushover">Pushover</SelectItem>
|
||||
<SelectItem value="telegram">Telegram</SelectItem>
|
||||
<SelectItem value="custom">Custom</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select value={statusFilter} onValueChange={setStatusFilter}>
|
||||
<SelectTrigger className="w-full lg:w-[180px] min-w-[180px] -mt-px">
|
||||
<SelectValue placeholder="All status" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="enabled">Enabled</SelectItem>
|
||||
<SelectItem value="disabled">Disabled</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{(searchQuery || typeFilter || statusFilter) && (
|
||||
<Button onClick={clearFilters} className="w-full lg:w-auto mt-2 lg:mt-0 lg:ml-2">
|
||||
<RotateCcw className="h-4 w-4 mr-2" />
|
||||
Clear filters
|
||||
</Button>
|
||||
)}
|
||||
</span>
|
||||
<Button onClick={() => navigate("/notifications/create")}>
|
||||
<Plus size={16} className="mr-2" />
|
||||
Create Destination
|
||||
</Button>
|
||||
</div>
|
||||
<div className="overflow-x-auto">
|
||||
<Table className="border-t">
|
||||
<TableHeader className="bg-card-header">
|
||||
<TableRow>
|
||||
<TableHead className="w-[100px] uppercase">Name</TableHead>
|
||||
<TableHead className="uppercase text-left">Type</TableHead>
|
||||
<TableHead className="uppercase text-center">Status</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{hasNoFilteredNotifications ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={3} className="text-center py-12">
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
<p className="text-muted-foreground">No destinations match your filters.</p>
|
||||
<Button onClick={clearFilters} variant="outline" size="sm">
|
||||
<RotateCcw className="h-4 w-4 mr-2" />
|
||||
Clear filters
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
filteredNotifications.map((notification) => (
|
||||
<TableRow
|
||||
key={notification.id}
|
||||
className="hover:bg-accent/50 hover:cursor-pointer"
|
||||
onClick={() => navigate(`/notifications/${notification.id}`)}
|
||||
>
|
||||
<TableCell className="font-medium text-strong-accent">{notification.name}</TableCell>
|
||||
<TableCell className="capitalize">{notification.type}</TableCell>
|
||||
<TableCell className="text-center">
|
||||
<StatusDot
|
||||
variant={notification.enabled ? "success" : "neutral"}
|
||||
label={notification.enabled ? "Enabled" : "Disabled"}
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
<div className="px-4 py-2 text-sm text-muted-foreground bg-card-header flex justify-end border-t">
|
||||
<span>
|
||||
<span className="text-strong-accent">{filteredNotifications.length}</span> destination
|
||||
{filteredNotifications.length !== 1 ? "s" : ""}
|
||||
</span>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -1,94 +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);
|
||||
|
||||
restore.mutate({
|
||||
path: { name },
|
||||
body: {
|
||||
snapshotId,
|
||||
include: include && include.length > 0 ? include : undefined,
|
||||
exclude: exclude && exclude.length > 0 ? exclude : 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>
|
||||
);
|
||||
};
|
||||
@@ -1,97 +0,0 @@
|
||||
import { arktypeResolver } from "@hookform/resolvers/arktype";
|
||||
import { type } from "arktype";
|
||||
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";
|
||||
|
||||
const restoreSnapshotFormSchema = type({
|
||||
path: "string?",
|
||||
include: "string?",
|
||||
exclude: "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 form = useForm<RestoreSnapshotFormValues>({
|
||||
resolver: arktypeResolver(restoreSnapshotFormSchema),
|
||||
defaultValues: {
|
||||
path: "",
|
||||
include: "",
|
||||
exclude: "",
|
||||
},
|
||||
});
|
||||
|
||||
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>
|
||||
</form>
|
||||
</Form>
|
||||
);
|
||||
};
|
||||
89
app/client/modules/repositories/routes/create-repository.tsx
Normal file
89
app/client/modules/repositories/routes/create-repository.tsx
Normal file
@@ -0,0 +1,89 @@
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { Database } from "lucide-react";
|
||||
import { useId } from "react";
|
||||
import { useNavigate } from "react-router";
|
||||
import { toast } from "sonner";
|
||||
import { createRepositoryMutation } from "~/client/api-client/@tanstack/react-query.gen";
|
||||
import { CreateRepositoryForm, type RepositoryFormValues } from "~/client/components/create-repository-form";
|
||||
import { Button } from "~/client/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "~/client/components/ui/card";
|
||||
import { parseError } from "~/client/lib/errors";
|
||||
import type { Route } from "./+types/create-repository";
|
||||
import { Alert, AlertDescription } from "~/client/components/ui/alert";
|
||||
|
||||
export const handle = {
|
||||
breadcrumb: () => [{ label: "Repositories", href: "/repositories" }, { label: "Create" }],
|
||||
};
|
||||
|
||||
export function meta(_: Route.MetaArgs) {
|
||||
return [
|
||||
{ title: "Zerobyte - Create Repository" },
|
||||
{
|
||||
name: "description",
|
||||
content: "Create a new backup repository with encryption and compression.",
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
export default function CreateRepository() {
|
||||
const navigate = useNavigate();
|
||||
const formId = useId();
|
||||
|
||||
const createRepository = useMutation({
|
||||
...createRepositoryMutation(),
|
||||
onSuccess: (data) => {
|
||||
toast.success("Repository created successfully");
|
||||
navigate(`/repositories/${data.repository.name}`);
|
||||
},
|
||||
});
|
||||
|
||||
const handleSubmit = (values: RepositoryFormValues) => {
|
||||
createRepository.mutate({
|
||||
body: {
|
||||
config: values,
|
||||
name: values.name,
|
||||
compressionMode: values.compressionMode,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="container mx-auto space-y-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex items-center justify-center w-10 h-10 rounded-lg bg-primary/10">
|
||||
<Database className="w-5 h-5 text-primary" />
|
||||
</div>
|
||||
<CardTitle>Create Repository</CardTitle>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
{createRepository.isError && (
|
||||
<Alert variant="destructive">
|
||||
<AlertDescription>
|
||||
<strong>Failed to create repository:</strong>
|
||||
<br />
|
||||
{parseError(createRepository.error)?.message}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
<CreateRepositoryForm
|
||||
mode="create"
|
||||
formId={formId}
|
||||
onSubmit={handleSubmit}
|
||||
loading={createRepository.isPending}
|
||||
/>
|
||||
<div className="flex justify-end gap-2 pt-4 border-t">
|
||||
<Button type="button" variant="secondary" onClick={() => navigate("/repositories")}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" form={formId} loading={createRepository.isPending}>
|
||||
Create Repository
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,10 +1,9 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Database, RotateCcw } from "lucide-react";
|
||||
import { Database, Plus, RotateCcw } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { useNavigate } from "react-router";
|
||||
import { listRepositories } from "~/client/api-client/sdk.gen";
|
||||
import { listRepositoriesOptions } from "~/client/api-client/@tanstack/react-query.gen";
|
||||
import { CreateRepositoryDialog } from "~/client/components/create-repository-dialog";
|
||||
import { RepositoryIcon } from "~/client/components/repository-icon";
|
||||
import { Button } from "~/client/components/ui/button";
|
||||
import { Card } from "~/client/components/ui/card";
|
||||
@@ -21,7 +20,7 @@ export const handle = {
|
||||
|
||||
export function meta(_: Route.MetaArgs) {
|
||||
return [
|
||||
{ title: "Repositories" },
|
||||
{ title: "Zerobyte - Repositories" },
|
||||
{
|
||||
name: "description",
|
||||
content: "Manage your backup repositories with encryption and compression.",
|
||||
@@ -39,7 +38,6 @@ export default function Repositories({ loaderData }: Route.ComponentProps) {
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [statusFilter, setStatusFilter] = useState("");
|
||||
const [backendFilter, setBackendFilter] = useState("");
|
||||
const [createRepositoryOpen, setCreateRepositoryOpen] = useState(false);
|
||||
|
||||
const clearFilters = () => {
|
||||
setSearchQuery("");
|
||||
@@ -52,8 +50,6 @@ export default function Repositories({ loaderData }: Route.ComponentProps) {
|
||||
const { data } = useQuery({
|
||||
...listRepositoriesOptions(),
|
||||
initialData: loaderData,
|
||||
refetchInterval: 10000,
|
||||
refetchOnWindowFocus: true,
|
||||
});
|
||||
|
||||
const filteredRepositories =
|
||||
@@ -73,7 +69,12 @@ export default function Repositories({ loaderData }: Route.ComponentProps) {
|
||||
icon={Database}
|
||||
title="No repository"
|
||||
description="Repositories are remote storage locations where you can backup your volumes securely. Encrypted and optimized for storage efficiency."
|
||||
button={<CreateRepositoryDialog open={createRepositoryOpen} setOpen={setCreateRepositoryOpen} />}
|
||||
button={
|
||||
<Button onClick={() => navigate("/repositories/create")}>
|
||||
<Plus size={16} className="mr-2" />
|
||||
Create Repository
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -116,7 +117,10 @@ export default function Repositories({ loaderData }: Route.ComponentProps) {
|
||||
</Button>
|
||||
)}
|
||||
</span>
|
||||
<CreateRepositoryDialog open={createRepositoryOpen} setOpen={setCreateRepositoryOpen} />
|
||||
<Button onClick={() => navigate("/repositories/create")}>
|
||||
<Plus size={16} className="mr-2" />
|
||||
Create Repository
|
||||
</Button>
|
||||
</div>
|
||||
<div className="overflow-x-auto">
|
||||
<Table className="border-t">
|
||||
|
||||
@@ -36,7 +36,7 @@ export const handle = {
|
||||
|
||||
export function meta({ params }: Route.MetaArgs) {
|
||||
return [
|
||||
{ title: params.name },
|
||||
{ title: `Zerobyte - ${params.name}` },
|
||||
{
|
||||
name: "description",
|
||||
content: "View repository configuration, status, and snapshots.",
|
||||
@@ -64,8 +64,6 @@ export default function RepositoryDetailsPage({ loaderData }: Route.ComponentPro
|
||||
const { data } = useQuery({
|
||||
...getRepositoryOptions({ path: { name: loaderData.name } }),
|
||||
initialData: loaderData,
|
||||
refetchInterval: 10000,
|
||||
refetchOnWindowFocus: true,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
@@ -181,8 +179,8 @@ export default function RepositoryDetailsPage({ loaderData }: Route.ComponentPro
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Delete repository?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
Are you sure you want to delete the repository <strong>{data.name}</strong>? This action cannot be undone
|
||||
and will remove all backup data.
|
||||
Are you sure you want to delete the repository <strong>{data.name}</strong>? This will not remove the
|
||||
actual data from the backend storage, only the repository configuration will be deleted.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<div className="flex gap-3 justify-end">
|
||||
|
||||
45
app/client/modules/repositories/routes/restore-snapshot.tsx
Normal file
45
app/client/modules/repositories/routes/restore-snapshot.tsx
Normal 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}`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -2,7 +2,6 @@ import { useQuery } from "@tanstack/react-query";
|
||||
import { redirect, useParams } from "react-router";
|
||||
import { listSnapshotFilesOptions } from "~/client/api-client/@tanstack/react-query.gen";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "~/client/components/ui/card";
|
||||
import { RestoreSnapshotDialog } from "../components/restore-snapshot-dialog";
|
||||
import { SnapshotFileBrowser } from "~/client/modules/backups/components/snapshot-file-browser";
|
||||
import { getSnapshotDetails } from "~/client/api-client";
|
||||
import type { Route } from "./+types/snapshot-details";
|
||||
@@ -17,7 +16,7 @@ export const handle = {
|
||||
|
||||
export function meta({ params }: Route.MetaArgs) {
|
||||
return [
|
||||
{ title: `Snapshot ${params.snapshotId}` },
|
||||
{ title: `Zerobyte - Snapshot ${params.snapshotId}` },
|
||||
{
|
||||
name: "description",
|
||||
content: "Browse and restore files from a backup snapshot.",
|
||||
@@ -63,7 +62,6 @@ export default function SnapshotDetailsPage({ loaderData }: Route.ComponentProps
|
||||
<h1 className="text-2xl font-bold">{name}</h1>
|
||||
<p className="text-sm text-muted-foreground">Snapshot: {snapshotId}</p>
|
||||
</div>
|
||||
<RestoreSnapshotDialog name={name} snapshotId={snapshotId} />
|
||||
</div>
|
||||
|
||||
<SnapshotFileBrowser repositoryName={name} snapshot={loaderData} />
|
||||
|
||||
@@ -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 { Button } from "~/client/components/ui/button";
|
||||
import { Input } from "~/client/components/ui/input";
|
||||
import { Label } from "~/client/components/ui/label";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "~/client/components/ui/select";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "~/client/components/ui/alert-dialog";
|
||||
import type { Repository } from "~/client/lib/types";
|
||||
import { slugify } from "~/client/lib/utils";
|
||||
import { updateRepositoryMutation } from "~/client/api-client/@tanstack/react-query.gen";
|
||||
import type { UpdateRepositoryResponse } from "~/client/api-client/types.gen";
|
||||
import type { CompressionMode } from "~/schemas/restic";
|
||||
|
||||
type Props = {
|
||||
repository: Repository;
|
||||
};
|
||||
|
||||
export const RepositoryInfoTabContent = ({ repository }: Props) => {
|
||||
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 (
|
||||
<>
|
||||
<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>
|
||||
<h3 className="text-lg font-semibold mb-4">Repository Information</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<div className="text-sm font-medium text-muted-foreground">Name</div>
|
||||
<p className="mt-1 text-sm">{repository.name}</p>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm font-medium text-muted-foreground">Backend</div>
|
||||
<p className="mt-1 text-sm">{repository.type}</p>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm font-medium text-muted-foreground">Compression Mode</div>
|
||||
<p className="mt-1 text-sm">{repository.compressionMode || "off"}</p>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm font-medium text-muted-foreground">Status</div>
|
||||
<p className="mt-1 text-sm">{repository.status || "unknown"}</p>
|
||||
@@ -40,24 +125,45 @@ export const RepositoryInfoTabContent = ({ repository }: Props) => {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{repository.lastError && (
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-lg font-semibold text-red-500">Last Error</h3>
|
||||
</div>
|
||||
|
||||
<div className="bg-red-500/10 border border-red-500/20 rounded-md p-4">
|
||||
<p className="text-sm text-red-500">{repository.lastError}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold mb-4">Configuration</h3>
|
||||
<div className="bg-muted/50 rounded-md p-4">
|
||||
<pre className="text-sm overflow-auto">{JSON.stringify(repository.config, null, 2)}</pre>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end pt-4 border-t">
|
||||
<Button type="submit" disabled={!hasChanges || updateMutation.isPending} loading={updateMutation.isPending}>
|
||||
Save Changes
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Card>
|
||||
|
||||
<AlertDialog open={showConfirmDialog} onOpenChange={setShowConfirmDialog}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Update Repository</AlertDialogTitle>
|
||||
<AlertDialogDescription>Are you sure you want to update the repository settings?</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={confirmUpdate}>Update</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Database } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { listSnapshotsOptions } from "~/client/api-client/@tanstack/react-query.gen";
|
||||
import { listBackupSchedulesOptions, listSnapshotsOptions } from "~/client/api-client/@tanstack/react-query.gen";
|
||||
import { SnapshotsTable } from "~/client/components/snapshots-table";
|
||||
import { Button } from "~/client/components/ui/button";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "~/client/components/ui/card";
|
||||
@@ -18,11 +18,13 @@ export const RepositorySnapshotsTabContent = ({ repository }: Props) => {
|
||||
|
||||
const { data, isFetching, failureReason } = useQuery({
|
||||
...listSnapshotsOptions({ path: { name: repository.name } }),
|
||||
refetchInterval: 10000,
|
||||
refetchOnWindowFocus: true,
|
||||
initialData: [],
|
||||
});
|
||||
|
||||
const schedules = useQuery({
|
||||
...listBackupSchedulesOptions(),
|
||||
});
|
||||
|
||||
const filteredSnapshots = data.filter((snapshot: Snapshot) => {
|
||||
if (!searchQuery) return true;
|
||||
const searchLower = searchQuery.toLowerCase();
|
||||
@@ -134,7 +136,7 @@ export const RepositorySnapshotsTabContent = ({ repository }: Props) => {
|
||||
</TableBody>
|
||||
</Table>
|
||||
) : (
|
||||
<SnapshotsTable snapshots={filteredSnapshots} repositoryName={repository.name} />
|
||||
<SnapshotsTable snapshots={filteredSnapshots} repositoryName={repository.name} backups={schedules.data ?? []} />
|
||||
)}
|
||||
<div className="px-4 py-2 text-sm text-muted-foreground bg-card-header flex justify-between border-t">
|
||||
<span>
|
||||
|
||||
@@ -30,7 +30,7 @@ export const handle = {
|
||||
|
||||
export function meta(_: Route.MetaArgs) {
|
||||
return [
|
||||
{ title: "Settings" },
|
||||
{ title: "Zerobyte - Settings" },
|
||||
{
|
||||
name: "description",
|
||||
content: "Manage your account settings and preferences.",
|
||||
|
||||
83
app/client/modules/volumes/routes/create-volume.tsx
Normal file
83
app/client/modules/volumes/routes/create-volume.tsx
Normal file
@@ -0,0 +1,83 @@
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { HardDrive } from "lucide-react";
|
||||
import { useId } from "react";
|
||||
import { useNavigate } from "react-router";
|
||||
import { toast } from "sonner";
|
||||
import { createVolumeMutation } from "~/client/api-client/@tanstack/react-query.gen";
|
||||
import { CreateVolumeForm, type FormValues } from "~/client/components/create-volume-form";
|
||||
import { Button } from "~/client/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "~/client/components/ui/card";
|
||||
import { parseError } from "~/client/lib/errors";
|
||||
import type { Route } from "./+types/create-volume";
|
||||
import { Alert, AlertDescription } from "~/client/components/ui/alert";
|
||||
|
||||
export const handle = {
|
||||
breadcrumb: () => [{ label: "Volumes", href: "/volumes" }, { label: "Create" }],
|
||||
};
|
||||
|
||||
export function meta(_: Route.MetaArgs) {
|
||||
return [
|
||||
{ title: "Zerobyte - Create Volume" },
|
||||
{
|
||||
name: "description",
|
||||
content: "Create a new storage volume with automatic mounting and health checks.",
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
export default function CreateVolume() {
|
||||
const navigate = useNavigate();
|
||||
const formId = useId();
|
||||
|
||||
const createVolume = useMutation({
|
||||
...createVolumeMutation(),
|
||||
onSuccess: (data) => {
|
||||
toast.success("Volume created successfully");
|
||||
navigate(`/volumes/${data.name}`);
|
||||
},
|
||||
});
|
||||
|
||||
const handleSubmit = (values: FormValues) => {
|
||||
createVolume.mutate({
|
||||
body: {
|
||||
config: values,
|
||||
name: values.name,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="container mx-auto space-y-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex items-center justify-center w-10 h-10 rounded-lg bg-primary/10">
|
||||
<HardDrive className="w-5 h-5 text-primary" />
|
||||
</div>
|
||||
<CardTitle>Create Volume</CardTitle>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
{createVolume.isError && (
|
||||
<Alert variant="destructive">
|
||||
<AlertDescription>
|
||||
<strong>Failed to create volume:</strong>
|
||||
<br />
|
||||
{parseError(createVolume.error)?.message}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
<CreateVolumeForm mode="create" formId={formId} onSubmit={handleSubmit} loading={createVolume.isPending} />
|
||||
<div className="flex justify-end gap-2 pt-4 border-t">
|
||||
<Button type="button" variant="secondary" onClick={() => navigate("/volumes")}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" form={formId} loading={createVolume.isPending}>
|
||||
Create Volume
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -24,6 +24,7 @@ import { DockerTabContent } from "../tabs/docker";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "~/client/components/ui/tooltip";
|
||||
import { useSystemInfo } from "~/client/hooks/use-system-info";
|
||||
import { getVolume } from "~/client/api-client";
|
||||
import type { VolumeStatus } from "~/client/lib/types";
|
||||
import {
|
||||
deleteVolumeMutation,
|
||||
getVolumeOptions,
|
||||
@@ -31,13 +32,23 @@ import {
|
||||
unmountVolumeMutation,
|
||||
} from "~/client/api-client/@tanstack/react-query.gen";
|
||||
|
||||
const getVolumeStatusVariant = (status: VolumeStatus): "success" | "neutral" | "error" | "warning" => {
|
||||
const statusMap = {
|
||||
mounted: "success" as const,
|
||||
unmounted: "neutral" as const,
|
||||
error: "error" as const,
|
||||
unknown: "warning" as const,
|
||||
};
|
||||
return statusMap[status];
|
||||
};
|
||||
|
||||
export const handle = {
|
||||
breadcrumb: (match: Route.MetaArgs) => [{ label: "Volumes", href: "/volumes" }, { label: match.params.name }],
|
||||
};
|
||||
|
||||
export function meta({ params }: Route.MetaArgs) {
|
||||
return [
|
||||
{ title: params.name },
|
||||
{ title: `Zerobyte - ${params.name}` },
|
||||
{
|
||||
name: "description",
|
||||
content: "View and manage volume details, configuration, and files.",
|
||||
@@ -60,8 +71,6 @@ export default function VolumeDetails({ loaderData }: Route.ComponentProps) {
|
||||
const { data } = useQuery({
|
||||
...getVolumeOptions({ path: { name: name ?? "" } }),
|
||||
initialData: loaderData,
|
||||
refetchInterval: 10000,
|
||||
refetchOnWindowFocus: true,
|
||||
});
|
||||
|
||||
const { capabilities } = useSystemInfo();
|
||||
@@ -124,9 +133,14 @@ export default function VolumeDetails({ loaderData }: Route.ComponentProps) {
|
||||
<div className="flex flex-col items-start xs:items-center xs:flex-row xs:justify-between">
|
||||
<div className="text-sm font-semibold mb-2 xs:mb-0 text-muted-foreground flex items-center gap-2">
|
||||
<span className="flex items-center gap-2">
|
||||
<StatusDot status={volume.status} /> {volume.status[0].toUpperCase() + volume.status.slice(1)}
|
||||
<StatusDot
|
||||
variant={getVolumeStatusVariant(volume.status)}
|
||||
label={volume.status[0].toUpperCase() + volume.status.slice(1)}
|
||||
/>
|
||||
|
||||
{volume.status[0].toUpperCase() + volume.status.slice(1)}
|
||||
</span>
|
||||
<VolumeIcon size={14} backend={volume?.config.backend} />
|
||||
<VolumeIcon backend={volume?.config.backend} />
|
||||
</div>
|
||||
<div className="flex gap-4">
|
||||
<Button
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { HardDrive, RotateCcw } from "lucide-react";
|
||||
import { HardDrive, Plus, RotateCcw } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { useNavigate } from "react-router";
|
||||
import { CreateVolumeDialog } from "~/client/components/create-volume-dialog";
|
||||
import { EmptyState } from "~/client/components/empty-state";
|
||||
import { StatusDot } from "~/client/components/status-dot";
|
||||
import { Button } from "~/client/components/ui/button";
|
||||
@@ -14,6 +13,17 @@ import { VolumeIcon } from "~/client/components/volume-icon";
|
||||
import type { Route } from "./+types/volumes";
|
||||
import { listVolumes } from "~/client/api-client";
|
||||
import { listVolumesOptions } from "~/client/api-client/@tanstack/react-query.gen";
|
||||
import type { VolumeStatus } from "~/client/lib/types";
|
||||
|
||||
const getVolumeStatusVariant = (status: VolumeStatus): "success" | "neutral" | "error" | "warning" => {
|
||||
const statusMap = {
|
||||
mounted: "success" as const,
|
||||
unmounted: "neutral" as const,
|
||||
error: "error" as const,
|
||||
unknown: "warning" as const,
|
||||
};
|
||||
return statusMap[status];
|
||||
};
|
||||
|
||||
export const handle = {
|
||||
breadcrumb: () => [{ label: "Volumes" }],
|
||||
@@ -21,7 +31,7 @@ export const handle = {
|
||||
|
||||
export function meta(_: Route.MetaArgs) {
|
||||
return [
|
||||
{ title: "Volumes" },
|
||||
{ title: "Zerobyte - Volumes" },
|
||||
{
|
||||
name: "description",
|
||||
content: "Create, manage, monitor, and automate your Docker volumes with ease.",
|
||||
@@ -36,7 +46,6 @@ export const clientLoader = async () => {
|
||||
};
|
||||
|
||||
export default function Volumes({ loaderData }: Route.ComponentProps) {
|
||||
const [createVolumeOpen, setCreateVolumeOpen] = useState(false);
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [statusFilter, setStatusFilter] = useState("");
|
||||
const [backendFilter, setBackendFilter] = useState("");
|
||||
@@ -52,8 +61,6 @@ export default function Volumes({ loaderData }: Route.ComponentProps) {
|
||||
const { data } = useQuery({
|
||||
...listVolumesOptions(),
|
||||
initialData: loaderData,
|
||||
refetchInterval: 10000,
|
||||
refetchOnWindowFocus: true,
|
||||
});
|
||||
|
||||
const filteredVolumes =
|
||||
@@ -73,7 +80,12 @@ export default function Volumes({ loaderData }: Route.ComponentProps) {
|
||||
icon={HardDrive}
|
||||
title="No volume"
|
||||
description="Manage and monitor all your storage backends in one place with advanced features like automatic mounting and health checks."
|
||||
button={<CreateVolumeDialog open={createVolumeOpen} setOpen={setCreateVolumeOpen} />}
|
||||
button={
|
||||
<Button onClick={() => navigate("/volumes/create")}>
|
||||
<Plus size={16} className="mr-2" />
|
||||
Create Volume
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -115,7 +127,10 @@ export default function Volumes({ loaderData }: Route.ComponentProps) {
|
||||
</Button>
|
||||
)}
|
||||
</span>
|
||||
<CreateVolumeDialog open={createVolumeOpen} setOpen={setCreateVolumeOpen} />
|
||||
<Button onClick={() => navigate("/volumes/create")}>
|
||||
<Plus size={16} className="mr-2" />
|
||||
Create Volume
|
||||
</Button>
|
||||
</div>
|
||||
<div className="overflow-x-auto">
|
||||
<Table className="border-t">
|
||||
@@ -151,7 +166,10 @@ export default function Volumes({ loaderData }: Route.ComponentProps) {
|
||||
<VolumeIcon backend={volume.type} />
|
||||
</TableCell>
|
||||
<TableCell className="text-center">
|
||||
<StatusDot status={volume.status} />
|
||||
<StatusDot
|
||||
variant={getVolumeStatusVariant(volume.status)}
|
||||
label={volume.status[0].toUpperCase() + volume.status.slice(1)}
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
|
||||
@@ -16,17 +16,17 @@ export const DockerTabContent = ({ volume }: Props) => {
|
||||
services: {
|
||||
nginx: {
|
||||
image: "nginx:latest",
|
||||
volumes: [`im-${volume.name}:/path/in/container`],
|
||||
volumes: [`zb-${volume.shortId}:/path/in/container`],
|
||||
},
|
||||
},
|
||||
volumes: {
|
||||
[`im-${volume.name}`]: {
|
||||
[`zb-${volume.shortId}`]: {
|
||||
external: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const dockerRunCommand = `docker run -v im-${volume.name}:/path/in/container nginx:latest`;
|
||||
const dockerRunCommand = `docker run -v zb-${volume.shortId}:/path/in/container nginx:latest`;
|
||||
|
||||
const {
|
||||
data: containersData,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { useState } from "react";
|
||||
import { useNavigate } from "react-router";
|
||||
import { toast } from "sonner";
|
||||
import { CreateVolumeForm, type FormValues } from "~/client/components/create-volume-form";
|
||||
import {
|
||||
@@ -17,6 +18,7 @@ import type { StatFs, Volume } from "~/client/lib/types";
|
||||
import { HealthchecksCard } from "../components/healthchecks-card";
|
||||
import { StorageChart } from "../components/storage-chart";
|
||||
import { updateVolumeMutation } from "~/client/api-client/@tanstack/react-query.gen";
|
||||
import type { UpdateVolumeResponse } from "~/client/api-client/types.gen";
|
||||
|
||||
type Props = {
|
||||
volume: Volume;
|
||||
@@ -24,12 +26,18 @@ type Props = {
|
||||
};
|
||||
|
||||
export const VolumeInfoTabContent = ({ volume, statfs }: Props) => {
|
||||
const navigate = useNavigate();
|
||||
|
||||
const updateMutation = useMutation({
|
||||
...updateVolumeMutation(),
|
||||
onSuccess: (_) => {
|
||||
onSuccess: (data: UpdateVolumeResponse) => {
|
||||
toast.success("Volume updated successfully");
|
||||
setOpen(false);
|
||||
setPendingValues(null);
|
||||
|
||||
if (data.name !== volume.name) {
|
||||
navigate(`/volumes/${data.name}`);
|
||||
}
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error("Failed to update volume", { description: error.message });
|
||||
@@ -50,7 +58,7 @@ export const VolumeInfoTabContent = ({ volume, statfs }: Props) => {
|
||||
if (pendingValues) {
|
||||
updateMutation.mutate({
|
||||
path: { name: volume.name },
|
||||
body: { config: pendingValues },
|
||||
body: { name: pendingValues.name, config: pendingValues },
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
23
app/drizzle/0011_familiar_stone_men.sql
Normal file
23
app/drizzle/0011_familiar_stone_men.sql
Normal file
@@ -0,0 +1,23 @@
|
||||
CREATE TABLE `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()) 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
|
||||
CREATE TABLE `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()) NOT NULL,
|
||||
`updated_at` integer DEFAULT (unixepoch()) NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX `notification_destinations_table_name_unique` ON `notification_destinations_table` (`name`);
|
||||
7
app/drizzle/0012_add_short_ids.sql
Normal file
7
app/drizzle/0012_add_short_ids.sql
Normal 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`);
|
||||
6
app/drizzle/0013_elite_sprite.sql
Normal file
6
app/drizzle/0013_elite_sprite.sql
Normal 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
|
||||
);
|
||||
40
app/drizzle/0014_wild_echo.sql
Normal file
40
app/drizzle/0014_wild_echo.sql
Normal 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`);
|
||||
40
app/drizzle/0015_jazzy_sersi.sql
Normal file
40
app/drizzle/0015_jazzy_sersi.sql
Normal 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`);
|
||||
47
app/drizzle/0016_fix-timestamps-to-ms.sql
Normal file
47
app/drizzle/0016_fix-timestamps-to-ms.sql
Normal 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;
|
||||
1
app/drizzle/0017_fix-compression-modes.sql
Normal file
1
app/drizzle/0017_fix-compression-modes.sql
Normal file
@@ -0,0 +1 @@
|
||||
UPDATE `repositories_table` SET `compression_mode` = 'auto' WHERE `compression_mode` IN ('fastest', 'better');
|
||||
139
app/drizzle/0018_breezy_invaders.sql
Normal file
139
app/drizzle/0018_breezy_invaders.sql
Normal 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`);
|
||||
24
app/drizzle/0019_secret_nomad.sql
Normal file
24
app/drizzle/0019_secret_nomad.sql
Normal 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`);
|
||||
1
app/drizzle/0020_even_dexter_bennett.sql
Normal file
1
app/drizzle/0020_even_dexter_bennett.sql
Normal file
@@ -0,0 +1 @@
|
||||
ALTER TABLE `backup_schedules_table` ADD `exclude_if_present` text DEFAULT '[]';
|
||||
589
app/drizzle/meta/0011_snapshot.json
Normal file
589
app/drizzle/meta/0011_snapshot.json
Normal file
@@ -0,0 +1,589 @@
|
||||
{
|
||||
"version": "6",
|
||||
"dialect": "sqlite",
|
||||
"id": "67552135-fa49-478f-9333-107d3dbd7610",
|
||||
"prevId": "17f234ba-4123-4951-a39f-6002d537435f",
|
||||
"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
|
||||
},
|
||||
"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_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
|
||||
},
|
||||
"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_name_unique": {
|
||||
"name": "volumes_table_name_unique",
|
||||
"columns": ["name"],
|
||||
"isUnique": true
|
||||
}
|
||||
},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
}
|
||||
},
|
||||
"views": {},
|
||||
"enums": {},
|
||||
"_meta": {
|
||||
"schemas": {},
|
||||
"tables": {},
|
||||
"columns": {}
|
||||
},
|
||||
"internal": {
|
||||
"indexes": {}
|
||||
}
|
||||
}
|
||||
613
app/drizzle/meta/0012_snapshot.json
Normal file
613
app/drizzle/meta/0012_snapshot.json
Normal 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": {}
|
||||
}
|
||||
}
|
||||
653
app/drizzle/meta/0013_snapshot.json
Normal file
653
app/drizzle/meta/0013_snapshot.json
Normal 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": {}
|
||||
}
|
||||
}
|
||||
653
app/drizzle/meta/0014_snapshot.json
Normal file
653
app/drizzle/meta/0014_snapshot.json
Normal 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": {}
|
||||
}
|
||||
}
|
||||
653
app/drizzle/meta/0015_snapshot.json
Normal file
653
app/drizzle/meta/0015_snapshot.json
Normal 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": {}
|
||||
}
|
||||
}
|
||||
653
app/drizzle/meta/0016_snapshot.json
Normal file
653
app/drizzle/meta/0016_snapshot.json
Normal 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": {}
|
||||
}
|
||||
}
|
||||
653
app/drizzle/meta/0017_snapshot.json
Normal file
653
app/drizzle/meta/0017_snapshot.json
Normal 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": {}
|
||||
}
|
||||
}
|
||||
792
app/drizzle/meta/0018_snapshot.json
Normal file
792
app/drizzle/meta/0018_snapshot.json
Normal 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": {}
|
||||
}
|
||||
}
|
||||
807
app/drizzle/meta/0019_snapshot.json
Normal file
807
app/drizzle/meta/0019_snapshot.json
Normal 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": {}
|
||||
}
|
||||
}
|
||||
815
app/drizzle/meta/0020_snapshot.json
Normal file
815
app/drizzle/meta/0020_snapshot.json
Normal 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": {}
|
||||
}
|
||||
}
|
||||
@@ -78,6 +78,76 @@
|
||||
"when": 1762610065889,
|
||||
"tag": "0010_perfect_proemial_gods",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 11,
|
||||
"version": "6",
|
||||
"when": 1763644043601,
|
||||
"tag": "0011_familiar_stone_men",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 12,
|
||||
"version": "6",
|
||||
"when": 1764100562084,
|
||||
"tag": "0012_add_short_ids",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 13,
|
||||
"version": "6",
|
||||
"when": 1764182159797,
|
||||
"tag": "0013_elite_sprite",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 14,
|
||||
"version": "6",
|
||||
"when": 1764182405089,
|
||||
"tag": "0014_wild_echo",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 15,
|
||||
"version": "6",
|
||||
"when": 1764182465287,
|
||||
"tag": "0015_jazzy_sersi",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 16,
|
||||
"version": "6",
|
||||
"when": 1764194697035,
|
||||
"tag": "0016_fix-timestamps-to-ms",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 17,
|
||||
"version": "6",
|
||||
"when": 1764357897219,
|
||||
"tag": "0017_fix-compression-modes",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 18,
|
||||
"version": "6",
|
||||
"when": 1764794371040,
|
||||
"tag": "0018_breezy_invaders",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 19,
|
||||
"version": "6",
|
||||
"when": 1764839917446,
|
||||
"tag": "0019_secret_nomad",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 20,
|
||||
"version": "6",
|
||||
"when": 1764847918249,
|
||||
"tag": "0020_even_dexter_bennett",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -46,7 +46,7 @@ export function Layout({ children }: { children: React.ReactNode }) {
|
||||
<link rel="icon" type="image/svg+xml" href="/images/favicon/favicon.svg" />
|
||||
<link rel="shortcut icon" href="/images/favicon/favicon.ico" />
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="/images/favicon/apple-touch-icon.png" />
|
||||
<meta name="apple-mobile-web-app-title" content="Ironmount" />
|
||||
<meta name="apple-mobile-web-app-title" content="Zerobyte" />
|
||||
<link rel="manifest" href="/images/favicon/site.webmanifest" />
|
||||
<Meta />
|
||||
<Links />
|
||||
|
||||
@@ -7,13 +7,20 @@ export default [
|
||||
layout("./client/components/layout.tsx", [
|
||||
route("/", "./client/routes/root.tsx"),
|
||||
route("volumes", "./client/modules/volumes/routes/volumes.tsx"),
|
||||
route("volumes/create", "./client/modules/volumes/routes/create-volume.tsx"),
|
||||
route("volumes/:name", "./client/modules/volumes/routes/volume-details.tsx"),
|
||||
route("backups", "./client/modules/backups/routes/backups.tsx"),
|
||||
route("backups/create", "./client/modules/backups/routes/create-backup.tsx"),
|
||||
route("backups/:id", "./client/modules/backups/routes/backup-details.tsx"),
|
||||
route("backups/:id/:snapshotId/restore", "./client/modules/backups/routes/restore-snapshot.tsx"),
|
||||
route("repositories", "./client/modules/repositories/routes/repositories.tsx"),
|
||||
route("repositories/create", "./client/modules/repositories/routes/create-repository.tsx"),
|
||||
route("repositories/:name", "./client/modules/repositories/routes/repository-details.tsx"),
|
||||
route("repositories/:name/:snapshotId", "./client/modules/repositories/routes/snapshot-details.tsx"),
|
||||
route("repositories/:name/:snapshotId/restore", "./client/modules/repositories/routes/restore-snapshot.tsx"),
|
||||
route("notifications", "./client/modules/notifications/routes/notifications.tsx"),
|
||||
route("notifications/create", "./client/modules/notifications/routes/create-notification.tsx"),
|
||||
route("notifications/:id", "./client/modules/notifications/routes/notification-details.tsx"),
|
||||
route("settings", "./client/modules/settings/routes/settings.tsx"),
|
||||
]),
|
||||
] satisfies RouteConfig;
|
||||
|
||||
1
app/schemas/node_modules/arktype
generated
vendored
1
app/schemas/node_modules/arktype
generated
vendored
@@ -1 +0,0 @@
|
||||
../../../node_modules/.bun/arktype@2.1.26/node_modules/arktype
|
||||
97
app/schemas/notifications.ts
Normal file
97
app/schemas/notifications.ts
Normal file
@@ -0,0 +1,97 @@
|
||||
import { type } from "arktype";
|
||||
|
||||
export const NOTIFICATION_TYPES = {
|
||||
email: "email",
|
||||
slack: "slack",
|
||||
discord: "discord",
|
||||
gotify: "gotify",
|
||||
ntfy: "ntfy",
|
||||
pushover: "pushover",
|
||||
telegram: "telegram",
|
||||
custom: "custom",
|
||||
} as const;
|
||||
|
||||
export type NotificationType = keyof typeof NOTIFICATION_TYPES;
|
||||
|
||||
export const emailNotificationConfigSchema = type({
|
||||
type: "'email'",
|
||||
smtpHost: "string",
|
||||
smtpPort: "1 <= number <= 65535",
|
||||
username: "string",
|
||||
password: "string",
|
||||
from: "string",
|
||||
to: "string[]",
|
||||
useTLS: "boolean",
|
||||
});
|
||||
|
||||
export const slackNotificationConfigSchema = type({
|
||||
type: "'slack'",
|
||||
webhookUrl: "string",
|
||||
channel: "string?",
|
||||
username: "string?",
|
||||
iconEmoji: "string?",
|
||||
});
|
||||
|
||||
export const discordNotificationConfigSchema = type({
|
||||
type: "'discord'",
|
||||
webhookUrl: "string",
|
||||
username: "string?",
|
||||
avatarUrl: "string?",
|
||||
threadId: "string?",
|
||||
});
|
||||
|
||||
export const gotifyNotificationConfigSchema = type({
|
||||
type: "'gotify'",
|
||||
serverUrl: "string",
|
||||
token: "string",
|
||||
path: "string?",
|
||||
priority: "0 <= number <= 10",
|
||||
});
|
||||
|
||||
export const ntfyNotificationConfigSchema = type({
|
||||
type: "'ntfy'",
|
||||
serverUrl: "string?",
|
||||
topic: "string",
|
||||
priority: "'max' | 'high' | 'default' | 'low' | 'min'",
|
||||
username: "string?",
|
||||
password: "string?",
|
||||
});
|
||||
|
||||
export const pushoverNotificationConfigSchema = type({
|
||||
type: "'pushover'",
|
||||
userKey: "string",
|
||||
apiToken: "string",
|
||||
devices: "string?",
|
||||
priority: "-1 | 0 | 1",
|
||||
});
|
||||
|
||||
export const telegramNotificationConfigSchema = type({
|
||||
type: "'telegram'",
|
||||
botToken: "string",
|
||||
chatId: "string",
|
||||
});
|
||||
|
||||
export const customNotificationConfigSchema = type({
|
||||
type: "'custom'",
|
||||
shoutrrrUrl: "string",
|
||||
});
|
||||
|
||||
export const notificationConfigSchema = emailNotificationConfigSchema
|
||||
.or(slackNotificationConfigSchema)
|
||||
.or(discordNotificationConfigSchema)
|
||||
.or(gotifyNotificationConfigSchema)
|
||||
.or(ntfyNotificationConfigSchema)
|
||||
.or(pushoverNotificationConfigSchema)
|
||||
.or(telegramNotificationConfigSchema)
|
||||
.or(customNotificationConfigSchema);
|
||||
|
||||
export type NotificationConfig = typeof notificationConfigSchema.infer;
|
||||
|
||||
export const NOTIFICATION_EVENTS = {
|
||||
start: "start",
|
||||
success: "success",
|
||||
failure: "failure",
|
||||
warning: "warning",
|
||||
} as const;
|
||||
|
||||
export type NotificationEvent = keyof typeof NOTIFICATION_EVENTS;
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user