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

|

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

|

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

|

|
||||||
|
|
||||||
## Propagating mounts to host
|
## Propagating mounts to host
|
||||||
|
|
||||||
Ironmount is capable of propagating mounted volumes from within the container to the host system. This is particularly useful when you want to access the mounted data directly from the host to use it with other applications or services.
|
Zerobyte is capable of propagating mounted volumes from within the container to the host system. This is particularly useful when you want to access the mounted data directly from the host to use it with other applications or services.
|
||||||
|
|
||||||
In order to enable this feature, you need to change your bind mount `/var/lib/ironmount` to use the `:rshared` flag. Here is an example of how to set this up in your `docker-compose.yml` file:
|
In order to enable this feature, you need to change your bind mount `/var/lib/zerobyte` to use the `:rshared` flag. Here is an example of how to set this up in your `docker-compose.yml` file:
|
||||||
|
|
||||||
```diff
|
```diff
|
||||||
services:
|
services:
|
||||||
ironmount:
|
zerobyte:
|
||||||
image: ghcr.io/nicotsx/ironmount:v0.6
|
image: ghcr.io/nicotsx/zerobyte:v0.12
|
||||||
container_name: ironmount
|
container_name: zerobyte
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
ports:
|
ports:
|
||||||
- "4096:4096"
|
- "4096:4096"
|
||||||
devices:
|
devices:
|
||||||
- /dev/fuse:/dev/fuse
|
- /dev/fuse:/dev/fuse
|
||||||
volumes:
|
volumes:
|
||||||
- - /var/lib/ironmount:/var/lib/ironmount
|
- /etc/localtime:/etc/localtime:ro
|
||||||
+ - /var/lib/ironmount:/var/lib/ironmount:rshared
|
- - /var/lib/zerobyte:/var/lib/zerobyte
|
||||||
|
+ - /var/lib/zerobyte:/var/lib/zerobyte:rshared
|
||||||
```
|
```
|
||||||
|
|
||||||
Restart the Ironmount container to apply the changes:
|
Restart the Zerobyte container to apply the changes:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker compose down
|
docker compose down
|
||||||
@@ -208,15 +217,15 @@ docker compose up -d
|
|||||||
|
|
||||||
## Docker plugin
|
## Docker plugin
|
||||||
|
|
||||||
Ironmount can also be used as a Docker volume plugin, allowing you to mount your volumes directly into other Docker containers. This enables seamless integration with your containerized applications.
|
Zerobyte can also be used as a Docker volume plugin, allowing you to mount your volumes directly into other Docker containers. This enables seamless integration with your containerized applications.
|
||||||
|
|
||||||
In order to enable this feature, you need to run Ironmount with several items shared from the host. Here is an example of how to set this up in your `docker-compose.yml` file:
|
In order to enable this feature, you need to run Zerobyte with several items shared from the host. Here is an example of how to set this up in your `docker-compose.yml` file:
|
||||||
|
|
||||||
```diff
|
```diff
|
||||||
services:
|
services:
|
||||||
ironmount:
|
zerobyte:
|
||||||
image: ghcr.io/nicotsx/ironmount:v0.6
|
image: ghcr.io/nicotsx/zerobyte:v0.12
|
||||||
container_name: ironmount
|
container_name: zerobyte
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
cap_add:
|
cap_add:
|
||||||
- SYS_ADMIN
|
- SYS_ADMIN
|
||||||
@@ -225,20 +234,21 @@ services:
|
|||||||
devices:
|
devices:
|
||||||
- /dev/fuse:/dev/fuse
|
- /dev/fuse:/dev/fuse
|
||||||
volumes:
|
volumes:
|
||||||
- - /var/lib/ironmount:/var/lib/ironmount
|
- /etc/localtime:/etc/localtime:ro
|
||||||
+ - /var/lib/ironmount:/var/lib/ironmount:rshared
|
- - /var/lib/zerobyte:/var/lib/zerobyte
|
||||||
|
+ - /var/lib/zerobyte:/var/lib/zerobyte:rshared
|
||||||
+ - /run/docker/plugins:/run/docker/plugins
|
+ - /run/docker/plugins:/run/docker/plugins
|
||||||
+ - /var/run/docker.sock:/var/run/docker.sock
|
+ - /var/run/docker.sock:/var/run/docker.sock
|
||||||
```
|
```
|
||||||
|
|
||||||
Restart the Ironmount container to apply the changes:
|
Restart the Zerobyte container to apply the changes:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker compose down
|
docker compose down
|
||||||
docker compose up -d
|
docker compose up -d
|
||||||
```
|
```
|
||||||
|
|
||||||
Your Ironmount volumes will now be available as Docker volumes that you can mount into other containers using the `--volume` flag:
|
Your Zerobyte volumes will now be available as Docker volumes that you can mount into other containers using the `--volume` flag:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker run -v im-nfs:/path/in/container nginx:latest
|
docker run -v im-nfs:/path/in/container nginx:latest
|
||||||
@@ -257,7 +267,7 @@ volumes:
|
|||||||
external: true
|
external: true
|
||||||
```
|
```
|
||||||
|
|
||||||
The volume name format is `im-<volume-name>` where `<volume-name>` is the name you assigned to the volume in Ironmount. You can verify that the volume is available by running:
|
The volume name format is `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:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker volume ls
|
docker volume ls
|
||||||
@@ -269,7 +279,7 @@ This project includes the following third-party software components:
|
|||||||
|
|
||||||
### Restic
|
### Restic
|
||||||
|
|
||||||
Ironmount includes [Restic](https://github.com/restic/restic) for backup functionality.
|
Zerobyte includes [Restic](https://github.com/restic/restic) for backup functionality.
|
||||||
|
|
||||||
- **License**: BSD 2-Clause License
|
- **License**: BSD 2-Clause License
|
||||||
- **Copyright**: Copyright (c) 2014, Alexander Neumann <alexander@bumpern.de>
|
- **Copyright**: Copyright (c) 2014, Alexander Neumann <alexander@bumpern.de>
|
||||||
@@ -277,3 +287,7 @@ Ironmount includes [Restic](https://github.com/restic/restic) for backup functio
|
|||||||
- **License Text**: See [LICENSES/BSD-2-Clause-Restic.txt](LICENSES/BSD-2-Clause-Restic.txt)
|
- **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.
|
For a complete list of third-party software licenses and attributions, please refer to the [NOTICES.md](NOTICES.md) file.
|
||||||
|
|
||||||
|
## Contributing
|
||||||
|
|
||||||
|
Contributions by anyone are welcome! If you find a bug or have a feature request, please open an issue on GitHub. If you want to contribute code, feel free to fork the repository and submit a pull request. We require that all contributors sign a Contributor License Agreement (CLA) before we can accept your contributions. This is to protect both you and the project. Please see the [CONTRIBUTING.md](CONTRIBUTING.md) file for more details.
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,7 +1,7 @@
|
|||||||
// This file is auto-generated by @hey-api/openapi-ts
|
// This file is auto-generated by @hey-api/openapi-ts
|
||||||
|
|
||||||
import { type ClientOptions, type Config, createClient, createConfig } from "./client";
|
import { type ClientOptions, type Config, createClient, createConfig } from './client';
|
||||||
import type { ClientOptions as ClientOptions2 } from "./types.gen";
|
import type { ClientOptions as ClientOptions2 } from './types.gen';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The `createClientConfig()` function will be called on client initialization
|
* The `createClientConfig()` function will be called on client initialization
|
||||||
@@ -11,12 +11,8 @@ import type { ClientOptions as ClientOptions2 } from "./types.gen";
|
|||||||
* `setConfig()`. This is useful for example if you're using Next.js
|
* `setConfig()`. This is useful for example if you're using Next.js
|
||||||
* to ensure your client always has the correct values.
|
* to ensure your client always has the correct values.
|
||||||
*/
|
*/
|
||||||
export type CreateClientConfig<T extends ClientOptions = ClientOptions2> = (
|
export type CreateClientConfig<T extends ClientOptions = ClientOptions2> = (override?: Config<ClientOptions & T>) => Config<Required<ClientOptions> & T>;
|
||||||
override?: Config<ClientOptions & T>,
|
|
||||||
) => Config<Required<ClientOptions> & T>;
|
|
||||||
|
|
||||||
export const client = createClient(
|
export const client = createClient(createConfig<ClientOptions2>({
|
||||||
createConfig<ClientOptions2>({
|
baseUrl: 'http://192.168.2.42:4096'
|
||||||
baseUrl: "http://192.168.2.42:4096",
|
}));
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|||||||
@@ -1,278 +1,301 @@
|
|||||||
// This file is auto-generated by @hey-api/openapi-ts
|
// This file is auto-generated by @hey-api/openapi-ts
|
||||||
|
|
||||||
import { createSseClient } from "../core/serverSentEvents.gen";
|
import { createSseClient } from '../core/serverSentEvents.gen';
|
||||||
import type { HttpMethod } from "../core/types.gen";
|
import type { HttpMethod } from '../core/types.gen';
|
||||||
import { getValidRequestBody } from "../core/utils.gen";
|
import { getValidRequestBody } from '../core/utils.gen';
|
||||||
import type { Client, Config, RequestOptions, ResolvedRequestOptions } from "./types.gen";
|
import type {
|
||||||
|
Client,
|
||||||
|
Config,
|
||||||
|
RequestOptions,
|
||||||
|
ResolvedRequestOptions,
|
||||||
|
} from './types.gen';
|
||||||
import {
|
import {
|
||||||
buildUrl,
|
buildUrl,
|
||||||
createConfig,
|
createConfig,
|
||||||
createInterceptors,
|
createInterceptors,
|
||||||
getParseAs,
|
getParseAs,
|
||||||
mergeConfigs,
|
mergeConfigs,
|
||||||
mergeHeaders,
|
mergeHeaders,
|
||||||
setAuthParams,
|
setAuthParams,
|
||||||
} from "./utils.gen";
|
} from './utils.gen';
|
||||||
|
|
||||||
type ReqInit = Omit<RequestInit, "body" | "headers"> & {
|
type ReqInit = Omit<RequestInit, 'body' | 'headers'> & {
|
||||||
body?: any;
|
body?: any;
|
||||||
headers: ReturnType<typeof mergeHeaders>;
|
headers: ReturnType<typeof mergeHeaders>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const createClient = (config: Config = {}): Client => {
|
export const createClient = (config: Config = {}): Client => {
|
||||||
let _config = mergeConfigs(createConfig(), config);
|
let _config = mergeConfigs(createConfig(), config);
|
||||||
|
|
||||||
const getConfig = (): Config => ({ ..._config });
|
const getConfig = (): Config => ({ ..._config });
|
||||||
|
|
||||||
const setConfig = (config: Config): Config => {
|
const setConfig = (config: Config): Config => {
|
||||||
_config = mergeConfigs(_config, config);
|
_config = mergeConfigs(_config, config);
|
||||||
return getConfig();
|
return getConfig();
|
||||||
};
|
};
|
||||||
|
|
||||||
const interceptors = createInterceptors<Request, Response, unknown, ResolvedRequestOptions>();
|
const interceptors = createInterceptors<
|
||||||
|
Request,
|
||||||
|
Response,
|
||||||
|
unknown,
|
||||||
|
ResolvedRequestOptions
|
||||||
|
>();
|
||||||
|
|
||||||
const beforeRequest = async (options: RequestOptions) => {
|
const beforeRequest = async (options: RequestOptions) => {
|
||||||
const opts = {
|
const opts = {
|
||||||
..._config,
|
..._config,
|
||||||
...options,
|
...options,
|
||||||
fetch: options.fetch ?? _config.fetch ?? globalThis.fetch,
|
fetch: options.fetch ?? _config.fetch ?? globalThis.fetch,
|
||||||
headers: mergeHeaders(_config.headers, options.headers),
|
headers: mergeHeaders(_config.headers, options.headers),
|
||||||
serializedBody: undefined,
|
serializedBody: undefined,
|
||||||
};
|
};
|
||||||
|
|
||||||
if (opts.security) {
|
if (opts.security) {
|
||||||
await setAuthParams({
|
await setAuthParams({
|
||||||
...opts,
|
...opts,
|
||||||
security: opts.security,
|
security: opts.security,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (opts.requestValidator) {
|
if (opts.requestValidator) {
|
||||||
await opts.requestValidator(opts);
|
await opts.requestValidator(opts);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (opts.body !== undefined && opts.bodySerializer) {
|
if (opts.body !== undefined && opts.bodySerializer) {
|
||||||
opts.serializedBody = opts.bodySerializer(opts.body);
|
opts.serializedBody = opts.bodySerializer(opts.body);
|
||||||
}
|
}
|
||||||
|
|
||||||
// remove Content-Type header if body is empty to avoid sending invalid requests
|
// remove Content-Type header if body is empty to avoid sending invalid requests
|
||||||
if (opts.body === undefined || opts.serializedBody === "") {
|
if (opts.body === undefined || opts.serializedBody === '') {
|
||||||
opts.headers.delete("Content-Type");
|
opts.headers.delete('Content-Type');
|
||||||
}
|
}
|
||||||
|
|
||||||
const url = buildUrl(opts);
|
const url = buildUrl(opts);
|
||||||
|
|
||||||
return { opts, url };
|
return { opts, url };
|
||||||
};
|
};
|
||||||
|
|
||||||
const request: Client["request"] = async (options) => {
|
const request: Client['request'] = async (options) => {
|
||||||
// @ts-expect-error
|
// @ts-expect-error
|
||||||
const { opts, url } = await beforeRequest(options);
|
const { opts, url } = await beforeRequest(options);
|
||||||
const requestInit: ReqInit = {
|
const requestInit: ReqInit = {
|
||||||
redirect: "follow",
|
redirect: 'follow',
|
||||||
...opts,
|
...opts,
|
||||||
body: getValidRequestBody(opts),
|
body: getValidRequestBody(opts),
|
||||||
};
|
};
|
||||||
|
|
||||||
let request = new Request(url, requestInit);
|
let request = new Request(url, requestInit);
|
||||||
|
|
||||||
for (const fn of interceptors.request.fns) {
|
for (const fn of interceptors.request.fns) {
|
||||||
if (fn) {
|
if (fn) {
|
||||||
request = await fn(request, opts);
|
request = await fn(request, opts);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// fetch must be assigned here, otherwise it would throw the error:
|
// fetch must be assigned here, otherwise it would throw the error:
|
||||||
// TypeError: Failed to execute 'fetch' on 'Window': Illegal invocation
|
// TypeError: Failed to execute 'fetch' on 'Window': Illegal invocation
|
||||||
const _fetch = opts.fetch!;
|
const _fetch = opts.fetch!;
|
||||||
let response: Response;
|
let response: Response;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
response = await _fetch(request);
|
response = await _fetch(request);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// Handle fetch exceptions (AbortError, network errors, etc.)
|
// Handle fetch exceptions (AbortError, network errors, etc.)
|
||||||
let finalError = error;
|
let finalError = error;
|
||||||
|
|
||||||
for (const fn of interceptors.error.fns) {
|
for (const fn of interceptors.error.fns) {
|
||||||
if (fn) {
|
if (fn) {
|
||||||
finalError = (await fn(error, undefined as any, request, opts)) as unknown;
|
finalError = (await fn(
|
||||||
}
|
error,
|
||||||
}
|
undefined as any,
|
||||||
|
request,
|
||||||
|
opts,
|
||||||
|
)) as unknown;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
finalError = finalError || ({} as unknown);
|
finalError = finalError || ({} as unknown);
|
||||||
|
|
||||||
if (opts.throwOnError) {
|
if (opts.throwOnError) {
|
||||||
throw finalError;
|
throw finalError;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Return error response
|
// Return error response
|
||||||
return opts.responseStyle === "data"
|
return opts.responseStyle === 'data'
|
||||||
? undefined
|
? undefined
|
||||||
: {
|
: {
|
||||||
error: finalError,
|
error: finalError,
|
||||||
request,
|
request,
|
||||||
response: undefined as any,
|
response: undefined as any,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const fn of interceptors.response.fns) {
|
for (const fn of interceptors.response.fns) {
|
||||||
if (fn) {
|
if (fn) {
|
||||||
response = await fn(response, request, opts);
|
response = await fn(response, request, opts);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = {
|
const result = {
|
||||||
request,
|
request,
|
||||||
response,
|
response,
|
||||||
};
|
};
|
||||||
|
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
const parseAs =
|
const parseAs =
|
||||||
(opts.parseAs === "auto" ? getParseAs(response.headers.get("Content-Type")) : opts.parseAs) ?? "json";
|
(opts.parseAs === 'auto'
|
||||||
|
? getParseAs(response.headers.get('Content-Type'))
|
||||||
|
: opts.parseAs) ?? 'json';
|
||||||
|
|
||||||
if (response.status === 204 || response.headers.get("Content-Length") === "0") {
|
if (
|
||||||
let emptyData: any;
|
response.status === 204 ||
|
||||||
switch (parseAs) {
|
response.headers.get('Content-Length') === '0'
|
||||||
case "arrayBuffer":
|
) {
|
||||||
case "blob":
|
let emptyData: any;
|
||||||
case "text":
|
switch (parseAs) {
|
||||||
emptyData = await response[parseAs]();
|
case 'arrayBuffer':
|
||||||
break;
|
case 'blob':
|
||||||
case "formData":
|
case 'text':
|
||||||
emptyData = new FormData();
|
emptyData = await response[parseAs]();
|
||||||
break;
|
break;
|
||||||
case "stream":
|
case 'formData':
|
||||||
emptyData = response.body;
|
emptyData = new FormData();
|
||||||
break;
|
break;
|
||||||
case "json":
|
case 'stream':
|
||||||
default:
|
emptyData = response.body;
|
||||||
emptyData = {};
|
break;
|
||||||
break;
|
case 'json':
|
||||||
}
|
default:
|
||||||
return opts.responseStyle === "data"
|
emptyData = {};
|
||||||
? emptyData
|
break;
|
||||||
: {
|
}
|
||||||
data: emptyData,
|
return opts.responseStyle === 'data'
|
||||||
...result,
|
? emptyData
|
||||||
};
|
: {
|
||||||
}
|
data: emptyData,
|
||||||
|
...result,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
let data: any;
|
let data: any;
|
||||||
switch (parseAs) {
|
switch (parseAs) {
|
||||||
case "arrayBuffer":
|
case 'arrayBuffer':
|
||||||
case "blob":
|
case 'blob':
|
||||||
case "formData":
|
case 'formData':
|
||||||
case "json":
|
case 'json':
|
||||||
case "text":
|
case 'text':
|
||||||
data = await response[parseAs]();
|
data = await response[parseAs]();
|
||||||
break;
|
break;
|
||||||
case "stream":
|
case 'stream':
|
||||||
return opts.responseStyle === "data"
|
return opts.responseStyle === 'data'
|
||||||
? response.body
|
? response.body
|
||||||
: {
|
: {
|
||||||
data: response.body,
|
data: response.body,
|
||||||
...result,
|
...result,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (parseAs === "json") {
|
if (parseAs === 'json') {
|
||||||
if (opts.responseValidator) {
|
if (opts.responseValidator) {
|
||||||
await opts.responseValidator(data);
|
await opts.responseValidator(data);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (opts.responseTransformer) {
|
if (opts.responseTransformer) {
|
||||||
data = await opts.responseTransformer(data);
|
data = await opts.responseTransformer(data);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return opts.responseStyle === "data"
|
return opts.responseStyle === 'data'
|
||||||
? data
|
? data
|
||||||
: {
|
: {
|
||||||
data,
|
data,
|
||||||
...result,
|
...result,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const textError = await response.text();
|
const textError = await response.text();
|
||||||
let jsonError: unknown;
|
let jsonError: unknown;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
jsonError = JSON.parse(textError);
|
jsonError = JSON.parse(textError);
|
||||||
} catch {
|
} catch {
|
||||||
// noop
|
// noop
|
||||||
}
|
}
|
||||||
|
|
||||||
const error = jsonError ?? textError;
|
const error = jsonError ?? textError;
|
||||||
let finalError = error;
|
let finalError = error;
|
||||||
|
|
||||||
for (const fn of interceptors.error.fns) {
|
for (const fn of interceptors.error.fns) {
|
||||||
if (fn) {
|
if (fn) {
|
||||||
finalError = (await fn(error, response, request, opts)) as string;
|
finalError = (await fn(error, response, request, opts)) as string;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
finalError = finalError || ({} as string);
|
finalError = finalError || ({} as string);
|
||||||
|
|
||||||
if (opts.throwOnError) {
|
if (opts.throwOnError) {
|
||||||
throw finalError;
|
throw finalError;
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: we probably want to return error and improve types
|
// TODO: we probably want to return error and improve types
|
||||||
return opts.responseStyle === "data"
|
return opts.responseStyle === 'data'
|
||||||
? undefined
|
? undefined
|
||||||
: {
|
: {
|
||||||
error: finalError,
|
error: finalError,
|
||||||
...result,
|
...result,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
const makeMethodFn = (method: Uppercase<HttpMethod>) => (options: RequestOptions) => request({ ...options, method });
|
const makeMethodFn =
|
||||||
|
(method: Uppercase<HttpMethod>) => (options: RequestOptions) =>
|
||||||
|
request({ ...options, method });
|
||||||
|
|
||||||
const makeSseFn = (method: Uppercase<HttpMethod>) => async (options: RequestOptions) => {
|
const makeSseFn =
|
||||||
const { opts, url } = await beforeRequest(options);
|
(method: Uppercase<HttpMethod>) => async (options: RequestOptions) => {
|
||||||
return createSseClient({
|
const { opts, url } = await beforeRequest(options);
|
||||||
...opts,
|
return createSseClient({
|
||||||
body: opts.body as BodyInit | null | undefined,
|
...opts,
|
||||||
headers: opts.headers as unknown as Record<string, string>,
|
body: opts.body as BodyInit | null | undefined,
|
||||||
method,
|
headers: opts.headers as unknown as Record<string, string>,
|
||||||
onRequest: async (url, init) => {
|
method,
|
||||||
let request = new Request(url, init);
|
onRequest: async (url, init) => {
|
||||||
for (const fn of interceptors.request.fns) {
|
let request = new Request(url, init);
|
||||||
if (fn) {
|
for (const fn of interceptors.request.fns) {
|
||||||
request = await fn(request, opts);
|
if (fn) {
|
||||||
}
|
request = await fn(request, opts);
|
||||||
}
|
}
|
||||||
return request;
|
}
|
||||||
},
|
return request;
|
||||||
url,
|
},
|
||||||
});
|
url,
|
||||||
};
|
});
|
||||||
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
buildUrl,
|
buildUrl,
|
||||||
connect: makeMethodFn("CONNECT"),
|
connect: makeMethodFn('CONNECT'),
|
||||||
delete: makeMethodFn("DELETE"),
|
delete: makeMethodFn('DELETE'),
|
||||||
get: makeMethodFn("GET"),
|
get: makeMethodFn('GET'),
|
||||||
getConfig,
|
getConfig,
|
||||||
head: makeMethodFn("HEAD"),
|
head: makeMethodFn('HEAD'),
|
||||||
interceptors,
|
interceptors,
|
||||||
options: makeMethodFn("OPTIONS"),
|
options: makeMethodFn('OPTIONS'),
|
||||||
patch: makeMethodFn("PATCH"),
|
patch: makeMethodFn('PATCH'),
|
||||||
post: makeMethodFn("POST"),
|
post: makeMethodFn('POST'),
|
||||||
put: makeMethodFn("PUT"),
|
put: makeMethodFn('PUT'),
|
||||||
request,
|
request,
|
||||||
setConfig,
|
setConfig,
|
||||||
sse: {
|
sse: {
|
||||||
connect: makeSseFn("CONNECT"),
|
connect: makeSseFn('CONNECT'),
|
||||||
delete: makeSseFn("DELETE"),
|
delete: makeSseFn('DELETE'),
|
||||||
get: makeSseFn("GET"),
|
get: makeSseFn('GET'),
|
||||||
head: makeSseFn("HEAD"),
|
head: makeSseFn('HEAD'),
|
||||||
options: makeSseFn("OPTIONS"),
|
options: makeSseFn('OPTIONS'),
|
||||||
patch: makeSseFn("PATCH"),
|
patch: makeSseFn('PATCH'),
|
||||||
post: makeSseFn("POST"),
|
post: makeSseFn('POST'),
|
||||||
put: makeSseFn("PUT"),
|
put: makeSseFn('PUT'),
|
||||||
trace: makeSseFn("TRACE"),
|
trace: makeSseFn('TRACE'),
|
||||||
},
|
},
|
||||||
trace: makeMethodFn("TRACE"),
|
trace: makeMethodFn('TRACE'),
|
||||||
} as Client;
|
} as Client;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,25 +1,25 @@
|
|||||||
// This file is auto-generated by @hey-api/openapi-ts
|
// This file is auto-generated by @hey-api/openapi-ts
|
||||||
|
|
||||||
export type { Auth } from "../core/auth.gen";
|
export type { Auth } from '../core/auth.gen';
|
||||||
export type { QuerySerializerOptions } from "../core/bodySerializer.gen";
|
export type { QuerySerializerOptions } from '../core/bodySerializer.gen';
|
||||||
export {
|
export {
|
||||||
formDataBodySerializer,
|
formDataBodySerializer,
|
||||||
jsonBodySerializer,
|
jsonBodySerializer,
|
||||||
urlSearchParamsBodySerializer,
|
urlSearchParamsBodySerializer,
|
||||||
} from "../core/bodySerializer.gen";
|
} from '../core/bodySerializer.gen';
|
||||||
export { buildClientParams } from "../core/params.gen";
|
export { buildClientParams } from '../core/params.gen';
|
||||||
export { serializeQueryKeyValue } from "../core/queryKeySerializer.gen";
|
export { serializeQueryKeyValue } from '../core/queryKeySerializer.gen';
|
||||||
export { createClient } from "./client.gen";
|
export { createClient } from './client.gen';
|
||||||
export type {
|
export type {
|
||||||
Client,
|
Client,
|
||||||
ClientOptions,
|
ClientOptions,
|
||||||
Config,
|
Config,
|
||||||
CreateClientConfig,
|
CreateClientConfig,
|
||||||
Options,
|
Options,
|
||||||
RequestOptions,
|
RequestOptions,
|
||||||
RequestResult,
|
RequestResult,
|
||||||
ResolvedRequestOptions,
|
ResolvedRequestOptions,
|
||||||
ResponseStyle,
|
ResponseStyle,
|
||||||
TDataShape,
|
TDataShape,
|
||||||
} from "./types.gen";
|
} from './types.gen';
|
||||||
export { createConfig, mergeHeaders } from "./utils.gen";
|
export { createConfig, mergeHeaders } from './utils.gen';
|
||||||
|
|||||||
@@ -1,174 +1,210 @@
|
|||||||
// This file is auto-generated by @hey-api/openapi-ts
|
// This file is auto-generated by @hey-api/openapi-ts
|
||||||
|
|
||||||
import type { Auth } from "../core/auth.gen";
|
import type { Auth } from '../core/auth.gen';
|
||||||
import type { ServerSentEventsOptions, ServerSentEventsResult } from "../core/serverSentEvents.gen";
|
import type {
|
||||||
import type { Client as CoreClient, Config as CoreConfig } from "../core/types.gen";
|
ServerSentEventsOptions,
|
||||||
import type { Middleware } from "./utils.gen";
|
ServerSentEventsResult,
|
||||||
|
} from '../core/serverSentEvents.gen';
|
||||||
|
import type {
|
||||||
|
Client as CoreClient,
|
||||||
|
Config as CoreConfig,
|
||||||
|
} from '../core/types.gen';
|
||||||
|
import type { Middleware } from './utils.gen';
|
||||||
|
|
||||||
export type ResponseStyle = "data" | "fields";
|
export type ResponseStyle = 'data' | 'fields';
|
||||||
|
|
||||||
export interface Config<T extends ClientOptions = ClientOptions>
|
export interface Config<T extends ClientOptions = ClientOptions>
|
||||||
extends Omit<RequestInit, "body" | "headers" | "method">,
|
extends Omit<RequestInit, 'body' | 'headers' | 'method'>,
|
||||||
CoreConfig {
|
CoreConfig {
|
||||||
/**
|
/**
|
||||||
* Base URL for all requests made by this client.
|
* Base URL for all requests made by this client.
|
||||||
*/
|
*/
|
||||||
baseUrl?: T["baseUrl"];
|
baseUrl?: T['baseUrl'];
|
||||||
/**
|
/**
|
||||||
* Fetch API implementation. You can use this option to provide a custom
|
* Fetch API implementation. You can use this option to provide a custom
|
||||||
* fetch instance.
|
* fetch instance.
|
||||||
*
|
*
|
||||||
* @default globalThis.fetch
|
* @default globalThis.fetch
|
||||||
*/
|
*/
|
||||||
fetch?: typeof fetch;
|
fetch?: typeof fetch;
|
||||||
/**
|
/**
|
||||||
* Please don't use the Fetch client for Next.js applications. The `next`
|
* Please don't use the Fetch client for Next.js applications. The `next`
|
||||||
* options won't have any effect.
|
* options won't have any effect.
|
||||||
*
|
*
|
||||||
* Install {@link https://www.npmjs.com/package/@hey-api/client-next `@hey-api/client-next`} instead.
|
* Install {@link https://www.npmjs.com/package/@hey-api/client-next `@hey-api/client-next`} instead.
|
||||||
*/
|
*/
|
||||||
next?: never;
|
next?: never;
|
||||||
/**
|
/**
|
||||||
* Return the response data parsed in a specified format. By default, `auto`
|
* Return the response data parsed in a specified format. By default, `auto`
|
||||||
* will infer the appropriate method from the `Content-Type` response header.
|
* will infer the appropriate method from the `Content-Type` response header.
|
||||||
* You can override this behavior with any of the {@link Body} methods.
|
* You can override this behavior with any of the {@link Body} methods.
|
||||||
* Select `stream` if you don't want to parse response data at all.
|
* Select `stream` if you don't want to parse response data at all.
|
||||||
*
|
*
|
||||||
* @default 'auto'
|
* @default 'auto'
|
||||||
*/
|
*/
|
||||||
parseAs?: "arrayBuffer" | "auto" | "blob" | "formData" | "json" | "stream" | "text";
|
parseAs?:
|
||||||
/**
|
| 'arrayBuffer'
|
||||||
* Should we return only data or multiple fields (data, error, response, etc.)?
|
| 'auto'
|
||||||
*
|
| 'blob'
|
||||||
* @default 'fields'
|
| 'formData'
|
||||||
*/
|
| 'json'
|
||||||
responseStyle?: ResponseStyle;
|
| 'stream'
|
||||||
/**
|
| 'text';
|
||||||
* Throw an error instead of returning it in the response?
|
/**
|
||||||
*
|
* Should we return only data or multiple fields (data, error, response, etc.)?
|
||||||
* @default false
|
*
|
||||||
*/
|
* @default 'fields'
|
||||||
throwOnError?: T["throwOnError"];
|
*/
|
||||||
|
responseStyle?: ResponseStyle;
|
||||||
|
/**
|
||||||
|
* Throw an error instead of returning it in the response?
|
||||||
|
*
|
||||||
|
* @default false
|
||||||
|
*/
|
||||||
|
throwOnError?: T['throwOnError'];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RequestOptions<
|
export interface RequestOptions<
|
||||||
TData = unknown,
|
TData = unknown,
|
||||||
TResponseStyle extends ResponseStyle = "fields",
|
TResponseStyle extends ResponseStyle = 'fields',
|
||||||
ThrowOnError extends boolean = boolean,
|
ThrowOnError extends boolean = boolean,
|
||||||
Url extends string = string,
|
Url extends string = string,
|
||||||
> extends Config<{
|
> extends Config<{
|
||||||
responseStyle: TResponseStyle;
|
responseStyle: TResponseStyle;
|
||||||
throwOnError: ThrowOnError;
|
throwOnError: ThrowOnError;
|
||||||
}>,
|
}>,
|
||||||
Pick<
|
Pick<
|
||||||
ServerSentEventsOptions<TData>,
|
ServerSentEventsOptions<TData>,
|
||||||
"onSseError" | "onSseEvent" | "sseDefaultRetryDelay" | "sseMaxRetryAttempts" | "sseMaxRetryDelay"
|
| 'onSseError'
|
||||||
> {
|
| 'onSseEvent'
|
||||||
/**
|
| 'sseDefaultRetryDelay'
|
||||||
* Any body that you want to add to your request.
|
| 'sseMaxRetryAttempts'
|
||||||
*
|
| 'sseMaxRetryDelay'
|
||||||
* {@link https://developer.mozilla.org/docs/Web/API/fetch#body}
|
> {
|
||||||
*/
|
/**
|
||||||
body?: unknown;
|
* Any body that you want to add to your request.
|
||||||
path?: Record<string, unknown>;
|
*
|
||||||
query?: Record<string, unknown>;
|
* {@link https://developer.mozilla.org/docs/Web/API/fetch#body}
|
||||||
/**
|
*/
|
||||||
* Security mechanism(s) to use for the request.
|
body?: unknown;
|
||||||
*/
|
path?: Record<string, unknown>;
|
||||||
security?: ReadonlyArray<Auth>;
|
query?: Record<string, unknown>;
|
||||||
url: Url;
|
/**
|
||||||
|
* Security mechanism(s) to use for the request.
|
||||||
|
*/
|
||||||
|
security?: ReadonlyArray<Auth>;
|
||||||
|
url: Url;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ResolvedRequestOptions<
|
export interface ResolvedRequestOptions<
|
||||||
TResponseStyle extends ResponseStyle = "fields",
|
TResponseStyle extends ResponseStyle = 'fields',
|
||||||
ThrowOnError extends boolean = boolean,
|
ThrowOnError extends boolean = boolean,
|
||||||
Url extends string = string,
|
Url extends string = string,
|
||||||
> extends RequestOptions<unknown, TResponseStyle, ThrowOnError, Url> {
|
> extends RequestOptions<unknown, TResponseStyle, ThrowOnError, Url> {
|
||||||
serializedBody?: string;
|
serializedBody?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type RequestResult<
|
export type RequestResult<
|
||||||
TData = unknown,
|
TData = unknown,
|
||||||
TError = unknown,
|
TError = unknown,
|
||||||
ThrowOnError extends boolean = boolean,
|
ThrowOnError extends boolean = boolean,
|
||||||
TResponseStyle extends ResponseStyle = "fields",
|
TResponseStyle extends ResponseStyle = 'fields',
|
||||||
> = ThrowOnError extends true
|
> = ThrowOnError extends true
|
||||||
? Promise<
|
? Promise<
|
||||||
TResponseStyle extends "data"
|
TResponseStyle extends 'data'
|
||||||
? TData extends Record<string, unknown>
|
? TData extends Record<string, unknown>
|
||||||
? TData[keyof TData]
|
? TData[keyof TData]
|
||||||
: TData
|
: TData
|
||||||
: {
|
: {
|
||||||
data: TData extends Record<string, unknown> ? TData[keyof TData] : TData;
|
data: TData extends Record<string, unknown>
|
||||||
request: Request;
|
? TData[keyof TData]
|
||||||
response: Response;
|
: TData;
|
||||||
}
|
request: Request;
|
||||||
>
|
response: Response;
|
||||||
: Promise<
|
}
|
||||||
TResponseStyle extends "data"
|
>
|
||||||
? (TData extends Record<string, unknown> ? TData[keyof TData] : TData) | undefined
|
: Promise<
|
||||||
: (
|
TResponseStyle extends 'data'
|
||||||
| {
|
?
|
||||||
data: TData extends Record<string, unknown> ? TData[keyof TData] : TData;
|
| (TData extends Record<string, unknown>
|
||||||
error: undefined;
|
? TData[keyof TData]
|
||||||
}
|
: TData)
|
||||||
| {
|
| undefined
|
||||||
data: undefined;
|
: (
|
||||||
error: TError extends Record<string, unknown> ? TError[keyof TError] : TError;
|
| {
|
||||||
}
|
data: TData extends Record<string, unknown>
|
||||||
) & {
|
? TData[keyof TData]
|
||||||
request: Request;
|
: TData;
|
||||||
response: Response;
|
error: undefined;
|
||||||
}
|
}
|
||||||
>;
|
| {
|
||||||
|
data: undefined;
|
||||||
|
error: TError extends Record<string, unknown>
|
||||||
|
? TError[keyof TError]
|
||||||
|
: TError;
|
||||||
|
}
|
||||||
|
) & {
|
||||||
|
request: Request;
|
||||||
|
response: Response;
|
||||||
|
}
|
||||||
|
>;
|
||||||
|
|
||||||
export interface ClientOptions {
|
export interface ClientOptions {
|
||||||
baseUrl?: string;
|
baseUrl?: string;
|
||||||
responseStyle?: ResponseStyle;
|
responseStyle?: ResponseStyle;
|
||||||
throwOnError?: boolean;
|
throwOnError?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
type MethodFn = <
|
type MethodFn = <
|
||||||
TData = unknown,
|
TData = unknown,
|
||||||
TError = unknown,
|
TError = unknown,
|
||||||
ThrowOnError extends boolean = false,
|
ThrowOnError extends boolean = false,
|
||||||
TResponseStyle extends ResponseStyle = "fields",
|
TResponseStyle extends ResponseStyle = 'fields',
|
||||||
>(
|
>(
|
||||||
options: Omit<RequestOptions<TData, TResponseStyle, ThrowOnError>, "method">,
|
options: Omit<RequestOptions<TData, TResponseStyle, ThrowOnError>, 'method'>,
|
||||||
) => RequestResult<TData, TError, ThrowOnError, TResponseStyle>;
|
) => RequestResult<TData, TError, ThrowOnError, TResponseStyle>;
|
||||||
|
|
||||||
type SseFn = <
|
type SseFn = <
|
||||||
TData = unknown,
|
TData = unknown,
|
||||||
TError = unknown,
|
TError = unknown,
|
||||||
ThrowOnError extends boolean = false,
|
ThrowOnError extends boolean = false,
|
||||||
TResponseStyle extends ResponseStyle = "fields",
|
TResponseStyle extends ResponseStyle = 'fields',
|
||||||
>(
|
>(
|
||||||
options: Omit<RequestOptions<TData, TResponseStyle, ThrowOnError>, "method">,
|
options: Omit<RequestOptions<TData, TResponseStyle, ThrowOnError>, 'method'>,
|
||||||
) => Promise<ServerSentEventsResult<TData, TError>>;
|
) => Promise<ServerSentEventsResult<TData, TError>>;
|
||||||
|
|
||||||
type RequestFn = <
|
type RequestFn = <
|
||||||
TData = unknown,
|
TData = unknown,
|
||||||
TError = unknown,
|
TError = unknown,
|
||||||
ThrowOnError extends boolean = false,
|
ThrowOnError extends boolean = false,
|
||||||
TResponseStyle extends ResponseStyle = "fields",
|
TResponseStyle extends ResponseStyle = 'fields',
|
||||||
>(
|
>(
|
||||||
options: Omit<RequestOptions<TData, TResponseStyle, ThrowOnError>, "method"> &
|
options: Omit<RequestOptions<TData, TResponseStyle, ThrowOnError>, 'method'> &
|
||||||
Pick<Required<RequestOptions<TData, TResponseStyle, ThrowOnError>>, "method">,
|
Pick<
|
||||||
|
Required<RequestOptions<TData, TResponseStyle, ThrowOnError>>,
|
||||||
|
'method'
|
||||||
|
>,
|
||||||
) => RequestResult<TData, TError, ThrowOnError, TResponseStyle>;
|
) => RequestResult<TData, TError, ThrowOnError, TResponseStyle>;
|
||||||
|
|
||||||
type BuildUrlFn = <
|
type BuildUrlFn = <
|
||||||
TData extends {
|
TData extends {
|
||||||
body?: unknown;
|
body?: unknown;
|
||||||
path?: Record<string, unknown>;
|
path?: Record<string, unknown>;
|
||||||
query?: Record<string, unknown>;
|
query?: Record<string, unknown>;
|
||||||
url: string;
|
url: string;
|
||||||
},
|
},
|
||||||
>(
|
>(
|
||||||
options: TData & Options<TData>,
|
options: TData & Options<TData>,
|
||||||
) => string;
|
) => string;
|
||||||
|
|
||||||
export type Client = CoreClient<RequestFn, Config, MethodFn, BuildUrlFn, SseFn> & {
|
export type Client = CoreClient<
|
||||||
interceptors: Middleware<Request, Response, unknown, ResolvedRequestOptions>;
|
RequestFn,
|
||||||
|
Config,
|
||||||
|
MethodFn,
|
||||||
|
BuildUrlFn,
|
||||||
|
SseFn
|
||||||
|
> & {
|
||||||
|
interceptors: Middleware<Request, Response, unknown, ResolvedRequestOptions>;
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -180,23 +216,26 @@ export type Client = CoreClient<RequestFn, Config, MethodFn, BuildUrlFn, SseFn>
|
|||||||
* to ensure your client always has the correct values.
|
* to ensure your client always has the correct values.
|
||||||
*/
|
*/
|
||||||
export type CreateClientConfig<T extends ClientOptions = ClientOptions> = (
|
export type CreateClientConfig<T extends ClientOptions = ClientOptions> = (
|
||||||
override?: Config<ClientOptions & T>,
|
override?: Config<ClientOptions & T>,
|
||||||
) => Config<Required<ClientOptions> & T>;
|
) => Config<Required<ClientOptions> & T>;
|
||||||
|
|
||||||
export interface TDataShape {
|
export interface TDataShape {
|
||||||
body?: unknown;
|
body?: unknown;
|
||||||
headers?: unknown;
|
headers?: unknown;
|
||||||
path?: unknown;
|
path?: unknown;
|
||||||
query?: unknown;
|
query?: unknown;
|
||||||
url: string;
|
url: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
type OmitKeys<T, K> = Pick<T, Exclude<keyof T, K>>;
|
type OmitKeys<T, K> = Pick<T, Exclude<keyof T, K>>;
|
||||||
|
|
||||||
export type Options<
|
export type Options<
|
||||||
TData extends TDataShape = TDataShape,
|
TData extends TDataShape = TDataShape,
|
||||||
ThrowOnError extends boolean = boolean,
|
ThrowOnError extends boolean = boolean,
|
||||||
TResponse = unknown,
|
TResponse = unknown,
|
||||||
TResponseStyle extends ResponseStyle = "fields",
|
TResponseStyle extends ResponseStyle = 'fields',
|
||||||
> = OmitKeys<RequestOptions<TResponse, TResponseStyle, ThrowOnError>, "body" | "path" | "query" | "url"> &
|
> = OmitKeys<
|
||||||
([TData] extends [never] ? unknown : Omit<TData, "url">);
|
RequestOptions<TResponse, TResponseStyle, ThrowOnError>,
|
||||||
|
'body' | 'path' | 'query' | 'url'
|
||||||
|
> &
|
||||||
|
([TData] extends [never] ? unknown : Omit<TData, 'url'>);
|
||||||
|
|||||||
@@ -1,289 +1,332 @@
|
|||||||
// This file is auto-generated by @hey-api/openapi-ts
|
// This file is auto-generated by @hey-api/openapi-ts
|
||||||
|
|
||||||
import { getAuthToken } from "../core/auth.gen";
|
import { getAuthToken } from '../core/auth.gen';
|
||||||
import type { QuerySerializerOptions } from "../core/bodySerializer.gen";
|
import type { QuerySerializerOptions } from '../core/bodySerializer.gen';
|
||||||
import { jsonBodySerializer } from "../core/bodySerializer.gen";
|
import { jsonBodySerializer } from '../core/bodySerializer.gen';
|
||||||
import { serializeArrayParam, serializeObjectParam, serializePrimitiveParam } from "../core/pathSerializer.gen";
|
import {
|
||||||
import { getUrl } from "../core/utils.gen";
|
serializeArrayParam,
|
||||||
import type { Client, ClientOptions, Config, RequestOptions } from "./types.gen";
|
serializeObjectParam,
|
||||||
|
serializePrimitiveParam,
|
||||||
|
} from '../core/pathSerializer.gen';
|
||||||
|
import { getUrl } from '../core/utils.gen';
|
||||||
|
import type { Client, ClientOptions, Config, RequestOptions } from './types.gen';
|
||||||
|
|
||||||
export const createQuerySerializer = <T = unknown>({ parameters = {}, ...args }: QuerySerializerOptions = {}) => {
|
export const createQuerySerializer = <T = unknown>({
|
||||||
const querySerializer = (queryParams: T) => {
|
parameters = {},
|
||||||
const search: string[] = [];
|
...args
|
||||||
if (queryParams && typeof queryParams === "object") {
|
}: QuerySerializerOptions = {}) => {
|
||||||
for (const name in queryParams) {
|
const querySerializer = (queryParams: T) => {
|
||||||
const value = queryParams[name];
|
const search: string[] = [];
|
||||||
|
if (queryParams && typeof queryParams === 'object') {
|
||||||
|
for (const name in queryParams) {
|
||||||
|
const value = queryParams[name];
|
||||||
|
|
||||||
if (value === undefined || value === null) {
|
if (value === undefined || value === null) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
const options = parameters[name] || args;
|
const options = parameters[name] || args;
|
||||||
|
|
||||||
if (Array.isArray(value)) {
|
if (Array.isArray(value)) {
|
||||||
const serializedArray = serializeArrayParam({
|
const serializedArray = serializeArrayParam({
|
||||||
allowReserved: options.allowReserved,
|
allowReserved: options.allowReserved,
|
||||||
explode: true,
|
explode: true,
|
||||||
name,
|
name,
|
||||||
style: "form",
|
style: 'form',
|
||||||
value,
|
value,
|
||||||
...options.array,
|
...options.array,
|
||||||
});
|
});
|
||||||
if (serializedArray) search.push(serializedArray);
|
if (serializedArray) search.push(serializedArray);
|
||||||
} else if (typeof value === "object") {
|
} else if (typeof value === 'object') {
|
||||||
const serializedObject = serializeObjectParam({
|
const serializedObject = serializeObjectParam({
|
||||||
allowReserved: options.allowReserved,
|
allowReserved: options.allowReserved,
|
||||||
explode: true,
|
explode: true,
|
||||||
name,
|
name,
|
||||||
style: "deepObject",
|
style: 'deepObject',
|
||||||
value: value as Record<string, unknown>,
|
value: value as Record<string, unknown>,
|
||||||
...options.object,
|
...options.object,
|
||||||
});
|
});
|
||||||
if (serializedObject) search.push(serializedObject);
|
if (serializedObject) search.push(serializedObject);
|
||||||
} else {
|
} else {
|
||||||
const serializedPrimitive = serializePrimitiveParam({
|
const serializedPrimitive = serializePrimitiveParam({
|
||||||
allowReserved: options.allowReserved,
|
allowReserved: options.allowReserved,
|
||||||
name,
|
name,
|
||||||
value: value as string,
|
value: value as string,
|
||||||
});
|
});
|
||||||
if (serializedPrimitive) search.push(serializedPrimitive);
|
if (serializedPrimitive) search.push(serializedPrimitive);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return search.join("&");
|
return search.join('&');
|
||||||
};
|
};
|
||||||
return querySerializer;
|
return querySerializer;
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Infers parseAs value from provided Content-Type header.
|
* Infers parseAs value from provided Content-Type header.
|
||||||
*/
|
*/
|
||||||
export const getParseAs = (contentType: string | null): Exclude<Config["parseAs"], "auto"> => {
|
export const getParseAs = (
|
||||||
if (!contentType) {
|
contentType: string | null,
|
||||||
// If no Content-Type header is provided, the best we can do is return the raw response body,
|
): Exclude<Config['parseAs'], 'auto'> => {
|
||||||
// which is effectively the same as the 'stream' option.
|
if (!contentType) {
|
||||||
return "stream";
|
// If no Content-Type header is provided, the best we can do is return the raw response body,
|
||||||
}
|
// which is effectively the same as the 'stream' option.
|
||||||
|
return 'stream';
|
||||||
|
}
|
||||||
|
|
||||||
const cleanContent = contentType.split(";")[0]?.trim();
|
const cleanContent = contentType.split(';')[0]?.trim();
|
||||||
|
|
||||||
if (!cleanContent) {
|
if (!cleanContent) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (cleanContent.startsWith("application/json") || cleanContent.endsWith("+json")) {
|
if (
|
||||||
return "json";
|
cleanContent.startsWith('application/json') ||
|
||||||
}
|
cleanContent.endsWith('+json')
|
||||||
|
) {
|
||||||
|
return 'json';
|
||||||
|
}
|
||||||
|
|
||||||
if (cleanContent === "multipart/form-data") {
|
if (cleanContent === 'multipart/form-data') {
|
||||||
return "formData";
|
return 'formData';
|
||||||
}
|
}
|
||||||
|
|
||||||
if (["application/", "audio/", "image/", "video/"].some((type) => cleanContent.startsWith(type))) {
|
if (
|
||||||
return "blob";
|
['application/', 'audio/', 'image/', 'video/'].some((type) =>
|
||||||
}
|
cleanContent.startsWith(type),
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
return 'blob';
|
||||||
|
}
|
||||||
|
|
||||||
if (cleanContent.startsWith("text/")) {
|
if (cleanContent.startsWith('text/')) {
|
||||||
return "text";
|
return 'text';
|
||||||
}
|
}
|
||||||
|
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
|
|
||||||
const checkForExistence = (
|
const checkForExistence = (
|
||||||
options: Pick<RequestOptions, "auth" | "query"> & {
|
options: Pick<RequestOptions, 'auth' | 'query'> & {
|
||||||
headers: Headers;
|
headers: Headers;
|
||||||
},
|
},
|
||||||
name?: string,
|
name?: string,
|
||||||
): boolean => {
|
): boolean => {
|
||||||
if (!name) {
|
if (!name) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
if (options.headers.has(name) || options.query?.[name] || options.headers.get("Cookie")?.includes(`${name}=`)) {
|
if (
|
||||||
return true;
|
options.headers.has(name) ||
|
||||||
}
|
options.query?.[name] ||
|
||||||
return false;
|
options.headers.get('Cookie')?.includes(`${name}=`)
|
||||||
|
) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const setAuthParams = async ({
|
export const setAuthParams = async ({
|
||||||
security,
|
security,
|
||||||
...options
|
...options
|
||||||
}: Pick<Required<RequestOptions>, "security"> &
|
}: Pick<Required<RequestOptions>, 'security'> &
|
||||||
Pick<RequestOptions, "auth" | "query"> & {
|
Pick<RequestOptions, 'auth' | 'query'> & {
|
||||||
headers: Headers;
|
headers: Headers;
|
||||||
}) => {
|
}) => {
|
||||||
for (const auth of security) {
|
for (const auth of security) {
|
||||||
if (checkForExistence(options, auth.name)) {
|
if (checkForExistence(options, auth.name)) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
const token = await getAuthToken(auth, options.auth);
|
const token = await getAuthToken(auth, options.auth);
|
||||||
|
|
||||||
if (!token) {
|
if (!token) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
const name = auth.name ?? "Authorization";
|
const name = auth.name ?? 'Authorization';
|
||||||
|
|
||||||
switch (auth.in) {
|
switch (auth.in) {
|
||||||
case "query":
|
case 'query':
|
||||||
if (!options.query) {
|
if (!options.query) {
|
||||||
options.query = {};
|
options.query = {};
|
||||||
}
|
}
|
||||||
options.query[name] = token;
|
options.query[name] = token;
|
||||||
break;
|
break;
|
||||||
case "cookie":
|
case 'cookie':
|
||||||
options.headers.append("Cookie", `${name}=${token}`);
|
options.headers.append('Cookie', `${name}=${token}`);
|
||||||
break;
|
break;
|
||||||
case "header":
|
case 'header':
|
||||||
default:
|
default:
|
||||||
options.headers.set(name, token);
|
options.headers.set(name, token);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export const buildUrl: Client["buildUrl"] = (options) =>
|
export const buildUrl: Client['buildUrl'] = (options) =>
|
||||||
getUrl({
|
getUrl({
|
||||||
baseUrl: options.baseUrl as string,
|
baseUrl: options.baseUrl as string,
|
||||||
path: options.path,
|
path: options.path,
|
||||||
query: options.query,
|
query: options.query,
|
||||||
querySerializer:
|
querySerializer:
|
||||||
typeof options.querySerializer === "function"
|
typeof options.querySerializer === 'function'
|
||||||
? options.querySerializer
|
? options.querySerializer
|
||||||
: createQuerySerializer(options.querySerializer),
|
: createQuerySerializer(options.querySerializer),
|
||||||
url: options.url,
|
url: options.url,
|
||||||
});
|
});
|
||||||
|
|
||||||
export const mergeConfigs = (a: Config, b: Config): Config => {
|
export const mergeConfigs = (a: Config, b: Config): Config => {
|
||||||
const config = { ...a, ...b };
|
const config = { ...a, ...b };
|
||||||
if (config.baseUrl?.endsWith("/")) {
|
if (config.baseUrl?.endsWith('/')) {
|
||||||
config.baseUrl = config.baseUrl.substring(0, config.baseUrl.length - 1);
|
config.baseUrl = config.baseUrl.substring(0, config.baseUrl.length - 1);
|
||||||
}
|
}
|
||||||
config.headers = mergeHeaders(a.headers, b.headers);
|
config.headers = mergeHeaders(a.headers, b.headers);
|
||||||
return config;
|
return config;
|
||||||
};
|
};
|
||||||
|
|
||||||
const headersEntries = (headers: Headers): Array<[string, string]> => {
|
const headersEntries = (headers: Headers): Array<[string, string]> => {
|
||||||
const entries: Array<[string, string]> = [];
|
const entries: Array<[string, string]> = [];
|
||||||
headers.forEach((value, key) => {
|
headers.forEach((value, key) => {
|
||||||
entries.push([key, value]);
|
entries.push([key, value]);
|
||||||
});
|
});
|
||||||
return entries;
|
return entries;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const mergeHeaders = (...headers: Array<Required<Config>["headers"] | undefined>): Headers => {
|
export const mergeHeaders = (
|
||||||
const mergedHeaders = new Headers();
|
...headers: Array<Required<Config>['headers'] | undefined>
|
||||||
for (const header of headers) {
|
): Headers => {
|
||||||
if (!header) {
|
const mergedHeaders = new Headers();
|
||||||
continue;
|
for (const header of headers) {
|
||||||
}
|
if (!header) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
const iterator = header instanceof Headers ? headersEntries(header) : Object.entries(header);
|
const iterator =
|
||||||
|
header instanceof Headers
|
||||||
|
? headersEntries(header)
|
||||||
|
: Object.entries(header);
|
||||||
|
|
||||||
for (const [key, value] of iterator) {
|
for (const [key, value] of iterator) {
|
||||||
if (value === null) {
|
if (value === null) {
|
||||||
mergedHeaders.delete(key);
|
mergedHeaders.delete(key);
|
||||||
} else if (Array.isArray(value)) {
|
} else if (Array.isArray(value)) {
|
||||||
for (const v of value) {
|
for (const v of value) {
|
||||||
mergedHeaders.append(key, v as string);
|
mergedHeaders.append(key, v as string);
|
||||||
}
|
}
|
||||||
} else if (value !== undefined) {
|
} else if (value !== undefined) {
|
||||||
// assume object headers are meant to be JSON stringified, i.e. their
|
// assume object headers are meant to be JSON stringified, i.e. their
|
||||||
// content value in OpenAPI specification is 'application/json'
|
// content value in OpenAPI specification is 'application/json'
|
||||||
mergedHeaders.set(key, typeof value === "object" ? JSON.stringify(value) : (value as string));
|
mergedHeaders.set(
|
||||||
}
|
key,
|
||||||
}
|
typeof value === 'object' ? JSON.stringify(value) : (value as string),
|
||||||
}
|
);
|
||||||
return mergedHeaders;
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return mergedHeaders;
|
||||||
};
|
};
|
||||||
|
|
||||||
type ErrInterceptor<Err, Res, Req, Options> = (
|
type ErrInterceptor<Err, Res, Req, Options> = (
|
||||||
error: Err,
|
error: Err,
|
||||||
response: Res,
|
response: Res,
|
||||||
request: Req,
|
request: Req,
|
||||||
options: Options,
|
options: Options,
|
||||||
) => Err | Promise<Err>;
|
) => Err | Promise<Err>;
|
||||||
|
|
||||||
type ReqInterceptor<Req, Options> = (request: Req, options: Options) => Req | Promise<Req>;
|
type ReqInterceptor<Req, Options> = (
|
||||||
|
request: Req,
|
||||||
|
options: Options,
|
||||||
|
) => Req | Promise<Req>;
|
||||||
|
|
||||||
type ResInterceptor<Res, Req, Options> = (response: Res, request: Req, options: Options) => Res | Promise<Res>;
|
type ResInterceptor<Res, Req, Options> = (
|
||||||
|
response: Res,
|
||||||
|
request: Req,
|
||||||
|
options: Options,
|
||||||
|
) => Res | Promise<Res>;
|
||||||
|
|
||||||
class Interceptors<Interceptor> {
|
class Interceptors<Interceptor> {
|
||||||
fns: Array<Interceptor | null> = [];
|
fns: Array<Interceptor | null> = [];
|
||||||
|
|
||||||
clear(): void {
|
clear(): void {
|
||||||
this.fns = [];
|
this.fns = [];
|
||||||
}
|
}
|
||||||
|
|
||||||
eject(id: number | Interceptor): void {
|
eject(id: number | Interceptor): void {
|
||||||
const index = this.getInterceptorIndex(id);
|
const index = this.getInterceptorIndex(id);
|
||||||
if (this.fns[index]) {
|
if (this.fns[index]) {
|
||||||
this.fns[index] = null;
|
this.fns[index] = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
exists(id: number | Interceptor): boolean {
|
exists(id: number | Interceptor): boolean {
|
||||||
const index = this.getInterceptorIndex(id);
|
const index = this.getInterceptorIndex(id);
|
||||||
return Boolean(this.fns[index]);
|
return Boolean(this.fns[index]);
|
||||||
}
|
}
|
||||||
|
|
||||||
getInterceptorIndex(id: number | Interceptor): number {
|
getInterceptorIndex(id: number | Interceptor): number {
|
||||||
if (typeof id === "number") {
|
if (typeof id === 'number') {
|
||||||
return this.fns[id] ? id : -1;
|
return this.fns[id] ? id : -1;
|
||||||
}
|
}
|
||||||
return this.fns.indexOf(id);
|
return this.fns.indexOf(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
update(id: number | Interceptor, fn: Interceptor): number | Interceptor | false {
|
update(
|
||||||
const index = this.getInterceptorIndex(id);
|
id: number | Interceptor,
|
||||||
if (this.fns[index]) {
|
fn: Interceptor,
|
||||||
this.fns[index] = fn;
|
): number | Interceptor | false {
|
||||||
return id;
|
const index = this.getInterceptorIndex(id);
|
||||||
}
|
if (this.fns[index]) {
|
||||||
return false;
|
this.fns[index] = fn;
|
||||||
}
|
return id;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
use(fn: Interceptor): number {
|
use(fn: Interceptor): number {
|
||||||
this.fns.push(fn);
|
this.fns.push(fn);
|
||||||
return this.fns.length - 1;
|
return this.fns.length - 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Middleware<Req, Res, Err, Options> {
|
export interface Middleware<Req, Res, Err, Options> {
|
||||||
error: Interceptors<ErrInterceptor<Err, Res, Req, Options>>;
|
error: Interceptors<ErrInterceptor<Err, Res, Req, Options>>;
|
||||||
request: Interceptors<ReqInterceptor<Req, Options>>;
|
request: Interceptors<ReqInterceptor<Req, Options>>;
|
||||||
response: Interceptors<ResInterceptor<Res, Req, Options>>;
|
response: Interceptors<ResInterceptor<Res, Req, Options>>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const createInterceptors = <Req, Res, Err, Options>(): Middleware<Req, Res, Err, Options> => ({
|
export const createInterceptors = <Req, Res, Err, Options>(): Middleware<
|
||||||
error: new Interceptors<ErrInterceptor<Err, Res, Req, Options>>(),
|
Req,
|
||||||
request: new Interceptors<ReqInterceptor<Req, Options>>(),
|
Res,
|
||||||
response: new Interceptors<ResInterceptor<Res, Req, Options>>(),
|
Err,
|
||||||
|
Options
|
||||||
|
> => ({
|
||||||
|
error: new Interceptors<ErrInterceptor<Err, Res, Req, Options>>(),
|
||||||
|
request: new Interceptors<ReqInterceptor<Req, Options>>(),
|
||||||
|
response: new Interceptors<ResInterceptor<Res, Req, Options>>(),
|
||||||
});
|
});
|
||||||
|
|
||||||
const defaultQuerySerializer = createQuerySerializer({
|
const defaultQuerySerializer = createQuerySerializer({
|
||||||
allowReserved: false,
|
allowReserved: false,
|
||||||
array: {
|
array: {
|
||||||
explode: true,
|
explode: true,
|
||||||
style: "form",
|
style: 'form',
|
||||||
},
|
},
|
||||||
object: {
|
object: {
|
||||||
explode: true,
|
explode: true,
|
||||||
style: "deepObject",
|
style: 'deepObject',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const defaultHeaders = {
|
const defaultHeaders = {
|
||||||
"Content-Type": "application/json",
|
'Content-Type': 'application/json',
|
||||||
};
|
};
|
||||||
|
|
||||||
export const createConfig = <T extends ClientOptions = ClientOptions>(
|
export const createConfig = <T extends ClientOptions = ClientOptions>(
|
||||||
override: Config<Omit<ClientOptions, keyof T> & T> = {},
|
override: Config<Omit<ClientOptions, keyof T> & T> = {},
|
||||||
): Config<Omit<ClientOptions, keyof T> & T> => ({
|
): Config<Omit<ClientOptions, keyof T> & T> => ({
|
||||||
...jsonBodySerializer,
|
...jsonBodySerializer,
|
||||||
headers: defaultHeaders,
|
headers: defaultHeaders,
|
||||||
parseAs: "auto",
|
parseAs: 'auto',
|
||||||
querySerializer: defaultQuerySerializer,
|
querySerializer: defaultQuerySerializer,
|
||||||
...override,
|
...override,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -3,39 +3,40 @@
|
|||||||
export type AuthToken = string | undefined;
|
export type AuthToken = string | undefined;
|
||||||
|
|
||||||
export interface Auth {
|
export interface Auth {
|
||||||
/**
|
/**
|
||||||
* Which part of the request do we use to send the auth?
|
* Which part of the request do we use to send the auth?
|
||||||
*
|
*
|
||||||
* @default 'header'
|
* @default 'header'
|
||||||
*/
|
*/
|
||||||
in?: "header" | "query" | "cookie";
|
in?: 'header' | 'query' | 'cookie';
|
||||||
/**
|
/**
|
||||||
* Header or query parameter name.
|
* Header or query parameter name.
|
||||||
*
|
*
|
||||||
* @default 'Authorization'
|
* @default 'Authorization'
|
||||||
*/
|
*/
|
||||||
name?: string;
|
name?: string;
|
||||||
scheme?: "basic" | "bearer";
|
scheme?: 'basic' | 'bearer';
|
||||||
type: "apiKey" | "http";
|
type: 'apiKey' | 'http';
|
||||||
}
|
}
|
||||||
|
|
||||||
export const getAuthToken = async (
|
export const getAuthToken = async (
|
||||||
auth: Auth,
|
auth: Auth,
|
||||||
callback: ((auth: Auth) => Promise<AuthToken> | AuthToken) | AuthToken,
|
callback: ((auth: Auth) => Promise<AuthToken> | AuthToken) | AuthToken,
|
||||||
): Promise<string | undefined> => {
|
): Promise<string | undefined> => {
|
||||||
const token = typeof callback === "function" ? await callback(auth) : callback;
|
const token =
|
||||||
|
typeof callback === 'function' ? await callback(auth) : callback;
|
||||||
|
|
||||||
if (!token) {
|
if (!token) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (auth.scheme === "bearer") {
|
if (auth.scheme === 'bearer') {
|
||||||
return `Bearer ${token}`;
|
return `Bearer ${token}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (auth.scheme === "basic") {
|
if (auth.scheme === 'basic') {
|
||||||
return `Basic ${btoa(token)}`;
|
return `Basic ${btoa(token)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
return token;
|
return token;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,82 +1,100 @@
|
|||||||
// This file is auto-generated by @hey-api/openapi-ts
|
// This file is auto-generated by @hey-api/openapi-ts
|
||||||
|
|
||||||
import type { ArrayStyle, ObjectStyle, SerializerOptions } from "./pathSerializer.gen";
|
import type {
|
||||||
|
ArrayStyle,
|
||||||
|
ObjectStyle,
|
||||||
|
SerializerOptions,
|
||||||
|
} from './pathSerializer.gen';
|
||||||
|
|
||||||
export type QuerySerializer = (query: Record<string, unknown>) => string;
|
export type QuerySerializer = (query: Record<string, unknown>) => string;
|
||||||
|
|
||||||
export type BodySerializer = (body: any) => any;
|
export type BodySerializer = (body: any) => any;
|
||||||
|
|
||||||
type QuerySerializerOptionsObject = {
|
type QuerySerializerOptionsObject = {
|
||||||
allowReserved?: boolean;
|
allowReserved?: boolean;
|
||||||
array?: Partial<SerializerOptions<ArrayStyle>>;
|
array?: Partial<SerializerOptions<ArrayStyle>>;
|
||||||
object?: Partial<SerializerOptions<ObjectStyle>>;
|
object?: Partial<SerializerOptions<ObjectStyle>>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type QuerySerializerOptions = QuerySerializerOptionsObject & {
|
export type QuerySerializerOptions = QuerySerializerOptionsObject & {
|
||||||
/**
|
/**
|
||||||
* Per-parameter serialization overrides. When provided, these settings
|
* Per-parameter serialization overrides. When provided, these settings
|
||||||
* override the global array/object settings for specific parameter names.
|
* override the global array/object settings for specific parameter names.
|
||||||
*/
|
*/
|
||||||
parameters?: Record<string, QuerySerializerOptionsObject>;
|
parameters?: Record<string, QuerySerializerOptionsObject>;
|
||||||
};
|
};
|
||||||
|
|
||||||
const serializeFormDataPair = (data: FormData, key: string, value: unknown): void => {
|
const serializeFormDataPair = (
|
||||||
if (typeof value === "string" || value instanceof Blob) {
|
data: FormData,
|
||||||
data.append(key, value);
|
key: string,
|
||||||
} else if (value instanceof Date) {
|
value: unknown,
|
||||||
data.append(key, value.toISOString());
|
): void => {
|
||||||
} else {
|
if (typeof value === 'string' || value instanceof Blob) {
|
||||||
data.append(key, JSON.stringify(value));
|
data.append(key, value);
|
||||||
}
|
} else if (value instanceof Date) {
|
||||||
|
data.append(key, value.toISOString());
|
||||||
|
} else {
|
||||||
|
data.append(key, JSON.stringify(value));
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const serializeUrlSearchParamsPair = (data: URLSearchParams, key: string, value: unknown): void => {
|
const serializeUrlSearchParamsPair = (
|
||||||
if (typeof value === "string") {
|
data: URLSearchParams,
|
||||||
data.append(key, value);
|
key: string,
|
||||||
} else {
|
value: unknown,
|
||||||
data.append(key, JSON.stringify(value));
|
): void => {
|
||||||
}
|
if (typeof value === 'string') {
|
||||||
|
data.append(key, value);
|
||||||
|
} else {
|
||||||
|
data.append(key, JSON.stringify(value));
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export const formDataBodySerializer = {
|
export const formDataBodySerializer = {
|
||||||
bodySerializer: <T extends Record<string, any> | Array<Record<string, any>>>(body: T): FormData => {
|
bodySerializer: <T extends Record<string, any> | Array<Record<string, any>>>(
|
||||||
const data = new FormData();
|
body: T,
|
||||||
|
): FormData => {
|
||||||
|
const data = new FormData();
|
||||||
|
|
||||||
Object.entries(body).forEach(([key, value]) => {
|
Object.entries(body).forEach(([key, value]) => {
|
||||||
if (value === undefined || value === null) {
|
if (value === undefined || value === null) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (Array.isArray(value)) {
|
if (Array.isArray(value)) {
|
||||||
value.forEach((v) => serializeFormDataPair(data, key, v));
|
value.forEach((v) => serializeFormDataPair(data, key, v));
|
||||||
} else {
|
} else {
|
||||||
serializeFormDataPair(data, key, value);
|
serializeFormDataPair(data, key, value);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
return data;
|
return data;
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export const jsonBodySerializer = {
|
export const jsonBodySerializer = {
|
||||||
bodySerializer: <T>(body: T): string =>
|
bodySerializer: <T>(body: T): string =>
|
||||||
JSON.stringify(body, (_key, value) => (typeof value === "bigint" ? value.toString() : value)),
|
JSON.stringify(body, (_key, value) =>
|
||||||
|
typeof value === 'bigint' ? value.toString() : value,
|
||||||
|
),
|
||||||
};
|
};
|
||||||
|
|
||||||
export const urlSearchParamsBodySerializer = {
|
export const urlSearchParamsBodySerializer = {
|
||||||
bodySerializer: <T extends Record<string, any> | Array<Record<string, any>>>(body: T): string => {
|
bodySerializer: <T extends Record<string, any> | Array<Record<string, any>>>(
|
||||||
const data = new URLSearchParams();
|
body: T,
|
||||||
|
): string => {
|
||||||
|
const data = new URLSearchParams();
|
||||||
|
|
||||||
Object.entries(body).forEach(([key, value]) => {
|
Object.entries(body).forEach(([key, value]) => {
|
||||||
if (value === undefined || value === null) {
|
if (value === undefined || value === null) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (Array.isArray(value)) {
|
if (Array.isArray(value)) {
|
||||||
value.forEach((v) => serializeUrlSearchParamsPair(data, key, v));
|
value.forEach((v) => serializeUrlSearchParamsPair(data, key, v));
|
||||||
} else {
|
} else {
|
||||||
serializeUrlSearchParamsPair(data, key, value);
|
serializeUrlSearchParamsPair(data, key, value);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
return data.toString();
|
return data.toString();
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,169 +1,176 @@
|
|||||||
// This file is auto-generated by @hey-api/openapi-ts
|
// This file is auto-generated by @hey-api/openapi-ts
|
||||||
|
|
||||||
type Slot = "body" | "headers" | "path" | "query";
|
type Slot = 'body' | 'headers' | 'path' | 'query';
|
||||||
|
|
||||||
export type Field =
|
export type Field =
|
||||||
| {
|
| {
|
||||||
in: Exclude<Slot, "body">;
|
in: Exclude<Slot, 'body'>;
|
||||||
/**
|
/**
|
||||||
* Field name. This is the name we want the user to see and use.
|
* Field name. This is the name we want the user to see and use.
|
||||||
*/
|
*/
|
||||||
key: string;
|
key: string;
|
||||||
/**
|
/**
|
||||||
* Field mapped name. This is the name we want to use in the request.
|
* Field mapped name. This is the name we want to use in the request.
|
||||||
* If omitted, we use the same value as `key`.
|
* If omitted, we use the same value as `key`.
|
||||||
*/
|
*/
|
||||||
map?: string;
|
map?: string;
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
in: Extract<Slot, "body">;
|
in: Extract<Slot, 'body'>;
|
||||||
/**
|
/**
|
||||||
* Key isn't required for bodies.
|
* Key isn't required for bodies.
|
||||||
*/
|
*/
|
||||||
key?: string;
|
key?: string;
|
||||||
map?: string;
|
map?: string;
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
/**
|
/**
|
||||||
* Field name. This is the name we want the user to see and use.
|
* Field name. This is the name we want the user to see and use.
|
||||||
*/
|
*/
|
||||||
key: string;
|
key: string;
|
||||||
/**
|
/**
|
||||||
* Field mapped name. This is the name we want to use in the request.
|
* Field mapped name. This is the name we want to use in the request.
|
||||||
* If `in` is omitted, `map` aliases `key` to the transport layer.
|
* If `in` is omitted, `map` aliases `key` to the transport layer.
|
||||||
*/
|
*/
|
||||||
map: Slot;
|
map: Slot;
|
||||||
};
|
};
|
||||||
|
|
||||||
export interface Fields {
|
export interface Fields {
|
||||||
allowExtra?: Partial<Record<Slot, boolean>>;
|
allowExtra?: Partial<Record<Slot, boolean>>;
|
||||||
args?: ReadonlyArray<Field>;
|
args?: ReadonlyArray<Field>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type FieldsConfig = ReadonlyArray<Field | Fields>;
|
export type FieldsConfig = ReadonlyArray<Field | Fields>;
|
||||||
|
|
||||||
const extraPrefixesMap: Record<string, Slot> = {
|
const extraPrefixesMap: Record<string, Slot> = {
|
||||||
$body_: "body",
|
$body_: 'body',
|
||||||
$headers_: "headers",
|
$headers_: 'headers',
|
||||||
$path_: "path",
|
$path_: 'path',
|
||||||
$query_: "query",
|
$query_: 'query',
|
||||||
};
|
};
|
||||||
const extraPrefixes = Object.entries(extraPrefixesMap);
|
const extraPrefixes = Object.entries(extraPrefixesMap);
|
||||||
|
|
||||||
type KeyMap = Map<
|
type KeyMap = Map<
|
||||||
string,
|
string,
|
||||||
| {
|
| {
|
||||||
in: Slot;
|
in: Slot;
|
||||||
map?: string;
|
map?: string;
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
in?: never;
|
in?: never;
|
||||||
map: Slot;
|
map: Slot;
|
||||||
}
|
}
|
||||||
>;
|
>;
|
||||||
|
|
||||||
const buildKeyMap = (fields: FieldsConfig, map?: KeyMap): KeyMap => {
|
const buildKeyMap = (fields: FieldsConfig, map?: KeyMap): KeyMap => {
|
||||||
if (!map) {
|
if (!map) {
|
||||||
map = new Map();
|
map = new Map();
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const config of fields) {
|
for (const config of fields) {
|
||||||
if ("in" in config) {
|
if ('in' in config) {
|
||||||
if (config.key) {
|
if (config.key) {
|
||||||
map.set(config.key, {
|
map.set(config.key, {
|
||||||
in: config.in,
|
in: config.in,
|
||||||
map: config.map,
|
map: config.map,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
} else if ("key" in config) {
|
} else if ('key' in config) {
|
||||||
map.set(config.key, {
|
map.set(config.key, {
|
||||||
map: config.map,
|
map: config.map,
|
||||||
});
|
});
|
||||||
} else if (config.args) {
|
} else if (config.args) {
|
||||||
buildKeyMap(config.args, map);
|
buildKeyMap(config.args, map);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return map;
|
return map;
|
||||||
};
|
};
|
||||||
|
|
||||||
interface Params {
|
interface Params {
|
||||||
body: unknown;
|
body: unknown;
|
||||||
headers: Record<string, unknown>;
|
headers: Record<string, unknown>;
|
||||||
path: Record<string, unknown>;
|
path: Record<string, unknown>;
|
||||||
query: Record<string, unknown>;
|
query: Record<string, unknown>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const stripEmptySlots = (params: Params) => {
|
const stripEmptySlots = (params: Params) => {
|
||||||
for (const [slot, value] of Object.entries(params)) {
|
for (const [slot, value] of Object.entries(params)) {
|
||||||
if (value && typeof value === "object" && !Object.keys(value).length) {
|
if (value && typeof value === 'object' && !Object.keys(value).length) {
|
||||||
delete params[slot as Slot];
|
delete params[slot as Slot];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export const buildClientParams = (args: ReadonlyArray<unknown>, fields: FieldsConfig) => {
|
export const buildClientParams = (
|
||||||
const params: Params = {
|
args: ReadonlyArray<unknown>,
|
||||||
body: {},
|
fields: FieldsConfig,
|
||||||
headers: {},
|
) => {
|
||||||
path: {},
|
const params: Params = {
|
||||||
query: {},
|
body: {},
|
||||||
};
|
headers: {},
|
||||||
|
path: {},
|
||||||
|
query: {},
|
||||||
|
};
|
||||||
|
|
||||||
const map = buildKeyMap(fields);
|
const map = buildKeyMap(fields);
|
||||||
|
|
||||||
let config: FieldsConfig[number] | undefined;
|
let config: FieldsConfig[number] | undefined;
|
||||||
|
|
||||||
for (const [index, arg] of args.entries()) {
|
for (const [index, arg] of args.entries()) {
|
||||||
if (fields[index]) {
|
if (fields[index]) {
|
||||||
config = fields[index];
|
config = fields[index];
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!config) {
|
if (!config) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
if ("in" in config) {
|
if ('in' in config) {
|
||||||
if (config.key) {
|
if (config.key) {
|
||||||
const field = map.get(config.key)!;
|
const field = map.get(config.key)!;
|
||||||
const name = field.map || config.key;
|
const name = field.map || config.key;
|
||||||
if (field.in) {
|
if (field.in) {
|
||||||
(params[field.in] as Record<string, unknown>)[name] = arg;
|
(params[field.in] as Record<string, unknown>)[name] = arg;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
params.body = arg;
|
params.body = arg;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
for (const [key, value] of Object.entries(arg ?? {})) {
|
for (const [key, value] of Object.entries(arg ?? {})) {
|
||||||
const field = map.get(key);
|
const field = map.get(key);
|
||||||
|
|
||||||
if (field) {
|
if (field) {
|
||||||
if (field.in) {
|
if (field.in) {
|
||||||
const name = field.map || key;
|
const name = field.map || key;
|
||||||
(params[field.in] as Record<string, unknown>)[name] = value;
|
(params[field.in] as Record<string, unknown>)[name] = value;
|
||||||
} else {
|
} else {
|
||||||
params[field.map] = value;
|
params[field.map] = value;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
const extra = extraPrefixes.find(([prefix]) => key.startsWith(prefix));
|
const extra = extraPrefixes.find(([prefix]) =>
|
||||||
|
key.startsWith(prefix),
|
||||||
|
);
|
||||||
|
|
||||||
if (extra) {
|
if (extra) {
|
||||||
const [prefix, slot] = extra;
|
const [prefix, slot] = extra;
|
||||||
(params[slot] as Record<string, unknown>)[key.slice(prefix.length)] = value;
|
(params[slot] as Record<string, unknown>)[
|
||||||
} else if ("allowExtra" in config && config.allowExtra) {
|
key.slice(prefix.length)
|
||||||
for (const [slot, allowed] of Object.entries(config.allowExtra)) {
|
] = value;
|
||||||
if (allowed) {
|
} else if ('allowExtra' in config && config.allowExtra) {
|
||||||
(params[slot as Slot] as Record<string, unknown>)[key] = value;
|
for (const [slot, allowed] of Object.entries(config.allowExtra)) {
|
||||||
break;
|
if (allowed) {
|
||||||
}
|
(params[slot as Slot] as Record<string, unknown>)[key] = value;
|
||||||
}
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
stripEmptySlots(params);
|
stripEmptySlots(params);
|
||||||
|
|
||||||
return params;
|
return params;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,167 +1,181 @@
|
|||||||
// This file is auto-generated by @hey-api/openapi-ts
|
// This file is auto-generated by @hey-api/openapi-ts
|
||||||
|
|
||||||
interface SerializeOptions<T> extends SerializePrimitiveOptions, SerializerOptions<T> {}
|
interface SerializeOptions<T>
|
||||||
|
extends SerializePrimitiveOptions,
|
||||||
|
SerializerOptions<T> {}
|
||||||
|
|
||||||
interface SerializePrimitiveOptions {
|
interface SerializePrimitiveOptions {
|
||||||
allowReserved?: boolean;
|
allowReserved?: boolean;
|
||||||
name: string;
|
name: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SerializerOptions<T> {
|
export interface SerializerOptions<T> {
|
||||||
/**
|
/**
|
||||||
* @default true
|
* @default true
|
||||||
*/
|
*/
|
||||||
explode: boolean;
|
explode: boolean;
|
||||||
style: T;
|
style: T;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ArrayStyle = "form" | "spaceDelimited" | "pipeDelimited";
|
export type ArrayStyle = 'form' | 'spaceDelimited' | 'pipeDelimited';
|
||||||
export type ArraySeparatorStyle = ArrayStyle | MatrixStyle;
|
export type ArraySeparatorStyle = ArrayStyle | MatrixStyle;
|
||||||
type MatrixStyle = "label" | "matrix" | "simple";
|
type MatrixStyle = 'label' | 'matrix' | 'simple';
|
||||||
export type ObjectStyle = "form" | "deepObject";
|
export type ObjectStyle = 'form' | 'deepObject';
|
||||||
type ObjectSeparatorStyle = ObjectStyle | MatrixStyle;
|
type ObjectSeparatorStyle = ObjectStyle | MatrixStyle;
|
||||||
|
|
||||||
interface SerializePrimitiveParam extends SerializePrimitiveOptions {
|
interface SerializePrimitiveParam extends SerializePrimitiveOptions {
|
||||||
value: string;
|
value: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const separatorArrayExplode = (style: ArraySeparatorStyle) => {
|
export const separatorArrayExplode = (style: ArraySeparatorStyle) => {
|
||||||
switch (style) {
|
switch (style) {
|
||||||
case "label":
|
case 'label':
|
||||||
return ".";
|
return '.';
|
||||||
case "matrix":
|
case 'matrix':
|
||||||
return ";";
|
return ';';
|
||||||
case "simple":
|
case 'simple':
|
||||||
return ",";
|
return ',';
|
||||||
default:
|
default:
|
||||||
return "&";
|
return '&';
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export const separatorArrayNoExplode = (style: ArraySeparatorStyle) => {
|
export const separatorArrayNoExplode = (style: ArraySeparatorStyle) => {
|
||||||
switch (style) {
|
switch (style) {
|
||||||
case "form":
|
case 'form':
|
||||||
return ",";
|
return ',';
|
||||||
case "pipeDelimited":
|
case 'pipeDelimited':
|
||||||
return "|";
|
return '|';
|
||||||
case "spaceDelimited":
|
case 'spaceDelimited':
|
||||||
return "%20";
|
return '%20';
|
||||||
default:
|
default:
|
||||||
return ",";
|
return ',';
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export const separatorObjectExplode = (style: ObjectSeparatorStyle) => {
|
export const separatorObjectExplode = (style: ObjectSeparatorStyle) => {
|
||||||
switch (style) {
|
switch (style) {
|
||||||
case "label":
|
case 'label':
|
||||||
return ".";
|
return '.';
|
||||||
case "matrix":
|
case 'matrix':
|
||||||
return ";";
|
return ';';
|
||||||
case "simple":
|
case 'simple':
|
||||||
return ",";
|
return ',';
|
||||||
default:
|
default:
|
||||||
return "&";
|
return '&';
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export const serializeArrayParam = ({
|
export const serializeArrayParam = ({
|
||||||
allowReserved,
|
allowReserved,
|
||||||
explode,
|
explode,
|
||||||
name,
|
name,
|
||||||
style,
|
style,
|
||||||
value,
|
value,
|
||||||
}: SerializeOptions<ArraySeparatorStyle> & {
|
}: SerializeOptions<ArraySeparatorStyle> & {
|
||||||
value: unknown[];
|
value: unknown[];
|
||||||
}) => {
|
}) => {
|
||||||
if (!explode) {
|
if (!explode) {
|
||||||
const joinedValues = (allowReserved ? value : value.map((v) => encodeURIComponent(v as string))).join(
|
const joinedValues = (
|
||||||
separatorArrayNoExplode(style),
|
allowReserved ? value : value.map((v) => encodeURIComponent(v as string))
|
||||||
);
|
).join(separatorArrayNoExplode(style));
|
||||||
switch (style) {
|
switch (style) {
|
||||||
case "label":
|
case 'label':
|
||||||
return `.${joinedValues}`;
|
return `.${joinedValues}`;
|
||||||
case "matrix":
|
case 'matrix':
|
||||||
return `;${name}=${joinedValues}`;
|
return `;${name}=${joinedValues}`;
|
||||||
case "simple":
|
case 'simple':
|
||||||
return joinedValues;
|
return joinedValues;
|
||||||
default:
|
default:
|
||||||
return `${name}=${joinedValues}`;
|
return `${name}=${joinedValues}`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const separator = separatorArrayExplode(style);
|
const separator = separatorArrayExplode(style);
|
||||||
const joinedValues = value
|
const joinedValues = value
|
||||||
.map((v) => {
|
.map((v) => {
|
||||||
if (style === "label" || style === "simple") {
|
if (style === 'label' || style === 'simple') {
|
||||||
return allowReserved ? v : encodeURIComponent(v as string);
|
return allowReserved ? v : encodeURIComponent(v as string);
|
||||||
}
|
}
|
||||||
|
|
||||||
return serializePrimitiveParam({
|
return serializePrimitiveParam({
|
||||||
allowReserved,
|
allowReserved,
|
||||||
name,
|
name,
|
||||||
value: v as string,
|
value: v as string,
|
||||||
});
|
});
|
||||||
})
|
})
|
||||||
.join(separator);
|
.join(separator);
|
||||||
return style === "label" || style === "matrix" ? separator + joinedValues : joinedValues;
|
return style === 'label' || style === 'matrix'
|
||||||
|
? separator + joinedValues
|
||||||
|
: joinedValues;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const serializePrimitiveParam = ({ allowReserved, name, value }: SerializePrimitiveParam) => {
|
export const serializePrimitiveParam = ({
|
||||||
if (value === undefined || value === null) {
|
allowReserved,
|
||||||
return "";
|
name,
|
||||||
}
|
value,
|
||||||
|
}: SerializePrimitiveParam) => {
|
||||||
|
if (value === undefined || value === null) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
if (typeof value === "object") {
|
if (typeof value === 'object') {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
"Deeply-nested arrays/objects aren’t supported. Provide your own `querySerializer()` to handle these.",
|
'Deeply-nested arrays/objects aren’t supported. Provide your own `querySerializer()` to handle these.',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return `${name}=${allowReserved ? value : encodeURIComponent(value)}`;
|
return `${name}=${allowReserved ? value : encodeURIComponent(value)}`;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const serializeObjectParam = ({
|
export const serializeObjectParam = ({
|
||||||
allowReserved,
|
allowReserved,
|
||||||
explode,
|
explode,
|
||||||
name,
|
name,
|
||||||
style,
|
style,
|
||||||
value,
|
value,
|
||||||
valueOnly,
|
valueOnly,
|
||||||
}: SerializeOptions<ObjectSeparatorStyle> & {
|
}: SerializeOptions<ObjectSeparatorStyle> & {
|
||||||
value: Record<string, unknown> | Date;
|
value: Record<string, unknown> | Date;
|
||||||
valueOnly?: boolean;
|
valueOnly?: boolean;
|
||||||
}) => {
|
}) => {
|
||||||
if (value instanceof Date) {
|
if (value instanceof Date) {
|
||||||
return valueOnly ? value.toISOString() : `${name}=${value.toISOString()}`;
|
return valueOnly ? value.toISOString() : `${name}=${value.toISOString()}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (style !== "deepObject" && !explode) {
|
if (style !== 'deepObject' && !explode) {
|
||||||
let values: string[] = [];
|
let values: string[] = [];
|
||||||
Object.entries(value).forEach(([key, v]) => {
|
Object.entries(value).forEach(([key, v]) => {
|
||||||
values = [...values, key, allowReserved ? (v as string) : encodeURIComponent(v as string)];
|
values = [
|
||||||
});
|
...values,
|
||||||
const joinedValues = values.join(",");
|
key,
|
||||||
switch (style) {
|
allowReserved ? (v as string) : encodeURIComponent(v as string),
|
||||||
case "form":
|
];
|
||||||
return `${name}=${joinedValues}`;
|
});
|
||||||
case "label":
|
const joinedValues = values.join(',');
|
||||||
return `.${joinedValues}`;
|
switch (style) {
|
||||||
case "matrix":
|
case 'form':
|
||||||
return `;${name}=${joinedValues}`;
|
return `${name}=${joinedValues}`;
|
||||||
default:
|
case 'label':
|
||||||
return joinedValues;
|
return `.${joinedValues}`;
|
||||||
}
|
case 'matrix':
|
||||||
}
|
return `;${name}=${joinedValues}`;
|
||||||
|
default:
|
||||||
|
return joinedValues;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const separator = separatorObjectExplode(style);
|
const separator = separatorObjectExplode(style);
|
||||||
const joinedValues = Object.entries(value)
|
const joinedValues = Object.entries(value)
|
||||||
.map(([key, v]) =>
|
.map(([key, v]) =>
|
||||||
serializePrimitiveParam({
|
serializePrimitiveParam({
|
||||||
allowReserved,
|
allowReserved,
|
||||||
name: style === "deepObject" ? `${name}[${key}]` : key,
|
name: style === 'deepObject' ? `${name}[${key}]` : key,
|
||||||
value: v as string,
|
value: v as string,
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
.join(separator);
|
.join(separator);
|
||||||
return style === "label" || style === "matrix" ? separator + joinedValues : joinedValues;
|
return style === 'label' || style === 'matrix'
|
||||||
|
? separator + joinedValues
|
||||||
|
: joinedValues;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -3,109 +3,134 @@
|
|||||||
/**
|
/**
|
||||||
* JSON-friendly union that mirrors what Pinia Colada can hash.
|
* JSON-friendly union that mirrors what Pinia Colada can hash.
|
||||||
*/
|
*/
|
||||||
export type JsonValue = null | string | number | boolean | JsonValue[] | { [key: string]: JsonValue };
|
export type JsonValue =
|
||||||
|
| null
|
||||||
|
| string
|
||||||
|
| number
|
||||||
|
| boolean
|
||||||
|
| JsonValue[]
|
||||||
|
| { [key: string]: JsonValue };
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Replacer that converts non-JSON values (bigint, Date, etc.) to safe substitutes.
|
* Replacer that converts non-JSON values (bigint, Date, etc.) to safe substitutes.
|
||||||
*/
|
*/
|
||||||
export const queryKeyJsonReplacer = (_key: string, value: unknown) => {
|
export const queryKeyJsonReplacer = (_key: string, value: unknown) => {
|
||||||
if (value === undefined || typeof value === "function" || typeof value === "symbol") {
|
if (
|
||||||
return undefined;
|
value === undefined ||
|
||||||
}
|
typeof value === 'function' ||
|
||||||
if (typeof value === "bigint") {
|
typeof value === 'symbol'
|
||||||
return value.toString();
|
) {
|
||||||
}
|
return undefined;
|
||||||
if (value instanceof Date) {
|
}
|
||||||
return value.toISOString();
|
if (typeof value === 'bigint') {
|
||||||
}
|
return value.toString();
|
||||||
return value;
|
}
|
||||||
|
if (value instanceof Date) {
|
||||||
|
return value.toISOString();
|
||||||
|
}
|
||||||
|
return value;
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Safely stringifies a value and parses it back into a JsonValue.
|
* Safely stringifies a value and parses it back into a JsonValue.
|
||||||
*/
|
*/
|
||||||
export const stringifyToJsonValue = (input: unknown): JsonValue | undefined => {
|
export const stringifyToJsonValue = (input: unknown): JsonValue | undefined => {
|
||||||
try {
|
try {
|
||||||
const json = JSON.stringify(input, queryKeyJsonReplacer);
|
const json = JSON.stringify(input, queryKeyJsonReplacer);
|
||||||
if (json === undefined) {
|
if (json === undefined) {
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
return JSON.parse(json) as JsonValue;
|
return JSON.parse(json) as JsonValue;
|
||||||
} catch {
|
} catch {
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Detects plain objects (including objects with a null prototype).
|
* Detects plain objects (including objects with a null prototype).
|
||||||
*/
|
*/
|
||||||
const isPlainObject = (value: unknown): value is Record<string, unknown> => {
|
const isPlainObject = (value: unknown): value is Record<string, unknown> => {
|
||||||
if (value === null || typeof value !== "object") {
|
if (value === null || typeof value !== 'object') {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
const prototype = Object.getPrototypeOf(value as object);
|
const prototype = Object.getPrototypeOf(value as object);
|
||||||
return prototype === Object.prototype || prototype === null;
|
return prototype === Object.prototype || prototype === null;
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Turns URLSearchParams into a sorted JSON object for deterministic keys.
|
* Turns URLSearchParams into a sorted JSON object for deterministic keys.
|
||||||
*/
|
*/
|
||||||
const serializeSearchParams = (params: URLSearchParams): JsonValue => {
|
const serializeSearchParams = (params: URLSearchParams): JsonValue => {
|
||||||
const entries = Array.from(params.entries()).sort(([a], [b]) => a.localeCompare(b));
|
const entries = Array.from(params.entries()).sort(([a], [b]) =>
|
||||||
const result: Record<string, JsonValue> = {};
|
a.localeCompare(b),
|
||||||
|
);
|
||||||
|
const result: Record<string, JsonValue> = {};
|
||||||
|
|
||||||
for (const [key, value] of entries) {
|
for (const [key, value] of entries) {
|
||||||
const existing = result[key];
|
const existing = result[key];
|
||||||
if (existing === undefined) {
|
if (existing === undefined) {
|
||||||
result[key] = value;
|
result[key] = value;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (Array.isArray(existing)) {
|
if (Array.isArray(existing)) {
|
||||||
(existing as string[]).push(value);
|
(existing as string[]).push(value);
|
||||||
} else {
|
} else {
|
||||||
result[key] = [existing, value];
|
result[key] = [existing, value];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Normalizes any accepted value into a JSON-friendly shape for query keys.
|
* Normalizes any accepted value into a JSON-friendly shape for query keys.
|
||||||
*/
|
*/
|
||||||
export const serializeQueryKeyValue = (value: unknown): JsonValue | undefined => {
|
export const serializeQueryKeyValue = (
|
||||||
if (value === null) {
|
value: unknown,
|
||||||
return null;
|
): JsonValue | undefined => {
|
||||||
}
|
if (value === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") {
|
if (
|
||||||
return value;
|
typeof value === 'string' ||
|
||||||
}
|
typeof value === 'number' ||
|
||||||
|
typeof value === 'boolean'
|
||||||
|
) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
if (value === undefined || typeof value === "function" || typeof value === "symbol") {
|
if (
|
||||||
return undefined;
|
value === undefined ||
|
||||||
}
|
typeof value === 'function' ||
|
||||||
|
typeof value === 'symbol'
|
||||||
|
) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
if (typeof value === "bigint") {
|
if (typeof value === 'bigint') {
|
||||||
return value.toString();
|
return value.toString();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (value instanceof Date) {
|
if (value instanceof Date) {
|
||||||
return value.toISOString();
|
return value.toISOString();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (Array.isArray(value)) {
|
if (Array.isArray(value)) {
|
||||||
return stringifyToJsonValue(value);
|
return stringifyToJsonValue(value);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (typeof URLSearchParams !== "undefined" && value instanceof URLSearchParams) {
|
if (
|
||||||
return serializeSearchParams(value);
|
typeof URLSearchParams !== 'undefined' &&
|
||||||
}
|
value instanceof URLSearchParams
|
||||||
|
) {
|
||||||
|
return serializeSearchParams(value);
|
||||||
|
}
|
||||||
|
|
||||||
if (isPlainObject(value)) {
|
if (isPlainObject(value)) {
|
||||||
return stringifyToJsonValue(value);
|
return stringifyToJsonValue(value);
|
||||||
}
|
}
|
||||||
|
|
||||||
return undefined;
|
return undefined;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,237 +1,264 @@
|
|||||||
// This file is auto-generated by @hey-api/openapi-ts
|
// This file is auto-generated by @hey-api/openapi-ts
|
||||||
|
|
||||||
import type { Config } from "./types.gen";
|
import type { Config } from './types.gen';
|
||||||
|
|
||||||
export type ServerSentEventsOptions<TData = unknown> = Omit<RequestInit, "method"> &
|
export type ServerSentEventsOptions<TData = unknown> = Omit<
|
||||||
Pick<Config, "method" | "responseTransformer" | "responseValidator"> & {
|
RequestInit,
|
||||||
/**
|
'method'
|
||||||
* Fetch API implementation. You can use this option to provide a custom
|
> &
|
||||||
* fetch instance.
|
Pick<Config, 'method' | 'responseTransformer' | 'responseValidator'> & {
|
||||||
*
|
/**
|
||||||
* @default globalThis.fetch
|
* Fetch API implementation. You can use this option to provide a custom
|
||||||
*/
|
* fetch instance.
|
||||||
fetch?: typeof fetch;
|
*
|
||||||
/**
|
* @default globalThis.fetch
|
||||||
* Implementing clients can call request interceptors inside this hook.
|
*/
|
||||||
*/
|
fetch?: typeof fetch;
|
||||||
onRequest?: (url: string, init: RequestInit) => Promise<Request>;
|
/**
|
||||||
/**
|
* Implementing clients can call request interceptors inside this hook.
|
||||||
* Callback invoked when a network or parsing error occurs during streaming.
|
*/
|
||||||
*
|
onRequest?: (url: string, init: RequestInit) => Promise<Request>;
|
||||||
* This option applies only if the endpoint returns a stream of events.
|
/**
|
||||||
*
|
* Callback invoked when a network or parsing error occurs during streaming.
|
||||||
* @param error The error that occurred.
|
*
|
||||||
*/
|
* This option applies only if the endpoint returns a stream of events.
|
||||||
onSseError?: (error: unknown) => void;
|
*
|
||||||
/**
|
* @param error The error that occurred.
|
||||||
* Callback invoked when an event is streamed from the server.
|
*/
|
||||||
*
|
onSseError?: (error: unknown) => void;
|
||||||
* This option applies only if the endpoint returns a stream of events.
|
/**
|
||||||
*
|
* Callback invoked when an event is streamed from the server.
|
||||||
* @param event Event streamed from the server.
|
*
|
||||||
* @returns Nothing (void).
|
* This option applies only if the endpoint returns a stream of events.
|
||||||
*/
|
*
|
||||||
onSseEvent?: (event: StreamEvent<TData>) => void;
|
* @param event Event streamed from the server.
|
||||||
serializedBody?: RequestInit["body"];
|
* @returns Nothing (void).
|
||||||
/**
|
*/
|
||||||
* Default retry delay in milliseconds.
|
onSseEvent?: (event: StreamEvent<TData>) => void;
|
||||||
*
|
serializedBody?: RequestInit['body'];
|
||||||
* This option applies only if the endpoint returns a stream of events.
|
/**
|
||||||
*
|
* Default retry delay in milliseconds.
|
||||||
* @default 3000
|
*
|
||||||
*/
|
* This option applies only if the endpoint returns a stream of events.
|
||||||
sseDefaultRetryDelay?: number;
|
*
|
||||||
/**
|
* @default 3000
|
||||||
* Maximum number of retry attempts before giving up.
|
*/
|
||||||
*/
|
sseDefaultRetryDelay?: number;
|
||||||
sseMaxRetryAttempts?: number;
|
/**
|
||||||
/**
|
* Maximum number of retry attempts before giving up.
|
||||||
* Maximum retry delay in milliseconds.
|
*/
|
||||||
*
|
sseMaxRetryAttempts?: number;
|
||||||
* Applies only when exponential backoff is used.
|
/**
|
||||||
*
|
* Maximum retry delay in milliseconds.
|
||||||
* This option applies only if the endpoint returns a stream of events.
|
*
|
||||||
*
|
* Applies only when exponential backoff is used.
|
||||||
* @default 30000
|
*
|
||||||
*/
|
* This option applies only if the endpoint returns a stream of events.
|
||||||
sseMaxRetryDelay?: number;
|
*
|
||||||
/**
|
* @default 30000
|
||||||
* Optional sleep function for retry backoff.
|
*/
|
||||||
*
|
sseMaxRetryDelay?: number;
|
||||||
* Defaults to using `setTimeout`.
|
/**
|
||||||
*/
|
* Optional sleep function for retry backoff.
|
||||||
sseSleepFn?: (ms: number) => Promise<void>;
|
*
|
||||||
url: string;
|
* Defaults to using `setTimeout`.
|
||||||
};
|
*/
|
||||||
|
sseSleepFn?: (ms: number) => Promise<void>;
|
||||||
|
url: string;
|
||||||
|
};
|
||||||
|
|
||||||
export interface StreamEvent<TData = unknown> {
|
export interface StreamEvent<TData = unknown> {
|
||||||
data: TData;
|
data: TData;
|
||||||
event?: string;
|
event?: string;
|
||||||
id?: string;
|
id?: string;
|
||||||
retry?: number;
|
retry?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ServerSentEventsResult<TData = unknown, TReturn = void, TNext = unknown> = {
|
export type ServerSentEventsResult<
|
||||||
stream: AsyncGenerator<TData extends Record<string, unknown> ? TData[keyof TData] : TData, TReturn, TNext>;
|
TData = unknown,
|
||||||
|
TReturn = void,
|
||||||
|
TNext = unknown,
|
||||||
|
> = {
|
||||||
|
stream: AsyncGenerator<
|
||||||
|
TData extends Record<string, unknown> ? TData[keyof TData] : TData,
|
||||||
|
TReturn,
|
||||||
|
TNext
|
||||||
|
>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const createSseClient = <TData = unknown>({
|
export const createSseClient = <TData = unknown>({
|
||||||
onRequest,
|
onRequest,
|
||||||
onSseError,
|
onSseError,
|
||||||
onSseEvent,
|
onSseEvent,
|
||||||
responseTransformer,
|
responseTransformer,
|
||||||
responseValidator,
|
responseValidator,
|
||||||
sseDefaultRetryDelay,
|
sseDefaultRetryDelay,
|
||||||
sseMaxRetryAttempts,
|
sseMaxRetryAttempts,
|
||||||
sseMaxRetryDelay,
|
sseMaxRetryDelay,
|
||||||
sseSleepFn,
|
sseSleepFn,
|
||||||
url,
|
url,
|
||||||
...options
|
...options
|
||||||
}: ServerSentEventsOptions): ServerSentEventsResult<TData> => {
|
}: ServerSentEventsOptions): ServerSentEventsResult<TData> => {
|
||||||
let lastEventId: string | undefined;
|
let lastEventId: string | undefined;
|
||||||
|
|
||||||
const sleep = sseSleepFn ?? ((ms: number) => new Promise((resolve) => setTimeout(resolve, ms)));
|
const sleep =
|
||||||
|
sseSleepFn ??
|
||||||
|
((ms: number) => new Promise((resolve) => setTimeout(resolve, ms)));
|
||||||
|
|
||||||
const createStream = async function* () {
|
const createStream = async function* () {
|
||||||
let retryDelay: number = sseDefaultRetryDelay ?? 3000;
|
let retryDelay: number = sseDefaultRetryDelay ?? 3000;
|
||||||
let attempt = 0;
|
let attempt = 0;
|
||||||
const signal = options.signal ?? new AbortController().signal;
|
const signal = options.signal ?? new AbortController().signal;
|
||||||
|
|
||||||
while (true) {
|
while (true) {
|
||||||
if (signal.aborted) break;
|
if (signal.aborted) break;
|
||||||
|
|
||||||
attempt++;
|
attempt++;
|
||||||
|
|
||||||
const headers =
|
const headers =
|
||||||
options.headers instanceof Headers
|
options.headers instanceof Headers
|
||||||
? options.headers
|
? options.headers
|
||||||
: new Headers(options.headers as Record<string, string> | undefined);
|
: new Headers(options.headers as Record<string, string> | undefined);
|
||||||
|
|
||||||
if (lastEventId !== undefined) {
|
if (lastEventId !== undefined) {
|
||||||
headers.set("Last-Event-ID", lastEventId);
|
headers.set('Last-Event-ID', lastEventId);
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const requestInit: RequestInit = {
|
const requestInit: RequestInit = {
|
||||||
redirect: "follow",
|
redirect: 'follow',
|
||||||
...options,
|
...options,
|
||||||
body: options.serializedBody,
|
body: options.serializedBody,
|
||||||
headers,
|
headers,
|
||||||
signal,
|
signal,
|
||||||
};
|
};
|
||||||
let request = new Request(url, requestInit);
|
let request = new Request(url, requestInit);
|
||||||
if (onRequest) {
|
if (onRequest) {
|
||||||
request = await onRequest(url, requestInit);
|
request = await onRequest(url, requestInit);
|
||||||
}
|
}
|
||||||
// fetch must be assigned here, otherwise it would throw the error:
|
// fetch must be assigned here, otherwise it would throw the error:
|
||||||
// TypeError: Failed to execute 'fetch' on 'Window': Illegal invocation
|
// TypeError: Failed to execute 'fetch' on 'Window': Illegal invocation
|
||||||
const _fetch = options.fetch ?? globalThis.fetch;
|
const _fetch = options.fetch ?? globalThis.fetch;
|
||||||
const response = await _fetch(request);
|
const response = await _fetch(request);
|
||||||
|
|
||||||
if (!response.ok) throw new Error(`SSE failed: ${response.status} ${response.statusText}`);
|
if (!response.ok)
|
||||||
|
throw new Error(
|
||||||
|
`SSE failed: ${response.status} ${response.statusText}`,
|
||||||
|
);
|
||||||
|
|
||||||
if (!response.body) throw new Error("No body in SSE response");
|
if (!response.body) throw new Error('No body in SSE response');
|
||||||
|
|
||||||
const reader = response.body.pipeThrough(new TextDecoderStream()).getReader();
|
const reader = response.body
|
||||||
|
.pipeThrough(new TextDecoderStream())
|
||||||
|
.getReader();
|
||||||
|
|
||||||
let buffer = "";
|
let buffer = '';
|
||||||
|
|
||||||
const abortHandler = () => {
|
const abortHandler = () => {
|
||||||
try {
|
try {
|
||||||
reader.cancel();
|
reader.cancel();
|
||||||
} catch {
|
} catch {
|
||||||
// noop
|
// noop
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
signal.addEventListener("abort", abortHandler);
|
signal.addEventListener('abort', abortHandler);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
while (true) {
|
while (true) {
|
||||||
const { done, value } = await reader.read();
|
const { done, value } = await reader.read();
|
||||||
if (done) break;
|
if (done) break;
|
||||||
buffer += value;
|
buffer += value;
|
||||||
|
|
||||||
const chunks = buffer.split("\n\n");
|
const chunks = buffer.split('\n\n');
|
||||||
buffer = chunks.pop() ?? "";
|
buffer = chunks.pop() ?? '';
|
||||||
|
|
||||||
for (const chunk of chunks) {
|
for (const chunk of chunks) {
|
||||||
const lines = chunk.split("\n");
|
const lines = chunk.split('\n');
|
||||||
const dataLines: Array<string> = [];
|
const dataLines: Array<string> = [];
|
||||||
let eventName: string | undefined;
|
let eventName: string | undefined;
|
||||||
|
|
||||||
for (const line of lines) {
|
for (const line of lines) {
|
||||||
if (line.startsWith("data:")) {
|
if (line.startsWith('data:')) {
|
||||||
dataLines.push(line.replace(/^data:\s*/, ""));
|
dataLines.push(line.replace(/^data:\s*/, ''));
|
||||||
} else if (line.startsWith("event:")) {
|
} else if (line.startsWith('event:')) {
|
||||||
eventName = line.replace(/^event:\s*/, "");
|
eventName = line.replace(/^event:\s*/, '');
|
||||||
} else if (line.startsWith("id:")) {
|
} else if (line.startsWith('id:')) {
|
||||||
lastEventId = line.replace(/^id:\s*/, "");
|
lastEventId = line.replace(/^id:\s*/, '');
|
||||||
} else if (line.startsWith("retry:")) {
|
} else if (line.startsWith('retry:')) {
|
||||||
const parsed = Number.parseInt(line.replace(/^retry:\s*/, ""), 10);
|
const parsed = Number.parseInt(
|
||||||
if (!Number.isNaN(parsed)) {
|
line.replace(/^retry:\s*/, ''),
|
||||||
retryDelay = parsed;
|
10,
|
||||||
}
|
);
|
||||||
}
|
if (!Number.isNaN(parsed)) {
|
||||||
}
|
retryDelay = parsed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let data: unknown;
|
let data: unknown;
|
||||||
let parsedJson = false;
|
let parsedJson = false;
|
||||||
|
|
||||||
if (dataLines.length) {
|
if (dataLines.length) {
|
||||||
const rawData = dataLines.join("\n");
|
const rawData = dataLines.join('\n');
|
||||||
try {
|
try {
|
||||||
data = JSON.parse(rawData);
|
data = JSON.parse(rawData);
|
||||||
parsedJson = true;
|
parsedJson = true;
|
||||||
} catch {
|
} catch {
|
||||||
data = rawData;
|
data = rawData;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (parsedJson) {
|
if (parsedJson) {
|
||||||
if (responseValidator) {
|
if (responseValidator) {
|
||||||
await responseValidator(data);
|
await responseValidator(data);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (responseTransformer) {
|
if (responseTransformer) {
|
||||||
data = await responseTransformer(data);
|
data = await responseTransformer(data);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
onSseEvent?.({
|
onSseEvent?.({
|
||||||
data,
|
data,
|
||||||
event: eventName,
|
event: eventName,
|
||||||
id: lastEventId,
|
id: lastEventId,
|
||||||
retry: retryDelay,
|
retry: retryDelay,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (dataLines.length) {
|
if (dataLines.length) {
|
||||||
yield data as any;
|
yield data as any;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
signal.removeEventListener("abort", abortHandler);
|
signal.removeEventListener('abort', abortHandler);
|
||||||
reader.releaseLock();
|
reader.releaseLock();
|
||||||
}
|
}
|
||||||
|
|
||||||
break; // exit loop on normal completion
|
break; // exit loop on normal completion
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// connection failed or aborted; retry after delay
|
// connection failed or aborted; retry after delay
|
||||||
onSseError?.(error);
|
onSseError?.(error);
|
||||||
|
|
||||||
if (sseMaxRetryAttempts !== undefined && attempt >= sseMaxRetryAttempts) {
|
if (
|
||||||
break; // stop after firing error
|
sseMaxRetryAttempts !== undefined &&
|
||||||
}
|
attempt >= sseMaxRetryAttempts
|
||||||
|
) {
|
||||||
|
break; // stop after firing error
|
||||||
|
}
|
||||||
|
|
||||||
// exponential backoff: double retry each attempt, cap at 30s
|
// exponential backoff: double retry each attempt, cap at 30s
|
||||||
const backoff = Math.min(retryDelay * 2 ** (attempt - 1), sseMaxRetryDelay ?? 30000);
|
const backoff = Math.min(
|
||||||
await sleep(backoff);
|
retryDelay * 2 ** (attempt - 1),
|
||||||
}
|
sseMaxRetryDelay ?? 30000,
|
||||||
}
|
);
|
||||||
};
|
await sleep(backoff);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const stream = createStream();
|
const stream = createStream();
|
||||||
|
|
||||||
return { stream };
|
return { stream };
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,86 +1,118 @@
|
|||||||
// This file is auto-generated by @hey-api/openapi-ts
|
// This file is auto-generated by @hey-api/openapi-ts
|
||||||
|
|
||||||
import type { Auth, AuthToken } from "./auth.gen";
|
import type { Auth, AuthToken } from './auth.gen';
|
||||||
import type { BodySerializer, QuerySerializer, QuerySerializerOptions } from "./bodySerializer.gen";
|
import type {
|
||||||
|
BodySerializer,
|
||||||
|
QuerySerializer,
|
||||||
|
QuerySerializerOptions,
|
||||||
|
} from './bodySerializer.gen';
|
||||||
|
|
||||||
export type HttpMethod = "connect" | "delete" | "get" | "head" | "options" | "patch" | "post" | "put" | "trace";
|
export type HttpMethod =
|
||||||
|
| 'connect'
|
||||||
|
| 'delete'
|
||||||
|
| 'get'
|
||||||
|
| 'head'
|
||||||
|
| 'options'
|
||||||
|
| 'patch'
|
||||||
|
| 'post'
|
||||||
|
| 'put'
|
||||||
|
| 'trace';
|
||||||
|
|
||||||
export type Client<RequestFn = never, Config = unknown, MethodFn = never, BuildUrlFn = never, SseFn = never> = {
|
export type Client<
|
||||||
/**
|
RequestFn = never,
|
||||||
* Returns the final request URL.
|
Config = unknown,
|
||||||
*/
|
MethodFn = never,
|
||||||
buildUrl: BuildUrlFn;
|
BuildUrlFn = never,
|
||||||
getConfig: () => Config;
|
SseFn = never,
|
||||||
request: RequestFn;
|
> = {
|
||||||
setConfig: (config: Config) => Config;
|
/**
|
||||||
|
* Returns the final request URL.
|
||||||
|
*/
|
||||||
|
buildUrl: BuildUrlFn;
|
||||||
|
getConfig: () => Config;
|
||||||
|
request: RequestFn;
|
||||||
|
setConfig: (config: Config) => Config;
|
||||||
} & {
|
} & {
|
||||||
[K in HttpMethod]: MethodFn;
|
[K in HttpMethod]: MethodFn;
|
||||||
} & ([SseFn] extends [never] ? { sse?: never } : { sse: { [K in HttpMethod]: SseFn } });
|
} & ([SseFn] extends [never]
|
||||||
|
? { sse?: never }
|
||||||
|
: { sse: { [K in HttpMethod]: SseFn } });
|
||||||
|
|
||||||
export interface Config {
|
export interface Config {
|
||||||
/**
|
/**
|
||||||
* Auth token or a function returning auth token. The resolved value will be
|
* Auth token or a function returning auth token. The resolved value will be
|
||||||
* added to the request payload as defined by its `security` array.
|
* added to the request payload as defined by its `security` array.
|
||||||
*/
|
*/
|
||||||
auth?: ((auth: Auth) => Promise<AuthToken> | AuthToken) | AuthToken;
|
auth?: ((auth: Auth) => Promise<AuthToken> | AuthToken) | AuthToken;
|
||||||
/**
|
/**
|
||||||
* A function for serializing request body parameter. By default,
|
* A function for serializing request body parameter. By default,
|
||||||
* {@link JSON.stringify()} will be used.
|
* {@link JSON.stringify()} will be used.
|
||||||
*/
|
*/
|
||||||
bodySerializer?: BodySerializer | null;
|
bodySerializer?: BodySerializer | null;
|
||||||
/**
|
/**
|
||||||
* An object containing any HTTP headers that you want to pre-populate your
|
* An object containing any HTTP headers that you want to pre-populate your
|
||||||
* `Headers` object with.
|
* `Headers` object with.
|
||||||
*
|
*
|
||||||
* {@link https://developer.mozilla.org/docs/Web/API/Headers/Headers#init See more}
|
* {@link https://developer.mozilla.org/docs/Web/API/Headers/Headers#init See more}
|
||||||
*/
|
*/
|
||||||
headers?:
|
headers?:
|
||||||
| RequestInit["headers"]
|
| RequestInit['headers']
|
||||||
| Record<string, string | number | boolean | (string | number | boolean)[] | null | undefined | unknown>;
|
| Record<
|
||||||
/**
|
string,
|
||||||
* The request method.
|
| string
|
||||||
*
|
| number
|
||||||
* {@link https://developer.mozilla.org/docs/Web/API/fetch#method See more}
|
| boolean
|
||||||
*/
|
| (string | number | boolean)[]
|
||||||
method?: Uppercase<HttpMethod>;
|
| null
|
||||||
/**
|
| undefined
|
||||||
* A function for serializing request query parameters. By default, arrays
|
| unknown
|
||||||
* will be exploded in form style, objects will be exploded in deepObject
|
>;
|
||||||
* style, and reserved characters are percent-encoded.
|
/**
|
||||||
*
|
* The request method.
|
||||||
* This method will have no effect if the native `paramsSerializer()` Axios
|
*
|
||||||
* API function is used.
|
* {@link https://developer.mozilla.org/docs/Web/API/fetch#method See more}
|
||||||
*
|
*/
|
||||||
* {@link https://swagger.io/docs/specification/serialization/#query View examples}
|
method?: Uppercase<HttpMethod>;
|
||||||
*/
|
/**
|
||||||
querySerializer?: QuerySerializer | QuerySerializerOptions;
|
* A function for serializing request query parameters. By default, arrays
|
||||||
/**
|
* will be exploded in form style, objects will be exploded in deepObject
|
||||||
* A function validating request data. This is useful if you want to ensure
|
* style, and reserved characters are percent-encoded.
|
||||||
* the request conforms to the desired shape, so it can be safely sent to
|
*
|
||||||
* the server.
|
* This method will have no effect if the native `paramsSerializer()` Axios
|
||||||
*/
|
* API function is used.
|
||||||
requestValidator?: (data: unknown) => Promise<unknown>;
|
*
|
||||||
/**
|
* {@link https://swagger.io/docs/specification/serialization/#query View examples}
|
||||||
* A function transforming response data before it's returned. This is useful
|
*/
|
||||||
* for post-processing data, e.g. converting ISO strings into Date objects.
|
querySerializer?: QuerySerializer | QuerySerializerOptions;
|
||||||
*/
|
/**
|
||||||
responseTransformer?: (data: unknown) => Promise<unknown>;
|
* A function validating request data. This is useful if you want to ensure
|
||||||
/**
|
* the request conforms to the desired shape, so it can be safely sent to
|
||||||
* A function validating response data. This is useful if you want to ensure
|
* the server.
|
||||||
* the response conforms to the desired shape, so it can be safely passed to
|
*/
|
||||||
* the transformers and returned to the user.
|
requestValidator?: (data: unknown) => Promise<unknown>;
|
||||||
*/
|
/**
|
||||||
responseValidator?: (data: unknown) => Promise<unknown>;
|
* A function transforming response data before it's returned. This is useful
|
||||||
|
* for post-processing data, e.g. converting ISO strings into Date objects.
|
||||||
|
*/
|
||||||
|
responseTransformer?: (data: unknown) => Promise<unknown>;
|
||||||
|
/**
|
||||||
|
* A function validating response data. This is useful if you want to ensure
|
||||||
|
* the response conforms to the desired shape, so it can be safely passed to
|
||||||
|
* the transformers and returned to the user.
|
||||||
|
*/
|
||||||
|
responseValidator?: (data: unknown) => Promise<unknown>;
|
||||||
}
|
}
|
||||||
|
|
||||||
type IsExactlyNeverOrNeverUndefined<T> = [T] extends [never]
|
type IsExactlyNeverOrNeverUndefined<T> = [T] extends [never]
|
||||||
? true
|
? true
|
||||||
: [T] extends [never | undefined]
|
: [T] extends [never | undefined]
|
||||||
? [undefined] extends [T]
|
? [undefined] extends [T]
|
||||||
? false
|
? false
|
||||||
: true
|
: true
|
||||||
: false;
|
: false;
|
||||||
|
|
||||||
export type OmitNever<T extends Record<string, unknown>> = {
|
export type OmitNever<T extends Record<string, unknown>> = {
|
||||||
[K in keyof T as IsExactlyNeverOrNeverUndefined<T[K]> extends true ? never : K]: T[K];
|
[K in keyof T as IsExactlyNeverOrNeverUndefined<T[K]> extends true
|
||||||
|
? never
|
||||||
|
: K]: T[K];
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,137 +1,143 @@
|
|||||||
// This file is auto-generated by @hey-api/openapi-ts
|
// This file is auto-generated by @hey-api/openapi-ts
|
||||||
|
|
||||||
import type { BodySerializer, QuerySerializer } from "./bodySerializer.gen";
|
import type { BodySerializer, QuerySerializer } from './bodySerializer.gen';
|
||||||
import {
|
import {
|
||||||
type ArraySeparatorStyle,
|
type ArraySeparatorStyle,
|
||||||
serializeArrayParam,
|
serializeArrayParam,
|
||||||
serializeObjectParam,
|
serializeObjectParam,
|
||||||
serializePrimitiveParam,
|
serializePrimitiveParam,
|
||||||
} from "./pathSerializer.gen";
|
} from './pathSerializer.gen';
|
||||||
|
|
||||||
export interface PathSerializer {
|
export interface PathSerializer {
|
||||||
path: Record<string, unknown>;
|
path: Record<string, unknown>;
|
||||||
url: string;
|
url: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const PATH_PARAM_RE = /\{[^{}]+\}/g;
|
export const PATH_PARAM_RE = /\{[^{}]+\}/g;
|
||||||
|
|
||||||
export const defaultPathSerializer = ({ path, url: _url }: PathSerializer) => {
|
export const defaultPathSerializer = ({ path, url: _url }: PathSerializer) => {
|
||||||
let url = _url;
|
let url = _url;
|
||||||
const matches = _url.match(PATH_PARAM_RE);
|
const matches = _url.match(PATH_PARAM_RE);
|
||||||
if (matches) {
|
if (matches) {
|
||||||
for (const match of matches) {
|
for (const match of matches) {
|
||||||
let explode = false;
|
let explode = false;
|
||||||
let name = match.substring(1, match.length - 1);
|
let name = match.substring(1, match.length - 1);
|
||||||
let style: ArraySeparatorStyle = "simple";
|
let style: ArraySeparatorStyle = 'simple';
|
||||||
|
|
||||||
if (name.endsWith("*")) {
|
if (name.endsWith('*')) {
|
||||||
explode = true;
|
explode = true;
|
||||||
name = name.substring(0, name.length - 1);
|
name = name.substring(0, name.length - 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (name.startsWith(".")) {
|
if (name.startsWith('.')) {
|
||||||
name = name.substring(1);
|
name = name.substring(1);
|
||||||
style = "label";
|
style = 'label';
|
||||||
} else if (name.startsWith(";")) {
|
} else if (name.startsWith(';')) {
|
||||||
name = name.substring(1);
|
name = name.substring(1);
|
||||||
style = "matrix";
|
style = 'matrix';
|
||||||
}
|
}
|
||||||
|
|
||||||
const value = path[name];
|
const value = path[name];
|
||||||
|
|
||||||
if (value === undefined || value === null) {
|
if (value === undefined || value === null) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (Array.isArray(value)) {
|
if (Array.isArray(value)) {
|
||||||
url = url.replace(match, serializeArrayParam({ explode, name, style, value }));
|
url = url.replace(
|
||||||
continue;
|
match,
|
||||||
}
|
serializeArrayParam({ explode, name, style, value }),
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
if (typeof value === "object") {
|
if (typeof value === 'object') {
|
||||||
url = url.replace(
|
url = url.replace(
|
||||||
match,
|
match,
|
||||||
serializeObjectParam({
|
serializeObjectParam({
|
||||||
explode,
|
explode,
|
||||||
name,
|
name,
|
||||||
style,
|
style,
|
||||||
value: value as Record<string, unknown>,
|
value: value as Record<string, unknown>,
|
||||||
valueOnly: true,
|
valueOnly: true,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (style === "matrix") {
|
if (style === 'matrix') {
|
||||||
url = url.replace(
|
url = url.replace(
|
||||||
match,
|
match,
|
||||||
`;${serializePrimitiveParam({
|
`;${serializePrimitiveParam({
|
||||||
name,
|
name,
|
||||||
value: value as string,
|
value: value as string,
|
||||||
})}`,
|
})}`,
|
||||||
);
|
);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
const replaceValue = encodeURIComponent(style === "label" ? `.${value as string}` : (value as string));
|
const replaceValue = encodeURIComponent(
|
||||||
url = url.replace(match, replaceValue);
|
style === 'label' ? `.${value as string}` : (value as string),
|
||||||
}
|
);
|
||||||
}
|
url = url.replace(match, replaceValue);
|
||||||
return url;
|
}
|
||||||
|
}
|
||||||
|
return url;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getUrl = ({
|
export const getUrl = ({
|
||||||
baseUrl,
|
baseUrl,
|
||||||
path,
|
path,
|
||||||
query,
|
query,
|
||||||
querySerializer,
|
querySerializer,
|
||||||
url: _url,
|
url: _url,
|
||||||
}: {
|
}: {
|
||||||
baseUrl?: string;
|
baseUrl?: string;
|
||||||
path?: Record<string, unknown>;
|
path?: Record<string, unknown>;
|
||||||
query?: Record<string, unknown>;
|
query?: Record<string, unknown>;
|
||||||
querySerializer: QuerySerializer;
|
querySerializer: QuerySerializer;
|
||||||
url: string;
|
url: string;
|
||||||
}) => {
|
}) => {
|
||||||
const pathUrl = _url.startsWith("/") ? _url : `/${_url}`;
|
const pathUrl = _url.startsWith('/') ? _url : `/${_url}`;
|
||||||
let url = (baseUrl ?? "") + pathUrl;
|
let url = (baseUrl ?? '') + pathUrl;
|
||||||
if (path) {
|
if (path) {
|
||||||
url = defaultPathSerializer({ path, url });
|
url = defaultPathSerializer({ path, url });
|
||||||
}
|
}
|
||||||
let search = query ? querySerializer(query) : "";
|
let search = query ? querySerializer(query) : '';
|
||||||
if (search.startsWith("?")) {
|
if (search.startsWith('?')) {
|
||||||
search = search.substring(1);
|
search = search.substring(1);
|
||||||
}
|
}
|
||||||
if (search) {
|
if (search) {
|
||||||
url += `?${search}`;
|
url += `?${search}`;
|
||||||
}
|
}
|
||||||
return url;
|
return url;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function getValidRequestBody(options: {
|
export function getValidRequestBody(options: {
|
||||||
body?: unknown;
|
body?: unknown;
|
||||||
bodySerializer?: BodySerializer | null;
|
bodySerializer?: BodySerializer | null;
|
||||||
serializedBody?: unknown;
|
serializedBody?: unknown;
|
||||||
}) {
|
}) {
|
||||||
const hasBody = options.body !== undefined;
|
const hasBody = options.body !== undefined;
|
||||||
const isSerializedBody = hasBody && options.bodySerializer;
|
const isSerializedBody = hasBody && options.bodySerializer;
|
||||||
|
|
||||||
if (isSerializedBody) {
|
if (isSerializedBody) {
|
||||||
if ("serializedBody" in options) {
|
if ('serializedBody' in options) {
|
||||||
const hasSerializedBody = options.serializedBody !== undefined && options.serializedBody !== "";
|
const hasSerializedBody =
|
||||||
|
options.serializedBody !== undefined && options.serializedBody !== '';
|
||||||
|
|
||||||
return hasSerializedBody ? options.serializedBody : null;
|
return hasSerializedBody ? options.serializedBody : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// not all clients implement a serializedBody property (i.e. client-axios)
|
// not all clients implement a serializedBody property (i.e. client-axios)
|
||||||
return options.body !== "" ? options.body : null;
|
return options.body !== '' ? options.body : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// plain/text body
|
// plain/text body
|
||||||
if (hasBody) {
|
if (hasBody) {
|
||||||
return options.body;
|
return options.body;
|
||||||
}
|
}
|
||||||
|
|
||||||
// no body was provided
|
// no body was provided
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
// This file is auto-generated by @hey-api/openapi-ts
|
// This file is auto-generated by @hey-api/openapi-ts
|
||||||
|
|
||||||
export type * from "./types.gen";
|
export type * from './types.gen';
|
||||||
export * from "./sdk.gen";
|
export * from './sdk.gen';
|
||||||
|
|||||||
@@ -1,588 +1,463 @@
|
|||||||
// This file is auto-generated by @hey-api/openapi-ts
|
// This file is auto-generated by @hey-api/openapi-ts
|
||||||
|
|
||||||
import type { Client, Options as Options2, TDataShape } from "./client";
|
import type { Client, Options as Options2, TDataShape } from './client';
|
||||||
import { client } from "./client.gen";
|
import { client } from './client.gen';
|
||||||
import type {
|
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';
|
||||||
BrowseFilesystemData,
|
|
||||||
BrowseFilesystemResponses,
|
|
||||||
ChangePasswordData,
|
|
||||||
ChangePasswordResponses,
|
|
||||||
CreateBackupScheduleData,
|
|
||||||
CreateBackupScheduleResponses,
|
|
||||||
CreateRepositoryData,
|
|
||||||
CreateRepositoryResponses,
|
|
||||||
CreateVolumeData,
|
|
||||||
CreateVolumeResponses,
|
|
||||||
DeleteBackupScheduleData,
|
|
||||||
DeleteBackupScheduleResponses,
|
|
||||||
DeleteRepositoryData,
|
|
||||||
DeleteRepositoryResponses,
|
|
||||||
DeleteVolumeData,
|
|
||||||
DeleteVolumeResponses,
|
|
||||||
DoctorRepositoryData,
|
|
||||||
DoctorRepositoryResponses,
|
|
||||||
DownloadResticPasswordData,
|
|
||||||
DownloadResticPasswordResponses,
|
|
||||||
GetBackupScheduleData,
|
|
||||||
GetBackupScheduleForVolumeData,
|
|
||||||
GetBackupScheduleForVolumeResponses,
|
|
||||||
GetBackupScheduleResponses,
|
|
||||||
GetContainersUsingVolumeData,
|
|
||||||
GetContainersUsingVolumeErrors,
|
|
||||||
GetContainersUsingVolumeResponses,
|
|
||||||
GetMeData,
|
|
||||||
GetMeResponses,
|
|
||||||
GetRepositoryData,
|
|
||||||
GetRepositoryResponses,
|
|
||||||
GetSnapshotDetailsData,
|
|
||||||
GetSnapshotDetailsResponses,
|
|
||||||
GetStatusData,
|
|
||||||
GetStatusResponses,
|
|
||||||
GetSystemInfoData,
|
|
||||||
GetSystemInfoResponses,
|
|
||||||
GetVolumeData,
|
|
||||||
GetVolumeErrors,
|
|
||||||
GetVolumeResponses,
|
|
||||||
HealthCheckVolumeData,
|
|
||||||
HealthCheckVolumeErrors,
|
|
||||||
HealthCheckVolumeResponses,
|
|
||||||
ListBackupSchedulesData,
|
|
||||||
ListBackupSchedulesResponses,
|
|
||||||
ListFilesData,
|
|
||||||
ListFilesResponses,
|
|
||||||
ListRcloneRemotesData,
|
|
||||||
ListRcloneRemotesResponses,
|
|
||||||
ListRepositoriesData,
|
|
||||||
ListRepositoriesResponses,
|
|
||||||
ListSnapshotFilesData,
|
|
||||||
ListSnapshotFilesResponses,
|
|
||||||
ListSnapshotsData,
|
|
||||||
ListSnapshotsResponses,
|
|
||||||
ListVolumesData,
|
|
||||||
ListVolumesResponses,
|
|
||||||
LoginData,
|
|
||||||
LoginResponses,
|
|
||||||
LogoutData,
|
|
||||||
LogoutResponses,
|
|
||||||
MountVolumeData,
|
|
||||||
MountVolumeResponses,
|
|
||||||
RegisterData,
|
|
||||||
RegisterResponses,
|
|
||||||
RestoreSnapshotData,
|
|
||||||
RestoreSnapshotResponses,
|
|
||||||
RunBackupNowData,
|
|
||||||
RunBackupNowResponses,
|
|
||||||
StopBackupData,
|
|
||||||
StopBackupErrors,
|
|
||||||
StopBackupResponses,
|
|
||||||
TestConnectionData,
|
|
||||||
TestConnectionResponses,
|
|
||||||
UnmountVolumeData,
|
|
||||||
UnmountVolumeResponses,
|
|
||||||
UpdateBackupScheduleData,
|
|
||||||
UpdateBackupScheduleResponses,
|
|
||||||
UpdateVolumeData,
|
|
||||||
UpdateVolumeErrors,
|
|
||||||
UpdateVolumeResponses,
|
|
||||||
} from "./types.gen";
|
|
||||||
|
|
||||||
export type Options<TData extends TDataShape = TDataShape, ThrowOnError extends boolean = boolean> = Options2<
|
export type Options<TData extends TDataShape = TDataShape, ThrowOnError extends boolean = boolean> = Options2<TData, ThrowOnError> & {
|
||||||
TData,
|
/**
|
||||||
ThrowOnError
|
* You can provide a client instance returned by `createClient()` instead of
|
||||||
> & {
|
* individual options. This might be also useful if you want to implement a
|
||||||
/**
|
* custom client.
|
||||||
* You can provide a client instance returned by `createClient()` instead of
|
*/
|
||||||
* individual options. This might be also useful if you want to implement a
|
client?: Client;
|
||||||
* custom client.
|
/**
|
||||||
*/
|
* You can pass arbitrary values through the `meta` object. This can be
|
||||||
client?: Client;
|
* used to access values that aren't defined as part of the SDK function.
|
||||||
/**
|
*/
|
||||||
* You can pass arbitrary values through the `meta` object. This can be
|
meta?: Record<string, unknown>;
|
||||||
* used to access values that aren't defined as part of the SDK function.
|
|
||||||
*/
|
|
||||||
meta?: Record<string, unknown>;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Register a new user
|
* Register a new user
|
||||||
*/
|
*/
|
||||||
export const register = <ThrowOnError extends boolean = false>(options?: Options<RegisterData, ThrowOnError>) => {
|
export const register = <ThrowOnError extends boolean = false>(options?: Options<RegisterData, ThrowOnError>) => {
|
||||||
return (options?.client ?? client).post<RegisterResponses, unknown, ThrowOnError>({
|
return (options?.client ?? client).post<RegisterResponses, unknown, ThrowOnError>({
|
||||||
url: "/api/v1/auth/register",
|
url: '/api/v1/auth/register',
|
||||||
...options,
|
...options,
|
||||||
headers: {
|
headers: {
|
||||||
"Content-Type": "application/json",
|
'Content-Type': 'application/json',
|
||||||
...options?.headers,
|
...options?.headers
|
||||||
},
|
}
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Login with username and password
|
* Login with username and password
|
||||||
*/
|
*/
|
||||||
export const login = <ThrowOnError extends boolean = false>(options?: Options<LoginData, ThrowOnError>) => {
|
export const login = <ThrowOnError extends boolean = false>(options?: Options<LoginData, ThrowOnError>) => {
|
||||||
return (options?.client ?? client).post<LoginResponses, unknown, ThrowOnError>({
|
return (options?.client ?? client).post<LoginResponses, unknown, ThrowOnError>({
|
||||||
url: "/api/v1/auth/login",
|
url: '/api/v1/auth/login',
|
||||||
...options,
|
...options,
|
||||||
headers: {
|
headers: {
|
||||||
"Content-Type": "application/json",
|
'Content-Type': 'application/json',
|
||||||
...options?.headers,
|
...options?.headers
|
||||||
},
|
}
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Logout current user
|
* Logout current user
|
||||||
*/
|
*/
|
||||||
export const logout = <ThrowOnError extends boolean = false>(options?: Options<LogoutData, ThrowOnError>) => {
|
export const logout = <ThrowOnError extends boolean = false>(options?: Options<LogoutData, ThrowOnError>) => {
|
||||||
return (options?.client ?? client).post<LogoutResponses, unknown, ThrowOnError>({
|
return (options?.client ?? client).post<LogoutResponses, unknown, ThrowOnError>({
|
||||||
url: "/api/v1/auth/logout",
|
url: '/api/v1/auth/logout',
|
||||||
...options,
|
...options
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get current authenticated user
|
* Get current authenticated user
|
||||||
*/
|
*/
|
||||||
export const getMe = <ThrowOnError extends boolean = false>(options?: Options<GetMeData, ThrowOnError>) => {
|
export const getMe = <ThrowOnError extends boolean = false>(options?: Options<GetMeData, ThrowOnError>) => {
|
||||||
return (options?.client ?? client).get<GetMeResponses, unknown, ThrowOnError>({
|
return (options?.client ?? client).get<GetMeResponses, unknown, ThrowOnError>({
|
||||||
url: "/api/v1/auth/me",
|
url: '/api/v1/auth/me',
|
||||||
...options,
|
...options
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get authentication system status
|
* Get authentication system status
|
||||||
*/
|
*/
|
||||||
export const getStatus = <ThrowOnError extends boolean = false>(options?: Options<GetStatusData, ThrowOnError>) => {
|
export const getStatus = <ThrowOnError extends boolean = false>(options?: Options<GetStatusData, ThrowOnError>) => {
|
||||||
return (options?.client ?? client).get<GetStatusResponses, unknown, ThrowOnError>({
|
return (options?.client ?? client).get<GetStatusResponses, unknown, ThrowOnError>({
|
||||||
url: "/api/v1/auth/status",
|
url: '/api/v1/auth/status',
|
||||||
...options,
|
...options
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Change current user password
|
* Change current user password
|
||||||
*/
|
*/
|
||||||
export const changePassword = <ThrowOnError extends boolean = false>(
|
export const changePassword = <ThrowOnError extends boolean = false>(options?: Options<ChangePasswordData, ThrowOnError>) => {
|
||||||
options?: Options<ChangePasswordData, ThrowOnError>,
|
return (options?.client ?? client).post<ChangePasswordResponses, unknown, ThrowOnError>({
|
||||||
) => {
|
url: '/api/v1/auth/change-password',
|
||||||
return (options?.client ?? client).post<ChangePasswordResponses, unknown, ThrowOnError>({
|
...options,
|
||||||
url: "/api/v1/auth/change-password",
|
headers: {
|
||||||
...options,
|
'Content-Type': 'application/json',
|
||||||
headers: {
|
...options?.headers
|
||||||
"Content-Type": "application/json",
|
}
|
||||||
...options?.headers,
|
});
|
||||||
},
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* List all volumes
|
* List all volumes
|
||||||
*/
|
*/
|
||||||
export const listVolumes = <ThrowOnError extends boolean = false>(options?: Options<ListVolumesData, ThrowOnError>) => {
|
export const listVolumes = <ThrowOnError extends boolean = false>(options?: Options<ListVolumesData, ThrowOnError>) => {
|
||||||
return (options?.client ?? client).get<ListVolumesResponses, unknown, ThrowOnError>({
|
return (options?.client ?? client).get<ListVolumesResponses, unknown, ThrowOnError>({
|
||||||
url: "/api/v1/volumes",
|
url: '/api/v1/volumes',
|
||||||
...options,
|
...options
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create a new volume
|
* Create a new volume
|
||||||
*/
|
*/
|
||||||
export const createVolume = <ThrowOnError extends boolean = false>(
|
export const createVolume = <ThrowOnError extends boolean = false>(options?: Options<CreateVolumeData, ThrowOnError>) => {
|
||||||
options?: Options<CreateVolumeData, ThrowOnError>,
|
return (options?.client ?? client).post<CreateVolumeResponses, unknown, ThrowOnError>({
|
||||||
) => {
|
url: '/api/v1/volumes',
|
||||||
return (options?.client ?? client).post<CreateVolumeResponses, unknown, ThrowOnError>({
|
...options,
|
||||||
url: "/api/v1/volumes",
|
headers: {
|
||||||
...options,
|
'Content-Type': 'application/json',
|
||||||
headers: {
|
...options?.headers
|
||||||
"Content-Type": "application/json",
|
}
|
||||||
...options?.headers,
|
});
|
||||||
},
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Test connection to backend
|
* Test connection to backend
|
||||||
*/
|
*/
|
||||||
export const testConnection = <ThrowOnError extends boolean = false>(
|
export const testConnection = <ThrowOnError extends boolean = false>(options?: Options<TestConnectionData, ThrowOnError>) => {
|
||||||
options?: Options<TestConnectionData, ThrowOnError>,
|
return (options?.client ?? client).post<TestConnectionResponses, unknown, ThrowOnError>({
|
||||||
) => {
|
url: '/api/v1/volumes/test-connection',
|
||||||
return (options?.client ?? client).post<TestConnectionResponses, unknown, ThrowOnError>({
|
...options,
|
||||||
url: "/api/v1/volumes/test-connection",
|
headers: {
|
||||||
...options,
|
'Content-Type': 'application/json',
|
||||||
headers: {
|
...options?.headers
|
||||||
"Content-Type": "application/json",
|
}
|
||||||
...options?.headers,
|
});
|
||||||
},
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Delete a volume
|
* Delete a volume
|
||||||
*/
|
*/
|
||||||
export const deleteVolume = <ThrowOnError extends boolean = false>(
|
export const deleteVolume = <ThrowOnError extends boolean = false>(options: Options<DeleteVolumeData, ThrowOnError>) => {
|
||||||
options: Options<DeleteVolumeData, ThrowOnError>,
|
return (options.client ?? client).delete<DeleteVolumeResponses, unknown, ThrowOnError>({
|
||||||
) => {
|
url: '/api/v1/volumes/{name}',
|
||||||
return (options.client ?? client).delete<DeleteVolumeResponses, unknown, ThrowOnError>({
|
...options
|
||||||
url: "/api/v1/volumes/{name}",
|
});
|
||||||
...options,
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get a volume by name
|
* Get a volume by name
|
||||||
*/
|
*/
|
||||||
export const getVolume = <ThrowOnError extends boolean = false>(options: Options<GetVolumeData, ThrowOnError>) => {
|
export const getVolume = <ThrowOnError extends boolean = false>(options: Options<GetVolumeData, ThrowOnError>) => {
|
||||||
return (options.client ?? client).get<GetVolumeResponses, GetVolumeErrors, ThrowOnError>({
|
return (options.client ?? client).get<GetVolumeResponses, GetVolumeErrors, ThrowOnError>({
|
||||||
url: "/api/v1/volumes/{name}",
|
url: '/api/v1/volumes/{name}',
|
||||||
...options,
|
...options
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Update a volume's configuration
|
* Update a volume's configuration
|
||||||
*/
|
*/
|
||||||
export const updateVolume = <ThrowOnError extends boolean = false>(
|
export const updateVolume = <ThrowOnError extends boolean = false>(options: Options<UpdateVolumeData, ThrowOnError>) => {
|
||||||
options: Options<UpdateVolumeData, ThrowOnError>,
|
return (options.client ?? client).put<UpdateVolumeResponses, UpdateVolumeErrors, ThrowOnError>({
|
||||||
) => {
|
url: '/api/v1/volumes/{name}',
|
||||||
return (options.client ?? client).put<UpdateVolumeResponses, UpdateVolumeErrors, ThrowOnError>({
|
...options,
|
||||||
url: "/api/v1/volumes/{name}",
|
headers: {
|
||||||
...options,
|
'Content-Type': 'application/json',
|
||||||
headers: {
|
...options.headers
|
||||||
"Content-Type": "application/json",
|
}
|
||||||
...options.headers,
|
});
|
||||||
},
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get containers using a volume by name
|
* Get containers using a volume by name
|
||||||
*/
|
*/
|
||||||
export const getContainersUsingVolume = <ThrowOnError extends boolean = false>(
|
export const getContainersUsingVolume = <ThrowOnError extends boolean = false>(options: Options<GetContainersUsingVolumeData, ThrowOnError>) => {
|
||||||
options: Options<GetContainersUsingVolumeData, ThrowOnError>,
|
return (options.client ?? client).get<GetContainersUsingVolumeResponses, GetContainersUsingVolumeErrors, ThrowOnError>({
|
||||||
) => {
|
url: '/api/v1/volumes/{name}/containers',
|
||||||
return (options.client ?? client).get<
|
...options
|
||||||
GetContainersUsingVolumeResponses,
|
});
|
||||||
GetContainersUsingVolumeErrors,
|
|
||||||
ThrowOnError
|
|
||||||
>({
|
|
||||||
url: "/api/v1/volumes/{name}/containers",
|
|
||||||
...options,
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Mount a volume
|
* Mount a volume
|
||||||
*/
|
*/
|
||||||
export const mountVolume = <ThrowOnError extends boolean = false>(options: Options<MountVolumeData, ThrowOnError>) => {
|
export const mountVolume = <ThrowOnError extends boolean = false>(options: Options<MountVolumeData, ThrowOnError>) => {
|
||||||
return (options.client ?? client).post<MountVolumeResponses, unknown, ThrowOnError>({
|
return (options.client ?? client).post<MountVolumeResponses, unknown, ThrowOnError>({
|
||||||
url: "/api/v1/volumes/{name}/mount",
|
url: '/api/v1/volumes/{name}/mount',
|
||||||
...options,
|
...options
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Unmount a volume
|
* Unmount a volume
|
||||||
*/
|
*/
|
||||||
export const unmountVolume = <ThrowOnError extends boolean = false>(
|
export const unmountVolume = <ThrowOnError extends boolean = false>(options: Options<UnmountVolumeData, ThrowOnError>) => {
|
||||||
options: Options<UnmountVolumeData, ThrowOnError>,
|
return (options.client ?? client).post<UnmountVolumeResponses, unknown, ThrowOnError>({
|
||||||
) => {
|
url: '/api/v1/volumes/{name}/unmount',
|
||||||
return (options.client ?? client).post<UnmountVolumeResponses, unknown, ThrowOnError>({
|
...options
|
||||||
url: "/api/v1/volumes/{name}/unmount",
|
});
|
||||||
...options,
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Perform a health check on a volume
|
* Perform a health check on a volume
|
||||||
*/
|
*/
|
||||||
export const healthCheckVolume = <ThrowOnError extends boolean = false>(
|
export const healthCheckVolume = <ThrowOnError extends boolean = false>(options: Options<HealthCheckVolumeData, ThrowOnError>) => {
|
||||||
options: Options<HealthCheckVolumeData, ThrowOnError>,
|
return (options.client ?? client).post<HealthCheckVolumeResponses, HealthCheckVolumeErrors, ThrowOnError>({
|
||||||
) => {
|
url: '/api/v1/volumes/{name}/health-check',
|
||||||
return (options.client ?? client).post<HealthCheckVolumeResponses, HealthCheckVolumeErrors, ThrowOnError>({
|
...options
|
||||||
url: "/api/v1/volumes/{name}/health-check",
|
});
|
||||||
...options,
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* List files in a volume directory
|
* List files in a volume directory
|
||||||
*/
|
*/
|
||||||
export const listFiles = <ThrowOnError extends boolean = false>(options: Options<ListFilesData, ThrowOnError>) => {
|
export const listFiles = <ThrowOnError extends boolean = false>(options: Options<ListFilesData, ThrowOnError>) => {
|
||||||
return (options.client ?? client).get<ListFilesResponses, unknown, ThrowOnError>({
|
return (options.client ?? client).get<ListFilesResponses, unknown, ThrowOnError>({
|
||||||
url: "/api/v1/volumes/{name}/files",
|
url: '/api/v1/volumes/{name}/files',
|
||||||
...options,
|
...options
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Browse directories on the host filesystem
|
* Browse directories on the host filesystem
|
||||||
*/
|
*/
|
||||||
export const browseFilesystem = <ThrowOnError extends boolean = false>(
|
export const browseFilesystem = <ThrowOnError extends boolean = false>(options?: Options<BrowseFilesystemData, ThrowOnError>) => {
|
||||||
options?: Options<BrowseFilesystemData, ThrowOnError>,
|
return (options?.client ?? client).get<BrowseFilesystemResponses, unknown, ThrowOnError>({
|
||||||
) => {
|
url: '/api/v1/volumes/filesystem/browse',
|
||||||
return (options?.client ?? client).get<BrowseFilesystemResponses, unknown, ThrowOnError>({
|
...options
|
||||||
url: "/api/v1/volumes/filesystem/browse",
|
});
|
||||||
...options,
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* List all repositories
|
* List all repositories
|
||||||
*/
|
*/
|
||||||
export const listRepositories = <ThrowOnError extends boolean = false>(
|
export const listRepositories = <ThrowOnError extends boolean = false>(options?: Options<ListRepositoriesData, ThrowOnError>) => {
|
||||||
options?: Options<ListRepositoriesData, ThrowOnError>,
|
return (options?.client ?? client).get<ListRepositoriesResponses, unknown, ThrowOnError>({
|
||||||
) => {
|
url: '/api/v1/repositories',
|
||||||
return (options?.client ?? client).get<ListRepositoriesResponses, unknown, ThrowOnError>({
|
...options
|
||||||
url: "/api/v1/repositories",
|
});
|
||||||
...options,
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create a new restic repository
|
* Create a new restic repository
|
||||||
*/
|
*/
|
||||||
export const createRepository = <ThrowOnError extends boolean = false>(
|
export const createRepository = <ThrowOnError extends boolean = false>(options?: Options<CreateRepositoryData, ThrowOnError>) => {
|
||||||
options?: Options<CreateRepositoryData, ThrowOnError>,
|
return (options?.client ?? client).post<CreateRepositoryResponses, unknown, ThrowOnError>({
|
||||||
) => {
|
url: '/api/v1/repositories',
|
||||||
return (options?.client ?? client).post<CreateRepositoryResponses, unknown, ThrowOnError>({
|
...options,
|
||||||
url: "/api/v1/repositories",
|
headers: {
|
||||||
...options,
|
'Content-Type': 'application/json',
|
||||||
headers: {
|
...options?.headers
|
||||||
"Content-Type": "application/json",
|
}
|
||||||
...options?.headers,
|
});
|
||||||
},
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* List all configured rclone remotes on the host system
|
* List all configured rclone remotes on the host system
|
||||||
*/
|
*/
|
||||||
export const listRcloneRemotes = <ThrowOnError extends boolean = false>(
|
export const listRcloneRemotes = <ThrowOnError extends boolean = false>(options?: Options<ListRcloneRemotesData, ThrowOnError>) => {
|
||||||
options?: Options<ListRcloneRemotesData, ThrowOnError>,
|
return (options?.client ?? client).get<ListRcloneRemotesResponses, unknown, ThrowOnError>({
|
||||||
) => {
|
url: '/api/v1/repositories/rclone-remotes',
|
||||||
return (options?.client ?? client).get<ListRcloneRemotesResponses, unknown, ThrowOnError>({
|
...options
|
||||||
url: "/api/v1/repositories/rclone-remotes",
|
});
|
||||||
...options,
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Delete a repository
|
* Delete a repository
|
||||||
*/
|
*/
|
||||||
export const deleteRepository = <ThrowOnError extends boolean = false>(
|
export const deleteRepository = <ThrowOnError extends boolean = false>(options: Options<DeleteRepositoryData, ThrowOnError>) => {
|
||||||
options: Options<DeleteRepositoryData, ThrowOnError>,
|
return (options.client ?? client).delete<DeleteRepositoryResponses, unknown, ThrowOnError>({
|
||||||
) => {
|
url: '/api/v1/repositories/{name}',
|
||||||
return (options.client ?? client).delete<DeleteRepositoryResponses, unknown, ThrowOnError>({
|
...options
|
||||||
url: "/api/v1/repositories/{name}",
|
});
|
||||||
...options,
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get a single repository by name
|
* Get a single repository by name
|
||||||
*/
|
*/
|
||||||
export const getRepository = <ThrowOnError extends boolean = false>(
|
export const getRepository = <ThrowOnError extends boolean = false>(options: Options<GetRepositoryData, ThrowOnError>) => {
|
||||||
options: Options<GetRepositoryData, ThrowOnError>,
|
return (options.client ?? client).get<GetRepositoryResponses, unknown, ThrowOnError>({
|
||||||
) => {
|
url: '/api/v1/repositories/{name}',
|
||||||
return (options.client ?? client).get<GetRepositoryResponses, unknown, ThrowOnError>({
|
...options
|
||||||
url: "/api/v1/repositories/{name}",
|
});
|
||||||
...options,
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* List all snapshots in a repository
|
* List all snapshots in a repository
|
||||||
*/
|
*/
|
||||||
export const listSnapshots = <ThrowOnError extends boolean = false>(
|
export const listSnapshots = <ThrowOnError extends boolean = false>(options: Options<ListSnapshotsData, ThrowOnError>) => {
|
||||||
options: Options<ListSnapshotsData, ThrowOnError>,
|
return (options.client ?? client).get<ListSnapshotsResponses, unknown, ThrowOnError>({
|
||||||
) => {
|
url: '/api/v1/repositories/{name}/snapshots',
|
||||||
return (options.client ?? client).get<ListSnapshotsResponses, unknown, ThrowOnError>({
|
...options
|
||||||
url: "/api/v1/repositories/{name}/snapshots",
|
});
|
||||||
...options,
|
};
|
||||||
});
|
|
||||||
|
/**
|
||||||
|
* Delete a specific snapshot from a repository
|
||||||
|
*/
|
||||||
|
export const deleteSnapshot = <ThrowOnError extends boolean = false>(options: Options<DeleteSnapshotData, ThrowOnError>) => {
|
||||||
|
return (options.client ?? client).delete<DeleteSnapshotResponses, unknown, ThrowOnError>({
|
||||||
|
url: '/api/v1/repositories/{name}/snapshots/{snapshotId}',
|
||||||
|
...options
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get details of a specific snapshot
|
* Get details of a specific snapshot
|
||||||
*/
|
*/
|
||||||
export const getSnapshotDetails = <ThrowOnError extends boolean = false>(
|
export const getSnapshotDetails = <ThrowOnError extends boolean = false>(options: Options<GetSnapshotDetailsData, ThrowOnError>) => {
|
||||||
options: Options<GetSnapshotDetailsData, ThrowOnError>,
|
return (options.client ?? client).get<GetSnapshotDetailsResponses, unknown, ThrowOnError>({
|
||||||
) => {
|
url: '/api/v1/repositories/{name}/snapshots/{snapshotId}',
|
||||||
return (options.client ?? client).get<GetSnapshotDetailsResponses, unknown, ThrowOnError>({
|
...options
|
||||||
url: "/api/v1/repositories/{name}/snapshots/{snapshotId}",
|
});
|
||||||
...options,
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* List files and directories in a snapshot
|
* List files and directories in a snapshot
|
||||||
*/
|
*/
|
||||||
export const listSnapshotFiles = <ThrowOnError extends boolean = false>(
|
export const listSnapshotFiles = <ThrowOnError extends boolean = false>(options: Options<ListSnapshotFilesData, ThrowOnError>) => {
|
||||||
options: Options<ListSnapshotFilesData, ThrowOnError>,
|
return (options.client ?? client).get<ListSnapshotFilesResponses, unknown, ThrowOnError>({
|
||||||
) => {
|
url: '/api/v1/repositories/{name}/snapshots/{snapshotId}/files',
|
||||||
return (options.client ?? client).get<ListSnapshotFilesResponses, unknown, ThrowOnError>({
|
...options
|
||||||
url: "/api/v1/repositories/{name}/snapshots/{snapshotId}/files",
|
});
|
||||||
...options,
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Restore a snapshot to a target path on the filesystem
|
* Restore a snapshot to a target path on the filesystem
|
||||||
*/
|
*/
|
||||||
export const restoreSnapshot = <ThrowOnError extends boolean = false>(
|
export const restoreSnapshot = <ThrowOnError extends boolean = false>(options: Options<RestoreSnapshotData, ThrowOnError>) => {
|
||||||
options: Options<RestoreSnapshotData, ThrowOnError>,
|
return (options.client ?? client).post<RestoreSnapshotResponses, unknown, ThrowOnError>({
|
||||||
) => {
|
url: '/api/v1/repositories/{name}/restore',
|
||||||
return (options.client ?? client).post<RestoreSnapshotResponses, unknown, ThrowOnError>({
|
...options,
|
||||||
url: "/api/v1/repositories/{name}/restore",
|
headers: {
|
||||||
...options,
|
'Content-Type': 'application/json',
|
||||||
headers: {
|
...options.headers
|
||||||
"Content-Type": "application/json",
|
}
|
||||||
...options.headers,
|
});
|
||||||
},
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Run doctor operations on a repository to fix common issues (unlock, check, repair index). Use this when the repository is locked or has errors.
|
* Run doctor operations on a repository to fix common issues (unlock, check, repair index). Use this when the repository is locked or has errors.
|
||||||
*/
|
*/
|
||||||
export const doctorRepository = <ThrowOnError extends boolean = false>(
|
export const doctorRepository = <ThrowOnError extends boolean = false>(options: Options<DoctorRepositoryData, ThrowOnError>) => {
|
||||||
options: Options<DoctorRepositoryData, ThrowOnError>,
|
return (options.client ?? client).post<DoctorRepositoryResponses, unknown, ThrowOnError>({
|
||||||
) => {
|
url: '/api/v1/repositories/{name}/doctor',
|
||||||
return (options.client ?? client).post<DoctorRepositoryResponses, unknown, ThrowOnError>({
|
...options
|
||||||
url: "/api/v1/repositories/{name}/doctor",
|
});
|
||||||
...options,
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* List all backup schedules
|
* List all backup schedules
|
||||||
*/
|
*/
|
||||||
export const listBackupSchedules = <ThrowOnError extends boolean = false>(
|
export const listBackupSchedules = <ThrowOnError extends boolean = false>(options?: Options<ListBackupSchedulesData, ThrowOnError>) => {
|
||||||
options?: Options<ListBackupSchedulesData, ThrowOnError>,
|
return (options?.client ?? client).get<ListBackupSchedulesResponses, unknown, ThrowOnError>({
|
||||||
) => {
|
url: '/api/v1/backups',
|
||||||
return (options?.client ?? client).get<ListBackupSchedulesResponses, unknown, ThrowOnError>({
|
...options
|
||||||
url: "/api/v1/backups",
|
});
|
||||||
...options,
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create a new backup schedule for a volume
|
* Create a new backup schedule for a volume
|
||||||
*/
|
*/
|
||||||
export const createBackupSchedule = <ThrowOnError extends boolean = false>(
|
export const createBackupSchedule = <ThrowOnError extends boolean = false>(options?: Options<CreateBackupScheduleData, ThrowOnError>) => {
|
||||||
options?: Options<CreateBackupScheduleData, ThrowOnError>,
|
return (options?.client ?? client).post<CreateBackupScheduleResponses, unknown, ThrowOnError>({
|
||||||
) => {
|
url: '/api/v1/backups',
|
||||||
return (options?.client ?? client).post<CreateBackupScheduleResponses, unknown, ThrowOnError>({
|
...options,
|
||||||
url: "/api/v1/backups",
|
headers: {
|
||||||
...options,
|
'Content-Type': 'application/json',
|
||||||
headers: {
|
...options?.headers
|
||||||
"Content-Type": "application/json",
|
}
|
||||||
...options?.headers,
|
});
|
||||||
},
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Delete a backup schedule
|
* Delete a backup schedule
|
||||||
*/
|
*/
|
||||||
export const deleteBackupSchedule = <ThrowOnError extends boolean = false>(
|
export const deleteBackupSchedule = <ThrowOnError extends boolean = false>(options: Options<DeleteBackupScheduleData, ThrowOnError>) => {
|
||||||
options: Options<DeleteBackupScheduleData, ThrowOnError>,
|
return (options.client ?? client).delete<DeleteBackupScheduleResponses, unknown, ThrowOnError>({
|
||||||
) => {
|
url: '/api/v1/backups/{scheduleId}',
|
||||||
return (options.client ?? client).delete<DeleteBackupScheduleResponses, unknown, ThrowOnError>({
|
...options
|
||||||
url: "/api/v1/backups/{scheduleId}",
|
});
|
||||||
...options,
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get a backup schedule by ID
|
* Get a backup schedule by ID
|
||||||
*/
|
*/
|
||||||
export const getBackupSchedule = <ThrowOnError extends boolean = false>(
|
export const getBackupSchedule = <ThrowOnError extends boolean = false>(options: Options<GetBackupScheduleData, ThrowOnError>) => {
|
||||||
options: Options<GetBackupScheduleData, ThrowOnError>,
|
return (options.client ?? client).get<GetBackupScheduleResponses, unknown, ThrowOnError>({
|
||||||
) => {
|
url: '/api/v1/backups/{scheduleId}',
|
||||||
return (options.client ?? client).get<GetBackupScheduleResponses, unknown, ThrowOnError>({
|
...options
|
||||||
url: "/api/v1/backups/{scheduleId}",
|
});
|
||||||
...options,
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Update a backup schedule
|
* Update a backup schedule
|
||||||
*/
|
*/
|
||||||
export const updateBackupSchedule = <ThrowOnError extends boolean = false>(
|
export const updateBackupSchedule = <ThrowOnError extends boolean = false>(options: Options<UpdateBackupScheduleData, ThrowOnError>) => {
|
||||||
options: Options<UpdateBackupScheduleData, ThrowOnError>,
|
return (options.client ?? client).patch<UpdateBackupScheduleResponses, unknown, ThrowOnError>({
|
||||||
) => {
|
url: '/api/v1/backups/{scheduleId}',
|
||||||
return (options.client ?? client).patch<UpdateBackupScheduleResponses, unknown, ThrowOnError>({
|
...options,
|
||||||
url: "/api/v1/backups/{scheduleId}",
|
headers: {
|
||||||
...options,
|
'Content-Type': 'application/json',
|
||||||
headers: {
|
...options.headers
|
||||||
"Content-Type": "application/json",
|
}
|
||||||
...options.headers,
|
});
|
||||||
},
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get a backup schedule for a specific volume
|
* Get a backup schedule for a specific volume
|
||||||
*/
|
*/
|
||||||
export const getBackupScheduleForVolume = <ThrowOnError extends boolean = false>(
|
export const getBackupScheduleForVolume = <ThrowOnError extends boolean = false>(options: Options<GetBackupScheduleForVolumeData, ThrowOnError>) => {
|
||||||
options: Options<GetBackupScheduleForVolumeData, ThrowOnError>,
|
return (options.client ?? client).get<GetBackupScheduleForVolumeResponses, unknown, ThrowOnError>({
|
||||||
) => {
|
url: '/api/v1/backups/volume/{volumeId}',
|
||||||
return (options.client ?? client).get<GetBackupScheduleForVolumeResponses, unknown, ThrowOnError>({
|
...options
|
||||||
url: "/api/v1/backups/volume/{volumeId}",
|
});
|
||||||
...options,
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Trigger a backup immediately for a schedule
|
* Trigger a backup immediately for a schedule
|
||||||
*/
|
*/
|
||||||
export const runBackupNow = <ThrowOnError extends boolean = false>(
|
export const runBackupNow = <ThrowOnError extends boolean = false>(options: Options<RunBackupNowData, ThrowOnError>) => {
|
||||||
options: Options<RunBackupNowData, ThrowOnError>,
|
return (options.client ?? client).post<RunBackupNowResponses, unknown, ThrowOnError>({
|
||||||
) => {
|
url: '/api/v1/backups/{scheduleId}/run',
|
||||||
return (options.client ?? client).post<RunBackupNowResponses, unknown, ThrowOnError>({
|
...options
|
||||||
url: "/api/v1/backups/{scheduleId}/run",
|
});
|
||||||
...options,
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Stop a backup that is currently in progress
|
* Stop a backup that is currently in progress
|
||||||
*/
|
*/
|
||||||
export const stopBackup = <ThrowOnError extends boolean = false>(options: Options<StopBackupData, ThrowOnError>) => {
|
export const stopBackup = <ThrowOnError extends boolean = false>(options: Options<StopBackupData, ThrowOnError>) => {
|
||||||
return (options.client ?? client).post<StopBackupResponses, StopBackupErrors, ThrowOnError>({
|
return (options.client ?? client).post<StopBackupResponses, StopBackupErrors, ThrowOnError>({
|
||||||
url: "/api/v1/backups/{scheduleId}/stop",
|
url: '/api/v1/backups/{scheduleId}/stop',
|
||||||
...options,
|
...options
|
||||||
});
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Manually apply retention policy to clean up old snapshots
|
||||||
|
*/
|
||||||
|
export const runForget = <ThrowOnError extends boolean = false>(options: Options<RunForgetData, ThrowOnError>) => {
|
||||||
|
return (options.client ?? client).post<RunForgetResponses, unknown, ThrowOnError>({
|
||||||
|
url: '/api/v1/backups/{scheduleId}/forget',
|
||||||
|
...options
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get system information including available capabilities
|
* Get system information including available capabilities
|
||||||
*/
|
*/
|
||||||
export const getSystemInfo = <ThrowOnError extends boolean = false>(
|
export const getSystemInfo = <ThrowOnError extends boolean = false>(options?: Options<GetSystemInfoData, ThrowOnError>) => {
|
||||||
options?: Options<GetSystemInfoData, ThrowOnError>,
|
return (options?.client ?? client).get<GetSystemInfoResponses, unknown, ThrowOnError>({
|
||||||
) => {
|
url: '/api/v1/system/info',
|
||||||
return (options?.client ?? client).get<GetSystemInfoResponses, unknown, ThrowOnError>({
|
...options
|
||||||
url: "/api/v1/system/info",
|
});
|
||||||
...options,
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Download the Restic password file for backup recovery. Requires password re-authentication.
|
* Download the Restic password file for backup recovery. Requires password re-authentication.
|
||||||
*/
|
*/
|
||||||
export const downloadResticPassword = <ThrowOnError extends boolean = false>(
|
export const downloadResticPassword = <ThrowOnError extends boolean = false>(options?: Options<DownloadResticPasswordData, ThrowOnError>) => {
|
||||||
options?: Options<DownloadResticPasswordData, ThrowOnError>,
|
return (options?.client ?? client).post<DownloadResticPasswordResponses, unknown, ThrowOnError>({
|
||||||
) => {
|
url: '/api/v1/system/restic-password',
|
||||||
return (options?.client ?? client).post<DownloadResticPasswordResponses, unknown, ThrowOnError>({
|
...options,
|
||||||
url: "/api/v1/system/restic-password",
|
headers: {
|
||||||
...options,
|
'Content-Type': 'application/json',
|
||||||
headers: {
|
...options?.headers
|
||||||
"Content-Type": "application/json",
|
}
|
||||||
...options?.headers,
|
});
|
||||||
},
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,4 +1,4 @@
|
|||||||
import { Link } from "react-router";
|
import { Link, useMatches, type UIMatch } from "react-router";
|
||||||
import {
|
import {
|
||||||
Breadcrumb,
|
Breadcrumb,
|
||||||
BreadcrumbItem,
|
BreadcrumbItem,
|
||||||
@@ -7,14 +7,38 @@ import {
|
|||||||
BreadcrumbPage,
|
BreadcrumbPage,
|
||||||
BreadcrumbSeparator,
|
BreadcrumbSeparator,
|
||||||
} from "~/client/components/ui/breadcrumb";
|
} from "~/client/components/ui/breadcrumb";
|
||||||
import { useBreadcrumbs } from "~/client/lib/breadcrumbs";
|
|
||||||
|
export interface BreadcrumbItemData {
|
||||||
|
label: string;
|
||||||
|
href?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RouteHandle {
|
||||||
|
breadcrumb?: (match: UIMatch) => BreadcrumbItemData[] | null;
|
||||||
|
}
|
||||||
|
|
||||||
export function AppBreadcrumb() {
|
export function AppBreadcrumb() {
|
||||||
const breadcrumbs = useBreadcrumbs();
|
const matches = useMatches();
|
||||||
|
|
||||||
|
// Find the last match with a breadcrumb handler
|
||||||
|
const lastMatchWithBreadcrumb = [...matches].reverse().find((match) => {
|
||||||
|
const handle = match.handle as RouteHandle | undefined;
|
||||||
|
return handle?.breadcrumb;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!lastMatchWithBreadcrumb) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const handle = lastMatchWithBreadcrumb.handle as RouteHandle;
|
||||||
|
const breadcrumbs = handle.breadcrumb?.(lastMatchWithBreadcrumb);
|
||||||
|
|
||||||
|
if (!breadcrumbs || breadcrumbs.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Breadcrumb>
|
<Breadcrumb>
|
||||||
<BreadcrumbLink asChild></BreadcrumbLink>
|
|
||||||
<BreadcrumbList>
|
<BreadcrumbList>
|
||||||
{breadcrumbs.map((breadcrumb, index) => {
|
{breadcrumbs.map((breadcrumb, index) => {
|
||||||
const isLast = index === breadcrumbs.length - 1;
|
const isLast = index === breadcrumbs.length - 1;
|
||||||
@@ -22,14 +46,12 @@ export function AppBreadcrumb() {
|
|||||||
return (
|
return (
|
||||||
<div key={`${breadcrumb.label}-${index}`} className="contents">
|
<div key={`${breadcrumb.label}-${index}`} className="contents">
|
||||||
<BreadcrumbItem>
|
<BreadcrumbItem>
|
||||||
{isLast || breadcrumb.isCurrentPage ? (
|
{isLast || !breadcrumb.href ? (
|
||||||
<BreadcrumbPage>{breadcrumb.label}</BreadcrumbPage>
|
<BreadcrumbPage>{breadcrumb.label}</BreadcrumbPage>
|
||||||
) : breadcrumb.href ? (
|
) : (
|
||||||
<BreadcrumbLink asChild>
|
<BreadcrumbLink asChild>
|
||||||
<Link to={breadcrumb.href}>{breadcrumb.label}</Link>
|
<Link to={breadcrumb.href}>{breadcrumb.label}</Link>
|
||||||
</BreadcrumbLink>
|
</BreadcrumbLink>
|
||||||
) : (
|
|
||||||
<BreadcrumbPage>{breadcrumb.label}</BreadcrumbPage>
|
|
||||||
)}
|
)}
|
||||||
</BreadcrumbItem>
|
</BreadcrumbItem>
|
||||||
{!isLast && <BreadcrumbSeparator />}
|
{!isLast && <BreadcrumbSeparator />}
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import { CalendarClock, Database, HardDrive, Mountain, Settings } from "lucide-react";
|
import { CalendarClock, Database, HardDrive, Settings } from "lucide-react";
|
||||||
import { Link, NavLink } from "react-router";
|
import { Link, NavLink } from "react-router";
|
||||||
import {
|
import {
|
||||||
Sidebar,
|
Sidebar,
|
||||||
SidebarContent,
|
SidebarContent,
|
||||||
|
SidebarFooter,
|
||||||
SidebarGroup,
|
SidebarGroup,
|
||||||
SidebarGroupContent,
|
SidebarGroupContent,
|
||||||
SidebarHeader,
|
SidebarHeader,
|
||||||
@@ -13,6 +14,7 @@ import {
|
|||||||
} from "~/client/components/ui/sidebar";
|
} from "~/client/components/ui/sidebar";
|
||||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "~/client/components/ui/tooltip";
|
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "~/client/components/ui/tooltip";
|
||||||
import { cn } from "~/client/lib/utils";
|
import { cn } from "~/client/lib/utils";
|
||||||
|
import { APP_VERSION } from "~/client/lib/version";
|
||||||
|
|
||||||
const items = [
|
const items = [
|
||||||
{
|
{
|
||||||
@@ -44,13 +46,17 @@ export function AppSidebar() {
|
|||||||
<Sidebar variant="inset" collapsible="icon" className="p-0">
|
<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">
|
<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">
|
<Link to="/volumes" className="flex items-center gap-3 font-semibold pl-2">
|
||||||
<Mountain className="size-5 text-strong-accent" />
|
<img
|
||||||
|
src="/images/zerobyte.png"
|
||||||
|
alt="Zerobyte Logo"
|
||||||
|
className={cn("h-8 w-8 flex-shrink-0 object-contain -ml-2")}
|
||||||
|
/>
|
||||||
<span
|
<span
|
||||||
className={cn("text-base transition-all duration-200", {
|
className={cn("text-base transition-all duration-200 -ml-1", {
|
||||||
"opacity-0 w-0 overflow-hidden ": state === "collapsed",
|
"opacity-0 w-0 overflow-hidden ": state === "collapsed",
|
||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
Ironmount
|
Zerobyte
|
||||||
</span>
|
</span>
|
||||||
</Link>
|
</Link>
|
||||||
</SidebarHeader>
|
</SidebarHeader>
|
||||||
@@ -85,6 +91,15 @@ export function AppSidebar() {
|
|||||||
</SidebarGroupContent>
|
</SidebarGroupContent>
|
||||||
</SidebarGroup>
|
</SidebarGroup>
|
||||||
</SidebarContent>
|
</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>
|
</Sidebar>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import { Mountain } from "lucide-react";
|
|
||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
|
|
||||||
type AuthLayoutProps = {
|
type AuthLayoutProps = {
|
||||||
@@ -13,8 +12,8 @@ export function AuthLayout({ title, description, children }: AuthLayoutProps) {
|
|||||||
<div className="flex flex-1 items-center justify-center bg-background p-8">
|
<div className="flex flex-1 items-center justify-center bg-background p-8">
|
||||||
<div className="w-full max-w-md space-y-8">
|
<div className="w-full max-w-md space-y-8">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<Mountain className="size-5 text-strong-accent" />
|
<img src="/images/zerobyte.png" alt="Zerobyte Logo" className="h-5 w-5 object-contain" />
|
||||||
<span className="text-lg font-semibold">Ironmount</span>
|
<span className="text-lg font-semibold">Zerobyte</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
|
|||||||
@@ -1,66 +0,0 @@
|
|||||||
import { useMutation } from "@tanstack/react-query";
|
|
||||||
import { Plus } from "lucide-react";
|
|
||||||
import { useId } from "react";
|
|
||||||
import { toast } from "sonner";
|
|
||||||
import { parseError } from "~/client/lib/errors";
|
|
||||||
import { CreateRepositoryForm } from "./create-repository-form";
|
|
||||||
import { Button } from "./ui/button";
|
|
||||||
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from "./ui/dialog";
|
|
||||||
import { ScrollArea } from "./ui/scroll-area";
|
|
||||||
import { createRepositoryMutation } from "../api-client/@tanstack/react-query.gen";
|
|
||||||
|
|
||||||
type Props = {
|
|
||||||
open: boolean;
|
|
||||||
setOpen: (open: boolean) => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const CreateRepositoryDialog = ({ open, setOpen }: Props) => {
|
|
||||||
const formId = useId();
|
|
||||||
|
|
||||||
const create = useMutation({
|
|
||||||
...createRepositoryMutation(),
|
|
||||||
onSuccess: () => {
|
|
||||||
toast.success("Repository created successfully");
|
|
||||||
setOpen(false);
|
|
||||||
},
|
|
||||||
onError: (error) => {
|
|
||||||
toast.error("Failed to create repository", {
|
|
||||||
description: parseError(error)?.message,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Dialog open={open} onOpenChange={setOpen}>
|
|
||||||
<DialogTrigger asChild>
|
|
||||||
<Button>
|
|
||||||
<Plus size={16} className="mr-2" />
|
|
||||||
Create Repository
|
|
||||||
</Button>
|
|
||||||
</DialogTrigger>
|
|
||||||
<DialogContent>
|
|
||||||
<ScrollArea className="h-[500px] p-4">
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>Create repository</DialogTitle>
|
|
||||||
</DialogHeader>
|
|
||||||
<CreateRepositoryForm
|
|
||||||
className="mt-4"
|
|
||||||
mode="create"
|
|
||||||
formId={formId}
|
|
||||||
onSubmit={(values) => {
|
|
||||||
create.mutate({ body: { config: values, name: values.name, compressionMode: values.compressionMode } });
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<DialogFooter className="mt-4">
|
|
||||||
<Button type="button" variant="secondary" onClick={() => setOpen(false)}>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
<Button type="submit" form={formId} disabled={create.isPending}>
|
|
||||||
Create
|
|
||||||
</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</ScrollArea>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { arktypeResolver } from "@hookform/resolvers/arktype";
|
import { arktypeResolver } from "@hookform/resolvers/arktype";
|
||||||
import { type } from "arktype";
|
import { type } from "arktype";
|
||||||
import { useEffect } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
import { cn, slugify } from "~/client/lib/utils";
|
import { cn, slugify } from "~/client/lib/utils";
|
||||||
import { deepClean } from "~/utils/object";
|
import { deepClean } from "~/utils/object";
|
||||||
@@ -10,11 +10,24 @@ import { Input } from "./ui/input";
|
|||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select";
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { Alert, AlertDescription } from "./ui/alert";
|
import { Alert, AlertDescription } from "./ui/alert";
|
||||||
import { ExternalLink } from "lucide-react";
|
import { ExternalLink, AlertTriangle } from "lucide-react";
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip";
|
import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip";
|
||||||
import { useSystemInfo } from "~/client/hooks/use-system-info";
|
import { useSystemInfo } from "~/client/hooks/use-system-info";
|
||||||
import { COMPRESSION_MODES, repositoryConfigSchema } from "~/schemas/restic";
|
import { COMPRESSION_MODES, repositoryConfigSchema } from "~/schemas/restic";
|
||||||
import { listRcloneRemotesOptions } from "../api-client/@tanstack/react-query.gen";
|
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({
|
export const formSchema = type({
|
||||||
name: "2<=string<=32",
|
name: "2<=string<=32",
|
||||||
@@ -36,9 +49,12 @@ type Props = {
|
|||||||
const defaultValuesForType = {
|
const defaultValuesForType = {
|
||||||
local: { backend: "local" as const, compressionMode: "auto" as const },
|
local: { backend: "local" as const, compressionMode: "auto" as const },
|
||||||
s3: { backend: "s3" as const, compressionMode: "auto" as const },
|
s3: { backend: "s3" as const, compressionMode: "auto" as const },
|
||||||
|
r2: { backend: "r2" as const, compressionMode: "auto" as const },
|
||||||
gcs: { backend: "gcs" as const, compressionMode: "auto" as const },
|
gcs: { backend: "gcs" as const, compressionMode: "auto" as const },
|
||||||
azure: { backend: "azure" as const, compressionMode: "auto" as const },
|
azure: { backend: "azure" as const, compressionMode: "auto" as const },
|
||||||
rclone: { backend: "rclone" 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 = ({
|
export const CreateRepositoryForm = ({
|
||||||
@@ -58,23 +74,31 @@ export const CreateRepositoryForm = ({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const { watch } = form;
|
const { watch, setValue } = form;
|
||||||
|
|
||||||
const watchedBackend = watch("backend");
|
const watchedBackend = watch("backend");
|
||||||
|
const watchedIsExistingRepository = watch("isExistingRepository");
|
||||||
|
|
||||||
|
const [passwordMode, setPasswordMode] = useState<"default" | "custom">("default");
|
||||||
|
const [showPathBrowser, setShowPathBrowser] = useState(false);
|
||||||
|
const [showPathWarning, setShowPathWarning] = useState(false);
|
||||||
|
|
||||||
|
const { capabilities } = useSystemInfo();
|
||||||
|
|
||||||
const { data: rcloneRemotes, isLoading: isLoadingRemotes } = useQuery({
|
const { data: rcloneRemotes, isLoading: isLoadingRemotes } = useQuery({
|
||||||
...listRcloneRemotesOptions(),
|
...listRcloneRemotesOptions(),
|
||||||
|
enabled: capabilities.rclone,
|
||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
form.reset({
|
form.reset({
|
||||||
name: form.getValues().name,
|
name: form.getValues().name,
|
||||||
|
isExistingRepository: form.getValues().isExistingRepository,
|
||||||
|
customPassword: form.getValues().customPassword,
|
||||||
...defaultValuesForType[watchedBackend as keyof typeof defaultValuesForType],
|
...defaultValuesForType[watchedBackend as keyof typeof defaultValuesForType],
|
||||||
});
|
});
|
||||||
}, [watchedBackend, form]);
|
}, [watchedBackend, form]);
|
||||||
|
|
||||||
const { capabilities } = useSystemInfo();
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Form {...form}>
|
<Form {...form}>
|
||||||
<form id={formId} onSubmit={form.handleSubmit(onSubmit)} className={cn("space-y-4", className)}>
|
<form id={formId} onSubmit={form.handleSubmit(onSubmit)} className={cn("space-y-4", className)}>
|
||||||
@@ -115,8 +139,11 @@ export const CreateRepositoryForm = ({
|
|||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="local">Local</SelectItem>
|
<SelectItem value="local">Local</SelectItem>
|
||||||
<SelectItem value="s3">S3</SelectItem>
|
<SelectItem value="s3">S3</SelectItem>
|
||||||
|
<SelectItem value="r2">Cloudflare R2</SelectItem>
|
||||||
<SelectItem value="gcs">Google Cloud Storage</SelectItem>
|
<SelectItem value="gcs">Google Cloud Storage</SelectItem>
|
||||||
<SelectItem value="azure">Azure Blob Storage</SelectItem>
|
<SelectItem value="azure">Azure Blob Storage</SelectItem>
|
||||||
|
<SelectItem value="rest">REST Server</SelectItem>
|
||||||
|
<SelectItem value="sftp">SFTP</SelectItem>
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger>
|
<TooltipTrigger>
|
||||||
<SelectItem disabled={!capabilities.rclone} value="rclone">
|
<SelectItem disabled={!capabilities.rclone} value="rclone">
|
||||||
@@ -161,6 +188,151 @@ export const CreateRepositoryForm = ({
|
|||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="isExistingRepository"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem className="flex flex-row items-center space-x-3">
|
||||||
|
<FormControl>
|
||||||
|
<Checkbox
|
||||||
|
checked={field.value}
|
||||||
|
onCheckedChange={(checked) => {
|
||||||
|
field.onChange(checked);
|
||||||
|
if (!checked) {
|
||||||
|
setPasswordMode("default");
|
||||||
|
setValue("customPassword", undefined);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<FormLabel>Import existing repository</FormLabel>
|
||||||
|
<FormDescription>Check this if the repository already exists at the specified location</FormDescription>
|
||||||
|
</div>
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
{watchedIsExistingRepository && (
|
||||||
|
<>
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Repository Password</FormLabel>
|
||||||
|
<Select
|
||||||
|
onValueChange={(value) => {
|
||||||
|
setPasswordMode(value as "default" | "custom");
|
||||||
|
if (value === "default") {
|
||||||
|
setValue("customPassword", undefined);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
defaultValue={passwordMode}
|
||||||
|
value={passwordMode}
|
||||||
|
>
|
||||||
|
<FormControl>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Select password option" />
|
||||||
|
</SelectTrigger>
|
||||||
|
</FormControl>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="default">Use Zerobyte's password</SelectItem>
|
||||||
|
<SelectItem value="custom">Enter password manually</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<FormDescription>
|
||||||
|
Choose whether to use Zerobyte's master password or enter a custom password for the existing
|
||||||
|
repository.
|
||||||
|
</FormDescription>
|
||||||
|
</FormItem>
|
||||||
|
|
||||||
|
{passwordMode === "custom" && (
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="customPassword"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Repository Password</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input type="password" placeholder="Enter repository password" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormDescription>
|
||||||
|
The password used to encrypt this repository. It will be stored securely.
|
||||||
|
</FormDescription>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{watchedBackend === "local" && (
|
||||||
|
<>
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Repository Directory</FormLabel>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="flex-1 text-sm font-mono bg-muted px-3 py-2 rounded-md border">
|
||||||
|
{form.watch("path") || "/var/lib/zerobyte/repositories"}
|
||||||
|
</div>
|
||||||
|
<Button type="button" variant="outline" onClick={() => setShowPathWarning(true)} size="sm">
|
||||||
|
Change
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<FormDescription>The directory where the repository will be stored.</FormDescription>
|
||||||
|
</FormItem>
|
||||||
|
|
||||||
|
<AlertDialog open={showPathWarning} onOpenChange={setShowPathWarning}>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle className="flex items-center gap-2">
|
||||||
|
<AlertTriangle className="h-5 w-5 text-yellow-500" />
|
||||||
|
Important: Host Mount Required
|
||||||
|
</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription className="space-y-3">
|
||||||
|
<p>When selecting a custom path, ensure it is mounted from the host machine into the container.</p>
|
||||||
|
<p className="font-medium">
|
||||||
|
If the path is not a host mount, you will lose your repository data when the container restarts.
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
The default path <code className="bg-muted px-1 rounded">/var/lib/zerobyte/repositories</code> is
|
||||||
|
already mounted from the host and is safe to use.
|
||||||
|
</p>
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||||
|
<AlertDialogAction
|
||||||
|
onClick={() => {
|
||||||
|
setShowPathBrowser(true);
|
||||||
|
setShowPathWarning(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
I Understand, Continue
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
|
||||||
|
<AlertDialog open={showPathBrowser} onOpenChange={setShowPathBrowser}>
|
||||||
|
<AlertDialogContent className="max-w-2xl">
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>Select Repository Directory</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
Choose a directory from the filesystem to store the repository.
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<div className="py-4">
|
||||||
|
<DirectoryBrowser
|
||||||
|
onSelectPath={(path) => form.setValue("path", path)}
|
||||||
|
selectedPath={form.watch("path") || "/var/lib/zerobyte/repositories"}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||||
|
<AlertDialogAction onClick={() => setShowPathBrowser(false)}>Done</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
{watchedBackend === "s3" && (
|
{watchedBackend === "s3" && (
|
||||||
<>
|
<>
|
||||||
<FormField
|
<FormField
|
||||||
@@ -222,6 +394,69 @@ export const CreateRepositoryForm = ({
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{watchedBackend === "r2" && (
|
||||||
|
<>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="endpoint"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Endpoint</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input placeholder="<account-id>.r2.cloudflarestorage.com" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormDescription>
|
||||||
|
R2 endpoint (without https://). Find in R2 dashboard under bucket settings.
|
||||||
|
</FormDescription>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="bucket"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Bucket</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input placeholder="my-backup-bucket" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormDescription>R2 bucket name for storing backups.</FormDescription>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="accessKeyId"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Access Key ID</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input placeholder="Access Key ID from R2 API tokens" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormDescription>R2 API token Access Key ID (create in Cloudflare R2 dashboard).</FormDescription>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="secretAccessKey"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Secret Access Key</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input type="password" placeholder="••••••••" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormDescription>R2 API token Secret Access Key (shown once when creating token).</FormDescription>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
{watchedBackend === "gcs" && (
|
{watchedBackend === "gcs" && (
|
||||||
<>
|
<>
|
||||||
<FormField
|
<FormField
|
||||||
@@ -389,7 +624,7 @@ export const CreateRepositoryForm = ({
|
|||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>Path</FormLabel>
|
<FormLabel>Path</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input placeholder="backups/ironmount" {...field} />
|
<Input placeholder="backups/zerobyte" {...field} />
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormDescription>Path within the remote where backups will be stored.</FormDescription>
|
<FormDescription>Path within the remote where backups will be stored.</FormDescription>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
@@ -399,6 +634,150 @@ export const CreateRepositoryForm = ({
|
|||||||
</>
|
</>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
|
{watchedBackend === "rest" && (
|
||||||
|
<>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="url"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>REST Server URL</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input placeholder="http://192.168.1.30:8000" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormDescription>URL of the REST server.</FormDescription>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="path"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Repository Path (Optional)</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input placeholder="my-backup-repo" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormDescription>Path to the repository on the REST server (leave empty for root).</FormDescription>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="username"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Username (Optional)</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input placeholder="username" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormDescription>Username for REST server authentication.</FormDescription>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="password"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Password (Optional)</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input type="password" placeholder="••••••••" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormDescription>Password for REST server authentication.</FormDescription>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{watchedBackend === "sftp" && (
|
||||||
|
<>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="host"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Host</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input placeholder="192.168.1.100" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormDescription>SFTP server hostname or IP address.</FormDescription>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="port"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Port</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
placeholder="22"
|
||||||
|
{...field}
|
||||||
|
onChange={(e) => field.onChange(parseInt(e.target.value, 10))}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormDescription>SSH port (default: 22).</FormDescription>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="user"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>User</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input placeholder="backup-user" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormDescription>SSH username for authentication.</FormDescription>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="path"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Path</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input placeholder="backups/ironmount" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormDescription>Repository path on the SFTP server. </FormDescription>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="privateKey"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>SSH Private Key</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Textarea
|
||||||
|
{...field}
|
||||||
|
placeholder="-----BEGIN OPENSSH PRIVATE KEY----- ... -----END OPENSSH PRIVATE KEY-----"
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormDescription>Paste the contents of your SSH private key.</FormDescription>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
{mode === "update" && (
|
{mode === "update" && (
|
||||||
<Button type="submit" className="w-full" loading={loading}>
|
<Button type="submit" className="w-full" loading={loading}>
|
||||||
Save Changes
|
Save Changes
|
||||||
|
|||||||
@@ -1,66 +0,0 @@
|
|||||||
import { useMutation } from "@tanstack/react-query";
|
|
||||||
import { Plus } from "lucide-react";
|
|
||||||
import { useId } from "react";
|
|
||||||
import { toast } from "sonner";
|
|
||||||
import { parseError } from "~/client/lib/errors";
|
|
||||||
import { CreateVolumeForm } from "./create-volume-form";
|
|
||||||
import { Button } from "./ui/button";
|
|
||||||
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from "./ui/dialog";
|
|
||||||
import { ScrollArea } from "./ui/scroll-area";
|
|
||||||
import { createVolumeMutation } from "../api-client/@tanstack/react-query.gen";
|
|
||||||
|
|
||||||
type Props = {
|
|
||||||
open: boolean;
|
|
||||||
setOpen: (open: boolean) => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const CreateVolumeDialog = ({ open, setOpen }: Props) => {
|
|
||||||
const formId = useId();
|
|
||||||
|
|
||||||
const create = useMutation({
|
|
||||||
...createVolumeMutation(),
|
|
||||||
onSuccess: () => {
|
|
||||||
toast.success("Volume created successfully");
|
|
||||||
setOpen(false);
|
|
||||||
},
|
|
||||||
onError: (error) => {
|
|
||||||
toast.error("Failed to create volume", {
|
|
||||||
description: parseError(error)?.message,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Dialog open={open} onOpenChange={setOpen}>
|
|
||||||
<DialogTrigger asChild>
|
|
||||||
<Button>
|
|
||||||
<Plus size={16} className="mr-2" />
|
|
||||||
Create volume
|
|
||||||
</Button>
|
|
||||||
</DialogTrigger>
|
|
||||||
<DialogContent>
|
|
||||||
<ScrollArea className="h-[500px] p-4">
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>Create volume</DialogTitle>
|
|
||||||
</DialogHeader>
|
|
||||||
<CreateVolumeForm
|
|
||||||
className="mt-4"
|
|
||||||
mode="create"
|
|
||||||
formId={formId}
|
|
||||||
onSubmit={(values) => {
|
|
||||||
create.mutate({ body: { config: values, name: values.name } });
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<DialogFooter className="mt-4">
|
|
||||||
<Button type="button" variant="secondary" onClick={() => setOpen(false)}>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
<Button type="submit" form={formId} disabled={create.isPending}>
|
|
||||||
Create
|
|
||||||
</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</ScrollArea>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -207,7 +207,12 @@ export const CreateVolumeForm = ({ onSubmit, mode = "create", initialValues, for
|
|||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>Port</FormLabel>
|
<FormLabel>Port</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input type="number" placeholder="2049" {...field} />
|
<Input
|
||||||
|
type="number"
|
||||||
|
placeholder="2049"
|
||||||
|
{...field}
|
||||||
|
onChange={(e) => field.onChange(parseInt(e.target.value, 10) || undefined)}
|
||||||
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormDescription>NFS server port (default: 2049).</FormDescription>
|
<FormDescription>NFS server port (default: 2049).</FormDescription>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
@@ -332,7 +337,12 @@ export const CreateVolumeForm = ({ onSubmit, mode = "create", initialValues, for
|
|||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>Port</FormLabel>
|
<FormLabel>Port</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input type="number" placeholder="80" {...field} />
|
<Input
|
||||||
|
type="number"
|
||||||
|
placeholder="80"
|
||||||
|
{...field}
|
||||||
|
onChange={(e) => field.onChange(parseInt(e.target.value, 10) || undefined)}
|
||||||
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormDescription>WebDAV server port (default: 80 for HTTP, 443 for HTTPS).</FormDescription>
|
<FormDescription>WebDAV server port (default: 80 for HTTP, 443 for HTTPS).</FormDescription>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
@@ -536,42 +546,44 @@ export const CreateVolumeForm = ({ onSubmit, mode = "create", initialValues, for
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="space-y-3">
|
{watchedBackend && watchedBackend !== "directory" && (
|
||||||
<div className="flex items-center gap-2">
|
<div className="space-y-3">
|
||||||
<Button
|
<div className="flex items-center gap-2">
|
||||||
type="button"
|
<Button
|
||||||
variant="outline"
|
type="button"
|
||||||
onClick={handleTestConnection}
|
variant="outline"
|
||||||
disabled={testBackendConnection.isPending}
|
onClick={handleTestConnection}
|
||||||
className="flex-1"
|
disabled={testBackendConnection.isPending}
|
||||||
>
|
className="flex-1"
|
||||||
{testBackendConnection.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
>
|
||||||
{!testBackendConnection.isPending && testMessage?.success && (
|
{testBackendConnection.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||||
<CheckCircle className="mr-2 h-4 w-4 text-green-500" />
|
{!testBackendConnection.isPending && testMessage?.success && (
|
||||||
)}
|
<CheckCircle className="mr-2 h-4 w-4 text-green-500" />
|
||||||
{!testBackendConnection.isPending && testMessage && !testMessage.success && (
|
)}
|
||||||
<XCircle className="mr-2 h-4 w-4 text-red-500" />
|
{!testBackendConnection.isPending && testMessage && !testMessage.success && (
|
||||||
)}
|
<XCircle className="mr-2 h-4 w-4 text-red-500" />
|
||||||
{testBackendConnection.isPending
|
)}
|
||||||
? "Testing..."
|
{testBackendConnection.isPending
|
||||||
: testMessage
|
? "Testing..."
|
||||||
? testMessage.success
|
: testMessage
|
||||||
? "Connection Successful"
|
? testMessage.success
|
||||||
: "Test Failed"
|
? "Connection Successful"
|
||||||
: "Test Connection"}
|
: "Test Failed"
|
||||||
</Button>
|
: "Test Connection"}
|
||||||
</div>
|
</Button>
|
||||||
{testMessage && (
|
|
||||||
<div
|
|
||||||
className={cn("text-xs p-2 rounded-md text-wrap wrap-anywhere", {
|
|
||||||
"bg-green-50 text-green-700 border border-green-200": testMessage.success,
|
|
||||||
"bg-red-50 text-red-700 border border-red-200": !testMessage.success,
|
|
||||||
})}
|
|
||||||
>
|
|
||||||
{testMessage.message}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
{testMessage && (
|
||||||
</div>
|
<div
|
||||||
|
className={cn("text-xs p-2 rounded-md text-wrap wrap-anywhere", {
|
||||||
|
"bg-green-50 text-green-700 border border-green-200": testMessage.success,
|
||||||
|
"bg-red-50 text-red-700 border border-red-200": !testMessage.success,
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
{testMessage.message}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{mode === "update" && (
|
{mode === "update" && (
|
||||||
<Button type="submit" className="w-full" loading={loading}>
|
<Button type="submit" className="w-full" loading={loading}>
|
||||||
Save Changes
|
Save Changes
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { useCallback, useMemo, useState } from "react";
|
import { FileTree } from "./file-tree";
|
||||||
import { FileTree, type FileEntry } from "./file-tree";
|
|
||||||
import { ScrollArea } from "./ui/scroll-area";
|
import { ScrollArea } from "./ui/scroll-area";
|
||||||
import { browseFilesystemOptions } from "../api-client/@tanstack/react-query.gen";
|
import { browseFilesystemOptions } from "../api-client/@tanstack/react-query.gen";
|
||||||
|
import { useFileBrowser } from "../hooks/use-file-browser";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
onSelectPath: (path: string) => void;
|
onSelectPath: (path: string) => void;
|
||||||
@@ -11,82 +11,23 @@ type Props = {
|
|||||||
|
|
||||||
export const DirectoryBrowser = ({ onSelectPath, selectedPath }: Props) => {
|
export const DirectoryBrowser = ({ onSelectPath, selectedPath }: Props) => {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const [expandedFolders, setExpandedFolders] = useState<Set<string>>(new Set());
|
|
||||||
const [fetchedFolders, setFetchedFolders] = useState<Set<string>>(new Set(["/"]));
|
|
||||||
const [loadingFolders, setLoadingFolders] = useState<Set<string>>(new Set());
|
|
||||||
const [allFiles, setAllFiles] = useState<Map<string, FileEntry>>(new Map());
|
|
||||||
|
|
||||||
const { data, isLoading } = useQuery({
|
const { data, isLoading } = useQuery({
|
||||||
...browseFilesystemOptions({ query: { path: "/" } }),
|
...browseFilesystemOptions({ query: { path: "/" } }),
|
||||||
});
|
});
|
||||||
|
|
||||||
useMemo(() => {
|
const fileBrowser = useFileBrowser({
|
||||||
if (data?.directories) {
|
initialData: data,
|
||||||
setAllFiles((prev) => {
|
isLoading,
|
||||||
const next = new Map(prev);
|
fetchFolder: async (path) => {
|
||||||
for (const dir of data.directories) {
|
return await queryClient.ensureQueryData(browseFilesystemOptions({ query: { path } }));
|
||||||
next.set(dir.path, { name: dir.name, path: dir.path, type: "folder" });
|
|
||||||
}
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}, [data]);
|
|
||||||
|
|
||||||
const fileArray = useMemo(() => Array.from(allFiles.values()), [allFiles]);
|
|
||||||
|
|
||||||
const handleFolderExpand = useCallback(
|
|
||||||
async (folderPath: string) => {
|
|
||||||
setExpandedFolders((prev) => {
|
|
||||||
const next = new Set(prev);
|
|
||||||
next.add(folderPath);
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!fetchedFolders.has(folderPath)) {
|
|
||||||
setLoadingFolders((prev) => new Set(prev).add(folderPath));
|
|
||||||
|
|
||||||
try {
|
|
||||||
const result = await queryClient.ensureQueryData(
|
|
||||||
browseFilesystemOptions({
|
|
||||||
query: { path: folderPath },
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
if (result.directories) {
|
|
||||||
setAllFiles((prev) => {
|
|
||||||
const next = new Map(prev);
|
|
||||||
for (const dir of result.directories) {
|
|
||||||
next.set(dir.path, { name: dir.name, path: dir.path, type: "folder" });
|
|
||||||
}
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
|
|
||||||
setFetchedFolders((prev) => new Set(prev).add(folderPath));
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Failed to fetch folder contents:", error);
|
|
||||||
} finally {
|
|
||||||
setLoadingFolders((prev) => {
|
|
||||||
const next = new Set(prev);
|
|
||||||
next.delete(folderPath);
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
[fetchedFolders, queryClient],
|
prefetchFolder: (path) => {
|
||||||
);
|
queryClient.prefetchQuery(browseFilesystemOptions({ query: { path } }));
|
||||||
|
|
||||||
const handleFolderHover = useCallback(
|
|
||||||
(folderPath: string) => {
|
|
||||||
if (!fetchedFolders.has(folderPath) && !loadingFolders.has(folderPath)) {
|
|
||||||
queryClient.prefetchQuery(browseFilesystemOptions({ query: { path: folderPath } }));
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
[fetchedFolders, loadingFolders, queryClient],
|
});
|
||||||
);
|
|
||||||
|
|
||||||
if (isLoading && fileArray.length === 0) {
|
if (fileBrowser.isLoading) {
|
||||||
return (
|
return (
|
||||||
<div className="border rounded-lg overflow-hidden">
|
<div className="border rounded-lg overflow-hidden">
|
||||||
<ScrollArea className="h-64">
|
<ScrollArea className="h-64">
|
||||||
@@ -96,7 +37,7 @@ export const DirectoryBrowser = ({ onSelectPath, selectedPath }: Props) => {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (fileArray.length === 0) {
|
if (fileBrowser.isEmpty) {
|
||||||
return (
|
return (
|
||||||
<div className="border rounded-lg overflow-hidden">
|
<div className="border rounded-lg overflow-hidden">
|
||||||
<ScrollArea className="h-64">
|
<ScrollArea className="h-64">
|
||||||
@@ -110,11 +51,11 @@ export const DirectoryBrowser = ({ onSelectPath, selectedPath }: Props) => {
|
|||||||
<div className="border rounded-lg overflow-hidden">
|
<div className="border rounded-lg overflow-hidden">
|
||||||
<ScrollArea className="h-64">
|
<ScrollArea className="h-64">
|
||||||
<FileTree
|
<FileTree
|
||||||
files={fileArray}
|
files={fileBrowser.fileArray}
|
||||||
onFolderExpand={handleFolderExpand}
|
onFolderExpand={fileBrowser.handleFolderExpand}
|
||||||
onFolderHover={handleFolderHover}
|
onFolderHover={fileBrowser.handleFolderHover}
|
||||||
expandedFolders={expandedFolders}
|
expandedFolders={fileBrowser.expandedFolders}
|
||||||
loadingFolders={loadingFolders}
|
loadingFolders={fileBrowser.loadingFolders}
|
||||||
foldersOnly
|
foldersOnly
|
||||||
selectableFolders
|
selectableFolders
|
||||||
selectedFolder={selectedPath}
|
selectedFolder={selectedPath}
|
||||||
|
|||||||
@@ -59,7 +59,7 @@ export default function Layout({ loaderData }: Route.ComponentProps) {
|
|||||||
</Button>
|
</Button>
|
||||||
<Button variant="default" size="sm" className="relative overflow-hidden hidden lg:inline-flex">
|
<Button variant="default" size="sm" className="relative overflow-hidden hidden lg:inline-flex">
|
||||||
<a
|
<a
|
||||||
href="https://github.com/nicotsx/ironmount/issues/new"
|
href="https://github.com/nicotsx/zerobyte/issues/new"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noreferrer"
|
rel="noreferrer"
|
||||||
className="flex items-center gap-2"
|
className="flex items-center gap-2"
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Database, HardDrive, Cloud } from "lucide-react";
|
import { Database, HardDrive, Cloud, Server } from "lucide-react";
|
||||||
import type { RepositoryBackend } from "~/schemas/restic";
|
import type { RepositoryBackend } from "~/schemas/restic";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
@@ -14,6 +14,9 @@ export const RepositoryIcon = ({ backend, className = "h-4 w-4" }: Props) => {
|
|||||||
return <Cloud className={className} />;
|
return <Cloud className={className} />;
|
||||||
case "gcs":
|
case "gcs":
|
||||||
return <Cloud className={className} />;
|
return <Cloud className={className} />;
|
||||||
|
case "rest":
|
||||||
|
case "sftp":
|
||||||
|
return <Server className={className} />;
|
||||||
default:
|
default:
|
||||||
return <Database className={className} />;
|
return <Database className={className} />;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,26 @@
|
|||||||
import { Calendar, Clock, Database, FolderTree, HardDrive } from "lucide-react";
|
import { useState } from "react";
|
||||||
|
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { Calendar, Clock, Database, FolderTree, HardDrive, Trash2 } from "lucide-react";
|
||||||
import { useNavigate } from "react-router";
|
import { useNavigate } from "react-router";
|
||||||
|
import { toast } from "sonner";
|
||||||
import { ByteSize } from "~/client/components/bytes-size";
|
import { ByteSize } from "~/client/components/bytes-size";
|
||||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "~/client/components/ui/table";
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "~/client/components/ui/table";
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from "~/client/components/ui/tooltip";
|
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 { formatDuration } from "~/utils/utils";
|
||||||
import type { ListSnapshotsResponse } from "../api-client";
|
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];
|
type Snapshot = ListSnapshotsResponse[number];
|
||||||
|
|
||||||
@@ -15,81 +31,149 @@ type Props = {
|
|||||||
|
|
||||||
export const SnapshotsTable = ({ snapshots, repositoryName }: Props) => {
|
export const SnapshotsTable = ({ snapshots, repositoryName }: Props) => {
|
||||||
const navigate = useNavigate();
|
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) => {
|
const handleRowClick = (snapshotId: string) => {
|
||||||
navigate(`/repositories/${repositoryName}/${snapshotId}`);
|
navigate(`/repositories/${repositoryName}/${snapshotId}`);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="overflow-x-auto">
|
<>
|
||||||
<Table className="border-t">
|
<div className="overflow-x-auto">
|
||||||
<TableHeader className="bg-card-header">
|
<Table className="border-t">
|
||||||
<TableRow>
|
<TableHeader className="bg-card-header">
|
||||||
<TableHead className="uppercase">Snapshot ID</TableHead>
|
<TableRow>
|
||||||
<TableHead className="uppercase">Date & Time</TableHead>
|
<TableHead className="uppercase">Snapshot ID</TableHead>
|
||||||
<TableHead className="uppercase">Size</TableHead>
|
<TableHead className="uppercase">Date & Time</TableHead>
|
||||||
<TableHead className="uppercase hidden md:table-cell text-right">Duration</TableHead>
|
<TableHead className="uppercase">Size</TableHead>
|
||||||
<TableHead className="uppercase hidden text-right lg:table-cell">Paths</TableHead>
|
<TableHead className="uppercase hidden md:table-cell text-right">Duration</TableHead>
|
||||||
</TableRow>
|
<TableHead className="uppercase hidden text-right lg:table-cell">Paths</TableHead>
|
||||||
</TableHeader>
|
<TableHead className="uppercase text-right">Actions</TableHead>
|
||||||
<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>
|
|
||||||
</TableRow>
|
</TableRow>
|
||||||
))}
|
</TableHeader>
|
||||||
</TableBody>
|
<TableBody>
|
||||||
</Table>
|
{snapshots.map((snapshot) => (
|
||||||
</div>
|
<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"
|
||||||
|
>
|
||||||
|
Delete snapshot
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,16 +1,8 @@
|
|||||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { FolderOpen } from "lucide-react";
|
import { FolderOpen } from "lucide-react";
|
||||||
import { useCallback, useMemo, useState } from "react";
|
|
||||||
import { FileTree } from "~/client/components/file-tree";
|
import { FileTree } from "~/client/components/file-tree";
|
||||||
import { listFilesOptions } from "../api-client/@tanstack/react-query.gen";
|
import { listFilesOptions } from "../api-client/@tanstack/react-query.gen";
|
||||||
|
import { useFileBrowser } from "../hooks/use-file-browser";
|
||||||
interface FileEntry {
|
|
||||||
name: string;
|
|
||||||
path: string;
|
|
||||||
type: "file" | "directory";
|
|
||||||
size?: number;
|
|
||||||
modifiedAt?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
type VolumeFileBrowserProps = {
|
type VolumeFileBrowserProps = {
|
||||||
volumeName: string;
|
volumeName: string;
|
||||||
@@ -36,89 +28,34 @@ export const VolumeFileBrowser = ({
|
|||||||
emptyDescription,
|
emptyDescription,
|
||||||
}: VolumeFileBrowserProps) => {
|
}: VolumeFileBrowserProps) => {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const [expandedFolders, setExpandedFolders] = useState<Set<string>>(new Set());
|
|
||||||
const [fetchedFolders, setFetchedFolders] = useState<Set<string>>(new Set(["/"]));
|
|
||||||
const [loadingFolders, setLoadingFolders] = useState<Set<string>>(new Set());
|
|
||||||
const [allFiles, setAllFiles] = useState<Map<string, FileEntry>>(new Map());
|
|
||||||
|
|
||||||
const { data, isLoading, error } = useQuery({
|
const { data, isLoading, error } = useQuery({
|
||||||
...listFilesOptions({ path: { name: volumeName } }),
|
...listFilesOptions({ path: { name: volumeName } }),
|
||||||
enabled,
|
enabled,
|
||||||
});
|
});
|
||||||
|
|
||||||
useMemo(() => {
|
const fileBrowser = useFileBrowser({
|
||||||
if (data?.files) {
|
initialData: data,
|
||||||
setAllFiles((prev) => {
|
isLoading,
|
||||||
const next = new Map(prev);
|
fetchFolder: async (path) => {
|
||||||
for (const file of data.files) {
|
return await queryClient.ensureQueryData(
|
||||||
next.set(file.path, file);
|
listFilesOptions({
|
||||||
}
|
path: { name: volumeName },
|
||||||
return next;
|
query: { path },
|
||||||
});
|
}),
|
||||||
}
|
);
|
||||||
}, [data]);
|
|
||||||
|
|
||||||
const fileArray = useMemo(() => Array.from(allFiles.values()), [allFiles]);
|
|
||||||
|
|
||||||
const handleFolderExpand = useCallback(
|
|
||||||
async (folderPath: string) => {
|
|
||||||
setExpandedFolders((prev) => {
|
|
||||||
const next = new Set(prev);
|
|
||||||
next.add(folderPath);
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!fetchedFolders.has(folderPath)) {
|
|
||||||
setLoadingFolders((prev) => new Set(prev).add(folderPath));
|
|
||||||
|
|
||||||
try {
|
|
||||||
const result = await queryClient.ensureQueryData(
|
|
||||||
listFilesOptions({
|
|
||||||
path: { name: volumeName },
|
|
||||||
query: { path: folderPath },
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
if (result.files) {
|
|
||||||
setAllFiles((prev) => {
|
|
||||||
const next = new Map(prev);
|
|
||||||
for (const file of result.files) {
|
|
||||||
next.set(file.path, file);
|
|
||||||
}
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
|
|
||||||
setFetchedFolders((prev) => new Set(prev).add(folderPath));
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Failed to fetch folder contents:", error);
|
|
||||||
} finally {
|
|
||||||
setLoadingFolders((prev) => {
|
|
||||||
const next = new Set(prev);
|
|
||||||
next.delete(folderPath);
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
[volumeName, fetchedFolders, queryClient.ensureQueryData],
|
prefetchFolder: (path) => {
|
||||||
);
|
queryClient.prefetchQuery(
|
||||||
|
listFilesOptions({
|
||||||
const handleFolderHover = useCallback(
|
path: { name: volumeName },
|
||||||
(folderPath: string) => {
|
query: { path },
|
||||||
if (!fetchedFolders.has(folderPath) && !loadingFolders.has(folderPath)) {
|
}),
|
||||||
queryClient.prefetchQuery(
|
);
|
||||||
listFilesOptions({
|
|
||||||
path: { name: volumeName },
|
|
||||||
query: { path: folderPath },
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
[volumeName, fetchedFolders, loadingFolders, queryClient],
|
});
|
||||||
);
|
|
||||||
|
|
||||||
if (isLoading && fileArray.length === 0) {
|
if (fileBrowser.isLoading) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center h-full min-h-[200px]">
|
<div className="flex items-center justify-center h-full min-h-[200px]">
|
||||||
<p className="text-muted-foreground">Loading files...</p>
|
<p className="text-muted-foreground">Loading files...</p>
|
||||||
@@ -134,7 +71,7 @@ export const VolumeFileBrowser = ({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (fileArray.length === 0) {
|
if (fileBrowser.isEmpty) {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col items-center justify-center h-full text-center p-8 min-h-[200px]">
|
<div className="flex flex-col items-center justify-center h-full text-center p-8 min-h-[200px]">
|
||||||
<FolderOpen className="mb-4 h-12 w-12 text-muted-foreground" />
|
<FolderOpen className="mb-4 h-12 w-12 text-muted-foreground" />
|
||||||
@@ -147,11 +84,11 @@ export const VolumeFileBrowser = ({
|
|||||||
return (
|
return (
|
||||||
<div className={className}>
|
<div className={className}>
|
||||||
<FileTree
|
<FileTree
|
||||||
files={fileArray}
|
files={fileBrowser.fileArray}
|
||||||
onFolderExpand={handleFolderExpand}
|
onFolderExpand={fileBrowser.handleFolderExpand}
|
||||||
onFolderHover={handleFolderHover}
|
onFolderHover={fileBrowser.handleFolderHover}
|
||||||
expandedFolders={expandedFolders}
|
expandedFolders={fileBrowser.expandedFolders}
|
||||||
loadingFolders={loadingFolders}
|
loadingFolders={fileBrowser.loadingFolders}
|
||||||
withCheckboxes={withCheckboxes}
|
withCheckboxes={withCheckboxes}
|
||||||
selectedPaths={selectedPaths}
|
selectedPaths={selectedPaths}
|
||||||
onSelectionChange={onSelectionChange}
|
onSelectionChange={onSelectionChange}
|
||||||
|
|||||||
135
app/client/hooks/use-file-browser.ts
Normal file
135
app/client/hooks/use-file-browser.ts
Normal file
@@ -0,0 +1,135 @@
|
|||||||
|
import { useCallback, useMemo, useState } from "react";
|
||||||
|
import type { FileEntry } from "../components/file-tree";
|
||||||
|
|
||||||
|
type FetchFolderFn = (
|
||||||
|
path: string,
|
||||||
|
) => Promise<{ files?: FileEntry[]; directories?: Array<{ name: string; path: string }> }>;
|
||||||
|
|
||||||
|
type PathTransformFns = {
|
||||||
|
strip?: (path: string) => string;
|
||||||
|
add?: (path: string) => string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type UseFileBrowserOptions = {
|
||||||
|
initialData?: { files?: FileEntry[]; directories?: Array<{ name: string; path: string }> };
|
||||||
|
isLoading?: boolean;
|
||||||
|
fetchFolder: FetchFolderFn;
|
||||||
|
prefetchFolder?: (path: string) => void;
|
||||||
|
pathTransform?: PathTransformFns;
|
||||||
|
rootPath?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useFileBrowser = (props: UseFileBrowserOptions) => {
|
||||||
|
const { initialData, isLoading, fetchFolder, prefetchFolder, pathTransform, rootPath = "/" } = props;
|
||||||
|
const [expandedFolders, setExpandedFolders] = useState<Set<string>>(new Set());
|
||||||
|
const [fetchedFolders, setFetchedFolders] = useState<Set<string>>(new Set([rootPath]));
|
||||||
|
const [loadingFolders, setLoadingFolders] = useState<Set<string>>(new Set());
|
||||||
|
const [allFiles, setAllFiles] = useState<Map<string, FileEntry>>(new Map());
|
||||||
|
|
||||||
|
const stripPath = pathTransform?.strip;
|
||||||
|
const addPath = pathTransform?.add;
|
||||||
|
|
||||||
|
useMemo(() => {
|
||||||
|
if (initialData?.files) {
|
||||||
|
const files = initialData.files;
|
||||||
|
setAllFiles((prev) => {
|
||||||
|
const next = new Map(prev);
|
||||||
|
for (const file of files) {
|
||||||
|
const path = stripPath ? stripPath(file.path) : file.path;
|
||||||
|
if (path !== rootPath) {
|
||||||
|
next.set(path, { ...file, path });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
if (rootPath) {
|
||||||
|
setFetchedFolders((prev) => new Set(prev).add(rootPath));
|
||||||
|
}
|
||||||
|
} else if (initialData?.directories) {
|
||||||
|
const directories = initialData.directories;
|
||||||
|
setAllFiles((prev) => {
|
||||||
|
const next = new Map(prev);
|
||||||
|
for (const dir of directories) {
|
||||||
|
next.set(dir.path, { name: dir.name, path: dir.path, type: "folder" });
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [initialData, stripPath, rootPath]);
|
||||||
|
|
||||||
|
const fileArray = useMemo(() => Array.from(allFiles.values()), [allFiles]);
|
||||||
|
|
||||||
|
const handleFolderExpand = useCallback(
|
||||||
|
async (folderPath: string) => {
|
||||||
|
setExpandedFolders((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
next.add(folderPath);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!fetchedFolders.has(folderPath)) {
|
||||||
|
setLoadingFolders((prev) => new Set(prev).add(folderPath));
|
||||||
|
|
||||||
|
try {
|
||||||
|
const pathToFetch = addPath ? addPath(folderPath) : folderPath;
|
||||||
|
const result = await fetchFolder(pathToFetch);
|
||||||
|
|
||||||
|
if (result.files) {
|
||||||
|
const files = result.files;
|
||||||
|
setAllFiles((prev) => {
|
||||||
|
const next = new Map(prev);
|
||||||
|
for (const file of files) {
|
||||||
|
const strippedPath = stripPath ? stripPath(file.path) : file.path;
|
||||||
|
// Skip the directory itself
|
||||||
|
if (strippedPath !== folderPath) {
|
||||||
|
next.set(strippedPath, { ...file, path: strippedPath });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
} else if (result.directories) {
|
||||||
|
const directories = result.directories;
|
||||||
|
setAllFiles((prev) => {
|
||||||
|
const next = new Map(prev);
|
||||||
|
for (const dir of directories) {
|
||||||
|
next.set(dir.path, { name: dir.name, path: dir.path, type: "folder" });
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
setFetchedFolders((prev) => new Set(prev).add(folderPath));
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to fetch folder contents:", error);
|
||||||
|
} finally {
|
||||||
|
setLoadingFolders((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
next.delete(folderPath);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[fetchedFolders, fetchFolder, stripPath, addPath],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleFolderHover = useCallback(
|
||||||
|
(folderPath: string) => {
|
||||||
|
if (!fetchedFolders.has(folderPath) && !loadingFolders.has(folderPath) && prefetchFolder) {
|
||||||
|
const pathToPrefetch = addPath ? addPath(folderPath) : folderPath;
|
||||||
|
prefetchFolder(pathToPrefetch);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[fetchedFolders, loadingFolders, prefetchFolder, addPath],
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
fileArray,
|
||||||
|
expandedFolders,
|
||||||
|
loadingFolders,
|
||||||
|
handleFolderExpand,
|
||||||
|
handleFolderHover,
|
||||||
|
isLoading: isLoading && fileArray.length === 0,
|
||||||
|
isEmpty: fileArray.length === 0 && !isLoading,
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -1,86 +0,0 @@
|
|||||||
import { useLocation, useParams } from "react-router";
|
|
||||||
|
|
||||||
export interface BreadcrumbItem {
|
|
||||||
label: string;
|
|
||||||
href?: string;
|
|
||||||
isCurrentPage?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Generates breadcrumb items based on the current route
|
|
||||||
* @param pathname - Current pathname from useLocation
|
|
||||||
* @param params - Route parameters from useParams
|
|
||||||
* @returns Array of breadcrumb items
|
|
||||||
*/
|
|
||||||
export function generateBreadcrumbs(pathname: string, params: Record<string, string | undefined>): BreadcrumbItem[] {
|
|
||||||
const breadcrumbs: BreadcrumbItem[] = [];
|
|
||||||
|
|
||||||
if (pathname.startsWith("/repositories")) {
|
|
||||||
breadcrumbs.push({
|
|
||||||
label: "Repositories",
|
|
||||||
href: "/repositories",
|
|
||||||
isCurrentPage: pathname === "/repositories",
|
|
||||||
});
|
|
||||||
|
|
||||||
if (pathname.startsWith("/repositories/") && params.name) {
|
|
||||||
const isSnapshotPage = !!params.snapshotId;
|
|
||||||
|
|
||||||
breadcrumbs.push({
|
|
||||||
label: params.name,
|
|
||||||
href: isSnapshotPage ? `/repositories/${params.name}` : undefined,
|
|
||||||
isCurrentPage: !isSnapshotPage,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (isSnapshotPage && params.snapshotId) {
|
|
||||||
breadcrumbs.push({
|
|
||||||
label: params.snapshotId,
|
|
||||||
isCurrentPage: true,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return breadcrumbs;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (pathname.startsWith("/backups")) {
|
|
||||||
breadcrumbs.push({
|
|
||||||
label: "Backups",
|
|
||||||
href: "/backups",
|
|
||||||
isCurrentPage: pathname === "/backups",
|
|
||||||
});
|
|
||||||
|
|
||||||
if (pathname.startsWith("/backups/") && params.id) {
|
|
||||||
breadcrumbs.push({
|
|
||||||
label: `Schedule #${params.id}`,
|
|
||||||
isCurrentPage: true,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return breadcrumbs;
|
|
||||||
}
|
|
||||||
|
|
||||||
breadcrumbs.push({
|
|
||||||
label: "Volumes",
|
|
||||||
href: "/volumes",
|
|
||||||
isCurrentPage: pathname === "/volumes",
|
|
||||||
});
|
|
||||||
|
|
||||||
if (pathname.startsWith("/volumes/") && params.name) {
|
|
||||||
breadcrumbs.push({
|
|
||||||
label: params.name,
|
|
||||||
isCurrentPage: true,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return breadcrumbs;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Hook to get breadcrumb data for the current route
|
|
||||||
*/
|
|
||||||
export function useBreadcrumbs(): BreadcrumbItem[] {
|
|
||||||
const location = useLocation();
|
|
||||||
const params = useParams();
|
|
||||||
|
|
||||||
return generateBreadcrumbs(location.pathname, params);
|
|
||||||
}
|
|
||||||
1
app/client/lib/version.ts
Normal file
1
app/client/lib/version.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export const APP_VERSION = import.meta.env.VITE_APP_VERSION || "dev";
|
||||||
@@ -16,7 +16,7 @@ export const clientMiddleware = [authMiddleware];
|
|||||||
|
|
||||||
export function meta(_: Route.MetaArgs) {
|
export function meta(_: Route.MetaArgs) {
|
||||||
return [
|
return [
|
||||||
{ title: "Download Recovery Key" },
|
{ title: "Zerobyte - Download Recovery Key" },
|
||||||
{
|
{
|
||||||
name: "description",
|
name: "description",
|
||||||
content: "Download your backup recovery key to ensure you can restore your data.",
|
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) {
|
export function meta(_: Route.MetaArgs) {
|
||||||
return [
|
return [
|
||||||
{ title: "Login" },
|
{ title: "Zerobyte - Login" },
|
||||||
{
|
{
|
||||||
name: "description",
|
name: "description",
|
||||||
content: "Sign in to your Ironmount account.",
|
content: "Sign in to your Zerobyte account.",
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,10 +24,10 @@ export const clientMiddleware = [authMiddleware];
|
|||||||
|
|
||||||
export function meta(_: Route.MetaArgs) {
|
export function meta(_: Route.MetaArgs) {
|
||||||
return [
|
return [
|
||||||
{ title: "Onboarding" },
|
{ title: "Zerobyte - Onboarding" },
|
||||||
{
|
{
|
||||||
name: "description",
|
name: "description",
|
||||||
content: "Welcome to Ironmount. Create your admin account to get started.",
|
content: "Welcome to Zerobyte. Create your admin account to get started.",
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
@@ -82,7 +82,7 @@ export default function OnboardingPage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AuthLayout title="Welcome to Ironmount" description="Create the admin user to get started">
|
<AuthLayout title="Welcome to Zerobyte" description="Create the admin user to get started">
|
||||||
<Form {...form}>
|
<Form {...form}>
|
||||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
|
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
|
||||||
<FormField
|
<FormField
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ const internalFormSchema = type({
|
|||||||
frequency: "string",
|
frequency: "string",
|
||||||
dailyTime: "string?",
|
dailyTime: "string?",
|
||||||
weeklyDay: "string?",
|
weeklyDay: "string?",
|
||||||
|
limitUploadKbps: "number?",
|
||||||
keepLast: "number?",
|
keepLast: "number?",
|
||||||
keepHourly: "number?",
|
keepHourly: "number?",
|
||||||
keepDaily: "number?",
|
keepDaily: "number?",
|
||||||
@@ -86,6 +87,7 @@ const backupScheduleToFormValues = (schedule?: BackupSchedule): InternalFormValu
|
|||||||
weeklyDay,
|
weeklyDay,
|
||||||
includePatterns: schedule.includePatterns || undefined,
|
includePatterns: schedule.includePatterns || undefined,
|
||||||
excludePatternsText: schedule.excludePatterns?.join("\n") || undefined,
|
excludePatternsText: schedule.excludePatterns?.join("\n") || undefined,
|
||||||
|
limitUploadKbps: schedule.limitUploadKbps || undefined,
|
||||||
...schedule.retentionPolicy,
|
...schedule.retentionPolicy,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
@@ -247,6 +249,29 @@ 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>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
@@ -254,8 +279,8 @@ export const CreateScheduleForm = ({ initialValues, formId, onSubmit, volume }:
|
|||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Backup paths</CardTitle>
|
<CardTitle>Backup paths</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
Select which folders to include in the backup. If no paths are selected, the entire volume will be
|
Select which folders or files to include in the backup. If no paths are selected, the entire volume will
|
||||||
backed up.
|
be backed up.
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
@@ -264,7 +289,7 @@ export const CreateScheduleForm = ({ initialValues, formId, onSubmit, volume }:
|
|||||||
selectedPaths={selectedPaths}
|
selectedPaths={selectedPaths}
|
||||||
onSelectionChange={handleSelectionChange}
|
onSelectionChange={handleSelectionChange}
|
||||||
withCheckboxes={true}
|
withCheckboxes={true}
|
||||||
foldersOnly={true}
|
foldersOnly={false}
|
||||||
className="flex-1 border rounded-md bg-card p-2 min-h-[300px] max-h-[400px] overflow-auto"
|
className="flex-1 border rounded-md bg-card p-2 min-h-[300px] max-h-[400px] overflow-auto"
|
||||||
/>
|
/>
|
||||||
{selectedPaths.size > 0 && (
|
{selectedPaths.size > 0 && (
|
||||||
@@ -482,6 +507,12 @@ export const CreateScheduleForm = ({ initialValues, formId, onSubmit, volume }:
|
|||||||
{repositoriesData?.find((r) => r.id === formValues.repositoryId)?.name || "—"}
|
{repositoriesData?.find((r) => r.id === formValues.repositoryId)?.name || "—"}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
{formValues.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 && (
|
{formValues.includePatterns && formValues.includePatterns.length > 0 && (
|
||||||
<div>
|
<div>
|
||||||
<p className="text-xs uppercase text-muted-foreground">Include paths</p>
|
<p className="text-xs uppercase text-muted-foreground">Include paths</p>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Pencil, Play, Square, Trash2 } from "lucide-react";
|
import { Eraser, Pencil, Play, Square, Trash2 } from "lucide-react";
|
||||||
import { useMemo, useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import { OnOff } from "~/client/components/onoff";
|
import { OnOff } from "~/client/components/onoff";
|
||||||
import { Button } from "~/client/components/ui/button";
|
import { Button } from "~/client/components/ui/button";
|
||||||
@@ -14,6 +14,10 @@ import {
|
|||||||
} from "~/client/components/ui/alert-dialog";
|
} from "~/client/components/ui/alert-dialog";
|
||||||
import type { BackupSchedule } from "~/client/lib/types";
|
import type { BackupSchedule } from "~/client/lib/types";
|
||||||
import { BackupProgressCard } from "./backup-progress-card";
|
import { BackupProgressCard } from "./backup-progress-card";
|
||||||
|
import { runForgetMutation } from "~/client/api-client/@tanstack/react-query.gen";
|
||||||
|
import { useMutation } from "@tanstack/react-query";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { parseError } from "~/client/lib/errors";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
schedule: BackupSchedule;
|
schedule: BackupSchedule;
|
||||||
@@ -28,6 +32,17 @@ export const ScheduleSummary = (props: Props) => {
|
|||||||
const { schedule, handleToggleEnabled, handleRunBackupNow, handleStopBackup, handleDeleteSchedule, setIsEditMode } =
|
const { schedule, handleToggleEnabled, handleRunBackupNow, handleStopBackup, handleDeleteSchedule, setIsEditMode } =
|
||||||
props;
|
props;
|
||||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
|
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
|
||||||
|
const [showForgetConfirm, setShowForgetConfirm] = useState(false);
|
||||||
|
|
||||||
|
const runForget = useMutation({
|
||||||
|
...runForgetMutation(),
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success("Retention policy applied successfully");
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
toast.error("Failed to apply retention policy", { description: parseError(error)?.message });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
const summary = useMemo(() => {
|
const summary = useMemo(() => {
|
||||||
const scheduleLabel = schedule ? schedule.cronExpression : "-";
|
const scheduleLabel = schedule ? schedule.cronExpression : "-";
|
||||||
@@ -56,6 +71,11 @@ export const ScheduleSummary = (props: Props) => {
|
|||||||
handleDeleteSchedule();
|
handleDeleteSchedule();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleConfirmForget = () => {
|
||||||
|
setShowForgetConfirm(false);
|
||||||
|
runForget.mutate({ path: { scheduleId: schedule.id.toString() } });
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<Card>
|
<Card>
|
||||||
@@ -89,6 +109,18 @@ export const ScheduleSummary = (props: Props) => {
|
|||||||
<span className="sm:inline">Backup now</span>
|
<span className="sm:inline">Backup now</span>
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
{schedule.retentionPolicy && (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
loading={runForget.isPending}
|
||||||
|
onClick={() => setShowForgetConfirm(true)}
|
||||||
|
className="w-full sm:w-auto"
|
||||||
|
>
|
||||||
|
<Eraser className="h-4 w-4 mr-2" />
|
||||||
|
<span className="sm:inline">Run cleanup</span>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
<Button variant="outline" size="sm" onClick={() => setIsEditMode(true)} className="w-full sm:w-auto">
|
<Button variant="outline" size="sm" onClick={() => setIsEditMode(true)} className="w-full sm:w-auto">
|
||||||
<Pencil className="h-4 w-4 mr-2" />
|
<Pencil className="h-4 w-4 mr-2" />
|
||||||
<span className="sm:inline">Edit schedule</span>
|
<span className="sm:inline">Edit schedule</span>
|
||||||
@@ -167,6 +199,22 @@ export const ScheduleSummary = (props: Props) => {
|
|||||||
</div>
|
</div>
|
||||||
</AlertDialogContent>
|
</AlertDialogContent>
|
||||||
</AlertDialog>
|
</AlertDialog>
|
||||||
|
|
||||||
|
<AlertDialog open={showForgetConfirm} onOpenChange={setShowForgetConfirm}>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>Run retention policy cleanup?</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
This will apply the retention policy and permanently delete old snapshots according to the configured
|
||||||
|
rules ({summary.retentionLabel}). This action cannot be undone.
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<div className="flex gap-3 justify-end">
|
||||||
|
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||||
|
<AlertDialogAction onClick={handleConfirmForget}>Run cleanup</AlertDialogAction>
|
||||||
|
</div>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useCallback, useMemo, useState } from "react";
|
import { useCallback, useState } from "react";
|
||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { FileIcon } from "lucide-react";
|
import { FileIcon } from "lucide-react";
|
||||||
import { FileTree, type FileEntry } from "~/client/components/file-tree";
|
import { FileTree } from "~/client/components/file-tree";
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "~/client/components/ui/card";
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "~/client/components/ui/card";
|
||||||
import { Button } from "~/client/components/ui/button";
|
import { Button } from "~/client/components/ui/button";
|
||||||
import { Checkbox } from "~/client/components/ui/checkbox";
|
import { Checkbox } from "~/client/components/ui/checkbox";
|
||||||
@@ -20,23 +20,22 @@ import { Tooltip, TooltipContent, TooltipTrigger } from "~/client/components/ui/
|
|||||||
import type { Snapshot, Volume } from "~/client/lib/types";
|
import type { Snapshot, Volume } from "~/client/lib/types";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { listSnapshotFilesOptions, restoreSnapshotMutation } from "~/client/api-client/@tanstack/react-query.gen";
|
import { listSnapshotFilesOptions, restoreSnapshotMutation } from "~/client/api-client/@tanstack/react-query.gen";
|
||||||
|
import { useFileBrowser } from "~/client/hooks/use-file-browser";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
snapshot: Snapshot;
|
snapshot: Snapshot;
|
||||||
repositoryName: string;
|
repositoryName: string;
|
||||||
volume?: Volume;
|
volume?: Volume;
|
||||||
|
onDeleteSnapshot?: (snapshotId: string) => void;
|
||||||
|
isDeletingSnapshot?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const SnapshotFileBrowser = (props: Props) => {
|
export const SnapshotFileBrowser = (props: Props) => {
|
||||||
const { snapshot, repositoryName, volume } = props;
|
const { snapshot, repositoryName, volume, onDeleteSnapshot, isDeletingSnapshot } = props;
|
||||||
|
|
||||||
const isReadOnly = volume?.config && "readOnly" in volume.config && volume.config.readOnly === true;
|
const isReadOnly = volume?.config && "readOnly" in volume.config && volume.config.readOnly === true;
|
||||||
|
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const [expandedFolders, setExpandedFolders] = useState<Set<string>>(new Set());
|
|
||||||
const [fetchedFolders, setFetchedFolders] = useState<Set<string>>(new Set());
|
|
||||||
const [loadingFolders, setLoadingFolders] = useState<Set<string>>(new Set());
|
|
||||||
const [allFiles, setAllFiles] = useState<Map<string, FileEntry>>(new Map());
|
|
||||||
const [selectedPaths, setSelectedPaths] = useState<Set<string>>(new Set());
|
const [selectedPaths, setSelectedPaths] = useState<Set<string>>(new Set());
|
||||||
const [showRestoreDialog, setShowRestoreDialog] = useState(false);
|
const [showRestoreDialog, setShowRestoreDialog] = useState(false);
|
||||||
const [deleteExtraFiles, setDeleteExtraFiles] = useState(false);
|
const [deleteExtraFiles, setDeleteExtraFiles] = useState(false);
|
||||||
@@ -72,89 +71,30 @@ export const SnapshotFileBrowser = (props: Props) => {
|
|||||||
[volumeBasePath],
|
[volumeBasePath],
|
||||||
);
|
);
|
||||||
|
|
||||||
useMemo(() => {
|
const fileBrowser = useFileBrowser({
|
||||||
if (filesData?.files) {
|
initialData: filesData,
|
||||||
setAllFiles((prev) => {
|
isLoading: filesLoading,
|
||||||
const next = new Map(prev);
|
fetchFolder: async (path) => {
|
||||||
for (const file of filesData.files) {
|
return await queryClient.ensureQueryData(
|
||||||
const strippedPath = stripBasePath(file.path);
|
listSnapshotFilesOptions({
|
||||||
if (strippedPath !== "/") {
|
path: { name: repositoryName, snapshotId: snapshot.short_id },
|
||||||
next.set(strippedPath, { ...file, path: strippedPath });
|
query: { path },
|
||||||
}
|
}),
|
||||||
}
|
);
|
||||||
return next;
|
|
||||||
});
|
|
||||||
setFetchedFolders((prev) => new Set(prev).add("/"));
|
|
||||||
}
|
|
||||||
}, [filesData, stripBasePath]);
|
|
||||||
|
|
||||||
const fileArray = useMemo(() => Array.from(allFiles.values()), [allFiles]);
|
|
||||||
|
|
||||||
const handleFolderExpand = useCallback(
|
|
||||||
async (folderPath: string) => {
|
|
||||||
setExpandedFolders((prev) => {
|
|
||||||
const next = new Set(prev);
|
|
||||||
next.add(folderPath);
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!fetchedFolders.has(folderPath)) {
|
|
||||||
setLoadingFolders((prev) => new Set(prev).add(folderPath));
|
|
||||||
|
|
||||||
try {
|
|
||||||
const fullPath = addBasePath(folderPath);
|
|
||||||
|
|
||||||
const result = await queryClient.ensureQueryData(
|
|
||||||
listSnapshotFilesOptions({
|
|
||||||
path: { name: repositoryName, snapshotId: snapshot.short_id },
|
|
||||||
query: { path: fullPath },
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
if (result.files) {
|
|
||||||
setAllFiles((prev) => {
|
|
||||||
const next = new Map(prev);
|
|
||||||
for (const file of result.files) {
|
|
||||||
const strippedPath = stripBasePath(file.path);
|
|
||||||
// Skip the directory itself
|
|
||||||
if (strippedPath !== folderPath) {
|
|
||||||
next.set(strippedPath, { ...file, path: strippedPath });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
|
|
||||||
setFetchedFolders((prev) => new Set(prev).add(folderPath));
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Failed to fetch folder contents:", error);
|
|
||||||
} finally {
|
|
||||||
setLoadingFolders((prev) => {
|
|
||||||
const next = new Set(prev);
|
|
||||||
next.delete(folderPath);
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
[repositoryName, snapshot, fetchedFolders, queryClient, stripBasePath, addBasePath],
|
prefetchFolder: (path) => {
|
||||||
);
|
queryClient.prefetchQuery(
|
||||||
|
listSnapshotFilesOptions({
|
||||||
const handleFolderHover = useCallback(
|
path: { name: repositoryName, snapshotId: snapshot.short_id },
|
||||||
(folderPath: string) => {
|
query: { path },
|
||||||
if (!fetchedFolders.has(folderPath) && !loadingFolders.has(folderPath)) {
|
}),
|
||||||
const fullPath = addBasePath(folderPath);
|
);
|
||||||
|
|
||||||
queryClient.prefetchQuery({
|
|
||||||
...listSnapshotFilesOptions({
|
|
||||||
path: { name: repositoryName, snapshotId: snapshot.short_id },
|
|
||||||
query: { path: fullPath },
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
[repositoryName, snapshot, fetchedFolders, loadingFolders, queryClient, addBasePath],
|
pathTransform: {
|
||||||
);
|
strip: stripBasePath,
|
||||||
|
add: addBasePath,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
const { mutate: restoreSnapshot, isPending: isRestoring } = useMutation({
|
const { mutate: restoreSnapshot, isPending: isRestoring } = useMutation({
|
||||||
...restoreSnapshotMutation(),
|
...restoreSnapshotMutation(),
|
||||||
@@ -198,54 +138,67 @@ export const SnapshotFileBrowser = (props: Props) => {
|
|||||||
<CardTitle>File Browser</CardTitle>
|
<CardTitle>File Browser</CardTitle>
|
||||||
<CardDescription>{`Viewing snapshot from ${new Date(snapshot?.time ?? 0).toLocaleString()}`}</CardDescription>
|
<CardDescription>{`Viewing snapshot from ${new Date(snapshot?.time ?? 0).toLocaleString()}`}</CardDescription>
|
||||||
</div>
|
</div>
|
||||||
{selectedPaths.size > 0 && (
|
<div className="flex gap-2">
|
||||||
<Tooltip>
|
{selectedPaths.size > 0 && (
|
||||||
<TooltipTrigger asChild>
|
<Tooltip>
|
||||||
<span tabIndex={isReadOnly ? 0 : undefined}>
|
<TooltipTrigger asChild>
|
||||||
<Button
|
<span tabIndex={isReadOnly ? 0 : undefined}>
|
||||||
onClick={handleRestoreClick}
|
<Button
|
||||||
variant="primary"
|
onClick={handleRestoreClick}
|
||||||
size="sm"
|
variant="primary"
|
||||||
disabled={isRestoring || isReadOnly}
|
size="sm"
|
||||||
>
|
disabled={isRestoring || isReadOnly}
|
||||||
{isRestoring
|
>
|
||||||
? "Restoring..."
|
{isRestoring
|
||||||
: `Restore ${selectedPaths.size} selected ${selectedPaths.size === 1 ? "item" : "items"}`}
|
? "Restoring..."
|
||||||
</Button>
|
: `Restore ${selectedPaths.size} selected ${selectedPaths.size === 1 ? "item" : "items"}`}
|
||||||
</span>
|
</Button>
|
||||||
</TooltipTrigger>
|
</span>
|
||||||
{isReadOnly && (
|
</TooltipTrigger>
|
||||||
<TooltipContent className="text-center">
|
{isReadOnly && (
|
||||||
<p>Volume is mounted as read-only.</p>
|
<TooltipContent className="text-center">
|
||||||
<p>Please remount with read-only disabled to restore files.</p>
|
<p>Volume is mounted as read-only.</p>
|
||||||
</TooltipContent>
|
<p>Please remount with read-only disabled to restore files.</p>
|
||||||
)}
|
</TooltipContent>
|
||||||
</Tooltip>
|
)}
|
||||||
)}
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
{onDeleteSnapshot && (
|
||||||
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => onDeleteSnapshot(snapshot.short_id)}
|
||||||
|
disabled={isDeletingSnapshot}
|
||||||
|
loading={isDeletingSnapshot}
|
||||||
|
>
|
||||||
|
{isDeletingSnapshot ? "Deleting..." : "Delete Snapshot"}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="flex-1 overflow-hidden flex flex-col p-0">
|
<CardContent className="flex-1 overflow-hidden flex flex-col p-0">
|
||||||
{filesLoading && fileArray.length === 0 && (
|
{fileBrowser.isLoading && (
|
||||||
<div className="flex items-center justify-center flex-1">
|
<div className="flex items-center justify-center flex-1">
|
||||||
<p className="text-muted-foreground">Loading files...</p>
|
<p className="text-muted-foreground">Loading files...</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{fileArray.length === 0 && !filesLoading && (
|
{fileBrowser.isEmpty && (
|
||||||
<div className="flex flex-col items-center justify-center flex-1 text-center p-8">
|
<div className="flex flex-col items-center justify-center flex-1 text-center p-8">
|
||||||
<FileIcon className="w-12 h-12 text-muted-foreground/50 mb-4" />
|
<FileIcon className="w-12 h-12 text-muted-foreground/50 mb-4" />
|
||||||
<p className="text-muted-foreground">No files in this snapshot</p>
|
<p className="text-muted-foreground">No files in this snapshot</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{fileArray.length > 0 && (
|
{!fileBrowser.isLoading && !fileBrowser.isEmpty && (
|
||||||
<div className="overflow-auto flex-1 border border-border rounded-md bg-card m-4">
|
<div className="overflow-auto flex-1 border border-border rounded-md bg-card m-4">
|
||||||
<FileTree
|
<FileTree
|
||||||
files={fileArray}
|
files={fileBrowser.fileArray}
|
||||||
onFolderExpand={handleFolderExpand}
|
onFolderExpand={fileBrowser.handleFolderExpand}
|
||||||
onFolderHover={handleFolderHover}
|
onFolderHover={fileBrowser.handleFolderHover}
|
||||||
expandedFolders={expandedFolders}
|
expandedFolders={fileBrowser.expandedFolders}
|
||||||
loadingFolders={loadingFolders}
|
loadingFolders={fileBrowser.loadingFolders}
|
||||||
className="px-2 py-2"
|
className="px-2 py-2"
|
||||||
withCheckboxes={true}
|
withCheckboxes={true}
|
||||||
selectedPaths={selectedPaths}
|
selectedPaths={selectedPaths}
|
||||||
|
|||||||
@@ -3,6 +3,16 @@ import { useQuery, useMutation } from "@tanstack/react-query";
|
|||||||
import { redirect, useNavigate } from "react-router";
|
import { redirect, useNavigate } from "react-router";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { Button } from "~/client/components/ui/button";
|
import { Button } from "~/client/components/ui/button";
|
||||||
|
import {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogTitle,
|
||||||
|
} from "~/client/components/ui/alert-dialog";
|
||||||
import {
|
import {
|
||||||
getBackupScheduleOptions,
|
getBackupScheduleOptions,
|
||||||
runBackupNowMutation,
|
runBackupNowMutation,
|
||||||
@@ -10,6 +20,7 @@ import {
|
|||||||
listSnapshotsOptions,
|
listSnapshotsOptions,
|
||||||
updateBackupScheduleMutation,
|
updateBackupScheduleMutation,
|
||||||
stopBackupMutation,
|
stopBackupMutation,
|
||||||
|
deleteSnapshotMutation,
|
||||||
} from "~/client/api-client/@tanstack/react-query.gen";
|
} from "~/client/api-client/@tanstack/react-query.gen";
|
||||||
import { parseError } from "~/client/lib/errors";
|
import { parseError } from "~/client/lib/errors";
|
||||||
import { getCronExpression } from "~/utils/utils";
|
import { getCronExpression } from "~/utils/utils";
|
||||||
@@ -20,9 +31,16 @@ import { SnapshotFileBrowser } from "../components/snapshot-file-browser";
|
|||||||
import { SnapshotTimeline } from "../components/snapshot-timeline";
|
import { SnapshotTimeline } from "../components/snapshot-timeline";
|
||||||
import { getBackupSchedule } from "~/client/api-client";
|
import { getBackupSchedule } from "~/client/api-client";
|
||||||
|
|
||||||
|
export const handle = {
|
||||||
|
breadcrumb: (match: Route.MetaArgs) => [
|
||||||
|
{ label: "Backups", href: "/backups" },
|
||||||
|
{ label: `Schedule #${match.params.id}` },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
export function meta(_: Route.MetaArgs) {
|
export function meta(_: Route.MetaArgs) {
|
||||||
return [
|
return [
|
||||||
{ title: "Backup Job Details" },
|
{ title: "Zerobyte - Backup Job Details" },
|
||||||
{
|
{
|
||||||
name: "description",
|
name: "description",
|
||||||
content: "View and manage backup job configuration, schedule, and snapshots.",
|
content: "View and manage backup job configuration, schedule, and snapshots.",
|
||||||
@@ -43,6 +61,8 @@ export default function ScheduleDetailsPage({ params, loaderData }: Route.Compon
|
|||||||
const [isEditMode, setIsEditMode] = useState(false);
|
const [isEditMode, setIsEditMode] = useState(false);
|
||||||
const formId = useId();
|
const formId = useId();
|
||||||
const [selectedSnapshotId, setSelectedSnapshotId] = useState<string>();
|
const [selectedSnapshotId, setSelectedSnapshotId] = useState<string>();
|
||||||
|
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
|
||||||
|
const [snapshotToDelete, setSnapshotToDelete] = useState<string | null>(null);
|
||||||
|
|
||||||
const { data: schedule } = useQuery({
|
const { data: schedule } = useQuery({
|
||||||
...getBackupScheduleOptions({ path: { scheduleId: params.id } }),
|
...getBackupScheduleOptions({ path: { scheduleId: params.id } }),
|
||||||
@@ -103,6 +123,17 @@ export default function ScheduleDetailsPage({ params, loaderData }: Route.Compon
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const deleteSnapshot = useMutation({
|
||||||
|
...deleteSnapshotMutation(),
|
||||||
|
onSuccess: () => {
|
||||||
|
setShowDeleteConfirm(false);
|
||||||
|
setSnapshotToDelete(null);
|
||||||
|
if (selectedSnapshotId === snapshotToDelete) {
|
||||||
|
setSelectedSnapshotId(undefined);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
const handleSubmit = (formValues: BackupScheduleFormValues) => {
|
const handleSubmit = (formValues: BackupScheduleFormValues) => {
|
||||||
if (!schedule) return;
|
if (!schedule) return;
|
||||||
|
|
||||||
@@ -125,6 +156,7 @@ export default function ScheduleDetailsPage({ params, loaderData }: Route.Compon
|
|||||||
retentionPolicy: Object.keys(retentionPolicy).length > 0 ? retentionPolicy : undefined,
|
retentionPolicy: Object.keys(retentionPolicy).length > 0 ? retentionPolicy : undefined,
|
||||||
includePatterns: formValues.includePatterns,
|
includePatterns: formValues.includePatterns,
|
||||||
excludePatterns: formValues.excludePatterns,
|
excludePatterns: formValues.excludePatterns,
|
||||||
|
limitUploadKbps: formValues.limitUploadKbps,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
@@ -139,10 +171,31 @@ export default function ScheduleDetailsPage({ params, loaderData }: Route.Compon
|
|||||||
retentionPolicy: schedule.retentionPolicy || undefined,
|
retentionPolicy: schedule.retentionPolicy || undefined,
|
||||||
includePatterns: schedule.includePatterns || undefined,
|
includePatterns: schedule.includePatterns || undefined,
|
||||||
excludePatterns: schedule.excludePatterns || 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) {
|
if (isEditMode) {
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
@@ -184,8 +237,32 @@ export default function ScheduleDetailsPage({ params, loaderData }: Route.Compon
|
|||||||
snapshot={selectedSnapshot}
|
snapshot={selectedSnapshot}
|
||||||
repositoryName={schedule.repository.name}
|
repositoryName={schedule.repository.name}
|
||||||
volume={schedule.volume}
|
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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,9 +9,13 @@ import type { Route } from "./+types/backups";
|
|||||||
import { listBackupSchedules } from "~/client/api-client";
|
import { listBackupSchedules } from "~/client/api-client";
|
||||||
import { listBackupSchedulesOptions } from "~/client/api-client/@tanstack/react-query.gen";
|
import { listBackupSchedulesOptions } from "~/client/api-client/@tanstack/react-query.gen";
|
||||||
|
|
||||||
|
export const handle = {
|
||||||
|
breadcrumb: () => [{ label: "Backups" }],
|
||||||
|
};
|
||||||
|
|
||||||
export function meta(_: Route.MetaArgs) {
|
export function meta(_: Route.MetaArgs) {
|
||||||
return [
|
return [
|
||||||
{ title: "Backup Jobs" },
|
{ title: "Zerobyte - Backup Jobs" },
|
||||||
{
|
{
|
||||||
name: "description",
|
name: "description",
|
||||||
content: "Automate volume backups with scheduled jobs and retention policies.",
|
content: "Automate volume backups with scheduled jobs and retention policies.",
|
||||||
|
|||||||
@@ -18,9 +18,13 @@ import { CreateScheduleForm, type BackupScheduleFormValues } from "../components
|
|||||||
import type { Route } from "./+types/create-backup";
|
import type { Route } from "./+types/create-backup";
|
||||||
import { listRepositories, listVolumes } from "~/client/api-client";
|
import { listRepositories, listVolumes } from "~/client/api-client";
|
||||||
|
|
||||||
|
export const handle = {
|
||||||
|
breadcrumb: () => [{ label: "Backups", href: "/backups" }, { label: "Create" }],
|
||||||
|
};
|
||||||
|
|
||||||
export function meta(_: Route.MetaArgs) {
|
export function meta(_: Route.MetaArgs) {
|
||||||
return [
|
return [
|
||||||
{ title: "Create Backup Job" },
|
{ title: "Zerobyte - Create Backup Job" },
|
||||||
{
|
{
|
||||||
name: "description",
|
name: "description",
|
||||||
content: "Create a new automated backup job for your volumes.",
|
content: "Create a new automated backup job for your volumes.",
|
||||||
@@ -86,6 +90,7 @@ export default function CreateBackup({ loaderData }: Route.ComponentProps) {
|
|||||||
retentionPolicy: Object.keys(retentionPolicy).length > 0 ? retentionPolicy : undefined,
|
retentionPolicy: Object.keys(retentionPolicy).length > 0 ? retentionPolicy : undefined,
|
||||||
includePatterns: formValues.includePatterns,
|
includePatterns: formValues.includePatterns,
|
||||||
excludePatterns: formValues.excludePatterns,
|
excludePatterns: formValues.excludePatterns,
|
||||||
|
limitUploadKbps: formValues.limitUploadKbps,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|||||||
89
app/client/modules/repositories/routes/create-repository.tsx
Normal file
89
app/client/modules/repositories/routes/create-repository.tsx
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
import { useMutation } from "@tanstack/react-query";
|
||||||
|
import { Database } from "lucide-react";
|
||||||
|
import { useId } from "react";
|
||||||
|
import { useNavigate } from "react-router";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { createRepositoryMutation } from "~/client/api-client/@tanstack/react-query.gen";
|
||||||
|
import { CreateRepositoryForm, type RepositoryFormValues } from "~/client/components/create-repository-form";
|
||||||
|
import { Button } from "~/client/components/ui/button";
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "~/client/components/ui/card";
|
||||||
|
import { parseError } from "~/client/lib/errors";
|
||||||
|
import type { Route } from "./+types/create-repository";
|
||||||
|
import { Alert, AlertDescription } from "~/client/components/ui/alert";
|
||||||
|
|
||||||
|
export const handle = {
|
||||||
|
breadcrumb: () => [{ label: "Repositories", href: "/repositories" }, { label: "Create" }],
|
||||||
|
};
|
||||||
|
|
||||||
|
export function meta(_: Route.MetaArgs) {
|
||||||
|
return [
|
||||||
|
{ title: "Zerobyte - Create Repository" },
|
||||||
|
{
|
||||||
|
name: "description",
|
||||||
|
content: "Create a new backup repository with encryption and compression.",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function CreateRepository() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const formId = useId();
|
||||||
|
|
||||||
|
const createRepository = useMutation({
|
||||||
|
...createRepositoryMutation(),
|
||||||
|
onSuccess: (data) => {
|
||||||
|
toast.success("Repository created successfully");
|
||||||
|
navigate(`/repositories/${data.repository.name}`);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleSubmit = (values: RepositoryFormValues) => {
|
||||||
|
createRepository.mutate({
|
||||||
|
body: {
|
||||||
|
config: values,
|
||||||
|
name: values.name,
|
||||||
|
compressionMode: values.compressionMode,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="container mx-auto space-y-6">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="flex items-center justify-center w-10 h-10 rounded-lg bg-primary/10">
|
||||||
|
<Database className="w-5 h-5 text-primary" />
|
||||||
|
</div>
|
||||||
|
<CardTitle>Create Repository</CardTitle>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-6">
|
||||||
|
{createRepository.isError && (
|
||||||
|
<Alert variant="destructive">
|
||||||
|
<AlertDescription>
|
||||||
|
<strong>Failed to create repository:</strong>
|
||||||
|
<br />
|
||||||
|
{parseError(createRepository.error)?.message}
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
<CreateRepositoryForm
|
||||||
|
mode="create"
|
||||||
|
formId={formId}
|
||||||
|
onSubmit={handleSubmit}
|
||||||
|
loading={createRepository.isPending}
|
||||||
|
/>
|
||||||
|
<div className="flex justify-end gap-2 pt-4 border-t">
|
||||||
|
<Button type="button" variant="secondary" onClick={() => navigate("/repositories")}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" form={formId} loading={createRepository.isPending}>
|
||||||
|
Create Repository
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,10 +1,9 @@
|
|||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { Database, RotateCcw } from "lucide-react";
|
import { Database, Plus, RotateCcw } from "lucide-react";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useNavigate } from "react-router";
|
import { useNavigate } from "react-router";
|
||||||
import { listRepositories } from "~/client/api-client/sdk.gen";
|
import { listRepositories } from "~/client/api-client/sdk.gen";
|
||||||
import { listRepositoriesOptions } from "~/client/api-client/@tanstack/react-query.gen";
|
import { listRepositoriesOptions } from "~/client/api-client/@tanstack/react-query.gen";
|
||||||
import { CreateRepositoryDialog } from "~/client/components/create-repository-dialog";
|
|
||||||
import { RepositoryIcon } from "~/client/components/repository-icon";
|
import { RepositoryIcon } from "~/client/components/repository-icon";
|
||||||
import { Button } from "~/client/components/ui/button";
|
import { Button } from "~/client/components/ui/button";
|
||||||
import { Card } from "~/client/components/ui/card";
|
import { Card } from "~/client/components/ui/card";
|
||||||
@@ -15,9 +14,13 @@ import type { Route } from "./+types/repositories";
|
|||||||
import { cn } from "~/client/lib/utils";
|
import { cn } from "~/client/lib/utils";
|
||||||
import { EmptyState } from "~/client/components/empty-state";
|
import { EmptyState } from "~/client/components/empty-state";
|
||||||
|
|
||||||
|
export const handle = {
|
||||||
|
breadcrumb: () => [{ label: "Repositories" }],
|
||||||
|
};
|
||||||
|
|
||||||
export function meta(_: Route.MetaArgs) {
|
export function meta(_: Route.MetaArgs) {
|
||||||
return [
|
return [
|
||||||
{ title: "Repositories" },
|
{ title: "Zerobyte - Repositories" },
|
||||||
{
|
{
|
||||||
name: "description",
|
name: "description",
|
||||||
content: "Manage your backup repositories with encryption and compression.",
|
content: "Manage your backup repositories with encryption and compression.",
|
||||||
@@ -35,7 +38,6 @@ export default function Repositories({ loaderData }: Route.ComponentProps) {
|
|||||||
const [searchQuery, setSearchQuery] = useState("");
|
const [searchQuery, setSearchQuery] = useState("");
|
||||||
const [statusFilter, setStatusFilter] = useState("");
|
const [statusFilter, setStatusFilter] = useState("");
|
||||||
const [backendFilter, setBackendFilter] = useState("");
|
const [backendFilter, setBackendFilter] = useState("");
|
||||||
const [createRepositoryOpen, setCreateRepositoryOpen] = useState(false);
|
|
||||||
|
|
||||||
const clearFilters = () => {
|
const clearFilters = () => {
|
||||||
setSearchQuery("");
|
setSearchQuery("");
|
||||||
@@ -69,7 +71,12 @@ export default function Repositories({ loaderData }: Route.ComponentProps) {
|
|||||||
icon={Database}
|
icon={Database}
|
||||||
title="No repository"
|
title="No repository"
|
||||||
description="Repositories are remote storage locations where you can backup your volumes securely. Encrypted and optimized for storage efficiency."
|
description="Repositories are remote storage locations where you can backup your volumes securely. Encrypted and optimized for storage efficiency."
|
||||||
button={<CreateRepositoryDialog open={createRepositoryOpen} setOpen={setCreateRepositoryOpen} />}
|
button={
|
||||||
|
<Button onClick={() => navigate("/repositories/create")}>
|
||||||
|
<Plus size={16} className="mr-2" />
|
||||||
|
Create Repository
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -112,7 +119,10 @@ export default function Repositories({ loaderData }: Route.ComponentProps) {
|
|||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</span>
|
</span>
|
||||||
<CreateRepositoryDialog open={createRepositoryOpen} setOpen={setCreateRepositoryOpen} />
|
<Button onClick={() => navigate("/repositories/create")}>
|
||||||
|
<Plus size={16} className="mr-2" />
|
||||||
|
Create Repository
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
<Table className="border-t">
|
<Table className="border-t">
|
||||||
|
|||||||
@@ -27,9 +27,16 @@ import { RepositoryInfoTabContent } from "../tabs/info";
|
|||||||
import { RepositorySnapshotsTabContent } from "../tabs/snapshots";
|
import { RepositorySnapshotsTabContent } from "../tabs/snapshots";
|
||||||
import { Loader2 } from "lucide-react";
|
import { Loader2 } from "lucide-react";
|
||||||
|
|
||||||
|
export const handle = {
|
||||||
|
breadcrumb: (match: Route.MetaArgs) => [
|
||||||
|
{ label: "Repositories", href: "/repositories" },
|
||||||
|
{ label: match.params.name },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
export function meta({ params }: Route.MetaArgs) {
|
export function meta({ params }: Route.MetaArgs) {
|
||||||
return [
|
return [
|
||||||
{ title: params.name },
|
{ title: `Zerobyte - ${params.name}` },
|
||||||
{
|
{
|
||||||
name: "description",
|
name: "description",
|
||||||
content: "View repository configuration, status, and snapshots.",
|
content: "View repository configuration, status, and snapshots.",
|
||||||
@@ -174,8 +181,8 @@ export default function RepositoryDetailsPage({ loaderData }: Route.ComponentPro
|
|||||||
<AlertDialogHeader>
|
<AlertDialogHeader>
|
||||||
<AlertDialogTitle>Delete repository?</AlertDialogTitle>
|
<AlertDialogTitle>Delete repository?</AlertDialogTitle>
|
||||||
<AlertDialogDescription>
|
<AlertDialogDescription>
|
||||||
Are you sure you want to delete the repository <strong>{data.name}</strong>? This action cannot be undone
|
Are you sure you want to delete the repository <strong>{data.name}</strong>? This will not remove the
|
||||||
and will remove all backup data.
|
actual data from the backend storage, only the repository configuration will be deleted.
|
||||||
</AlertDialogDescription>
|
</AlertDialogDescription>
|
||||||
</AlertDialogHeader>
|
</AlertDialogHeader>
|
||||||
<div className="flex gap-3 justify-end">
|
<div className="flex gap-3 justify-end">
|
||||||
|
|||||||
@@ -7,9 +7,17 @@ import { SnapshotFileBrowser } from "~/client/modules/backups/components/snapsho
|
|||||||
import { getSnapshotDetails } from "~/client/api-client";
|
import { getSnapshotDetails } from "~/client/api-client";
|
||||||
import type { Route } from "./+types/snapshot-details";
|
import type { Route } from "./+types/snapshot-details";
|
||||||
|
|
||||||
|
export const handle = {
|
||||||
|
breadcrumb: (match: Route.MetaArgs) => [
|
||||||
|
{ label: "Repositories", href: "/repositories" },
|
||||||
|
{ label: match.params.name, href: `/repositories/${match.params.name}` },
|
||||||
|
{ label: match.params.snapshotId },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
export function meta({ params }: Route.MetaArgs) {
|
export function meta({ params }: Route.MetaArgs) {
|
||||||
return [
|
return [
|
||||||
{ title: `Snapshot ${params.snapshotId}` },
|
{ title: `Zerobyte - Snapshot ${params.snapshotId}` },
|
||||||
{
|
{
|
||||||
name: "description",
|
name: "description",
|
||||||
content: "Browse and restore files from a backup snapshot.",
|
content: "Browse and restore files from a backup snapshot.",
|
||||||
|
|||||||
@@ -24,9 +24,13 @@ import {
|
|||||||
logoutMutation,
|
logoutMutation,
|
||||||
} from "~/client/api-client/@tanstack/react-query.gen";
|
} from "~/client/api-client/@tanstack/react-query.gen";
|
||||||
|
|
||||||
|
export const handle = {
|
||||||
|
breadcrumb: () => [{ label: "Settings" }],
|
||||||
|
};
|
||||||
|
|
||||||
export function meta(_: Route.MetaArgs) {
|
export function meta(_: Route.MetaArgs) {
|
||||||
return [
|
return [
|
||||||
{ title: "Settings" },
|
{ title: "Zerobyte - Settings" },
|
||||||
{
|
{
|
||||||
name: "description",
|
name: "description",
|
||||||
content: "Manage your account settings and preferences.",
|
content: "Manage your account settings and preferences.",
|
||||||
|
|||||||
83
app/client/modules/volumes/routes/create-volume.tsx
Normal file
83
app/client/modules/volumes/routes/create-volume.tsx
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
import { useMutation } from "@tanstack/react-query";
|
||||||
|
import { HardDrive } from "lucide-react";
|
||||||
|
import { useId } from "react";
|
||||||
|
import { useNavigate } from "react-router";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { createVolumeMutation } from "~/client/api-client/@tanstack/react-query.gen";
|
||||||
|
import { CreateVolumeForm, type FormValues } from "~/client/components/create-volume-form";
|
||||||
|
import { Button } from "~/client/components/ui/button";
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "~/client/components/ui/card";
|
||||||
|
import { parseError } from "~/client/lib/errors";
|
||||||
|
import type { Route } from "./+types/create-volume";
|
||||||
|
import { Alert, AlertDescription } from "~/client/components/ui/alert";
|
||||||
|
|
||||||
|
export const handle = {
|
||||||
|
breadcrumb: () => [{ label: "Volumes", href: "/volumes" }, { label: "Create" }],
|
||||||
|
};
|
||||||
|
|
||||||
|
export function meta(_: Route.MetaArgs) {
|
||||||
|
return [
|
||||||
|
{ title: "Zerobyte - Create Volume" },
|
||||||
|
{
|
||||||
|
name: "description",
|
||||||
|
content: "Create a new storage volume with automatic mounting and health checks.",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function CreateVolume() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const formId = useId();
|
||||||
|
|
||||||
|
const createVolume = useMutation({
|
||||||
|
...createVolumeMutation(),
|
||||||
|
onSuccess: (data) => {
|
||||||
|
toast.success("Volume created successfully");
|
||||||
|
navigate(`/volumes/${data.name}`);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleSubmit = (values: FormValues) => {
|
||||||
|
createVolume.mutate({
|
||||||
|
body: {
|
||||||
|
config: values,
|
||||||
|
name: values.name,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="container mx-auto space-y-6">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="flex items-center justify-center w-10 h-10 rounded-lg bg-primary/10">
|
||||||
|
<HardDrive className="w-5 h-5 text-primary" />
|
||||||
|
</div>
|
||||||
|
<CardTitle>Create Volume</CardTitle>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-6">
|
||||||
|
{createVolume.isError && (
|
||||||
|
<Alert variant="destructive">
|
||||||
|
<AlertDescription>
|
||||||
|
<strong>Failed to create volume:</strong>
|
||||||
|
<br />
|
||||||
|
{parseError(createVolume.error)?.message}
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
<CreateVolumeForm mode="create" formId={formId} onSubmit={handleSubmit} loading={createVolume.isPending} />
|
||||||
|
<div className="flex justify-end gap-2 pt-4 border-t">
|
||||||
|
<Button type="button" variant="secondary" onClick={() => navigate("/volumes")}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" form={formId} loading={createVolume.isPending}>
|
||||||
|
Create Volume
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -31,9 +31,13 @@ import {
|
|||||||
unmountVolumeMutation,
|
unmountVolumeMutation,
|
||||||
} from "~/client/api-client/@tanstack/react-query.gen";
|
} from "~/client/api-client/@tanstack/react-query.gen";
|
||||||
|
|
||||||
|
export const handle = {
|
||||||
|
breadcrumb: (match: Route.MetaArgs) => [{ label: "Volumes", href: "/volumes" }, { label: match.params.name }],
|
||||||
|
};
|
||||||
|
|
||||||
export function meta({ params }: Route.MetaArgs) {
|
export function meta({ params }: Route.MetaArgs) {
|
||||||
return [
|
return [
|
||||||
{ title: params.name },
|
{ title: `Zerobyte - ${params.name}` },
|
||||||
{
|
{
|
||||||
name: "description",
|
name: "description",
|
||||||
content: "View and manage volume details, configuration, and files.",
|
content: "View and manage volume details, configuration, and files.",
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { HardDrive, RotateCcw } from "lucide-react";
|
import { HardDrive, Plus, RotateCcw } from "lucide-react";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useNavigate } from "react-router";
|
import { useNavigate } from "react-router";
|
||||||
import { CreateVolumeDialog } from "~/client/components/create-volume-dialog";
|
|
||||||
import { EmptyState } from "~/client/components/empty-state";
|
import { EmptyState } from "~/client/components/empty-state";
|
||||||
import { StatusDot } from "~/client/components/status-dot";
|
import { StatusDot } from "~/client/components/status-dot";
|
||||||
import { Button } from "~/client/components/ui/button";
|
import { Button } from "~/client/components/ui/button";
|
||||||
@@ -15,9 +14,13 @@ import type { Route } from "./+types/volumes";
|
|||||||
import { listVolumes } from "~/client/api-client";
|
import { listVolumes } from "~/client/api-client";
|
||||||
import { listVolumesOptions } from "~/client/api-client/@tanstack/react-query.gen";
|
import { listVolumesOptions } from "~/client/api-client/@tanstack/react-query.gen";
|
||||||
|
|
||||||
|
export const handle = {
|
||||||
|
breadcrumb: () => [{ label: "Volumes" }],
|
||||||
|
};
|
||||||
|
|
||||||
export function meta(_: Route.MetaArgs) {
|
export function meta(_: Route.MetaArgs) {
|
||||||
return [
|
return [
|
||||||
{ title: "Volumes" },
|
{ title: "Zerobyte - Volumes" },
|
||||||
{
|
{
|
||||||
name: "description",
|
name: "description",
|
||||||
content: "Create, manage, monitor, and automate your Docker volumes with ease.",
|
content: "Create, manage, monitor, and automate your Docker volumes with ease.",
|
||||||
@@ -32,7 +35,6 @@ export const clientLoader = async () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export default function Volumes({ loaderData }: Route.ComponentProps) {
|
export default function Volumes({ loaderData }: Route.ComponentProps) {
|
||||||
const [createVolumeOpen, setCreateVolumeOpen] = useState(false);
|
|
||||||
const [searchQuery, setSearchQuery] = useState("");
|
const [searchQuery, setSearchQuery] = useState("");
|
||||||
const [statusFilter, setStatusFilter] = useState("");
|
const [statusFilter, setStatusFilter] = useState("");
|
||||||
const [backendFilter, setBackendFilter] = useState("");
|
const [backendFilter, setBackendFilter] = useState("");
|
||||||
@@ -69,7 +71,12 @@ export default function Volumes({ loaderData }: Route.ComponentProps) {
|
|||||||
icon={HardDrive}
|
icon={HardDrive}
|
||||||
title="No volume"
|
title="No volume"
|
||||||
description="Manage and monitor all your storage backends in one place with advanced features like automatic mounting and health checks."
|
description="Manage and monitor all your storage backends in one place with advanced features like automatic mounting and health checks."
|
||||||
button={<CreateVolumeDialog open={createVolumeOpen} setOpen={setCreateVolumeOpen} />}
|
button={
|
||||||
|
<Button onClick={() => navigate("/volumes/create")}>
|
||||||
|
<Plus size={16} className="mr-2" />
|
||||||
|
Create Volume
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -111,7 +118,10 @@ export default function Volumes({ loaderData }: Route.ComponentProps) {
|
|||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</span>
|
</span>
|
||||||
<CreateVolumeDialog open={createVolumeOpen} setOpen={setCreateVolumeOpen} />
|
<Button onClick={() => navigate("/volumes/create")}>
|
||||||
|
<Plus size={16} className="mr-2" />
|
||||||
|
Create Volume
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
<Table className="border-t">
|
<Table className="border-t">
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import { redirect } from "react-router";
|
import { redirect } from "react-router";
|
||||||
|
|
||||||
export const loader = async () => {
|
|
||||||
return redirect("/volumes");
|
|
||||||
};
|
|
||||||
|
|
||||||
export const clientLoader = async () => {
|
export const clientLoader = async () => {
|
||||||
return redirect("/volumes");
|
return redirect("/volumes");
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const loader = async () => {
|
||||||
|
return redirect("/volumes");
|
||||||
|
};
|
||||||
|
|||||||
1
app/drizzle/0011_lazy_havok.sql
Normal file
1
app/drizzle/0011_lazy_havok.sql
Normal file
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE `backup_schedules_table` ADD `limit_upload_kbps` integer;
|
||||||
466
app/drizzle/meta/0011_snapshot.json
Normal file
466
app/drizzle/meta/0011_snapshot.json
Normal file
@@ -0,0 +1,466 @@
|
|||||||
|
{
|
||||||
|
"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,83 +1,90 @@
|
|||||||
{
|
{
|
||||||
"version": "7",
|
"version": "7",
|
||||||
"dialect": "sqlite",
|
"dialect": "sqlite",
|
||||||
"entries": [
|
"entries": [
|
||||||
{
|
{
|
||||||
"idx": 0,
|
"idx": 0,
|
||||||
"version": "6",
|
"version": "6",
|
||||||
"when": 1755765658194,
|
"when": 1755765658194,
|
||||||
"tag": "0000_known_madelyne_pryor",
|
"tag": "0000_known_madelyne_pryor",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"idx": 1,
|
"idx": 1,
|
||||||
"version": "6",
|
"version": "6",
|
||||||
"when": 1755775437391,
|
"when": 1755775437391,
|
||||||
"tag": "0001_far_frank_castle",
|
"tag": "0001_far_frank_castle",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"idx": 2,
|
"idx": 2,
|
||||||
"version": "6",
|
"version": "6",
|
||||||
"when": 1756930554198,
|
"when": 1756930554198,
|
||||||
"tag": "0002_cheerful_randall",
|
"tag": "0002_cheerful_randall",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"idx": 3,
|
"idx": 3,
|
||||||
"version": "6",
|
"version": "6",
|
||||||
"when": 1758653407064,
|
"when": 1758653407064,
|
||||||
"tag": "0003_mature_hellcat",
|
"tag": "0003_mature_hellcat",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"idx": 4,
|
"idx": 4,
|
||||||
"version": "6",
|
"version": "6",
|
||||||
"when": 1758961535488,
|
"when": 1758961535488,
|
||||||
"tag": "0004_wealthy_tomas",
|
"tag": "0004_wealthy_tomas",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"idx": 5,
|
"idx": 5,
|
||||||
"version": "6",
|
"version": "6",
|
||||||
"when": 1759416698274,
|
"when": 1759416698274,
|
||||||
"tag": "0005_simple_alice",
|
"tag": "0005_simple_alice",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"idx": 6,
|
"idx": 6,
|
||||||
"version": "6",
|
"version": "6",
|
||||||
"when": 1760734377440,
|
"when": 1760734377440,
|
||||||
"tag": "0006_secret_micromacro",
|
"tag": "0006_secret_micromacro",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"idx": 7,
|
"idx": 7,
|
||||||
"version": "6",
|
"version": "6",
|
||||||
"when": 1761224911352,
|
"when": 1761224911352,
|
||||||
"tag": "0007_watery_sersi",
|
"tag": "0007_watery_sersi",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"idx": 8,
|
"idx": 8,
|
||||||
"version": "6",
|
"version": "6",
|
||||||
"when": 1761414054481,
|
"when": 1761414054481,
|
||||||
"tag": "0008_silent_lady_bullseye",
|
"tag": "0008_silent_lady_bullseye",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"idx": 9,
|
"idx": 9,
|
||||||
"version": "6",
|
"version": "6",
|
||||||
"when": 1762095226041,
|
"when": 1762095226041,
|
||||||
"tag": "0009_little_adam_warlock",
|
"tag": "0009_little_adam_warlock",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"idx": 10,
|
"idx": 10,
|
||||||
"version": "6",
|
"version": "6",
|
||||||
"when": 1762610065889,
|
"when": 1762610065889,
|
||||||
"tag": "0010_perfect_proemial_gods",
|
"tag": "0010_perfect_proemial_gods",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
}
|
},
|
||||||
]
|
{
|
||||||
}
|
"idx": 11,
|
||||||
|
"version": "6",
|
||||||
|
"when": 1763728410318,
|
||||||
|
"tag": "0011_lazy_havok",
|
||||||
|
"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="icon" type="image/svg+xml" href="/images/favicon/favicon.svg" />
|
||||||
<link rel="shortcut icon" href="/images/favicon/favicon.ico" />
|
<link rel="shortcut icon" href="/images/favicon/favicon.ico" />
|
||||||
<link rel="apple-touch-icon" sizes="180x180" href="/images/favicon/apple-touch-icon.png" />
|
<link rel="apple-touch-icon" sizes="180x180" href="/images/favicon/apple-touch-icon.png" />
|
||||||
<meta name="apple-mobile-web-app-title" content="Ironmount" />
|
<meta name="apple-mobile-web-app-title" content="Zerobyte" />
|
||||||
<link rel="manifest" href="/images/favicon/site.webmanifest" />
|
<link rel="manifest" href="/images/favicon/site.webmanifest" />
|
||||||
<Meta />
|
<Meta />
|
||||||
<Links />
|
<Links />
|
||||||
|
|||||||
@@ -7,11 +7,13 @@ export default [
|
|||||||
layout("./client/components/layout.tsx", [
|
layout("./client/components/layout.tsx", [
|
||||||
route("/", "./client/routes/root.tsx"),
|
route("/", "./client/routes/root.tsx"),
|
||||||
route("volumes", "./client/modules/volumes/routes/volumes.tsx"),
|
route("volumes", "./client/modules/volumes/routes/volumes.tsx"),
|
||||||
|
route("volumes/create", "./client/modules/volumes/routes/create-volume.tsx"),
|
||||||
route("volumes/:name", "./client/modules/volumes/routes/volume-details.tsx"),
|
route("volumes/:name", "./client/modules/volumes/routes/volume-details.tsx"),
|
||||||
route("backups", "./client/modules/backups/routes/backups.tsx"),
|
route("backups", "./client/modules/backups/routes/backups.tsx"),
|
||||||
route("backups/create", "./client/modules/backups/routes/create-backup.tsx"),
|
route("backups/create", "./client/modules/backups/routes/create-backup.tsx"),
|
||||||
route("backups/:id", "./client/modules/backups/routes/backup-details.tsx"),
|
route("backups/:id", "./client/modules/backups/routes/backup-details.tsx"),
|
||||||
route("repositories", "./client/modules/repositories/routes/repositories.tsx"),
|
route("repositories", "./client/modules/repositories/routes/repositories.tsx"),
|
||||||
|
route("repositories/create", "./client/modules/repositories/routes/create-repository.tsx"),
|
||||||
route("repositories/:name", "./client/modules/repositories/routes/repository-details.tsx"),
|
route("repositories/:name", "./client/modules/repositories/routes/repository-details.tsx"),
|
||||||
route("repositories/:name/:snapshotId", "./client/modules/repositories/routes/snapshot-details.tsx"),
|
route("repositories/:name/:snapshotId", "./client/modules/repositories/routes/snapshot-details.tsx"),
|
||||||
route("settings", "./client/modules/settings/routes/settings.tsx"),
|
route("settings", "./client/modules/settings/routes/settings.tsx"),
|
||||||
|
|||||||
1
app/schemas/node_modules/arktype
generated
vendored
1
app/schemas/node_modules/arktype
generated
vendored
@@ -1 +0,0 @@
|
|||||||
../../../node_modules/.bun/arktype@2.1.26/node_modules/arktype
|
|
||||||
@@ -3,32 +3,50 @@ import { type } from "arktype";
|
|||||||
export const REPOSITORY_BACKENDS = {
|
export const REPOSITORY_BACKENDS = {
|
||||||
local: "local",
|
local: "local",
|
||||||
s3: "s3",
|
s3: "s3",
|
||||||
|
r2: "r2",
|
||||||
gcs: "gcs",
|
gcs: "gcs",
|
||||||
azure: "azure",
|
azure: "azure",
|
||||||
rclone: "rclone",
|
rclone: "rclone",
|
||||||
|
rest: "rest",
|
||||||
|
sftp: "sftp",
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
export type RepositoryBackend = keyof typeof REPOSITORY_BACKENDS;
|
export type RepositoryBackend = keyof typeof REPOSITORY_BACKENDS;
|
||||||
|
|
||||||
|
// Common fields for all repository configs
|
||||||
|
const baseRepositoryConfigSchema = type({
|
||||||
|
isExistingRepository: "boolean?",
|
||||||
|
customPassword: "string?",
|
||||||
|
});
|
||||||
|
|
||||||
export const s3RepositoryConfigSchema = type({
|
export const s3RepositoryConfigSchema = type({
|
||||||
backend: "'s3'",
|
backend: "'s3'",
|
||||||
endpoint: "string",
|
endpoint: "string",
|
||||||
bucket: "string",
|
bucket: "string",
|
||||||
accessKeyId: "string",
|
accessKeyId: "string",
|
||||||
secretAccessKey: "string",
|
secretAccessKey: "string",
|
||||||
});
|
}).and(baseRepositoryConfigSchema);
|
||||||
|
|
||||||
|
export const r2RepositoryConfigSchema = type({
|
||||||
|
backend: "'r2'",
|
||||||
|
endpoint: "string",
|
||||||
|
bucket: "string",
|
||||||
|
accessKeyId: "string",
|
||||||
|
secretAccessKey: "string",
|
||||||
|
}).and(baseRepositoryConfigSchema);
|
||||||
|
|
||||||
export const localRepositoryConfigSchema = type({
|
export const localRepositoryConfigSchema = type({
|
||||||
backend: "'local'",
|
backend: "'local'",
|
||||||
name: "string",
|
name: "string",
|
||||||
});
|
path: "string?",
|
||||||
|
}).and(baseRepositoryConfigSchema);
|
||||||
|
|
||||||
export const gcsRepositoryConfigSchema = type({
|
export const gcsRepositoryConfigSchema = type({
|
||||||
backend: "'gcs'",
|
backend: "'gcs'",
|
||||||
bucket: "string",
|
bucket: "string",
|
||||||
projectId: "string",
|
projectId: "string",
|
||||||
credentialsJson: "string",
|
credentialsJson: "string",
|
||||||
});
|
}).and(baseRepositoryConfigSchema);
|
||||||
|
|
||||||
export const azureRepositoryConfigSchema = type({
|
export const azureRepositoryConfigSchema = type({
|
||||||
backend: "'azure'",
|
backend: "'azure'",
|
||||||
@@ -36,19 +54,39 @@ export const azureRepositoryConfigSchema = type({
|
|||||||
accountName: "string",
|
accountName: "string",
|
||||||
accountKey: "string",
|
accountKey: "string",
|
||||||
endpointSuffix: "string?",
|
endpointSuffix: "string?",
|
||||||
});
|
}).and(baseRepositoryConfigSchema);
|
||||||
|
|
||||||
export const rcloneRepositoryConfigSchema = type({
|
export const rcloneRepositoryConfigSchema = type({
|
||||||
backend: "'rclone'",
|
backend: "'rclone'",
|
||||||
remote: "string",
|
remote: "string",
|
||||||
path: "string",
|
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
|
export const repositoryConfigSchema = s3RepositoryConfigSchema
|
||||||
|
.or(r2RepositoryConfigSchema)
|
||||||
.or(localRepositoryConfigSchema)
|
.or(localRepositoryConfigSchema)
|
||||||
.or(gcsRepositoryConfigSchema)
|
.or(gcsRepositoryConfigSchema)
|
||||||
.or(azureRepositoryConfigSchema)
|
.or(azureRepositoryConfigSchema)
|
||||||
.or(rcloneRepositoryConfigSchema);
|
.or(rcloneRepositoryConfigSchema)
|
||||||
|
.or(restRepositoryConfigSchema)
|
||||||
|
.or(sftpRepositoryConfigSchema);
|
||||||
|
|
||||||
export type RepositoryConfig = typeof repositoryConfigSchema.infer;
|
export type RepositoryConfig = typeof repositoryConfigSchema.infer;
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
export const OPERATION_TIMEOUT = 5000;
|
export const OPERATION_TIMEOUT = 5000;
|
||||||
export const VOLUME_MOUNT_BASE = "/var/lib/ironmount/volumes";
|
export const VOLUME_MOUNT_BASE = "/var/lib/zerobyte/volumes";
|
||||||
export const REPOSITORY_BASE = "/var/lib/ironmount/repositories";
|
export const REPOSITORY_BASE = "/var/lib/zerobyte/repositories";
|
||||||
export const DATABASE_URL = "/var/lib/ironmount/data/ironmount.db";
|
export const DATABASE_URL = "/var/lib/zerobyte/data/ironmount.db";
|
||||||
export const RESTIC_PASS_FILE = "/var/lib/ironmount/data/restic.pass";
|
export const RESTIC_PASS_FILE = "/var/lib/zerobyte/data/restic.pass";
|
||||||
export const SOCKET_PATH = "/run/docker/plugins/ironmount.sock";
|
export const SOCKET_PATH = "/run/docker/plugins/zerobyte.sock";
|
||||||
|
|||||||
@@ -39,6 +39,14 @@ class SchedulerClass {
|
|||||||
this.tasks = [];
|
this.tasks = [];
|
||||||
logger.info("Scheduler stopped");
|
logger.info("Scheduler stopped");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async clear() {
|
||||||
|
for (const task of this.tasks) {
|
||||||
|
task.destroy();
|
||||||
|
}
|
||||||
|
this.tasks = [];
|
||||||
|
logger.info("Scheduler cleared all tasks");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const Scheduler = new SchedulerClass();
|
export const Scheduler = new SchedulerClass();
|
||||||
|
|||||||
@@ -83,6 +83,7 @@ export const backupSchedulesTable = sqliteTable("backup_schedules_table", {
|
|||||||
}>(),
|
}>(),
|
||||||
excludePatterns: text("exclude_patterns", { mode: "json" }).$type<string[]>().default([]),
|
excludePatterns: text("exclude_patterns", { mode: "json" }).$type<string[]>().default([]),
|
||||||
includePatterns: text("include_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" }),
|
lastBackupAt: int("last_backup_at", { mode: "number" }),
|
||||||
lastBackupStatus: text("last_backup_status").$type<"success" | "error" | "in_progress">(),
|
lastBackupStatus: text("last_backup_status").$type<"success" | "error" | "in_progress">(),
|
||||||
lastBackupError: text("last_backup_error"),
|
lastBackupError: text("last_backup_error"),
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ export const generalDescriptor = (app: Hono) =>
|
|||||||
openAPIRouteHandler(app, {
|
openAPIRouteHandler(app, {
|
||||||
documentation: {
|
documentation: {
|
||||||
info: {
|
info: {
|
||||||
title: "Ironmount API",
|
title: "Zerobyte API",
|
||||||
version: "1.0.0",
|
version: "1.0.0",
|
||||||
description: "API for managing volumes",
|
description: "API for managing volumes",
|
||||||
},
|
},
|
||||||
@@ -33,8 +33,8 @@ export const generalDescriptor = (app: Hono) =>
|
|||||||
});
|
});
|
||||||
|
|
||||||
export const scalarDescriptor = Scalar({
|
export const scalarDescriptor = Scalar({
|
||||||
title: "Ironmount API Docs",
|
title: "Zerobyte API Docs",
|
||||||
pageTitle: "Ironmount API Docs",
|
pageTitle: "Zerobyte API Docs",
|
||||||
url: "/api/v1/openapi.json",
|
url: "/api/v1/openapi.json",
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -15,11 +15,13 @@ export class CleanupDanglingMountsJob extends Job {
|
|||||||
const allSystemMounts = await readMountInfo();
|
const allSystemMounts = await readMountInfo();
|
||||||
|
|
||||||
for (const mount of allSystemMounts) {
|
for (const mount of allSystemMounts) {
|
||||||
if (mount.mountPoint.includes("ironmount") && mount.mountPoint.endsWith("_data")) {
|
if (mount.mountPoint.includes("zerobyte") && mount.mountPoint.endsWith("_data")) {
|
||||||
const matchingVolume = allVolumes.find((v) => getVolumePath(v) === mount.mountPoint);
|
const matchingVolume = allVolumes.find((v) => getVolumePath(v) === mount.mountPoint);
|
||||||
if (!matchingVolume) {
|
if (!matchingVolume) {
|
||||||
logger.info(`Found dangling mount at ${mount.mountPoint}, attempting to unmount...`);
|
logger.info(`Found dangling mount at ${mount.mountPoint}, attempting to unmount...`);
|
||||||
await executeUnmount(mount.mountPoint);
|
await executeUnmount(mount.mountPoint).catch((err) => {
|
||||||
|
logger.warn(`Failed to unmount dangling mount ${mount.mountPoint}: ${toMessage(err)}`);
|
||||||
|
});
|
||||||
|
|
||||||
await fs.rmdir(path.dirname(mount.mountPoint)).catch((err) => {
|
await fs.rmdir(path.dirname(mount.mountPoint)).catch((err) => {
|
||||||
logger.warn(
|
logger.warn(
|
||||||
@@ -30,9 +32,9 @@ export class CleanupDanglingMountsJob extends Job {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const allIronmountDirs = await fs.readdir(VOLUME_MOUNT_BASE).catch(() => []);
|
const allZerobyteDirs = await fs.readdir(VOLUME_MOUNT_BASE).catch(() => []);
|
||||||
|
|
||||||
for (const dir of allIronmountDirs) {
|
for (const dir of allZerobyteDirs) {
|
||||||
const volumePath = `${VOLUME_MOUNT_BASE}/${dir}/_data`;
|
const volumePath = `${VOLUME_MOUNT_BASE}/${dir}/_data`;
|
||||||
const matchingVolume = allVolumes.find((v) => getVolumePath(v) === volumePath);
|
const matchingVolume = allVolumes.find((v) => getVolumePath(v) === volumePath);
|
||||||
if (!matchingVolume) {
|
if (!matchingVolume) {
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ import { toMessage } from "../../utils/errors";
|
|||||||
const COOKIE_NAME = "session_id";
|
const COOKIE_NAME = "session_id";
|
||||||
const COOKIE_OPTIONS = {
|
const COOKIE_OPTIONS = {
|
||||||
httpOnly: true,
|
httpOnly: true,
|
||||||
secure: process.env.NODE_ENV === "production",
|
secure: false,
|
||||||
sameSite: "lax" as const,
|
sameSite: "lax" as const,
|
||||||
path: "/",
|
path: "/",
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import * as fs from "node:fs/promises";
|
import * as fs from "node:fs/promises";
|
||||||
import * as npath from "node:path";
|
|
||||||
import { toMessage } from "../../../utils/errors";
|
import { toMessage } from "../../../utils/errors";
|
||||||
import { logger } from "../../../utils/logger";
|
import { logger } from "../../../utils/logger";
|
||||||
import type { VolumeBackend } from "../backend";
|
import type { VolumeBackend } from "../backend";
|
||||||
@@ -40,11 +39,6 @@ const checkHealth = async (config: BackendConfig) => {
|
|||||||
try {
|
try {
|
||||||
await fs.access(config.path);
|
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 };
|
return { status: BACKEND_STATUS.mounted };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error("Directory health check failed:", error);
|
logger.error("Directory health check failed:", error);
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { logger } from "../../../utils/logger";
|
|||||||
import { getMountForPath } from "../../../utils/mountinfo";
|
import { getMountForPath } from "../../../utils/mountinfo";
|
||||||
import { withTimeout } from "../../../utils/timeout";
|
import { withTimeout } from "../../../utils/timeout";
|
||||||
import type { VolumeBackend } from "../backend";
|
import type { VolumeBackend } from "../backend";
|
||||||
import { createTestFile, executeMount, executeUnmount } from "../utils/backend-utils";
|
import { executeMount, executeUnmount } from "../utils/backend-utils";
|
||||||
import { BACKEND_STATUS, type BackendConfig } from "~/schemas/volumes";
|
import { BACKEND_STATUS, type BackendConfig } from "~/schemas/volumes";
|
||||||
|
|
||||||
const mount = async (config: BackendConfig, path: string) => {
|
const mount = async (config: BackendConfig, path: string) => {
|
||||||
@@ -22,7 +22,7 @@ const mount = async (config: BackendConfig, path: string) => {
|
|||||||
return { status: BACKEND_STATUS.error, error: "NFS mounting is only supported on Linux hosts." };
|
return { status: BACKEND_STATUS.error, error: "NFS mounting is only supported on Linux hosts." };
|
||||||
}
|
}
|
||||||
|
|
||||||
const { status } = await checkHealth(path, config.readOnly ?? false);
|
const { status } = await checkHealth(path);
|
||||||
if (status === "mounted") {
|
if (status === "mounted") {
|
||||||
return { status: BACKEND_STATUS.mounted };
|
return { status: BACKEND_STATUS.mounted };
|
||||||
}
|
}
|
||||||
@@ -87,7 +87,7 @@ const unmount = async (path: string) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const checkHealth = async (path: string, readOnly: boolean) => {
|
const checkHealth = async (path: string) => {
|
||||||
const run = async () => {
|
const run = async () => {
|
||||||
logger.debug(`Checking health of NFS volume at ${path}...`);
|
logger.debug(`Checking health of NFS volume at ${path}...`);
|
||||||
await fs.access(path);
|
await fs.access(path);
|
||||||
@@ -98,10 +98,6 @@ const checkHealth = async (path: string, readOnly: boolean) => {
|
|||||||
throw new Error(`Path ${path} is not mounted as NFS.`);
|
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.`);
|
logger.debug(`NFS volume at ${path} is healthy and mounted.`);
|
||||||
return { status: BACKEND_STATUS.mounted };
|
return { status: BACKEND_STATUS.mounted };
|
||||||
};
|
};
|
||||||
@@ -117,5 +113,5 @@ const checkHealth = async (path: string, readOnly: boolean) => {
|
|||||||
export const makeNfsBackend = (config: BackendConfig, path: string): VolumeBackend => ({
|
export const makeNfsBackend = (config: BackendConfig, path: string): VolumeBackend => ({
|
||||||
mount: () => mount(config, path),
|
mount: () => mount(config, path),
|
||||||
unmount: () => unmount(path),
|
unmount: () => unmount(path),
|
||||||
checkHealth: () => checkHealth(path, config.readOnly ?? false),
|
checkHealth: () => checkHealth(path),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { logger } from "../../../utils/logger";
|
|||||||
import { getMountForPath } from "../../../utils/mountinfo";
|
import { getMountForPath } from "../../../utils/mountinfo";
|
||||||
import { withTimeout } from "../../../utils/timeout";
|
import { withTimeout } from "../../../utils/timeout";
|
||||||
import type { VolumeBackend } from "../backend";
|
import type { VolumeBackend } from "../backend";
|
||||||
import { createTestFile, executeMount, executeUnmount } from "../utils/backend-utils";
|
import { executeMount, executeUnmount } from "../utils/backend-utils";
|
||||||
import { BACKEND_STATUS, type BackendConfig } from "~/schemas/volumes";
|
import { BACKEND_STATUS, type BackendConfig } from "~/schemas/volumes";
|
||||||
|
|
||||||
const mount = async (config: BackendConfig, path: string) => {
|
const mount = async (config: BackendConfig, path: string) => {
|
||||||
@@ -22,7 +22,7 @@ const mount = async (config: BackendConfig, path: string) => {
|
|||||||
return { status: BACKEND_STATUS.error, error: "SMB mounting is only supported on Linux hosts." };
|
return { status: BACKEND_STATUS.error, error: "SMB mounting is only supported on Linux hosts." };
|
||||||
}
|
}
|
||||||
|
|
||||||
const { status } = await checkHealth(path, config.readOnly ?? false);
|
const { status } = await checkHealth(path);
|
||||||
if (status === "mounted") {
|
if (status === "mounted") {
|
||||||
return { status: BACKEND_STATUS.mounted };
|
return { status: BACKEND_STATUS.mounted };
|
||||||
}
|
}
|
||||||
@@ -100,7 +100,7 @@ const unmount = async (path: string) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const checkHealth = async (path: string, readOnly: boolean) => {
|
const checkHealth = async (path: string) => {
|
||||||
const run = async () => {
|
const run = async () => {
|
||||||
logger.debug(`Checking health of SMB volume at ${path}...`);
|
logger.debug(`Checking health of SMB volume at ${path}...`);
|
||||||
await fs.access(path);
|
await fs.access(path);
|
||||||
@@ -111,10 +111,6 @@ const checkHealth = async (path: string, readOnly: boolean) => {
|
|||||||
throw new Error(`Path ${path} is not mounted as CIFS/SMB.`);
|
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.`);
|
logger.debug(`SMB volume at ${path} is healthy and mounted.`);
|
||||||
return { status: BACKEND_STATUS.mounted };
|
return { status: BACKEND_STATUS.mounted };
|
||||||
};
|
};
|
||||||
@@ -130,5 +126,5 @@ const checkHealth = async (path: string, readOnly: boolean) => {
|
|||||||
export const makeSmbBackend = (config: BackendConfig, path: string): VolumeBackend => ({
|
export const makeSmbBackend = (config: BackendConfig, path: string): VolumeBackend => ({
|
||||||
mount: () => mount(config, path),
|
mount: () => mount(config, path),
|
||||||
unmount: () => unmount(path),
|
unmount: () => unmount(path),
|
||||||
checkHealth: () => checkHealth(path, config.readOnly ?? false),
|
checkHealth: () => checkHealth(path),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -13,6 +13,10 @@ export const executeMount = async (args: string[]): Promise<void> => {
|
|||||||
if (stderr?.trim()) {
|
if (stderr?.trim()) {
|
||||||
logger.warn(stderr.trim());
|
logger.warn(stderr.trim());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (result.exitCode !== 0) {
|
||||||
|
throw new Error(`Mount command failed with exit code ${result.exitCode}: ${stderr?.trim()}`);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export const executeUnmount = async (path: string): Promise<void> => {
|
export const executeUnmount = async (path: string): Promise<void> => {
|
||||||
@@ -24,6 +28,10 @@ export const executeUnmount = async (path: string): Promise<void> => {
|
|||||||
if (stderr?.trim()) {
|
if (stderr?.trim()) {
|
||||||
logger.warn(stderr.trim());
|
logger.warn(stderr.trim());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (result.exitCode !== 0) {
|
||||||
|
throw new Error(`Mount command failed with exit code ${result.exitCode}: ${stderr?.trim()}`);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export const createTestFile = async (path: string): Promise<void> => {
|
export const createTestFile = async (path: string): Promise<void> => {
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import { logger } from "../../../utils/logger";
|
|||||||
import { getMountForPath } from "../../../utils/mountinfo";
|
import { getMountForPath } from "../../../utils/mountinfo";
|
||||||
import { withTimeout } from "../../../utils/timeout";
|
import { withTimeout } from "../../../utils/timeout";
|
||||||
import type { VolumeBackend } from "../backend";
|
import type { VolumeBackend } from "../backend";
|
||||||
import { createTestFile, executeMount, executeUnmount } from "../utils/backend-utils";
|
import { executeMount, executeUnmount } from "../utils/backend-utils";
|
||||||
import { BACKEND_STATUS, type BackendConfig } from "~/schemas/volumes";
|
import { BACKEND_STATUS, type BackendConfig } from "~/schemas/volumes";
|
||||||
|
|
||||||
const execFile = promisify(execFileCb);
|
const execFile = promisify(execFileCb);
|
||||||
@@ -26,7 +26,7 @@ const mount = async (config: BackendConfig, path: string) => {
|
|||||||
return { status: BACKEND_STATUS.error, error: "WebDAV mounting is only supported on Linux hosts." };
|
return { status: BACKEND_STATUS.error, error: "WebDAV mounting is only supported on Linux hosts." };
|
||||||
}
|
}
|
||||||
|
|
||||||
const { status } = await checkHealth(path, config.readOnly ?? false);
|
const { status } = await checkHealth(path);
|
||||||
if (status === "mounted") {
|
if (status === "mounted") {
|
||||||
return { status: BACKEND_STATUS.mounted };
|
return { status: BACKEND_STATUS.mounted };
|
||||||
}
|
}
|
||||||
@@ -134,7 +134,7 @@ const unmount = async (path: string) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const checkHealth = async (path: string, readOnly: boolean) => {
|
const checkHealth = async (path: string) => {
|
||||||
const run = async () => {
|
const run = async () => {
|
||||||
logger.debug(`Checking health of WebDAV volume at ${path}...`);
|
logger.debug(`Checking health of WebDAV volume at ${path}...`);
|
||||||
await fs.access(path);
|
await fs.access(path);
|
||||||
@@ -145,10 +145,6 @@ const checkHealth = async (path: string, readOnly: boolean) => {
|
|||||||
throw new Error(`Path ${path} is not mounted as WebDAV.`);
|
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.`);
|
logger.debug(`WebDAV volume at ${path} is healthy and mounted.`);
|
||||||
return { status: BACKEND_STATUS.mounted };
|
return { status: BACKEND_STATUS.mounted };
|
||||||
};
|
};
|
||||||
@@ -164,5 +160,5 @@ const checkHealth = async (path: string, readOnly: boolean) => {
|
|||||||
export const makeWebdavBackend = (config: BackendConfig, path: string): VolumeBackend => ({
|
export const makeWebdavBackend = (config: BackendConfig, path: string): VolumeBackend => ({
|
||||||
mount: () => mount(config, path),
|
mount: () => mount(config, path),
|
||||||
unmount: () => unmount(path),
|
unmount: () => unmount(path),
|
||||||
checkHealth: () => checkHealth(path, config.readOnly ?? false),
|
checkHealth: () => checkHealth(path),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
getBackupScheduleForVolumeDto,
|
getBackupScheduleForVolumeDto,
|
||||||
listBackupSchedulesDto,
|
listBackupSchedulesDto,
|
||||||
runBackupNowDto,
|
runBackupNowDto,
|
||||||
|
runForgetDto,
|
||||||
stopBackupDto,
|
stopBackupDto,
|
||||||
updateBackupScheduleDto,
|
updateBackupScheduleDto,
|
||||||
updateBackupScheduleBody,
|
updateBackupScheduleBody,
|
||||||
@@ -17,6 +18,7 @@ import {
|
|||||||
type GetBackupScheduleForVolumeResponseDto,
|
type GetBackupScheduleForVolumeResponseDto,
|
||||||
type ListBackupSchedulesResponseDto,
|
type ListBackupSchedulesResponseDto,
|
||||||
type RunBackupNowDto,
|
type RunBackupNowDto,
|
||||||
|
type RunForgetDto,
|
||||||
type StopBackupDto,
|
type StopBackupDto,
|
||||||
type UpdateBackupScheduleDto,
|
type UpdateBackupScheduleDto,
|
||||||
} from "./backups.dto";
|
} from "./backups.dto";
|
||||||
@@ -78,4 +80,11 @@ export const backupScheduleController = new Hono()
|
|||||||
await backupsService.stopBackup(Number(scheduleId));
|
await backupsService.stopBackup(Number(scheduleId));
|
||||||
|
|
||||||
return c.json<StopBackupDto>({ success: true }, 200);
|
return c.json<StopBackupDto>({ success: true }, 200);
|
||||||
|
})
|
||||||
|
.post("/:scheduleId/forget", runForgetDto, async (c) => {
|
||||||
|
const scheduleId = c.req.param("scheduleId");
|
||||||
|
|
||||||
|
await backupsService.runForget(Number(scheduleId));
|
||||||
|
|
||||||
|
return c.json<RunForgetDto>({ success: true }, 200);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ const backupScheduleSchema = type({
|
|||||||
retentionPolicy: retentionPolicySchema.or("null"),
|
retentionPolicy: retentionPolicySchema.or("null"),
|
||||||
excludePatterns: "string[] | null",
|
excludePatterns: "string[] | null",
|
||||||
includePatterns: "string[] | null",
|
includePatterns: "string[] | null",
|
||||||
|
limitUploadKbps: "number | null",
|
||||||
lastBackupAt: "number | null",
|
lastBackupAt: "number | null",
|
||||||
lastBackupStatus: "'success' | 'error' | 'in_progress' | null",
|
lastBackupStatus: "'success' | 'error' | 'in_progress' | null",
|
||||||
lastBackupError: "string | null",
|
lastBackupError: "string | null",
|
||||||
@@ -114,6 +115,7 @@ export const createBackupScheduleBody = type({
|
|||||||
retentionPolicy: retentionPolicySchema.optional(),
|
retentionPolicy: retentionPolicySchema.optional(),
|
||||||
excludePatterns: "string[]?",
|
excludePatterns: "string[]?",
|
||||||
includePatterns: "string[]?",
|
includePatterns: "string[]?",
|
||||||
|
limitUploadKbps: "number?",
|
||||||
tags: "string[]?",
|
tags: "string[]?",
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -149,6 +151,7 @@ export const updateBackupScheduleBody = type({
|
|||||||
retentionPolicy: retentionPolicySchema.optional(),
|
retentionPolicy: retentionPolicySchema.optional(),
|
||||||
excludePatterns: "string[]?",
|
excludePatterns: "string[]?",
|
||||||
includePatterns: "string[]?",
|
includePatterns: "string[]?",
|
||||||
|
limitUploadKbps: "number?",
|
||||||
tags: "string[]?",
|
tags: "string[]?",
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -251,3 +254,28 @@ export const stopBackupDto = describeRoute({
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Run retention policy (forget) manually
|
||||||
|
*/
|
||||||
|
export const runForgetResponse = type({
|
||||||
|
success: "boolean",
|
||||||
|
});
|
||||||
|
|
||||||
|
export type RunForgetDto = typeof runForgetResponse.infer;
|
||||||
|
|
||||||
|
export const runForgetDto = describeRoute({
|
||||||
|
description: "Manually apply retention policy to clean up old snapshots",
|
||||||
|
operationId: "runForget",
|
||||||
|
tags: ["Backups"],
|
||||||
|
responses: {
|
||||||
|
200: {
|
||||||
|
description: "Retention policy applied successfully",
|
||||||
|
content: {
|
||||||
|
"application/json": {
|
||||||
|
schema: resolver(runForgetResponse),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ const calculateNextRun = (cronExpression: string): number => {
|
|||||||
try {
|
try {
|
||||||
const interval = CronExpressionParser.parse(cronExpression, {
|
const interval = CronExpressionParser.parse(cronExpression, {
|
||||||
currentDate: new Date(),
|
currentDate: new Date(),
|
||||||
tz: "UTC",
|
tz: Intl.DateTimeFormat().resolvedOptions().timeZone,
|
||||||
});
|
});
|
||||||
|
|
||||||
return interval.next().getTime();
|
return interval.next().getTime();
|
||||||
@@ -88,6 +88,7 @@ const createSchedule = async (data: CreateBackupScheduleBody) => {
|
|||||||
retentionPolicy: data.retentionPolicy ?? null,
|
retentionPolicy: data.retentionPolicy ?? null,
|
||||||
excludePatterns: data.excludePatterns ?? [],
|
excludePatterns: data.excludePatterns ?? [],
|
||||||
includePatterns: data.includePatterns ?? [],
|
includePatterns: data.includePatterns ?? [],
|
||||||
|
limitUploadKbps: data.limitUploadKbps ?? null,
|
||||||
nextBackupAt: nextBackupAt,
|
nextBackupAt: nextBackupAt,
|
||||||
})
|
})
|
||||||
.returning();
|
.returning();
|
||||||
@@ -195,9 +196,11 @@ const executeBackup = async (scheduleId: number, manual = false) => {
|
|||||||
repositoryName: repository.name,
|
repositoryName: repository.name,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const nextBackupAt = calculateNextRun(schedule.cronExpression);
|
||||||
|
|
||||||
await db
|
await db
|
||||||
.update(backupSchedulesTable)
|
.update(backupSchedulesTable)
|
||||||
.set({ lastBackupStatus: "in_progress", updatedAt: Date.now(), lastBackupError: null })
|
.set({ lastBackupStatus: "in_progress", updatedAt: Date.now(), lastBackupError: null, nextBackupAt })
|
||||||
.where(eq(backupSchedulesTable.id, scheduleId));
|
.where(eq(backupSchedulesTable.id, scheduleId));
|
||||||
|
|
||||||
const abortController = new AbortController();
|
const abortController = new AbortController();
|
||||||
@@ -210,9 +213,11 @@ const executeBackup = async (scheduleId: number, manual = false) => {
|
|||||||
exclude?: string[];
|
exclude?: string[];
|
||||||
include?: string[];
|
include?: string[];
|
||||||
tags?: string[];
|
tags?: string[];
|
||||||
|
limitUploadKbps?: number | null;
|
||||||
signal?: AbortSignal;
|
signal?: AbortSignal;
|
||||||
} = {
|
} = {
|
||||||
tags: [schedule.id.toString()],
|
tags: [schedule.id.toString()],
|
||||||
|
limitUploadKbps: schedule.limitUploadKbps,
|
||||||
signal: abortController.signal,
|
signal: abortController.signal,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -340,6 +345,32 @@ const stopBackup = async (scheduleId: number) => {
|
|||||||
abortController.abort();
|
abortController.abort();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const runForget = async (scheduleId: number) => {
|
||||||
|
const schedule = await db.query.backupSchedulesTable.findFirst({
|
||||||
|
where: eq(backupSchedulesTable.id, scheduleId),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!schedule) {
|
||||||
|
throw new NotFoundError("Backup schedule not found");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!schedule.retentionPolicy) {
|
||||||
|
throw new BadRequestError("No retention policy configured for this schedule");
|
||||||
|
}
|
||||||
|
|
||||||
|
const repository = await db.query.repositoriesTable.findFirst({
|
||||||
|
where: eq(repositoriesTable.id, schedule.repositoryId),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!repository) {
|
||||||
|
throw new NotFoundError("Repository not found");
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info(`Manually running retention policy (forget) for schedule ${scheduleId}`);
|
||||||
|
await restic.forget(repository.config, schedule.retentionPolicy, { tag: schedule.id.toString() });
|
||||||
|
logger.info(`Retention policy applied successfully for schedule ${scheduleId}`);
|
||||||
|
};
|
||||||
|
|
||||||
export const backupsService = {
|
export const backupsService = {
|
||||||
listSchedules,
|
listSchedules,
|
||||||
getSchedule,
|
getSchedule,
|
||||||
@@ -350,4 +381,5 @@ export const backupsService = {
|
|||||||
getSchedulesToExecute,
|
getSchedulesToExecute,
|
||||||
getScheduleForVolume,
|
getScheduleForVolume,
|
||||||
stopBackup,
|
stopBackup,
|
||||||
|
runForget,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ export const driverController = new Hono()
|
|||||||
return c.json({ Err: "Volume name is required" }, 400);
|
return c.json({ Err: "Volume name is required" }, 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
const volumeName = body.Name.replace(/^im-/, "");
|
const volumeName = body.Name.replace(/^zb-/, "");
|
||||||
|
|
||||||
return c.json({
|
return c.json({
|
||||||
Mountpoint: getVolumePath(volumeName),
|
Mountpoint: getVolumePath(volumeName),
|
||||||
@@ -48,7 +48,7 @@ export const driverController = new Hono()
|
|||||||
return c.json({ Err: "Volume name is required" }, 400);
|
return c.json({ Err: "Volume name is required" }, 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
const { volume } = await volumeService.getVolume(body.Name.replace(/^im-/, ""));
|
const { volume } = await volumeService.getVolume(body.Name.replace(/^zb-/, ""));
|
||||||
|
|
||||||
return c.json({
|
return c.json({
|
||||||
Mountpoint: getVolumePath(volume),
|
Mountpoint: getVolumePath(volume),
|
||||||
@@ -61,11 +61,11 @@ export const driverController = new Hono()
|
|||||||
return c.json({ Err: "Volume name is required" }, 400);
|
return c.json({ Err: "Volume name is required" }, 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
const { volume } = await volumeService.getVolume(body.Name.replace(/^im-/, ""));
|
const { volume } = await volumeService.getVolume(body.Name.replace(/^zb-/, ""));
|
||||||
|
|
||||||
return c.json({
|
return c.json({
|
||||||
Volume: {
|
Volume: {
|
||||||
Name: `im-${volume.name}`,
|
Name: `zb-${volume.name}`,
|
||||||
Mountpoint: getVolumePath(volume),
|
Mountpoint: getVolumePath(volume),
|
||||||
Status: {},
|
Status: {},
|
||||||
},
|
},
|
||||||
@@ -76,7 +76,7 @@ export const driverController = new Hono()
|
|||||||
const volumes = await volumeService.listVolumes();
|
const volumes = await volumeService.listVolumes();
|
||||||
|
|
||||||
const res = volumes.map((volume) => ({
|
const res = volumes.map((volume) => ({
|
||||||
Name: `im-${volume.name}`,
|
Name: `zb-${volume.name}`,
|
||||||
Mountpoint: getVolumePath(volume),
|
Mountpoint: getVolumePath(volume),
|
||||||
Status: {},
|
Status: {},
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import { CleanupSessionsJob } from "../../jobs/cleanup-sessions";
|
|||||||
|
|
||||||
export const startup = async () => {
|
export const startup = async () => {
|
||||||
await Scheduler.start();
|
await Scheduler.start();
|
||||||
|
await Scheduler.clear();
|
||||||
|
|
||||||
await restic.ensurePassfile().catch((err) => {
|
await restic.ensurePassfile().catch((err) => {
|
||||||
logger.error(`Error ensuring restic passfile exists: ${err.message}`);
|
logger.error(`Error ensuring restic passfile exists: ${err.message}`);
|
||||||
@@ -32,8 +33,8 @@ export const startup = async () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Scheduler.build(CleanupDanglingMountsJob).schedule("0 * * * *");
|
Scheduler.build(CleanupDanglingMountsJob).schedule("0 * * * *");
|
||||||
Scheduler.build(VolumeHealthCheckJob).schedule("*/5 * * * *");
|
Scheduler.build(VolumeHealthCheckJob).schedule("*/30 * * * *");
|
||||||
Scheduler.build(RepositoryHealthCheckJob).schedule("*/10 * * * *");
|
Scheduler.build(RepositoryHealthCheckJob).schedule("0 * * * *");
|
||||||
Scheduler.build(BackupExecutionJob).schedule("* * * * *");
|
Scheduler.build(BackupExecutionJob).schedule("* * * * *");
|
||||||
Scheduler.build(CleanupSessionsJob).schedule("0 0 * * *");
|
Scheduler.build(CleanupSessionsJob).schedule("0 0 * * *");
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import {
|
|||||||
createRepositoryBody,
|
createRepositoryBody,
|
||||||
createRepositoryDto,
|
createRepositoryDto,
|
||||||
deleteRepositoryDto,
|
deleteRepositoryDto,
|
||||||
|
deleteSnapshotDto,
|
||||||
doctorRepositoryDto,
|
doctorRepositoryDto,
|
||||||
getRepositoryDto,
|
getRepositoryDto,
|
||||||
getSnapshotDetailsDto,
|
getSnapshotDetailsDto,
|
||||||
@@ -16,6 +17,7 @@ import {
|
|||||||
restoreSnapshotBody,
|
restoreSnapshotBody,
|
||||||
restoreSnapshotDto,
|
restoreSnapshotDto,
|
||||||
type DeleteRepositoryDto,
|
type DeleteRepositoryDto,
|
||||||
|
type DeleteSnapshotDto,
|
||||||
type DoctorRepositoryDto,
|
type DoctorRepositoryDto,
|
||||||
type GetRepositoryDto,
|
type GetRepositoryDto,
|
||||||
type GetSnapshotDetailsDto,
|
type GetSnapshotDetailsDto,
|
||||||
@@ -121,7 +123,8 @@ export const repositoriesController = new Hono()
|
|||||||
const { name, snapshotId } = c.req.param();
|
const { name, snapshotId } = c.req.param();
|
||||||
const { path } = c.req.valid("query");
|
const { path } = c.req.valid("query");
|
||||||
|
|
||||||
const result = await repositoriesService.listSnapshotFiles(name, snapshotId, path);
|
const decodedPath = path ? decodeURIComponent(path) : undefined;
|
||||||
|
const result = await repositoriesService.listSnapshotFiles(name, snapshotId, decodedPath);
|
||||||
|
|
||||||
c.header("Cache-Control", "max-age=300, stale-while-revalidate=600");
|
c.header("Cache-Control", "max-age=300, stale-while-revalidate=600");
|
||||||
|
|
||||||
@@ -142,4 +145,11 @@ export const repositoriesController = new Hono()
|
|||||||
const result = await repositoriesService.doctorRepository(name);
|
const result = await repositoriesService.doctorRepository(name);
|
||||||
|
|
||||||
return c.json<DoctorRepositoryDto>(result, 200);
|
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,3 +326,28 @@ 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),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|||||||
@@ -15,10 +15,15 @@ const listRepositories = async () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const encryptConfig = async (config: RepositoryConfig): Promise<RepositoryConfig> => {
|
const encryptConfig = async (config: RepositoryConfig): Promise<RepositoryConfig> => {
|
||||||
const encryptedConfig: Record<string, string> = { ...config };
|
const encryptedConfig: Record<string, string | boolean> = { ...config };
|
||||||
|
|
||||||
|
if (config.customPassword) {
|
||||||
|
encryptedConfig.customPassword = await cryptoUtils.encrypt(config.customPassword);
|
||||||
|
}
|
||||||
|
|
||||||
switch (config.backend) {
|
switch (config.backend) {
|
||||||
case "s3":
|
case "s3":
|
||||||
|
case "r2":
|
||||||
encryptedConfig.accessKeyId = await cryptoUtils.encrypt(config.accessKeyId);
|
encryptedConfig.accessKeyId = await cryptoUtils.encrypt(config.accessKeyId);
|
||||||
encryptedConfig.secretAccessKey = await cryptoUtils.encrypt(config.secretAccessKey);
|
encryptedConfig.secretAccessKey = await cryptoUtils.encrypt(config.secretAccessKey);
|
||||||
break;
|
break;
|
||||||
@@ -28,6 +33,17 @@ const encryptConfig = async (config: RepositoryConfig): Promise<RepositoryConfig
|
|||||||
case "azure":
|
case "azure":
|
||||||
encryptedConfig.accountKey = await cryptoUtils.encrypt(config.accountKey);
|
encryptedConfig.accountKey = await cryptoUtils.encrypt(config.accountKey);
|
||||||
break;
|
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;
|
return encryptedConfig as RepositoryConfig;
|
||||||
@@ -64,16 +80,24 @@ const createRepository = async (name: string, config: RepositoryConfig, compress
|
|||||||
throw new InternalServerError("Failed to create repository");
|
throw new InternalServerError("Failed to create repository");
|
||||||
}
|
}
|
||||||
|
|
||||||
const { success, error } = await restic.init(encryptedConfig);
|
let error: string | null = null;
|
||||||
|
|
||||||
if (success) {
|
if (config.isExistingRepository) {
|
||||||
|
const result = await restic
|
||||||
|
.snapshots(encryptedConfig)
|
||||||
|
.then(() => ({ error: null }))
|
||||||
|
.catch((error) => ({ error }));
|
||||||
|
|
||||||
|
error = result.error;
|
||||||
|
} else {
|
||||||
|
const initResult = await restic.init(encryptedConfig);
|
||||||
|
error = initResult.error;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!error) {
|
||||||
await db
|
await db
|
||||||
.update(repositoriesTable)
|
.update(repositoriesTable)
|
||||||
.set({
|
.set({ status: "healthy", lastChecked: Date.now(), lastError: null })
|
||||||
status: "healthy",
|
|
||||||
lastChecked: Date.now(),
|
|
||||||
lastError: null,
|
|
||||||
})
|
|
||||||
.where(eq(repositoriesTable.id, id));
|
.where(eq(repositoriesTable.id, id));
|
||||||
|
|
||||||
return { repository: created, status: 201 };
|
return { repository: created, status: 201 };
|
||||||
@@ -314,6 +338,18 @@ 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 = {
|
export const repositoriesService = {
|
||||||
listRepositories,
|
listRepositories,
|
||||||
createRepository,
|
createRepository,
|
||||||
@@ -325,4 +361,5 @@ export const repositoriesService = {
|
|||||||
getSnapshotDetails,
|
getSnapshotDetails,
|
||||||
checkHealth,
|
checkHealth,
|
||||||
doctorRepository,
|
doctorRepository,
|
||||||
|
deleteSnapshot,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -186,7 +186,7 @@ const updateVolume = async (name: string, volumeData: UpdateVolumeBody) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const testConnection = async (backendConfig: BackendConfig) => {
|
const testConnection = async (backendConfig: BackendConfig) => {
|
||||||
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "ironmount-test-"));
|
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "zerobyte-test-"));
|
||||||
|
|
||||||
const mockVolume = {
|
const mockVolume = {
|
||||||
id: 0,
|
id: 0,
|
||||||
|
|||||||
@@ -71,15 +71,25 @@ const ensurePassfile = async () => {
|
|||||||
const buildRepoUrl = (config: RepositoryConfig): string => {
|
const buildRepoUrl = (config: RepositoryConfig): string => {
|
||||||
switch (config.backend) {
|
switch (config.backend) {
|
||||||
case "local":
|
case "local":
|
||||||
return `${REPOSITORY_BASE}/${config.name}`;
|
return config.path ? `${config.path}/${config.name}` : `${REPOSITORY_BASE}/${config.name}`;
|
||||||
case "s3":
|
case "s3":
|
||||||
return `s3:${config.endpoint}/${config.bucket}`;
|
return `s3:${config.endpoint}/${config.bucket}`;
|
||||||
|
case "r2": {
|
||||||
|
const endpoint = config.endpoint.replace(/^https?:\/\//, "");
|
||||||
|
return `s3:${endpoint}/${config.bucket}`;
|
||||||
|
}
|
||||||
case "gcs":
|
case "gcs":
|
||||||
return `gs:${config.bucket}:/`;
|
return `gs:${config.bucket}:/`;
|
||||||
case "azure":
|
case "azure":
|
||||||
return `azure:${config.container}:/`;
|
return `azure:${config.container}:/`;
|
||||||
case "rclone":
|
case "rclone":
|
||||||
return `rclone:${config.remote}:${config.path}`;
|
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: {
|
default: {
|
||||||
throw new Error(`Unsupported repository backend: ${JSON.stringify(config)}`);
|
throw new Error(`Unsupported repository backend: ${JSON.stringify(config)}`);
|
||||||
}
|
}
|
||||||
@@ -88,19 +98,34 @@ const buildRepoUrl = (config: RepositoryConfig): string => {
|
|||||||
|
|
||||||
const buildEnv = async (config: RepositoryConfig) => {
|
const buildEnv = async (config: RepositoryConfig) => {
|
||||||
const env: Record<string, string> = {
|
const env: Record<string, string> = {
|
||||||
RESTIC_CACHE_DIR: "/var/lib/ironmount/restic/cache",
|
RESTIC_CACHE_DIR: "/var/lib/zerobyte/restic/cache",
|
||||||
RESTIC_PASSWORD_FILE: RESTIC_PASS_FILE,
|
|
||||||
PATH: process.env.PATH || "/usr/local/bin:/usr/bin:/bin",
|
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`);
|
||||||
|
|
||||||
|
await fs.writeFile(passwordFilePath, decryptedPassword, { mode: 0o600 });
|
||||||
|
env.RESTIC_PASSWORD_FILE = passwordFilePath;
|
||||||
|
} else {
|
||||||
|
env.RESTIC_PASSWORD_FILE = RESTIC_PASS_FILE;
|
||||||
|
}
|
||||||
|
|
||||||
switch (config.backend) {
|
switch (config.backend) {
|
||||||
case "s3":
|
case "s3":
|
||||||
env.AWS_ACCESS_KEY_ID = await cryptoUtils.decrypt(config.accessKeyId);
|
env.AWS_ACCESS_KEY_ID = await cryptoUtils.decrypt(config.accessKeyId);
|
||||||
env.AWS_SECRET_ACCESS_KEY = await cryptoUtils.decrypt(config.secretAccessKey);
|
env.AWS_SECRET_ACCESS_KEY = await cryptoUtils.decrypt(config.secretAccessKey);
|
||||||
break;
|
break;
|
||||||
|
case "r2":
|
||||||
|
env.AWS_ACCESS_KEY_ID = await cryptoUtils.decrypt(config.accessKeyId);
|
||||||
|
env.AWS_SECRET_ACCESS_KEY = await cryptoUtils.decrypt(config.secretAccessKey);
|
||||||
|
env.AWS_REGION = "auto";
|
||||||
|
env.AWS_S3_FORCE_PATH_STYLE = "true";
|
||||||
|
break;
|
||||||
case "gcs": {
|
case "gcs": {
|
||||||
const decryptedCredentials = await cryptoUtils.decrypt(config.credentialsJson);
|
const decryptedCredentials = await cryptoUtils.decrypt(config.credentialsJson);
|
||||||
const credentialsPath = path.join("/tmp", `gcs-credentials-${crypto.randomBytes(8).toString("hex")}.json`);
|
const credentialsPath = path.join("/tmp", `zerobyte-gcs-${crypto.randomBytes(8).toString("hex")}.json`);
|
||||||
await fs.writeFile(credentialsPath, decryptedCredentials, { mode: 0o600 });
|
await fs.writeFile(credentialsPath, decryptedCredentials, { mode: 0o600 });
|
||||||
env.GOOGLE_PROJECT_ID = config.projectId;
|
env.GOOGLE_PROJECT_ID = config.projectId;
|
||||||
env.GOOGLE_APPLICATION_CREDENTIALS = credentialsPath;
|
env.GOOGLE_APPLICATION_CREDENTIALS = credentialsPath;
|
||||||
@@ -114,6 +139,52 @@ const buildEnv = async (config: RepositoryConfig) => {
|
|||||||
}
|
}
|
||||||
break;
|
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;
|
return env;
|
||||||
@@ -123,13 +194,20 @@ const init = async (config: RepositoryConfig) => {
|
|||||||
await ensurePassfile();
|
await ensurePassfile();
|
||||||
|
|
||||||
const repoUrl = buildRepoUrl(config);
|
const repoUrl = buildRepoUrl(config);
|
||||||
|
|
||||||
|
logger.info(`Initializing restic repository at ${repoUrl}...`);
|
||||||
|
|
||||||
const env = await buildEnv(config);
|
const env = await buildEnv(config);
|
||||||
|
|
||||||
const res = await $`restic init --repo ${repoUrl} --json`.env(env).nothrow();
|
const args = ["init", "--repo", repoUrl, "--json"];
|
||||||
|
addRepoSpecificArgs(args, config, env);
|
||||||
|
|
||||||
|
const res = await $`restic ${args}`.env(env).nothrow();
|
||||||
|
await cleanupTemporaryKeys(config, env);
|
||||||
|
|
||||||
if (res.exitCode !== 0) {
|
if (res.exitCode !== 0) {
|
||||||
logger.error(`Restic init failed: ${res.stderr}`);
|
logger.error(`Restic init failed: ${res.stderr}`);
|
||||||
return { success: false, error: res.stderr };
|
return { success: false, error: res.stderr.toString() };
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.info(`Restic repository initialized: ${repoUrl}`);
|
logger.info(`Restic repository initialized: ${repoUrl}`);
|
||||||
@@ -156,6 +234,7 @@ const backup = async (
|
|||||||
exclude?: string[];
|
exclude?: string[];
|
||||||
include?: string[];
|
include?: string[];
|
||||||
tags?: string[];
|
tags?: string[];
|
||||||
|
limitUploadKbps?: number | null;
|
||||||
signal?: AbortSignal;
|
signal?: AbortSignal;
|
||||||
onProgress?: (progress: BackupProgress) => void;
|
onProgress?: (progress: BackupProgress) => void;
|
||||||
},
|
},
|
||||||
@@ -165,6 +244,10 @@ const backup = async (
|
|||||||
|
|
||||||
const args: string[] = ["--repo", repoUrl, "backup", "--one-file-system"];
|
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) {
|
if (options?.tags && options.tags.length > 0) {
|
||||||
for (const tag of options.tags) {
|
for (const tag of options.tags) {
|
||||||
args.push("--tag", tag);
|
args.push("--tag", tag);
|
||||||
@@ -190,6 +273,7 @@ const backup = async (
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
addRepoSpecificArgs(args, config, env);
|
||||||
args.push("--json");
|
args.push("--json");
|
||||||
|
|
||||||
const logData = throttle((data: string) => {
|
const logData = throttle((data: string) => {
|
||||||
@@ -230,6 +314,7 @@ const backup = async (
|
|||||||
},
|
},
|
||||||
finally: async () => {
|
finally: async () => {
|
||||||
includeFile && (await fs.unlink(includeFile).catch(() => {}));
|
includeFile && (await fs.unlink(includeFile).catch(() => {}));
|
||||||
|
await cleanupTemporaryKeys(config, env);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -300,11 +385,13 @@ const restore = async (
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
addRepoSpecificArgs(args, config, env);
|
||||||
args.push("--json");
|
args.push("--json");
|
||||||
|
|
||||||
console.log("Restic restore command:", ["restic", ...args].join(" "));
|
console.log("Restic restore command:", ["restic", ...args].join(" "));
|
||||||
|
|
||||||
const res = await $`restic ${args}`.env(env).nothrow();
|
const res = await $`restic ${args}`.env(env).nothrow();
|
||||||
|
await cleanupTemporaryKeys(config, env);
|
||||||
|
|
||||||
if (res.exitCode !== 0) {
|
if (res.exitCode !== 0) {
|
||||||
logger.error(`Restic restore failed: ${res.stderr}`);
|
logger.error(`Restic restore failed: ${res.stderr}`);
|
||||||
@@ -362,9 +449,11 @@ const snapshots = async (config: RepositoryConfig, options: { tags?: string[] }
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
addRepoSpecificArgs(args, config, env);
|
||||||
args.push("--json");
|
args.push("--json");
|
||||||
|
|
||||||
const res = await $`restic ${args}`.env(env).nothrow().quiet();
|
const res = await $`restic ${args}`.env(env).nothrow().quiet();
|
||||||
|
await cleanupTemporaryKeys(config, env);
|
||||||
|
|
||||||
if (res.exitCode !== 0) {
|
if (res.exitCode !== 0) {
|
||||||
logger.error(`Restic snapshots retrieval failed: ${res.stderr}`);
|
logger.error(`Restic snapshots retrieval failed: ${res.stderr}`);
|
||||||
@@ -410,9 +499,11 @@ const forget = async (config: RepositoryConfig, options: RetentionPolicy, extra:
|
|||||||
}
|
}
|
||||||
|
|
||||||
args.push("--prune");
|
args.push("--prune");
|
||||||
|
addRepoSpecificArgs(args, config, env);
|
||||||
args.push("--json");
|
args.push("--json");
|
||||||
|
|
||||||
const res = await $`restic ${args}`.env(env).nothrow();
|
const res = await $`restic ${args}`.env(env).nothrow();
|
||||||
|
await cleanupTemporaryKeys(config, env);
|
||||||
|
|
||||||
if (res.exitCode !== 0) {
|
if (res.exitCode !== 0) {
|
||||||
logger.error(`Restic forget failed: ${res.stderr}`);
|
logger.error(`Restic forget failed: ${res.stderr}`);
|
||||||
@@ -422,6 +513,24 @@ const forget = async (config: RepositoryConfig, options: RetentionPolicy, extra:
|
|||||||
return { success: true };
|
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({
|
const lsNodeSchema = type({
|
||||||
name: "string",
|
name: "string",
|
||||||
type: "string",
|
type: "string",
|
||||||
@@ -459,7 +568,10 @@ const ls = async (config: RepositoryConfig, snapshotId: string, path?: string) =
|
|||||||
args.push(path);
|
args.push(path);
|
||||||
}
|
}
|
||||||
|
|
||||||
const res = await $`restic ${args}`.env(env).nothrow().quiet();
|
addRepoSpecificArgs(args, config, env);
|
||||||
|
|
||||||
|
const res = await safeSpawn({ command: "restic", args, env });
|
||||||
|
await cleanupTemporaryKeys(config, env);
|
||||||
|
|
||||||
if (res.exitCode !== 0) {
|
if (res.exitCode !== 0) {
|
||||||
logger.error(`Restic ls failed: ${res.stderr}`);
|
logger.error(`Restic ls failed: ${res.stderr}`);
|
||||||
@@ -467,7 +579,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
|
// The output is a stream of JSON objects, first is snapshot info, rest are file/dir nodes
|
||||||
const stdout = res.text();
|
const stdout = res.stdout;
|
||||||
const lines = stdout
|
const lines = stdout
|
||||||
.trim()
|
.trim()
|
||||||
.split("\n")
|
.split("\n")
|
||||||
@@ -506,7 +618,11 @@ const unlock = async (config: RepositoryConfig) => {
|
|||||||
const repoUrl = buildRepoUrl(config);
|
const repoUrl = buildRepoUrl(config);
|
||||||
const env = await buildEnv(config);
|
const env = await buildEnv(config);
|
||||||
|
|
||||||
const res = await $`restic unlock --repo ${repoUrl} --remove-all --json`.env(env).nothrow();
|
const args = ["unlock", "--repo", repoUrl, "--remove-all", "--json"];
|
||||||
|
addRepoSpecificArgs(args, config, env);
|
||||||
|
|
||||||
|
const res = await $`restic ${args}`.env(env).nothrow();
|
||||||
|
await cleanupTemporaryKeys(config, env);
|
||||||
|
|
||||||
if (res.exitCode !== 0) {
|
if (res.exitCode !== 0) {
|
||||||
logger.error(`Restic unlock failed: ${res.stderr}`);
|
logger.error(`Restic unlock failed: ${res.stderr}`);
|
||||||
@@ -527,7 +643,10 @@ const check = async (config: RepositoryConfig, options?: { readData?: boolean })
|
|||||||
args.push("--read-data");
|
args.push("--read-data");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
addRepoSpecificArgs(args, config, env);
|
||||||
|
|
||||||
const res = await $`restic ${args}`.env(env).nothrow();
|
const res = await $`restic ${args}`.env(env).nothrow();
|
||||||
|
await cleanupTemporaryKeys(config, env);
|
||||||
|
|
||||||
const stdout = res.text();
|
const stdout = res.text();
|
||||||
const stderr = res.stderr.toString();
|
const stderr = res.stderr.toString();
|
||||||
@@ -557,7 +676,11 @@ const repairIndex = async (config: RepositoryConfig) => {
|
|||||||
const repoUrl = buildRepoUrl(config);
|
const repoUrl = buildRepoUrl(config);
|
||||||
const env = await buildEnv(config);
|
const env = await buildEnv(config);
|
||||||
|
|
||||||
const res = await $`restic repair index --repo ${repoUrl}`.env(env).nothrow();
|
const args = ["repair", "index", "--repo", repoUrl];
|
||||||
|
addRepoSpecificArgs(args, config, env);
|
||||||
|
|
||||||
|
const res = await $`restic ${args}`.env(env).nothrow();
|
||||||
|
await cleanupTemporaryKeys(config, env);
|
||||||
|
|
||||||
const stdout = res.text();
|
const stdout = res.text();
|
||||||
const stderr = res.stderr.toString();
|
const stderr = res.stderr.toString();
|
||||||
@@ -575,6 +698,22 @@ 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 = {
|
export const restic = {
|
||||||
ensurePassfile,
|
ensurePassfile,
|
||||||
init,
|
init,
|
||||||
@@ -582,6 +721,7 @@ export const restic = {
|
|||||||
restore,
|
restore,
|
||||||
snapshots,
|
snapshots,
|
||||||
forget,
|
forget,
|
||||||
|
deleteSnapshot,
|
||||||
unlock,
|
unlock,
|
||||||
ls,
|
ls,
|
||||||
check,
|
check,
|
||||||
|
|||||||
8
bun.lock
8
bun.lock
@@ -4,7 +4,6 @@
|
|||||||
"": {
|
"": {
|
||||||
"name": "@ironmount/client",
|
"name": "@ironmount/client",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@hono/node-server": "^1.19.6",
|
|
||||||
"@hono/standard-validator": "^0.1.5",
|
"@hono/standard-validator": "^0.1.5",
|
||||||
"@hookform/resolvers": "^5.2.2",
|
"@hookform/resolvers": "^5.2.2",
|
||||||
"@radix-ui/react-alert-dialog": "^1.1.15",
|
"@radix-ui/react-alert-dialog": "^1.1.15",
|
||||||
@@ -23,8 +22,6 @@
|
|||||||
"@react-router/serve": "^7.9.3",
|
"@react-router/serve": "^7.9.3",
|
||||||
"@scalar/hono-api-reference": "^0.9.24",
|
"@scalar/hono-api-reference": "^0.9.24",
|
||||||
"@tanstack/react-query": "^5.90.2",
|
"@tanstack/react-query": "^5.90.2",
|
||||||
"@tanstack/react-query-devtools": "^5.90.2",
|
|
||||||
"@tanstack/react-table": "^8.21.3",
|
|
||||||
"arktype": "^2.1.26",
|
"arktype": "^2.1.26",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
@@ -60,6 +57,7 @@
|
|||||||
"@hey-api/openapi-ts": "^0.87.4",
|
"@hey-api/openapi-ts": "^0.87.4",
|
||||||
"@react-router/dev": "^7.9.3",
|
"@react-router/dev": "^7.9.3",
|
||||||
"@tailwindcss/vite": "^4.1.14",
|
"@tailwindcss/vite": "^4.1.14",
|
||||||
|
"@tanstack/react-query-devtools": "^5.90.2",
|
||||||
"@types/bun": "^1.3.2",
|
"@types/bun": "^1.3.2",
|
||||||
"@types/dockerode": "^3.3.45",
|
"@types/dockerode": "^3.3.45",
|
||||||
"@types/node": "^24.6.2",
|
"@types/node": "^24.6.2",
|
||||||
@@ -482,10 +480,6 @@
|
|||||||
|
|
||||||
"@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-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/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=="],
|
"@types/d3-array": ["@types/d3-array@3.2.2", "", {}, "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw=="],
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
services:
|
services:
|
||||||
ironmount-dev:
|
zerobyte-dev:
|
||||||
build:
|
build:
|
||||||
context: .
|
context: .
|
||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
target: development
|
target: development
|
||||||
container_name: ironmount
|
container_name: zerobyte
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
devices:
|
devices:
|
||||||
- /dev/fuse:/dev/fuse
|
- /dev/fuse:/dev/fuse
|
||||||
@@ -15,17 +15,21 @@ services:
|
|||||||
ports:
|
ports:
|
||||||
- "4096:4096"
|
- "4096:4096"
|
||||||
volumes:
|
volumes:
|
||||||
- /var/lib/ironmount:/var/lib/ironmount
|
- /etc/localtime:/etc/localtime:ro
|
||||||
|
- /var/lib/zerobyte:/var/lib/zerobyte
|
||||||
|
|
||||||
- ./app:/app/app
|
- ./app:/app/app
|
||||||
# - ~/.config/rclone:/root/.config/rclone
|
- ~/.config/rclone:/root/.config/rclone
|
||||||
|
- /var/lib/zerobyte:/var/lib/zerobyte:rshared
|
||||||
|
- /run/docker/plugins:/run/docker/plugins
|
||||||
|
- /var/run/docker.sock:/var/run/docker.sock
|
||||||
|
|
||||||
ironmount-prod:
|
zerobyte-prod:
|
||||||
build:
|
build:
|
||||||
context: .
|
context: .
|
||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
target: production
|
target: production
|
||||||
container_name: ironmount
|
container_name: zerobyte
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
devices:
|
devices:
|
||||||
- /dev/fuse:/dev/fuse
|
- /dev/fuse:/dev/fuse
|
||||||
@@ -34,6 +38,7 @@ services:
|
|||||||
ports:
|
ports:
|
||||||
- "4096:4096"
|
- "4096:4096"
|
||||||
volumes:
|
volumes:
|
||||||
- /var/lib/ironmount:/var/lib/ironmount:rshared
|
- /etc/localtime:/etc/localtime:ro
|
||||||
|
- /var/lib/zerobyte:/var/lib/zerobyte:rshared
|
||||||
- /run/docker/plugins:/run/docker/plugins
|
- /run/docker/plugins:/run/docker/plugins
|
||||||
- /var/run/docker.sock:/var/run/docker.sock
|
- /var/run/docker.sock:/var/run/docker.sock
|
||||||
|
|||||||
@@ -6,6 +6,6 @@ export default defineConfig({
|
|||||||
schema: "./app/server/db/schema.ts",
|
schema: "./app/server/db/schema.ts",
|
||||||
dialect: "sqlite",
|
dialect: "sqlite",
|
||||||
dbCredentials: {
|
dbCredentials: {
|
||||||
url: "./data/ironmount.db",
|
url: "./data/zerobyte.db",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -10,9 +10,9 @@ sync:
|
|||||||
- "logs"
|
- "logs"
|
||||||
- "mutagen.yml.lock"
|
- "mutagen.yml.lock"
|
||||||
- "data"
|
- "data"
|
||||||
ironmount:
|
zerobyte:
|
||||||
alpha: "."
|
alpha: "."
|
||||||
beta: "nicolas@192.168.2.42:/home/nicolas/ironmount"
|
beta: "nicolas@192.168.2.42:/home/nicolas/zerobyte"
|
||||||
mode: "one-way-replica"
|
mode: "one-way-replica"
|
||||||
flushOnCreate: true
|
flushOnCreate: true
|
||||||
ignore:
|
ignore:
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
proj_Nwis7nYU1DiPGTtNlwRKBVtdgo5cOWPsnwbtxj2Urg0
|
|
||||||
4
notes.md
4
notes.md
@@ -1,4 +0,0 @@
|
|||||||
docker run --rm -it -v nicolas:/data alpine sh -lc 'echo hello > /data/hi && cat /data/hi'
|
|
||||||
|
|
||||||
mount -t davfs http://192.168.2.42 /mnt/webdav
|
|
||||||
|
|
||||||
10
package.json
10
package.json
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"name": "ironmount",
|
"name": "zerobyte",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"packageManager": "bun@1.3.1",
|
"packageManager": "bun@1.3.1",
|
||||||
@@ -10,14 +10,13 @@
|
|||||||
"tsc": "react-router typegen && tsc",
|
"tsc": "react-router typegen && tsc",
|
||||||
"lint": "biome check .",
|
"lint": "biome check .",
|
||||||
"lint:ci": "biome check . --ci",
|
"lint:ci": "biome check . --ci",
|
||||||
"start:dev": "docker compose down && docker compose up --build ironmount-dev",
|
"start:dev": "docker compose down && docker compose up --build zerobyte-dev",
|
||||||
"start:prod": "docker compose down && docker compose up --build ironmount-prod",
|
"start:prod": "docker compose down && docker compose up --build zerobyte-prod",
|
||||||
"gen:api-client": "openapi-ts",
|
"gen:api-client": "openapi-ts",
|
||||||
"gen:migrations": "drizzle-kit generate",
|
"gen:migrations": "drizzle-kit generate",
|
||||||
"studio": "drizzle-kit studio"
|
"studio": "drizzle-kit studio"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@hono/node-server": "^1.19.6",
|
|
||||||
"@hono/standard-validator": "^0.1.5",
|
"@hono/standard-validator": "^0.1.5",
|
||||||
"@hookform/resolvers": "^5.2.2",
|
"@hookform/resolvers": "^5.2.2",
|
||||||
"@radix-ui/react-alert-dialog": "^1.1.15",
|
"@radix-ui/react-alert-dialog": "^1.1.15",
|
||||||
@@ -36,8 +35,6 @@
|
|||||||
"@react-router/serve": "^7.9.3",
|
"@react-router/serve": "^7.9.3",
|
||||||
"@scalar/hono-api-reference": "^0.9.24",
|
"@scalar/hono-api-reference": "^0.9.24",
|
||||||
"@tanstack/react-query": "^5.90.2",
|
"@tanstack/react-query": "^5.90.2",
|
||||||
"@tanstack/react-query-devtools": "^5.90.2",
|
|
||||||
"@tanstack/react-table": "^8.21.3",
|
|
||||||
"arktype": "^2.1.26",
|
"arktype": "^2.1.26",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
@@ -73,6 +70,7 @@
|
|||||||
"@hey-api/openapi-ts": "^0.87.4",
|
"@hey-api/openapi-ts": "^0.87.4",
|
||||||
"@react-router/dev": "^7.9.3",
|
"@react-router/dev": "^7.9.3",
|
||||||
"@tailwindcss/vite": "^4.1.14",
|
"@tailwindcss/vite": "^4.1.14",
|
||||||
|
"@tanstack/react-query-devtools": "^5.90.2",
|
||||||
"@types/bun": "^1.3.2",
|
"@types/bun": "^1.3.2",
|
||||||
"@types/dockerode": "^3.3.45",
|
"@types/dockerode": "^3.3.45",
|
||||||
"@types/node": "^24.6.2",
|
"@types/node": "^24.6.2",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "Ironmount",
|
"name": "Zerobyte",
|
||||||
"short_name": "Ironmount",
|
"short_name": "Zerobyte",
|
||||||
"icons": [
|
"icons": [
|
||||||
{
|
{
|
||||||
"src": "/images/favicon/web-app-manifest-192x192.png",
|
"src": "/images/favicon/web-app-manifest-192x192.png",
|
||||||
|
|||||||
BIN
public/images/zerobyte.png
Normal file
BIN
public/images/zerobyte.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 8.1 KiB |
BIN
screenshots/backup-details.png
Normal file
BIN
screenshots/backup-details.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 296 KiB |
Reference in New Issue
Block a user