mirror of
https://github.com/nicotsx/ironmount.git
synced 2025-12-10 12:10:51 +01:00
Compare commits
8 Commits
feat/limit
...
altendorfm
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3c2791102f | ||
|
|
b70f973c12 | ||
|
|
14dadc85e7 | ||
|
|
ff16c6914d | ||
|
|
b333489ae6 | ||
|
|
0efe57b62e | ||
|
|
0b6f64e16d | ||
|
|
eb28667d90 |
8
.github/workflows/release.yml
vendored
8
.github/workflows/release.yml
vendored
@@ -54,7 +54,7 @@ jobs:
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ghcr.io/${{ github.repository_owner }}/zerobyte
|
||||
images: ghcr.io/${{ github.repository_owner }}/ironmount
|
||||
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/zerobyte:buildcache
|
||||
cache-to: type=registry,ref=ghcr.io/nicotsx/zerobyte:buildcache,mode=max
|
||||
cache-from: type=registry,ref=ghcr.io/nicotsx/ironmount:buildcache
|
||||
cache-to: type=registry,ref=ghcr.io/nicotsx/ironmount:buildcache,mode=max
|
||||
|
||||
- name: Build and push images
|
||||
uses: docker/build-push-action@v6
|
||||
@@ -74,8 +74,6 @@ 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
|
||||
|
||||
167
CONTRIBUTING.md
167
CONTRIBUTING.md
@@ -1,167 +0,0 @@
|
||||
# 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!
|
||||
12
Dockerfile
12
Dockerfile
@@ -2,8 +2,11 @@ ARG BUN_VERSION="1.3.1"
|
||||
|
||||
FROM oven/bun:${BUN_VERSION}-alpine AS base
|
||||
|
||||
RUN apk add --no-cache davfs2=1.6.1-r2 openssh-client
|
||||
|
||||
RUN apk add --no-cache \
|
||||
davfs2=1.6.1-r2 \
|
||||
mariadb-client \
|
||||
mysql-client \
|
||||
postgresql-client
|
||||
|
||||
# ------------------------------
|
||||
# DEPENDENCIES
|
||||
@@ -59,8 +62,6 @@ CMD ["bun", "run", "dev"]
|
||||
# ------------------------------
|
||||
FROM oven/bun:${BUN_VERSION} AS builder
|
||||
|
||||
ARG APP_VERSION=dev
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY ./package.json ./bun.lock ./
|
||||
@@ -68,9 +69,6 @@ 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
|
||||
|
||||
122
README.md
122
README.md
@@ -1,12 +1,12 @@
|
||||
<div align="center">
|
||||
<h1>Zerobyte</h1>
|
||||
<h1>Ironmount</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/zerobyte/blob/main/LICENSE">
|
||||
<img src="https://img.shields.io/github/license/nicotsx/zerobyte" />
|
||||
<a href="https://github.com/nicotsx/ironmount/blob/main/LICENSE">
|
||||
<img src="https://img.shields.io/github/license/nicotsx/ironmount" />
|
||||
</a>
|
||||
<br />
|
||||
<figure>
|
||||
<img src="https://github.com/nicotsx/zerobyte/blob/main/screenshots/backup-details.png?raw=true" alt="Demo" />
|
||||
<img src="https://github.com/nicotsx/ironmount/blob/main/screenshots/backup-details.png?raw=true" alt="Demo" />
|
||||
<figcaption>
|
||||
<p align="center">
|
||||
Backup management with scheduling and monitoring
|
||||
@@ -16,11 +16,11 @@
|
||||
</div>
|
||||
|
||||
> [!WARNING]
|
||||
> Zerobyte is still in version 0.x.x and is subject to major changes from version to version. I am developing the core features and collecting feedbacks. Expect bugs! Please open issues or feature requests
|
||||
> 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
|
||||
|
||||
## Intro
|
||||
|
||||
Zerobyte is a backup automation tool that helps you save your data across multiple storage backends. Built on top of Restic, it provides an modern web interface to schedule, manage, and monitor encrypted backups of your remote storage.
|
||||
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.
|
||||
|
||||
### Features
|
||||
|
||||
@@ -31,13 +31,13 @@ Zerobyte is a backup automation tool that helps you save your data across multip
|
||||
|
||||
## Installation
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
```yaml
|
||||
services:
|
||||
zerobyte:
|
||||
image: ghcr.io/nicotsx/zerobyte:v0.12
|
||||
container_name: zerobyte
|
||||
ironmount:
|
||||
image: ghcr.io/nicotsx/ironmount:v0.9
|
||||
container_name: ironmount
|
||||
restart: unless-stopped
|
||||
cap_add:
|
||||
- SYS_ADMIN
|
||||
@@ -46,14 +46,10 @@ services:
|
||||
devices:
|
||||
- /dev/fuse:/dev/fuse
|
||||
volumes:
|
||||
- /etc/localtime:/etc/localtime:ro
|
||||
- /var/lib/zerobyte:/var/lib/zerobyte
|
||||
- /var/lib/ironmount:/var/lib/ironmount
|
||||
```
|
||||
|
||||
> [!WARNING]
|
||||
> Do not try to change the location of the bind mount `/var/lib/zerobyte` on your host or store it on a network share. You will likely face permission issues and strong performance degradation.
|
||||
|
||||
Then, run the following command to start Zerobyte:
|
||||
Then, run the following command to start Ironmount:
|
||||
|
||||
```bash
|
||||
docker compose up -d
|
||||
@@ -63,17 +59,17 @@ Once the container is running, you can access the web interface at `http://<your
|
||||
|
||||
## Adding your first volume
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
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 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:
|
||||
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:
|
||||
|
||||
```diff
|
||||
services:
|
||||
zerobyte:
|
||||
image: ghcr.io/nicotsx/zerobyte:v0.12
|
||||
container_name: zerobyte
|
||||
ironmount:
|
||||
image: ghcr.io/nicotsx/ironmount:v0.9
|
||||
container_name: ironmount
|
||||
restart: unless-stopped
|
||||
cap_add:
|
||||
- SYS_ADMIN
|
||||
@@ -82,27 +78,26 @@ services:
|
||||
devices:
|
||||
- /dev/fuse:/dev/fuse
|
||||
volumes:
|
||||
- /etc/localtime:/etc/localtime:ro
|
||||
- /var/lib/zerobyte:/var/lib/zerobyte
|
||||
- /var/lib/ironmount:/var/lib/ironmount
|
||||
+ - /path/to/your/directory:/mydata
|
||||
```
|
||||
|
||||
After updating the `docker-compose.yml` file, restart the Zerobyte container to apply the changes:
|
||||
After updating the `docker-compose.yml` file, restart the Ironmount container to apply the changes:
|
||||
|
||||
```bash
|
||||
docker compose down
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||

|
||||

|
||||
|
||||
## Creating a repository
|
||||
|
||||
A repository is where your backups will be securely stored encrypted. Zerobyte supports multiple storage backends for your backup repositories:
|
||||
A repository is where your backups will be securely stored encrypted. Ironmount supports multiple storage backends for your backup repositories:
|
||||
|
||||
- **Local directories** - Store backups on local disk at `/var/lib/zerobyte/repositories/<repository-name>`
|
||||
- **Local directories** - Store backups on local disk at `/var/lib/ironmount/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
|
||||
@@ -114,7 +109,7 @@ To create a repository, navigate to the "Repositories" section in the web interf
|
||||
|
||||
### Using rclone for cloud storage
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
**Setup instructions:**
|
||||
|
||||
@@ -134,12 +129,12 @@ Zerobyte can use [rclone](https://rclone.org/) to support 40+ cloud storage prov
|
||||
rclone listremotes
|
||||
```
|
||||
|
||||
4. **Mount the rclone config into the Zerobyte container** by updating your `docker-compose.yml`:
|
||||
4. **Mount the rclone config into the Ironmount container** by updating your `docker-compose.yml`:
|
||||
```diff
|
||||
services:
|
||||
zerobyte:
|
||||
image: ghcr.io/nicotsx/zerobyte:v0.12
|
||||
container_name: zerobyte
|
||||
ironmount:
|
||||
image: ghcr.io/nicotsx/ironmount:v0.9
|
||||
container_name: ironmount
|
||||
restart: unless-stopped
|
||||
cap_add:
|
||||
- SYS_ADMIN
|
||||
@@ -148,21 +143,20 @@ Zerobyte can use [rclone](https://rclone.org/) to support 40+ cloud storage prov
|
||||
devices:
|
||||
- /dev/fuse:/dev/fuse
|
||||
volumes:
|
||||
- /etc/localtime:/etc/localtime:ro
|
||||
- /var/lib/zerobyte:/var/lib/zerobyte
|
||||
- /var/lib/ironmount:/var/lib/ironmount
|
||||
+ - ~/.config/rclone:/root/.config/rclone
|
||||
```
|
||||
|
||||
5. **Restart the Zerobyte container**:
|
||||
5. **Restart the Ironmount container**:
|
||||
```bash
|
||||
docker compose down
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
6. **Create a repository** in Zerobyte:
|
||||
6. **Create a repository** in Ironmount:
|
||||
- Select "rclone" as the repository type
|
||||
- Choose your configured remote from the dropdown
|
||||
- Specify the path within your remote (e.g., `backups/zerobyte`)
|
||||
- Specify the path within your remote (e.g., `backups/ironmount`)
|
||||
|
||||
For a complete list of supported providers, see the [rclone documentation](https://rclone.org/).
|
||||
|
||||
@@ -175,40 +169,39 @@ 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 Zerobyte will automatically execute the backup according to the defined schedule.
|
||||
After configuring the backup job, save it and Ironmount 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
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||

|
||||

|
||||
|
||||
## Propagating mounts to host
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
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:
|
||||
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:
|
||||
|
||||
```diff
|
||||
services:
|
||||
zerobyte:
|
||||
image: ghcr.io/nicotsx/zerobyte:v0.12
|
||||
container_name: zerobyte
|
||||
ironmount:
|
||||
image: ghcr.io/nicotsx/ironmount:v0.9
|
||||
container_name: ironmount
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "4096:4096"
|
||||
devices:
|
||||
- /dev/fuse:/dev/fuse
|
||||
volumes:
|
||||
- /etc/localtime:/etc/localtime:ro
|
||||
- - /var/lib/zerobyte:/var/lib/zerobyte
|
||||
+ - /var/lib/zerobyte:/var/lib/zerobyte:rshared
|
||||
- - /var/lib/ironmount:/var/lib/ironmount
|
||||
+ - /var/lib/ironmount:/var/lib/ironmount:rshared
|
||||
```
|
||||
|
||||
Restart the Zerobyte container to apply the changes:
|
||||
Restart the Ironmount container to apply the changes:
|
||||
|
||||
```bash
|
||||
docker compose down
|
||||
@@ -217,15 +210,15 @@ docker compose up -d
|
||||
|
||||
## Docker plugin
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
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:
|
||||
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:
|
||||
|
||||
```diff
|
||||
services:
|
||||
zerobyte:
|
||||
image: ghcr.io/nicotsx/zerobyte:v0.12
|
||||
container_name: zerobyte
|
||||
ironmount:
|
||||
image: ghcr.io/nicotsx/ironmount:v0.9
|
||||
container_name: ironmount
|
||||
restart: unless-stopped
|
||||
cap_add:
|
||||
- SYS_ADMIN
|
||||
@@ -234,21 +227,20 @@ services:
|
||||
devices:
|
||||
- /dev/fuse:/dev/fuse
|
||||
volumes:
|
||||
- /etc/localtime:/etc/localtime:ro
|
||||
- - /var/lib/zerobyte:/var/lib/zerobyte
|
||||
+ - /var/lib/zerobyte:/var/lib/zerobyte:rshared
|
||||
- - /var/lib/ironmount:/var/lib/ironmount
|
||||
+ - /var/lib/ironmount:/var/lib/ironmount:rshared
|
||||
+ - /run/docker/plugins:/run/docker/plugins
|
||||
+ - /var/run/docker.sock:/var/run/docker.sock
|
||||
```
|
||||
|
||||
Restart the Zerobyte container to apply the changes:
|
||||
Restart the Ironmount container to apply the changes:
|
||||
|
||||
```bash
|
||||
docker compose down
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
Your Zerobyte volumes will now be available as Docker volumes that you can mount into other containers using the `--volume` flag:
|
||||
Your Ironmount 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
|
||||
@@ -267,7 +259,7 @@ volumes:
|
||||
external: true
|
||||
```
|
||||
|
||||
The volume name format is `im-<volume-name>` where `<volume-name>` is the name you assigned to the volume in Zerobyte. You can verify that the volume is available by running:
|
||||
The volume name format is `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:
|
||||
|
||||
```bash
|
||||
docker volume ls
|
||||
@@ -279,7 +271,7 @@ This project includes the following third-party software components:
|
||||
|
||||
### Restic
|
||||
|
||||
Zerobyte includes [Restic](https://github.com/restic/restic) for backup functionality.
|
||||
Ironmount includes [Restic](https://github.com/restic/restic) for backup functionality.
|
||||
|
||||
- **License**: BSD 2-Clause License
|
||||
- **Copyright**: Copyright (c) 2014, Alexander Neumann <alexander@bumpern.de>
|
||||
@@ -287,7 +279,3 @@ Zerobyte includes [Restic](https://github.com/restic/restic) for backup function
|
||||
- **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.
|
||||
|
||||
@@ -3,8 +3,8 @@
|
||||
import { type DefaultError, queryOptions, type UseMutationOptions } from '@tanstack/react-query';
|
||||
|
||||
import { client } from '../client.gen';
|
||||
import { browseFilesystem, changePassword, createBackupSchedule, createRepository, createVolume, deleteBackupSchedule, deleteRepository, deleteSnapshot, deleteVolume, doctorRepository, downloadResticPassword, getBackupSchedule, getBackupScheduleForVolume, getContainersUsingVolume, getMe, getRepository, getSnapshotDetails, getStatus, getSystemInfo, getVolume, healthCheckVolume, listBackupSchedules, listFiles, listRcloneRemotes, listRepositories, listSnapshotFiles, listSnapshots, listVolumes, login, logout, mountVolume, type Options, register, restoreSnapshot, runBackupNow, runForget, stopBackup, testConnection, unmountVolume, updateBackupSchedule, updateVolume } from '../sdk.gen';
|
||||
import type { BrowseFilesystemData, BrowseFilesystemResponse, ChangePasswordData, ChangePasswordResponse, CreateBackupScheduleData, CreateBackupScheduleResponse, CreateRepositoryData, CreateRepositoryResponse, CreateVolumeData, CreateVolumeResponse, DeleteBackupScheduleData, DeleteBackupScheduleResponse, DeleteRepositoryData, DeleteRepositoryResponse, DeleteSnapshotData, DeleteSnapshotResponse, DeleteVolumeData, DeleteVolumeResponse, DoctorRepositoryData, DoctorRepositoryResponse, DownloadResticPasswordData, DownloadResticPasswordResponse, GetBackupScheduleData, GetBackupScheduleForVolumeData, GetBackupScheduleForVolumeResponse, GetBackupScheduleResponse, GetContainersUsingVolumeData, GetContainersUsingVolumeResponse, GetMeData, GetMeResponse, GetRepositoryData, GetRepositoryResponse, GetSnapshotDetailsData, GetSnapshotDetailsResponse, GetStatusData, GetStatusResponse, GetSystemInfoData, GetSystemInfoResponse, GetVolumeData, GetVolumeResponse, HealthCheckVolumeData, HealthCheckVolumeResponse, ListBackupSchedulesData, ListBackupSchedulesResponse, ListFilesData, ListFilesResponse, ListRcloneRemotesData, ListRcloneRemotesResponse, ListRepositoriesData, ListRepositoriesResponse, ListSnapshotFilesData, ListSnapshotFilesResponse, ListSnapshotsData, ListSnapshotsResponse, ListVolumesData, ListVolumesResponse, LoginData, LoginResponse, LogoutData, LogoutResponse, MountVolumeData, MountVolumeResponse, RegisterData, RegisterResponse, RestoreSnapshotData, RestoreSnapshotResponse, RunBackupNowData, RunBackupNowResponse, RunForgetData, RunForgetResponse, StopBackupData, StopBackupResponse, TestConnectionData, TestConnectionResponse, UnmountVolumeData, UnmountVolumeResponse, UpdateBackupScheduleData, UpdateBackupScheduleResponse, UpdateVolumeData, UpdateVolumeResponse } from '../types.gen';
|
||||
import { browseFilesystem, changePassword, createBackupSchedule, createRepository, createVolume, deleteBackupSchedule, deleteRepository, deleteVolume, doctorRepository, downloadResticPassword, getBackupSchedule, getBackupScheduleForVolume, getContainersUsingVolume, getMe, getRepository, getSnapshotDetails, getStatus, getSystemInfo, getVolume, healthCheckVolume, listBackupSchedules, listFiles, listRcloneRemotes, listRepositories, listSnapshotFiles, listSnapshots, listVolumes, login, logout, mountVolume, type Options, register, restoreSnapshot, runBackupNow, runForget, stopBackup, testConnection, unmountVolume, updateBackupSchedule, updateVolume } from '../sdk.gen';
|
||||
import type { BrowseFilesystemData, BrowseFilesystemResponse, ChangePasswordData, ChangePasswordResponse, CreateBackupScheduleData, CreateBackupScheduleResponse, CreateRepositoryData, CreateRepositoryResponse, CreateVolumeData, CreateVolumeResponse, DeleteBackupScheduleData, DeleteBackupScheduleResponse, DeleteRepositoryData, DeleteRepositoryResponse, DeleteVolumeData, DeleteVolumeResponse, DoctorRepositoryData, DoctorRepositoryResponse, DownloadResticPasswordData, DownloadResticPasswordResponse, GetBackupScheduleData, GetBackupScheduleForVolumeData, GetBackupScheduleForVolumeResponse, GetBackupScheduleResponse, GetContainersUsingVolumeData, GetContainersUsingVolumeResponse, GetMeData, GetMeResponse, GetRepositoryData, GetRepositoryResponse, GetSnapshotDetailsData, GetSnapshotDetailsResponse, GetStatusData, GetStatusResponse, GetSystemInfoData, GetSystemInfoResponse, GetVolumeData, GetVolumeResponse, HealthCheckVolumeData, HealthCheckVolumeResponse, ListBackupSchedulesData, ListBackupSchedulesResponse, ListFilesData, ListFilesResponse, ListRcloneRemotesData, ListRcloneRemotesResponse, ListRepositoriesData, ListRepositoriesResponse, ListSnapshotFilesData, ListSnapshotFilesResponse, ListSnapshotsData, ListSnapshotsResponse, ListVolumesData, ListVolumesResponse, LoginData, LoginResponse, LogoutData, LogoutResponse, MountVolumeData, MountVolumeResponse, RegisterData, RegisterResponse, RestoreSnapshotData, RestoreSnapshotResponse, RunBackupNowData, RunBackupNowResponse, RunForgetData, RunForgetResponse, StopBackupData, StopBackupResponse, TestConnectionData, TestConnectionResponse, UnmountVolumeData, UnmountVolumeResponse, UpdateBackupScheduleData, UpdateBackupScheduleResponse, UpdateVolumeData, UpdateVolumeResponse } from '../types.gen';
|
||||
|
||||
/**
|
||||
* Register a new user
|
||||
@@ -460,23 +460,6 @@ export const listSnapshotsOptions = (options: Options<ListSnapshotsData>) => que
|
||||
queryKey: listSnapshotsQueryKey(options)
|
||||
});
|
||||
|
||||
/**
|
||||
* Delete a specific snapshot from a repository
|
||||
*/
|
||||
export const deleteSnapshotMutation = (options?: Partial<Options<DeleteSnapshotData>>): UseMutationOptions<DeleteSnapshotResponse, DefaultError, Options<DeleteSnapshotData>> => {
|
||||
const mutationOptions: UseMutationOptions<DeleteSnapshotResponse, DefaultError, Options<DeleteSnapshotData>> = {
|
||||
mutationFn: async (fnOptions) => {
|
||||
const { data } = await deleteSnapshot({
|
||||
...options,
|
||||
...fnOptions,
|
||||
throwOnError: true
|
||||
});
|
||||
return data;
|
||||
}
|
||||
};
|
||||
return mutationOptions;
|
||||
};
|
||||
|
||||
export const getSnapshotDetailsQueryKey = (options: Options<GetSnapshotDetailsData>) => createQueryKey("getSnapshotDetails", options);
|
||||
|
||||
/**
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import type { Client, Options as Options2, TDataShape } from './client';
|
||||
import { client } from './client.gen';
|
||||
import type { BrowseFilesystemData, BrowseFilesystemResponses, ChangePasswordData, ChangePasswordResponses, CreateBackupScheduleData, CreateBackupScheduleResponses, CreateRepositoryData, CreateRepositoryResponses, CreateVolumeData, CreateVolumeResponses, DeleteBackupScheduleData, DeleteBackupScheduleResponses, DeleteRepositoryData, DeleteRepositoryResponses, DeleteSnapshotData, DeleteSnapshotResponses, 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, RunForgetData, RunForgetResponses, StopBackupData, StopBackupErrors, StopBackupResponses, TestConnectionData, TestConnectionResponses, UnmountVolumeData, UnmountVolumeResponses, UpdateBackupScheduleData, UpdateBackupScheduleResponses, UpdateVolumeData, UpdateVolumeErrors, UpdateVolumeResponses } from './types.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, RunForgetData, RunForgetResponses, StopBackupData, StopBackupErrors, StopBackupResponses, TestConnectionData, TestConnectionResponses, UnmountVolumeData, UnmountVolumeResponses, UpdateBackupScheduleData, UpdateBackupScheduleResponses, UpdateVolumeData, UpdateVolumeErrors, UpdateVolumeResponses } from './types.gen';
|
||||
|
||||
export type Options<TData extends TDataShape = TDataShape, ThrowOnError extends boolean = boolean> = Options2<TData, ThrowOnError> & {
|
||||
/**
|
||||
@@ -286,16 +286,6 @@ export const listSnapshots = <ThrowOnError extends boolean = false>(options: Opt
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Delete a specific snapshot from a repository
|
||||
*/
|
||||
export const deleteSnapshot = <ThrowOnError extends boolean = false>(options: Options<DeleteSnapshotData, ThrowOnError>) => {
|
||||
return (options.client ?? client).delete<DeleteSnapshotResponses, unknown, ThrowOnError>({
|
||||
url: '/api/v1/repositories/{name}/snapshots/{snapshotId}',
|
||||
...options
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Get details of a specific snapshot
|
||||
*/
|
||||
|
||||
@@ -157,6 +157,22 @@ export type ListVolumesResponses = {
|
||||
backend: 'directory';
|
||||
path: string;
|
||||
readOnly?: false;
|
||||
} | {
|
||||
backend: 'mariadb';
|
||||
database: string;
|
||||
host: string;
|
||||
password: string;
|
||||
username: string;
|
||||
port?: number;
|
||||
dumpOptions?: Array<string>;
|
||||
} | {
|
||||
backend: 'mysql';
|
||||
database: string;
|
||||
host: string;
|
||||
password: string;
|
||||
username: string;
|
||||
port?: number;
|
||||
dumpOptions?: Array<string>;
|
||||
} | {
|
||||
backend: 'nfs';
|
||||
exportPath: string;
|
||||
@@ -164,6 +180,15 @@ export type ListVolumesResponses = {
|
||||
version: '3' | '4' | '4.1';
|
||||
port?: number;
|
||||
readOnly?: boolean;
|
||||
} | {
|
||||
backend: 'postgres';
|
||||
database: string;
|
||||
host: string;
|
||||
password: string;
|
||||
username: string;
|
||||
dumpFormat?: 'custom' | 'directory' | 'plain';
|
||||
port?: number;
|
||||
dumpOptions?: Array<string>;
|
||||
} | {
|
||||
backend: 'smb';
|
||||
password: string;
|
||||
@@ -190,7 +215,7 @@ export type ListVolumesResponses = {
|
||||
lastHealthCheck: number;
|
||||
name: string;
|
||||
status: 'error' | 'mounted' | 'unmounted';
|
||||
type: 'directory' | 'nfs' | 'smb' | 'webdav';
|
||||
type: 'directory' | 'mariadb' | 'mysql' | 'nfs' | 'postgres' | 'smb' | 'webdav';
|
||||
updatedAt: number;
|
||||
}>;
|
||||
};
|
||||
@@ -203,6 +228,22 @@ export type CreateVolumeData = {
|
||||
backend: 'directory';
|
||||
path: string;
|
||||
readOnly?: false;
|
||||
} | {
|
||||
backend: 'mariadb';
|
||||
database: string;
|
||||
host: string;
|
||||
password: string;
|
||||
username: string;
|
||||
port?: number;
|
||||
dumpOptions?: Array<string>;
|
||||
} | {
|
||||
backend: 'mysql';
|
||||
database: string;
|
||||
host: string;
|
||||
password: string;
|
||||
username: string;
|
||||
port?: number;
|
||||
dumpOptions?: Array<string>;
|
||||
} | {
|
||||
backend: 'nfs';
|
||||
exportPath: string;
|
||||
@@ -210,6 +251,15 @@ export type CreateVolumeData = {
|
||||
version: '3' | '4' | '4.1';
|
||||
port?: number;
|
||||
readOnly?: boolean;
|
||||
} | {
|
||||
backend: 'postgres';
|
||||
database: string;
|
||||
host: string;
|
||||
password: string;
|
||||
username: string;
|
||||
dumpFormat?: 'custom' | 'directory' | 'plain';
|
||||
port?: number;
|
||||
dumpOptions?: Array<string>;
|
||||
} | {
|
||||
backend: 'smb';
|
||||
password: string;
|
||||
@@ -247,6 +297,22 @@ export type CreateVolumeResponses = {
|
||||
backend: 'directory';
|
||||
path: string;
|
||||
readOnly?: false;
|
||||
} | {
|
||||
backend: 'mariadb';
|
||||
database: string;
|
||||
host: string;
|
||||
password: string;
|
||||
username: string;
|
||||
port?: number;
|
||||
dumpOptions?: Array<string>;
|
||||
} | {
|
||||
backend: 'mysql';
|
||||
database: string;
|
||||
host: string;
|
||||
password: string;
|
||||
username: string;
|
||||
port?: number;
|
||||
dumpOptions?: Array<string>;
|
||||
} | {
|
||||
backend: 'nfs';
|
||||
exportPath: string;
|
||||
@@ -254,6 +320,15 @@ export type CreateVolumeResponses = {
|
||||
version: '3' | '4' | '4.1';
|
||||
port?: number;
|
||||
readOnly?: boolean;
|
||||
} | {
|
||||
backend: 'postgres';
|
||||
database: string;
|
||||
host: string;
|
||||
password: string;
|
||||
username: string;
|
||||
dumpFormat?: 'custom' | 'directory' | 'plain';
|
||||
port?: number;
|
||||
dumpOptions?: Array<string>;
|
||||
} | {
|
||||
backend: 'smb';
|
||||
password: string;
|
||||
@@ -280,7 +355,7 @@ export type CreateVolumeResponses = {
|
||||
lastHealthCheck: number;
|
||||
name: string;
|
||||
status: 'error' | 'mounted' | 'unmounted';
|
||||
type: 'directory' | 'nfs' | 'smb' | 'webdav';
|
||||
type: 'directory' | 'mariadb' | 'mysql' | 'nfs' | 'postgres' | 'smb' | 'webdav';
|
||||
updatedAt: number;
|
||||
};
|
||||
};
|
||||
@@ -293,6 +368,22 @@ export type TestConnectionData = {
|
||||
backend: 'directory';
|
||||
path: string;
|
||||
readOnly?: false;
|
||||
} | {
|
||||
backend: 'mariadb';
|
||||
database: string;
|
||||
host: string;
|
||||
password: string;
|
||||
username: string;
|
||||
port?: number;
|
||||
dumpOptions?: Array<string>;
|
||||
} | {
|
||||
backend: 'mysql';
|
||||
database: string;
|
||||
host: string;
|
||||
password: string;
|
||||
username: string;
|
||||
port?: number;
|
||||
dumpOptions?: Array<string>;
|
||||
} | {
|
||||
backend: 'nfs';
|
||||
exportPath: string;
|
||||
@@ -300,6 +391,15 @@ export type TestConnectionData = {
|
||||
version: '3' | '4' | '4.1';
|
||||
port?: number;
|
||||
readOnly?: boolean;
|
||||
} | {
|
||||
backend: 'postgres';
|
||||
database: string;
|
||||
host: string;
|
||||
password: string;
|
||||
username: string;
|
||||
dumpFormat?: 'custom' | 'directory' | 'plain';
|
||||
port?: number;
|
||||
dumpOptions?: Array<string>;
|
||||
} | {
|
||||
backend: 'smb';
|
||||
password: string;
|
||||
@@ -390,6 +490,22 @@ export type GetVolumeResponses = {
|
||||
backend: 'directory';
|
||||
path: string;
|
||||
readOnly?: false;
|
||||
} | {
|
||||
backend: 'mariadb';
|
||||
database: string;
|
||||
host: string;
|
||||
password: string;
|
||||
username: string;
|
||||
port?: number;
|
||||
dumpOptions?: Array<string>;
|
||||
} | {
|
||||
backend: 'mysql';
|
||||
database: string;
|
||||
host: string;
|
||||
password: string;
|
||||
username: string;
|
||||
port?: number;
|
||||
dumpOptions?: Array<string>;
|
||||
} | {
|
||||
backend: 'nfs';
|
||||
exportPath: string;
|
||||
@@ -397,6 +513,15 @@ export type GetVolumeResponses = {
|
||||
version: '3' | '4' | '4.1';
|
||||
port?: number;
|
||||
readOnly?: boolean;
|
||||
} | {
|
||||
backend: 'postgres';
|
||||
database: string;
|
||||
host: string;
|
||||
password: string;
|
||||
username: string;
|
||||
dumpFormat?: 'custom' | 'directory' | 'plain';
|
||||
port?: number;
|
||||
dumpOptions?: Array<string>;
|
||||
} | {
|
||||
backend: 'smb';
|
||||
password: string;
|
||||
@@ -423,7 +548,7 @@ export type GetVolumeResponses = {
|
||||
lastHealthCheck: number;
|
||||
name: string;
|
||||
status: 'error' | 'mounted' | 'unmounted';
|
||||
type: 'directory' | 'nfs' | 'smb' | 'webdav';
|
||||
type: 'directory' | 'mariadb' | 'mysql' | 'nfs' | 'postgres' | 'smb' | 'webdav';
|
||||
updatedAt: number;
|
||||
};
|
||||
};
|
||||
@@ -438,6 +563,22 @@ export type UpdateVolumeData = {
|
||||
backend: 'directory';
|
||||
path: string;
|
||||
readOnly?: false;
|
||||
} | {
|
||||
backend: 'mariadb';
|
||||
database: string;
|
||||
host: string;
|
||||
password: string;
|
||||
username: string;
|
||||
port?: number;
|
||||
dumpOptions?: Array<string>;
|
||||
} | {
|
||||
backend: 'mysql';
|
||||
database: string;
|
||||
host: string;
|
||||
password: string;
|
||||
username: string;
|
||||
port?: number;
|
||||
dumpOptions?: Array<string>;
|
||||
} | {
|
||||
backend: 'nfs';
|
||||
exportPath: string;
|
||||
@@ -445,6 +586,15 @@ export type UpdateVolumeData = {
|
||||
version: '3' | '4' | '4.1';
|
||||
port?: number;
|
||||
readOnly?: boolean;
|
||||
} | {
|
||||
backend: 'postgres';
|
||||
database: string;
|
||||
host: string;
|
||||
password: string;
|
||||
username: string;
|
||||
dumpFormat?: 'custom' | 'directory' | 'plain';
|
||||
port?: number;
|
||||
dumpOptions?: Array<string>;
|
||||
} | {
|
||||
backend: 'smb';
|
||||
password: string;
|
||||
@@ -490,6 +640,22 @@ export type UpdateVolumeResponses = {
|
||||
backend: 'directory';
|
||||
path: string;
|
||||
readOnly?: false;
|
||||
} | {
|
||||
backend: 'mariadb';
|
||||
database: string;
|
||||
host: string;
|
||||
password: string;
|
||||
username: string;
|
||||
port?: number;
|
||||
dumpOptions?: Array<string>;
|
||||
} | {
|
||||
backend: 'mysql';
|
||||
database: string;
|
||||
host: string;
|
||||
password: string;
|
||||
username: string;
|
||||
port?: number;
|
||||
dumpOptions?: Array<string>;
|
||||
} | {
|
||||
backend: 'nfs';
|
||||
exportPath: string;
|
||||
@@ -497,6 +663,15 @@ export type UpdateVolumeResponses = {
|
||||
version: '3' | '4' | '4.1';
|
||||
port?: number;
|
||||
readOnly?: boolean;
|
||||
} | {
|
||||
backend: 'postgres';
|
||||
database: string;
|
||||
host: string;
|
||||
password: string;
|
||||
username: string;
|
||||
dumpFormat?: 'custom' | 'directory' | 'plain';
|
||||
port?: number;
|
||||
dumpOptions?: Array<string>;
|
||||
} | {
|
||||
backend: 'smb';
|
||||
password: string;
|
||||
@@ -523,7 +698,7 @@ export type UpdateVolumeResponses = {
|
||||
lastHealthCheck: number;
|
||||
name: string;
|
||||
status: 'error' | 'mounted' | 'unmounted';
|
||||
type: 'directory' | 'nfs' | 'smb' | 'webdav';
|
||||
type: 'directory' | 'mariadb' | 'mysql' | 'nfs' | 'postgres' | 'smb' | 'webdav';
|
||||
updatedAt: number;
|
||||
};
|
||||
};
|
||||
@@ -741,30 +916,12 @@ export type ListRepositoriesResponses = {
|
||||
name: string;
|
||||
customPassword?: string;
|
||||
isExistingRepository?: boolean;
|
||||
path?: string;
|
||||
} | {
|
||||
backend: 'rclone';
|
||||
path: string;
|
||||
remote: string;
|
||||
customPassword?: string;
|
||||
isExistingRepository?: boolean;
|
||||
} | {
|
||||
backend: 'rest';
|
||||
url: string;
|
||||
customPassword?: string;
|
||||
isExistingRepository?: boolean;
|
||||
password?: string;
|
||||
path?: string;
|
||||
username?: string;
|
||||
} | {
|
||||
backend: 'sftp';
|
||||
host: string;
|
||||
path: string;
|
||||
privateKey: string;
|
||||
user: string;
|
||||
port?: number;
|
||||
customPassword?: string;
|
||||
isExistingRepository?: boolean;
|
||||
};
|
||||
createdAt: number;
|
||||
id: string;
|
||||
@@ -772,7 +929,7 @@ export type ListRepositoriesResponses = {
|
||||
lastError: string | null;
|
||||
name: string;
|
||||
status: 'error' | 'healthy' | 'unknown' | null;
|
||||
type: 'azure' | 'gcs' | 'local' | 'r2' | 'rclone' | 'rest' | 's3' | 'sftp';
|
||||
type: 'azure' | 'gcs' | 'local' | 'r2' | 'rclone' | 's3';
|
||||
updatedAt: number;
|
||||
}>;
|
||||
};
|
||||
@@ -817,30 +974,12 @@ export type CreateRepositoryData = {
|
||||
name: string;
|
||||
customPassword?: string;
|
||||
isExistingRepository?: boolean;
|
||||
path?: string;
|
||||
} | {
|
||||
backend: 'rclone';
|
||||
path: string;
|
||||
remote: string;
|
||||
customPassword?: string;
|
||||
isExistingRepository?: boolean;
|
||||
} | {
|
||||
backend: 'rest';
|
||||
url: string;
|
||||
customPassword?: string;
|
||||
isExistingRepository?: boolean;
|
||||
password?: string;
|
||||
path?: string;
|
||||
username?: string;
|
||||
} | {
|
||||
backend: 'sftp';
|
||||
host: string;
|
||||
path: string;
|
||||
privateKey: string;
|
||||
user: string;
|
||||
port?: number;
|
||||
customPassword?: string;
|
||||
isExistingRepository?: boolean;
|
||||
};
|
||||
name: string;
|
||||
compressionMode?: 'auto' | 'better' | 'fastest' | 'max' | 'off';
|
||||
@@ -955,30 +1094,12 @@ export type GetRepositoryResponses = {
|
||||
name: string;
|
||||
customPassword?: string;
|
||||
isExistingRepository?: boolean;
|
||||
path?: string;
|
||||
} | {
|
||||
backend: 'rclone';
|
||||
path: string;
|
||||
remote: string;
|
||||
customPassword?: string;
|
||||
isExistingRepository?: boolean;
|
||||
} | {
|
||||
backend: 'rest';
|
||||
url: string;
|
||||
customPassword?: string;
|
||||
isExistingRepository?: boolean;
|
||||
password?: string;
|
||||
path?: string;
|
||||
username?: string;
|
||||
} | {
|
||||
backend: 'sftp';
|
||||
host: string;
|
||||
path: string;
|
||||
privateKey: string;
|
||||
user: string;
|
||||
port?: number;
|
||||
customPassword?: string;
|
||||
isExistingRepository?: boolean;
|
||||
};
|
||||
createdAt: number;
|
||||
id: string;
|
||||
@@ -986,7 +1107,7 @@ export type GetRepositoryResponses = {
|
||||
lastError: string | null;
|
||||
name: string;
|
||||
status: 'error' | 'healthy' | 'unknown' | null;
|
||||
type: 'azure' | 'gcs' | 'local' | 'r2' | 'rclone' | 'rest' | 's3' | 'sftp';
|
||||
type: 'azure' | 'gcs' | 'local' | 'r2' | 'rclone' | 's3';
|
||||
updatedAt: number;
|
||||
};
|
||||
};
|
||||
@@ -1019,27 +1140,6 @@ export type ListSnapshotsResponses = {
|
||||
|
||||
export type ListSnapshotsResponse = ListSnapshotsResponses[keyof ListSnapshotsResponses];
|
||||
|
||||
export type DeleteSnapshotData = {
|
||||
body?: never;
|
||||
path: {
|
||||
name: string;
|
||||
snapshotId: string;
|
||||
};
|
||||
query?: never;
|
||||
url: '/api/v1/repositories/{name}/snapshots/{snapshotId}';
|
||||
};
|
||||
|
||||
export type DeleteSnapshotResponses = {
|
||||
/**
|
||||
* Snapshot deleted successfully
|
||||
*/
|
||||
200: {
|
||||
message: string;
|
||||
};
|
||||
};
|
||||
|
||||
export type DeleteSnapshotResponse = DeleteSnapshotResponses[keyof DeleteSnapshotResponses];
|
||||
|
||||
export type GetSnapshotDetailsData = {
|
||||
body?: never;
|
||||
path: {
|
||||
@@ -1220,30 +1320,12 @@ export type ListBackupSchedulesResponses = {
|
||||
name: string;
|
||||
customPassword?: string;
|
||||
isExistingRepository?: boolean;
|
||||
path?: string;
|
||||
} | {
|
||||
backend: 'rclone';
|
||||
path: string;
|
||||
remote: string;
|
||||
customPassword?: string;
|
||||
isExistingRepository?: boolean;
|
||||
} | {
|
||||
backend: 'rest';
|
||||
url: string;
|
||||
customPassword?: string;
|
||||
isExistingRepository?: boolean;
|
||||
password?: string;
|
||||
path?: string;
|
||||
username?: string;
|
||||
} | {
|
||||
backend: 'sftp';
|
||||
host: string;
|
||||
path: string;
|
||||
privateKey: string;
|
||||
user: string;
|
||||
port?: number;
|
||||
customPassword?: string;
|
||||
isExistingRepository?: boolean;
|
||||
};
|
||||
createdAt: number;
|
||||
id: string;
|
||||
@@ -1251,7 +1333,7 @@ export type ListBackupSchedulesResponses = {
|
||||
lastError: string | null;
|
||||
name: string;
|
||||
status: 'error' | 'healthy' | 'unknown' | null;
|
||||
type: 'azure' | 'gcs' | 'local' | 'r2' | 'rclone' | 'rest' | 's3' | 'sftp';
|
||||
type: 'azure' | 'gcs' | 'local' | 'r2' | 'rclone' | 's3';
|
||||
updatedAt: number;
|
||||
};
|
||||
repositoryId: string;
|
||||
@@ -1271,6 +1353,22 @@ export type ListBackupSchedulesResponses = {
|
||||
backend: 'directory';
|
||||
path: string;
|
||||
readOnly?: false;
|
||||
} | {
|
||||
backend: 'mariadb';
|
||||
database: string;
|
||||
host: string;
|
||||
password: string;
|
||||
username: string;
|
||||
port?: number;
|
||||
dumpOptions?: Array<string>;
|
||||
} | {
|
||||
backend: 'mysql';
|
||||
database: string;
|
||||
host: string;
|
||||
password: string;
|
||||
username: string;
|
||||
port?: number;
|
||||
dumpOptions?: Array<string>;
|
||||
} | {
|
||||
backend: 'nfs';
|
||||
exportPath: string;
|
||||
@@ -1278,6 +1376,15 @@ export type ListBackupSchedulesResponses = {
|
||||
version: '3' | '4' | '4.1';
|
||||
port?: number;
|
||||
readOnly?: boolean;
|
||||
} | {
|
||||
backend: 'postgres';
|
||||
database: string;
|
||||
host: string;
|
||||
password: string;
|
||||
username: string;
|
||||
dumpFormat?: 'custom' | 'directory' | 'plain';
|
||||
port?: number;
|
||||
dumpOptions?: Array<string>;
|
||||
} | {
|
||||
backend: 'smb';
|
||||
password: string;
|
||||
@@ -1304,7 +1411,7 @@ export type ListBackupSchedulesResponses = {
|
||||
lastHealthCheck: number;
|
||||
name: string;
|
||||
status: 'error' | 'mounted' | 'unmounted';
|
||||
type: 'directory' | 'nfs' | 'smb' | 'webdav';
|
||||
type: 'directory' | 'mariadb' | 'mysql' | 'nfs' | 'postgres' | 'smb' | 'webdav';
|
||||
updatedAt: number;
|
||||
};
|
||||
volumeId: number;
|
||||
@@ -1451,30 +1558,12 @@ export type GetBackupScheduleResponses = {
|
||||
name: string;
|
||||
customPassword?: string;
|
||||
isExistingRepository?: boolean;
|
||||
path?: string;
|
||||
} | {
|
||||
backend: 'rclone';
|
||||
path: string;
|
||||
remote: string;
|
||||
customPassword?: string;
|
||||
isExistingRepository?: boolean;
|
||||
} | {
|
||||
backend: 'rest';
|
||||
url: string;
|
||||
customPassword?: string;
|
||||
isExistingRepository?: boolean;
|
||||
password?: string;
|
||||
path?: string;
|
||||
username?: string;
|
||||
} | {
|
||||
backend: 'sftp';
|
||||
host: string;
|
||||
path: string;
|
||||
privateKey: string;
|
||||
user: string;
|
||||
port?: number;
|
||||
customPassword?: string;
|
||||
isExistingRepository?: boolean;
|
||||
};
|
||||
createdAt: number;
|
||||
id: string;
|
||||
@@ -1482,7 +1571,7 @@ export type GetBackupScheduleResponses = {
|
||||
lastError: string | null;
|
||||
name: string;
|
||||
status: 'error' | 'healthy' | 'unknown' | null;
|
||||
type: 'azure' | 'gcs' | 'local' | 'r2' | 'rclone' | 'rest' | 's3' | 'sftp';
|
||||
type: 'azure' | 'gcs' | 'local' | 'r2' | 'rclone' | 's3';
|
||||
updatedAt: number;
|
||||
};
|
||||
repositoryId: string;
|
||||
@@ -1502,6 +1591,22 @@ export type GetBackupScheduleResponses = {
|
||||
backend: 'directory';
|
||||
path: string;
|
||||
readOnly?: false;
|
||||
} | {
|
||||
backend: 'mariadb';
|
||||
database: string;
|
||||
host: string;
|
||||
password: string;
|
||||
username: string;
|
||||
port?: number;
|
||||
dumpOptions?: Array<string>;
|
||||
} | {
|
||||
backend: 'mysql';
|
||||
database: string;
|
||||
host: string;
|
||||
password: string;
|
||||
username: string;
|
||||
port?: number;
|
||||
dumpOptions?: Array<string>;
|
||||
} | {
|
||||
backend: 'nfs';
|
||||
exportPath: string;
|
||||
@@ -1509,6 +1614,15 @@ export type GetBackupScheduleResponses = {
|
||||
version: '3' | '4' | '4.1';
|
||||
port?: number;
|
||||
readOnly?: boolean;
|
||||
} | {
|
||||
backend: 'postgres';
|
||||
database: string;
|
||||
host: string;
|
||||
password: string;
|
||||
username: string;
|
||||
dumpFormat?: 'custom' | 'directory' | 'plain';
|
||||
port?: number;
|
||||
dumpOptions?: Array<string>;
|
||||
} | {
|
||||
backend: 'smb';
|
||||
password: string;
|
||||
@@ -1535,7 +1649,7 @@ export type GetBackupScheduleResponses = {
|
||||
lastHealthCheck: number;
|
||||
name: string;
|
||||
status: 'error' | 'mounted' | 'unmounted';
|
||||
type: 'directory' | 'nfs' | 'smb' | 'webdav';
|
||||
type: 'directory' | 'mariadb' | 'mysql' | 'nfs' | 'postgres' | 'smb' | 'webdav';
|
||||
updatedAt: number;
|
||||
};
|
||||
volumeId: number;
|
||||
@@ -1663,30 +1777,12 @@ export type GetBackupScheduleForVolumeResponses = {
|
||||
name: string;
|
||||
customPassword?: string;
|
||||
isExistingRepository?: boolean;
|
||||
path?: string;
|
||||
} | {
|
||||
backend: 'rclone';
|
||||
path: string;
|
||||
remote: string;
|
||||
customPassword?: string;
|
||||
isExistingRepository?: boolean;
|
||||
} | {
|
||||
backend: 'rest';
|
||||
url: string;
|
||||
customPassword?: string;
|
||||
isExistingRepository?: boolean;
|
||||
password?: string;
|
||||
path?: string;
|
||||
username?: string;
|
||||
} | {
|
||||
backend: 'sftp';
|
||||
host: string;
|
||||
path: string;
|
||||
privateKey: string;
|
||||
user: string;
|
||||
port?: number;
|
||||
customPassword?: string;
|
||||
isExistingRepository?: boolean;
|
||||
};
|
||||
createdAt: number;
|
||||
id: string;
|
||||
@@ -1694,7 +1790,7 @@ export type GetBackupScheduleForVolumeResponses = {
|
||||
lastError: string | null;
|
||||
name: string;
|
||||
status: 'error' | 'healthy' | 'unknown' | null;
|
||||
type: 'azure' | 'gcs' | 'local' | 'r2' | 'rclone' | 'rest' | 's3' | 'sftp';
|
||||
type: 'azure' | 'gcs' | 'local' | 'r2' | 'rclone' | 's3';
|
||||
updatedAt: number;
|
||||
};
|
||||
repositoryId: string;
|
||||
@@ -1714,6 +1810,22 @@ export type GetBackupScheduleForVolumeResponses = {
|
||||
backend: 'directory';
|
||||
path: string;
|
||||
readOnly?: false;
|
||||
} | {
|
||||
backend: 'mariadb';
|
||||
database: string;
|
||||
host: string;
|
||||
password: string;
|
||||
username: string;
|
||||
port?: number;
|
||||
dumpOptions?: Array<string>;
|
||||
} | {
|
||||
backend: 'mysql';
|
||||
database: string;
|
||||
host: string;
|
||||
password: string;
|
||||
username: string;
|
||||
port?: number;
|
||||
dumpOptions?: Array<string>;
|
||||
} | {
|
||||
backend: 'nfs';
|
||||
exportPath: string;
|
||||
@@ -1721,6 +1833,15 @@ export type GetBackupScheduleForVolumeResponses = {
|
||||
version: '3' | '4' | '4.1';
|
||||
port?: number;
|
||||
readOnly?: boolean;
|
||||
} | {
|
||||
backend: 'postgres';
|
||||
database: string;
|
||||
host: string;
|
||||
password: string;
|
||||
username: string;
|
||||
dumpFormat?: 'custom' | 'directory' | 'plain';
|
||||
port?: number;
|
||||
dumpOptions?: Array<string>;
|
||||
} | {
|
||||
backend: 'smb';
|
||||
password: string;
|
||||
@@ -1747,7 +1868,7 @@ export type GetBackupScheduleForVolumeResponses = {
|
||||
lastHealthCheck: number;
|
||||
name: string;
|
||||
status: 'error' | 'mounted' | 'unmounted';
|
||||
type: 'directory' | 'nfs' | 'smb' | 'webdav';
|
||||
type: 'directory' | 'mariadb' | 'mysql' | 'nfs' | 'postgres' | 'smb' | 'webdav';
|
||||
updatedAt: number;
|
||||
};
|
||||
volumeId: number;
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import { CalendarClock, Database, HardDrive, Settings } from "lucide-react";
|
||||
import { CalendarClock, Database, HardDrive, Mountain, Settings } from "lucide-react";
|
||||
import { Link, NavLink } from "react-router";
|
||||
import {
|
||||
Sidebar,
|
||||
SidebarContent,
|
||||
SidebarFooter,
|
||||
SidebarGroup,
|
||||
SidebarGroupContent,
|
||||
SidebarHeader,
|
||||
@@ -14,7 +13,6 @@ 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 = [
|
||||
{
|
||||
@@ -46,17 +44,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">
|
||||
<img
|
||||
src="/images/zerobyte.png"
|
||||
alt="Zerobyte Logo"
|
||||
className={cn("h-8 w-8 flex-shrink-0 object-contain -ml-2")}
|
||||
/>
|
||||
<Mountain className="size-5 text-strong-accent" />
|
||||
<span
|
||||
className={cn("text-base transition-all duration-200 -ml-1", {
|
||||
className={cn("text-base transition-all duration-200", {
|
||||
"opacity-0 w-0 overflow-hidden ": state === "collapsed",
|
||||
})}
|
||||
>
|
||||
Zerobyte
|
||||
Ironmount
|
||||
</span>
|
||||
</Link>
|
||||
</SidebarHeader>
|
||||
@@ -91,15 +85,6 @@ 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,3 +1,4 @@
|
||||
import { Mountain } from "lucide-react";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
type AuthLayoutProps = {
|
||||
@@ -12,8 +13,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">
|
||||
<img src="/images/zerobyte.png" alt="Zerobyte Logo" className="h-5 w-5 object-contain" />
|
||||
<span className="text-lg font-semibold">Zerobyte</span>
|
||||
<Mountain className="size-5 text-strong-accent" />
|
||||
<span className="text-lg font-semibold">Ironmount</span>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
|
||||
@@ -10,24 +10,12 @@ 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, AlertTriangle } from "lucide-react";
|
||||
import { ExternalLink } 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",
|
||||
@@ -53,8 +41,6 @@ const defaultValuesForType = {
|
||||
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 = ({
|
||||
@@ -80,8 +66,6 @@ export const CreateRepositoryForm = ({
|
||||
const watchedIsExistingRepository = watch("isExistingRepository");
|
||||
|
||||
const [passwordMode, setPasswordMode] = useState<"default" | "custom">("default");
|
||||
const [showPathBrowser, setShowPathBrowser] = useState(false);
|
||||
const [showPathWarning, setShowPathWarning] = useState(false);
|
||||
|
||||
const { capabilities } = useSystemInfo();
|
||||
|
||||
@@ -142,8 +126,6 @@ export const CreateRepositoryForm = ({
|
||||
<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">
|
||||
@@ -232,12 +214,12 @@ export const CreateRepositoryForm = ({
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
<SelectItem value="default">Use Zerobyte's password</SelectItem>
|
||||
<SelectItem value="default">Use Ironmount'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
|
||||
Choose whether to use Ironmount's master password or enter a custom password for the existing
|
||||
repository.
|
||||
</FormDescription>
|
||||
</FormItem>
|
||||
@@ -263,76 +245,6 @@ export const CreateRepositoryForm = ({
|
||||
</>
|
||||
)}
|
||||
|
||||
{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
|
||||
@@ -624,7 +536,7 @@ export const CreateRepositoryForm = ({
|
||||
<FormItem>
|
||||
<FormLabel>Path</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="backups/zerobyte" {...field} />
|
||||
<Input placeholder="backups/ironmount" {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>Path within the remote where backups will be stored.</FormDescription>
|
||||
<FormMessage />
|
||||
@@ -634,150 +546,6 @@ 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
|
||||
|
||||
@@ -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/zerobyte/issues/new"
|
||||
href="https://github.com/nicotsx/ironmount/issues/new"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="flex items-center gap-2"
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Database, HardDrive, Cloud, Server } from "lucide-react";
|
||||
import { Database, HardDrive, Cloud } from "lucide-react";
|
||||
import type { RepositoryBackend } from "~/schemas/restic";
|
||||
|
||||
type Props = {
|
||||
@@ -14,9 +14,6 @@ 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} />;
|
||||
}
|
||||
|
||||
@@ -1,26 +1,10 @@
|
||||
import { useState } from "react";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { Calendar, Clock, Database, FolderTree, HardDrive, Trash2 } from "lucide-react";
|
||||
import { Calendar, Clock, Database, FolderTree, HardDrive } from "lucide-react";
|
||||
import { 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";
|
||||
import { deleteSnapshotMutation } from "~/client/api-client/@tanstack/react-query.gen";
|
||||
import { parseError } from "~/client/lib/errors";
|
||||
|
||||
type Snapshot = ListSnapshotsResponse[number];
|
||||
|
||||
@@ -31,149 +15,81 @@ type Props = {
|
||||
|
||||
export const SnapshotsTable = ({ snapshots, repositoryName }: 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">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) => (
|
||||
<TableRow
|
||||
key={snapshot.short_id}
|
||||
className="hover:bg-accent/50 cursor-pointer"
|
||||
onClick={() => handleRowClick(snapshot.short_id)}
|
||||
>
|
||||
<TableCell className="font-mono text-sm">
|
||||
<div className="flex items-center gap-2">
|
||||
<HardDrive className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="text-strong-accent">{snapshot.short_id}</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-2">
|
||||
<Calendar className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="text-sm">{new Date(snapshot.time).toLocaleString()}</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-2">
|
||||
<Database className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="font-medium">
|
||||
<ByteSize bytes={snapshot.size} base={1024} />
|
||||
</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="hidden md:table-cell">
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<Clock className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="text-sm text-muted-foreground">{formatDuration(snapshot.duration / 1000)}</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="hidden lg:table-cell">
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<FolderTree className="h-4 w-4 text-muted-foreground" />
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="text-xs bg-primary/10 text-primary rounded-md px-2 py-1 cursor-help">
|
||||
{snapshot.paths.length} {snapshot.paths.length === 1 ? "path" : "paths"}
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" className="max-w-md">
|
||||
<div className="flex flex-col gap-1">
|
||||
{snapshot.paths.map((path) => (
|
||||
<div key={`${snapshot.short_id}-${path}`} className="text-xs font-mono">
|
||||
{path}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={(e) => handleDeleteClick(e, snapshot.short_id)}
|
||||
disabled={deleteSnapshot.isPending}
|
||||
>
|
||||
<Trash2 className="h-4 w-4 text-destructive" />
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</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"
|
||||
<div className="overflow-x-auto">
|
||||
<Table className="border-t">
|
||||
<TableHeader className="bg-card-header">
|
||||
<TableRow>
|
||||
<TableHead className="uppercase">Snapshot ID</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>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{snapshots.map((snapshot) => (
|
||||
<TableRow
|
||||
key={snapshot.short_id}
|
||||
className="hover:bg-accent/50 cursor-pointer"
|
||||
onClick={() => handleRowClick(snapshot.short_id)}
|
||||
>
|
||||
Delete snapshot
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</>
|
||||
<TableCell className="font-mono text-sm">
|
||||
<div className="flex items-center gap-2">
|
||||
<HardDrive className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="text-strong-accent">{snapshot.short_id}</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-2">
|
||||
<Calendar className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="text-sm">{new Date(snapshot.time).toLocaleString()}</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-2">
|
||||
<Database className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="font-medium">
|
||||
<ByteSize bytes={snapshot.size} base={1024} />
|
||||
</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="hidden md:table-cell">
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<Clock className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="text-sm text-muted-foreground">{formatDuration(snapshot.duration / 1000)}</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="hidden lg:table-cell">
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<FolderTree className="h-4 w-4 text-muted-foreground" />
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="text-xs bg-primary/10 text-primary rounded-md px-2 py-1 cursor-help">
|
||||
{snapshot.paths.length} {snapshot.paths.length === 1 ? "path" : "paths"}
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" className="max-w-md">
|
||||
<div className="flex flex-col gap-1">
|
||||
{snapshot.paths.map((path) => (
|
||||
<div key={`${snapshot.short_id}-${path}`} className="text-xs font-mono">
|
||||
{path}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Cloud, Folder, Server, Share2 } from "lucide-react";
|
||||
import { Cloud, Database, Folder, Server, Share2 } from "lucide-react";
|
||||
import type { BackendType } from "~/schemas/volumes";
|
||||
|
||||
type VolumeIconProps = {
|
||||
@@ -32,6 +32,24 @@ const getIconAndColor = (backend: BackendType) => {
|
||||
color: "text-green-600 dark:text-green-400",
|
||||
label: "WebDAV",
|
||||
};
|
||||
case "mariadb":
|
||||
return {
|
||||
icon: Database,
|
||||
color: "text-teal-600 dark:text-teal-400",
|
||||
label: "MariaDB",
|
||||
};
|
||||
case "mysql":
|
||||
return {
|
||||
icon: Database,
|
||||
color: "text-cyan-600 dark:text-cyan-400",
|
||||
label: "MySQL",
|
||||
};
|
||||
case "postgres":
|
||||
return {
|
||||
icon: Database,
|
||||
color: "text-indigo-600 dark:text-indigo-400",
|
||||
label: "PostgreSQL",
|
||||
};
|
||||
default:
|
||||
return {
|
||||
icon: Folder,
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
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: "Zerobyte - Download Recovery Key" },
|
||||
{ title: "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: "Zerobyte - Login" },
|
||||
{ title: "Login" },
|
||||
{
|
||||
name: "description",
|
||||
content: "Sign in to your Zerobyte account.",
|
||||
content: "Sign in to your Ironmount account.",
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
@@ -24,10 +24,10 @@ export const clientMiddleware = [authMiddleware];
|
||||
|
||||
export function meta(_: Route.MetaArgs) {
|
||||
return [
|
||||
{ title: "Zerobyte - Onboarding" },
|
||||
{ title: "Onboarding" },
|
||||
{
|
||||
name: "description",
|
||||
content: "Welcome to Zerobyte. Create your admin account to get started.",
|
||||
content: "Welcome to Ironmount. Create your admin account to get started.",
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -82,7 +82,7 @@ export default function OnboardingPage() {
|
||||
};
|
||||
|
||||
return (
|
||||
<AuthLayout title="Welcome to Zerobyte" description="Create the admin user to get started">
|
||||
<AuthLayout title="Welcome to Ironmount" description="Create the admin user to get started">
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
|
||||
<FormField
|
||||
|
||||
@@ -29,7 +29,6 @@ const internalFormSchema = type({
|
||||
frequency: "string",
|
||||
dailyTime: "string?",
|
||||
weeklyDay: "string?",
|
||||
limitUploadKbps: "number?",
|
||||
keepLast: "number?",
|
||||
keepHourly: "number?",
|
||||
keepDaily: "number?",
|
||||
@@ -87,7 +86,6 @@ const backupScheduleToFormValues = (schedule?: BackupSchedule): InternalFormValu
|
||||
weeklyDay,
|
||||
includePatterns: schedule.includePatterns || undefined,
|
||||
excludePatternsText: schedule.excludePatterns?.join("\n") || undefined,
|
||||
limitUploadKbps: schedule.limitUploadKbps || undefined,
|
||||
...schedule.retentionPolicy,
|
||||
};
|
||||
};
|
||||
@@ -249,29 +247,6 @@ export const CreateScheduleForm = ({ initialValues, formId, onSubmit, volume }:
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="limitUploadKbps"
|
||||
render={({ field }) => (
|
||||
<FormItem className="md:col-span-2">
|
||||
<FormLabel>Upload speed limit (KB/s)</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
type="number"
|
||||
min={0}
|
||||
placeholder="Unlimited"
|
||||
onChange={(v) => field.onChange(v.target.value ? Number(v.target.value) : undefined)}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Limit upload bandwidth in kilobytes per second. Leave empty for unlimited speed.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -279,8 +254,8 @@ export const CreateScheduleForm = ({ initialValues, formId, onSubmit, volume }:
|
||||
<CardHeader>
|
||||
<CardTitle>Backup paths</CardTitle>
|
||||
<CardDescription>
|
||||
Select which folders or files 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>
|
||||
@@ -507,12 +482,6 @@ export const CreateScheduleForm = ({ initialValues, formId, onSubmit, volume }:
|
||||
{repositoriesData?.find((r) => r.id === formValues.repositoryId)?.name || "—"}
|
||||
</p>
|
||||
</div>
|
||||
{formValues.limitUploadKbps && (
|
||||
<div>
|
||||
<p className="text-xs uppercase text-muted-foreground">Upload speed limit</p>
|
||||
<p className="font-medium">{formValues.limitUploadKbps} KB/s</p>
|
||||
</div>
|
||||
)}
|
||||
{formValues.includePatterns && formValues.includePatterns.length > 0 && (
|
||||
<div>
|
||||
<p className="text-xs uppercase text-muted-foreground">Include paths</p>
|
||||
|
||||
@@ -26,12 +26,10 @@ interface Props {
|
||||
snapshot: Snapshot;
|
||||
repositoryName: string;
|
||||
volume?: Volume;
|
||||
onDeleteSnapshot?: (snapshotId: string) => void;
|
||||
isDeletingSnapshot?: boolean;
|
||||
}
|
||||
|
||||
export const SnapshotFileBrowser = (props: Props) => {
|
||||
const { snapshot, repositoryName, volume, onDeleteSnapshot, isDeletingSnapshot } = props;
|
||||
const { snapshot, repositoryName, volume } = props;
|
||||
|
||||
const isReadOnly = volume?.config && "readOnly" in volume.config && volume.config.readOnly === true;
|
||||
|
||||
@@ -138,43 +136,30 @@ export const SnapshotFileBrowser = (props: Props) => {
|
||||
<CardTitle>File Browser</CardTitle>
|
||||
<CardDescription>{`Viewing snapshot from ${new Date(snapshot?.time ?? 0).toLocaleString()}`}</CardDescription>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
{selectedPaths.size > 0 && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span tabIndex={isReadOnly ? 0 : undefined}>
|
||||
<Button
|
||||
onClick={handleRestoreClick}
|
||||
variant="primary"
|
||||
size="sm"
|
||||
disabled={isRestoring || isReadOnly}
|
||||
>
|
||||
{isRestoring
|
||||
? "Restoring..."
|
||||
: `Restore ${selectedPaths.size} selected ${selectedPaths.size === 1 ? "item" : "items"}`}
|
||||
</Button>
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
{isReadOnly && (
|
||||
<TooltipContent className="text-center">
|
||||
<p>Volume is mounted as read-only.</p>
|
||||
<p>Please remount with read-only disabled to restore files.</p>
|
||||
</TooltipContent>
|
||||
)}
|
||||
</Tooltip>
|
||||
)}
|
||||
{onDeleteSnapshot && (
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
onClick={() => onDeleteSnapshot(snapshot.short_id)}
|
||||
disabled={isDeletingSnapshot}
|
||||
loading={isDeletingSnapshot}
|
||||
>
|
||||
{isDeletingSnapshot ? "Deleting..." : "Delete Snapshot"}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
{selectedPaths.size > 0 && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span tabIndex={isReadOnly ? 0 : undefined}>
|
||||
<Button
|
||||
onClick={handleRestoreClick}
|
||||
variant="primary"
|
||||
size="sm"
|
||||
disabled={isRestoring || isReadOnly}
|
||||
>
|
||||
{isRestoring
|
||||
? "Restoring..."
|
||||
: `Restore ${selectedPaths.size} selected ${selectedPaths.size === 1 ? "item" : "items"}`}
|
||||
</Button>
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
{isReadOnly && (
|
||||
<TooltipContent className="text-center">
|
||||
<p>Volume is mounted as read-only.</p>
|
||||
<p>Please remount with read-only disabled to restore files.</p>
|
||||
</TooltipContent>
|
||||
)}
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="flex-1 overflow-hidden flex flex-col p-0">
|
||||
|
||||
@@ -3,16 +3,6 @@ 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,
|
||||
@@ -20,7 +10,6 @@ import {
|
||||
listSnapshotsOptions,
|
||||
updateBackupScheduleMutation,
|
||||
stopBackupMutation,
|
||||
deleteSnapshotMutation,
|
||||
} from "~/client/api-client/@tanstack/react-query.gen";
|
||||
import { parseError } from "~/client/lib/errors";
|
||||
import { getCronExpression } from "~/utils/utils";
|
||||
@@ -40,7 +29,7 @@ export const handle = {
|
||||
|
||||
export function meta(_: Route.MetaArgs) {
|
||||
return [
|
||||
{ title: "Zerobyte - Backup Job Details" },
|
||||
{ title: "Backup Job Details" },
|
||||
{
|
||||
name: "description",
|
||||
content: "View and manage backup job configuration, schedule, and snapshots.",
|
||||
@@ -61,8 +50,6 @@ 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 } }),
|
||||
@@ -123,17 +110,6 @@ 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;
|
||||
|
||||
@@ -156,7 +132,6 @@ export default function ScheduleDetailsPage({ params, loaderData }: Route.Compon
|
||||
retentionPolicy: Object.keys(retentionPolicy).length > 0 ? retentionPolicy : undefined,
|
||||
includePatterns: formValues.includePatterns,
|
||||
excludePatterns: formValues.excludePatterns,
|
||||
limitUploadKbps: formValues.limitUploadKbps,
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -171,31 +146,10 @@ export default function ScheduleDetailsPage({ params, loaderData }: Route.Compon
|
||||
retentionPolicy: schedule.retentionPolicy || undefined,
|
||||
includePatterns: schedule.includePatterns || undefined,
|
||||
excludePatterns: schedule.excludePatterns || undefined,
|
||||
limitUploadKbps: schedule.limitUploadKbps || undefined,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
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>
|
||||
@@ -237,32 +191,8 @@ export default function ScheduleDetailsPage({ params, loaderData }: Route.Compon
|
||||
snapshot={selectedSnapshot}
|
||||
repositoryName={schedule.repository.name}
|
||||
volume={schedule.volume}
|
||||
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: "Zerobyte - Backup Jobs" },
|
||||
{ title: "Backup Jobs" },
|
||||
{
|
||||
name: "description",
|
||||
content: "Automate volume backups with scheduled jobs and retention policies.",
|
||||
|
||||
@@ -24,7 +24,7 @@ export const handle = {
|
||||
|
||||
export function meta(_: Route.MetaArgs) {
|
||||
return [
|
||||
{ title: "Zerobyte - Create Backup Job" },
|
||||
{ title: "Create Backup Job" },
|
||||
{
|
||||
name: "description",
|
||||
content: "Create a new automated backup job for your volumes.",
|
||||
@@ -90,7 +90,6 @@ export default function CreateBackup({ loaderData }: Route.ComponentProps) {
|
||||
retentionPolicy: Object.keys(retentionPolicy).length > 0 ? retentionPolicy : undefined,
|
||||
includePatterns: formValues.includePatterns,
|
||||
excludePatterns: formValues.excludePatterns,
|
||||
limitUploadKbps: formValues.limitUploadKbps,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
@@ -17,7 +17,7 @@ export const handle = {
|
||||
|
||||
export function meta(_: Route.MetaArgs) {
|
||||
return [
|
||||
{ title: "Zerobyte - Create Repository" },
|
||||
{ title: "Create Repository" },
|
||||
{
|
||||
name: "description",
|
||||
content: "Create a new backup repository with encryption and compression.",
|
||||
|
||||
@@ -20,7 +20,7 @@ export const handle = {
|
||||
|
||||
export function meta(_: Route.MetaArgs) {
|
||||
return [
|
||||
{ title: "Zerobyte - Repositories" },
|
||||
{ title: "Repositories" },
|
||||
{
|
||||
name: "description",
|
||||
content: "Manage your backup repositories with encryption and compression.",
|
||||
|
||||
@@ -36,7 +36,7 @@ export const handle = {
|
||||
|
||||
export function meta({ params }: Route.MetaArgs) {
|
||||
return [
|
||||
{ title: `Zerobyte - ${params.name}` },
|
||||
{ title: params.name },
|
||||
{
|
||||
name: "description",
|
||||
content: "View repository configuration, status, and snapshots.",
|
||||
@@ -181,8 +181,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 will not remove the
|
||||
actual data from the backend storage, only the repository configuration will be deleted.
|
||||
Are you sure you want to delete the repository <strong>{data.name}</strong>? This action cannot be undone
|
||||
and will remove all backup data.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<div className="flex gap-3 justify-end">
|
||||
|
||||
@@ -17,7 +17,7 @@ export const handle = {
|
||||
|
||||
export function meta({ params }: Route.MetaArgs) {
|
||||
return [
|
||||
{ title: `Zerobyte - Snapshot ${params.snapshotId}` },
|
||||
{ title: `Snapshot ${params.snapshotId}` },
|
||||
{
|
||||
name: "description",
|
||||
content: "Browse and restore files from a backup snapshot.",
|
||||
|
||||
@@ -30,7 +30,7 @@ export const handle = {
|
||||
|
||||
export function meta(_: Route.MetaArgs) {
|
||||
return [
|
||||
{ title: "Zerobyte - Settings" },
|
||||
{ title: "Settings" },
|
||||
{
|
||||
name: "description",
|
||||
content: "Manage your account settings and preferences.",
|
||||
|
||||
@@ -6,13 +6,31 @@ import { useEffect, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { cn, slugify } from "~/client/lib/utils";
|
||||
import { deepClean } from "~/utils/object";
|
||||
import { DirectoryBrowser } from "./directory-browser";
|
||||
import { Button } from "./ui/button";
|
||||
import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from "./ui/form";
|
||||
import { Input } from "./ui/input";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select";
|
||||
import { volumeConfigSchema } from "~/schemas/volumes";
|
||||
import { testConnectionMutation } from "../api-client/@tanstack/react-query.gen";
|
||||
import { testConnectionMutation } from "~/client/api-client/@tanstack/react-query.gen";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "~/client/components/ui/form";
|
||||
import { Input } from "~/client/components/ui/input";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectLabel,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "~/client/components/ui/select";
|
||||
import { Button } from "~/client/components/ui/button";
|
||||
import { DirectoryBrowser } from "~/client/components/directory-browser";
|
||||
|
||||
const SUPPORTS_CONNECTION_TEST = ["nfs", "smb", "webdav", "mariadb", "mysql", "postgres"];
|
||||
|
||||
export const formSchema = type({
|
||||
name: "2<=string<=32",
|
||||
@@ -35,6 +53,9 @@ const defaultValuesForType = {
|
||||
nfs: { backend: "nfs" as const, port: 2049, version: "4.1" as const },
|
||||
smb: { backend: "smb" as const, port: 445, vers: "3.0" as const },
|
||||
webdav: { backend: "webdav" as const, port: 80, ssl: false },
|
||||
mariadb: { backend: "mariadb" as const, port: 3306 },
|
||||
mysql: { backend: "mysql" as const, port: 3306 },
|
||||
postgres: { backend: "postgres" as const, port: 5432, dumpFormat: "custom" as const },
|
||||
};
|
||||
|
||||
export const CreateVolumeForm = ({ onSubmit, mode = "create", initialValues, formId, loading, className }: Props) => {
|
||||
@@ -81,7 +102,7 @@ export const CreateVolumeForm = ({ onSubmit, mode = "create", initialValues, for
|
||||
const handleTestConnection = async () => {
|
||||
const formValues = getValues();
|
||||
|
||||
if (formValues.backend === "nfs" || formValues.backend === "smb" || formValues.backend === "webdav") {
|
||||
if (SUPPORTS_CONNECTION_TEST.includes(formValues.backend)) {
|
||||
testBackendConnection.mutate({
|
||||
body: { config: formValues },
|
||||
});
|
||||
@@ -121,15 +142,26 @@ export const CreateVolumeForm = ({ onSubmit, mode = "create", initialValues, for
|
||||
<FormLabel>Backend</FormLabel>
|
||||
<Select onValueChange={field.onChange} defaultValue={field.value} value={field.value}>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectTrigger className="w-[280px]">
|
||||
<SelectValue placeholder="Select a backend" />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
<SelectItem value="directory">Directory</SelectItem>
|
||||
<SelectItem value="nfs">NFS</SelectItem>
|
||||
<SelectItem value="smb">SMB</SelectItem>
|
||||
<SelectItem value="webdav">WebDAV</SelectItem>
|
||||
<SelectGroup>
|
||||
<SelectItem value="directory">Directory</SelectItem>
|
||||
</SelectGroup>
|
||||
<SelectGroup>
|
||||
<SelectLabel>Network Storage</SelectLabel>
|
||||
<SelectItem value="nfs">NFS</SelectItem>
|
||||
<SelectItem value="smb">SMB</SelectItem>
|
||||
<SelectItem value="webdav">WebDAV</SelectItem>
|
||||
</SelectGroup>
|
||||
<SelectGroup>
|
||||
<SelectLabel>Databases</SelectLabel>
|
||||
<SelectItem value="mariadb">MariaDB</SelectItem>
|
||||
<SelectItem value="mysql">MySQL</SelectItem>
|
||||
<SelectItem value="postgres">PostgreSQL</SelectItem>
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormDescription>Choose the storage backend for this volume.</FormDescription>
|
||||
@@ -207,12 +239,7 @@ export const CreateVolumeForm = ({ onSubmit, mode = "create", initialValues, for
|
||||
<FormItem>
|
||||
<FormLabel>Port</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="number"
|
||||
placeholder="2049"
|
||||
{...field}
|
||||
onChange={(e) => field.onChange(parseInt(e.target.value, 10) || undefined)}
|
||||
/>
|
||||
<Input type="number" placeholder="2049" {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>NFS server port (default: 2049).</FormDescription>
|
||||
<FormMessage />
|
||||
@@ -337,12 +364,7 @@ export const CreateVolumeForm = ({ onSubmit, mode = "create", initialValues, for
|
||||
<FormItem>
|
||||
<FormLabel>Port</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="number"
|
||||
placeholder="80"
|
||||
{...field}
|
||||
onChange={(e) => field.onChange(parseInt(e.target.value, 10) || undefined)}
|
||||
/>
|
||||
<Input type="number" placeholder="80" {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>WebDAV server port (default: 80 for HTTP, 443 for HTTPS).</FormDescription>
|
||||
<FormMessage />
|
||||
@@ -546,7 +568,259 @@ export const CreateVolumeForm = ({ onSubmit, mode = "create", initialValues, for
|
||||
</>
|
||||
)}
|
||||
|
||||
{watchedBackend && watchedBackend !== "directory" && (
|
||||
{watchedBackend === "mariadb" && (
|
||||
<>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="host"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Host</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="localhost" {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>MariaDB server hostname or IP address.</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="port"
|
||||
defaultValue={3306}
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Port</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="number" placeholder="3306" {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>MariaDB server port (default: 3306).</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="username"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Username</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="root" {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>Database user with backup privileges.</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="password"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Password</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="password" placeholder="••••••••" {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>Password for database authentication.</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="database"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Database</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="myapp_production" {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>Name of the database to backup.</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{watchedBackend === "mysql" && (
|
||||
<>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="host"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Host</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="localhost" {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>MySQL server hostname or IP address.</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="port"
|
||||
defaultValue={3306}
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Port</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="number" placeholder="3306" {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>MySQL server port (default: 3306).</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="username"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Username</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="root" {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>Database user with backup privileges.</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="password"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Password</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="password" placeholder="••••••••" {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>Password for database authentication.</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="database"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Database</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="myapp_production" {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>Name of the database to backup.</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{watchedBackend === "postgres" && (
|
||||
<>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="host"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Host</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="localhost" {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>PostgreSQL server hostname or IP address.</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="port"
|
||||
defaultValue={5432}
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Port</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="number" placeholder="5432" {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>PostgreSQL server port (default: 5432).</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="username"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Username</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="postgres" {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>Database user with backup privileges.</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="password"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Password</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="password" placeholder="••••••••" {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>Password for database authentication.</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="database"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Database</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="myapp_production" {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>Name of the database to backup.</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="dumpFormat"
|
||||
defaultValue="custom"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Dump Format</FormLabel>
|
||||
<Select onValueChange={field.onChange} defaultValue={field.value || "custom"}>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select dump format" />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
<SelectItem value="custom">Custom (Compressed)</SelectItem>
|
||||
<SelectItem value="plain">Plain SQL</SelectItem>
|
||||
<SelectItem value="directory">Directory</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormDescription>Format for database dumps (custom recommended).</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{watchedBackend !== "directory" && (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
@@ -4,12 +4,12 @@ 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";
|
||||
import { CreateVolumeForm, type FormValues } from "../components/create-volume-form";
|
||||
|
||||
export const handle = {
|
||||
breadcrumb: () => [{ label: "Volumes", href: "/volumes" }, { label: "Create" }],
|
||||
@@ -17,7 +17,7 @@ export const handle = {
|
||||
|
||||
export function meta(_: Route.MetaArgs) {
|
||||
return [
|
||||
{ title: "Zerobyte - Create Volume" },
|
||||
{ title: "Create Volume" },
|
||||
{
|
||||
name: "description",
|
||||
content: "Create a new storage volume with automatic mounting and health checks.",
|
||||
|
||||
@@ -37,7 +37,7 @@ export const handle = {
|
||||
|
||||
export function meta({ params }: Route.MetaArgs) {
|
||||
return [
|
||||
{ title: `Zerobyte - ${params.name}` },
|
||||
{ title: params.name },
|
||||
{
|
||||
name: "description",
|
||||
content: "View and manage volume details, configuration, and files.",
|
||||
@@ -119,6 +119,8 @@ export default function VolumeDetails({ loaderData }: Route.ComponentProps) {
|
||||
const { volume, statfs } = data;
|
||||
const dockerAvailable = capabilities.docker;
|
||||
|
||||
const isDatabaseVolume = ["mariadb", "mysql", "postgres"].includes(volume.config.backend);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex flex-col items-start xs:items-center xs:flex-row xs:justify-between">
|
||||
@@ -152,7 +154,9 @@ export default function VolumeDetails({ loaderData }: Route.ComponentProps) {
|
||||
<Tabs value={activeTab} onValueChange={(value) => setSearchParams({ tab: value })} className="mt-4">
|
||||
<TabsList className="mb-2">
|
||||
<TabsTrigger value="info">Configuration</TabsTrigger>
|
||||
<TabsTrigger value="files">Files</TabsTrigger>
|
||||
<TabsTrigger disabled={isDatabaseVolume} value="files">
|
||||
Files
|
||||
</TabsTrigger>
|
||||
<Tooltip>
|
||||
<TooltipTrigger>
|
||||
<TabsTrigger disabled={!dockerAvailable} value="docker">
|
||||
@@ -167,9 +171,11 @@ export default function VolumeDetails({ loaderData }: Route.ComponentProps) {
|
||||
<TabsContent value="info">
|
||||
<VolumeInfoTabContent volume={volume} statfs={statfs} />
|
||||
</TabsContent>
|
||||
<TabsContent value="files">
|
||||
<FilesTabContent volume={volume} />
|
||||
</TabsContent>
|
||||
{!isDatabaseVolume && (
|
||||
<TabsContent value="files">
|
||||
<FilesTabContent volume={volume} />
|
||||
</TabsContent>
|
||||
)}
|
||||
{dockerAvailable && (
|
||||
<TabsContent value="docker">
|
||||
<DockerTabContent volume={volume} />
|
||||
|
||||
@@ -20,7 +20,7 @@ export const handle = {
|
||||
|
||||
export function meta(_: Route.MetaArgs) {
|
||||
return [
|
||||
{ title: "Zerobyte - Volumes" },
|
||||
{ title: "Volumes" },
|
||||
{
|
||||
name: "description",
|
||||
content: "Create, manage, monitor, and automate your Docker volumes with ease.",
|
||||
@@ -109,6 +109,10 @@ export default function Volumes({ loaderData }: Route.ComponentProps) {
|
||||
<SelectItem value="directory">Directory</SelectItem>
|
||||
<SelectItem value="nfs">NFS</SelectItem>
|
||||
<SelectItem value="smb">SMB</SelectItem>
|
||||
<SelectItem value="webdav">WebDAV</SelectItem>
|
||||
<SelectItem value="mariadb">MariaDB</SelectItem>
|
||||
<SelectItem value="mysql">MySQL</SelectItem>
|
||||
<SelectItem value="postgres">PostgreSQL</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{(searchQuery || statusFilter || backendFilter) && (
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { CreateVolumeForm, type FormValues } from "~/client/components/create-volume-form";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
@@ -17,6 +16,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 { CreateVolumeForm, type FormValues } from "../components/create-volume-form";
|
||||
|
||||
type Props = {
|
||||
volume: Volume;
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
ALTER TABLE `backup_schedules_table` ADD `limit_upload_kbps` integer;
|
||||
@@ -1,466 +0,0 @@
|
||||
{
|
||||
"version": "6",
|
||||
"dialect": "sqlite",
|
||||
"id": "3ad94485-0846-44f1-8430-44d75bf16f69",
|
||||
"prevId": "17f234ba-4123-4951-a39f-6002d537435f",
|
||||
"tables": {
|
||||
"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": "'[]'"
|
||||
},
|
||||
"limit_upload_kbps": {
|
||||
"name": "limit_upload_kbps",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"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": {}
|
||||
},
|
||||
"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": {}
|
||||
}
|
||||
}
|
||||
@@ -1,90 +1,83 @@
|
||||
{
|
||||
"version": "7",
|
||||
"dialect": "sqlite",
|
||||
"entries": [
|
||||
{
|
||||
"idx": 0,
|
||||
"version": "6",
|
||||
"when": 1755765658194,
|
||||
"tag": "0000_known_madelyne_pryor",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 1,
|
||||
"version": "6",
|
||||
"when": 1755775437391,
|
||||
"tag": "0001_far_frank_castle",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 2,
|
||||
"version": "6",
|
||||
"when": 1756930554198,
|
||||
"tag": "0002_cheerful_randall",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 3,
|
||||
"version": "6",
|
||||
"when": 1758653407064,
|
||||
"tag": "0003_mature_hellcat",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 4,
|
||||
"version": "6",
|
||||
"when": 1758961535488,
|
||||
"tag": "0004_wealthy_tomas",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 5,
|
||||
"version": "6",
|
||||
"when": 1759416698274,
|
||||
"tag": "0005_simple_alice",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 6,
|
||||
"version": "6",
|
||||
"when": 1760734377440,
|
||||
"tag": "0006_secret_micromacro",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 7,
|
||||
"version": "6",
|
||||
"when": 1761224911352,
|
||||
"tag": "0007_watery_sersi",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 8,
|
||||
"version": "6",
|
||||
"when": 1761414054481,
|
||||
"tag": "0008_silent_lady_bullseye",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 9,
|
||||
"version": "6",
|
||||
"when": 1762095226041,
|
||||
"tag": "0009_little_adam_warlock",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 10,
|
||||
"version": "6",
|
||||
"when": 1762610065889,
|
||||
"tag": "0010_perfect_proemial_gods",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 11,
|
||||
"version": "6",
|
||||
"when": 1763728410318,
|
||||
"tag": "0011_lazy_havok",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
"version": "7",
|
||||
"dialect": "sqlite",
|
||||
"entries": [
|
||||
{
|
||||
"idx": 0,
|
||||
"version": "6",
|
||||
"when": 1755765658194,
|
||||
"tag": "0000_known_madelyne_pryor",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 1,
|
||||
"version": "6",
|
||||
"when": 1755775437391,
|
||||
"tag": "0001_far_frank_castle",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 2,
|
||||
"version": "6",
|
||||
"when": 1756930554198,
|
||||
"tag": "0002_cheerful_randall",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 3,
|
||||
"version": "6",
|
||||
"when": 1758653407064,
|
||||
"tag": "0003_mature_hellcat",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 4,
|
||||
"version": "6",
|
||||
"when": 1758961535488,
|
||||
"tag": "0004_wealthy_tomas",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 5,
|
||||
"version": "6",
|
||||
"when": 1759416698274,
|
||||
"tag": "0005_simple_alice",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 6,
|
||||
"version": "6",
|
||||
"when": 1760734377440,
|
||||
"tag": "0006_secret_micromacro",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 7,
|
||||
"version": "6",
|
||||
"when": 1761224911352,
|
||||
"tag": "0007_watery_sersi",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 8,
|
||||
"version": "6",
|
||||
"when": 1761414054481,
|
||||
"tag": "0008_silent_lady_bullseye",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 9,
|
||||
"version": "6",
|
||||
"when": 1762095226041,
|
||||
"tag": "0009_little_adam_warlock",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 10,
|
||||
"version": "6",
|
||||
"when": 1762610065889,
|
||||
"tag": "0010_perfect_proemial_gods",
|
||||
"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="Zerobyte" />
|
||||
<meta name="apple-mobile-web-app-title" content="Ironmount" />
|
||||
<link rel="manifest" href="/images/favicon/site.webmanifest" />
|
||||
<Meta />
|
||||
<Links />
|
||||
|
||||
@@ -7,8 +7,6 @@ export const REPOSITORY_BACKENDS = {
|
||||
gcs: "gcs",
|
||||
azure: "azure",
|
||||
rclone: "rclone",
|
||||
rest: "rest",
|
||||
sftp: "sftp",
|
||||
} as const;
|
||||
|
||||
export type RepositoryBackend = keyof typeof REPOSITORY_BACKENDS;
|
||||
@@ -38,7 +36,6 @@ export const r2RepositoryConfigSchema = type({
|
||||
export const localRepositoryConfigSchema = type({
|
||||
backend: "'local'",
|
||||
name: "string",
|
||||
path: "string?",
|
||||
}).and(baseRepositoryConfigSchema);
|
||||
|
||||
export const gcsRepositoryConfigSchema = type({
|
||||
@@ -62,31 +59,12 @@ export const rcloneRepositoryConfigSchema = type({
|
||||
path: "string",
|
||||
}).and(baseRepositoryConfigSchema);
|
||||
|
||||
export const restRepositoryConfigSchema = type({
|
||||
backend: "'rest'",
|
||||
url: "string",
|
||||
username: "string?",
|
||||
password: "string?",
|
||||
path: "string?",
|
||||
}).and(baseRepositoryConfigSchema);
|
||||
|
||||
export const sftpRepositoryConfigSchema = type({
|
||||
backend: "'sftp'",
|
||||
host: "string",
|
||||
port: type("string.integer").or(type("number")).to("1 <= number <= 65535").default(22),
|
||||
user: "string",
|
||||
path: "string",
|
||||
privateKey: "string",
|
||||
}).and(baseRepositoryConfigSchema);
|
||||
|
||||
export const repositoryConfigSchema = s3RepositoryConfigSchema
|
||||
.or(r2RepositoryConfigSchema)
|
||||
.or(localRepositoryConfigSchema)
|
||||
.or(gcsRepositoryConfigSchema)
|
||||
.or(azureRepositoryConfigSchema)
|
||||
.or(rcloneRepositoryConfigSchema)
|
||||
.or(restRepositoryConfigSchema)
|
||||
.or(sftpRepositoryConfigSchema);
|
||||
.or(rcloneRepositoryConfigSchema);
|
||||
|
||||
export type RepositoryConfig = typeof repositoryConfigSchema.infer;
|
||||
|
||||
|
||||
@@ -5,6 +5,9 @@ export const BACKEND_TYPES = {
|
||||
smb: "smb",
|
||||
directory: "directory",
|
||||
webdav: "webdav",
|
||||
mariadb: "mariadb",
|
||||
mysql: "mysql",
|
||||
postgres: "postgres",
|
||||
} as const;
|
||||
|
||||
export type BackendType = keyof typeof BACKEND_TYPES;
|
||||
@@ -47,7 +50,47 @@ export const webdavConfigSchema = type({
|
||||
ssl: "boolean?",
|
||||
});
|
||||
|
||||
export const volumeConfigSchema = nfsConfigSchema.or(smbConfigSchema).or(webdavConfigSchema).or(directoryConfigSchema);
|
||||
export const mariadbConfigSchema = type({
|
||||
backend: "'mariadb'",
|
||||
host: "string",
|
||||
port: type("string.integer").or(type("number")).to("1 <= number <= 65535").default(3306),
|
||||
username: "string",
|
||||
password: "string",
|
||||
database: "string",
|
||||
dumpOptions: "string[]?",
|
||||
readOnly: "false?",
|
||||
});
|
||||
|
||||
export const mysqlConfigSchema = type({
|
||||
backend: "'mysql'",
|
||||
host: "string",
|
||||
port: type("string.integer").or(type("number")).to("1 <= number <= 65535").default(3306),
|
||||
username: "string",
|
||||
password: "string",
|
||||
database: "string",
|
||||
dumpOptions: "string[]?",
|
||||
readOnly: "false?",
|
||||
});
|
||||
|
||||
export const postgresConfigSchema = type({
|
||||
backend: "'postgres'",
|
||||
host: "string",
|
||||
port: type("string.integer").or(type("number")).to("1 <= number <= 65535").default(5432),
|
||||
username: "string",
|
||||
password: "string",
|
||||
database: "string",
|
||||
dumpFormat: type("'plain' | 'custom' | 'directory'").default("custom"),
|
||||
dumpOptions: "string[]?",
|
||||
readOnly: "false?",
|
||||
});
|
||||
|
||||
export const volumeConfigSchema = nfsConfigSchema
|
||||
.or(smbConfigSchema)
|
||||
.or(webdavConfigSchema)
|
||||
.or(directoryConfigSchema)
|
||||
.or(mariadbConfigSchema)
|
||||
.or(mysqlConfigSchema)
|
||||
.or(postgresConfigSchema);
|
||||
|
||||
export type BackendConfig = typeof volumeConfigSchema.infer;
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
export const OPERATION_TIMEOUT = 5000;
|
||||
export const VOLUME_MOUNT_BASE = "/var/lib/zerobyte/volumes";
|
||||
export const REPOSITORY_BASE = "/var/lib/zerobyte/repositories";
|
||||
export const DATABASE_URL = "/var/lib/zerobyte/data/ironmount.db";
|
||||
export const RESTIC_PASS_FILE = "/var/lib/zerobyte/data/restic.pass";
|
||||
export const SOCKET_PATH = "/run/docker/plugins/zerobyte.sock";
|
||||
export const VOLUME_MOUNT_BASE = "/var/lib/ironmount/volumes";
|
||||
export const REPOSITORY_BASE = "/var/lib/ironmount/repositories";
|
||||
export const DATABASE_URL = "/var/lib/ironmount/data/ironmount.db";
|
||||
export const RESTIC_PASS_FILE = "/var/lib/ironmount/data/restic.pass";
|
||||
export const SOCKET_PATH = "/run/docker/plugins/ironmount.sock";
|
||||
|
||||
@@ -83,7 +83,6 @@ export const backupSchedulesTable = sqliteTable("backup_schedules_table", {
|
||||
}>(),
|
||||
excludePatterns: text("exclude_patterns", { mode: "json" }).$type<string[]>().default([]),
|
||||
includePatterns: text("include_patterns", { mode: "json" }).$type<string[]>().default([]),
|
||||
limitUploadKbps: int("limit_upload_kbps", { mode: "number" }),
|
||||
lastBackupAt: int("last_backup_at", { mode: "number" }),
|
||||
lastBackupStatus: text("last_backup_status").$type<"success" | "error" | "in_progress">(),
|
||||
lastBackupError: text("last_backup_error"),
|
||||
|
||||
@@ -24,7 +24,7 @@ export const generalDescriptor = (app: Hono) =>
|
||||
openAPIRouteHandler(app, {
|
||||
documentation: {
|
||||
info: {
|
||||
title: "Zerobyte API",
|
||||
title: "Ironmount API",
|
||||
version: "1.0.0",
|
||||
description: "API for managing volumes",
|
||||
},
|
||||
@@ -33,8 +33,8 @@ export const generalDescriptor = (app: Hono) =>
|
||||
});
|
||||
|
||||
export const scalarDescriptor = Scalar({
|
||||
title: "Zerobyte API Docs",
|
||||
pageTitle: "Zerobyte API Docs",
|
||||
title: "Ironmount API Docs",
|
||||
pageTitle: "Ironmount API Docs",
|
||||
url: "/api/v1/openapi.json",
|
||||
});
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ import path from "node:path";
|
||||
import fs from "node:fs/promises";
|
||||
import { volumeService } from "../modules/volumes/volume.service";
|
||||
import { readMountInfo } from "../utils/mountinfo";
|
||||
import { getVolumePath } from "../modules/volumes/helpers";
|
||||
import { createVolumeBackend } from "../modules/backends/backend";
|
||||
import { logger } from "../utils/logger";
|
||||
import { executeUnmount } from "../modules/backends/utils/backend-utils";
|
||||
import { toMessage } from "../utils/errors";
|
||||
@@ -15,8 +15,12 @@ export class CleanupDanglingMountsJob extends Job {
|
||||
const allSystemMounts = await readMountInfo();
|
||||
|
||||
for (const mount of allSystemMounts) {
|
||||
if (mount.mountPoint.includes("zerobyte") && mount.mountPoint.endsWith("_data")) {
|
||||
const matchingVolume = allVolumes.find((v) => getVolumePath(v) === mount.mountPoint);
|
||||
if (mount.mountPoint.includes("ironmount") && mount.mountPoint.endsWith("_data")) {
|
||||
const matchingVolume = allVolumes.find((v) => {
|
||||
const backend = createVolumeBackend(v);
|
||||
return backend.getVolumePath() === mount.mountPoint;
|
||||
});
|
||||
|
||||
if (!matchingVolume) {
|
||||
logger.info(`Found dangling mount at ${mount.mountPoint}, attempting to unmount...`);
|
||||
await executeUnmount(mount.mountPoint).catch((err) => {
|
||||
@@ -32,11 +36,14 @@ export class CleanupDanglingMountsJob extends Job {
|
||||
}
|
||||
}
|
||||
|
||||
const allZerobyteDirs = await fs.readdir(VOLUME_MOUNT_BASE).catch(() => []);
|
||||
const allIronmountDirs = await fs.readdir(VOLUME_MOUNT_BASE).catch(() => []);
|
||||
|
||||
for (const dir of allZerobyteDirs) {
|
||||
for (const dir of allIronmountDirs) {
|
||||
const volumePath = `${VOLUME_MOUNT_BASE}/${dir}/_data`;
|
||||
const matchingVolume = allVolumes.find((v) => getVolumePath(v) === volumePath);
|
||||
const matchingVolume = allVolumes.find((v) => {
|
||||
const backend = createVolumeBackend(v);
|
||||
return backend.getVolumePath() === volumePath;
|
||||
});
|
||||
if (!matchingVolume) {
|
||||
const fullPath = path.join(VOLUME_MOUNT_BASE, dir);
|
||||
logger.info(`Found dangling mount directory at ${fullPath}, attempting to remove...`);
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import type { BackendStatus } from "~/schemas/volumes";
|
||||
import type { Volume } from "../../db/schema";
|
||||
import { getVolumePath } from "../volumes/helpers";
|
||||
import { makeDirectoryBackend } from "./directory/directory-backend";
|
||||
import { makeNfsBackend } from "./nfs/nfs-backend";
|
||||
import { makeSmbBackend } from "./smb/smb-backend";
|
||||
import { makeWebdavBackend } from "./webdav/webdav-backend";
|
||||
import { makeMariaDBBackend } from "./mariadb/mariadb-backend";
|
||||
import { makeMySQLBackend } from "./mysql/mysql-backend";
|
||||
import { makePostgresBackend } from "./postgres/postgres-backend";
|
||||
|
||||
type OperationResult = {
|
||||
error?: string;
|
||||
@@ -15,23 +17,35 @@ export type VolumeBackend = {
|
||||
mount: () => Promise<OperationResult>;
|
||||
unmount: () => Promise<OperationResult>;
|
||||
checkHealth: () => Promise<OperationResult>;
|
||||
getVolumePath: () => string;
|
||||
getBackupPath: () => Promise<string>;
|
||||
};
|
||||
|
||||
export const createVolumeBackend = (volume: Volume): VolumeBackend => {
|
||||
const path = getVolumePath(volume);
|
||||
|
||||
switch (volume.config.backend) {
|
||||
case "nfs": {
|
||||
return makeNfsBackend(volume.config, path);
|
||||
return makeNfsBackend(volume.config, volume.name);
|
||||
}
|
||||
case "smb": {
|
||||
return makeSmbBackend(volume.config, path);
|
||||
return makeSmbBackend(volume.config, volume.name);
|
||||
}
|
||||
case "directory": {
|
||||
return makeDirectoryBackend(volume.config, path);
|
||||
return makeDirectoryBackend(volume.config, volume.name);
|
||||
}
|
||||
case "webdav": {
|
||||
return makeWebdavBackend(volume.config, path);
|
||||
return makeWebdavBackend(volume.config, volume.name);
|
||||
}
|
||||
case "mariadb": {
|
||||
return makeMariaDBBackend(volume.config);
|
||||
}
|
||||
case "mysql": {
|
||||
return makeMySQLBackend(volume.config);
|
||||
}
|
||||
case "postgres": {
|
||||
return makePostgresBackend(volume.config);
|
||||
}
|
||||
default: {
|
||||
throw new Error(`Unsupported backend type: ${(volume.config as any).backend}`);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import * as fs from "node:fs/promises";
|
||||
import * as npath from "node:path";
|
||||
import { toMessage } from "../../../utils/errors";
|
||||
import { logger } from "../../../utils/logger";
|
||||
import type { VolumeBackend } from "../backend";
|
||||
@@ -39,6 +40,11 @@ const checkHealth = async (config: BackendConfig) => {
|
||||
try {
|
||||
await fs.access(config.path);
|
||||
|
||||
// Try to create a temporary file to ensure write access
|
||||
const tempFilePath = npath.join(config.path, `.healthcheck-${Date.now()}`);
|
||||
await fs.writeFile(tempFilePath, "healthcheck");
|
||||
await fs.unlink(tempFilePath);
|
||||
|
||||
return { status: BACKEND_STATUS.mounted };
|
||||
} catch (error) {
|
||||
logger.error("Directory health check failed:", error);
|
||||
@@ -46,8 +52,18 @@ const checkHealth = async (config: BackendConfig) => {
|
||||
}
|
||||
};
|
||||
|
||||
const getVolumePath = (config: BackendConfig): string => {
|
||||
if (config.backend !== "directory") {
|
||||
throw new Error("Invalid backend type");
|
||||
}
|
||||
|
||||
return config.path;
|
||||
};
|
||||
|
||||
export const makeDirectoryBackend = (config: BackendConfig, volumePath: string): VolumeBackend => ({
|
||||
mount: () => mount(config, volumePath),
|
||||
unmount,
|
||||
checkHealth: () => checkHealth(config),
|
||||
getVolumePath: () => getVolumePath(config),
|
||||
getBackupPath: async () => getVolumePath(config),
|
||||
});
|
||||
|
||||
81
app/server/modules/backends/mariadb/mariadb-backend.ts
Normal file
81
app/server/modules/backends/mariadb/mariadb-backend.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
import * as fs from "node:fs/promises";
|
||||
import { toMessage } from "../../../utils/errors";
|
||||
import { logger } from "../../../utils/logger";
|
||||
import type { VolumeBackend } from "../backend";
|
||||
import { BACKEND_STATUS, type BackendConfig } from "~/schemas/volumes";
|
||||
import { $ } from "bun";
|
||||
|
||||
const checkHealth = async (config: BackendConfig) => {
|
||||
if (config.backend !== "mariadb") {
|
||||
return { status: BACKEND_STATUS.error, error: "Invalid backend type" };
|
||||
}
|
||||
|
||||
try {
|
||||
logger.debug(`Testing MariaDB connection to: ${config.host}:${config.port}`);
|
||||
|
||||
const args = [
|
||||
`--host=${config.host}`,
|
||||
`--port=${config.port}`,
|
||||
`--user=${config.username}`,
|
||||
`--database=${config.database}`,
|
||||
"--skip-ssl",
|
||||
"--execute=SELECT 1",
|
||||
];
|
||||
|
||||
const env = {
|
||||
MYSQL_PWD: config.password,
|
||||
};
|
||||
|
||||
await $`mariadb ${args.join(" ")}`.env(env);
|
||||
|
||||
return { status: BACKEND_STATUS.mounted };
|
||||
} catch (error) {
|
||||
logger.error("MariaDB health check failed:", error);
|
||||
return { status: BACKEND_STATUS.error, error: toMessage(error) };
|
||||
}
|
||||
};
|
||||
|
||||
const getBackupPath = async (config: BackendConfig) => {
|
||||
const dumpDir = await fs.mkdtemp(`/tmp/ironmount-mariadb-`);
|
||||
|
||||
if (config.backend !== "mariadb") {
|
||||
throw new Error("Invalid backend type for MariaDB dump");
|
||||
}
|
||||
|
||||
logger.info(`Starting MariaDB dump for database: ${config.database}`);
|
||||
|
||||
const args = [
|
||||
`--host=${config.host}`,
|
||||
`--port=${config.port}`,
|
||||
`--user=${config.username}`,
|
||||
`--skip-ssl`,
|
||||
`--single-transaction`,
|
||||
`--quick`,
|
||||
`--lock-tables=false`,
|
||||
...(config.dumpOptions || []),
|
||||
config.database,
|
||||
];
|
||||
|
||||
const env = {
|
||||
MYSQL_PWD: config.password,
|
||||
};
|
||||
|
||||
const result = await $`mariadb-dump ${args}`.env(env).nothrow();
|
||||
|
||||
if (result.exitCode !== 0) {
|
||||
throw new Error(`mariadb-dump failed with exit code ${result.exitCode}: ${result.stderr}`);
|
||||
}
|
||||
|
||||
await fs.writeFile(`${dumpDir}/dump.sql`, result.stdout);
|
||||
logger.info(`MariaDB dump completed: ${dumpDir}/dump.sql`);
|
||||
|
||||
return `${dumpDir}/dump.sql`;
|
||||
};
|
||||
|
||||
export const makeMariaDBBackend = (config: BackendConfig): VolumeBackend => ({
|
||||
mount: () => Promise.resolve({ status: BACKEND_STATUS.mounted }),
|
||||
unmount: () => Promise.resolve({ status: BACKEND_STATUS.unmounted }),
|
||||
checkHealth: () => checkHealth(config),
|
||||
getVolumePath: () => "/tmp",
|
||||
getBackupPath: () => getBackupPath(config),
|
||||
});
|
||||
76
app/server/modules/backends/mysql/mysql-backend.ts
Normal file
76
app/server/modules/backends/mysql/mysql-backend.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
import { toMessage } from "../../../utils/errors";
|
||||
import { logger } from "../../../utils/logger";
|
||||
import type { VolumeBackend } from "../backend";
|
||||
import { BACKEND_STATUS, type BackendConfig } from "~/schemas/volumes";
|
||||
import { $ } from "bun";
|
||||
|
||||
const checkHealth = async (config: BackendConfig) => {
|
||||
if (config.backend !== "mysql") {
|
||||
return { status: BACKEND_STATUS.error, error: "Invalid backend type" };
|
||||
}
|
||||
|
||||
logger.debug(`Testing MySQL connection to: ${config.host}:${config.port}`);
|
||||
try {
|
||||
const args = [
|
||||
`--host=${config.host}`,
|
||||
`--port=${config.port}`,
|
||||
`--user=${config.username}`,
|
||||
`--database=${config.database}`,
|
||||
"--skip-ssl",
|
||||
"--execute=SELECT 1",
|
||||
];
|
||||
|
||||
const env = {
|
||||
...process.env,
|
||||
MYSQL_PWD: config.password,
|
||||
};
|
||||
|
||||
await $`mysql ${args.join(" ")}`.env(env);
|
||||
return { status: BACKEND_STATUS.mounted };
|
||||
} catch (error) {
|
||||
logger.error("MySQL health check failed:", error);
|
||||
return { status: BACKEND_STATUS.error, error: toMessage(error) };
|
||||
}
|
||||
};
|
||||
|
||||
const getBackupPath = async (config: BackendConfig) => {
|
||||
if (config.backend !== "mysql") {
|
||||
throw new Error("Invalid backend type");
|
||||
}
|
||||
|
||||
logger.info(`Starting MySQL dump for database: ${config.database}`);
|
||||
|
||||
const args = [
|
||||
`--host=${config.host}`,
|
||||
`--port=${config.port}`,
|
||||
`--user=${config.username}`,
|
||||
`--skip-ssl`,
|
||||
`--single-transaction`,
|
||||
`--quick`,
|
||||
`--lock-tables=false`,
|
||||
...(config.dumpOptions || []),
|
||||
config.database,
|
||||
];
|
||||
|
||||
const env = {
|
||||
MYSQL_PWD: config.password,
|
||||
};
|
||||
|
||||
const result = await $`mysql ${args}`.env(env).nothrow();
|
||||
|
||||
if (result.exitCode !== 0) {
|
||||
throw new Error(`MySQL dump failed: ${result.stderr}`);
|
||||
}
|
||||
|
||||
console.log(result.stdout);
|
||||
|
||||
return "Nothing for now";
|
||||
};
|
||||
|
||||
export const makeMySQLBackend = (config: BackendConfig): VolumeBackend => ({
|
||||
mount: () => Promise.resolve({ status: BACKEND_STATUS.mounted }),
|
||||
unmount: () => Promise.resolve({ status: BACKEND_STATUS.unmounted }),
|
||||
checkHealth: () => checkHealth(config),
|
||||
getVolumePath: () => "/tmp",
|
||||
getBackupPath: () => getBackupPath(config),
|
||||
});
|
||||
@@ -1,15 +1,16 @@
|
||||
import * as fs from "node:fs/promises";
|
||||
import * as os from "node:os";
|
||||
import { OPERATION_TIMEOUT } from "../../../core/constants";
|
||||
import { OPERATION_TIMEOUT, VOLUME_MOUNT_BASE } from "../../../core/constants";
|
||||
import { toMessage } from "../../../utils/errors";
|
||||
import { logger } from "../../../utils/logger";
|
||||
import { getMountForPath } from "../../../utils/mountinfo";
|
||||
import { withTimeout } from "../../../utils/timeout";
|
||||
import type { VolumeBackend } from "../backend";
|
||||
import { executeMount, executeUnmount } from "../utils/backend-utils";
|
||||
import { createTestFile, executeMount, executeUnmount } from "../utils/backend-utils";
|
||||
import { BACKEND_STATUS, type BackendConfig } from "~/schemas/volumes";
|
||||
|
||||
const mount = async (config: BackendConfig, path: string) => {
|
||||
const mount = async (config: BackendConfig, name: string) => {
|
||||
const path = getVolumePath(name);
|
||||
logger.debug(`Mounting volume ${path}...`);
|
||||
|
||||
if (config.backend !== "nfs") {
|
||||
@@ -22,13 +23,13 @@ const mount = async (config: BackendConfig, path: string) => {
|
||||
return { status: BACKEND_STATUS.error, error: "NFS mounting is only supported on Linux hosts." };
|
||||
}
|
||||
|
||||
const { status } = await checkHealth(path);
|
||||
const { status } = await checkHealth(name, config.readOnly ?? false);
|
||||
if (status === "mounted") {
|
||||
return { status: BACKEND_STATUS.mounted };
|
||||
}
|
||||
|
||||
logger.debug(`Trying to unmount any existing mounts at ${path} before mounting...`);
|
||||
await unmount(path);
|
||||
await unmount(name);
|
||||
|
||||
const run = async () => {
|
||||
await fs.mkdir(path, { recursive: true });
|
||||
@@ -57,7 +58,9 @@ const mount = async (config: BackendConfig, path: string) => {
|
||||
}
|
||||
};
|
||||
|
||||
const unmount = async (path: string) => {
|
||||
const unmount = async (name: string) => {
|
||||
const path = getVolumePath(name);
|
||||
|
||||
if (os.platform() !== "linux") {
|
||||
logger.error("NFS unmounting is only supported on Linux hosts.");
|
||||
return { status: BACKEND_STATUS.error, error: "NFS unmounting is only supported on Linux hosts." };
|
||||
@@ -87,7 +90,9 @@ const unmount = async (path: string) => {
|
||||
}
|
||||
};
|
||||
|
||||
const checkHealth = async (path: string) => {
|
||||
const checkHealth = async (name: string, readOnly: boolean) => {
|
||||
const path = getVolumePath(name);
|
||||
|
||||
const run = async () => {
|
||||
logger.debug(`Checking health of NFS volume at ${path}...`);
|
||||
await fs.access(path);
|
||||
@@ -98,6 +103,10 @@ const checkHealth = async (path: string) => {
|
||||
throw new Error(`Path ${path} is not mounted as NFS.`);
|
||||
}
|
||||
|
||||
if (!readOnly) {
|
||||
await createTestFile(path);
|
||||
}
|
||||
|
||||
logger.debug(`NFS volume at ${path} is healthy and mounted.`);
|
||||
return { status: BACKEND_STATUS.mounted };
|
||||
};
|
||||
@@ -110,8 +119,14 @@ const checkHealth = async (path: string) => {
|
||||
}
|
||||
};
|
||||
|
||||
export const makeNfsBackend = (config: BackendConfig, path: string): VolumeBackend => ({
|
||||
mount: () => mount(config, path),
|
||||
unmount: () => unmount(path),
|
||||
checkHealth: () => checkHealth(path),
|
||||
const getVolumePath = (name: string) => {
|
||||
return `${VOLUME_MOUNT_BASE}/${name}/_data`;
|
||||
};
|
||||
|
||||
export const makeNfsBackend = (config: BackendConfig, name: string): VolumeBackend => ({
|
||||
mount: () => mount(config, name),
|
||||
unmount: () => unmount(name),
|
||||
checkHealth: () => checkHealth(name, config.readOnly ?? false),
|
||||
getVolumePath: () => getVolumePath(name),
|
||||
getBackupPath: async () => getVolumePath(name),
|
||||
});
|
||||
|
||||
80
app/server/modules/backends/postgres/postgres-backend.ts
Normal file
80
app/server/modules/backends/postgres/postgres-backend.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
import * as fs from "node:fs/promises";
|
||||
import { toMessage } from "../../../utils/errors";
|
||||
import { logger } from "../../../utils/logger";
|
||||
import type { VolumeBackend } from "../backend";
|
||||
import { BACKEND_STATUS, type BackendConfig } from "~/schemas/volumes";
|
||||
import { $ } from "bun";
|
||||
|
||||
const checkHealth = async (config: BackendConfig) => {
|
||||
if (config.backend !== "postgres") {
|
||||
return { status: BACKEND_STATUS.error, error: "Invalid backend type" };
|
||||
}
|
||||
|
||||
if (config.backend !== "postgres") {
|
||||
throw new Error("Invalid backend type for PostgreSQL connection test");
|
||||
}
|
||||
|
||||
logger.debug(`Testing PostgreSQL connection to: ${config.host}:${config.port}`);
|
||||
|
||||
const args = [
|
||||
`--host=${config.host}`,
|
||||
`--port=${config.port}`,
|
||||
`--username=${config.username}`,
|
||||
`--dbname=${config.database}`,
|
||||
"--command=SELECT 1",
|
||||
"--no-password",
|
||||
];
|
||||
|
||||
const env = {
|
||||
PGPASSWORD: config.password,
|
||||
PGSSLMODE: "disable",
|
||||
};
|
||||
|
||||
logger.debug(`Running psql with args: ${args.join(" ")}`);
|
||||
const res = await $`psql ${args}`.env(env).nothrow();
|
||||
|
||||
if (res.exitCode !== 0) {
|
||||
return { status: BACKEND_STATUS.error, error: res.stderr.toString() };
|
||||
}
|
||||
|
||||
return { status: BACKEND_STATUS.mounted };
|
||||
};
|
||||
|
||||
const getBackupPath = async (config: BackendConfig) => {
|
||||
if (config.backend !== "postgres") {
|
||||
throw new Error("Invalid backend type for PostgreSQL dump");
|
||||
}
|
||||
|
||||
const dumpDir = await fs.mkdtemp(`/tmp/ironmount-postgres-`);
|
||||
const outputPath = `${dumpDir}/${config.dumpFormat === "plain" ? "dump.sql" : "dump.dump"}`;
|
||||
|
||||
logger.info(`Starting PostgreSQL dump for database: ${config.database}`);
|
||||
|
||||
const args = [
|
||||
`--host=${config.host}`,
|
||||
`--port=${config.port}`,
|
||||
`--username=${config.username}`,
|
||||
`--dbname=${config.database}`,
|
||||
`--format=${config.dumpFormat}`,
|
||||
`--file=${outputPath}`,
|
||||
"--no-password",
|
||||
...(config.dumpOptions || []),
|
||||
];
|
||||
|
||||
const env = {
|
||||
PGPASSWORD: config.password,
|
||||
PGSSLMODE: "disable",
|
||||
};
|
||||
|
||||
await $`pg_dump ${args}`.env(env);
|
||||
|
||||
return outputPath;
|
||||
};
|
||||
|
||||
export const makePostgresBackend = (config: BackendConfig): VolumeBackend => ({
|
||||
mount: () => Promise.resolve({ status: "mounted" }),
|
||||
unmount: () => Promise.resolve({ status: "unmounted" }),
|
||||
checkHealth: () => checkHealth(config),
|
||||
getVolumePath: () => "/tmp",
|
||||
getBackupPath: () => getBackupPath(config),
|
||||
});
|
||||
@@ -1,15 +1,16 @@
|
||||
import * as fs from "node:fs/promises";
|
||||
import * as os from "node:os";
|
||||
import { OPERATION_TIMEOUT } from "../../../core/constants";
|
||||
import { OPERATION_TIMEOUT, VOLUME_MOUNT_BASE } from "../../../core/constants";
|
||||
import { toMessage } from "../../../utils/errors";
|
||||
import { logger } from "../../../utils/logger";
|
||||
import { getMountForPath } from "../../../utils/mountinfo";
|
||||
import { withTimeout } from "../../../utils/timeout";
|
||||
import type { VolumeBackend } from "../backend";
|
||||
import { executeMount, executeUnmount } from "../utils/backend-utils";
|
||||
import { createTestFile, executeMount, executeUnmount } from "../utils/backend-utils";
|
||||
import { BACKEND_STATUS, type BackendConfig } from "~/schemas/volumes";
|
||||
|
||||
const mount = async (config: BackendConfig, path: string) => {
|
||||
const mount = async (config: BackendConfig, name: string) => {
|
||||
const path = getVolumePath(name);
|
||||
logger.debug(`Mounting SMB volume ${path}...`);
|
||||
|
||||
if (config.backend !== "smb") {
|
||||
@@ -22,13 +23,13 @@ const mount = async (config: BackendConfig, path: string) => {
|
||||
return { status: BACKEND_STATUS.error, error: "SMB mounting is only supported on Linux hosts." };
|
||||
}
|
||||
|
||||
const { status } = await checkHealth(path);
|
||||
const { status } = await checkHealth(name, config.readOnly ?? false);
|
||||
if (status === "mounted") {
|
||||
return { status: BACKEND_STATUS.mounted };
|
||||
}
|
||||
|
||||
logger.debug(`Trying to unmount any existing mounts at ${path} before mounting...`);
|
||||
await unmount(path);
|
||||
await unmount(name);
|
||||
|
||||
const run = async () => {
|
||||
await fs.mkdir(path, { recursive: true });
|
||||
@@ -70,7 +71,9 @@ const mount = async (config: BackendConfig, path: string) => {
|
||||
}
|
||||
};
|
||||
|
||||
const unmount = async (path: string) => {
|
||||
const unmount = async (name: string) => {
|
||||
const path = getVolumePath(name);
|
||||
|
||||
if (os.platform() !== "linux") {
|
||||
logger.error("SMB unmounting is only supported on Linux hosts.");
|
||||
return { status: BACKEND_STATUS.error, error: "SMB unmounting is only supported on Linux hosts." };
|
||||
@@ -100,7 +103,9 @@ const unmount = async (path: string) => {
|
||||
}
|
||||
};
|
||||
|
||||
const checkHealth = async (path: string) => {
|
||||
const checkHealth = async (name: string, readOnly: boolean) => {
|
||||
const path = getVolumePath(name);
|
||||
|
||||
const run = async () => {
|
||||
logger.debug(`Checking health of SMB volume at ${path}...`);
|
||||
await fs.access(path);
|
||||
@@ -111,6 +116,10 @@ const checkHealth = async (path: string) => {
|
||||
throw new Error(`Path ${path} is not mounted as CIFS/SMB.`);
|
||||
}
|
||||
|
||||
if (!readOnly) {
|
||||
await createTestFile(path);
|
||||
}
|
||||
|
||||
logger.debug(`SMB volume at ${path} is healthy and mounted.`);
|
||||
return { status: BACKEND_STATUS.mounted };
|
||||
};
|
||||
@@ -123,8 +132,14 @@ const checkHealth = async (path: string) => {
|
||||
}
|
||||
};
|
||||
|
||||
export const makeSmbBackend = (config: BackendConfig, path: string): VolumeBackend => ({
|
||||
mount: () => mount(config, path),
|
||||
unmount: () => unmount(path),
|
||||
checkHealth: () => checkHealth(path),
|
||||
const getVolumePath = (name: string) => {
|
||||
return `${VOLUME_MOUNT_BASE}/${name}/_data`;
|
||||
};
|
||||
|
||||
export const makeSmbBackend = (config: BackendConfig, name: string): VolumeBackend => ({
|
||||
mount: () => mount(config, name),
|
||||
unmount: () => unmount(name),
|
||||
checkHealth: () => checkHealth(name, config.readOnly ?? false),
|
||||
getVolumePath: () => getVolumePath(name),
|
||||
getBackupPath: async () => getVolumePath(name),
|
||||
});
|
||||
|
||||
@@ -2,18 +2,19 @@ import { execFile as execFileCb } from "node:child_process";
|
||||
import * as fs from "node:fs/promises";
|
||||
import * as os from "node:os";
|
||||
import { promisify } from "node:util";
|
||||
import { OPERATION_TIMEOUT } from "../../../core/constants";
|
||||
import { OPERATION_TIMEOUT, VOLUME_MOUNT_BASE } from "../../../core/constants";
|
||||
import { toMessage } from "../../../utils/errors";
|
||||
import { logger } from "../../../utils/logger";
|
||||
import { getMountForPath } from "../../../utils/mountinfo";
|
||||
import { withTimeout } from "../../../utils/timeout";
|
||||
import type { VolumeBackend } from "../backend";
|
||||
import { executeMount, executeUnmount } from "../utils/backend-utils";
|
||||
import { createTestFile, executeMount, executeUnmount } from "../utils/backend-utils";
|
||||
import { BACKEND_STATUS, type BackendConfig } from "~/schemas/volumes";
|
||||
|
||||
const execFile = promisify(execFileCb);
|
||||
|
||||
const mount = async (config: BackendConfig, path: string) => {
|
||||
const mount = async (config: BackendConfig, name: string) => {
|
||||
const path = getVolumePath(name);
|
||||
logger.debug(`Mounting WebDAV volume ${path}...`);
|
||||
|
||||
if (config.backend !== "webdav") {
|
||||
@@ -26,7 +27,7 @@ const mount = async (config: BackendConfig, path: string) => {
|
||||
return { status: BACKEND_STATUS.error, error: "WebDAV mounting is only supported on Linux hosts." };
|
||||
}
|
||||
|
||||
const { status } = await checkHealth(path);
|
||||
const { status } = await checkHealth(path, config.readOnly ?? false);
|
||||
if (status === "mounted") {
|
||||
return { status: BACKEND_STATUS.mounted };
|
||||
}
|
||||
@@ -104,7 +105,8 @@ const mount = async (config: BackendConfig, path: string) => {
|
||||
}
|
||||
};
|
||||
|
||||
const unmount = async (path: string) => {
|
||||
const unmount = async (name: string) => {
|
||||
const path = getVolumePath(name);
|
||||
if (os.platform() !== "linux") {
|
||||
logger.error("WebDAV unmounting is only supported on Linux hosts.");
|
||||
return { status: BACKEND_STATUS.error, error: "WebDAV unmounting is only supported on Linux hosts." };
|
||||
@@ -134,7 +136,9 @@ const unmount = async (path: string) => {
|
||||
}
|
||||
};
|
||||
|
||||
const checkHealth = async (path: string) => {
|
||||
const checkHealth = async (name: string, readOnly: boolean) => {
|
||||
const path = getVolumePath(name);
|
||||
|
||||
const run = async () => {
|
||||
logger.debug(`Checking health of WebDAV volume at ${path}...`);
|
||||
await fs.access(path);
|
||||
@@ -145,6 +149,10 @@ const checkHealth = async (path: string) => {
|
||||
throw new Error(`Path ${path} is not mounted as WebDAV.`);
|
||||
}
|
||||
|
||||
if (!readOnly) {
|
||||
await createTestFile(path);
|
||||
}
|
||||
|
||||
logger.debug(`WebDAV volume at ${path} is healthy and mounted.`);
|
||||
return { status: BACKEND_STATUS.mounted };
|
||||
};
|
||||
@@ -157,8 +165,14 @@ const checkHealth = async (path: string) => {
|
||||
}
|
||||
};
|
||||
|
||||
export const makeWebdavBackend = (config: BackendConfig, path: string): VolumeBackend => ({
|
||||
mount: () => mount(config, path),
|
||||
unmount: () => unmount(path),
|
||||
checkHealth: () => checkHealth(path),
|
||||
const getVolumePath = (name: string) => {
|
||||
return `${VOLUME_MOUNT_BASE}/${name}/_data`;
|
||||
};
|
||||
|
||||
export const makeWebdavBackend = (config: BackendConfig, name: string): VolumeBackend => ({
|
||||
mount: () => mount(config, name),
|
||||
unmount: () => unmount(name),
|
||||
checkHealth: () => checkHealth(name, config.readOnly ?? false),
|
||||
getVolumePath: () => getVolumePath(name),
|
||||
getBackupPath: async () => getVolumePath(name),
|
||||
});
|
||||
|
||||
@@ -24,7 +24,6 @@ const backupScheduleSchema = type({
|
||||
retentionPolicy: retentionPolicySchema.or("null"),
|
||||
excludePatterns: "string[] | null",
|
||||
includePatterns: "string[] | null",
|
||||
limitUploadKbps: "number | null",
|
||||
lastBackupAt: "number | null",
|
||||
lastBackupStatus: "'success' | 'error' | 'in_progress' | null",
|
||||
lastBackupError: "string | null",
|
||||
@@ -115,7 +114,6 @@ export const createBackupScheduleBody = type({
|
||||
retentionPolicy: retentionPolicySchema.optional(),
|
||||
excludePatterns: "string[]?",
|
||||
includePatterns: "string[]?",
|
||||
limitUploadKbps: "number?",
|
||||
tags: "string[]?",
|
||||
});
|
||||
|
||||
@@ -151,7 +149,6 @@ export const updateBackupScheduleBody = type({
|
||||
retentionPolicy: retentionPolicySchema.optional(),
|
||||
excludePatterns: "string[]?",
|
||||
includePatterns: "string[]?",
|
||||
limitUploadKbps: "number?",
|
||||
tags: "string[]?",
|
||||
});
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ import { db } from "../../db/db";
|
||||
import { backupSchedulesTable, repositoriesTable, volumesTable } from "../../db/schema";
|
||||
import { restic } from "../../utils/restic";
|
||||
import { logger } from "../../utils/logger";
|
||||
import { getVolumePath } from "../volumes/helpers";
|
||||
import { createVolumeBackend } from "../backends/backend";
|
||||
import type { CreateBackupScheduleBody, UpdateBackupScheduleBody } from "./backups.dto";
|
||||
import { toMessage } from "../../utils/errors";
|
||||
import { serverEvents } from "../../core/events";
|
||||
@@ -17,7 +17,7 @@ const calculateNextRun = (cronExpression: string): number => {
|
||||
try {
|
||||
const interval = CronExpressionParser.parse(cronExpression, {
|
||||
currentDate: new Date(),
|
||||
tz: Intl.DateTimeFormat().resolvedOptions().timeZone,
|
||||
tz: "UTC",
|
||||
});
|
||||
|
||||
return interval.next().getTime();
|
||||
@@ -88,7 +88,6 @@ const createSchedule = async (data: CreateBackupScheduleBody) => {
|
||||
retentionPolicy: data.retentionPolicy ?? null,
|
||||
excludePatterns: data.excludePatterns ?? [],
|
||||
includePatterns: data.includePatterns ?? [],
|
||||
limitUploadKbps: data.limitUploadKbps ?? null,
|
||||
nextBackupAt: nextBackupAt,
|
||||
})
|
||||
.returning();
|
||||
@@ -207,17 +206,16 @@ const executeBackup = async (scheduleId: number, manual = false) => {
|
||||
runningBackups.set(scheduleId, abortController);
|
||||
|
||||
try {
|
||||
const volumePath = getVolumePath(volume);
|
||||
const backend = createVolumeBackend(volume);
|
||||
const backupPath = await backend.getBackupPath();
|
||||
|
||||
const backupOptions: {
|
||||
exclude?: string[];
|
||||
include?: string[];
|
||||
tags?: string[];
|
||||
limitUploadKbps?: number | null;
|
||||
signal?: AbortSignal;
|
||||
} = {
|
||||
tags: [schedule.id.toString()],
|
||||
limitUploadKbps: schedule.limitUploadKbps,
|
||||
signal: abortController.signal,
|
||||
};
|
||||
|
||||
@@ -229,7 +227,7 @@ const executeBackup = async (scheduleId: number, manual = false) => {
|
||||
backupOptions.include = schedule.includePatterns;
|
||||
}
|
||||
|
||||
await restic.backup(repository.config, volumePath, {
|
||||
await restic.backup(repository.config, backupPath, {
|
||||
...backupOptions,
|
||||
onProgress: (progress) => {
|
||||
serverEvents.emit("backup:progress", {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Hono } from "hono";
|
||||
import { volumeService } from "../volumes/volume.service";
|
||||
import { getVolumePath } from "../volumes/helpers";
|
||||
import { createVolumeBackend } from "../backends/backend";
|
||||
|
||||
export const driverController = new Hono()
|
||||
.post("/VolumeDriver.Capabilities", (c) => {
|
||||
@@ -30,10 +30,12 @@ export const driverController = new Hono()
|
||||
return c.json({ Err: "Volume name is required" }, 400);
|
||||
}
|
||||
|
||||
const volumeName = body.Name.replace(/^zb-/, "");
|
||||
const volumeName = body.Name.replace(/^im-/, "");
|
||||
const { volume } = await volumeService.getVolume(volumeName);
|
||||
const backend = createVolumeBackend(volume);
|
||||
|
||||
return c.json({
|
||||
Mountpoint: getVolumePath(volumeName),
|
||||
Mountpoint: backend.getVolumePath(),
|
||||
});
|
||||
})
|
||||
.post("/VolumeDriver.Unmount", (c) => {
|
||||
@@ -48,10 +50,11 @@ export const driverController = new Hono()
|
||||
return c.json({ Err: "Volume name is required" }, 400);
|
||||
}
|
||||
|
||||
const { volume } = await volumeService.getVolume(body.Name.replace(/^zb-/, ""));
|
||||
const { volume } = await volumeService.getVolume(body.Name.replace(/^im-/, ""));
|
||||
const backend = createVolumeBackend(volume);
|
||||
|
||||
return c.json({
|
||||
Mountpoint: getVolumePath(volume),
|
||||
Mountpoint: backend.getVolumePath(),
|
||||
});
|
||||
})
|
||||
.post("/VolumeDriver.Get", async (c) => {
|
||||
@@ -61,12 +64,13 @@ export const driverController = new Hono()
|
||||
return c.json({ Err: "Volume name is required" }, 400);
|
||||
}
|
||||
|
||||
const { volume } = await volumeService.getVolume(body.Name.replace(/^zb-/, ""));
|
||||
const { volume } = await volumeService.getVolume(body.Name.replace(/^im-/, ""));
|
||||
const backend = createVolumeBackend(volume);
|
||||
|
||||
return c.json({
|
||||
Volume: {
|
||||
Name: `zb-${volume.name}`,
|
||||
Mountpoint: getVolumePath(volume),
|
||||
Name: `im-${volume.name}`,
|
||||
Mountpoint: backend.getVolumePath(),
|
||||
Status: {},
|
||||
},
|
||||
Err: "",
|
||||
@@ -75,11 +79,16 @@ export const driverController = new Hono()
|
||||
.post("/VolumeDriver.List", async (c) => {
|
||||
const volumes = await volumeService.listVolumes();
|
||||
|
||||
const res = volumes.map((volume) => ({
|
||||
Name: `zb-${volume.name}`,
|
||||
Mountpoint: getVolumePath(volume),
|
||||
Status: {},
|
||||
}));
|
||||
let res = [];
|
||||
for (const volume of volumes) {
|
||||
const backend = createVolumeBackend(volume);
|
||||
|
||||
res.push({
|
||||
Name: `im-${volume.name}`,
|
||||
Mountpoint: backend.getVolumePath(),
|
||||
Status: {},
|
||||
});
|
||||
}
|
||||
|
||||
return c.json({
|
||||
Volumes: res,
|
||||
|
||||
@@ -33,8 +33,8 @@ export const startup = async () => {
|
||||
}
|
||||
|
||||
Scheduler.build(CleanupDanglingMountsJob).schedule("0 * * * *");
|
||||
Scheduler.build(VolumeHealthCheckJob).schedule("*/30 * * * *");
|
||||
Scheduler.build(RepositoryHealthCheckJob).schedule("0 * * * *");
|
||||
Scheduler.build(VolumeHealthCheckJob).schedule("*/5 * * * *");
|
||||
Scheduler.build(RepositoryHealthCheckJob).schedule("*/10 * * * *");
|
||||
Scheduler.build(BackupExecutionJob).schedule("* * * * *");
|
||||
Scheduler.build(CleanupSessionsJob).schedule("0 0 * * *");
|
||||
};
|
||||
|
||||
@@ -4,7 +4,6 @@ import {
|
||||
createRepositoryBody,
|
||||
createRepositoryDto,
|
||||
deleteRepositoryDto,
|
||||
deleteSnapshotDto,
|
||||
doctorRepositoryDto,
|
||||
getRepositoryDto,
|
||||
getSnapshotDetailsDto,
|
||||
@@ -17,7 +16,6 @@ import {
|
||||
restoreSnapshotBody,
|
||||
restoreSnapshotDto,
|
||||
type DeleteRepositoryDto,
|
||||
type DeleteSnapshotDto,
|
||||
type DoctorRepositoryDto,
|
||||
type GetRepositoryDto,
|
||||
type GetSnapshotDetailsDto,
|
||||
@@ -123,8 +121,7 @@ export const repositoriesController = new Hono()
|
||||
const { name, snapshotId } = c.req.param();
|
||||
const { path } = c.req.valid("query");
|
||||
|
||||
const decodedPath = path ? decodeURIComponent(path) : undefined;
|
||||
const result = await repositoriesService.listSnapshotFiles(name, snapshotId, decodedPath);
|
||||
const result = await repositoriesService.listSnapshotFiles(name, snapshotId, path);
|
||||
|
||||
c.header("Cache-Control", "max-age=300, stale-while-revalidate=600");
|
||||
|
||||
@@ -145,11 +142,4 @@ export const repositoriesController = new Hono()
|
||||
const result = await repositoriesService.doctorRepository(name);
|
||||
|
||||
return c.json<DoctorRepositoryDto>(result, 200);
|
||||
})
|
||||
.delete("/:name/snapshots/:snapshotId", deleteSnapshotDto, async (c) => {
|
||||
const { name, snapshotId } = c.req.param();
|
||||
|
||||
await repositoriesService.deleteSnapshot(name, snapshotId);
|
||||
|
||||
return c.json<DeleteSnapshotDto>({ message: "Snapshot deleted" }, 200);
|
||||
});
|
||||
|
||||
@@ -326,28 +326,3 @@ export const listRcloneRemotesDto = describeRoute({
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Delete a snapshot
|
||||
*/
|
||||
export const deleteSnapshotResponse = type({
|
||||
message: "string",
|
||||
});
|
||||
|
||||
export type DeleteSnapshotDto = typeof deleteSnapshotResponse.infer;
|
||||
|
||||
export const deleteSnapshotDto = describeRoute({
|
||||
description: "Delete a specific snapshot from a repository",
|
||||
tags: ["Repositories"],
|
||||
operationId: "deleteSnapshot",
|
||||
responses: {
|
||||
200: {
|
||||
description: "Snapshot deleted successfully",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(deleteSnapshotResponse),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -33,17 +33,6 @@ const encryptConfig = async (config: RepositoryConfig): Promise<RepositoryConfig
|
||||
case "azure":
|
||||
encryptedConfig.accountKey = await cryptoUtils.encrypt(config.accountKey);
|
||||
break;
|
||||
case "rest":
|
||||
if (config.username) {
|
||||
encryptedConfig.username = await cryptoUtils.encrypt(config.username);
|
||||
}
|
||||
if (config.password) {
|
||||
encryptedConfig.password = await cryptoUtils.encrypt(config.password);
|
||||
}
|
||||
break;
|
||||
case "sftp":
|
||||
encryptedConfig.privateKey = await cryptoUtils.encrypt(config.privateKey);
|
||||
break;
|
||||
}
|
||||
|
||||
return encryptedConfig as RepositoryConfig;
|
||||
@@ -338,18 +327,6 @@ const doctorRepository = async (name: string) => {
|
||||
};
|
||||
};
|
||||
|
||||
const deleteSnapshot = async (name: string, snapshotId: string) => {
|
||||
const repository = await db.query.repositoriesTable.findFirst({
|
||||
where: eq(repositoriesTable.name, name),
|
||||
});
|
||||
|
||||
if (!repository) {
|
||||
throw new NotFoundError("Repository not found");
|
||||
}
|
||||
|
||||
await restic.deleteSnapshot(repository.config, snapshotId);
|
||||
};
|
||||
|
||||
export const repositoriesService = {
|
||||
listRepositories,
|
||||
createRepository,
|
||||
@@ -361,5 +338,4 @@ export const repositoriesService = {
|
||||
getSnapshotDetails,
|
||||
checkHealth,
|
||||
doctorRepository,
|
||||
deleteSnapshot,
|
||||
};
|
||||
|
||||
@@ -1,10 +0,0 @@
|
||||
import { VOLUME_MOUNT_BASE } from "../../core/constants";
|
||||
import type { Volume } from "../../db/schema";
|
||||
|
||||
export const getVolumePath = (volume: Volume) => {
|
||||
if (volume.config.backend === "directory") {
|
||||
return volume.config.path;
|
||||
}
|
||||
|
||||
return `${VOLUME_MOUNT_BASE}/${volume.name}/_data`;
|
||||
};
|
||||
@@ -25,7 +25,7 @@ import {
|
||||
type BrowseFilesystemDto,
|
||||
} from "./volume.dto";
|
||||
import { volumeService } from "./volume.service";
|
||||
import { getVolumePath } from "./helpers";
|
||||
import { createVolumeBackend } from "../backends/backend";
|
||||
|
||||
export const volumeController = new Hono()
|
||||
.get("/", listVolumesDto, async (c) => {
|
||||
@@ -37,9 +37,10 @@ export const volumeController = new Hono()
|
||||
const body = c.req.valid("json");
|
||||
const res = await volumeService.createVolume(body.name, body.config);
|
||||
|
||||
const backend = createVolumeBackend(res.volume);
|
||||
const response = {
|
||||
...res.volume,
|
||||
path: getVolumePath(res.volume),
|
||||
path: backend.getVolumePath(),
|
||||
};
|
||||
|
||||
return c.json<CreateVolumeDto>(response, 201);
|
||||
@@ -60,10 +61,11 @@ export const volumeController = new Hono()
|
||||
const { name } = c.req.param();
|
||||
const res = await volumeService.getVolume(name);
|
||||
|
||||
const backend = createVolumeBackend(res.volume);
|
||||
const response = {
|
||||
volume: {
|
||||
...res.volume,
|
||||
path: getVolumePath(res.volume),
|
||||
path: backend.getVolumePath(),
|
||||
},
|
||||
statfs: {
|
||||
total: res.statfs.total ?? 0,
|
||||
@@ -85,9 +87,10 @@ export const volumeController = new Hono()
|
||||
const body = c.req.valid("json");
|
||||
const res = await volumeService.updateVolume(name, body);
|
||||
|
||||
const backend = createVolumeBackend(res.volume);
|
||||
const response = {
|
||||
...res.volume,
|
||||
path: getVolumePath(res.volume),
|
||||
path: backend.getVolumePath(),
|
||||
};
|
||||
|
||||
return c.json<UpdateVolumeDto>(response, 200);
|
||||
|
||||
@@ -13,7 +13,6 @@ import { getStatFs, type StatFs } from "../../utils/mountinfo";
|
||||
import { withTimeout } from "../../utils/timeout";
|
||||
import { createVolumeBackend } from "../backends/backend";
|
||||
import type { UpdateVolumeBody } from "./volume.dto";
|
||||
import { getVolumePath } from "./helpers";
|
||||
import { logger } from "../../utils/logger";
|
||||
import { serverEvents } from "../../core/events";
|
||||
import type { BackendConfig } from "~/schemas/volumes";
|
||||
@@ -129,7 +128,9 @@ const getVolume = async (name: string) => {
|
||||
|
||||
let statfs: Partial<StatFs> = {};
|
||||
if (volume.status === "mounted") {
|
||||
statfs = await withTimeout(getStatFs(getVolumePath(volume)), 1000, "getStatFs").catch((error) => {
|
||||
const backend = createVolumeBackend(volume);
|
||||
const volumePath = backend.getVolumePath();
|
||||
statfs = await withTimeout(getStatFs(volumePath), 1000, "getStatFs").catch((error) => {
|
||||
logger.warn(`Failed to get statfs for volume ${name}: ${toMessage(error)}`);
|
||||
return {};
|
||||
});
|
||||
@@ -186,7 +187,7 @@ const updateVolume = async (name: string, volumeData: UpdateVolumeBody) => {
|
||||
};
|
||||
|
||||
const testConnection = async (backendConfig: BackendConfig) => {
|
||||
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "zerobyte-test-"));
|
||||
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "ironmount-test-"));
|
||||
|
||||
const mockVolume = {
|
||||
id: 0,
|
||||
@@ -203,7 +204,16 @@ const testConnection = async (backendConfig: BackendConfig) => {
|
||||
};
|
||||
|
||||
const backend = createVolumeBackend(mockVolume);
|
||||
const { error } = await backend.mount();
|
||||
let error: string | null = null;
|
||||
const mount = await backend.mount();
|
||||
if (mount.error) {
|
||||
error = mount.error;
|
||||
} else {
|
||||
const health = await backend.checkHealth();
|
||||
if (health.error) {
|
||||
error = health.error;
|
||||
}
|
||||
}
|
||||
|
||||
await backend.unmount();
|
||||
|
||||
@@ -295,8 +305,8 @@ const listFiles = async (name: string, subPath?: string) => {
|
||||
throw new InternalServerError("Volume is not mounted");
|
||||
}
|
||||
|
||||
// For directory volumes, use the configured path directly
|
||||
const volumePath = getVolumePath(volume);
|
||||
const backend = createVolumeBackend(volume);
|
||||
const volumePath = backend.getVolumePath();
|
||||
|
||||
const requestedPath = subPath ? path.join(volumePath, subPath) : volumePath;
|
||||
|
||||
|
||||
@@ -71,7 +71,7 @@ const ensurePassfile = async () => {
|
||||
const buildRepoUrl = (config: RepositoryConfig): string => {
|
||||
switch (config.backend) {
|
||||
case "local":
|
||||
return config.path ? `${config.path}/${config.name}` : `${REPOSITORY_BASE}/${config.name}`;
|
||||
return `${REPOSITORY_BASE}/${config.name}`;
|
||||
case "s3":
|
||||
return `s3:${config.endpoint}/${config.bucket}`;
|
||||
case "r2": {
|
||||
@@ -84,12 +84,6 @@ const buildRepoUrl = (config: RepositoryConfig): string => {
|
||||
return `azure:${config.container}:/`;
|
||||
case "rclone":
|
||||
return `rclone:${config.remote}:${config.path}`;
|
||||
case "rest": {
|
||||
const path = config.path ? `/${config.path}` : "";
|
||||
return `rest:${config.url}${path}`;
|
||||
}
|
||||
case "sftp":
|
||||
return `sftp:${config.user}@${config.host}:${config.path}`;
|
||||
default: {
|
||||
throw new Error(`Unsupported repository backend: ${JSON.stringify(config)}`);
|
||||
}
|
||||
@@ -98,13 +92,13 @@ const buildRepoUrl = (config: RepositoryConfig): string => {
|
||||
|
||||
const buildEnv = async (config: RepositoryConfig) => {
|
||||
const env: Record<string, string> = {
|
||||
RESTIC_CACHE_DIR: "/var/lib/zerobyte/restic/cache",
|
||||
RESTIC_CACHE_DIR: "/var/lib/ironmount/restic/cache",
|
||||
PATH: process.env.PATH || "/usr/local/bin:/usr/bin:/bin",
|
||||
};
|
||||
|
||||
if (config.isExistingRepository && config.customPassword) {
|
||||
const decryptedPassword = await cryptoUtils.decrypt(config.customPassword);
|
||||
const passwordFilePath = path.join("/tmp", `zerobyte-pass-${crypto.randomBytes(8).toString("hex")}.txt`);
|
||||
const passwordFilePath = path.join("/tmp", `ironmount-pass-${crypto.randomBytes(8).toString("hex")}.txt`);
|
||||
|
||||
await fs.writeFile(passwordFilePath, decryptedPassword, { mode: 0o600 });
|
||||
env.RESTIC_PASSWORD_FILE = passwordFilePath;
|
||||
@@ -125,7 +119,7 @@ const buildEnv = async (config: RepositoryConfig) => {
|
||||
break;
|
||||
case "gcs": {
|
||||
const decryptedCredentials = await cryptoUtils.decrypt(config.credentialsJson);
|
||||
const credentialsPath = path.join("/tmp", `zerobyte-gcs-${crypto.randomBytes(8).toString("hex")}.json`);
|
||||
const credentialsPath = path.join("/tmp", `ironmount-gcs-${crypto.randomBytes(8).toString("hex")}.json`);
|
||||
await fs.writeFile(credentialsPath, decryptedCredentials, { mode: 0o600 });
|
||||
env.GOOGLE_PROJECT_ID = config.projectId;
|
||||
env.GOOGLE_APPLICATION_CREDENTIALS = credentialsPath;
|
||||
@@ -139,52 +133,6 @@ const buildEnv = async (config: RepositoryConfig) => {
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "rest": {
|
||||
if (config.username) {
|
||||
env.RESTIC_REST_USERNAME = await cryptoUtils.decrypt(config.username);
|
||||
}
|
||||
if (config.password) {
|
||||
env.RESTIC_REST_PASSWORD = await cryptoUtils.decrypt(config.password);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "sftp": {
|
||||
const decryptedKey = await cryptoUtils.decrypt(config.privateKey);
|
||||
const keyPath = path.join("/tmp", `ironmount-ssh-${crypto.randomBytes(8).toString("hex")}`);
|
||||
|
||||
let normalizedKey = decryptedKey.replace(/\r\n/g, "\n");
|
||||
if (!normalizedKey.endsWith("\n")) {
|
||||
normalizedKey += "\n";
|
||||
}
|
||||
|
||||
if (normalizedKey.includes("ENCRYPTED")) {
|
||||
logger.error("SFTP: Private key appears to be passphrase-protected. Please use an unencrypted key.");
|
||||
throw new Error("Passphrase-protected SSH keys are not supported. Please provide an unencrypted private key.");
|
||||
}
|
||||
|
||||
await fs.writeFile(keyPath, normalizedKey, { mode: 0o600 });
|
||||
|
||||
env._SFTP_KEY_PATH = keyPath;
|
||||
|
||||
const sshArgs = [
|
||||
"-o",
|
||||
"StrictHostKeyChecking=no",
|
||||
"-o",
|
||||
"UserKnownHostsFile=/dev/null",
|
||||
"-o",
|
||||
"LogLevel=VERBOSE",
|
||||
"-i",
|
||||
keyPath,
|
||||
];
|
||||
|
||||
if (config.port && config.port !== 22) {
|
||||
sshArgs.push("-p", String(config.port));
|
||||
}
|
||||
|
||||
env._SFTP_SSH_ARGS = sshArgs.join(" ");
|
||||
logger.info(`SFTP: SSH args: ${env._SFTP_SSH_ARGS}`);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return env;
|
||||
@@ -194,16 +142,9 @@ const init = async (config: RepositoryConfig) => {
|
||||
await ensurePassfile();
|
||||
|
||||
const repoUrl = buildRepoUrl(config);
|
||||
|
||||
logger.info(`Initializing restic repository at ${repoUrl}...`);
|
||||
|
||||
const env = await buildEnv(config);
|
||||
|
||||
const args = ["init", "--repo", repoUrl, "--json"];
|
||||
addRepoSpecificArgs(args, config, env);
|
||||
|
||||
const res = await $`restic ${args}`.env(env).nothrow();
|
||||
await cleanupTemporaryKeys(config, env);
|
||||
const res = await $`restic init --repo ${repoUrl} --json`.env(env).nothrow();
|
||||
|
||||
if (res.exitCode !== 0) {
|
||||
logger.error(`Restic init failed: ${res.stderr}`);
|
||||
@@ -234,7 +175,6 @@ const backup = async (
|
||||
exclude?: string[];
|
||||
include?: string[];
|
||||
tags?: string[];
|
||||
limitUploadKbps?: number | null;
|
||||
signal?: AbortSignal;
|
||||
onProgress?: (progress: BackupProgress) => void;
|
||||
},
|
||||
@@ -244,10 +184,6 @@ const backup = async (
|
||||
|
||||
const args: string[] = ["--repo", repoUrl, "backup", "--one-file-system"];
|
||||
|
||||
if (options?.limitUploadKbps) {
|
||||
args.push("--limit-upload", String(options.limitUploadKbps));
|
||||
}
|
||||
|
||||
if (options?.tags && options.tags.length > 0) {
|
||||
for (const tag of options.tags) {
|
||||
args.push("--tag", tag);
|
||||
@@ -273,7 +209,6 @@ const backup = async (
|
||||
}
|
||||
}
|
||||
|
||||
addRepoSpecificArgs(args, config, env);
|
||||
args.push("--json");
|
||||
|
||||
const logData = throttle((data: string) => {
|
||||
@@ -314,7 +249,6 @@ const backup = async (
|
||||
},
|
||||
finally: async () => {
|
||||
includeFile && (await fs.unlink(includeFile).catch(() => {}));
|
||||
await cleanupTemporaryKeys(config, env);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -385,13 +319,11 @@ const restore = async (
|
||||
}
|
||||
}
|
||||
|
||||
addRepoSpecificArgs(args, config, env);
|
||||
args.push("--json");
|
||||
|
||||
console.log("Restic restore command:", ["restic", ...args].join(" "));
|
||||
|
||||
const res = await $`restic ${args}`.env(env).nothrow();
|
||||
await cleanupTemporaryKeys(config, env);
|
||||
|
||||
if (res.exitCode !== 0) {
|
||||
logger.error(`Restic restore failed: ${res.stderr}`);
|
||||
@@ -449,11 +381,9 @@ const snapshots = async (config: RepositoryConfig, options: { tags?: string[] }
|
||||
}
|
||||
}
|
||||
|
||||
addRepoSpecificArgs(args, config, env);
|
||||
args.push("--json");
|
||||
|
||||
const res = await $`restic ${args}`.env(env).nothrow().quiet();
|
||||
await cleanupTemporaryKeys(config, env);
|
||||
|
||||
if (res.exitCode !== 0) {
|
||||
logger.error(`Restic snapshots retrieval failed: ${res.stderr}`);
|
||||
@@ -499,11 +429,9 @@ const forget = async (config: RepositoryConfig, options: RetentionPolicy, extra:
|
||||
}
|
||||
|
||||
args.push("--prune");
|
||||
addRepoSpecificArgs(args, config, env);
|
||||
args.push("--json");
|
||||
|
||||
const res = await $`restic ${args}`.env(env).nothrow();
|
||||
await cleanupTemporaryKeys(config, env);
|
||||
|
||||
if (res.exitCode !== 0) {
|
||||
logger.error(`Restic forget failed: ${res.stderr}`);
|
||||
@@ -513,24 +441,6 @@ const forget = async (config: RepositoryConfig, options: RetentionPolicy, extra:
|
||||
return { success: true };
|
||||
};
|
||||
|
||||
const deleteSnapshot = async (config: RepositoryConfig, snapshotId: string) => {
|
||||
const repoUrl = buildRepoUrl(config);
|
||||
const env = await buildEnv(config);
|
||||
|
||||
const args: string[] = ["--repo", repoUrl, "forget", snapshotId, "--prune"];
|
||||
addRepoSpecificArgs(args, config, env);
|
||||
|
||||
const res = await $`restic ${args}`.env(env).nothrow();
|
||||
await cleanupTemporaryKeys(config, env);
|
||||
|
||||
if (res.exitCode !== 0) {
|
||||
logger.error(`Restic snapshot deletion failed: ${res.stderr}`);
|
||||
throw new Error(`Failed to delete snapshot: ${res.stderr}`);
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
};
|
||||
|
||||
const lsNodeSchema = type({
|
||||
name: "string",
|
||||
type: "string",
|
||||
@@ -568,10 +478,7 @@ const ls = async (config: RepositoryConfig, snapshotId: string, path?: string) =
|
||||
args.push(path);
|
||||
}
|
||||
|
||||
addRepoSpecificArgs(args, config, env);
|
||||
|
||||
const res = await safeSpawn({ command: "restic", args, env });
|
||||
await cleanupTemporaryKeys(config, env);
|
||||
const res = await $`restic ${args}`.env(env).nothrow().quiet();
|
||||
|
||||
if (res.exitCode !== 0) {
|
||||
logger.error(`Restic ls failed: ${res.stderr}`);
|
||||
@@ -579,7 +486,7 @@ const ls = async (config: RepositoryConfig, snapshotId: string, path?: string) =
|
||||
}
|
||||
|
||||
// The output is a stream of JSON objects, first is snapshot info, rest are file/dir nodes
|
||||
const stdout = res.stdout;
|
||||
const stdout = res.text();
|
||||
const lines = stdout
|
||||
.trim()
|
||||
.split("\n")
|
||||
@@ -618,11 +525,7 @@ const unlock = async (config: RepositoryConfig) => {
|
||||
const repoUrl = buildRepoUrl(config);
|
||||
const env = await buildEnv(config);
|
||||
|
||||
const args = ["unlock", "--repo", repoUrl, "--remove-all", "--json"];
|
||||
addRepoSpecificArgs(args, config, env);
|
||||
|
||||
const res = await $`restic ${args}`.env(env).nothrow();
|
||||
await cleanupTemporaryKeys(config, env);
|
||||
const res = await $`restic unlock --repo ${repoUrl} --remove-all --json`.env(env).nothrow();
|
||||
|
||||
if (res.exitCode !== 0) {
|
||||
logger.error(`Restic unlock failed: ${res.stderr}`);
|
||||
@@ -643,10 +546,7 @@ const check = async (config: RepositoryConfig, options?: { readData?: boolean })
|
||||
args.push("--read-data");
|
||||
}
|
||||
|
||||
addRepoSpecificArgs(args, config, env);
|
||||
|
||||
const res = await $`restic ${args}`.env(env).nothrow();
|
||||
await cleanupTemporaryKeys(config, env);
|
||||
|
||||
const stdout = res.text();
|
||||
const stderr = res.stderr.toString();
|
||||
@@ -676,11 +576,7 @@ const repairIndex = async (config: RepositoryConfig) => {
|
||||
const repoUrl = buildRepoUrl(config);
|
||||
const env = await buildEnv(config);
|
||||
|
||||
const args = ["repair", "index", "--repo", repoUrl];
|
||||
addRepoSpecificArgs(args, config, env);
|
||||
|
||||
const res = await $`restic ${args}`.env(env).nothrow();
|
||||
await cleanupTemporaryKeys(config, env);
|
||||
const res = await $`restic repair index --repo ${repoUrl}`.env(env).nothrow();
|
||||
|
||||
const stdout = res.text();
|
||||
const stderr = res.stderr.toString();
|
||||
@@ -698,22 +594,6 @@ const repairIndex = async (config: RepositoryConfig) => {
|
||||
};
|
||||
};
|
||||
|
||||
const addRepoSpecificArgs = (args: string[], config: RepositoryConfig, env: Record<string, string>) => {
|
||||
if (config.backend === "sftp" && env._SFTP_SSH_ARGS) {
|
||||
args.push("-o", `sftp.args=${env._SFTP_SSH_ARGS}`);
|
||||
}
|
||||
};
|
||||
|
||||
const cleanupTemporaryKeys = async (config: RepositoryConfig, env: Record<string, string>) => {
|
||||
if (config.backend === "sftp" && env._SFTP_KEY_PATH) {
|
||||
await fs.unlink(env._SFTP_KEY_PATH).catch(() => {});
|
||||
} else if (config.isExistingRepository && config.customPassword && env.RESTIC_PASSWORD_FILE) {
|
||||
await fs.unlink(env.RESTIC_PASSWORD_FILE).catch(() => {});
|
||||
} else if (config.backend === "gcs" && env.GOOGLE_APPLICATION_CREDENTIALS) {
|
||||
await fs.unlink(env.GOOGLE_APPLICATION_CREDENTIALS).catch(() => {});
|
||||
}
|
||||
};
|
||||
|
||||
export const restic = {
|
||||
ensurePassfile,
|
||||
init,
|
||||
@@ -721,7 +601,6 @@ export const restic = {
|
||||
restore,
|
||||
snapshots,
|
||||
forget,
|
||||
deleteSnapshot,
|
||||
unlock,
|
||||
ls,
|
||||
check,
|
||||
|
||||
@@ -3,6 +3,10 @@
|
||||
* This removes passwords and credentials from logs and error messages
|
||||
*/
|
||||
export const sanitizeSensitiveData = (text: string): string => {
|
||||
if (process.env.NODE_ENV === "development") {
|
||||
return text;
|
||||
}
|
||||
|
||||
let sanitized = text.replace(/\b(pass|password)=([^\s,]+)/gi, "$1=***");
|
||||
|
||||
sanitized = sanitized.replace(/\/\/([^:@\s]+):([^@\s]+)@/g, "//$1:***@");
|
||||
|
||||
@@ -5,6 +5,8 @@ interface Params {
|
||||
args: string[];
|
||||
env?: NodeJS.ProcessEnv;
|
||||
signal?: AbortSignal;
|
||||
stdin?: string;
|
||||
timeout?: number;
|
||||
onStdout?: (data: string) => void;
|
||||
onStderr?: (error: string) => void;
|
||||
onError?: (error: Error) => Promise<void> | void;
|
||||
@@ -19,17 +21,26 @@ type SpawnResult = {
|
||||
};
|
||||
|
||||
export const safeSpawn = (params: Params) => {
|
||||
const { command, args, env = {}, signal, ...callbacks } = params;
|
||||
const { command, args, env = {}, signal, stdin, timeout, ...callbacks } = params;
|
||||
|
||||
return new Promise<SpawnResult>((resolve) => {
|
||||
return new Promise<SpawnResult>((resolve, reject) => {
|
||||
let stdoutData = "";
|
||||
let stderrData = "";
|
||||
let timeoutId: NodeJS.Timeout | undefined;
|
||||
|
||||
const child = spawn(command, args, {
|
||||
env: { ...process.env, ...env },
|
||||
signal: signal,
|
||||
});
|
||||
|
||||
// Handle timeout if specified
|
||||
if (timeout) {
|
||||
timeoutId = setTimeout(() => {
|
||||
child.kill("SIGTERM");
|
||||
reject(new Error(`Command timed out after ${timeout}ms`));
|
||||
}, timeout);
|
||||
}
|
||||
|
||||
child.stdout.on("data", (data) => {
|
||||
if (callbacks.onStdout) {
|
||||
callbacks.onStdout(data.toString());
|
||||
@@ -47,6 +58,7 @@ export const safeSpawn = (params: Params) => {
|
||||
});
|
||||
|
||||
child.on("error", async (error) => {
|
||||
if (timeoutId) clearTimeout(timeoutId);
|
||||
if (callbacks.onError) {
|
||||
await callbacks.onError(error);
|
||||
}
|
||||
@@ -62,6 +74,7 @@ export const safeSpawn = (params: Params) => {
|
||||
});
|
||||
|
||||
child.on("close", async (code) => {
|
||||
if (timeoutId) clearTimeout(timeoutId);
|
||||
if (callbacks.onClose) {
|
||||
await callbacks.onClose(code);
|
||||
}
|
||||
@@ -69,11 +82,15 @@ export const safeSpawn = (params: Params) => {
|
||||
await callbacks.finally();
|
||||
}
|
||||
|
||||
resolve({
|
||||
exitCode: code === null ? -1 : code,
|
||||
stdout: stdoutData,
|
||||
stderr: stderrData,
|
||||
});
|
||||
if (code !== 0 && code !== null) {
|
||||
reject(new Error(`Command failed with exit code ${code}: ${stderrData || stdoutData}`));
|
||||
} else {
|
||||
resolve({
|
||||
exitCode: code === null ? -1 : code,
|
||||
stdout: stdoutData,
|
||||
stderr: stderrData,
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
8
bun.lock
8
bun.lock
@@ -4,6 +4,7 @@
|
||||
"": {
|
||||
"name": "@ironmount/client",
|
||||
"dependencies": {
|
||||
"@hono/node-server": "^1.19.6",
|
||||
"@hono/standard-validator": "^0.1.5",
|
||||
"@hookform/resolvers": "^5.2.2",
|
||||
"@radix-ui/react-alert-dialog": "^1.1.15",
|
||||
@@ -22,6 +23,8 @@
|
||||
"@react-router/serve": "^7.9.3",
|
||||
"@scalar/hono-api-reference": "^0.9.24",
|
||||
"@tanstack/react-query": "^5.90.2",
|
||||
"@tanstack/react-query-devtools": "^5.90.2",
|
||||
"@tanstack/react-table": "^8.21.3",
|
||||
"arktype": "^2.1.26",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
@@ -57,7 +60,6 @@
|
||||
"@hey-api/openapi-ts": "^0.87.4",
|
||||
"@react-router/dev": "^7.9.3",
|
||||
"@tailwindcss/vite": "^4.1.14",
|
||||
"@tanstack/react-query-devtools": "^5.90.2",
|
||||
"@types/bun": "^1.3.2",
|
||||
"@types/dockerode": "^3.3.45",
|
||||
"@types/node": "^24.6.2",
|
||||
@@ -480,6 +482,10 @@
|
||||
|
||||
"@tanstack/react-query-devtools": ["@tanstack/react-query-devtools@5.90.2", "", { "dependencies": { "@tanstack/query-devtools": "5.90.1" }, "peerDependencies": { "@tanstack/react-query": "^5.90.2", "react": "^18 || ^19" } }, "sha512-vAXJzZuBXtCQtrY3F/yUNJCV4obT/A/n81kb3+YqLbro5Z2+phdAbceO+deU3ywPw8B42oyJlp4FhO0SoivDFQ=="],
|
||||
|
||||
"@tanstack/react-table": ["@tanstack/react-table@8.21.3", "", { "dependencies": { "@tanstack/table-core": "8.21.3" }, "peerDependencies": { "react": ">=16.8", "react-dom": ">=16.8" } }, "sha512-5nNMTSETP4ykGegmVkhjcS8tTLW6Vl4axfEGQN3v0zdHYbK4UfoqfPChclTrJ4EoK9QynqAu9oUf8VEmrpZ5Ww=="],
|
||||
|
||||
"@tanstack/table-core": ["@tanstack/table-core@8.21.3", "", {}, "sha512-ldZXEhOBb8Is7xLs01fR3YEc3DERiz5silj8tnGkFZytt1abEvl/GhUmCE0PMLaMPTa3Jk4HbKmRlHmu+gCftg=="],
|
||||
|
||||
"@types/bun": ["@types/bun@1.3.2", "", { "dependencies": { "bun-types": "1.3.2" } }, "sha512-t15P7k5UIgHKkxwnMNkJbWlh/617rkDGEdSsDbu+qNHTaz9SKf7aC8fiIlUdD5RPpH6GEkP0cK7WlvmrEBRtWg=="],
|
||||
|
||||
"@types/d3-array": ["@types/d3-array@3.2.2", "", {}, "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw=="],
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
services:
|
||||
zerobyte-dev:
|
||||
ironmount-dev:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
target: development
|
||||
container_name: zerobyte
|
||||
container_name: ironmount
|
||||
restart: unless-stopped
|
||||
devices:
|
||||
- /dev/fuse:/dev/fuse
|
||||
@@ -15,21 +15,20 @@ services:
|
||||
ports:
|
||||
- "4096:4096"
|
||||
volumes:
|
||||
- /etc/localtime:/etc/localtime:ro
|
||||
- /var/lib/zerobyte:/var/lib/zerobyte
|
||||
- /var/lib/ironmount:/var/lib/ironmount
|
||||
|
||||
- ./app:/app/app
|
||||
- ~/.config/rclone:/root/.config/rclone
|
||||
- /var/lib/zerobyte:/var/lib/zerobyte:rshared
|
||||
- /var/lib/ironmount:/var/lib/ironmount:rshared
|
||||
- /run/docker/plugins:/run/docker/plugins
|
||||
- /var/run/docker.sock:/var/run/docker.sock
|
||||
|
||||
zerobyte-prod:
|
||||
ironmount-prod:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
target: production
|
||||
container_name: zerobyte
|
||||
container_name: ironmount
|
||||
restart: unless-stopped
|
||||
devices:
|
||||
- /dev/fuse:/dev/fuse
|
||||
@@ -38,7 +37,6 @@ services:
|
||||
ports:
|
||||
- "4096:4096"
|
||||
volumes:
|
||||
- /etc/localtime:/etc/localtime:ro
|
||||
- /var/lib/zerobyte:/var/lib/zerobyte:rshared
|
||||
- /var/lib/ironmount:/var/lib/ironmount:rshared
|
||||
- /run/docker/plugins:/run/docker/plugins
|
||||
- /var/run/docker.sock:/var/run/docker.sock
|
||||
|
||||
@@ -6,6 +6,6 @@ export default defineConfig({
|
||||
schema: "./app/server/db/schema.ts",
|
||||
dialect: "sqlite",
|
||||
dbCredentials: {
|
||||
url: "./data/zerobyte.db",
|
||||
url: "./data/ironmount.db",
|
||||
},
|
||||
});
|
||||
|
||||
@@ -10,9 +10,9 @@ sync:
|
||||
- "logs"
|
||||
- "mutagen.yml.lock"
|
||||
- "data"
|
||||
zerobyte:
|
||||
ironmount:
|
||||
alpha: "."
|
||||
beta: "nicolas@192.168.2.42:/home/nicolas/zerobyte"
|
||||
beta: "nicolas@192.168.2.42:/home/nicolas/ironmount"
|
||||
mode: "one-way-replica"
|
||||
flushOnCreate: true
|
||||
ignore:
|
||||
|
||||
10
package.json
10
package.json
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"name": "zerobyte",
|
||||
"name": "ironmount",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"packageManager": "bun@1.3.1",
|
||||
@@ -10,13 +10,14 @@
|
||||
"tsc": "react-router typegen && tsc",
|
||||
"lint": "biome check .",
|
||||
"lint:ci": "biome check . --ci",
|
||||
"start:dev": "docker compose down && docker compose up --build zerobyte-dev",
|
||||
"start:prod": "docker compose down && docker compose up --build zerobyte-prod",
|
||||
"start:dev": "docker compose down && docker compose up --build ironmount-dev",
|
||||
"start:prod": "docker compose down && docker compose up --build ironmount-prod",
|
||||
"gen:api-client": "openapi-ts",
|
||||
"gen:migrations": "drizzle-kit generate",
|
||||
"studio": "drizzle-kit studio"
|
||||
},
|
||||
"dependencies": {
|
||||
"@hono/node-server": "^1.19.6",
|
||||
"@hono/standard-validator": "^0.1.5",
|
||||
"@hookform/resolvers": "^5.2.2",
|
||||
"@radix-ui/react-alert-dialog": "^1.1.15",
|
||||
@@ -35,6 +36,8 @@
|
||||
"@react-router/serve": "^7.9.3",
|
||||
"@scalar/hono-api-reference": "^0.9.24",
|
||||
"@tanstack/react-query": "^5.90.2",
|
||||
"@tanstack/react-query-devtools": "^5.90.2",
|
||||
"@tanstack/react-table": "^8.21.3",
|
||||
"arktype": "^2.1.26",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
@@ -70,7 +73,6 @@
|
||||
"@hey-api/openapi-ts": "^0.87.4",
|
||||
"@react-router/dev": "^7.9.3",
|
||||
"@tailwindcss/vite": "^4.1.14",
|
||||
"@tanstack/react-query-devtools": "^5.90.2",
|
||||
"@types/bun": "^1.3.2",
|
||||
"@types/dockerode": "^3.3.45",
|
||||
"@types/node": "^24.6.2",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "Zerobyte",
|
||||
"short_name": "Zerobyte",
|
||||
"name": "Ironmount",
|
||||
"short_name": "Ironmount",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/images/favicon/web-app-manifest-192x192.png",
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 8.1 KiB |
Reference in New Issue
Block a user