mirror of
https://github.com/nicotsx/ironmount.git
synced 2025-12-10 12:10:51 +01:00
Compare commits
76 Commits
v0.4.0
...
v0.9.0-bet
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3ff6a04f8e | ||
|
|
54ee02deb9 | ||
|
|
b83881c189 | ||
|
|
d78b4adfd9 | ||
|
|
4d3ec524e2 | ||
|
|
681cf5dff1 | ||
|
|
31da747c2d | ||
|
|
b86081b2e8 | ||
|
|
3622fd57ef | ||
|
|
5b1d7eff17 | ||
|
|
2b3d8dffc5 | ||
|
|
f517438a8e | ||
|
|
1ddd4d701b | ||
|
|
9a1797b8b2 | ||
|
|
52046c88cc | ||
|
|
951d9d970c | ||
|
|
ffc821af2b | ||
|
|
cfeff643c4 | ||
|
|
c898e1ce07 | ||
|
|
c179a16d15 | ||
|
|
00916a1fd2 | ||
|
|
18f863cbac | ||
|
|
1b8595c17e | ||
|
|
6e6becec3b | ||
|
|
6d3d3c38f9 | ||
|
|
95a0d44b45 | ||
|
|
8d7e50508d | ||
|
|
52e38a6242 | ||
|
|
36b0282d18 | ||
|
|
8f9873148a | ||
|
|
a1cc89c66e | ||
|
|
ff7f6ffad9 | ||
|
|
e98c0af8ca | ||
|
|
d31fa8d464 | ||
|
|
bf33b15b3e | ||
|
|
2b0fea9645 | ||
|
|
e9eeda304b | ||
|
|
4ddc45a74f | ||
|
|
2aa90ec44d | ||
|
|
dd36397346 | ||
|
|
2ec8d4c1dd | ||
|
|
4b981bdcac | ||
|
|
5e908dc945 | ||
|
|
5f35cfd4c2 | ||
|
|
1152939373 | ||
|
|
94398f81bf | ||
|
|
db0d153610 | ||
|
|
5ff48f4d5d | ||
|
|
ffca433a43 | ||
|
|
4389029ba5 | ||
|
|
927db77f60 | ||
|
|
3e80850396 | ||
|
|
5f620b4c45 | ||
|
|
3abf8ab12d | ||
|
|
b5ba03da3d | ||
|
|
b289920720 | ||
|
|
ef5d95d347 | ||
|
|
a1ef34118c | ||
|
|
59433f3686 | ||
|
|
3debd80e15 | ||
|
|
0461ed63d8 | ||
|
|
1c4b1f34dd | ||
|
|
11dd6f46c8 | ||
|
|
fd3a527164 | ||
|
|
5b4b571581 | ||
|
|
4aeebea5b2 | ||
|
|
ba08c97681 | ||
|
|
42497be4b5 | ||
|
|
9d27bffc21 | ||
|
|
9ec765bd90 | ||
|
|
4dc239139f | ||
|
|
f5339d3708 | ||
|
|
418369c4ad | ||
|
|
195aea052e | ||
|
|
c32eb0831f | ||
|
|
885ae02b18 |
@@ -12,14 +12,12 @@
|
||||
!**/build.ts
|
||||
!**/components.json
|
||||
|
||||
!apps/**/src/**
|
||||
!apps/**/drizzle/**
|
||||
!apps/**/app/**
|
||||
!apps/**/public/**
|
||||
|
||||
!packages/**/src/**
|
||||
!src/**
|
||||
!app/**
|
||||
!public/**
|
||||
|
||||
# License files and attributions
|
||||
!LICENSE
|
||||
!NOTICES.md
|
||||
!LICENSES/**
|
||||
|
||||
|
||||
49
.gitignore
vendored
49
.gitignore
vendored
@@ -1,47 +1,14 @@
|
||||
# If you prefer the allow list template instead of the deny list, see community template:
|
||||
# https://github.com/github/gitignore/blob/main/community/Golang/Go.AllowList.gitignore
|
||||
#
|
||||
# Binaries for programs and plugins
|
||||
*.exe
|
||||
*.exe~
|
||||
*.dll
|
||||
*.so
|
||||
*.dylib
|
||||
.DS_Store
|
||||
/node_modules/
|
||||
|
||||
# Test binary, built with `go test -c`
|
||||
*.test
|
||||
# React Router
|
||||
/.react-router/
|
||||
/build/
|
||||
/dist/
|
||||
|
||||
# Code coverage profiles and other test artifacts
|
||||
*.out
|
||||
coverage.*
|
||||
*.coverprofile
|
||||
profile.cov
|
||||
|
||||
# Dependency directories (remove the comment below to include it)
|
||||
# vendor/
|
||||
|
||||
# Go workspace file
|
||||
go.work
|
||||
go.work.sum
|
||||
|
||||
# env file
|
||||
.env
|
||||
|
||||
# Editor/IDE
|
||||
# .idea/
|
||||
# .vscode/
|
||||
ironmount
|
||||
out/
|
||||
*.db
|
||||
tmp/
|
||||
|
||||
node_modules/
|
||||
.env*
|
||||
|
||||
.turbo
|
||||
CLAUDE.md
|
||||
|
||||
mutagen.yml.lock
|
||||
|
||||
data/
|
||||
|
||||
CLAUDE.md
|
||||
notes.md
|
||||
|
||||
31
Dockerfile
31
Dockerfile
@@ -21,11 +21,17 @@ RUN apk add --no-cache curl bzip2
|
||||
RUN echo "Building for ${TARGETARCH}"
|
||||
RUN if [ "${TARGETARCH}" = "arm64" ]; then \
|
||||
curl -L -o restic.bz2 "https://github.com/restic/restic/releases/download/v$RESTIC_VERSION/restic_$RESTIC_VERSION"_linux_arm64.bz2; \
|
||||
curl -O https://downloads.rclone.org/rclone-current-linux-arm64.zip; \
|
||||
unzip rclone-current-linux-arm64.zip; \
|
||||
elif [ "${TARGETARCH}" = "amd64" ]; then \
|
||||
curl -L -o restic.bz2 "https://github.com/restic/restic/releases/download/v$RESTIC_VERSION/restic_$RESTIC_VERSION"_linux_amd64.bz2; \
|
||||
curl -O https://downloads.rclone.org/rclone-current-linux-amd64.zip; \
|
||||
unzip rclone-current-linux-amd64.zip; \
|
||||
fi
|
||||
|
||||
RUN bzip2 -d restic.bz2 && chmod +x restic
|
||||
RUN mv rclone-*-linux-*/rclone /deps/rclone && chmod +x /deps/rclone
|
||||
|
||||
|
||||
# ------------------------------
|
||||
# DEVELOPMENT
|
||||
@@ -37,16 +43,14 @@ ENV NODE_ENV="development"
|
||||
WORKDIR /app
|
||||
|
||||
COPY --from=deps /deps/restic /usr/local/bin/restic
|
||||
COPY --from=deps /deps/rclone /usr/local/bin/rclone
|
||||
COPY ./package.json ./bun.lock ./
|
||||
COPY ./packages/schemas/package.json ./packages/schemas/package.json
|
||||
COPY ./apps/client/package.json ./apps/client/package.json
|
||||
COPY ./apps/server/package.json ./apps/server/package.json
|
||||
|
||||
RUN bun install --frozen-lockfile
|
||||
|
||||
COPY . .
|
||||
|
||||
EXPOSE 3000
|
||||
EXPOSE 4096
|
||||
|
||||
CMD ["bun", "run", "dev"]
|
||||
|
||||
@@ -58,11 +62,6 @@ FROM oven/bun:${BUN_VERSION} AS builder
|
||||
WORKDIR /app
|
||||
|
||||
COPY ./package.json ./bun.lock ./
|
||||
|
||||
COPY ./packages/schemas/package.json ./packages/schemas/package.json
|
||||
COPY ./apps/client/package.json ./apps/client/package.json
|
||||
COPY ./apps/server/package.json ./apps/server/package.json
|
||||
|
||||
RUN bun install --frozen-lockfile
|
||||
|
||||
COPY . .
|
||||
@@ -75,15 +74,21 @@ ENV NODE_ENV="production"
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY --from=builder /app/package.json ./
|
||||
RUN bun install --production --frozen-lockfile
|
||||
|
||||
COPY --from=deps /deps/restic /usr/local/bin/restic
|
||||
COPY --from=builder /app/apps/server/dist ./
|
||||
COPY --from=builder /app/apps/server/drizzle ./assets/migrations
|
||||
COPY --from=builder /app/apps/client/dist/client ./assets/frontend
|
||||
COPY --from=deps /deps/rclone /usr/local/bin/rclone
|
||||
COPY --from=builder /app/dist/client ./dist/client
|
||||
COPY --from=builder /app/dist/server ./dist/server
|
||||
COPY --from=builder /app/app/drizzle ./assets/migrations
|
||||
|
||||
# Include third-party licenses and attribution
|
||||
COPY ./LICENSES ./LICENSES
|
||||
COPY ./NOTICES.md ./NOTICES.md
|
||||
COPY ./LICENSE ./LICENSE.md
|
||||
|
||||
CMD ["bun", "./index.js"]
|
||||
EXPOSE 4096
|
||||
|
||||
CMD ["bun", "run", "start"]
|
||||
|
||||
|
||||
248
README.md
248
README.md
@@ -1,39 +1,33 @@
|
||||
<div align="center">
|
||||
<h1>Ironmount</h1>
|
||||
<h3>Keep your volumes in check!<br />One interface to manage all your storage</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">
|
||||
<img src="https://img.shields.io/github/license/nicotsx/ironmount" />
|
||||
</a>
|
||||
<br />
|
||||
<figure>
|
||||
<img src="https://github.com/nicotsx/ironmount/blob/main/screenshots/volume-details.png?raw=true" alt="Demo" />
|
||||
<img src="https://github.com/nicotsx/ironmount/blob/main/screenshots/backup-details.png?raw=true" alt="Demo" />
|
||||
<figcaption>
|
||||
<p align="center">
|
||||
Volume details view with usage statistics and health check status
|
||||
Backup management with scheduling and monitoring
|
||||
</p>
|
||||
</figcaption>
|
||||
</figure>
|
||||
</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
|
||||
|
||||
## Intro
|
||||
|
||||
Ironmount is an easy to use web interface to manage your remote storage and mount them as local volumes on your server. Docker as a first class citizen, Ironmount allows you to easily mount your remote storage directly into your containers with few lines of code.
|
||||
Ironmount is a backup automation tool that helps you save your data across multiple storage backends. Built on top of Restic, it provides an modern web interface to schedule, manage, and monitor encrypted backups of your remote storage.
|
||||
|
||||
### Features
|
||||
|
||||
- ✅ Support for multiple protocols: NFS, SMB, WebDAV, Directory
|
||||
- 📡 Mount your remote storage as local folders
|
||||
- 🐳 Docker integration: mount your remote storage directly into your containers via a docker volume syntax
|
||||
- 🔍 Keep an eye on your mounts with health checks and automatic remounting on error
|
||||
- 📊 Monitor your mounts usage with detailed statistics and graphs
|
||||
|
||||
### Coming soon
|
||||
|
||||
- Automated backups with encryption and retention policies
|
||||
- Integration with cloud storage providers (e.g. AWS S3, Google Drive, Dropbox)
|
||||
- **Automated backups** with encryption, compression and retention policies powered by Restic
|
||||
- **Flexible scheduling** For automated backup jobs with fine-grained retention policies
|
||||
- **End-to-end encryption** ensuring your data is always protected
|
||||
- **Multi-protocol support**: Backup from NFS, SMB, WebDAV, or local directories
|
||||
|
||||
## Installation
|
||||
|
||||
@@ -42,25 +36,17 @@ In order to run Ironmount, you need to have Docker and Docker Compose installed
|
||||
```yaml
|
||||
services:
|
||||
ironmount:
|
||||
image: ghcr.io/nicotsx/ironmount:v0.4.0
|
||||
image: ghcr.io/nicotsx/ironmount:v0.8
|
||||
container_name: ironmount
|
||||
restart: unless-stopped
|
||||
privileged: true
|
||||
cap_add:
|
||||
- SYS_ADMIN
|
||||
ports:
|
||||
- "4096:4096"
|
||||
devices:
|
||||
- /dev/fuse:/dev/fuse
|
||||
volumes:
|
||||
- /var/run/docker.sock:/var/run/docker.sock
|
||||
- /run/docker/plugins:/run/docker/plugins
|
||||
- /var/lib/ironmount/volumes/:/var/lib/ironmount/volumes:rslave
|
||||
- /var/lib/repositories/:/var/lib/repositories
|
||||
- /proc:/host/proc:ro
|
||||
- ironmount_data:/data
|
||||
|
||||
volumes:
|
||||
ironmount_data:
|
||||
driver: local
|
||||
- /var/lib/ironmount:/var/lib/ironmount
|
||||
```
|
||||
|
||||
Then, run the following command to start Ironmount:
|
||||
@@ -71,9 +57,213 @@ docker compose up -d
|
||||
|
||||
Once the container is running, you can access the web interface at `http://<your-server-ip>:4096`.
|
||||
|
||||
## Docker volume usage
|
||||
## 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.
|
||||
|
||||
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:
|
||||
|
||||
```diff
|
||||
services:
|
||||
ironmount:
|
||||
image: ghcr.io/nicotsx/ironmount:v0.8
|
||||
container_name: ironmount
|
||||
restart: unless-stopped
|
||||
cap_add:
|
||||
- SYS_ADMIN
|
||||
ports:
|
||||
- "4096:4096"
|
||||
devices:
|
||||
- /dev/fuse:/dev/fuse
|
||||
volumes:
|
||||
- /var/lib/ironmount:/var/lib/ironmount
|
||||
+ - /path/to/your/directory:/mydata
|
||||
```
|
||||
|
||||
After updating the `docker-compose.yml` file, restart the Ironmount container to apply the changes:
|
||||
|
||||
```bash
|
||||
docker compose down
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
Now, when adding a new volume in the Ironmount web interface, you can select "Directory" as the volume type and search for your mounted path (e.g., `/mydata`) as the source path.
|
||||
|
||||

|
||||
|
||||
## Creating a repository
|
||||
|
||||
A repository is where your backups will be securely stored encrypted. Ironmount supports multiple storage backends for your backup repositories:
|
||||
|
||||
- **Local directories** - Store backups on local disk at `/var/lib/ironmount/repositories/<repository-name>`
|
||||
- **S3-compatible storage** - Amazon S3, MinIO, Wasabi, DigitalOcean Spaces, etc.
|
||||
- **Google Cloud Storage** - Google's cloud storage service
|
||||
- **Azure Blob Storage** - Microsoft Azure storage
|
||||
- **rclone remotes** - 40+ cloud storage providers via rclone (see below)
|
||||
|
||||
Repositories are optimized for storage efficiency and data integrity, leveraging Restic's deduplication and encryption features.
|
||||
|
||||
To create a repository, navigate to the "Repositories" section in the web interface and click on "Create repository". Fill in the required details such as repository name, type, and connection settings.
|
||||
|
||||
### 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.
|
||||
|
||||
**Setup instructions:**
|
||||
|
||||
1. **Install rclone on your host system** (if not already installed):
|
||||
```bash
|
||||
curl https://rclone.org/install.sh | sudo bash
|
||||
```
|
||||
|
||||
2. **Configure your cloud storage remote** using rclone's interactive config:
|
||||
```bash
|
||||
rclone config
|
||||
```
|
||||
Follow the prompts to set up your cloud storage provider. For OAuth providers (Google Drive, Dropbox, etc.), rclone will guide you through the authentication flow.
|
||||
|
||||
3. **Verify your remote is configured**:
|
||||
```bash
|
||||
rclone listremotes
|
||||
```
|
||||
|
||||
4. **Mount the rclone config into the Ironmount container** by updating your `docker-compose.yml`:
|
||||
```diff
|
||||
services:
|
||||
ironmount:
|
||||
image: ghcr.io/nicotsx/ironmount:v0.8
|
||||
container_name: ironmount
|
||||
restart: unless-stopped
|
||||
cap_add:
|
||||
- SYS_ADMIN
|
||||
ports:
|
||||
- "4096:4096"
|
||||
devices:
|
||||
- /dev/fuse:/dev/fuse
|
||||
volumes:
|
||||
- /var/lib/ironmount:/var/lib/ironmount
|
||||
+ - ~/.config/rclone:/root/.config/rclone
|
||||
```
|
||||
|
||||
5. **Restart the Ironmount container**:
|
||||
```bash
|
||||
docker compose down
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
6. **Create a repository** in Ironmount:
|
||||
- Select "rclone" as the repository type
|
||||
- Choose your configured remote from the dropdown
|
||||
- Specify the path within your remote (e.g., `backups/ironmount`)
|
||||
|
||||
For a complete list of supported providers, see the [rclone documentation](https://rclone.org/).
|
||||
|
||||
## Your first backup job
|
||||
|
||||
Once you have added a volume and created a repository, you can create your first backup job. A backup job defines the schedule and parameters for backing up a specific volume to a designated repository.
|
||||
|
||||
When creating a backup job, you can specify the following settings:
|
||||
- **Schedule**: Define how often the backup should run (e.g., daily, weekly)
|
||||
- **Retention Policy**: Set rules for how long backups should be retained (e.g., keep daily backups for 7 days, weekly backups for 4 weeks)
|
||||
- **Paths**: Specify which files or directories to include in the backup
|
||||
|
||||
After configuring the backup job, save it and Ironmount will automatically execute the backup according to the defined schedule.
|
||||
You can monitor the progress and status of your backup jobs in the "Backups" section of the web interface.
|
||||
|
||||

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

|
||||
|
||||
## 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.
|
||||
|
||||
In order to enable this feature, you need to change your bind mount `/var/lib/ironmount` to use the `:rshared` flag. Here is an example of how to set this up in your `docker-compose.yml` file:
|
||||
|
||||
```diff
|
||||
services:
|
||||
ironmount:
|
||||
image: ghcr.io/nicotsx/ironmount:v0.8
|
||||
container_name: ironmount
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "4096:4096"
|
||||
devices:
|
||||
- /dev/fuse:/dev/fuse
|
||||
volumes:
|
||||
- - /var/lib/ironmount:/var/lib/ironmount
|
||||
+ - /var/lib/ironmount:/var/lib/ironmount:rshared
|
||||
```
|
||||
|
||||
Restart the Ironmount container to apply the changes:
|
||||
|
||||
```bash
|
||||
docker compose down
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
## Docker plugin
|
||||
|
||||
Ironmount can also be used as a Docker volume plugin, allowing you to mount your volumes directly into other Docker containers. This enables seamless integration with your containerized applications.
|
||||
|
||||
In order to enable this feature, you need to run Ironmount with several items shared from the host. Here is an example of how to set this up in your `docker-compose.yml` file:
|
||||
|
||||
```diff
|
||||
services:
|
||||
ironmount:
|
||||
image: ghcr.io/nicotsx/ironmount:v0.8
|
||||
container_name: ironmount
|
||||
restart: unless-stopped
|
||||
cap_add:
|
||||
- SYS_ADMIN
|
||||
ports:
|
||||
- "4096:4096"
|
||||
devices:
|
||||
- /dev/fuse:/dev/fuse
|
||||
volumes:
|
||||
- - /var/lib/ironmount:/var/lib/ironmount
|
||||
+ - /var/lib/ironmount:/var/lib/ironmount:rshared
|
||||
+ - /run/docker/plugins:/run/docker/plugins
|
||||
+ - /var/run/docker.sock:/var/run/docker.sock
|
||||
```
|
||||
|
||||
Restart the Ironmount container to apply the changes:
|
||||
|
||||
```bash
|
||||
docker compose down
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
Your Ironmount volumes will now be available as Docker volumes that you can mount into other containers using the `--volume` flag:
|
||||
|
||||
```bash
|
||||
docker run -v im-nfs:/path/in/container nginx:latest
|
||||
```
|
||||
|
||||
Or using Docker Compose:
|
||||
|
||||
```yaml
|
||||
services:
|
||||
myservice:
|
||||
image: nginx:latest
|
||||
volumes:
|
||||
- im-nfs:/path/in/container
|
||||
volumes:
|
||||
im-nfs:
|
||||
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:
|
||||
|
||||
```bash
|
||||
docker volume ls
|
||||
```
|
||||
|
||||
## Third-Party Software
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
|
||||
@theme {
|
||||
--breakpoint-xs: 32rem;
|
||||
--font-sans:
|
||||
"Google Sans Code", ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol",
|
||||
"Noto Color Emoji";
|
||||
@@ -12,16 +13,16 @@
|
||||
|
||||
html,
|
||||
body {
|
||||
@apply bg-white dark:bg-[#131313];
|
||||
overflow-x: hidden;
|
||||
width: 100%;
|
||||
position: relative;
|
||||
overscroll-behavior: none;
|
||||
scrollbar-width: thin;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
color-scheme: dark;
|
||||
}
|
||||
body {
|
||||
@apply bg-[#131313];
|
||||
min-height: 100dvh;
|
||||
}
|
||||
|
||||
.main-content {
|
||||
@@ -107,6 +108,8 @@ body {
|
||||
}
|
||||
|
||||
.dark {
|
||||
color-scheme: dark;
|
||||
|
||||
--background: #131313;
|
||||
--foreground: oklch(0.985 0 0);
|
||||
--card: #131313;
|
||||
722
app/client/api-client/@tanstack/react-query.gen.ts
Normal file
722
app/client/api-client/@tanstack/react-query.gen.ts
Normal file
@@ -0,0 +1,722 @@
|
||||
// This file is auto-generated by @hey-api/openapi-ts
|
||||
|
||||
import { type DefaultError, queryOptions, type UseMutationOptions } from '@tanstack/react-query';
|
||||
|
||||
import { client } from '../client.gen';
|
||||
import { browseFilesystem, changePassword, createBackupSchedule, createRepository, createVolume, deleteBackupSchedule, deleteRepository, deleteVolume, doctorRepository, downloadResticPassword, getBackupSchedule, getBackupScheduleForVolume, getContainersUsingVolume, getMe, getRepository, getSnapshotDetails, getStatus, getSystemInfo, getVolume, healthCheckVolume, listBackupSchedules, listFiles, listRcloneRemotes, listRepositories, listSnapshotFiles, listSnapshots, listVolumes, login, logout, mountVolume, type Options, register, restoreSnapshot, runBackupNow, runForget, stopBackup, testConnection, unmountVolume, updateBackupSchedule, updateVolume } from '../sdk.gen';
|
||||
import type { BrowseFilesystemData, BrowseFilesystemResponse, ChangePasswordData, ChangePasswordResponse, CreateBackupScheduleData, CreateBackupScheduleResponse, CreateRepositoryData, CreateRepositoryResponse, CreateVolumeData, CreateVolumeResponse, DeleteBackupScheduleData, DeleteBackupScheduleResponse, DeleteRepositoryData, DeleteRepositoryResponse, DeleteVolumeData, DeleteVolumeResponse, DoctorRepositoryData, DoctorRepositoryResponse, DownloadResticPasswordData, DownloadResticPasswordResponse, GetBackupScheduleData, GetBackupScheduleForVolumeData, GetBackupScheduleForVolumeResponse, GetBackupScheduleResponse, GetContainersUsingVolumeData, GetContainersUsingVolumeResponse, GetMeData, GetMeResponse, GetRepositoryData, GetRepositoryResponse, GetSnapshotDetailsData, GetSnapshotDetailsResponse, GetStatusData, GetStatusResponse, GetSystemInfoData, GetSystemInfoResponse, GetVolumeData, GetVolumeResponse, HealthCheckVolumeData, HealthCheckVolumeResponse, ListBackupSchedulesData, ListBackupSchedulesResponse, ListFilesData, ListFilesResponse, ListRcloneRemotesData, ListRcloneRemotesResponse, ListRepositoriesData, ListRepositoriesResponse, ListSnapshotFilesData, ListSnapshotFilesResponse, ListSnapshotsData, ListSnapshotsResponse, ListVolumesData, ListVolumesResponse, LoginData, LoginResponse, LogoutData, LogoutResponse, MountVolumeData, MountVolumeResponse, RegisterData, RegisterResponse, RestoreSnapshotData, RestoreSnapshotResponse, RunBackupNowData, RunBackupNowResponse, RunForgetData, RunForgetResponse, StopBackupData, StopBackupResponse, TestConnectionData, TestConnectionResponse, UnmountVolumeData, UnmountVolumeResponse, UpdateBackupScheduleData, UpdateBackupScheduleResponse, UpdateVolumeData, UpdateVolumeResponse } from '../types.gen';
|
||||
|
||||
/**
|
||||
* Register a new user
|
||||
*/
|
||||
export const registerMutation = (options?: Partial<Options<RegisterData>>): UseMutationOptions<RegisterResponse, DefaultError, Options<RegisterData>> => {
|
||||
const mutationOptions: UseMutationOptions<RegisterResponse, DefaultError, Options<RegisterData>> = {
|
||||
mutationFn: async (fnOptions) => {
|
||||
const { data } = await register({
|
||||
...options,
|
||||
...fnOptions,
|
||||
throwOnError: true
|
||||
});
|
||||
return data;
|
||||
}
|
||||
};
|
||||
return mutationOptions;
|
||||
};
|
||||
|
||||
/**
|
||||
* Login with username and password
|
||||
*/
|
||||
export const loginMutation = (options?: Partial<Options<LoginData>>): UseMutationOptions<LoginResponse, DefaultError, Options<LoginData>> => {
|
||||
const mutationOptions: UseMutationOptions<LoginResponse, DefaultError, Options<LoginData>> = {
|
||||
mutationFn: async (fnOptions) => {
|
||||
const { data } = await login({
|
||||
...options,
|
||||
...fnOptions,
|
||||
throwOnError: true
|
||||
});
|
||||
return data;
|
||||
}
|
||||
};
|
||||
return mutationOptions;
|
||||
};
|
||||
|
||||
/**
|
||||
* Logout current user
|
||||
*/
|
||||
export const logoutMutation = (options?: Partial<Options<LogoutData>>): UseMutationOptions<LogoutResponse, DefaultError, Options<LogoutData>> => {
|
||||
const mutationOptions: UseMutationOptions<LogoutResponse, DefaultError, Options<LogoutData>> = {
|
||||
mutationFn: async (fnOptions) => {
|
||||
const { data } = await logout({
|
||||
...options,
|
||||
...fnOptions,
|
||||
throwOnError: true
|
||||
});
|
||||
return data;
|
||||
}
|
||||
};
|
||||
return mutationOptions;
|
||||
};
|
||||
|
||||
export type QueryKey<TOptions extends Options> = [
|
||||
Pick<TOptions, 'baseUrl' | 'body' | 'headers' | 'path' | 'query'> & {
|
||||
_id: string;
|
||||
_infinite?: boolean;
|
||||
tags?: ReadonlyArray<string>;
|
||||
}
|
||||
];
|
||||
|
||||
const createQueryKey = <TOptions extends Options>(id: string, options?: TOptions, infinite?: boolean, tags?: ReadonlyArray<string>): [
|
||||
QueryKey<TOptions>[0]
|
||||
] => {
|
||||
const params: QueryKey<TOptions>[0] = { _id: id, baseUrl: options?.baseUrl || (options?.client ?? client).getConfig().baseUrl } as QueryKey<TOptions>[0];
|
||||
if (infinite) {
|
||||
params._infinite = infinite;
|
||||
}
|
||||
if (tags) {
|
||||
params.tags = tags;
|
||||
}
|
||||
if (options?.body) {
|
||||
params.body = options.body;
|
||||
}
|
||||
if (options?.headers) {
|
||||
params.headers = options.headers;
|
||||
}
|
||||
if (options?.path) {
|
||||
params.path = options.path;
|
||||
}
|
||||
if (options?.query) {
|
||||
params.query = options.query;
|
||||
}
|
||||
return [
|
||||
params
|
||||
];
|
||||
};
|
||||
|
||||
export const getMeQueryKey = (options?: Options<GetMeData>) => createQueryKey("getMe", options);
|
||||
|
||||
/**
|
||||
* Get current authenticated user
|
||||
*/
|
||||
export const getMeOptions = (options?: Options<GetMeData>) => queryOptions<GetMeResponse, DefaultError, GetMeResponse, ReturnType<typeof getMeQueryKey>>({
|
||||
queryFn: async ({ queryKey, signal }) => {
|
||||
const { data } = await getMe({
|
||||
...options,
|
||||
...queryKey[0],
|
||||
signal,
|
||||
throwOnError: true
|
||||
});
|
||||
return data;
|
||||
},
|
||||
queryKey: getMeQueryKey(options)
|
||||
});
|
||||
|
||||
export const getStatusQueryKey = (options?: Options<GetStatusData>) => createQueryKey("getStatus", options);
|
||||
|
||||
/**
|
||||
* Get authentication system status
|
||||
*/
|
||||
export const getStatusOptions = (options?: Options<GetStatusData>) => queryOptions<GetStatusResponse, DefaultError, GetStatusResponse, ReturnType<typeof getStatusQueryKey>>({
|
||||
queryFn: async ({ queryKey, signal }) => {
|
||||
const { data } = await getStatus({
|
||||
...options,
|
||||
...queryKey[0],
|
||||
signal,
|
||||
throwOnError: true
|
||||
});
|
||||
return data;
|
||||
},
|
||||
queryKey: getStatusQueryKey(options)
|
||||
});
|
||||
|
||||
/**
|
||||
* Change current user password
|
||||
*/
|
||||
export const changePasswordMutation = (options?: Partial<Options<ChangePasswordData>>): UseMutationOptions<ChangePasswordResponse, DefaultError, Options<ChangePasswordData>> => {
|
||||
const mutationOptions: UseMutationOptions<ChangePasswordResponse, DefaultError, Options<ChangePasswordData>> = {
|
||||
mutationFn: async (fnOptions) => {
|
||||
const { data } = await changePassword({
|
||||
...options,
|
||||
...fnOptions,
|
||||
throwOnError: true
|
||||
});
|
||||
return data;
|
||||
}
|
||||
};
|
||||
return mutationOptions;
|
||||
};
|
||||
|
||||
export const listVolumesQueryKey = (options?: Options<ListVolumesData>) => createQueryKey("listVolumes", options);
|
||||
|
||||
/**
|
||||
* List all volumes
|
||||
*/
|
||||
export const listVolumesOptions = (options?: Options<ListVolumesData>) => queryOptions<ListVolumesResponse, DefaultError, ListVolumesResponse, ReturnType<typeof listVolumesQueryKey>>({
|
||||
queryFn: async ({ queryKey, signal }) => {
|
||||
const { data } = await listVolumes({
|
||||
...options,
|
||||
...queryKey[0],
|
||||
signal,
|
||||
throwOnError: true
|
||||
});
|
||||
return data;
|
||||
},
|
||||
queryKey: listVolumesQueryKey(options)
|
||||
});
|
||||
|
||||
/**
|
||||
* Create a new volume
|
||||
*/
|
||||
export const createVolumeMutation = (options?: Partial<Options<CreateVolumeData>>): UseMutationOptions<CreateVolumeResponse, DefaultError, Options<CreateVolumeData>> => {
|
||||
const mutationOptions: UseMutationOptions<CreateVolumeResponse, DefaultError, Options<CreateVolumeData>> = {
|
||||
mutationFn: async (fnOptions) => {
|
||||
const { data } = await createVolume({
|
||||
...options,
|
||||
...fnOptions,
|
||||
throwOnError: true
|
||||
});
|
||||
return data;
|
||||
}
|
||||
};
|
||||
return mutationOptions;
|
||||
};
|
||||
|
||||
/**
|
||||
* Test connection to backend
|
||||
*/
|
||||
export const testConnectionMutation = (options?: Partial<Options<TestConnectionData>>): UseMutationOptions<TestConnectionResponse, DefaultError, Options<TestConnectionData>> => {
|
||||
const mutationOptions: UseMutationOptions<TestConnectionResponse, DefaultError, Options<TestConnectionData>> = {
|
||||
mutationFn: async (fnOptions) => {
|
||||
const { data } = await testConnection({
|
||||
...options,
|
||||
...fnOptions,
|
||||
throwOnError: true
|
||||
});
|
||||
return data;
|
||||
}
|
||||
};
|
||||
return mutationOptions;
|
||||
};
|
||||
|
||||
/**
|
||||
* Delete a volume
|
||||
*/
|
||||
export const deleteVolumeMutation = (options?: Partial<Options<DeleteVolumeData>>): UseMutationOptions<DeleteVolumeResponse, DefaultError, Options<DeleteVolumeData>> => {
|
||||
const mutationOptions: UseMutationOptions<DeleteVolumeResponse, DefaultError, Options<DeleteVolumeData>> = {
|
||||
mutationFn: async (fnOptions) => {
|
||||
const { data } = await deleteVolume({
|
||||
...options,
|
||||
...fnOptions,
|
||||
throwOnError: true
|
||||
});
|
||||
return data;
|
||||
}
|
||||
};
|
||||
return mutationOptions;
|
||||
};
|
||||
|
||||
export const getVolumeQueryKey = (options: Options<GetVolumeData>) => createQueryKey("getVolume", options);
|
||||
|
||||
/**
|
||||
* Get a volume by name
|
||||
*/
|
||||
export const getVolumeOptions = (options: Options<GetVolumeData>) => queryOptions<GetVolumeResponse, DefaultError, GetVolumeResponse, ReturnType<typeof getVolumeQueryKey>>({
|
||||
queryFn: async ({ queryKey, signal }) => {
|
||||
const { data } = await getVolume({
|
||||
...options,
|
||||
...queryKey[0],
|
||||
signal,
|
||||
throwOnError: true
|
||||
});
|
||||
return data;
|
||||
},
|
||||
queryKey: getVolumeQueryKey(options)
|
||||
});
|
||||
|
||||
/**
|
||||
* Update a volume's configuration
|
||||
*/
|
||||
export const updateVolumeMutation = (options?: Partial<Options<UpdateVolumeData>>): UseMutationOptions<UpdateVolumeResponse, DefaultError, Options<UpdateVolumeData>> => {
|
||||
const mutationOptions: UseMutationOptions<UpdateVolumeResponse, DefaultError, Options<UpdateVolumeData>> = {
|
||||
mutationFn: async (fnOptions) => {
|
||||
const { data } = await updateVolume({
|
||||
...options,
|
||||
...fnOptions,
|
||||
throwOnError: true
|
||||
});
|
||||
return data;
|
||||
}
|
||||
};
|
||||
return mutationOptions;
|
||||
};
|
||||
|
||||
export const getContainersUsingVolumeQueryKey = (options: Options<GetContainersUsingVolumeData>) => createQueryKey("getContainersUsingVolume", options);
|
||||
|
||||
/**
|
||||
* Get containers using a volume by name
|
||||
*/
|
||||
export const getContainersUsingVolumeOptions = (options: Options<GetContainersUsingVolumeData>) => queryOptions<GetContainersUsingVolumeResponse, DefaultError, GetContainersUsingVolumeResponse, ReturnType<typeof getContainersUsingVolumeQueryKey>>({
|
||||
queryFn: async ({ queryKey, signal }) => {
|
||||
const { data } = await getContainersUsingVolume({
|
||||
...options,
|
||||
...queryKey[0],
|
||||
signal,
|
||||
throwOnError: true
|
||||
});
|
||||
return data;
|
||||
},
|
||||
queryKey: getContainersUsingVolumeQueryKey(options)
|
||||
});
|
||||
|
||||
/**
|
||||
* Mount a volume
|
||||
*/
|
||||
export const mountVolumeMutation = (options?: Partial<Options<MountVolumeData>>): UseMutationOptions<MountVolumeResponse, DefaultError, Options<MountVolumeData>> => {
|
||||
const mutationOptions: UseMutationOptions<MountVolumeResponse, DefaultError, Options<MountVolumeData>> = {
|
||||
mutationFn: async (fnOptions) => {
|
||||
const { data } = await mountVolume({
|
||||
...options,
|
||||
...fnOptions,
|
||||
throwOnError: true
|
||||
});
|
||||
return data;
|
||||
}
|
||||
};
|
||||
return mutationOptions;
|
||||
};
|
||||
|
||||
/**
|
||||
* Unmount a volume
|
||||
*/
|
||||
export const unmountVolumeMutation = (options?: Partial<Options<UnmountVolumeData>>): UseMutationOptions<UnmountVolumeResponse, DefaultError, Options<UnmountVolumeData>> => {
|
||||
const mutationOptions: UseMutationOptions<UnmountVolumeResponse, DefaultError, Options<UnmountVolumeData>> = {
|
||||
mutationFn: async (fnOptions) => {
|
||||
const { data } = await unmountVolume({
|
||||
...options,
|
||||
...fnOptions,
|
||||
throwOnError: true
|
||||
});
|
||||
return data;
|
||||
}
|
||||
};
|
||||
return mutationOptions;
|
||||
};
|
||||
|
||||
/**
|
||||
* Perform a health check on a volume
|
||||
*/
|
||||
export const healthCheckVolumeMutation = (options?: Partial<Options<HealthCheckVolumeData>>): UseMutationOptions<HealthCheckVolumeResponse, DefaultError, Options<HealthCheckVolumeData>> => {
|
||||
const mutationOptions: UseMutationOptions<HealthCheckVolumeResponse, DefaultError, Options<HealthCheckVolumeData>> = {
|
||||
mutationFn: async (fnOptions) => {
|
||||
const { data } = await healthCheckVolume({
|
||||
...options,
|
||||
...fnOptions,
|
||||
throwOnError: true
|
||||
});
|
||||
return data;
|
||||
}
|
||||
};
|
||||
return mutationOptions;
|
||||
};
|
||||
|
||||
export const listFilesQueryKey = (options: Options<ListFilesData>) => createQueryKey("listFiles", options);
|
||||
|
||||
/**
|
||||
* List files in a volume directory
|
||||
*/
|
||||
export const listFilesOptions = (options: Options<ListFilesData>) => queryOptions<ListFilesResponse, DefaultError, ListFilesResponse, ReturnType<typeof listFilesQueryKey>>({
|
||||
queryFn: async ({ queryKey, signal }) => {
|
||||
const { data } = await listFiles({
|
||||
...options,
|
||||
...queryKey[0],
|
||||
signal,
|
||||
throwOnError: true
|
||||
});
|
||||
return data;
|
||||
},
|
||||
queryKey: listFilesQueryKey(options)
|
||||
});
|
||||
|
||||
export const browseFilesystemQueryKey = (options?: Options<BrowseFilesystemData>) => createQueryKey("browseFilesystem", options);
|
||||
|
||||
/**
|
||||
* Browse directories on the host filesystem
|
||||
*/
|
||||
export const browseFilesystemOptions = (options?: Options<BrowseFilesystemData>) => queryOptions<BrowseFilesystemResponse, DefaultError, BrowseFilesystemResponse, ReturnType<typeof browseFilesystemQueryKey>>({
|
||||
queryFn: async ({ queryKey, signal }) => {
|
||||
const { data } = await browseFilesystem({
|
||||
...options,
|
||||
...queryKey[0],
|
||||
signal,
|
||||
throwOnError: true
|
||||
});
|
||||
return data;
|
||||
},
|
||||
queryKey: browseFilesystemQueryKey(options)
|
||||
});
|
||||
|
||||
export const listRepositoriesQueryKey = (options?: Options<ListRepositoriesData>) => createQueryKey("listRepositories", options);
|
||||
|
||||
/**
|
||||
* List all repositories
|
||||
*/
|
||||
export const listRepositoriesOptions = (options?: Options<ListRepositoriesData>) => queryOptions<ListRepositoriesResponse, DefaultError, ListRepositoriesResponse, ReturnType<typeof listRepositoriesQueryKey>>({
|
||||
queryFn: async ({ queryKey, signal }) => {
|
||||
const { data } = await listRepositories({
|
||||
...options,
|
||||
...queryKey[0],
|
||||
signal,
|
||||
throwOnError: true
|
||||
});
|
||||
return data;
|
||||
},
|
||||
queryKey: listRepositoriesQueryKey(options)
|
||||
});
|
||||
|
||||
/**
|
||||
* Create a new restic repository
|
||||
*/
|
||||
export const createRepositoryMutation = (options?: Partial<Options<CreateRepositoryData>>): UseMutationOptions<CreateRepositoryResponse, DefaultError, Options<CreateRepositoryData>> => {
|
||||
const mutationOptions: UseMutationOptions<CreateRepositoryResponse, DefaultError, Options<CreateRepositoryData>> = {
|
||||
mutationFn: async (fnOptions) => {
|
||||
const { data } = await createRepository({
|
||||
...options,
|
||||
...fnOptions,
|
||||
throwOnError: true
|
||||
});
|
||||
return data;
|
||||
}
|
||||
};
|
||||
return mutationOptions;
|
||||
};
|
||||
|
||||
export const listRcloneRemotesQueryKey = (options?: Options<ListRcloneRemotesData>) => createQueryKey("listRcloneRemotes", options);
|
||||
|
||||
/**
|
||||
* List all configured rclone remotes on the host system
|
||||
*/
|
||||
export const listRcloneRemotesOptions = (options?: Options<ListRcloneRemotesData>) => queryOptions<ListRcloneRemotesResponse, DefaultError, ListRcloneRemotesResponse, ReturnType<typeof listRcloneRemotesQueryKey>>({
|
||||
queryFn: async ({ queryKey, signal }) => {
|
||||
const { data } = await listRcloneRemotes({
|
||||
...options,
|
||||
...queryKey[0],
|
||||
signal,
|
||||
throwOnError: true
|
||||
});
|
||||
return data;
|
||||
},
|
||||
queryKey: listRcloneRemotesQueryKey(options)
|
||||
});
|
||||
|
||||
/**
|
||||
* Delete a repository
|
||||
*/
|
||||
export const deleteRepositoryMutation = (options?: Partial<Options<DeleteRepositoryData>>): UseMutationOptions<DeleteRepositoryResponse, DefaultError, Options<DeleteRepositoryData>> => {
|
||||
const mutationOptions: UseMutationOptions<DeleteRepositoryResponse, DefaultError, Options<DeleteRepositoryData>> = {
|
||||
mutationFn: async (fnOptions) => {
|
||||
const { data } = await deleteRepository({
|
||||
...options,
|
||||
...fnOptions,
|
||||
throwOnError: true
|
||||
});
|
||||
return data;
|
||||
}
|
||||
};
|
||||
return mutationOptions;
|
||||
};
|
||||
|
||||
export const getRepositoryQueryKey = (options: Options<GetRepositoryData>) => createQueryKey("getRepository", options);
|
||||
|
||||
/**
|
||||
* Get a single repository by name
|
||||
*/
|
||||
export const getRepositoryOptions = (options: Options<GetRepositoryData>) => queryOptions<GetRepositoryResponse, DefaultError, GetRepositoryResponse, ReturnType<typeof getRepositoryQueryKey>>({
|
||||
queryFn: async ({ queryKey, signal }) => {
|
||||
const { data } = await getRepository({
|
||||
...options,
|
||||
...queryKey[0],
|
||||
signal,
|
||||
throwOnError: true
|
||||
});
|
||||
return data;
|
||||
},
|
||||
queryKey: getRepositoryQueryKey(options)
|
||||
});
|
||||
|
||||
export const listSnapshotsQueryKey = (options: Options<ListSnapshotsData>) => createQueryKey("listSnapshots", options);
|
||||
|
||||
/**
|
||||
* List all snapshots in a repository
|
||||
*/
|
||||
export const listSnapshotsOptions = (options: Options<ListSnapshotsData>) => queryOptions<ListSnapshotsResponse, DefaultError, ListSnapshotsResponse, ReturnType<typeof listSnapshotsQueryKey>>({
|
||||
queryFn: async ({ queryKey, signal }) => {
|
||||
const { data } = await listSnapshots({
|
||||
...options,
|
||||
...queryKey[0],
|
||||
signal,
|
||||
throwOnError: true
|
||||
});
|
||||
return data;
|
||||
},
|
||||
queryKey: listSnapshotsQueryKey(options)
|
||||
});
|
||||
|
||||
export const getSnapshotDetailsQueryKey = (options: Options<GetSnapshotDetailsData>) => createQueryKey("getSnapshotDetails", options);
|
||||
|
||||
/**
|
||||
* Get details of a specific snapshot
|
||||
*/
|
||||
export const getSnapshotDetailsOptions = (options: Options<GetSnapshotDetailsData>) => queryOptions<GetSnapshotDetailsResponse, DefaultError, GetSnapshotDetailsResponse, ReturnType<typeof getSnapshotDetailsQueryKey>>({
|
||||
queryFn: async ({ queryKey, signal }) => {
|
||||
const { data } = await getSnapshotDetails({
|
||||
...options,
|
||||
...queryKey[0],
|
||||
signal,
|
||||
throwOnError: true
|
||||
});
|
||||
return data;
|
||||
},
|
||||
queryKey: getSnapshotDetailsQueryKey(options)
|
||||
});
|
||||
|
||||
export const listSnapshotFilesQueryKey = (options: Options<ListSnapshotFilesData>) => createQueryKey("listSnapshotFiles", options);
|
||||
|
||||
/**
|
||||
* List files and directories in a snapshot
|
||||
*/
|
||||
export const listSnapshotFilesOptions = (options: Options<ListSnapshotFilesData>) => queryOptions<ListSnapshotFilesResponse, DefaultError, ListSnapshotFilesResponse, ReturnType<typeof listSnapshotFilesQueryKey>>({
|
||||
queryFn: async ({ queryKey, signal }) => {
|
||||
const { data } = await listSnapshotFiles({
|
||||
...options,
|
||||
...queryKey[0],
|
||||
signal,
|
||||
throwOnError: true
|
||||
});
|
||||
return data;
|
||||
},
|
||||
queryKey: listSnapshotFilesQueryKey(options)
|
||||
});
|
||||
|
||||
/**
|
||||
* Restore a snapshot to a target path on the filesystem
|
||||
*/
|
||||
export const restoreSnapshotMutation = (options?: Partial<Options<RestoreSnapshotData>>): UseMutationOptions<RestoreSnapshotResponse, DefaultError, Options<RestoreSnapshotData>> => {
|
||||
const mutationOptions: UseMutationOptions<RestoreSnapshotResponse, DefaultError, Options<RestoreSnapshotData>> = {
|
||||
mutationFn: async (fnOptions) => {
|
||||
const { data } = await restoreSnapshot({
|
||||
...options,
|
||||
...fnOptions,
|
||||
throwOnError: true
|
||||
});
|
||||
return data;
|
||||
}
|
||||
};
|
||||
return mutationOptions;
|
||||
};
|
||||
|
||||
/**
|
||||
* 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 doctorRepositoryMutation = (options?: Partial<Options<DoctorRepositoryData>>): UseMutationOptions<DoctorRepositoryResponse, DefaultError, Options<DoctorRepositoryData>> => {
|
||||
const mutationOptions: UseMutationOptions<DoctorRepositoryResponse, DefaultError, Options<DoctorRepositoryData>> = {
|
||||
mutationFn: async (fnOptions) => {
|
||||
const { data } = await doctorRepository({
|
||||
...options,
|
||||
...fnOptions,
|
||||
throwOnError: true
|
||||
});
|
||||
return data;
|
||||
}
|
||||
};
|
||||
return mutationOptions;
|
||||
};
|
||||
|
||||
export const listBackupSchedulesQueryKey = (options?: Options<ListBackupSchedulesData>) => createQueryKey("listBackupSchedules", options);
|
||||
|
||||
/**
|
||||
* List all backup schedules
|
||||
*/
|
||||
export const listBackupSchedulesOptions = (options?: Options<ListBackupSchedulesData>) => queryOptions<ListBackupSchedulesResponse, DefaultError, ListBackupSchedulesResponse, ReturnType<typeof listBackupSchedulesQueryKey>>({
|
||||
queryFn: async ({ queryKey, signal }) => {
|
||||
const { data } = await listBackupSchedules({
|
||||
...options,
|
||||
...queryKey[0],
|
||||
signal,
|
||||
throwOnError: true
|
||||
});
|
||||
return data;
|
||||
},
|
||||
queryKey: listBackupSchedulesQueryKey(options)
|
||||
});
|
||||
|
||||
/**
|
||||
* Create a new backup schedule for a volume
|
||||
*/
|
||||
export const createBackupScheduleMutation = (options?: Partial<Options<CreateBackupScheduleData>>): UseMutationOptions<CreateBackupScheduleResponse, DefaultError, Options<CreateBackupScheduleData>> => {
|
||||
const mutationOptions: UseMutationOptions<CreateBackupScheduleResponse, DefaultError, Options<CreateBackupScheduleData>> = {
|
||||
mutationFn: async (fnOptions) => {
|
||||
const { data } = await createBackupSchedule({
|
||||
...options,
|
||||
...fnOptions,
|
||||
throwOnError: true
|
||||
});
|
||||
return data;
|
||||
}
|
||||
};
|
||||
return mutationOptions;
|
||||
};
|
||||
|
||||
/**
|
||||
* Delete a backup schedule
|
||||
*/
|
||||
export const deleteBackupScheduleMutation = (options?: Partial<Options<DeleteBackupScheduleData>>): UseMutationOptions<DeleteBackupScheduleResponse, DefaultError, Options<DeleteBackupScheduleData>> => {
|
||||
const mutationOptions: UseMutationOptions<DeleteBackupScheduleResponse, DefaultError, Options<DeleteBackupScheduleData>> = {
|
||||
mutationFn: async (fnOptions) => {
|
||||
const { data } = await deleteBackupSchedule({
|
||||
...options,
|
||||
...fnOptions,
|
||||
throwOnError: true
|
||||
});
|
||||
return data;
|
||||
}
|
||||
};
|
||||
return mutationOptions;
|
||||
};
|
||||
|
||||
export const getBackupScheduleQueryKey = (options: Options<GetBackupScheduleData>) => createQueryKey("getBackupSchedule", options);
|
||||
|
||||
/**
|
||||
* Get a backup schedule by ID
|
||||
*/
|
||||
export const getBackupScheduleOptions = (options: Options<GetBackupScheduleData>) => queryOptions<GetBackupScheduleResponse, DefaultError, GetBackupScheduleResponse, ReturnType<typeof getBackupScheduleQueryKey>>({
|
||||
queryFn: async ({ queryKey, signal }) => {
|
||||
const { data } = await getBackupSchedule({
|
||||
...options,
|
||||
...queryKey[0],
|
||||
signal,
|
||||
throwOnError: true
|
||||
});
|
||||
return data;
|
||||
},
|
||||
queryKey: getBackupScheduleQueryKey(options)
|
||||
});
|
||||
|
||||
/**
|
||||
* Update a backup schedule
|
||||
*/
|
||||
export const updateBackupScheduleMutation = (options?: Partial<Options<UpdateBackupScheduleData>>): UseMutationOptions<UpdateBackupScheduleResponse, DefaultError, Options<UpdateBackupScheduleData>> => {
|
||||
const mutationOptions: UseMutationOptions<UpdateBackupScheduleResponse, DefaultError, Options<UpdateBackupScheduleData>> = {
|
||||
mutationFn: async (fnOptions) => {
|
||||
const { data } = await updateBackupSchedule({
|
||||
...options,
|
||||
...fnOptions,
|
||||
throwOnError: true
|
||||
});
|
||||
return data;
|
||||
}
|
||||
};
|
||||
return mutationOptions;
|
||||
};
|
||||
|
||||
export const getBackupScheduleForVolumeQueryKey = (options: Options<GetBackupScheduleForVolumeData>) => createQueryKey("getBackupScheduleForVolume", options);
|
||||
|
||||
/**
|
||||
* Get a backup schedule for a specific volume
|
||||
*/
|
||||
export const getBackupScheduleForVolumeOptions = (options: Options<GetBackupScheduleForVolumeData>) => queryOptions<GetBackupScheduleForVolumeResponse, DefaultError, GetBackupScheduleForVolumeResponse, ReturnType<typeof getBackupScheduleForVolumeQueryKey>>({
|
||||
queryFn: async ({ queryKey, signal }) => {
|
||||
const { data } = await getBackupScheduleForVolume({
|
||||
...options,
|
||||
...queryKey[0],
|
||||
signal,
|
||||
throwOnError: true
|
||||
});
|
||||
return data;
|
||||
},
|
||||
queryKey: getBackupScheduleForVolumeQueryKey(options)
|
||||
});
|
||||
|
||||
/**
|
||||
* Trigger a backup immediately for a schedule
|
||||
*/
|
||||
export const runBackupNowMutation = (options?: Partial<Options<RunBackupNowData>>): UseMutationOptions<RunBackupNowResponse, DefaultError, Options<RunBackupNowData>> => {
|
||||
const mutationOptions: UseMutationOptions<RunBackupNowResponse, DefaultError, Options<RunBackupNowData>> = {
|
||||
mutationFn: async (fnOptions) => {
|
||||
const { data } = await runBackupNow({
|
||||
...options,
|
||||
...fnOptions,
|
||||
throwOnError: true
|
||||
});
|
||||
return data;
|
||||
}
|
||||
};
|
||||
return mutationOptions;
|
||||
};
|
||||
|
||||
/**
|
||||
* Stop a backup that is currently in progress
|
||||
*/
|
||||
export const stopBackupMutation = (options?: Partial<Options<StopBackupData>>): UseMutationOptions<StopBackupResponse, DefaultError, Options<StopBackupData>> => {
|
||||
const mutationOptions: UseMutationOptions<StopBackupResponse, DefaultError, Options<StopBackupData>> = {
|
||||
mutationFn: async (fnOptions) => {
|
||||
const { data } = await stopBackup({
|
||||
...options,
|
||||
...fnOptions,
|
||||
throwOnError: true
|
||||
});
|
||||
return data;
|
||||
}
|
||||
};
|
||||
return mutationOptions;
|
||||
};
|
||||
|
||||
/**
|
||||
* Manually apply retention policy to clean up old snapshots
|
||||
*/
|
||||
export const runForgetMutation = (options?: Partial<Options<RunForgetData>>): UseMutationOptions<RunForgetResponse, DefaultError, Options<RunForgetData>> => {
|
||||
const mutationOptions: UseMutationOptions<RunForgetResponse, DefaultError, Options<RunForgetData>> = {
|
||||
mutationFn: async (fnOptions) => {
|
||||
const { data } = await runForget({
|
||||
...options,
|
||||
...fnOptions,
|
||||
throwOnError: true
|
||||
});
|
||||
return data;
|
||||
}
|
||||
};
|
||||
return mutationOptions;
|
||||
};
|
||||
|
||||
export const getSystemInfoQueryKey = (options?: Options<GetSystemInfoData>) => createQueryKey("getSystemInfo", options);
|
||||
|
||||
/**
|
||||
* Get system information including available capabilities
|
||||
*/
|
||||
export const getSystemInfoOptions = (options?: Options<GetSystemInfoData>) => queryOptions<GetSystemInfoResponse, DefaultError, GetSystemInfoResponse, ReturnType<typeof getSystemInfoQueryKey>>({
|
||||
queryFn: async ({ queryKey, signal }) => {
|
||||
const { data } = await getSystemInfo({
|
||||
...options,
|
||||
...queryKey[0],
|
||||
signal,
|
||||
throwOnError: true
|
||||
});
|
||||
return data;
|
||||
},
|
||||
queryKey: getSystemInfoQueryKey(options)
|
||||
});
|
||||
|
||||
/**
|
||||
* Download the Restic password file for backup recovery. Requires password re-authentication.
|
||||
*/
|
||||
export const downloadResticPasswordMutation = (options?: Partial<Options<DownloadResticPasswordData>>): UseMutationOptions<DownloadResticPasswordResponse, DefaultError, Options<DownloadResticPasswordData>> => {
|
||||
const mutationOptions: UseMutationOptions<DownloadResticPasswordResponse, DefaultError, Options<DownloadResticPasswordData>> = {
|
||||
mutationFn: async (fnOptions) => {
|
||||
const { data } = await downloadResticPassword({
|
||||
...options,
|
||||
...fnOptions,
|
||||
throwOnError: true
|
||||
});
|
||||
return data;
|
||||
}
|
||||
};
|
||||
return mutationOptions;
|
||||
};
|
||||
18
app/client/api-client/client.gen.ts
Normal file
18
app/client/api-client/client.gen.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
// This file is auto-generated by @hey-api/openapi-ts
|
||||
|
||||
import { type ClientOptions, type Config, createClient, createConfig } from './client';
|
||||
import type { ClientOptions as ClientOptions2 } from './types.gen';
|
||||
|
||||
/**
|
||||
* The `createClientConfig()` function will be called on client initialization
|
||||
* and the returned object will become the client's initial configuration.
|
||||
*
|
||||
* You may want to initialize your client this way instead of calling
|
||||
* `setConfig()`. This is useful for example if you're using Next.js
|
||||
* to ensure your client always has the correct values.
|
||||
*/
|
||||
export type CreateClientConfig<T extends ClientOptions = ClientOptions2> = (override?: Config<ClientOptions & T>) => Config<Required<ClientOptions> & T>;
|
||||
|
||||
export const client = createClient(createConfig<ClientOptions2>({
|
||||
baseUrl: 'http://192.168.2.42:4096'
|
||||
}));
|
||||
301
app/client/api-client/client/client.gen.ts
Normal file
301
app/client/api-client/client/client.gen.ts
Normal file
@@ -0,0 +1,301 @@
|
||||
// This file is auto-generated by @hey-api/openapi-ts
|
||||
|
||||
import { createSseClient } from '../core/serverSentEvents.gen';
|
||||
import type { HttpMethod } from '../core/types.gen';
|
||||
import { getValidRequestBody } from '../core/utils.gen';
|
||||
import type {
|
||||
Client,
|
||||
Config,
|
||||
RequestOptions,
|
||||
ResolvedRequestOptions,
|
||||
} from './types.gen';
|
||||
import {
|
||||
buildUrl,
|
||||
createConfig,
|
||||
createInterceptors,
|
||||
getParseAs,
|
||||
mergeConfigs,
|
||||
mergeHeaders,
|
||||
setAuthParams,
|
||||
} from './utils.gen';
|
||||
|
||||
type ReqInit = Omit<RequestInit, 'body' | 'headers'> & {
|
||||
body?: any;
|
||||
headers: ReturnType<typeof mergeHeaders>;
|
||||
};
|
||||
|
||||
export const createClient = (config: Config = {}): Client => {
|
||||
let _config = mergeConfigs(createConfig(), config);
|
||||
|
||||
const getConfig = (): Config => ({ ..._config });
|
||||
|
||||
const setConfig = (config: Config): Config => {
|
||||
_config = mergeConfigs(_config, config);
|
||||
return getConfig();
|
||||
};
|
||||
|
||||
const interceptors = createInterceptors<
|
||||
Request,
|
||||
Response,
|
||||
unknown,
|
||||
ResolvedRequestOptions
|
||||
>();
|
||||
|
||||
const beforeRequest = async (options: RequestOptions) => {
|
||||
const opts = {
|
||||
..._config,
|
||||
...options,
|
||||
fetch: options.fetch ?? _config.fetch ?? globalThis.fetch,
|
||||
headers: mergeHeaders(_config.headers, options.headers),
|
||||
serializedBody: undefined,
|
||||
};
|
||||
|
||||
if (opts.security) {
|
||||
await setAuthParams({
|
||||
...opts,
|
||||
security: opts.security,
|
||||
});
|
||||
}
|
||||
|
||||
if (opts.requestValidator) {
|
||||
await opts.requestValidator(opts);
|
||||
}
|
||||
|
||||
if (opts.body !== undefined && opts.bodySerializer) {
|
||||
opts.serializedBody = opts.bodySerializer(opts.body);
|
||||
}
|
||||
|
||||
// remove Content-Type header if body is empty to avoid sending invalid requests
|
||||
if (opts.body === undefined || opts.serializedBody === '') {
|
||||
opts.headers.delete('Content-Type');
|
||||
}
|
||||
|
||||
const url = buildUrl(opts);
|
||||
|
||||
return { opts, url };
|
||||
};
|
||||
|
||||
const request: Client['request'] = async (options) => {
|
||||
// @ts-expect-error
|
||||
const { opts, url } = await beforeRequest(options);
|
||||
const requestInit: ReqInit = {
|
||||
redirect: 'follow',
|
||||
...opts,
|
||||
body: getValidRequestBody(opts),
|
||||
};
|
||||
|
||||
let request = new Request(url, requestInit);
|
||||
|
||||
for (const fn of interceptors.request.fns) {
|
||||
if (fn) {
|
||||
request = await fn(request, opts);
|
||||
}
|
||||
}
|
||||
|
||||
// fetch must be assigned here, otherwise it would throw the error:
|
||||
// TypeError: Failed to execute 'fetch' on 'Window': Illegal invocation
|
||||
const _fetch = opts.fetch!;
|
||||
let response: Response;
|
||||
|
||||
try {
|
||||
response = await _fetch(request);
|
||||
} catch (error) {
|
||||
// Handle fetch exceptions (AbortError, network errors, etc.)
|
||||
let finalError = error;
|
||||
|
||||
for (const fn of interceptors.error.fns) {
|
||||
if (fn) {
|
||||
finalError = (await fn(
|
||||
error,
|
||||
undefined as any,
|
||||
request,
|
||||
opts,
|
||||
)) as unknown;
|
||||
}
|
||||
}
|
||||
|
||||
finalError = finalError || ({} as unknown);
|
||||
|
||||
if (opts.throwOnError) {
|
||||
throw finalError;
|
||||
}
|
||||
|
||||
// Return error response
|
||||
return opts.responseStyle === 'data'
|
||||
? undefined
|
||||
: {
|
||||
error: finalError,
|
||||
request,
|
||||
response: undefined as any,
|
||||
};
|
||||
}
|
||||
|
||||
for (const fn of interceptors.response.fns) {
|
||||
if (fn) {
|
||||
response = await fn(response, request, opts);
|
||||
}
|
||||
}
|
||||
|
||||
const result = {
|
||||
request,
|
||||
response,
|
||||
};
|
||||
|
||||
if (response.ok) {
|
||||
const parseAs =
|
||||
(opts.parseAs === 'auto'
|
||||
? getParseAs(response.headers.get('Content-Type'))
|
||||
: opts.parseAs) ?? 'json';
|
||||
|
||||
if (
|
||||
response.status === 204 ||
|
||||
response.headers.get('Content-Length') === '0'
|
||||
) {
|
||||
let emptyData: any;
|
||||
switch (parseAs) {
|
||||
case 'arrayBuffer':
|
||||
case 'blob':
|
||||
case 'text':
|
||||
emptyData = await response[parseAs]();
|
||||
break;
|
||||
case 'formData':
|
||||
emptyData = new FormData();
|
||||
break;
|
||||
case 'stream':
|
||||
emptyData = response.body;
|
||||
break;
|
||||
case 'json':
|
||||
default:
|
||||
emptyData = {};
|
||||
break;
|
||||
}
|
||||
return opts.responseStyle === 'data'
|
||||
? emptyData
|
||||
: {
|
||||
data: emptyData,
|
||||
...result,
|
||||
};
|
||||
}
|
||||
|
||||
let data: any;
|
||||
switch (parseAs) {
|
||||
case 'arrayBuffer':
|
||||
case 'blob':
|
||||
case 'formData':
|
||||
case 'json':
|
||||
case 'text':
|
||||
data = await response[parseAs]();
|
||||
break;
|
||||
case 'stream':
|
||||
return opts.responseStyle === 'data'
|
||||
? response.body
|
||||
: {
|
||||
data: response.body,
|
||||
...result,
|
||||
};
|
||||
}
|
||||
|
||||
if (parseAs === 'json') {
|
||||
if (opts.responseValidator) {
|
||||
await opts.responseValidator(data);
|
||||
}
|
||||
|
||||
if (opts.responseTransformer) {
|
||||
data = await opts.responseTransformer(data);
|
||||
}
|
||||
}
|
||||
|
||||
return opts.responseStyle === 'data'
|
||||
? data
|
||||
: {
|
||||
data,
|
||||
...result,
|
||||
};
|
||||
}
|
||||
|
||||
const textError = await response.text();
|
||||
let jsonError: unknown;
|
||||
|
||||
try {
|
||||
jsonError = JSON.parse(textError);
|
||||
} catch {
|
||||
// noop
|
||||
}
|
||||
|
||||
const error = jsonError ?? textError;
|
||||
let finalError = error;
|
||||
|
||||
for (const fn of interceptors.error.fns) {
|
||||
if (fn) {
|
||||
finalError = (await fn(error, response, request, opts)) as string;
|
||||
}
|
||||
}
|
||||
|
||||
finalError = finalError || ({} as string);
|
||||
|
||||
if (opts.throwOnError) {
|
||||
throw finalError;
|
||||
}
|
||||
|
||||
// TODO: we probably want to return error and improve types
|
||||
return opts.responseStyle === 'data'
|
||||
? undefined
|
||||
: {
|
||||
error: finalError,
|
||||
...result,
|
||||
};
|
||||
};
|
||||
|
||||
const makeMethodFn =
|
||||
(method: Uppercase<HttpMethod>) => (options: RequestOptions) =>
|
||||
request({ ...options, method });
|
||||
|
||||
const makeSseFn =
|
||||
(method: Uppercase<HttpMethod>) => async (options: RequestOptions) => {
|
||||
const { opts, url } = await beforeRequest(options);
|
||||
return createSseClient({
|
||||
...opts,
|
||||
body: opts.body as BodyInit | null | undefined,
|
||||
headers: opts.headers as unknown as Record<string, string>,
|
||||
method,
|
||||
onRequest: async (url, init) => {
|
||||
let request = new Request(url, init);
|
||||
for (const fn of interceptors.request.fns) {
|
||||
if (fn) {
|
||||
request = await fn(request, opts);
|
||||
}
|
||||
}
|
||||
return request;
|
||||
},
|
||||
url,
|
||||
});
|
||||
};
|
||||
|
||||
return {
|
||||
buildUrl,
|
||||
connect: makeMethodFn('CONNECT'),
|
||||
delete: makeMethodFn('DELETE'),
|
||||
get: makeMethodFn('GET'),
|
||||
getConfig,
|
||||
head: makeMethodFn('HEAD'),
|
||||
interceptors,
|
||||
options: makeMethodFn('OPTIONS'),
|
||||
patch: makeMethodFn('PATCH'),
|
||||
post: makeMethodFn('POST'),
|
||||
put: makeMethodFn('PUT'),
|
||||
request,
|
||||
setConfig,
|
||||
sse: {
|
||||
connect: makeSseFn('CONNECT'),
|
||||
delete: makeSseFn('DELETE'),
|
||||
get: makeSseFn('GET'),
|
||||
head: makeSseFn('HEAD'),
|
||||
options: makeSseFn('OPTIONS'),
|
||||
patch: makeSseFn('PATCH'),
|
||||
post: makeSseFn('POST'),
|
||||
put: makeSseFn('PUT'),
|
||||
trace: makeSseFn('TRACE'),
|
||||
},
|
||||
trace: makeMethodFn('TRACE'),
|
||||
} as Client;
|
||||
};
|
||||
25
app/client/api-client/client/index.ts
Normal file
25
app/client/api-client/client/index.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
// This file is auto-generated by @hey-api/openapi-ts
|
||||
|
||||
export type { Auth } from '../core/auth.gen';
|
||||
export type { QuerySerializerOptions } from '../core/bodySerializer.gen';
|
||||
export {
|
||||
formDataBodySerializer,
|
||||
jsonBodySerializer,
|
||||
urlSearchParamsBodySerializer,
|
||||
} from '../core/bodySerializer.gen';
|
||||
export { buildClientParams } from '../core/params.gen';
|
||||
export { serializeQueryKeyValue } from '../core/queryKeySerializer.gen';
|
||||
export { createClient } from './client.gen';
|
||||
export type {
|
||||
Client,
|
||||
ClientOptions,
|
||||
Config,
|
||||
CreateClientConfig,
|
||||
Options,
|
||||
RequestOptions,
|
||||
RequestResult,
|
||||
ResolvedRequestOptions,
|
||||
ResponseStyle,
|
||||
TDataShape,
|
||||
} from './types.gen';
|
||||
export { createConfig, mergeHeaders } from './utils.gen';
|
||||
241
app/client/api-client/client/types.gen.ts
Normal file
241
app/client/api-client/client/types.gen.ts
Normal file
@@ -0,0 +1,241 @@
|
||||
// This file is auto-generated by @hey-api/openapi-ts
|
||||
|
||||
import type { Auth } from '../core/auth.gen';
|
||||
import type {
|
||||
ServerSentEventsOptions,
|
||||
ServerSentEventsResult,
|
||||
} from '../core/serverSentEvents.gen';
|
||||
import type {
|
||||
Client as CoreClient,
|
||||
Config as CoreConfig,
|
||||
} from '../core/types.gen';
|
||||
import type { Middleware } from './utils.gen';
|
||||
|
||||
export type ResponseStyle = 'data' | 'fields';
|
||||
|
||||
export interface Config<T extends ClientOptions = ClientOptions>
|
||||
extends Omit<RequestInit, 'body' | 'headers' | 'method'>,
|
||||
CoreConfig {
|
||||
/**
|
||||
* Base URL for all requests made by this client.
|
||||
*/
|
||||
baseUrl?: T['baseUrl'];
|
||||
/**
|
||||
* Fetch API implementation. You can use this option to provide a custom
|
||||
* fetch instance.
|
||||
*
|
||||
* @default globalThis.fetch
|
||||
*/
|
||||
fetch?: typeof fetch;
|
||||
/**
|
||||
* Please don't use the Fetch client for Next.js applications. The `next`
|
||||
* options won't have any effect.
|
||||
*
|
||||
* Install {@link https://www.npmjs.com/package/@hey-api/client-next `@hey-api/client-next`} instead.
|
||||
*/
|
||||
next?: never;
|
||||
/**
|
||||
* Return the response data parsed in a specified format. By default, `auto`
|
||||
* will infer the appropriate method from the `Content-Type` response header.
|
||||
* 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.
|
||||
*
|
||||
* @default 'auto'
|
||||
*/
|
||||
parseAs?:
|
||||
| 'arrayBuffer'
|
||||
| 'auto'
|
||||
| 'blob'
|
||||
| 'formData'
|
||||
| 'json'
|
||||
| 'stream'
|
||||
| 'text';
|
||||
/**
|
||||
* Should we return only data or multiple fields (data, error, response, etc.)?
|
||||
*
|
||||
* @default 'fields'
|
||||
*/
|
||||
responseStyle?: ResponseStyle;
|
||||
/**
|
||||
* Throw an error instead of returning it in the response?
|
||||
*
|
||||
* @default false
|
||||
*/
|
||||
throwOnError?: T['throwOnError'];
|
||||
}
|
||||
|
||||
export interface RequestOptions<
|
||||
TData = unknown,
|
||||
TResponseStyle extends ResponseStyle = 'fields',
|
||||
ThrowOnError extends boolean = boolean,
|
||||
Url extends string = string,
|
||||
> extends Config<{
|
||||
responseStyle: TResponseStyle;
|
||||
throwOnError: ThrowOnError;
|
||||
}>,
|
||||
Pick<
|
||||
ServerSentEventsOptions<TData>,
|
||||
| 'onSseError'
|
||||
| 'onSseEvent'
|
||||
| 'sseDefaultRetryDelay'
|
||||
| 'sseMaxRetryAttempts'
|
||||
| 'sseMaxRetryDelay'
|
||||
> {
|
||||
/**
|
||||
* Any body that you want to add to your request.
|
||||
*
|
||||
* {@link https://developer.mozilla.org/docs/Web/API/fetch#body}
|
||||
*/
|
||||
body?: unknown;
|
||||
path?: Record<string, unknown>;
|
||||
query?: Record<string, unknown>;
|
||||
/**
|
||||
* Security mechanism(s) to use for the request.
|
||||
*/
|
||||
security?: ReadonlyArray<Auth>;
|
||||
url: Url;
|
||||
}
|
||||
|
||||
export interface ResolvedRequestOptions<
|
||||
TResponseStyle extends ResponseStyle = 'fields',
|
||||
ThrowOnError extends boolean = boolean,
|
||||
Url extends string = string,
|
||||
> extends RequestOptions<unknown, TResponseStyle, ThrowOnError, Url> {
|
||||
serializedBody?: string;
|
||||
}
|
||||
|
||||
export type RequestResult<
|
||||
TData = unknown,
|
||||
TError = unknown,
|
||||
ThrowOnError extends boolean = boolean,
|
||||
TResponseStyle extends ResponseStyle = 'fields',
|
||||
> = ThrowOnError extends true
|
||||
? Promise<
|
||||
TResponseStyle extends 'data'
|
||||
? TData extends Record<string, unknown>
|
||||
? TData[keyof TData]
|
||||
: TData
|
||||
: {
|
||||
data: TData extends Record<string, unknown>
|
||||
? TData[keyof TData]
|
||||
: TData;
|
||||
request: Request;
|
||||
response: Response;
|
||||
}
|
||||
>
|
||||
: Promise<
|
||||
TResponseStyle extends 'data'
|
||||
?
|
||||
| (TData extends Record<string, unknown>
|
||||
? TData[keyof TData]
|
||||
: TData)
|
||||
| undefined
|
||||
: (
|
||||
| {
|
||||
data: TData extends Record<string, unknown>
|
||||
? TData[keyof TData]
|
||||
: TData;
|
||||
error: undefined;
|
||||
}
|
||||
| {
|
||||
data: undefined;
|
||||
error: TError extends Record<string, unknown>
|
||||
? TError[keyof TError]
|
||||
: TError;
|
||||
}
|
||||
) & {
|
||||
request: Request;
|
||||
response: Response;
|
||||
}
|
||||
>;
|
||||
|
||||
export interface ClientOptions {
|
||||
baseUrl?: string;
|
||||
responseStyle?: ResponseStyle;
|
||||
throwOnError?: boolean;
|
||||
}
|
||||
|
||||
type MethodFn = <
|
||||
TData = unknown,
|
||||
TError = unknown,
|
||||
ThrowOnError extends boolean = false,
|
||||
TResponseStyle extends ResponseStyle = 'fields',
|
||||
>(
|
||||
options: Omit<RequestOptions<TData, TResponseStyle, ThrowOnError>, 'method'>,
|
||||
) => RequestResult<TData, TError, ThrowOnError, TResponseStyle>;
|
||||
|
||||
type SseFn = <
|
||||
TData = unknown,
|
||||
TError = unknown,
|
||||
ThrowOnError extends boolean = false,
|
||||
TResponseStyle extends ResponseStyle = 'fields',
|
||||
>(
|
||||
options: Omit<RequestOptions<TData, TResponseStyle, ThrowOnError>, 'method'>,
|
||||
) => Promise<ServerSentEventsResult<TData, TError>>;
|
||||
|
||||
type RequestFn = <
|
||||
TData = unknown,
|
||||
TError = unknown,
|
||||
ThrowOnError extends boolean = false,
|
||||
TResponseStyle extends ResponseStyle = 'fields',
|
||||
>(
|
||||
options: Omit<RequestOptions<TData, TResponseStyle, ThrowOnError>, 'method'> &
|
||||
Pick<
|
||||
Required<RequestOptions<TData, TResponseStyle, ThrowOnError>>,
|
||||
'method'
|
||||
>,
|
||||
) => RequestResult<TData, TError, ThrowOnError, TResponseStyle>;
|
||||
|
||||
type BuildUrlFn = <
|
||||
TData extends {
|
||||
body?: unknown;
|
||||
path?: Record<string, unknown>;
|
||||
query?: Record<string, unknown>;
|
||||
url: string;
|
||||
},
|
||||
>(
|
||||
options: TData & Options<TData>,
|
||||
) => string;
|
||||
|
||||
export type Client = CoreClient<
|
||||
RequestFn,
|
||||
Config,
|
||||
MethodFn,
|
||||
BuildUrlFn,
|
||||
SseFn
|
||||
> & {
|
||||
interceptors: Middleware<Request, Response, unknown, ResolvedRequestOptions>;
|
||||
};
|
||||
|
||||
/**
|
||||
* The `createClientConfig()` function will be called on client initialization
|
||||
* and the returned object will become the client's initial configuration.
|
||||
*
|
||||
* You may want to initialize your client this way instead of calling
|
||||
* `setConfig()`. This is useful for example if you're using Next.js
|
||||
* to ensure your client always has the correct values.
|
||||
*/
|
||||
export type CreateClientConfig<T extends ClientOptions = ClientOptions> = (
|
||||
override?: Config<ClientOptions & T>,
|
||||
) => Config<Required<ClientOptions> & T>;
|
||||
|
||||
export interface TDataShape {
|
||||
body?: unknown;
|
||||
headers?: unknown;
|
||||
path?: unknown;
|
||||
query?: unknown;
|
||||
url: string;
|
||||
}
|
||||
|
||||
type OmitKeys<T, K> = Pick<T, Exclude<keyof T, K>>;
|
||||
|
||||
export type Options<
|
||||
TData extends TDataShape = TDataShape,
|
||||
ThrowOnError extends boolean = boolean,
|
||||
TResponse = unknown,
|
||||
TResponseStyle extends ResponseStyle = 'fields',
|
||||
> = OmitKeys<
|
||||
RequestOptions<TResponse, TResponseStyle, ThrowOnError>,
|
||||
'body' | 'path' | 'query' | 'url'
|
||||
> &
|
||||
([TData] extends [never] ? unknown : Omit<TData, 'url'>);
|
||||
332
app/client/api-client/client/utils.gen.ts
Normal file
332
app/client/api-client/client/utils.gen.ts
Normal file
@@ -0,0 +1,332 @@
|
||||
// This file is auto-generated by @hey-api/openapi-ts
|
||||
|
||||
import { getAuthToken } from '../core/auth.gen';
|
||||
import type { QuerySerializerOptions } from '../core/bodySerializer.gen';
|
||||
import { jsonBodySerializer } from '../core/bodySerializer.gen';
|
||||
import {
|
||||
serializeArrayParam,
|
||||
serializeObjectParam,
|
||||
serializePrimitiveParam,
|
||||
} from '../core/pathSerializer.gen';
|
||||
import { getUrl } from '../core/utils.gen';
|
||||
import type { Client, ClientOptions, Config, RequestOptions } from './types.gen';
|
||||
|
||||
export const createQuerySerializer = <T = unknown>({
|
||||
parameters = {},
|
||||
...args
|
||||
}: QuerySerializerOptions = {}) => {
|
||||
const querySerializer = (queryParams: T) => {
|
||||
const search: string[] = [];
|
||||
if (queryParams && typeof queryParams === 'object') {
|
||||
for (const name in queryParams) {
|
||||
const value = queryParams[name];
|
||||
|
||||
if (value === undefined || value === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const options = parameters[name] || args;
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
const serializedArray = serializeArrayParam({
|
||||
allowReserved: options.allowReserved,
|
||||
explode: true,
|
||||
name,
|
||||
style: 'form',
|
||||
value,
|
||||
...options.array,
|
||||
});
|
||||
if (serializedArray) search.push(serializedArray);
|
||||
} else if (typeof value === 'object') {
|
||||
const serializedObject = serializeObjectParam({
|
||||
allowReserved: options.allowReserved,
|
||||
explode: true,
|
||||
name,
|
||||
style: 'deepObject',
|
||||
value: value as Record<string, unknown>,
|
||||
...options.object,
|
||||
});
|
||||
if (serializedObject) search.push(serializedObject);
|
||||
} else {
|
||||
const serializedPrimitive = serializePrimitiveParam({
|
||||
allowReserved: options.allowReserved,
|
||||
name,
|
||||
value: value as string,
|
||||
});
|
||||
if (serializedPrimitive) search.push(serializedPrimitive);
|
||||
}
|
||||
}
|
||||
}
|
||||
return search.join('&');
|
||||
};
|
||||
return querySerializer;
|
||||
};
|
||||
|
||||
/**
|
||||
* Infers parseAs value from provided Content-Type header.
|
||||
*/
|
||||
export const getParseAs = (
|
||||
contentType: string | null,
|
||||
): Exclude<Config['parseAs'], 'auto'> => {
|
||||
if (!contentType) {
|
||||
// If no Content-Type header is provided, the best we can do is return the raw response body,
|
||||
// which is effectively the same as the 'stream' option.
|
||||
return 'stream';
|
||||
}
|
||||
|
||||
const cleanContent = contentType.split(';')[0]?.trim();
|
||||
|
||||
if (!cleanContent) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
cleanContent.startsWith('application/json') ||
|
||||
cleanContent.endsWith('+json')
|
||||
) {
|
||||
return 'json';
|
||||
}
|
||||
|
||||
if (cleanContent === 'multipart/form-data') {
|
||||
return 'formData';
|
||||
}
|
||||
|
||||
if (
|
||||
['application/', 'audio/', 'image/', 'video/'].some((type) =>
|
||||
cleanContent.startsWith(type),
|
||||
)
|
||||
) {
|
||||
return 'blob';
|
||||
}
|
||||
|
||||
if (cleanContent.startsWith('text/')) {
|
||||
return 'text';
|
||||
}
|
||||
|
||||
return;
|
||||
};
|
||||
|
||||
const checkForExistence = (
|
||||
options: Pick<RequestOptions, 'auth' | 'query'> & {
|
||||
headers: Headers;
|
||||
},
|
||||
name?: string,
|
||||
): boolean => {
|
||||
if (!name) {
|
||||
return false;
|
||||
}
|
||||
if (
|
||||
options.headers.has(name) ||
|
||||
options.query?.[name] ||
|
||||
options.headers.get('Cookie')?.includes(`${name}=`)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
export const setAuthParams = async ({
|
||||
security,
|
||||
...options
|
||||
}: Pick<Required<RequestOptions>, 'security'> &
|
||||
Pick<RequestOptions, 'auth' | 'query'> & {
|
||||
headers: Headers;
|
||||
}) => {
|
||||
for (const auth of security) {
|
||||
if (checkForExistence(options, auth.name)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const token = await getAuthToken(auth, options.auth);
|
||||
|
||||
if (!token) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const name = auth.name ?? 'Authorization';
|
||||
|
||||
switch (auth.in) {
|
||||
case 'query':
|
||||
if (!options.query) {
|
||||
options.query = {};
|
||||
}
|
||||
options.query[name] = token;
|
||||
break;
|
||||
case 'cookie':
|
||||
options.headers.append('Cookie', `${name}=${token}`);
|
||||
break;
|
||||
case 'header':
|
||||
default:
|
||||
options.headers.set(name, token);
|
||||
break;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export const buildUrl: Client['buildUrl'] = (options) =>
|
||||
getUrl({
|
||||
baseUrl: options.baseUrl as string,
|
||||
path: options.path,
|
||||
query: options.query,
|
||||
querySerializer:
|
||||
typeof options.querySerializer === 'function'
|
||||
? options.querySerializer
|
||||
: createQuerySerializer(options.querySerializer),
|
||||
url: options.url,
|
||||
});
|
||||
|
||||
export const mergeConfigs = (a: Config, b: Config): Config => {
|
||||
const config = { ...a, ...b };
|
||||
if (config.baseUrl?.endsWith('/')) {
|
||||
config.baseUrl = config.baseUrl.substring(0, config.baseUrl.length - 1);
|
||||
}
|
||||
config.headers = mergeHeaders(a.headers, b.headers);
|
||||
return config;
|
||||
};
|
||||
|
||||
const headersEntries = (headers: Headers): Array<[string, string]> => {
|
||||
const entries: Array<[string, string]> = [];
|
||||
headers.forEach((value, key) => {
|
||||
entries.push([key, value]);
|
||||
});
|
||||
return entries;
|
||||
};
|
||||
|
||||
export const mergeHeaders = (
|
||||
...headers: Array<Required<Config>['headers'] | undefined>
|
||||
): Headers => {
|
||||
const mergedHeaders = new Headers();
|
||||
for (const header of headers) {
|
||||
if (!header) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const iterator =
|
||||
header instanceof Headers
|
||||
? headersEntries(header)
|
||||
: Object.entries(header);
|
||||
|
||||
for (const [key, value] of iterator) {
|
||||
if (value === null) {
|
||||
mergedHeaders.delete(key);
|
||||
} else if (Array.isArray(value)) {
|
||||
for (const v of value) {
|
||||
mergedHeaders.append(key, v as string);
|
||||
}
|
||||
} else if (value !== undefined) {
|
||||
// assume object headers are meant to be JSON stringified, i.e. their
|
||||
// content value in OpenAPI specification is 'application/json'
|
||||
mergedHeaders.set(
|
||||
key,
|
||||
typeof value === 'object' ? JSON.stringify(value) : (value as string),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
return mergedHeaders;
|
||||
};
|
||||
|
||||
type ErrInterceptor<Err, Res, Req, Options> = (
|
||||
error: Err,
|
||||
response: Res,
|
||||
request: Req,
|
||||
options: Options,
|
||||
) => Err | Promise<Err>;
|
||||
|
||||
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>;
|
||||
|
||||
class Interceptors<Interceptor> {
|
||||
fns: Array<Interceptor | null> = [];
|
||||
|
||||
clear(): void {
|
||||
this.fns = [];
|
||||
}
|
||||
|
||||
eject(id: number | Interceptor): void {
|
||||
const index = this.getInterceptorIndex(id);
|
||||
if (this.fns[index]) {
|
||||
this.fns[index] = null;
|
||||
}
|
||||
}
|
||||
|
||||
exists(id: number | Interceptor): boolean {
|
||||
const index = this.getInterceptorIndex(id);
|
||||
return Boolean(this.fns[index]);
|
||||
}
|
||||
|
||||
getInterceptorIndex(id: number | Interceptor): number {
|
||||
if (typeof id === 'number') {
|
||||
return this.fns[id] ? id : -1;
|
||||
}
|
||||
return this.fns.indexOf(id);
|
||||
}
|
||||
|
||||
update(
|
||||
id: number | Interceptor,
|
||||
fn: Interceptor,
|
||||
): number | Interceptor | false {
|
||||
const index = this.getInterceptorIndex(id);
|
||||
if (this.fns[index]) {
|
||||
this.fns[index] = fn;
|
||||
return id;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
use(fn: Interceptor): number {
|
||||
this.fns.push(fn);
|
||||
return this.fns.length - 1;
|
||||
}
|
||||
}
|
||||
|
||||
export interface Middleware<Req, Res, Err, Options> {
|
||||
error: Interceptors<ErrInterceptor<Err, Res, Req, Options>>;
|
||||
request: Interceptors<ReqInterceptor<Req, Options>>;
|
||||
response: Interceptors<ResInterceptor<Res, Req, Options>>;
|
||||
}
|
||||
|
||||
export const createInterceptors = <Req, Res, Err, Options>(): Middleware<
|
||||
Req,
|
||||
Res,
|
||||
Err,
|
||||
Options
|
||||
> => ({
|
||||
error: new Interceptors<ErrInterceptor<Err, Res, Req, Options>>(),
|
||||
request: new Interceptors<ReqInterceptor<Req, Options>>(),
|
||||
response: new Interceptors<ResInterceptor<Res, Req, Options>>(),
|
||||
});
|
||||
|
||||
const defaultQuerySerializer = createQuerySerializer({
|
||||
allowReserved: false,
|
||||
array: {
|
||||
explode: true,
|
||||
style: 'form',
|
||||
},
|
||||
object: {
|
||||
explode: true,
|
||||
style: 'deepObject',
|
||||
},
|
||||
});
|
||||
|
||||
const defaultHeaders = {
|
||||
'Content-Type': 'application/json',
|
||||
};
|
||||
|
||||
export const createConfig = <T extends ClientOptions = ClientOptions>(
|
||||
override: Config<Omit<ClientOptions, keyof T> & T> = {},
|
||||
): Config<Omit<ClientOptions, keyof T> & T> => ({
|
||||
...jsonBodySerializer,
|
||||
headers: defaultHeaders,
|
||||
parseAs: 'auto',
|
||||
querySerializer: defaultQuerySerializer,
|
||||
...override,
|
||||
});
|
||||
42
app/client/api-client/core/auth.gen.ts
Normal file
42
app/client/api-client/core/auth.gen.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
// This file is auto-generated by @hey-api/openapi-ts
|
||||
|
||||
export type AuthToken = string | undefined;
|
||||
|
||||
export interface Auth {
|
||||
/**
|
||||
* Which part of the request do we use to send the auth?
|
||||
*
|
||||
* @default 'header'
|
||||
*/
|
||||
in?: 'header' | 'query' | 'cookie';
|
||||
/**
|
||||
* Header or query parameter name.
|
||||
*
|
||||
* @default 'Authorization'
|
||||
*/
|
||||
name?: string;
|
||||
scheme?: 'basic' | 'bearer';
|
||||
type: 'apiKey' | 'http';
|
||||
}
|
||||
|
||||
export const getAuthToken = async (
|
||||
auth: Auth,
|
||||
callback: ((auth: Auth) => Promise<AuthToken> | AuthToken) | AuthToken,
|
||||
): Promise<string | undefined> => {
|
||||
const token =
|
||||
typeof callback === 'function' ? await callback(auth) : callback;
|
||||
|
||||
if (!token) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (auth.scheme === 'bearer') {
|
||||
return `Bearer ${token}`;
|
||||
}
|
||||
|
||||
if (auth.scheme === 'basic') {
|
||||
return `Basic ${btoa(token)}`;
|
||||
}
|
||||
|
||||
return token;
|
||||
};
|
||||
100
app/client/api-client/core/bodySerializer.gen.ts
Normal file
100
app/client/api-client/core/bodySerializer.gen.ts
Normal file
@@ -0,0 +1,100 @@
|
||||
// This file is auto-generated by @hey-api/openapi-ts
|
||||
|
||||
import type {
|
||||
ArrayStyle,
|
||||
ObjectStyle,
|
||||
SerializerOptions,
|
||||
} from './pathSerializer.gen';
|
||||
|
||||
export type QuerySerializer = (query: Record<string, unknown>) => string;
|
||||
|
||||
export type BodySerializer = (body: any) => any;
|
||||
|
||||
type QuerySerializerOptionsObject = {
|
||||
allowReserved?: boolean;
|
||||
array?: Partial<SerializerOptions<ArrayStyle>>;
|
||||
object?: Partial<SerializerOptions<ObjectStyle>>;
|
||||
};
|
||||
|
||||
export type QuerySerializerOptions = QuerySerializerOptionsObject & {
|
||||
/**
|
||||
* Per-parameter serialization overrides. When provided, these settings
|
||||
* override the global array/object settings for specific parameter names.
|
||||
*/
|
||||
parameters?: Record<string, QuerySerializerOptionsObject>;
|
||||
};
|
||||
|
||||
const serializeFormDataPair = (
|
||||
data: FormData,
|
||||
key: string,
|
||||
value: unknown,
|
||||
): void => {
|
||||
if (typeof value === 'string' || value instanceof Blob) {
|
||||
data.append(key, value);
|
||||
} else if (value instanceof Date) {
|
||||
data.append(key, value.toISOString());
|
||||
} else {
|
||||
data.append(key, JSON.stringify(value));
|
||||
}
|
||||
};
|
||||
|
||||
const serializeUrlSearchParamsPair = (
|
||||
data: URLSearchParams,
|
||||
key: string,
|
||||
value: unknown,
|
||||
): void => {
|
||||
if (typeof value === 'string') {
|
||||
data.append(key, value);
|
||||
} else {
|
||||
data.append(key, JSON.stringify(value));
|
||||
}
|
||||
};
|
||||
|
||||
export const formDataBodySerializer = {
|
||||
bodySerializer: <T extends Record<string, any> | Array<Record<string, any>>>(
|
||||
body: T,
|
||||
): FormData => {
|
||||
const data = new FormData();
|
||||
|
||||
Object.entries(body).forEach(([key, value]) => {
|
||||
if (value === undefined || value === null) {
|
||||
return;
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
value.forEach((v) => serializeFormDataPair(data, key, v));
|
||||
} else {
|
||||
serializeFormDataPair(data, key, value);
|
||||
}
|
||||
});
|
||||
|
||||
return data;
|
||||
},
|
||||
};
|
||||
|
||||
export const jsonBodySerializer = {
|
||||
bodySerializer: <T>(body: T): string =>
|
||||
JSON.stringify(body, (_key, value) =>
|
||||
typeof value === 'bigint' ? value.toString() : value,
|
||||
),
|
||||
};
|
||||
|
||||
export const urlSearchParamsBodySerializer = {
|
||||
bodySerializer: <T extends Record<string, any> | Array<Record<string, any>>>(
|
||||
body: T,
|
||||
): string => {
|
||||
const data = new URLSearchParams();
|
||||
|
||||
Object.entries(body).forEach(([key, value]) => {
|
||||
if (value === undefined || value === null) {
|
||||
return;
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
value.forEach((v) => serializeUrlSearchParamsPair(data, key, v));
|
||||
} else {
|
||||
serializeUrlSearchParamsPair(data, key, value);
|
||||
}
|
||||
});
|
||||
|
||||
return data.toString();
|
||||
},
|
||||
};
|
||||
176
app/client/api-client/core/params.gen.ts
Normal file
176
app/client/api-client/core/params.gen.ts
Normal file
@@ -0,0 +1,176 @@
|
||||
// This file is auto-generated by @hey-api/openapi-ts
|
||||
|
||||
type Slot = 'body' | 'headers' | 'path' | 'query';
|
||||
|
||||
export type Field =
|
||||
| {
|
||||
in: Exclude<Slot, 'body'>;
|
||||
/**
|
||||
* Field name. This is the name we want the user to see and use.
|
||||
*/
|
||||
key: string;
|
||||
/**
|
||||
* Field mapped name. This is the name we want to use in the request.
|
||||
* If omitted, we use the same value as `key`.
|
||||
*/
|
||||
map?: string;
|
||||
}
|
||||
| {
|
||||
in: Extract<Slot, 'body'>;
|
||||
/**
|
||||
* Key isn't required for bodies.
|
||||
*/
|
||||
key?: string;
|
||||
map?: string;
|
||||
}
|
||||
| {
|
||||
/**
|
||||
* Field name. This is the name we want the user to see and use.
|
||||
*/
|
||||
key: string;
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
map: Slot;
|
||||
};
|
||||
|
||||
export interface Fields {
|
||||
allowExtra?: Partial<Record<Slot, boolean>>;
|
||||
args?: ReadonlyArray<Field>;
|
||||
}
|
||||
|
||||
export type FieldsConfig = ReadonlyArray<Field | Fields>;
|
||||
|
||||
const extraPrefixesMap: Record<string, Slot> = {
|
||||
$body_: 'body',
|
||||
$headers_: 'headers',
|
||||
$path_: 'path',
|
||||
$query_: 'query',
|
||||
};
|
||||
const extraPrefixes = Object.entries(extraPrefixesMap);
|
||||
|
||||
type KeyMap = Map<
|
||||
string,
|
||||
| {
|
||||
in: Slot;
|
||||
map?: string;
|
||||
}
|
||||
| {
|
||||
in?: never;
|
||||
map: Slot;
|
||||
}
|
||||
>;
|
||||
|
||||
const buildKeyMap = (fields: FieldsConfig, map?: KeyMap): KeyMap => {
|
||||
if (!map) {
|
||||
map = new Map();
|
||||
}
|
||||
|
||||
for (const config of fields) {
|
||||
if ('in' in config) {
|
||||
if (config.key) {
|
||||
map.set(config.key, {
|
||||
in: config.in,
|
||||
map: config.map,
|
||||
});
|
||||
}
|
||||
} else if ('key' in config) {
|
||||
map.set(config.key, {
|
||||
map: config.map,
|
||||
});
|
||||
} else if (config.args) {
|
||||
buildKeyMap(config.args, map);
|
||||
}
|
||||
}
|
||||
|
||||
return map;
|
||||
};
|
||||
|
||||
interface Params {
|
||||
body: unknown;
|
||||
headers: Record<string, unknown>;
|
||||
path: Record<string, unknown>;
|
||||
query: Record<string, unknown>;
|
||||
}
|
||||
|
||||
const stripEmptySlots = (params: Params) => {
|
||||
for (const [slot, value] of Object.entries(params)) {
|
||||
if (value && typeof value === 'object' && !Object.keys(value).length) {
|
||||
delete params[slot as Slot];
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export const buildClientParams = (
|
||||
args: ReadonlyArray<unknown>,
|
||||
fields: FieldsConfig,
|
||||
) => {
|
||||
const params: Params = {
|
||||
body: {},
|
||||
headers: {},
|
||||
path: {},
|
||||
query: {},
|
||||
};
|
||||
|
||||
const map = buildKeyMap(fields);
|
||||
|
||||
let config: FieldsConfig[number] | undefined;
|
||||
|
||||
for (const [index, arg] of args.entries()) {
|
||||
if (fields[index]) {
|
||||
config = fields[index];
|
||||
}
|
||||
|
||||
if (!config) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if ('in' in config) {
|
||||
if (config.key) {
|
||||
const field = map.get(config.key)!;
|
||||
const name = field.map || config.key;
|
||||
if (field.in) {
|
||||
(params[field.in] as Record<string, unknown>)[name] = arg;
|
||||
}
|
||||
} else {
|
||||
params.body = arg;
|
||||
}
|
||||
} else {
|
||||
for (const [key, value] of Object.entries(arg ?? {})) {
|
||||
const field = map.get(key);
|
||||
|
||||
if (field) {
|
||||
if (field.in) {
|
||||
const name = field.map || key;
|
||||
(params[field.in] as Record<string, unknown>)[name] = value;
|
||||
} else {
|
||||
params[field.map] = value;
|
||||
}
|
||||
} else {
|
||||
const extra = extraPrefixes.find(([prefix]) =>
|
||||
key.startsWith(prefix),
|
||||
);
|
||||
|
||||
if (extra) {
|
||||
const [prefix, slot] = extra;
|
||||
(params[slot] as Record<string, unknown>)[
|
||||
key.slice(prefix.length)
|
||||
] = value;
|
||||
} else if ('allowExtra' in config && config.allowExtra) {
|
||||
for (const [slot, allowed] of Object.entries(config.allowExtra)) {
|
||||
if (allowed) {
|
||||
(params[slot as Slot] as Record<string, unknown>)[key] = value;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
stripEmptySlots(params);
|
||||
|
||||
return params;
|
||||
};
|
||||
181
app/client/api-client/core/pathSerializer.gen.ts
Normal file
181
app/client/api-client/core/pathSerializer.gen.ts
Normal file
@@ -0,0 +1,181 @@
|
||||
// This file is auto-generated by @hey-api/openapi-ts
|
||||
|
||||
interface SerializeOptions<T>
|
||||
extends SerializePrimitiveOptions,
|
||||
SerializerOptions<T> {}
|
||||
|
||||
interface SerializePrimitiveOptions {
|
||||
allowReserved?: boolean;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface SerializerOptions<T> {
|
||||
/**
|
||||
* @default true
|
||||
*/
|
||||
explode: boolean;
|
||||
style: T;
|
||||
}
|
||||
|
||||
export type ArrayStyle = 'form' | 'spaceDelimited' | 'pipeDelimited';
|
||||
export type ArraySeparatorStyle = ArrayStyle | MatrixStyle;
|
||||
type MatrixStyle = 'label' | 'matrix' | 'simple';
|
||||
export type ObjectStyle = 'form' | 'deepObject';
|
||||
type ObjectSeparatorStyle = ObjectStyle | MatrixStyle;
|
||||
|
||||
interface SerializePrimitiveParam extends SerializePrimitiveOptions {
|
||||
value: string;
|
||||
}
|
||||
|
||||
export const separatorArrayExplode = (style: ArraySeparatorStyle) => {
|
||||
switch (style) {
|
||||
case 'label':
|
||||
return '.';
|
||||
case 'matrix':
|
||||
return ';';
|
||||
case 'simple':
|
||||
return ',';
|
||||
default:
|
||||
return '&';
|
||||
}
|
||||
};
|
||||
|
||||
export const separatorArrayNoExplode = (style: ArraySeparatorStyle) => {
|
||||
switch (style) {
|
||||
case 'form':
|
||||
return ',';
|
||||
case 'pipeDelimited':
|
||||
return '|';
|
||||
case 'spaceDelimited':
|
||||
return '%20';
|
||||
default:
|
||||
return ',';
|
||||
}
|
||||
};
|
||||
|
||||
export const separatorObjectExplode = (style: ObjectSeparatorStyle) => {
|
||||
switch (style) {
|
||||
case 'label':
|
||||
return '.';
|
||||
case 'matrix':
|
||||
return ';';
|
||||
case 'simple':
|
||||
return ',';
|
||||
default:
|
||||
return '&';
|
||||
}
|
||||
};
|
||||
|
||||
export const serializeArrayParam = ({
|
||||
allowReserved,
|
||||
explode,
|
||||
name,
|
||||
style,
|
||||
value,
|
||||
}: SerializeOptions<ArraySeparatorStyle> & {
|
||||
value: unknown[];
|
||||
}) => {
|
||||
if (!explode) {
|
||||
const joinedValues = (
|
||||
allowReserved ? value : value.map((v) => encodeURIComponent(v as string))
|
||||
).join(separatorArrayNoExplode(style));
|
||||
switch (style) {
|
||||
case 'label':
|
||||
return `.${joinedValues}`;
|
||||
case 'matrix':
|
||||
return `;${name}=${joinedValues}`;
|
||||
case 'simple':
|
||||
return joinedValues;
|
||||
default:
|
||||
return `${name}=${joinedValues}`;
|
||||
}
|
||||
}
|
||||
|
||||
const separator = separatorArrayExplode(style);
|
||||
const joinedValues = value
|
||||
.map((v) => {
|
||||
if (style === 'label' || style === 'simple') {
|
||||
return allowReserved ? v : encodeURIComponent(v as string);
|
||||
}
|
||||
|
||||
return serializePrimitiveParam({
|
||||
allowReserved,
|
||||
name,
|
||||
value: v as string,
|
||||
});
|
||||
})
|
||||
.join(separator);
|
||||
return style === 'label' || style === 'matrix'
|
||||
? separator + joinedValues
|
||||
: joinedValues;
|
||||
};
|
||||
|
||||
export const serializePrimitiveParam = ({
|
||||
allowReserved,
|
||||
name,
|
||||
value,
|
||||
}: SerializePrimitiveParam) => {
|
||||
if (value === undefined || value === null) {
|
||||
return '';
|
||||
}
|
||||
|
||||
if (typeof value === 'object') {
|
||||
throw new Error(
|
||||
'Deeply-nested arrays/objects aren’t supported. Provide your own `querySerializer()` to handle these.',
|
||||
);
|
||||
}
|
||||
|
||||
return `${name}=${allowReserved ? value : encodeURIComponent(value)}`;
|
||||
};
|
||||
|
||||
export const serializeObjectParam = ({
|
||||
allowReserved,
|
||||
explode,
|
||||
name,
|
||||
style,
|
||||
value,
|
||||
valueOnly,
|
||||
}: SerializeOptions<ObjectSeparatorStyle> & {
|
||||
value: Record<string, unknown> | Date;
|
||||
valueOnly?: boolean;
|
||||
}) => {
|
||||
if (value instanceof Date) {
|
||||
return valueOnly ? value.toISOString() : `${name}=${value.toISOString()}`;
|
||||
}
|
||||
|
||||
if (style !== 'deepObject' && !explode) {
|
||||
let values: string[] = [];
|
||||
Object.entries(value).forEach(([key, v]) => {
|
||||
values = [
|
||||
...values,
|
||||
key,
|
||||
allowReserved ? (v as string) : encodeURIComponent(v as string),
|
||||
];
|
||||
});
|
||||
const joinedValues = values.join(',');
|
||||
switch (style) {
|
||||
case 'form':
|
||||
return `${name}=${joinedValues}`;
|
||||
case 'label':
|
||||
return `.${joinedValues}`;
|
||||
case 'matrix':
|
||||
return `;${name}=${joinedValues}`;
|
||||
default:
|
||||
return joinedValues;
|
||||
}
|
||||
}
|
||||
|
||||
const separator = separatorObjectExplode(style);
|
||||
const joinedValues = Object.entries(value)
|
||||
.map(([key, v]) =>
|
||||
serializePrimitiveParam({
|
||||
allowReserved,
|
||||
name: style === 'deepObject' ? `${name}[${key}]` : key,
|
||||
value: v as string,
|
||||
}),
|
||||
)
|
||||
.join(separator);
|
||||
return style === 'label' || style === 'matrix'
|
||||
? separator + joinedValues
|
||||
: joinedValues;
|
||||
};
|
||||
136
app/client/api-client/core/queryKeySerializer.gen.ts
Normal file
136
app/client/api-client/core/queryKeySerializer.gen.ts
Normal file
@@ -0,0 +1,136 @@
|
||||
// This file is auto-generated by @hey-api/openapi-ts
|
||||
|
||||
/**
|
||||
* JSON-friendly union that mirrors what Pinia Colada can hash.
|
||||
*/
|
||||
export type JsonValue =
|
||||
| null
|
||||
| string
|
||||
| number
|
||||
| boolean
|
||||
| JsonValue[]
|
||||
| { [key: string]: JsonValue };
|
||||
|
||||
/**
|
||||
* Replacer that converts non-JSON values (bigint, Date, etc.) to safe substitutes.
|
||||
*/
|
||||
export const queryKeyJsonReplacer = (_key: string, value: unknown) => {
|
||||
if (
|
||||
value === undefined ||
|
||||
typeof value === 'function' ||
|
||||
typeof value === 'symbol'
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
if (typeof value === 'bigint') {
|
||||
return value.toString();
|
||||
}
|
||||
if (value instanceof Date) {
|
||||
return value.toISOString();
|
||||
}
|
||||
return value;
|
||||
};
|
||||
|
||||
/**
|
||||
* Safely stringifies a value and parses it back into a JsonValue.
|
||||
*/
|
||||
export const stringifyToJsonValue = (input: unknown): JsonValue | undefined => {
|
||||
try {
|
||||
const json = JSON.stringify(input, queryKeyJsonReplacer);
|
||||
if (json === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
return JSON.parse(json) as JsonValue;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Detects plain objects (including objects with a null prototype).
|
||||
*/
|
||||
const isPlainObject = (value: unknown): value is Record<string, unknown> => {
|
||||
if (value === null || typeof value !== 'object') {
|
||||
return false;
|
||||
}
|
||||
const prototype = Object.getPrototypeOf(value as object);
|
||||
return prototype === Object.prototype || prototype === null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Turns URLSearchParams into a sorted JSON object for deterministic keys.
|
||||
*/
|
||||
const serializeSearchParams = (params: URLSearchParams): JsonValue => {
|
||||
const entries = Array.from(params.entries()).sort(([a], [b]) =>
|
||||
a.localeCompare(b),
|
||||
);
|
||||
const result: Record<string, JsonValue> = {};
|
||||
|
||||
for (const [key, value] of entries) {
|
||||
const existing = result[key];
|
||||
if (existing === undefined) {
|
||||
result[key] = value;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (Array.isArray(existing)) {
|
||||
(existing as string[]).push(value);
|
||||
} else {
|
||||
result[key] = [existing, value];
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
/**
|
||||
* Normalizes any accepted value into a JSON-friendly shape for query keys.
|
||||
*/
|
||||
export const serializeQueryKeyValue = (
|
||||
value: unknown,
|
||||
): JsonValue | undefined => {
|
||||
if (value === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (
|
||||
typeof value === 'string' ||
|
||||
typeof value === 'number' ||
|
||||
typeof value === 'boolean'
|
||||
) {
|
||||
return value;
|
||||
}
|
||||
|
||||
if (
|
||||
value === undefined ||
|
||||
typeof value === 'function' ||
|
||||
typeof value === 'symbol'
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (typeof value === 'bigint') {
|
||||
return value.toString();
|
||||
}
|
||||
|
||||
if (value instanceof Date) {
|
||||
return value.toISOString();
|
||||
}
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
return stringifyToJsonValue(value);
|
||||
}
|
||||
|
||||
if (
|
||||
typeof URLSearchParams !== 'undefined' &&
|
||||
value instanceof URLSearchParams
|
||||
) {
|
||||
return serializeSearchParams(value);
|
||||
}
|
||||
|
||||
if (isPlainObject(value)) {
|
||||
return stringifyToJsonValue(value);
|
||||
}
|
||||
|
||||
return undefined;
|
||||
};
|
||||
264
app/client/api-client/core/serverSentEvents.gen.ts
Normal file
264
app/client/api-client/core/serverSentEvents.gen.ts
Normal file
@@ -0,0 +1,264 @@
|
||||
// This file is auto-generated by @hey-api/openapi-ts
|
||||
|
||||
import type { Config } from './types.gen';
|
||||
|
||||
export type ServerSentEventsOptions<TData = unknown> = Omit<
|
||||
RequestInit,
|
||||
'method'
|
||||
> &
|
||||
Pick<Config, 'method' | 'responseTransformer' | 'responseValidator'> & {
|
||||
/**
|
||||
* Fetch API implementation. You can use this option to provide a custom
|
||||
* fetch instance.
|
||||
*
|
||||
* @default globalThis.fetch
|
||||
*/
|
||||
fetch?: typeof fetch;
|
||||
/**
|
||||
* Implementing clients can call request interceptors inside this hook.
|
||||
*/
|
||||
onRequest?: (url: string, init: RequestInit) => Promise<Request>;
|
||||
/**
|
||||
* Callback invoked when a network or parsing error occurs during streaming.
|
||||
*
|
||||
* This option applies only if the endpoint returns a stream of events.
|
||||
*
|
||||
* @param error The error that occurred.
|
||||
*/
|
||||
onSseError?: (error: unknown) => void;
|
||||
/**
|
||||
* Callback invoked when an event is streamed from the server.
|
||||
*
|
||||
* This option applies only if the endpoint returns a stream of events.
|
||||
*
|
||||
* @param event Event streamed from the server.
|
||||
* @returns Nothing (void).
|
||||
*/
|
||||
onSseEvent?: (event: StreamEvent<TData>) => void;
|
||||
serializedBody?: RequestInit['body'];
|
||||
/**
|
||||
* Default retry delay in milliseconds.
|
||||
*
|
||||
* This option applies only if the endpoint returns a stream of events.
|
||||
*
|
||||
* @default 3000
|
||||
*/
|
||||
sseDefaultRetryDelay?: number;
|
||||
/**
|
||||
* Maximum number of retry attempts before giving up.
|
||||
*/
|
||||
sseMaxRetryAttempts?: number;
|
||||
/**
|
||||
* Maximum retry delay in milliseconds.
|
||||
*
|
||||
* Applies only when exponential backoff is used.
|
||||
*
|
||||
* This option applies only if the endpoint returns a stream of events.
|
||||
*
|
||||
* @default 30000
|
||||
*/
|
||||
sseMaxRetryDelay?: number;
|
||||
/**
|
||||
* Optional sleep function for retry backoff.
|
||||
*
|
||||
* Defaults to using `setTimeout`.
|
||||
*/
|
||||
sseSleepFn?: (ms: number) => Promise<void>;
|
||||
url: string;
|
||||
};
|
||||
|
||||
export interface StreamEvent<TData = unknown> {
|
||||
data: TData;
|
||||
event?: string;
|
||||
id?: string;
|
||||
retry?: number;
|
||||
}
|
||||
|
||||
export type ServerSentEventsResult<
|
||||
TData = unknown,
|
||||
TReturn = void,
|
||||
TNext = unknown,
|
||||
> = {
|
||||
stream: AsyncGenerator<
|
||||
TData extends Record<string, unknown> ? TData[keyof TData] : TData,
|
||||
TReturn,
|
||||
TNext
|
||||
>;
|
||||
};
|
||||
|
||||
export const createSseClient = <TData = unknown>({
|
||||
onRequest,
|
||||
onSseError,
|
||||
onSseEvent,
|
||||
responseTransformer,
|
||||
responseValidator,
|
||||
sseDefaultRetryDelay,
|
||||
sseMaxRetryAttempts,
|
||||
sseMaxRetryDelay,
|
||||
sseSleepFn,
|
||||
url,
|
||||
...options
|
||||
}: ServerSentEventsOptions): ServerSentEventsResult<TData> => {
|
||||
let lastEventId: string | undefined;
|
||||
|
||||
const sleep =
|
||||
sseSleepFn ??
|
||||
((ms: number) => new Promise((resolve) => setTimeout(resolve, ms)));
|
||||
|
||||
const createStream = async function* () {
|
||||
let retryDelay: number = sseDefaultRetryDelay ?? 3000;
|
||||
let attempt = 0;
|
||||
const signal = options.signal ?? new AbortController().signal;
|
||||
|
||||
while (true) {
|
||||
if (signal.aborted) break;
|
||||
|
||||
attempt++;
|
||||
|
||||
const headers =
|
||||
options.headers instanceof Headers
|
||||
? options.headers
|
||||
: new Headers(options.headers as Record<string, string> | undefined);
|
||||
|
||||
if (lastEventId !== undefined) {
|
||||
headers.set('Last-Event-ID', lastEventId);
|
||||
}
|
||||
|
||||
try {
|
||||
const requestInit: RequestInit = {
|
||||
redirect: 'follow',
|
||||
...options,
|
||||
body: options.serializedBody,
|
||||
headers,
|
||||
signal,
|
||||
};
|
||||
let request = new Request(url, requestInit);
|
||||
if (onRequest) {
|
||||
request = await onRequest(url, requestInit);
|
||||
}
|
||||
// fetch must be assigned here, otherwise it would throw the error:
|
||||
// TypeError: Failed to execute 'fetch' on 'Window': Illegal invocation
|
||||
const _fetch = options.fetch ?? globalThis.fetch;
|
||||
const response = await _fetch(request);
|
||||
|
||||
if (!response.ok)
|
||||
throw new Error(
|
||||
`SSE failed: ${response.status} ${response.statusText}`,
|
||||
);
|
||||
|
||||
if (!response.body) throw new Error('No body in SSE response');
|
||||
|
||||
const reader = response.body
|
||||
.pipeThrough(new TextDecoderStream())
|
||||
.getReader();
|
||||
|
||||
let buffer = '';
|
||||
|
||||
const abortHandler = () => {
|
||||
try {
|
||||
reader.cancel();
|
||||
} catch {
|
||||
// noop
|
||||
}
|
||||
};
|
||||
|
||||
signal.addEventListener('abort', abortHandler);
|
||||
|
||||
try {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
buffer += value;
|
||||
|
||||
const chunks = buffer.split('\n\n');
|
||||
buffer = chunks.pop() ?? '';
|
||||
|
||||
for (const chunk of chunks) {
|
||||
const lines = chunk.split('\n');
|
||||
const dataLines: Array<string> = [];
|
||||
let eventName: string | undefined;
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.startsWith('data:')) {
|
||||
dataLines.push(line.replace(/^data:\s*/, ''));
|
||||
} else if (line.startsWith('event:')) {
|
||||
eventName = line.replace(/^event:\s*/, '');
|
||||
} else if (line.startsWith('id:')) {
|
||||
lastEventId = line.replace(/^id:\s*/, '');
|
||||
} else if (line.startsWith('retry:')) {
|
||||
const parsed = Number.parseInt(
|
||||
line.replace(/^retry:\s*/, ''),
|
||||
10,
|
||||
);
|
||||
if (!Number.isNaN(parsed)) {
|
||||
retryDelay = parsed;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let data: unknown;
|
||||
let parsedJson = false;
|
||||
|
||||
if (dataLines.length) {
|
||||
const rawData = dataLines.join('\n');
|
||||
try {
|
||||
data = JSON.parse(rawData);
|
||||
parsedJson = true;
|
||||
} catch {
|
||||
data = rawData;
|
||||
}
|
||||
}
|
||||
|
||||
if (parsedJson) {
|
||||
if (responseValidator) {
|
||||
await responseValidator(data);
|
||||
}
|
||||
|
||||
if (responseTransformer) {
|
||||
data = await responseTransformer(data);
|
||||
}
|
||||
}
|
||||
|
||||
onSseEvent?.({
|
||||
data,
|
||||
event: eventName,
|
||||
id: lastEventId,
|
||||
retry: retryDelay,
|
||||
});
|
||||
|
||||
if (dataLines.length) {
|
||||
yield data as any;
|
||||
}
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
signal.removeEventListener('abort', abortHandler);
|
||||
reader.releaseLock();
|
||||
}
|
||||
|
||||
break; // exit loop on normal completion
|
||||
} catch (error) {
|
||||
// connection failed or aborted; retry after delay
|
||||
onSseError?.(error);
|
||||
|
||||
if (
|
||||
sseMaxRetryAttempts !== undefined &&
|
||||
attempt >= sseMaxRetryAttempts
|
||||
) {
|
||||
break; // stop after firing error
|
||||
}
|
||||
|
||||
// exponential backoff: double retry each attempt, cap at 30s
|
||||
const backoff = Math.min(
|
||||
retryDelay * 2 ** (attempt - 1),
|
||||
sseMaxRetryDelay ?? 30000,
|
||||
);
|
||||
await sleep(backoff);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const stream = createStream();
|
||||
|
||||
return { stream };
|
||||
};
|
||||
118
app/client/api-client/core/types.gen.ts
Normal file
118
app/client/api-client/core/types.gen.ts
Normal file
@@ -0,0 +1,118 @@
|
||||
// This file is auto-generated by @hey-api/openapi-ts
|
||||
|
||||
import type { Auth, AuthToken } from './auth.gen';
|
||||
import type {
|
||||
BodySerializer,
|
||||
QuerySerializer,
|
||||
QuerySerializerOptions,
|
||||
} from './bodySerializer.gen';
|
||||
|
||||
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,
|
||||
> = {
|
||||
/**
|
||||
* Returns the final request URL.
|
||||
*/
|
||||
buildUrl: BuildUrlFn;
|
||||
getConfig: () => Config;
|
||||
request: RequestFn;
|
||||
setConfig: (config: Config) => Config;
|
||||
} & {
|
||||
[K in HttpMethod]: MethodFn;
|
||||
} & ([SseFn] extends [never]
|
||||
? { sse?: never }
|
||||
: { sse: { [K in HttpMethod]: SseFn } });
|
||||
|
||||
export interface Config {
|
||||
/**
|
||||
* Auth token or a function returning auth token. The resolved value will be
|
||||
* added to the request payload as defined by its `security` array.
|
||||
*/
|
||||
auth?: ((auth: Auth) => Promise<AuthToken> | AuthToken) | AuthToken;
|
||||
/**
|
||||
* A function for serializing request body parameter. By default,
|
||||
* {@link JSON.stringify()} will be used.
|
||||
*/
|
||||
bodySerializer?: BodySerializer | null;
|
||||
/**
|
||||
* An object containing any HTTP headers that you want to pre-populate your
|
||||
* `Headers` object with.
|
||||
*
|
||||
* {@link https://developer.mozilla.org/docs/Web/API/Headers/Headers#init See more}
|
||||
*/
|
||||
headers?:
|
||||
| RequestInit['headers']
|
||||
| Record<
|
||||
string,
|
||||
| string
|
||||
| number
|
||||
| boolean
|
||||
| (string | number | boolean)[]
|
||||
| null
|
||||
| undefined
|
||||
| unknown
|
||||
>;
|
||||
/**
|
||||
* The request method.
|
||||
*
|
||||
* {@link https://developer.mozilla.org/docs/Web/API/fetch#method See more}
|
||||
*/
|
||||
method?: Uppercase<HttpMethod>;
|
||||
/**
|
||||
* A function for serializing request query parameters. By default, arrays
|
||||
* will be exploded in form style, objects will be exploded in deepObject
|
||||
* style, and reserved characters are percent-encoded.
|
||||
*
|
||||
* This method will have no effect if the native `paramsSerializer()` Axios
|
||||
* API function is used.
|
||||
*
|
||||
* {@link https://swagger.io/docs/specification/serialization/#query View examples}
|
||||
*/
|
||||
querySerializer?: QuerySerializer | QuerySerializerOptions;
|
||||
/**
|
||||
* 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
|
||||
* the server.
|
||||
*/
|
||||
requestValidator?: (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]
|
||||
? true
|
||||
: [T] extends [never | undefined]
|
||||
? [undefined] extends [T]
|
||||
? false
|
||||
: true
|
||||
: false;
|
||||
|
||||
export type OmitNever<T extends Record<string, unknown>> = {
|
||||
[K in keyof T as IsExactlyNeverOrNeverUndefined<T[K]> extends true
|
||||
? never
|
||||
: K]: T[K];
|
||||
};
|
||||
143
app/client/api-client/core/utils.gen.ts
Normal file
143
app/client/api-client/core/utils.gen.ts
Normal file
@@ -0,0 +1,143 @@
|
||||
// This file is auto-generated by @hey-api/openapi-ts
|
||||
|
||||
import type { BodySerializer, QuerySerializer } from './bodySerializer.gen';
|
||||
import {
|
||||
type ArraySeparatorStyle,
|
||||
serializeArrayParam,
|
||||
serializeObjectParam,
|
||||
serializePrimitiveParam,
|
||||
} from './pathSerializer.gen';
|
||||
|
||||
export interface PathSerializer {
|
||||
path: Record<string, unknown>;
|
||||
url: string;
|
||||
}
|
||||
|
||||
export const PATH_PARAM_RE = /\{[^{}]+\}/g;
|
||||
|
||||
export const defaultPathSerializer = ({ path, url: _url }: PathSerializer) => {
|
||||
let url = _url;
|
||||
const matches = _url.match(PATH_PARAM_RE);
|
||||
if (matches) {
|
||||
for (const match of matches) {
|
||||
let explode = false;
|
||||
let name = match.substring(1, match.length - 1);
|
||||
let style: ArraySeparatorStyle = 'simple';
|
||||
|
||||
if (name.endsWith('*')) {
|
||||
explode = true;
|
||||
name = name.substring(0, name.length - 1);
|
||||
}
|
||||
|
||||
if (name.startsWith('.')) {
|
||||
name = name.substring(1);
|
||||
style = 'label';
|
||||
} else if (name.startsWith(';')) {
|
||||
name = name.substring(1);
|
||||
style = 'matrix';
|
||||
}
|
||||
|
||||
const value = path[name];
|
||||
|
||||
if (value === undefined || value === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
url = url.replace(
|
||||
match,
|
||||
serializeArrayParam({ explode, name, style, value }),
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (typeof value === 'object') {
|
||||
url = url.replace(
|
||||
match,
|
||||
serializeObjectParam({
|
||||
explode,
|
||||
name,
|
||||
style,
|
||||
value: value as Record<string, unknown>,
|
||||
valueOnly: true,
|
||||
}),
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (style === 'matrix') {
|
||||
url = url.replace(
|
||||
match,
|
||||
`;${serializePrimitiveParam({
|
||||
name,
|
||||
value: value as string,
|
||||
})}`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
const replaceValue = encodeURIComponent(
|
||||
style === 'label' ? `.${value as string}` : (value as string),
|
||||
);
|
||||
url = url.replace(match, replaceValue);
|
||||
}
|
||||
}
|
||||
return url;
|
||||
};
|
||||
|
||||
export const getUrl = ({
|
||||
baseUrl,
|
||||
path,
|
||||
query,
|
||||
querySerializer,
|
||||
url: _url,
|
||||
}: {
|
||||
baseUrl?: string;
|
||||
path?: Record<string, unknown>;
|
||||
query?: Record<string, unknown>;
|
||||
querySerializer: QuerySerializer;
|
||||
url: string;
|
||||
}) => {
|
||||
const pathUrl = _url.startsWith('/') ? _url : `/${_url}`;
|
||||
let url = (baseUrl ?? '') + pathUrl;
|
||||
if (path) {
|
||||
url = defaultPathSerializer({ path, url });
|
||||
}
|
||||
let search = query ? querySerializer(query) : '';
|
||||
if (search.startsWith('?')) {
|
||||
search = search.substring(1);
|
||||
}
|
||||
if (search) {
|
||||
url += `?${search}`;
|
||||
}
|
||||
return url;
|
||||
};
|
||||
|
||||
export function getValidRequestBody(options: {
|
||||
body?: unknown;
|
||||
bodySerializer?: BodySerializer | null;
|
||||
serializedBody?: unknown;
|
||||
}) {
|
||||
const hasBody = options.body !== undefined;
|
||||
const isSerializedBody = hasBody && options.bodySerializer;
|
||||
|
||||
if (isSerializedBody) {
|
||||
if ('serializedBody' in options) {
|
||||
const hasSerializedBody =
|
||||
options.serializedBody !== undefined && options.serializedBody !== '';
|
||||
|
||||
return hasSerializedBody ? options.serializedBody : null;
|
||||
}
|
||||
|
||||
// not all clients implement a serializedBody property (i.e. client-axios)
|
||||
return options.body !== '' ? options.body : null;
|
||||
}
|
||||
|
||||
// plain/text body
|
||||
if (hasBody) {
|
||||
return options.body;
|
||||
}
|
||||
|
||||
// no body was provided
|
||||
return undefined;
|
||||
}
|
||||
4
app/client/api-client/index.ts
Normal file
4
app/client/api-client/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
// This file is auto-generated by @hey-api/openapi-ts
|
||||
|
||||
export type * from './types.gen';
|
||||
export * from './sdk.gen';
|
||||
453
app/client/api-client/sdk.gen.ts
Normal file
453
app/client/api-client/sdk.gen.ts
Normal file
@@ -0,0 +1,453 @@
|
||||
// This file is auto-generated by @hey-api/openapi-ts
|
||||
|
||||
import type { Client, Options as Options2, TDataShape } from './client';
|
||||
import { client } from './client.gen';
|
||||
import type { BrowseFilesystemData, BrowseFilesystemResponses, ChangePasswordData, ChangePasswordResponses, CreateBackupScheduleData, CreateBackupScheduleResponses, CreateRepositoryData, CreateRepositoryResponses, CreateVolumeData, CreateVolumeResponses, DeleteBackupScheduleData, DeleteBackupScheduleResponses, DeleteRepositoryData, DeleteRepositoryResponses, DeleteVolumeData, DeleteVolumeResponses, DoctorRepositoryData, DoctorRepositoryResponses, DownloadResticPasswordData, DownloadResticPasswordResponses, GetBackupScheduleData, GetBackupScheduleForVolumeData, GetBackupScheduleForVolumeResponses, GetBackupScheduleResponses, GetContainersUsingVolumeData, GetContainersUsingVolumeErrors, GetContainersUsingVolumeResponses, GetMeData, GetMeResponses, GetRepositoryData, GetRepositoryResponses, GetSnapshotDetailsData, GetSnapshotDetailsResponses, GetStatusData, GetStatusResponses, GetSystemInfoData, GetSystemInfoResponses, GetVolumeData, GetVolumeErrors, GetVolumeResponses, HealthCheckVolumeData, HealthCheckVolumeErrors, HealthCheckVolumeResponses, ListBackupSchedulesData, ListBackupSchedulesResponses, ListFilesData, ListFilesResponses, ListRcloneRemotesData, ListRcloneRemotesResponses, ListRepositoriesData, ListRepositoriesResponses, ListSnapshotFilesData, ListSnapshotFilesResponses, ListSnapshotsData, ListSnapshotsResponses, ListVolumesData, ListVolumesResponses, LoginData, LoginResponses, LogoutData, LogoutResponses, MountVolumeData, MountVolumeResponses, RegisterData, RegisterResponses, RestoreSnapshotData, RestoreSnapshotResponses, RunBackupNowData, RunBackupNowResponses, RunForgetData, RunForgetErrors, RunForgetResponses, StopBackupData, StopBackupErrors, StopBackupResponses, TestConnectionData, TestConnectionResponses, UnmountVolumeData, UnmountVolumeResponses, UpdateBackupScheduleData, UpdateBackupScheduleResponses, UpdateVolumeData, UpdateVolumeErrors, UpdateVolumeResponses } from './types.gen';
|
||||
|
||||
export type Options<TData extends TDataShape = TDataShape, ThrowOnError extends boolean = boolean> = Options2<TData, ThrowOnError> & {
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
client?: Client;
|
||||
/**
|
||||
* You can pass arbitrary values through the `meta` object. This can be
|
||||
* used to access values that aren't defined as part of the SDK function.
|
||||
*/
|
||||
meta?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
/**
|
||||
* Register a new user
|
||||
*/
|
||||
export const register = <ThrowOnError extends boolean = false>(options?: Options<RegisterData, ThrowOnError>) => {
|
||||
return (options?.client ?? client).post<RegisterResponses, unknown, ThrowOnError>({
|
||||
url: '/api/v1/auth/register',
|
||||
...options,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...options?.headers
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Login with username and password
|
||||
*/
|
||||
export const login = <ThrowOnError extends boolean = false>(options?: Options<LoginData, ThrowOnError>) => {
|
||||
return (options?.client ?? client).post<LoginResponses, unknown, ThrowOnError>({
|
||||
url: '/api/v1/auth/login',
|
||||
...options,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...options?.headers
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Logout current user
|
||||
*/
|
||||
export const logout = <ThrowOnError extends boolean = false>(options?: Options<LogoutData, ThrowOnError>) => {
|
||||
return (options?.client ?? client).post<LogoutResponses, unknown, ThrowOnError>({
|
||||
url: '/api/v1/auth/logout',
|
||||
...options
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Get current authenticated user
|
||||
*/
|
||||
export const getMe = <ThrowOnError extends boolean = false>(options?: Options<GetMeData, ThrowOnError>) => {
|
||||
return (options?.client ?? client).get<GetMeResponses, unknown, ThrowOnError>({
|
||||
url: '/api/v1/auth/me',
|
||||
...options
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Get authentication system status
|
||||
*/
|
||||
export const getStatus = <ThrowOnError extends boolean = false>(options?: Options<GetStatusData, ThrowOnError>) => {
|
||||
return (options?.client ?? client).get<GetStatusResponses, unknown, ThrowOnError>({
|
||||
url: '/api/v1/auth/status',
|
||||
...options
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Change current user password
|
||||
*/
|
||||
export const changePassword = <ThrowOnError extends boolean = false>(options?: Options<ChangePasswordData, ThrowOnError>) => {
|
||||
return (options?.client ?? client).post<ChangePasswordResponses, unknown, ThrowOnError>({
|
||||
url: '/api/v1/auth/change-password',
|
||||
...options,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...options?.headers
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* List all volumes
|
||||
*/
|
||||
export const listVolumes = <ThrowOnError extends boolean = false>(options?: Options<ListVolumesData, ThrowOnError>) => {
|
||||
return (options?.client ?? client).get<ListVolumesResponses, unknown, ThrowOnError>({
|
||||
url: '/api/v1/volumes',
|
||||
...options
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Create a new volume
|
||||
*/
|
||||
export const createVolume = <ThrowOnError extends boolean = false>(options?: Options<CreateVolumeData, ThrowOnError>) => {
|
||||
return (options?.client ?? client).post<CreateVolumeResponses, unknown, ThrowOnError>({
|
||||
url: '/api/v1/volumes',
|
||||
...options,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...options?.headers
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Test connection to backend
|
||||
*/
|
||||
export const testConnection = <ThrowOnError extends boolean = false>(options?: Options<TestConnectionData, ThrowOnError>) => {
|
||||
return (options?.client ?? client).post<TestConnectionResponses, unknown, ThrowOnError>({
|
||||
url: '/api/v1/volumes/test-connection',
|
||||
...options,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...options?.headers
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Delete a volume
|
||||
*/
|
||||
export const deleteVolume = <ThrowOnError extends boolean = false>(options: Options<DeleteVolumeData, ThrowOnError>) => {
|
||||
return (options.client ?? client).delete<DeleteVolumeResponses, unknown, ThrowOnError>({
|
||||
url: '/api/v1/volumes/{name}',
|
||||
...options
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Get a volume by name
|
||||
*/
|
||||
export const getVolume = <ThrowOnError extends boolean = false>(options: Options<GetVolumeData, ThrowOnError>) => {
|
||||
return (options.client ?? client).get<GetVolumeResponses, GetVolumeErrors, ThrowOnError>({
|
||||
url: '/api/v1/volumes/{name}',
|
||||
...options
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Update a volume's configuration
|
||||
*/
|
||||
export const updateVolume = <ThrowOnError extends boolean = false>(options: Options<UpdateVolumeData, ThrowOnError>) => {
|
||||
return (options.client ?? client).put<UpdateVolumeResponses, UpdateVolumeErrors, ThrowOnError>({
|
||||
url: '/api/v1/volumes/{name}',
|
||||
...options,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...options.headers
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Get containers using a volume by name
|
||||
*/
|
||||
export const getContainersUsingVolume = <ThrowOnError extends boolean = false>(options: Options<GetContainersUsingVolumeData, ThrowOnError>) => {
|
||||
return (options.client ?? client).get<GetContainersUsingVolumeResponses, GetContainersUsingVolumeErrors, ThrowOnError>({
|
||||
url: '/api/v1/volumes/{name}/containers',
|
||||
...options
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Mount a volume
|
||||
*/
|
||||
export const mountVolume = <ThrowOnError extends boolean = false>(options: Options<MountVolumeData, ThrowOnError>) => {
|
||||
return (options.client ?? client).post<MountVolumeResponses, unknown, ThrowOnError>({
|
||||
url: '/api/v1/volumes/{name}/mount',
|
||||
...options
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Unmount a volume
|
||||
*/
|
||||
export const unmountVolume = <ThrowOnError extends boolean = false>(options: Options<UnmountVolumeData, ThrowOnError>) => {
|
||||
return (options.client ?? client).post<UnmountVolumeResponses, unknown, ThrowOnError>({
|
||||
url: '/api/v1/volumes/{name}/unmount',
|
||||
...options
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Perform a health check on a volume
|
||||
*/
|
||||
export const healthCheckVolume = <ThrowOnError extends boolean = false>(options: Options<HealthCheckVolumeData, ThrowOnError>) => {
|
||||
return (options.client ?? client).post<HealthCheckVolumeResponses, HealthCheckVolumeErrors, ThrowOnError>({
|
||||
url: '/api/v1/volumes/{name}/health-check',
|
||||
...options
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* List files in a volume directory
|
||||
*/
|
||||
export const listFiles = <ThrowOnError extends boolean = false>(options: Options<ListFilesData, ThrowOnError>) => {
|
||||
return (options.client ?? client).get<ListFilesResponses, unknown, ThrowOnError>({
|
||||
url: '/api/v1/volumes/{name}/files',
|
||||
...options
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Browse directories on the host filesystem
|
||||
*/
|
||||
export const browseFilesystem = <ThrowOnError extends boolean = false>(options?: Options<BrowseFilesystemData, ThrowOnError>) => {
|
||||
return (options?.client ?? client).get<BrowseFilesystemResponses, unknown, ThrowOnError>({
|
||||
url: '/api/v1/volumes/filesystem/browse',
|
||||
...options
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* List all repositories
|
||||
*/
|
||||
export const listRepositories = <ThrowOnError extends boolean = false>(options?: Options<ListRepositoriesData, ThrowOnError>) => {
|
||||
return (options?.client ?? client).get<ListRepositoriesResponses, unknown, ThrowOnError>({
|
||||
url: '/api/v1/repositories',
|
||||
...options
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Create a new restic repository
|
||||
*/
|
||||
export const createRepository = <ThrowOnError extends boolean = false>(options?: Options<CreateRepositoryData, ThrowOnError>) => {
|
||||
return (options?.client ?? client).post<CreateRepositoryResponses, unknown, ThrowOnError>({
|
||||
url: '/api/v1/repositories',
|
||||
...options,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...options?.headers
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* List all configured rclone remotes on the host system
|
||||
*/
|
||||
export const listRcloneRemotes = <ThrowOnError extends boolean = false>(options?: Options<ListRcloneRemotesData, ThrowOnError>) => {
|
||||
return (options?.client ?? client).get<ListRcloneRemotesResponses, unknown, ThrowOnError>({
|
||||
url: '/api/v1/repositories/rclone-remotes',
|
||||
...options
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Delete a repository
|
||||
*/
|
||||
export const deleteRepository = <ThrowOnError extends boolean = false>(options: Options<DeleteRepositoryData, ThrowOnError>) => {
|
||||
return (options.client ?? client).delete<DeleteRepositoryResponses, unknown, ThrowOnError>({
|
||||
url: '/api/v1/repositories/{name}',
|
||||
...options
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Get a single repository by name
|
||||
*/
|
||||
export const getRepository = <ThrowOnError extends boolean = false>(options: Options<GetRepositoryData, ThrowOnError>) => {
|
||||
return (options.client ?? client).get<GetRepositoryResponses, unknown, ThrowOnError>({
|
||||
url: '/api/v1/repositories/{name}',
|
||||
...options
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* List all snapshots in a repository
|
||||
*/
|
||||
export const listSnapshots = <ThrowOnError extends boolean = false>(options: Options<ListSnapshotsData, ThrowOnError>) => {
|
||||
return (options.client ?? client).get<ListSnapshotsResponses, unknown, ThrowOnError>({
|
||||
url: '/api/v1/repositories/{name}/snapshots',
|
||||
...options
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Get details of a specific snapshot
|
||||
*/
|
||||
export const getSnapshotDetails = <ThrowOnError extends boolean = false>(options: Options<GetSnapshotDetailsData, ThrowOnError>) => {
|
||||
return (options.client ?? client).get<GetSnapshotDetailsResponses, unknown, ThrowOnError>({
|
||||
url: '/api/v1/repositories/{name}/snapshots/{snapshotId}',
|
||||
...options
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* List files and directories in a snapshot
|
||||
*/
|
||||
export const listSnapshotFiles = <ThrowOnError extends boolean = false>(options: Options<ListSnapshotFilesData, ThrowOnError>) => {
|
||||
return (options.client ?? client).get<ListSnapshotFilesResponses, unknown, ThrowOnError>({
|
||||
url: '/api/v1/repositories/{name}/snapshots/{snapshotId}/files',
|
||||
...options
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Restore a snapshot to a target path on the filesystem
|
||||
*/
|
||||
export const restoreSnapshot = <ThrowOnError extends boolean = false>(options: Options<RestoreSnapshotData, ThrowOnError>) => {
|
||||
return (options.client ?? client).post<RestoreSnapshotResponses, unknown, ThrowOnError>({
|
||||
url: '/api/v1/repositories/{name}/restore',
|
||||
...options,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...options.headers
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Run doctor operations on a repository to fix common issues (unlock, check, repair index). Use this when the repository is locked or has errors.
|
||||
*/
|
||||
export const doctorRepository = <ThrowOnError extends boolean = false>(options: Options<DoctorRepositoryData, ThrowOnError>) => {
|
||||
return (options.client ?? client).post<DoctorRepositoryResponses, unknown, ThrowOnError>({
|
||||
url: '/api/v1/repositories/{name}/doctor',
|
||||
...options
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* List all backup schedules
|
||||
*/
|
||||
export const listBackupSchedules = <ThrowOnError extends boolean = false>(options?: Options<ListBackupSchedulesData, ThrowOnError>) => {
|
||||
return (options?.client ?? client).get<ListBackupSchedulesResponses, unknown, ThrowOnError>({
|
||||
url: '/api/v1/backups',
|
||||
...options
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Create a new backup schedule for a volume
|
||||
*/
|
||||
export const createBackupSchedule = <ThrowOnError extends boolean = false>(options?: Options<CreateBackupScheduleData, ThrowOnError>) => {
|
||||
return (options?.client ?? client).post<CreateBackupScheduleResponses, unknown, ThrowOnError>({
|
||||
url: '/api/v1/backups',
|
||||
...options,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...options?.headers
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Delete a backup schedule
|
||||
*/
|
||||
export const deleteBackupSchedule = <ThrowOnError extends boolean = false>(options: Options<DeleteBackupScheduleData, ThrowOnError>) => {
|
||||
return (options.client ?? client).delete<DeleteBackupScheduleResponses, unknown, ThrowOnError>({
|
||||
url: '/api/v1/backups/{scheduleId}',
|
||||
...options
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Get a backup schedule by ID
|
||||
*/
|
||||
export const getBackupSchedule = <ThrowOnError extends boolean = false>(options: Options<GetBackupScheduleData, ThrowOnError>) => {
|
||||
return (options.client ?? client).get<GetBackupScheduleResponses, unknown, ThrowOnError>({
|
||||
url: '/api/v1/backups/{scheduleId}',
|
||||
...options
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Update a backup schedule
|
||||
*/
|
||||
export const updateBackupSchedule = <ThrowOnError extends boolean = false>(options: Options<UpdateBackupScheduleData, ThrowOnError>) => {
|
||||
return (options.client ?? client).patch<UpdateBackupScheduleResponses, unknown, ThrowOnError>({
|
||||
url: '/api/v1/backups/{scheduleId}',
|
||||
...options,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...options.headers
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Get a backup schedule for a specific volume
|
||||
*/
|
||||
export const getBackupScheduleForVolume = <ThrowOnError extends boolean = false>(options: Options<GetBackupScheduleForVolumeData, ThrowOnError>) => {
|
||||
return (options.client ?? client).get<GetBackupScheduleForVolumeResponses, unknown, ThrowOnError>({
|
||||
url: '/api/v1/backups/volume/{volumeId}',
|
||||
...options
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Trigger a backup immediately for a schedule
|
||||
*/
|
||||
export const runBackupNow = <ThrowOnError extends boolean = false>(options: Options<RunBackupNowData, ThrowOnError>) => {
|
||||
return (options.client ?? client).post<RunBackupNowResponses, unknown, ThrowOnError>({
|
||||
url: '/api/v1/backups/{scheduleId}/run',
|
||||
...options
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Stop a backup that is currently in progress
|
||||
*/
|
||||
export const stopBackup = <ThrowOnError extends boolean = false>(options: Options<StopBackupData, ThrowOnError>) => {
|
||||
return (options.client ?? client).post<StopBackupResponses, StopBackupErrors, ThrowOnError>({
|
||||
url: '/api/v1/backups/{scheduleId}/stop',
|
||||
...options
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* 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, RunForgetErrors, ThrowOnError>({
|
||||
url: '/api/v1/backups/{scheduleId}/forget',
|
||||
...options
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Get system information including available capabilities
|
||||
*/
|
||||
export const getSystemInfo = <ThrowOnError extends boolean = false>(options?: Options<GetSystemInfoData, ThrowOnError>) => {
|
||||
return (options?.client ?? client).get<GetSystemInfoResponses, unknown, ThrowOnError>({
|
||||
url: '/api/v1/system/info',
|
||||
...options
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Download the Restic password file for backup recovery. Requires password re-authentication.
|
||||
*/
|
||||
export const downloadResticPassword = <ThrowOnError extends boolean = false>(options?: Options<DownloadResticPasswordData, ThrowOnError>) => {
|
||||
return (options?.client ?? client).post<DownloadResticPasswordResponses, unknown, ThrowOnError>({
|
||||
url: '/api/v1/system/restic-password',
|
||||
...options,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...options?.headers
|
||||
}
|
||||
});
|
||||
};
|
||||
1669
app/client/api-client/types.gen.ts
Normal file
1669
app/client/api-client/types.gen.ts
Normal file
File diff suppressed because it is too large
Load Diff
64
app/client/components/app-breadcrumb.tsx
Normal file
64
app/client/components/app-breadcrumb.tsx
Normal file
@@ -0,0 +1,64 @@
|
||||
import { Link, useMatches, type UIMatch } from "react-router";
|
||||
import {
|
||||
Breadcrumb,
|
||||
BreadcrumbItem,
|
||||
BreadcrumbLink,
|
||||
BreadcrumbList,
|
||||
BreadcrumbPage,
|
||||
BreadcrumbSeparator,
|
||||
} from "~/client/components/ui/breadcrumb";
|
||||
|
||||
export interface BreadcrumbItemData {
|
||||
label: string;
|
||||
href?: string;
|
||||
}
|
||||
|
||||
interface RouteHandle {
|
||||
breadcrumb?: (match: UIMatch) => BreadcrumbItemData[] | null;
|
||||
}
|
||||
|
||||
export function AppBreadcrumb() {
|
||||
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 (
|
||||
<Breadcrumb>
|
||||
<BreadcrumbList>
|
||||
{breadcrumbs.map((breadcrumb, index) => {
|
||||
const isLast = index === breadcrumbs.length - 1;
|
||||
|
||||
return (
|
||||
<div key={`${breadcrumb.label}-${index}`} className="contents">
|
||||
<BreadcrumbItem>
|
||||
{isLast || !breadcrumb.href ? (
|
||||
<BreadcrumbPage>{breadcrumb.label}</BreadcrumbPage>
|
||||
) : (
|
||||
<BreadcrumbLink asChild>
|
||||
<Link to={breadcrumb.href}>{breadcrumb.label}</Link>
|
||||
</BreadcrumbLink>
|
||||
)}
|
||||
</BreadcrumbItem>
|
||||
{!isLast && <BreadcrumbSeparator />}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</BreadcrumbList>
|
||||
</Breadcrumb>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { CalendarClock, Database, HardDrive, Mountain } from "lucide-react";
|
||||
import { CalendarClock, Database, HardDrive, Mountain, Settings } from "lucide-react";
|
||||
import { Link, NavLink } from "react-router";
|
||||
import {
|
||||
Sidebar,
|
||||
@@ -10,9 +10,9 @@ import {
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
useSidebar,
|
||||
} from "~/components/ui/sidebar";
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "~/components/ui/tooltip";
|
||||
import { cn } from "~/lib/utils";
|
||||
} from "~/client/components/ui/sidebar";
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "~/client/components/ui/tooltip";
|
||||
import { cn } from "~/client/lib/utils";
|
||||
|
||||
const items = [
|
||||
{
|
||||
@@ -30,6 +30,11 @@ const items = [
|
||||
url: "/backups",
|
||||
icon: CalendarClock,
|
||||
},
|
||||
{
|
||||
title: "Settings",
|
||||
url: "/settings",
|
||||
icon: Settings,
|
||||
},
|
||||
];
|
||||
|
||||
export function AppSidebar() {
|
||||
@@ -9,7 +9,7 @@ type AuthLayoutProps = {
|
||||
|
||||
export function AuthLayout({ title, description, children }: AuthLayoutProps) {
|
||||
return (
|
||||
<div className="flex min-h-screen">
|
||||
<div className="flex mt-[25%] lg:mt-0 lg:min-h-screen">
|
||||
<div className="flex flex-1 items-center justify-center bg-background p-8">
|
||||
<div className="w-full max-w-md space-y-8">
|
||||
<div className="flex items-center gap-3">
|
||||
@@ -26,8 +26,8 @@ export function AuthLayout({ title, description, children }: AuthLayoutProps) {
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className="hidden lg:block lg:flex-1 dither-xl bg-cover bg-center"
|
||||
style={{ backgroundImage: "url(/background.jpg)" }}
|
||||
className="hidden lg:block lg:flex-1 dither-lg bg-cover bg-center"
|
||||
style={{ backgroundImage: "url(/images/background.jpg)" }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
556
app/client/components/create-repository-form.tsx
Normal file
556
app/client/components/create-repository-form.tsx
Normal file
@@ -0,0 +1,556 @@
|
||||
import { arktypeResolver } from "@hookform/resolvers/arktype";
|
||||
import { type } from "arktype";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { cn, slugify } from "~/client/lib/utils";
|
||||
import { deepClean } from "~/utils/object";
|
||||
import { Button } from "./ui/button";
|
||||
import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from "./ui/form";
|
||||
import { Input } from "./ui/input";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Alert, AlertDescription } from "./ui/alert";
|
||||
import { ExternalLink } from "lucide-react";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip";
|
||||
import { useSystemInfo } from "~/client/hooks/use-system-info";
|
||||
import { COMPRESSION_MODES, repositoryConfigSchema } from "~/schemas/restic";
|
||||
import { listRcloneRemotesOptions } from "../api-client/@tanstack/react-query.gen";
|
||||
import { Checkbox } from "./ui/checkbox";
|
||||
|
||||
export const formSchema = type({
|
||||
name: "2<=string<=32",
|
||||
compressionMode: type.valueOf(COMPRESSION_MODES).optional(),
|
||||
}).and(repositoryConfigSchema);
|
||||
const cleanSchema = type.pipe((d) => formSchema(deepClean(d)));
|
||||
|
||||
export type RepositoryFormValues = typeof formSchema.inferIn;
|
||||
|
||||
type Props = {
|
||||
onSubmit: (values: RepositoryFormValues) => void;
|
||||
mode?: "create" | "update";
|
||||
initialValues?: Partial<RepositoryFormValues>;
|
||||
formId?: string;
|
||||
loading?: boolean;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
const defaultValuesForType = {
|
||||
local: { backend: "local" as const, compressionMode: "auto" as const },
|
||||
s3: { backend: "s3" as const, compressionMode: "auto" as const },
|
||||
r2: { backend: "r2" as const, compressionMode: "auto" as const },
|
||||
gcs: { backend: "gcs" as const, compressionMode: "auto" as const },
|
||||
azure: { backend: "azure" as const, compressionMode: "auto" as const },
|
||||
rclone: { backend: "rclone" as const, compressionMode: "auto" as const },
|
||||
};
|
||||
|
||||
export const CreateRepositoryForm = ({
|
||||
onSubmit,
|
||||
mode = "create",
|
||||
initialValues,
|
||||
formId,
|
||||
loading,
|
||||
className,
|
||||
}: Props) => {
|
||||
const form = useForm<RepositoryFormValues>({
|
||||
resolver: arktypeResolver(cleanSchema as unknown as typeof formSchema),
|
||||
defaultValues: initialValues,
|
||||
resetOptions: {
|
||||
keepDefaultValues: true,
|
||||
keepDirtyValues: false,
|
||||
},
|
||||
});
|
||||
|
||||
const { watch, setValue } = form;
|
||||
|
||||
const watchedBackend = watch("backend");
|
||||
const watchedIsExistingRepository = watch("isExistingRepository");
|
||||
|
||||
const [passwordMode, setPasswordMode] = useState<"default" | "custom">("default");
|
||||
|
||||
const { data: rcloneRemotes, isLoading: isLoadingRemotes } = useQuery({
|
||||
...listRcloneRemotesOptions(),
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
form.reset({
|
||||
name: form.getValues().name,
|
||||
isExistingRepository: form.getValues().isExistingRepository,
|
||||
customPassword: form.getValues().customPassword,
|
||||
...defaultValuesForType[watchedBackend as keyof typeof defaultValuesForType],
|
||||
});
|
||||
}, [watchedBackend, form]);
|
||||
|
||||
const { capabilities } = useSystemInfo();
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form id={formId} onSubmit={form.handleSubmit(onSubmit)} className={cn("space-y-4", className)}>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Name</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
placeholder="Repository name"
|
||||
onChange={(e) => field.onChange(slugify(e.target.value))}
|
||||
max={32}
|
||||
min={2}
|
||||
disabled={mode === "update"}
|
||||
className={mode === "update" ? "bg-gray-50" : ""}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>Unique identifier for the repository.</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="backend"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Backend</FormLabel>
|
||||
<Select onValueChange={field.onChange} defaultValue={field.value} value={field.value}>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select a backend" />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
<SelectItem value="local">Local</SelectItem>
|
||||
<SelectItem value="s3">S3</SelectItem>
|
||||
<SelectItem value="r2">Cloudflare R2</SelectItem>
|
||||
<SelectItem value="gcs">Google Cloud Storage</SelectItem>
|
||||
<SelectItem value="azure">Azure Blob Storage</SelectItem>
|
||||
<Tooltip>
|
||||
<TooltipTrigger>
|
||||
<SelectItem disabled={!capabilities.rclone} value="rclone">
|
||||
rclone (40+ cloud providers)
|
||||
</SelectItem>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className={cn({ hidden: capabilities.rclone })}>
|
||||
<p>Setup rclone to use this backend</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormDescription>Choose the storage backend for this repository.</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="compressionMode"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Compression Mode</FormLabel>
|
||||
<Select onValueChange={field.onChange} defaultValue={field.value} value={field.value}>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select compression mode" />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
<SelectItem value="off">Off</SelectItem>
|
||||
<SelectItem value="auto">Auto</SelectItem>
|
||||
<SelectItem value="fastest">Fastest</SelectItem>
|
||||
<SelectItem value="better">Better</SelectItem>
|
||||
<SelectItem value="max">Max</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormDescription>Compression mode for backups stored in this repository.</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<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 Ironmount's password</SelectItem>
|
||||
<SelectItem value="custom">Enter password manually</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormDescription>
|
||||
Choose whether to use Ironmount'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 === "s3" && (
|
||||
<>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="endpoint"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Endpoint</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="s3.amazonaws.com" {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>S3-compatible endpoint URL.</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="bucket"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Bucket</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="my-backup-bucket" {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>S3 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="AKIAIOSFODNN7EXAMPLE" {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>S3 access key ID for authentication.</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>S3 secret access key for authentication.</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{watchedBackend === "r2" && (
|
||||
<>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="endpoint"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Endpoint</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="<account-id>.r2.cloudflarestorage.com" {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
R2 endpoint (without https://). Find in R2 dashboard under bucket settings.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="bucket"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Bucket</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="my-backup-bucket" {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>R2 bucket name for storing backups.</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="accessKeyId"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Access Key ID</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="Access Key ID from R2 API tokens" {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>R2 API token Access Key ID (create in Cloudflare R2 dashboard).</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="secretAccessKey"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Secret Access Key</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="password" placeholder="••••••••" {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>R2 API token Secret Access Key (shown once when creating token).</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{watchedBackend === "gcs" && (
|
||||
<>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="bucket"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Bucket</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="my-backup-bucket" {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>GCS bucket name for storing backups.</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="projectId"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Project ID</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="my-gcp-project-123" {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>Google Cloud project ID.</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="credentialsJson"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Service Account JSON</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="password" placeholder="Paste service account JSON key..." {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>Service account JSON credentials for authentication.</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{watchedBackend === "azure" && (
|
||||
<>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="container"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Container</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="my-backup-container" {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>Azure Blob Storage container name for storing backups.</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="accountName"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Account Name</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="mystorageaccount" {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>Azure Storage account name.</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="accountKey"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Account Key</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="password" placeholder="••••••••" {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>Azure Storage account key for authentication.</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="endpointSuffix"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Endpoint Suffix (Optional)</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="core.windows.net" {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>Custom Azure endpoint suffix (defaults to core.windows.net).</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{watchedBackend === "rclone" &&
|
||||
(!rcloneRemotes || rcloneRemotes.length === 0 ? (
|
||||
<Alert>
|
||||
<AlertDescription className="space-y-2">
|
||||
<p className="font-medium">No rclone remotes configured</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
To use rclone, you need to configure remotes on your host system
|
||||
</p>
|
||||
<a
|
||||
href="https://rclone.org/docs/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-sm text-strong-accent inline-flex items-center gap-1"
|
||||
>
|
||||
View rclone documentation
|
||||
<ExternalLink className="w-3 h-3" />
|
||||
</a>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
) : (
|
||||
<>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="remote"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Remote</FormLabel>
|
||||
<Select onValueChange={(v) => field.onChange(v)} defaultValue={field.value} value={field.value}>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select an rclone remote" />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
{isLoadingRemotes ? (
|
||||
<SelectItem value="loading" disabled>
|
||||
Loading remotes...
|
||||
</SelectItem>
|
||||
) : (
|
||||
rcloneRemotes.map((remote: { name: string; type: string }) => (
|
||||
<SelectItem key={remote.name} value={remote.name}>
|
||||
{remote.name} ({remote.type})
|
||||
</SelectItem>
|
||||
))
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormDescription>Select the rclone remote configured on your host system.</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="path"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Path</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="backups/ironmount" {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>Path within the remote where backups will be stored.</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
))}
|
||||
|
||||
{mode === "update" && (
|
||||
<Button type="submit" className="w-full" loading={loading}>
|
||||
Save Changes
|
||||
</Button>
|
||||
)}
|
||||
</form>
|
||||
</Form>
|
||||
);
|
||||
};
|
||||
@@ -1,17 +1,18 @@
|
||||
import { arktypeResolver } from "@hookform/resolvers/arktype";
|
||||
import { volumeConfigSchema } from "@ironmount/schemas";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { type } from "arktype";
|
||||
import { CheckCircle, Loader2, XCircle } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { testConnectionMutation } from "~/api-client/@tanstack/react-query.gen";
|
||||
import { cn, slugify } from "~/lib/utils";
|
||||
import { cn, slugify } from "~/client/lib/utils";
|
||||
import { deepClean } from "~/utils/object";
|
||||
import { DirectoryBrowser } from "./directory-browser";
|
||||
import { Button } from "./ui/button";
|
||||
import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from "./ui/form";
|
||||
import { Input } from "./ui/input";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select";
|
||||
import { volumeConfigSchema } from "~/schemas/volumes";
|
||||
import { testConnectionMutation } from "../api-client/@tanstack/react-query.gen";
|
||||
|
||||
export const formSchema = type({
|
||||
name: "2<=string<=32",
|
||||
@@ -30,7 +31,7 @@ type Props = {
|
||||
};
|
||||
|
||||
const defaultValuesForType = {
|
||||
directory: { backend: "directory" as const },
|
||||
directory: { backend: "directory" as const, path: "/" },
|
||||
nfs: { backend: "nfs" as const, port: 2049, version: "4.1" as const },
|
||||
smb: { backend: "smb" as const, port: 445, vers: "3.0" as const },
|
||||
webdav: { backend: "webdav" as const, port: 80, ssl: false },
|
||||
@@ -49,11 +50,15 @@ export const CreateVolumeForm = ({ onSubmit, mode = "create", initialValues, for
|
||||
const { watch, getValues } = form;
|
||||
|
||||
const watchedBackend = watch("backend");
|
||||
const watchedName = watch("name");
|
||||
|
||||
useEffect(() => {
|
||||
form.reset({ name: watchedName, ...defaultValuesForType[watchedBackend as keyof typeof defaultValuesForType] });
|
||||
}, [watchedBackend, watchedName, form.reset]);
|
||||
if (mode === "create") {
|
||||
form.reset({
|
||||
name: form.getValues().name,
|
||||
...defaultValuesForType[watchedBackend as keyof typeof defaultValuesForType],
|
||||
});
|
||||
}
|
||||
}, [watchedBackend, form, mode]);
|
||||
|
||||
const [testMessage, setTestMessage] = useState<{ success: boolean; message: string } | null>(null);
|
||||
|
||||
@@ -133,6 +138,37 @@ export const CreateVolumeForm = ({ onSubmit, mode = "create", initialValues, for
|
||||
)}
|
||||
/>
|
||||
|
||||
{watchedBackend === "directory" && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="path"
|
||||
render={({ field }) => {
|
||||
return (
|
||||
<FormItem>
|
||||
<FormLabel>Directory Path</FormLabel>
|
||||
<FormControl>
|
||||
{field.value ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex-1 border rounded-md p-3 bg-muted/50">
|
||||
<div className="text-xs font-medium text-muted-foreground mb-1">Selected path:</div>
|
||||
<div className="text-sm font-mono break-all">{field.value}</div>
|
||||
</div>
|
||||
<Button type="button" variant="outline" size="sm" onClick={() => field.onChange("")}>
|
||||
Change
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<DirectoryBrowser onSelectPath={(path) => field.onChange(path)} selectedPath={field.value} />
|
||||
)}
|
||||
</FormControl>
|
||||
<FormDescription>Browse and select a directory on the host filesystem to track.</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{watchedBackend === "nfs" && (
|
||||
<>
|
||||
<FormField
|
||||
@@ -202,6 +238,31 @@ export const CreateVolumeForm = ({ onSubmit, mode = "create", initialValues, for
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="readOnly"
|
||||
defaultValue={false}
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Read-only Mode</FormLabel>
|
||||
<FormControl>
|
||||
<div className="flex items-center space-x-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={field.value ?? false}
|
||||
onChange={(e) => field.onChange(e.target.checked)}
|
||||
className="rounded border-gray-300"
|
||||
/>
|
||||
<span className="text-sm">Mount volume as read-only</span>
|
||||
</div>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Prevent any modifications to the volume. Recommended for backup sources and sensitive data.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -301,6 +362,31 @@ export const CreateVolumeForm = ({ onSubmit, mode = "create", initialValues, for
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="readOnly"
|
||||
defaultValue={false}
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Read-only Mode</FormLabel>
|
||||
<FormControl>
|
||||
<div className="flex items-center space-x-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={field.value ?? false}
|
||||
onChange={(e) => field.onChange(e.target.checked)}
|
||||
className="rounded border-gray-300"
|
||||
/>
|
||||
<span className="text-sm">Mount volume as read-only</span>
|
||||
</div>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Prevent any modifications to the volume. Recommended for backup sources and sensitive data.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -422,45 +508,72 @@ export const CreateVolumeForm = ({ onSubmit, mode = "create", initialValues, for
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="readOnly"
|
||||
defaultValue={false}
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Read-only Mode</FormLabel>
|
||||
<FormControl>
|
||||
<div className="flex items-center space-x-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={field.value ?? false}
|
||||
onChange={(e) => field.onChange(e.target.checked)}
|
||||
className="rounded border-gray-300"
|
||||
/>
|
||||
<span className="text-sm">Mount volume as read-only</span>
|
||||
</div>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Prevent any modifications to the volume. Recommended for backup sources and sensitive data.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={handleTestConnection}
|
||||
disabled={testBackendConnection.isPending}
|
||||
className="flex-1"
|
||||
>
|
||||
{testBackendConnection.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
{!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
|
||||
? "Testing..."
|
||||
: testMessage
|
||||
? testMessage.success
|
||||
? "Connection Successful"
|
||||
: "Test Failed"
|
||||
: "Test Connection"}
|
||||
</Button>
|
||||
</div>
|
||||
{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}
|
||||
{watchedBackend !== "directory" && (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={handleTestConnection}
|
||||
disabled={testBackendConnection.isPending}
|
||||
className="flex-1"
|
||||
>
|
||||
{testBackendConnection.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
{!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
|
||||
? "Testing..."
|
||||
: testMessage
|
||||
? testMessage.success
|
||||
? "Connection Successful"
|
||||
: "Test Failed"
|
||||
: "Test Connection"}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{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>
|
||||
)}
|
||||
{mode === "update" && (
|
||||
<Button type="submit" className="w-full" loading={loading}>
|
||||
Save Changes
|
||||
74
app/client/components/directory-browser.tsx
Normal file
74
app/client/components/directory-browser.tsx
Normal file
@@ -0,0 +1,74 @@
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { FileTree } from "./file-tree";
|
||||
import { ScrollArea } from "./ui/scroll-area";
|
||||
import { browseFilesystemOptions } from "../api-client/@tanstack/react-query.gen";
|
||||
import { useFileBrowser } from "../hooks/use-file-browser";
|
||||
|
||||
type Props = {
|
||||
onSelectPath: (path: string) => void;
|
||||
selectedPath?: string;
|
||||
};
|
||||
|
||||
export const DirectoryBrowser = ({ onSelectPath, selectedPath }: Props) => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
...browseFilesystemOptions({ query: { path: "/" } }),
|
||||
});
|
||||
|
||||
const fileBrowser = useFileBrowser({
|
||||
initialData: data,
|
||||
isLoading,
|
||||
fetchFolder: async (path) => {
|
||||
return await queryClient.ensureQueryData(browseFilesystemOptions({ query: { path } }));
|
||||
},
|
||||
prefetchFolder: (path) => {
|
||||
queryClient.prefetchQuery(browseFilesystemOptions({ query: { path } }));
|
||||
},
|
||||
});
|
||||
|
||||
if (fileBrowser.isLoading) {
|
||||
return (
|
||||
<div className="border rounded-lg overflow-hidden">
|
||||
<ScrollArea className="h-64">
|
||||
<div className="text-sm text-gray-500 p-4">Loading directories...</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (fileBrowser.isEmpty) {
|
||||
return (
|
||||
<div className="border rounded-lg overflow-hidden">
|
||||
<ScrollArea className="h-64">
|
||||
<div className="text-sm text-gray-500 p-4">No subdirectories found</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="border rounded-lg overflow-hidden">
|
||||
<ScrollArea className="h-64">
|
||||
<FileTree
|
||||
files={fileBrowser.fileArray}
|
||||
onFolderExpand={fileBrowser.handleFolderExpand}
|
||||
onFolderHover={fileBrowser.handleFolderHover}
|
||||
expandedFolders={fileBrowser.expandedFolders}
|
||||
loadingFolders={fileBrowser.loadingFolders}
|
||||
foldersOnly
|
||||
selectableFolders
|
||||
selectedFolder={selectedPath}
|
||||
onFolderSelect={onSelectPath}
|
||||
/>
|
||||
</ScrollArea>
|
||||
|
||||
{selectedPath && (
|
||||
<div className="bg-muted/50 border-t p-2 text-sm">
|
||||
<div className="font-medium text-muted-foreground">Selected path:</div>
|
||||
<div className="font-mono text-xs break-all">{selectedPath}</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -17,7 +17,7 @@ export function EmptyState(props: EmptyStateProps) {
|
||||
<div className="absolute inset-0 animate-pulse">
|
||||
<div className="w-32 h-32 rounded-full bg-primary/10 blur-2xl" />
|
||||
</div>
|
||||
<div className="relative flex items-center justify-center w-32 h-32 rounded-full bg-gradient-to-br from-primary/20 to-primary/5 border-2 border-primary/20">
|
||||
<div className="relative flex items-center justify-center w-32 h-32 rounded-full bg-linear-to-br from-primary/20 to-primary/5 border-2 border-primary/20">
|
||||
<Cicon className="w-16 h-16 text-primary/70" strokeWidth={1.5} />
|
||||
</div>
|
||||
</div>
|
||||
@@ -10,8 +10,8 @@
|
||||
|
||||
import { ChevronDown, ChevronRight, File as FileIcon, Folder as FolderIcon, FolderOpen, Loader2 } from "lucide-react";
|
||||
import { memo, type ReactNode, useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { cn } from "~/lib/utils";
|
||||
import { Checkbox } from "~/components/ui/checkbox";
|
||||
import { cn } from "~/client/lib/utils";
|
||||
import { Checkbox } from "~/client/components/ui/checkbox";
|
||||
|
||||
const NODE_PADDING_LEFT = 12;
|
||||
|
||||
@@ -36,6 +36,9 @@ interface Props {
|
||||
selectedPaths?: Set<string>;
|
||||
onSelectionChange?: (selectedPaths: Set<string>) => void;
|
||||
foldersOnly?: boolean;
|
||||
selectableFolders?: boolean;
|
||||
onFolderSelect?: (folderPath: string) => void;
|
||||
selectedFolder?: string;
|
||||
}
|
||||
|
||||
export const FileTree = memo((props: Props) => {
|
||||
@@ -52,6 +55,9 @@ export const FileTree = memo((props: Props) => {
|
||||
selectedPaths = new Set(),
|
||||
onSelectionChange,
|
||||
foldersOnly = false,
|
||||
selectableFolders = false,
|
||||
onFolderSelect,
|
||||
selectedFolder,
|
||||
} = props;
|
||||
|
||||
const fileList = useMemo(() => {
|
||||
@@ -126,6 +132,13 @@ export const FileTree = memo((props: Props) => {
|
||||
[onFileSelect],
|
||||
);
|
||||
|
||||
const handleFolderSelect = useCallback(
|
||||
(folderPath: string) => {
|
||||
onFolderSelect?.(folderPath);
|
||||
},
|
||||
[onFolderSelect],
|
||||
);
|
||||
|
||||
const handleSelectionChange = useCallback(
|
||||
(path: string, checked: boolean) => {
|
||||
const newSelection = new Set(selectedPaths);
|
||||
@@ -299,6 +312,9 @@ export const FileTree = memo((props: Props) => {
|
||||
checked={isPathSelected(fileOrFolder.fullPath) && !isPartiallySelected(fileOrFolder.fullPath)}
|
||||
partiallyChecked={isPartiallySelected(fileOrFolder.fullPath)}
|
||||
onCheckboxChange={handleSelectionChange}
|
||||
selectableMode={selectableFolders}
|
||||
onFolderSelect={handleFolderSelect}
|
||||
selected={selectedFolder === fileOrFolder.fullPath}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -321,6 +337,9 @@ interface FolderProps {
|
||||
checked?: boolean;
|
||||
partiallyChecked?: boolean;
|
||||
onCheckboxChange?: (path: string, checked: boolean) => void;
|
||||
selectableMode?: boolean;
|
||||
onFolderSelect?: (folderPath: string) => void;
|
||||
selected?: boolean;
|
||||
}
|
||||
|
||||
const Folder = memo(
|
||||
@@ -334,6 +353,9 @@ const Folder = memo(
|
||||
checked,
|
||||
partiallyChecked,
|
||||
onCheckboxChange,
|
||||
selectableMode,
|
||||
onFolderSelect,
|
||||
selected,
|
||||
}: FolderProps) => {
|
||||
const { depth, name, fullPath } = folder;
|
||||
const FolderIconComponent = collapsed ? FolderIcon : FolderOpen;
|
||||
@@ -359,9 +381,19 @@ const Folder = memo(
|
||||
[onCheckboxChange, fullPath],
|
||||
);
|
||||
|
||||
const handleFolderClick = useCallback(() => {
|
||||
if (selectableMode) {
|
||||
onFolderSelect?.(fullPath);
|
||||
}
|
||||
}, [selectableMode, onFolderSelect, fullPath]);
|
||||
|
||||
return (
|
||||
<NodeButton
|
||||
className={cn("group hover:bg-accent/50 text-foreground")}
|
||||
className={cn("group", {
|
||||
"hover:bg-accent/50 text-foreground": !selected,
|
||||
"bg-accent text-accent-foreground": selected,
|
||||
"cursor-pointer": selectableMode,
|
||||
})}
|
||||
depth={depth}
|
||||
icon={
|
||||
loading ? (
|
||||
@@ -372,6 +404,7 @@ const Folder = memo(
|
||||
<ChevronDown className="w-4 h-4 shrink-0 cursor-pointer" onClick={handleChevronClick} />
|
||||
)
|
||||
}
|
||||
onClick={selectableMode ? handleFolderClick : undefined}
|
||||
onMouseEnter={handleMouseEnter}
|
||||
>
|
||||
{withCheckbox && (
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { cn } from "~/lib/utils";
|
||||
import { cn } from "~/client/lib/utils";
|
||||
|
||||
interface GridBackgroundProps {
|
||||
children: ReactNode;
|
||||
@@ -12,9 +12,9 @@ export function GridBackground({ children, className, containerClassName }: Grid
|
||||
<div
|
||||
className={cn(
|
||||
"relative min-h-full w-full overflow-x-hidden",
|
||||
"[background-size:20px_20px] sm:[background-size:40px_40px]",
|
||||
"[background-image:linear-gradient(to_right,#e4e4e7_1px,transparent_1px),linear-gradient(to_bottom,#e4e4e7_1px,transparent_1px)]",
|
||||
"dark:[background-image:linear-gradient(to_right,#262626_1px,transparent_1px),linear-gradient(to_bottom,#262626_1px,transparent_1px)]",
|
||||
"bg-size-[20px_20px] sm:bg-size-[40px_40px]",
|
||||
"bg-[linear-gradient(to_right,#e4e4e7_1px,transparent_1px),linear-gradient(to_bottom,#e4e4e7_1px,transparent_1px)]",
|
||||
"dark:bg-[linear-gradient(to_right,#262626_1px,transparent_1px),linear-gradient(to_bottom,#262626_1px,transparent_1px)]",
|
||||
containerClassName,
|
||||
)}
|
||||
>
|
||||
@@ -1,8 +1,7 @@
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { LifeBuoy } from "lucide-react";
|
||||
import { Outlet, useNavigate } from "react-router";
|
||||
import { Outlet, redirect, useNavigate } from "react-router";
|
||||
import { toast } from "sonner";
|
||||
import { logoutMutation } from "~/api-client/@tanstack/react-query.gen";
|
||||
import { appContext } from "~/context";
|
||||
import { authMiddleware } from "~/middleware/auth";
|
||||
import type { Route } from "./+types/layout";
|
||||
@@ -11,11 +10,17 @@ import { GridBackground } from "./grid-background";
|
||||
import { Button } from "./ui/button";
|
||||
import { SidebarProvider, SidebarTrigger } from "./ui/sidebar";
|
||||
import { AppSidebar } from "./app-sidebar";
|
||||
import { logoutMutation } from "../api-client/@tanstack/react-query.gen";
|
||||
|
||||
export const clientMiddleware = [authMiddleware];
|
||||
|
||||
export async function clientLoader({ context }: Route.LoaderArgs) {
|
||||
const ctx = context.get(appContext);
|
||||
|
||||
if (ctx.user && !ctx.user.hasDownloadedResticPassword) {
|
||||
throw redirect("/download-recovery-key");
|
||||
}
|
||||
|
||||
return ctx;
|
||||
}
|
||||
|
||||
@@ -37,7 +42,7 @@ export default function Layout({ loaderData }: Route.ComponentProps) {
|
||||
<SidebarProvider defaultOpen={true}>
|
||||
<AppSidebar />
|
||||
<div className="w-full relative flex flex-col h-screen overflow-hidden">
|
||||
<header className="z-50 bg-card-header border-b border-border/50 flex-shrink-0">
|
||||
<header className="z-50 bg-card-header border-b border-border/50 shrink-0">
|
||||
<div className="flex items-center justify-between py-3 sm:py-4 px-2 sm:px-8 mx-auto container">
|
||||
<div className="flex items-center gap-4">
|
||||
<SidebarTrigger />
|
||||
@@ -71,7 +76,7 @@ export default function Layout({ loaderData }: Route.ComponentProps) {
|
||||
</header>
|
||||
<div className="main-content flex-1 overflow-y-auto">
|
||||
<GridBackground>
|
||||
<main className="flex flex-col p-2 pt-2 sm:p-8 sm:pt-6 mx-auto">
|
||||
<main className="flex flex-col p-2 pb-6 pt-2 sm:p-8 sm:pt-6 mx-auto">
|
||||
<Outlet />
|
||||
</main>
|
||||
</GridBackground>
|
||||
@@ -1,4 +1,4 @@
|
||||
import { cn } from "~/lib/utils";
|
||||
import { cn } from "~/client/lib/utils";
|
||||
import { Switch } from "./ui/switch";
|
||||
|
||||
type Props = {
|
||||
@@ -20,7 +20,12 @@ export const OnOff = ({ isOn, toggle, enabledLabel, disabledLabel, disabled }: P
|
||||
)}
|
||||
>
|
||||
<span>{isOn ? enabledLabel : disabledLabel}</span>
|
||||
<Switch disabled={disabled} checked={isOn} onCheckedChange={toggle} />
|
||||
<Switch
|
||||
disabled={disabled}
|
||||
checked={isOn}
|
||||
onCheckedChange={toggle}
|
||||
aria-label={isOn ? `Toggle ${enabledLabel}` : `Toggle ${disabledLabel}`}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { RepositoryBackend } from "@ironmount/schemas/restic";
|
||||
import { Database, HardDrive, Cloud } from "lucide-react";
|
||||
import type { RepositoryBackend } from "~/schemas/restic";
|
||||
|
||||
type Props = {
|
||||
backend: RepositoryBackend;
|
||||
@@ -12,6 +12,8 @@ export const RepositoryIcon = ({ backend, className = "h-4 w-4" }: Props) => {
|
||||
return <HardDrive className={className} />;
|
||||
case "s3":
|
||||
return <Cloud className={className} />;
|
||||
case "gcs":
|
||||
return <Cloud className={className} />;
|
||||
default:
|
||||
return <Database className={className} />;
|
||||
}
|
||||
@@ -1,10 +1,10 @@
|
||||
import { Calendar, Clock, Database, FolderTree, HardDrive } from "lucide-react";
|
||||
import { useNavigate } from "react-router";
|
||||
import type { ListSnapshotsResponse } from "~/api-client/types.gen";
|
||||
import { ByteSize } from "~/components/bytes-size";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "~/components/ui/table";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "~/components/ui/tooltip";
|
||||
import { formatSnapshotDuration } from "~/modules/repositories/tabs/snapshots";
|
||||
import { ByteSize } from "~/client/components/bytes-size";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "~/client/components/ui/table";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "~/client/components/ui/tooltip";
|
||||
import { formatDuration } from "~/utils/utils";
|
||||
import type { ListSnapshotsResponse } from "../api-client";
|
||||
|
||||
type Snapshot = ListSnapshotsResponse[number];
|
||||
|
||||
@@ -62,9 +62,7 @@ export const SnapshotsTable = ({ snapshots, repositoryName }: Props) => {
|
||||
<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">
|
||||
{formatSnapshotDuration(snapshot.duration / 1000)}
|
||||
</span>
|
||||
<span className="text-sm text-muted-foreground">{formatDuration(snapshot.duration / 1000)}</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="hidden lg:table-cell">
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { VolumeStatus } from "~/lib/types";
|
||||
import { cn } from "~/lib/utils";
|
||||
import type { VolumeStatus } from "~/client/lib/types";
|
||||
import { cn } from "~/client/lib/utils";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip";
|
||||
|
||||
export const StatusDot = ({ status }: { status: VolumeStatus }) => {
|
||||
@@ -1,7 +1,7 @@
|
||||
import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog";
|
||||
import type * as React from "react";
|
||||
import { buttonVariants } from "~/components/ui/button";
|
||||
import { cn } from "~/lib/utils";
|
||||
import { buttonVariants } from "~/client/components/ui/button";
|
||||
import { cn } from "~/client/lib/utils";
|
||||
|
||||
function AlertDialog({ ...props }: React.ComponentProps<typeof AlertDialogPrimitive.Root>) {
|
||||
return <AlertDialogPrimitive.Root data-slot="alert-dialog" {...props} />;
|
||||
44
app/client/components/ui/alert.tsx
Normal file
44
app/client/components/ui/alert.tsx
Normal file
@@ -0,0 +1,44 @@
|
||||
import * as React from "react";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
|
||||
import { cn } from "~/client/lib/utils";
|
||||
|
||||
const alertVariants = cva(
|
||||
"relative w-full rounded-lg border px-4 py-3 text-sm [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground [&>svg~*]:pl-7",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-background text-foreground",
|
||||
destructive: "border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive",
|
||||
warning: "border-orange-500/20 bg-orange-500/10 text-orange-500 [&>svg]:text-orange-500",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const Alert = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement> & VariantProps<typeof alertVariants>
|
||||
>(({ className, variant, ...props }, ref) => (
|
||||
<div ref={ref} role="alert" className={cn(alertVariants({ variant }), className)} {...props} />
|
||||
));
|
||||
Alert.displayName = "Alert";
|
||||
|
||||
const AlertTitle = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLHeadingElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<h5 ref={ref} className={cn("mb-1 font-medium leading-none tracking-tight", className)} {...props} />
|
||||
),
|
||||
);
|
||||
AlertTitle.displayName = "AlertTitle";
|
||||
|
||||
const AlertDescription = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLParagraphElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<div ref={ref} className={cn("text-sm [&_p]:leading-relaxed", className)} {...props} />
|
||||
),
|
||||
);
|
||||
AlertDescription.displayName = "AlertDescription";
|
||||
|
||||
export { Alert, AlertTitle, AlertDescription };
|
||||
@@ -2,7 +2,7 @@ import type * as React from "react";
|
||||
import { Slot } from "@radix-ui/react-slot";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
|
||||
import { cn } from "~/lib/utils";
|
||||
import { cn } from "~/client/lib/utils";
|
||||
|
||||
const badgeVariants = cva(
|
||||
"inline-flex items-center justify-center rounded-full border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden",
|
||||
92
app/client/components/ui/breadcrumb.tsx
Normal file
92
app/client/components/ui/breadcrumb.tsx
Normal file
@@ -0,0 +1,92 @@
|
||||
import type * as React from "react";
|
||||
import { Slot } from "@radix-ui/react-slot";
|
||||
import { ChevronRight, MoreHorizontal } from "lucide-react";
|
||||
|
||||
import { cn } from "~/client/lib/utils";
|
||||
|
||||
function Breadcrumb({ ...props }: React.ComponentProps<"nav">) {
|
||||
return <nav aria-label="breadcrumb" data-slot="breadcrumb" {...props} />;
|
||||
}
|
||||
|
||||
function BreadcrumbList({ className, ...props }: React.ComponentProps<"ol">) {
|
||||
return (
|
||||
<ol
|
||||
data-slot="breadcrumb-list"
|
||||
className={cn(
|
||||
"text-muted-foreground flex flex-wrap items-center gap-1.5 text-sm wrap-break-words sm:gap-2.5",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function BreadcrumbItem({ className, ...props }: React.ComponentProps<"li">) {
|
||||
return <li data-slot="breadcrumb-item" className={cn("inline-flex items-center gap-1.5", className)} {...props} />;
|
||||
}
|
||||
|
||||
function BreadcrumbLink({
|
||||
asChild,
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"a"> & {
|
||||
asChild?: boolean;
|
||||
}) {
|
||||
const Comp = asChild ? Slot : "a";
|
||||
|
||||
return (
|
||||
<Comp data-slot="breadcrumb-link" className={cn("hover:text-foreground transition-colors", className)} {...props} />
|
||||
);
|
||||
}
|
||||
|
||||
function BreadcrumbPage({ className, ...props }: React.ComponentProps<"span">) {
|
||||
return (
|
||||
<span
|
||||
data-slot="breadcrumb-page"
|
||||
role="link"
|
||||
aria-disabled="true"
|
||||
aria-current="page"
|
||||
className={cn("text-foreground font-normal", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function BreadcrumbSeparator({ children, className, ...props }: React.ComponentProps<"li">) {
|
||||
return (
|
||||
<li
|
||||
data-slot="breadcrumb-separator"
|
||||
role="presentation"
|
||||
aria-hidden="true"
|
||||
className={cn("[&>svg]:size-3.5", className)}
|
||||
{...props}
|
||||
>
|
||||
{children ?? <ChevronRight />}
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
function BreadcrumbEllipsis({ className, ...props }: React.ComponentProps<"span">) {
|
||||
return (
|
||||
<span
|
||||
data-slot="breadcrumb-ellipsis"
|
||||
role="presentation"
|
||||
aria-hidden="true"
|
||||
className={cn("flex size-9 items-center justify-center", className)}
|
||||
{...props}
|
||||
>
|
||||
<MoreHorizontal className="size-4" />
|
||||
<span className="sr-only">More</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
Breadcrumb,
|
||||
BreadcrumbList,
|
||||
BreadcrumbItem,
|
||||
BreadcrumbLink,
|
||||
BreadcrumbPage,
|
||||
BreadcrumbSeparator,
|
||||
BreadcrumbEllipsis,
|
||||
};
|
||||
@@ -3,7 +3,7 @@ import { cva, type VariantProps } from "class-variance-authority";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import type * as React from "react";
|
||||
|
||||
import { cn } from "~/lib/utils";
|
||||
import { cn } from "~/client/lib/utils";
|
||||
|
||||
const buttonVariants = cva(
|
||||
"inline-flex cursor-pointer uppercase rounded-sm items-center justify-center gap-2 whitespace-nowrap text-xs font-semibold tracking-wide transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-ring border-0",
|
||||
@@ -1,6 +1,6 @@
|
||||
import type * as React from "react";
|
||||
|
||||
import { cn } from "~/lib/utils";
|
||||
import { cn } from "~/client/lib/utils";
|
||||
|
||||
function Card({ className, children, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
@@ -3,7 +3,7 @@
|
||||
import * as React from "react";
|
||||
import * as RechartsPrimitive from "recharts";
|
||||
|
||||
import { cn } from "~/lib/utils";
|
||||
import { cn } from "~/client/lib/utils";
|
||||
|
||||
// Format: { THEME_NAME: CSS_SELECTOR }
|
||||
const THEMES = { light: "", dark: ".dark" } as const;
|
||||
27
app/client/components/ui/checkbox.tsx
Normal file
27
app/client/components/ui/checkbox.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
import type * as React from "react";
|
||||
import * as CheckboxPrimitive from "@radix-ui/react-checkbox";
|
||||
import { CheckIcon } from "lucide-react";
|
||||
|
||||
import { cn } from "~/client/lib/utils";
|
||||
|
||||
function Checkbox({ className, ...props }: React.ComponentProps<typeof CheckboxPrimitive.Root>) {
|
||||
return (
|
||||
<CheckboxPrimitive.Root
|
||||
data-slot="checkbox"
|
||||
className={cn(
|
||||
"peer border-input dark:bg-input/30 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground dark:data-[state=checked]:bg-primary data-[state=checked]:border-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive size-4 shrink-0 rounded-[4px] border shadow-xs transition-shadow outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<CheckboxPrimitive.Indicator
|
||||
data-slot="checkbox-indicator"
|
||||
className="grid place-content-center text-current transition-none"
|
||||
>
|
||||
<CheckIcon className="size-3.5" />
|
||||
</CheckboxPrimitive.Indicator>
|
||||
</CheckboxPrimitive.Root>
|
||||
);
|
||||
}
|
||||
|
||||
export { Checkbox };
|
||||
121
app/client/components/ui/dialog.tsx
Normal file
121
app/client/components/ui/dialog.tsx
Normal file
@@ -0,0 +1,121 @@
|
||||
import type * as React from "react";
|
||||
import * as DialogPrimitive from "@radix-ui/react-dialog";
|
||||
import { XIcon } from "lucide-react";
|
||||
|
||||
import { cn } from "~/client/lib/utils";
|
||||
|
||||
function Dialog({ ...props }: React.ComponentProps<typeof DialogPrimitive.Root>) {
|
||||
return <DialogPrimitive.Root data-slot="dialog" {...props} />;
|
||||
}
|
||||
|
||||
function DialogTrigger({ ...props }: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
|
||||
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />;
|
||||
}
|
||||
|
||||
function DialogPortal({ ...props }: React.ComponentProps<typeof DialogPrimitive.Portal>) {
|
||||
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />;
|
||||
}
|
||||
|
||||
function DialogClose({ ...props }: React.ComponentProps<typeof DialogPrimitive.Close>) {
|
||||
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />;
|
||||
}
|
||||
|
||||
function DialogOverlay({ className, ...props }: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
|
||||
return (
|
||||
<DialogPrimitive.Overlay
|
||||
data-slot="dialog-overlay"
|
||||
className={cn(
|
||||
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function DialogContent({
|
||||
className,
|
||||
children,
|
||||
showCloseButton = true,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
|
||||
showCloseButton?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<DialogPortal data-slot="dialog-portal">
|
||||
<DialogOverlay />
|
||||
<DialogPrimitive.Content
|
||||
data-slot="dialog-content"
|
||||
className={cn(
|
||||
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
{showCloseButton && (
|
||||
<DialogPrimitive.Close
|
||||
data-slot="dialog-close"
|
||||
className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
|
||||
>
|
||||
<XIcon />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
)}
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPortal>
|
||||
);
|
||||
}
|
||||
|
||||
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="dialog-header"
|
||||
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function DialogFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="dialog-footer"
|
||||
className={cn("flex flex-col-reverse gap-2 sm:flex-row sm:justify-end", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function DialogTitle({ className, ...props }: React.ComponentProps<typeof DialogPrimitive.Title>) {
|
||||
return (
|
||||
<DialogPrimitive.Title
|
||||
data-slot="dialog-title"
|
||||
className={cn("text-lg leading-none font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function DialogDescription({ className, ...props }: React.ComponentProps<typeof DialogPrimitive.Description>) {
|
||||
return (
|
||||
<DialogPrimitive.Description
|
||||
data-slot="dialog-description"
|
||||
className={cn("text-muted-foreground text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogOverlay,
|
||||
DialogPortal,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
};
|
||||
@@ -1,5 +1,5 @@
|
||||
import * as React from "react";
|
||||
import * as LabelPrimitive from "@radix-ui/react-label";
|
||||
import type * as LabelPrimitive from "@radix-ui/react-label";
|
||||
import { Slot } from "@radix-ui/react-slot";
|
||||
import {
|
||||
Controller,
|
||||
@@ -11,8 +11,8 @@ import {
|
||||
type FieldValues,
|
||||
} from "react-hook-form";
|
||||
|
||||
import { cn } from "~/lib/utils";
|
||||
import { Label } from "~/components/ui/label";
|
||||
import { cn } from "~/client/lib/utils";
|
||||
import { Label } from "~/client/components/ui/label";
|
||||
|
||||
const Form = FormProvider;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type * as React from "react";
|
||||
|
||||
import { cn } from "~/lib/utils";
|
||||
import { cn } from "~/client/lib/utils";
|
||||
|
||||
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
|
||||
return (
|
||||
21
app/client/components/ui/label.tsx
Normal file
21
app/client/components/ui/label.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
"use client";
|
||||
|
||||
import type * as React from "react";
|
||||
import * as LabelPrimitive from "@radix-ui/react-label";
|
||||
|
||||
import { cn } from "~/client/lib/utils";
|
||||
|
||||
function Label({ className, ...props }: React.ComponentProps<typeof LabelPrimitive.Root>) {
|
||||
return (
|
||||
<LabelPrimitive.Root
|
||||
data-slot="label"
|
||||
className={cn(
|
||||
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { Label };
|
||||
22
app/client/components/ui/progress.tsx
Normal file
22
app/client/components/ui/progress.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
import type * as React from "react";
|
||||
import * as ProgressPrimitive from "@radix-ui/react-progress";
|
||||
|
||||
import { cn } from "~/client/lib/utils";
|
||||
|
||||
function Progress({ className, value, ...props }: React.ComponentProps<typeof ProgressPrimitive.Root>) {
|
||||
return (
|
||||
<ProgressPrimitive.Root
|
||||
data-slot="progress"
|
||||
className={cn("bg-primary/20 relative h-2 w-full overflow-hidden rounded-full", className)}
|
||||
{...props}
|
||||
>
|
||||
<ProgressPrimitive.Indicator
|
||||
data-slot="progress-indicator"
|
||||
className="bg-primary h-full w-full flex-1 transition-all"
|
||||
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
|
||||
/>
|
||||
</ProgressPrimitive.Root>
|
||||
);
|
||||
}
|
||||
|
||||
export { Progress };
|
||||
46
app/client/components/ui/scroll-area.tsx
Normal file
46
app/client/components/ui/scroll-area.tsx
Normal file
@@ -0,0 +1,46 @@
|
||||
import type * as React from "react";
|
||||
import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area";
|
||||
|
||||
import { cn } from "~/client/lib/utils";
|
||||
|
||||
function ScrollArea({ className, children, ...props }: React.ComponentProps<typeof ScrollAreaPrimitive.Root>) {
|
||||
return (
|
||||
<ScrollAreaPrimitive.Root data-slot="scroll-area" className={cn("relative", className)} {...props}>
|
||||
<ScrollAreaPrimitive.Viewport
|
||||
data-slot="scroll-area-viewport"
|
||||
className="focus-visible:ring-ring/50 size-full rounded-[inherit] transition-[color,box-shadow] outline-none focus-visible:ring-[3px] focus-visible:outline-1"
|
||||
>
|
||||
{children}
|
||||
</ScrollAreaPrimitive.Viewport>
|
||||
<ScrollBar />
|
||||
<ScrollAreaPrimitive.Corner />
|
||||
</ScrollAreaPrimitive.Root>
|
||||
);
|
||||
}
|
||||
|
||||
function ScrollBar({
|
||||
className,
|
||||
orientation = "vertical",
|
||||
...props
|
||||
}: React.ComponentProps<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>) {
|
||||
return (
|
||||
<ScrollAreaPrimitive.ScrollAreaScrollbar
|
||||
data-slot="scroll-area-scrollbar"
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
"flex touch-none p-px transition-colors select-none",
|
||||
orientation === "vertical" && "h-full w-2.5 border-l border-l-transparent",
|
||||
orientation === "horizontal" && "h-2.5 flex-col border-t border-t-transparent",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ScrollAreaPrimitive.ScrollAreaThumb
|
||||
data-slot="scroll-area-thumb"
|
||||
className="bg-border relative flex-1 rounded-full"
|
||||
/>
|
||||
</ScrollAreaPrimitive.ScrollAreaScrollbar>
|
||||
);
|
||||
}
|
||||
|
||||
export { ScrollArea, ScrollBar };
|
||||
@@ -1,24 +1,18 @@
|
||||
import * as React from "react";
|
||||
import type * as React from "react";
|
||||
import * as SelectPrimitive from "@radix-ui/react-select";
|
||||
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react";
|
||||
|
||||
import { cn } from "~/lib/utils";
|
||||
import { cn } from "~/client/lib/utils";
|
||||
|
||||
function Select({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Root>) {
|
||||
function Select({ ...props }: React.ComponentProps<typeof SelectPrimitive.Root>) {
|
||||
return <SelectPrimitive.Root data-slot="select" {...props} />;
|
||||
}
|
||||
|
||||
function SelectGroup({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Group>) {
|
||||
function SelectGroup({ ...props }: React.ComponentProps<typeof SelectPrimitive.Group>) {
|
||||
return <SelectPrimitive.Group data-slot="select-group" {...props} />;
|
||||
}
|
||||
|
||||
function SelectValue({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Value>) {
|
||||
function SelectValue({ ...props }: React.ComponentProps<typeof SelectPrimitive.Value>) {
|
||||
return <SelectPrimitive.Value data-slot="select-value" {...props} />;
|
||||
}
|
||||
|
||||
@@ -83,10 +77,7 @@ function SelectContent({
|
||||
);
|
||||
}
|
||||
|
||||
function SelectLabel({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Label>) {
|
||||
function SelectLabel({ className, ...props }: React.ComponentProps<typeof SelectPrimitive.Label>) {
|
||||
return (
|
||||
<SelectPrimitive.Label
|
||||
data-slot="select-label"
|
||||
@@ -96,11 +87,7 @@ function SelectLabel({
|
||||
);
|
||||
}
|
||||
|
||||
function SelectItem({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Item>) {
|
||||
function SelectItem({ className, children, ...props }: React.ComponentProps<typeof SelectPrimitive.Item>) {
|
||||
return (
|
||||
<SelectPrimitive.Item
|
||||
data-slot="select-item"
|
||||
@@ -120,10 +107,7 @@ function SelectItem({
|
||||
);
|
||||
}
|
||||
|
||||
function SelectSeparator({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Separator>) {
|
||||
function SelectSeparator({ className, ...props }: React.ComponentProps<typeof SelectPrimitive.Separator>) {
|
||||
return (
|
||||
<SelectPrimitive.Separator
|
||||
data-slot="select-separator"
|
||||
@@ -133,17 +117,11 @@ function SelectSeparator({
|
||||
);
|
||||
}
|
||||
|
||||
function SelectScrollUpButton({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {
|
||||
function SelectScrollUpButton({ className, ...props }: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {
|
||||
return (
|
||||
<SelectPrimitive.ScrollUpButton
|
||||
data-slot="select-scroll-up-button"
|
||||
className={cn(
|
||||
"flex cursor-default items-center justify-center py-1",
|
||||
className,
|
||||
)}
|
||||
className={cn("flex cursor-default items-center justify-center py-1", className)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronUpIcon className="size-4" />
|
||||
@@ -158,10 +136,7 @@ function SelectScrollDownButton({
|
||||
return (
|
||||
<SelectPrimitive.ScrollDownButton
|
||||
data-slot="select-scroll-down-button"
|
||||
className={cn(
|
||||
"flex cursor-default items-center justify-center py-1",
|
||||
className,
|
||||
)}
|
||||
className={cn("flex cursor-default items-center justify-center py-1", className)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronDownIcon className="size-4" />
|
||||
26
app/client/components/ui/separator.tsx
Normal file
26
app/client/components/ui/separator.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
import type * as React from "react";
|
||||
import * as SeparatorPrimitive from "@radix-ui/react-separator";
|
||||
|
||||
import { cn } from "~/client/lib/utils";
|
||||
|
||||
function Separator({
|
||||
className,
|
||||
orientation = "horizontal",
|
||||
decorative = true,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SeparatorPrimitive.Root>) {
|
||||
return (
|
||||
<SeparatorPrimitive.Root
|
||||
data-slot="separator"
|
||||
decorative={decorative}
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
"bg-border shrink-0 data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-px",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { Separator };
|
||||
103
app/client/components/ui/sheet.tsx
Normal file
103
app/client/components/ui/sheet.tsx
Normal file
@@ -0,0 +1,103 @@
|
||||
"use client";
|
||||
|
||||
import type * as React from "react";
|
||||
import * as SheetPrimitive from "@radix-ui/react-dialog";
|
||||
import { XIcon } from "lucide-react";
|
||||
|
||||
import { cn } from "~/client/lib/utils";
|
||||
|
||||
function Sheet({ ...props }: React.ComponentProps<typeof SheetPrimitive.Root>) {
|
||||
return <SheetPrimitive.Root data-slot="sheet" {...props} />;
|
||||
}
|
||||
|
||||
function SheetTrigger({ ...props }: React.ComponentProps<typeof SheetPrimitive.Trigger>) {
|
||||
return <SheetPrimitive.Trigger data-slot="sheet-trigger" {...props} />;
|
||||
}
|
||||
|
||||
function SheetClose({ ...props }: React.ComponentProps<typeof SheetPrimitive.Close>) {
|
||||
return <SheetPrimitive.Close data-slot="sheet-close" {...props} />;
|
||||
}
|
||||
|
||||
function SheetPortal({ ...props }: React.ComponentProps<typeof SheetPrimitive.Portal>) {
|
||||
return <SheetPrimitive.Portal data-slot="sheet-portal" {...props} />;
|
||||
}
|
||||
|
||||
function SheetOverlay({ className, ...props }: React.ComponentProps<typeof SheetPrimitive.Overlay>) {
|
||||
return (
|
||||
<SheetPrimitive.Overlay
|
||||
data-slot="sheet-overlay"
|
||||
className={cn(
|
||||
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function SheetContent({
|
||||
className,
|
||||
children,
|
||||
side = "right",
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Content> & {
|
||||
side?: "top" | "right" | "bottom" | "left";
|
||||
}) {
|
||||
return (
|
||||
<SheetPortal>
|
||||
<SheetOverlay />
|
||||
<SheetPrimitive.Content
|
||||
data-slot="sheet-content"
|
||||
className={cn(
|
||||
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out fixed z-50 flex flex-col gap-4 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500",
|
||||
side === "right" &&
|
||||
"data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right inset-y-0 right-0 h-full w-3/4 border-l sm:max-w-sm",
|
||||
side === "left" &&
|
||||
"data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left inset-y-0 left-0 h-full w-3/4 border-r sm:max-w-sm",
|
||||
side === "top" &&
|
||||
"data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top inset-x-0 top-0 h-auto border-b",
|
||||
side === "bottom" &&
|
||||
"data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom inset-x-0 bottom-0 h-auto border-t",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<SheetPrimitive.Close className="ring-offset-background focus:ring-ring data-[state=open]:bg-secondary absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none">
|
||||
<XIcon className="size-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</SheetPrimitive.Close>
|
||||
</SheetPrimitive.Content>
|
||||
</SheetPortal>
|
||||
);
|
||||
}
|
||||
|
||||
function SheetHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return <div data-slot="sheet-header" className={cn("flex flex-col gap-1.5 p-4", className)} {...props} />;
|
||||
}
|
||||
|
||||
function SheetFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return <div data-slot="sheet-footer" className={cn("mt-auto flex flex-col gap-2 p-4", className)} {...props} />;
|
||||
}
|
||||
|
||||
function SheetTitle({ className, ...props }: React.ComponentProps<typeof SheetPrimitive.Title>) {
|
||||
return (
|
||||
<SheetPrimitive.Title
|
||||
data-slot="sheet-title"
|
||||
className={cn("text-foreground font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function SheetDescription({ className, ...props }: React.ComponentProps<typeof SheetPrimitive.Description>) {
|
||||
return (
|
||||
<SheetPrimitive.Description
|
||||
data-slot="sheet-description"
|
||||
className={cn("text-muted-foreground text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { Sheet, SheetTrigger, SheetClose, SheetContent, SheetHeader, SheetFooter, SheetTitle, SheetDescription };
|
||||
@@ -5,14 +5,14 @@ import { Slot } from "@radix-ui/react-slot";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
import { PanelLeftIcon } from "lucide-react";
|
||||
|
||||
import { useIsMobile } from "~/hooks/use-mobile";
|
||||
import { cn } from "~/lib/utils";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { Input } from "~/components/ui/input";
|
||||
import { Separator } from "~/components/ui/separator";
|
||||
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from "~/components/ui/sheet";
|
||||
import { Skeleton } from "~/components/ui/skeleton";
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "~/components/ui/tooltip";
|
||||
import { useIsMobile } from "~/client/hooks/use-mobile";
|
||||
import { cn } from "~/client/lib/utils";
|
||||
import { Button } from "~/client/components/ui/button";
|
||||
import { Input } from "~/client/components/ui/input";
|
||||
import { Separator } from "~/client/components/ui/separator";
|
||||
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from "~/client/components/ui/sheet";
|
||||
import { Skeleton } from "~/client/components/ui/skeleton";
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "~/client/components/ui/tooltip";
|
||||
|
||||
const SIDEBAR_COOKIE_NAME = "sidebar_state";
|
||||
const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7;
|
||||
7
app/client/components/ui/skeleton.tsx
Normal file
7
app/client/components/ui/skeleton.tsx
Normal file
@@ -0,0 +1,7 @@
|
||||
import { cn } from "~/client/lib/utils";
|
||||
|
||||
function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return <div data-slot="skeleton" className={cn("bg-accent animate-pulse rounded-md", className)} {...props} />;
|
||||
}
|
||||
|
||||
export { Skeleton };
|
||||
@@ -2,7 +2,7 @@ import { useTheme } from "next-themes";
|
||||
import { Toaster as Sonner, type ToasterProps } from "sonner";
|
||||
|
||||
const Toaster = ({ ...props }: ToasterProps) => {
|
||||
const { theme = "system" } = useTheme();
|
||||
const { theme = "dark" } = useTheme();
|
||||
|
||||
return (
|
||||
<Sonner
|
||||
@@ -1,7 +1,7 @@
|
||||
import * as SwitchPrimitive from "@radix-ui/react-switch";
|
||||
import type * as React from "react";
|
||||
|
||||
import { cn } from "~/lib/utils";
|
||||
import { cn } from "~/client/lib/utils";
|
||||
|
||||
function Switch({ className, ...props }: React.ComponentProps<typeof SwitchPrimitive.Root>) {
|
||||
return (
|
||||
73
app/client/components/ui/table.tsx
Normal file
73
app/client/components/ui/table.tsx
Normal file
@@ -0,0 +1,73 @@
|
||||
import type * as React from "react";
|
||||
|
||||
import { cn } from "~/client/lib/utils";
|
||||
|
||||
function Table({ className, ...props }: React.ComponentProps<"table">) {
|
||||
return (
|
||||
<div data-slot="table-container" className="relative w-full overflow-x-auto">
|
||||
<table data-slot="table" className={cn("w-full caption-bottom text-sm", className)} {...props} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TableHeader({ className, ...props }: React.ComponentProps<"thead">) {
|
||||
return <thead data-slot="table-header" className={cn("[&_tr]:border-b", className)} {...props} />;
|
||||
}
|
||||
|
||||
function TableBody({ className, ...props }: React.ComponentProps<"tbody">) {
|
||||
return <tbody data-slot="table-body" className={cn("[&_tr:last-child]:border-0", className)} {...props} />;
|
||||
}
|
||||
|
||||
function TableFooter({ className, ...props }: React.ComponentProps<"tfoot">) {
|
||||
return (
|
||||
<tfoot
|
||||
data-slot="table-footer"
|
||||
className={cn("bg-muted/50 border-t font-medium [&>tr]:last:border-b-0", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function TableRow({ className, ...props }: React.ComponentProps<"tr">) {
|
||||
return (
|
||||
<tr
|
||||
data-slot="table-row"
|
||||
className={cn("hover:bg-muted/50 data-[state=selected]:bg-muted border-b transition-colors", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function TableHead({ className, ...props }: React.ComponentProps<"th">) {
|
||||
return (
|
||||
<th
|
||||
data-slot="table-head"
|
||||
className={cn(
|
||||
"text-foreground h-10 px-2 text-left align-middle font-medium whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function TableCell({ className, ...props }: React.ComponentProps<"td">) {
|
||||
return (
|
||||
<td
|
||||
data-slot="table-cell"
|
||||
className={cn(
|
||||
"p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function TableCaption({ className, ...props }: React.ComponentProps<"caption">) {
|
||||
return (
|
||||
<caption data-slot="table-caption" className={cn("text-muted-foreground mt-4 text-sm", className)} {...props} />
|
||||
);
|
||||
}
|
||||
|
||||
export { Table, TableHeader, TableBody, TableFooter, TableHead, TableRow, TableCell, TableCaption };
|
||||
@@ -1,7 +1,7 @@
|
||||
import * as TabsPrimitive from "@radix-ui/react-tabs";
|
||||
import type * as React from "react";
|
||||
|
||||
import { cn } from "~/lib/utils";
|
||||
import { cn } from "~/client/lib/utils";
|
||||
|
||||
function Tabs({ className, ...props }: React.ComponentProps<typeof TabsPrimitive.Root>) {
|
||||
return <TabsPrimitive.Root data-slot="tabs" className={cn("flex flex-col gap-2", className)} {...props} />;
|
||||
18
app/client/components/ui/textarea.tsx
Normal file
18
app/client/components/ui/textarea.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
import type * as React from "react";
|
||||
|
||||
import { cn } from "~/client/lib/utils";
|
||||
|
||||
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
|
||||
return (
|
||||
<textarea
|
||||
data-slot="textarea"
|
||||
className={cn(
|
||||
"border-input placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 flex field-sizing-content min-h-16 w-full rounded-md border bg-transparent px-3 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { Textarea };
|
||||
46
app/client/components/ui/tooltip.tsx
Normal file
46
app/client/components/ui/tooltip.tsx
Normal file
@@ -0,0 +1,46 @@
|
||||
import type * as React from "react";
|
||||
import * as TooltipPrimitive from "@radix-ui/react-tooltip";
|
||||
|
||||
import { cn } from "~/client/lib/utils";
|
||||
|
||||
function TooltipProvider({ delayDuration = 0, ...props }: React.ComponentProps<typeof TooltipPrimitive.Provider>) {
|
||||
return <TooltipPrimitive.Provider data-slot="tooltip-provider" delayDuration={delayDuration} {...props} />;
|
||||
}
|
||||
|
||||
function Tooltip({ ...props }: React.ComponentProps<typeof TooltipPrimitive.Root>) {
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<TooltipPrimitive.Root data-slot="tooltip" {...props} />
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
|
||||
function TooltipTrigger({ ...props }: React.ComponentProps<typeof TooltipPrimitive.Trigger>) {
|
||||
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />;
|
||||
}
|
||||
|
||||
function TooltipContent({
|
||||
className,
|
||||
sideOffset = 0,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TooltipPrimitive.Content>) {
|
||||
return (
|
||||
<TooltipPrimitive.Portal>
|
||||
<TooltipPrimitive.Content
|
||||
data-slot="tooltip-content"
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"bg-foreground text-background animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-fit origin-(--radix-tooltip-content-transform-origin) rounded-md px-3 py-1.5 text-xs text-balance",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<TooltipPrimitive.Arrow className="bg-foreground fill-foreground z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px]" />
|
||||
</TooltipPrimitive.Content>
|
||||
</TooltipPrimitive.Portal>
|
||||
);
|
||||
}
|
||||
|
||||
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider };
|
||||
99
app/client/components/volume-file-browser.tsx
Normal file
99
app/client/components/volume-file-browser.tsx
Normal file
@@ -0,0 +1,99 @@
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { FolderOpen } from "lucide-react";
|
||||
import { FileTree } from "~/client/components/file-tree";
|
||||
import { listFilesOptions } from "../api-client/@tanstack/react-query.gen";
|
||||
import { useFileBrowser } from "../hooks/use-file-browser";
|
||||
|
||||
type VolumeFileBrowserProps = {
|
||||
volumeName: string;
|
||||
enabled?: boolean;
|
||||
withCheckboxes?: boolean;
|
||||
selectedPaths?: Set<string>;
|
||||
onSelectionChange?: (paths: Set<string>) => void;
|
||||
foldersOnly?: boolean;
|
||||
className?: string;
|
||||
emptyMessage?: string;
|
||||
emptyDescription?: string;
|
||||
};
|
||||
|
||||
export const VolumeFileBrowser = ({
|
||||
volumeName,
|
||||
enabled = true,
|
||||
withCheckboxes = false,
|
||||
selectedPaths,
|
||||
onSelectionChange,
|
||||
foldersOnly = false,
|
||||
className,
|
||||
emptyMessage = "This volume appears to be empty.",
|
||||
emptyDescription,
|
||||
}: VolumeFileBrowserProps) => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const { data, isLoading, error } = useQuery({
|
||||
...listFilesOptions({ path: { name: volumeName } }),
|
||||
enabled,
|
||||
});
|
||||
|
||||
const fileBrowser = useFileBrowser({
|
||||
initialData: data,
|
||||
isLoading,
|
||||
fetchFolder: async (path) => {
|
||||
return await queryClient.ensureQueryData(
|
||||
listFilesOptions({
|
||||
path: { name: volumeName },
|
||||
query: { path },
|
||||
}),
|
||||
);
|
||||
},
|
||||
prefetchFolder: (path) => {
|
||||
queryClient.prefetchQuery(
|
||||
listFilesOptions({
|
||||
path: { name: volumeName },
|
||||
query: { path },
|
||||
}),
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
if (fileBrowser.isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full min-h-[200px]">
|
||||
<p className="text-muted-foreground">Loading files...</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full min-h-[200px]">
|
||||
<p className="text-destructive">Failed to load files: {(error as Error).message}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (fileBrowser.isEmpty) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center h-full text-center p-8 min-h-[200px]">
|
||||
<FolderOpen className="mb-4 h-12 w-12 text-muted-foreground" />
|
||||
<p className="text-muted-foreground">{emptyMessage}</p>
|
||||
{emptyDescription && <p className="text-sm text-muted-foreground mt-2">{emptyDescription}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
<FileTree
|
||||
files={fileBrowser.fileArray}
|
||||
onFolderExpand={fileBrowser.handleFolderExpand}
|
||||
onFolderHover={fileBrowser.handleFolderHover}
|
||||
expandedFolders={fileBrowser.expandedFolders}
|
||||
loadingFolders={fileBrowser.loadingFolders}
|
||||
withCheckboxes={withCheckboxes}
|
||||
selectedPaths={selectedPaths}
|
||||
onSelectionChange={onSelectionChange}
|
||||
foldersOnly={foldersOnly}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { BackendType } from "@ironmount/schemas";
|
||||
import { Cloud, Folder, Server, Share2 } from "lucide-react";
|
||||
import type { BackendType } from "~/schemas/volumes";
|
||||
|
||||
type VolumeIconProps = {
|
||||
backend: BackendType;
|
||||
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,
|
||||
};
|
||||
};
|
||||
19
app/client/hooks/use-mobile.ts
Normal file
19
app/client/hooks/use-mobile.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import * as React from "react";
|
||||
|
||||
const MOBILE_BREAKPOINT = 768;
|
||||
|
||||
export function useIsMobile() {
|
||||
const [isMobile, setIsMobile] = React.useState<boolean | undefined>(undefined);
|
||||
|
||||
React.useEffect(() => {
|
||||
const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`);
|
||||
const onChange = () => {
|
||||
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT);
|
||||
};
|
||||
mql.addEventListener("change", onChange);
|
||||
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT);
|
||||
return () => mql.removeEventListener("change", onChange);
|
||||
}, []);
|
||||
|
||||
return !!isMobile;
|
||||
}
|
||||
151
app/client/hooks/use-server-events.ts
Normal file
151
app/client/hooks/use-server-events.ts
Normal file
@@ -0,0 +1,151 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
type ServerEventType =
|
||||
| "connected"
|
||||
| "heartbeat"
|
||||
| "backup:started"
|
||||
| "backup:progress"
|
||||
| "backup:completed"
|
||||
| "volume:mounted"
|
||||
| "volume:unmounted"
|
||||
| "volume:updated";
|
||||
|
||||
export interface BackupEvent {
|
||||
scheduleId: number;
|
||||
volumeName: string;
|
||||
repositoryName: string;
|
||||
status?: "success" | "error";
|
||||
}
|
||||
|
||||
export interface BackupProgressEvent {
|
||||
scheduleId: number;
|
||||
volumeName: string;
|
||||
repositoryName: string;
|
||||
seconds_elapsed: number;
|
||||
percent_done: number;
|
||||
total_files: number;
|
||||
files_done: number;
|
||||
total_bytes: number;
|
||||
bytes_done: number;
|
||||
current_files: string[];
|
||||
}
|
||||
|
||||
export interface VolumeEvent {
|
||||
volumeName: string;
|
||||
}
|
||||
|
||||
type EventHandler = (data: unknown) => void;
|
||||
|
||||
/**
|
||||
* Hook to listen to Server-Sent Events (SSE) from the backend
|
||||
* Automatically handles cache invalidation for backup and volume events
|
||||
*/
|
||||
export function useServerEvents() {
|
||||
const queryClient = useQueryClient();
|
||||
const eventSourceRef = useRef<EventSource | null>(null);
|
||||
const handlersRef = useRef<Map<ServerEventType, Set<EventHandler>>>(new Map());
|
||||
|
||||
useEffect(() => {
|
||||
const eventSource = new EventSource("/api/v1/events");
|
||||
eventSourceRef.current = eventSource;
|
||||
|
||||
eventSource.addEventListener("connected", () => {
|
||||
console.log("[SSE] Connected to server events");
|
||||
});
|
||||
|
||||
eventSource.addEventListener("heartbeat", () => {});
|
||||
|
||||
eventSource.addEventListener("backup:started", (e) => {
|
||||
const data = JSON.parse(e.data) as BackupEvent;
|
||||
console.log("[SSE] Backup started:", data);
|
||||
|
||||
handlersRef.current.get("backup:started")?.forEach((handler) => {
|
||||
handler(data);
|
||||
});
|
||||
});
|
||||
|
||||
eventSource.addEventListener("backup:progress", (e) => {
|
||||
const data = JSON.parse(e.data) as BackupProgressEvent;
|
||||
|
||||
handlersRef.current.get("backup:progress")?.forEach((handler) => {
|
||||
handler(data);
|
||||
});
|
||||
});
|
||||
|
||||
eventSource.addEventListener("backup:completed", (e) => {
|
||||
const data = JSON.parse(e.data) as BackupEvent;
|
||||
console.log("[SSE] Backup completed:", data);
|
||||
|
||||
queryClient.invalidateQueries();
|
||||
queryClient.refetchQueries();
|
||||
|
||||
handlersRef.current.get("backup:completed")?.forEach((handler) => {
|
||||
handler(data);
|
||||
});
|
||||
});
|
||||
|
||||
eventSource.addEventListener("volume:mounted", (e) => {
|
||||
const data = JSON.parse(e.data) as VolumeEvent;
|
||||
console.log("[SSE] Volume mounted:", data);
|
||||
|
||||
handlersRef.current.get("volume:mounted")?.forEach((handler) => {
|
||||
handler(data);
|
||||
});
|
||||
});
|
||||
|
||||
eventSource.addEventListener("volume:unmounted", (e) => {
|
||||
const data = JSON.parse(e.data) as VolumeEvent;
|
||||
console.log("[SSE] Volume unmounted:", data);
|
||||
|
||||
handlersRef.current.get("volume:unmounted")?.forEach((handler) => {
|
||||
handler(data);
|
||||
});
|
||||
});
|
||||
|
||||
eventSource.addEventListener("volume:updated", (e) => {
|
||||
const data = JSON.parse(e.data) as VolumeEvent;
|
||||
console.log("[SSE] Volume updated:", data);
|
||||
|
||||
queryClient.invalidateQueries();
|
||||
|
||||
handlersRef.current.get("volume:updated")?.forEach((handler) => {
|
||||
handler(data);
|
||||
});
|
||||
});
|
||||
|
||||
eventSource.addEventListener("volume:status_updated", (e) => {
|
||||
const data = JSON.parse(e.data) as VolumeEvent;
|
||||
console.log("[SSE] Volume status updated:", data);
|
||||
|
||||
queryClient.invalidateQueries();
|
||||
|
||||
handlersRef.current.get("volume:updated")?.forEach((handler) => {
|
||||
handler(data);
|
||||
});
|
||||
});
|
||||
|
||||
eventSource.onerror = (error) => {
|
||||
console.error("[SSE] Connection error:", error);
|
||||
};
|
||||
|
||||
return () => {
|
||||
console.log("[SSE] Disconnecting from server events");
|
||||
eventSource.close();
|
||||
eventSourceRef.current = null;
|
||||
};
|
||||
}, [queryClient]);
|
||||
|
||||
const addEventListener = (event: ServerEventType, handler: EventHandler) => {
|
||||
if (!handlersRef.current.has(event)) {
|
||||
handlersRef.current.set(event, new Set());
|
||||
}
|
||||
handlersRef.current.get(event)?.add(handler);
|
||||
|
||||
return () => {
|
||||
handlersRef.current.get(event)?.delete(handler);
|
||||
};
|
||||
};
|
||||
|
||||
return { addEventListener };
|
||||
}
|
||||
18
app/client/hooks/use-system-info.ts
Normal file
18
app/client/hooks/use-system-info.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { getSystemInfoOptions } from "../api-client/@tanstack/react-query.gen";
|
||||
|
||||
export function useSystemInfo() {
|
||||
const { data, isLoading, error } = useQuery({
|
||||
...getSystemInfoOptions(),
|
||||
staleTime: 5 * 60 * 1000,
|
||||
gcTime: 10 * 60 * 1000,
|
||||
refetchOnWindowFocus: false,
|
||||
});
|
||||
|
||||
return {
|
||||
capabilities: data?.capabilities ?? { docker: false, rclone: false },
|
||||
isLoading,
|
||||
error,
|
||||
systemInfo: data,
|
||||
};
|
||||
}
|
||||
@@ -4,7 +4,7 @@ import type {
|
||||
GetRepositoryResponse,
|
||||
GetVolumeResponse,
|
||||
ListSnapshotsResponse,
|
||||
} from "~/api-client";
|
||||
} from "../api-client";
|
||||
|
||||
export type Volume = GetVolumeResponse["volume"];
|
||||
export type StatFs = GetVolumeResponse["statfs"];
|
||||
106
app/client/modules/auth/routes/download-recovery-key.tsx
Normal file
106
app/client/modules/auth/routes/download-recovery-key.tsx
Normal file
@@ -0,0 +1,106 @@
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { AlertTriangle, Download } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { useNavigate } from "react-router";
|
||||
import { toast } from "sonner";
|
||||
import { AuthLayout } from "~/client/components/auth-layout";
|
||||
import { Alert, AlertDescription, AlertTitle } from "~/client/components/ui/alert";
|
||||
import { Button } from "~/client/components/ui/button";
|
||||
import { Input } from "~/client/components/ui/input";
|
||||
import { Label } from "~/client/components/ui/label";
|
||||
import { authMiddleware } from "~/middleware/auth";
|
||||
import type { Route } from "./+types/download-recovery-key";
|
||||
import { downloadResticPasswordMutation } from "~/client/api-client/@tanstack/react-query.gen";
|
||||
|
||||
export const clientMiddleware = [authMiddleware];
|
||||
|
||||
export function meta(_: Route.MetaArgs) {
|
||||
return [
|
||||
{ title: "Download Recovery Key" },
|
||||
{
|
||||
name: "description",
|
||||
content: "Download your backup recovery key to ensure you can restore your data.",
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
export default function DownloadRecoveryKeyPage() {
|
||||
const navigate = useNavigate();
|
||||
const [password, setPassword] = useState("");
|
||||
|
||||
const downloadResticPassword = useMutation({
|
||||
...downloadResticPasswordMutation(),
|
||||
onSuccess: (data) => {
|
||||
const blob = new Blob([data], { type: "text/plain" });
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = "restic.pass";
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
window.URL.revokeObjectURL(url);
|
||||
|
||||
toast.success("Recovery key downloaded successfully!");
|
||||
navigate("/volumes", { replace: true });
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error("Failed to download recovery key", { description: error.message });
|
||||
},
|
||||
});
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!password) {
|
||||
toast.error("Password is required");
|
||||
return;
|
||||
}
|
||||
|
||||
downloadResticPassword.mutate({
|
||||
body: {
|
||||
password,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<AuthLayout
|
||||
title="Download Your Recovery Key"
|
||||
description="This is a critical step to ensure you can recover your backups"
|
||||
>
|
||||
<Alert variant="warning" className="mb-6">
|
||||
<AlertTriangle className="size-5" />
|
||||
<AlertTitle>Important: Save This File Securely</AlertTitle>
|
||||
<AlertDescription>
|
||||
Your Restic password is essential for recovering your backup data. If you lose access to this server without
|
||||
this file, your backups will be unrecoverable. Store it in a password manager or encrypted storage.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="password">Confirm Your Password</Label>
|
||||
<Input
|
||||
id="password"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder="Enter your password"
|
||||
required
|
||||
autoFocus
|
||||
disabled={downloadResticPassword.isPending}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">Enter your account password to download the recovery key</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<Button type="submit" loading={downloadResticPassword.isPending} className="w-full">
|
||||
<Download size={16} className="mr-2" />
|
||||
Download Recovery Key
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</AuthLayout>
|
||||
);
|
||||
}
|
||||
@@ -4,15 +4,26 @@ import { type } from "arktype";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { useNavigate } from "react-router";
|
||||
import { toast } from "sonner";
|
||||
import { loginMutation } from "~/api-client/@tanstack/react-query.gen";
|
||||
import { AuthLayout } from "~/components/auth-layout";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "~/components/ui/form";
|
||||
import { Input } from "~/components/ui/input";
|
||||
import { AuthLayout } from "~/client/components/auth-layout";
|
||||
import { Button } from "~/client/components/ui/button";
|
||||
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "~/client/components/ui/form";
|
||||
import { Input } from "~/client/components/ui/input";
|
||||
import { authMiddleware } from "~/middleware/auth";
|
||||
import type { Route } from "./+types/login";
|
||||
import { loginMutation } from "~/client/api-client/@tanstack/react-query.gen";
|
||||
|
||||
export const clientMiddleware = [authMiddleware];
|
||||
|
||||
export function meta(_: Route.MetaArgs) {
|
||||
return [
|
||||
{ title: "Login" },
|
||||
{
|
||||
name: "description",
|
||||
content: "Sign in to your Ironmount account.",
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
const loginSchema = type({
|
||||
username: "2<=string<=50",
|
||||
password: "string>=1",
|
||||
@@ -33,8 +44,12 @@ export default function LoginPage() {
|
||||
|
||||
const login = useMutation({
|
||||
...loginMutation(),
|
||||
onSuccess: async () => {
|
||||
navigate("/volumes");
|
||||
onSuccess: async (data) => {
|
||||
if (data.user && !data.user.hasDownloadedResticPassword) {
|
||||
navigate("/download-recovery-key");
|
||||
} else {
|
||||
navigate("/volumes");
|
||||
}
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error(error);
|
||||
@@ -4,15 +4,34 @@ import { type } from "arktype";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { useNavigate } from "react-router";
|
||||
import { toast } from "sonner";
|
||||
import { registerMutation } from "~/api-client/@tanstack/react-query.gen";
|
||||
import { AuthLayout } from "~/components/auth-layout";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from "~/components/ui/form";
|
||||
import { Input } from "~/components/ui/input";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "~/client/components/ui/form";
|
||||
import { authMiddleware } from "~/middleware/auth";
|
||||
import type { Route } from "./+types/onboarding";
|
||||
import { AuthLayout } from "~/client/components/auth-layout";
|
||||
import { Input } from "~/client/components/ui/input";
|
||||
import { Button } from "~/client/components/ui/button";
|
||||
import { registerMutation } from "~/client/api-client/@tanstack/react-query.gen";
|
||||
|
||||
export const clientMiddleware = [authMiddleware];
|
||||
|
||||
export function meta(_: Route.MetaArgs) {
|
||||
return [
|
||||
{ title: "Onboarding" },
|
||||
{
|
||||
name: "description",
|
||||
content: "Welcome to Ironmount. Create your admin account to get started.",
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
const onboardingSchema = type({
|
||||
username: "2<=string<=50",
|
||||
password: "string>=8",
|
||||
@@ -37,7 +56,7 @@ export default function OnboardingPage() {
|
||||
...registerMutation(),
|
||||
onSuccess: async () => {
|
||||
toast.success("Admin user created successfully!");
|
||||
navigate("/volumes");
|
||||
navigate("/download-recovery-key");
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error(error);
|
||||
100
app/client/modules/backups/components/backup-progress-card.tsx
Normal file
100
app/client/modules/backups/components/backup-progress-card.tsx
Normal file
@@ -0,0 +1,100 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { ByteSize, formatBytes } from "~/client/components/bytes-size";
|
||||
import { Card } from "~/client/components/ui/card";
|
||||
import { Progress } from "~/client/components/ui/progress";
|
||||
import { type BackupProgressEvent, useServerEvents } from "~/client/hooks/use-server-events";
|
||||
import { formatDuration } from "~/utils/utils";
|
||||
|
||||
type Props = {
|
||||
scheduleId: number;
|
||||
};
|
||||
|
||||
export const BackupProgressCard = ({ scheduleId }: Props) => {
|
||||
const { addEventListener } = useServerEvents();
|
||||
const [progress, setProgress] = useState<BackupProgressEvent | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const unsubscribe = addEventListener("backup:progress", (data) => {
|
||||
const progressData = data as BackupProgressEvent;
|
||||
if (progressData.scheduleId === scheduleId) {
|
||||
setProgress(progressData);
|
||||
}
|
||||
});
|
||||
|
||||
const unsubscribeComplete = addEventListener("backup:completed", (data) => {
|
||||
const completedData = data as { scheduleId: number };
|
||||
if (completedData.scheduleId === scheduleId) {
|
||||
setProgress(null);
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
unsubscribe();
|
||||
unsubscribeComplete();
|
||||
};
|
||||
}, [addEventListener, scheduleId]);
|
||||
|
||||
if (!progress) {
|
||||
return (
|
||||
<Card className="p-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="h-4 w-4 animate-spin rounded-full border-2 border-primary border-t-transparent" />
|
||||
<span className="font-medium">Backup in progress</span>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
const percentDone = Math.round(progress.percent_done * 100);
|
||||
const currentFile = progress.current_files[0] || "";
|
||||
const fileName = currentFile.split("/").pop() || currentFile;
|
||||
const speed = formatBytes(progress.bytes_done / progress.seconds_elapsed);
|
||||
|
||||
return (
|
||||
<Card className="p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="h-4 w-4 animate-spin rounded-full border-2 border-primary border-t-transparent" />
|
||||
<span className="font-medium">Backup in progress</span>
|
||||
</div>
|
||||
<span className="text-sm font-medium text-primary">{percentDone}%</span>
|
||||
</div>
|
||||
|
||||
<Progress value={percentDone} className="h-2" />
|
||||
|
||||
<div className="grid grid-cols-2 gap-4 text-sm">
|
||||
<div>
|
||||
<p className="text-xs uppercase text-muted-foreground">Files</p>
|
||||
<p className="font-medium">
|
||||
{progress.files_done.toLocaleString()} / {progress.total_files.toLocaleString()}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs uppercase text-muted-foreground">Data</p>
|
||||
<p className="font-medium">
|
||||
<ByteSize bytes={progress.bytes_done} /> / <ByteSize bytes={progress.total_bytes} />
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs uppercase text-muted-foreground">Elapsed</p>
|
||||
<p className="font-medium">{formatDuration(progress.seconds_elapsed)}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs uppercase text-muted-foreground">Speed</p>
|
||||
<p className="font-medium">
|
||||
{progress.seconds_elapsed > 0 ? `${speed.text} ${speed.unit}/s` : "Calculating..."}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{fileName && (
|
||||
<div className="pt-2 border-t border-border">
|
||||
<p className="text-xs uppercase text-muted-foreground mb-1">Current file</p>
|
||||
<p className="text-xs font-mono text-muted-foreground truncate" title={currentFile}>
|
||||
{fileName}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
@@ -1,11 +1,21 @@
|
||||
import { cn } from "~/lib/utils";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "~/components/ui/tooltip";
|
||||
import { cn } from "~/client/lib/utils";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "~/client/components/ui/tooltip";
|
||||
|
||||
type BackupStatus = "active" | "paused" | "error";
|
||||
type BackupStatus = "active" | "paused" | "error" | "in_progress";
|
||||
|
||||
export const BackupStatusDot = ({ enabled, hasError }: { enabled: boolean; hasError?: boolean }) => {
|
||||
export const BackupStatusDot = ({
|
||||
enabled,
|
||||
hasError,
|
||||
isInProgress,
|
||||
}: {
|
||||
enabled: boolean;
|
||||
hasError?: boolean;
|
||||
isInProgress?: boolean;
|
||||
}) => {
|
||||
let status: BackupStatus = "paused";
|
||||
if (hasError) {
|
||||
if (isInProgress) {
|
||||
status = "in_progress";
|
||||
} else if (hasError) {
|
||||
status = "error";
|
||||
} else if (enabled) {
|
||||
status = "active";
|
||||
@@ -30,6 +40,12 @@ export const BackupStatusDot = ({ enabled, hasError }: { enabled: boolean; hasEr
|
||||
animated: true,
|
||||
label: "Error",
|
||||
},
|
||||
in_progress: {
|
||||
color: "bg-blue-500",
|
||||
colorLight: "bg-blue-400",
|
||||
animated: true,
|
||||
label: "Backup in progress",
|
||||
},
|
||||
}[status];
|
||||
|
||||
return (
|
||||
@@ -3,19 +3,28 @@ import { useQuery } from "@tanstack/react-query";
|
||||
import { type } from "arktype";
|
||||
import { useCallback, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { listRepositoriesOptions } from "~/api-client/@tanstack/react-query.gen";
|
||||
import { RepositoryIcon } from "~/components/repository-icon";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "~/components/ui/card";
|
||||
import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from "~/components/ui/form";
|
||||
import { Input } from "~/components/ui/input";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "~/components/ui/select";
|
||||
import { VolumeFileBrowser } from "~/components/volume-file-browser";
|
||||
import type { BackupSchedule, Volume } from "~/lib/types";
|
||||
import { listRepositoriesOptions } from "~/client/api-client/@tanstack/react-query.gen";
|
||||
import { RepositoryIcon } from "~/client/components/repository-icon";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "~/client/components/ui/card";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "~/client/components/ui/form";
|
||||
import { Input } from "~/client/components/ui/input";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "~/client/components/ui/select";
|
||||
import { Textarea } from "~/client/components/ui/textarea";
|
||||
import { VolumeFileBrowser } from "~/client/components/volume-file-browser";
|
||||
import type { BackupSchedule, Volume } from "~/client/lib/types";
|
||||
import { deepClean } from "~/utils/object";
|
||||
|
||||
const formSchema = type({
|
||||
const internalFormSchema = type({
|
||||
repositoryId: "string",
|
||||
excludePatterns: "string[]?",
|
||||
excludePatternsText: "string?",
|
||||
includePatterns: "string[]?",
|
||||
frequency: "string",
|
||||
dailyTime: "string?",
|
||||
@@ -27,7 +36,7 @@ const formSchema = type({
|
||||
keepMonthly: "number?",
|
||||
keepYearly: "number?",
|
||||
});
|
||||
const cleanSchema = type.pipe((d) => formSchema(deepClean(d)));
|
||||
const cleanSchema = type.pipe((d) => internalFormSchema(deepClean(d)));
|
||||
|
||||
export const weeklyDays = [
|
||||
{ label: "Monday", value: "1" },
|
||||
@@ -39,7 +48,11 @@ export const weeklyDays = [
|
||||
{ label: "Sunday", value: "0" },
|
||||
];
|
||||
|
||||
export type BackupScheduleFormValues = typeof formSchema.infer;
|
||||
type InternalFormValues = typeof internalFormSchema.infer;
|
||||
|
||||
export type BackupScheduleFormValues = Omit<InternalFormValues, "excludePatternsText"> & {
|
||||
excludePatterns?: string[];
|
||||
};
|
||||
|
||||
type Props = {
|
||||
volume: Volume;
|
||||
@@ -50,7 +63,7 @@ type Props = {
|
||||
formId: string;
|
||||
};
|
||||
|
||||
const backupScheduleToFormValues = (schedule?: BackupSchedule): BackupScheduleFormValues | undefined => {
|
||||
const backupScheduleToFormValues = (schedule?: BackupSchedule): InternalFormValues | undefined => {
|
||||
if (!schedule) {
|
||||
return undefined;
|
||||
}
|
||||
@@ -72,16 +85,36 @@ const backupScheduleToFormValues = (schedule?: BackupSchedule): BackupScheduleFo
|
||||
dailyTime,
|
||||
weeklyDay,
|
||||
includePatterns: schedule.includePatterns || undefined,
|
||||
excludePatternsText: schedule.excludePatterns?.join("\n") || undefined,
|
||||
...schedule.retentionPolicy,
|
||||
};
|
||||
};
|
||||
|
||||
export const CreateScheduleForm = ({ initialValues, formId, onSubmit, volume }: Props) => {
|
||||
const form = useForm<BackupScheduleFormValues>({
|
||||
resolver: arktypeResolver(cleanSchema as unknown as typeof formSchema),
|
||||
const form = useForm<InternalFormValues>({
|
||||
resolver: arktypeResolver(cleanSchema as unknown as typeof internalFormSchema),
|
||||
defaultValues: backupScheduleToFormValues(initialValues),
|
||||
});
|
||||
|
||||
const handleSubmit = useCallback(
|
||||
(data: InternalFormValues) => {
|
||||
// Convert excludePatternsText string to excludePatterns array
|
||||
const { excludePatternsText, ...rest } = data;
|
||||
const excludePatterns = excludePatternsText
|
||||
? excludePatternsText
|
||||
.split("\n")
|
||||
.map((p) => p.trim())
|
||||
.filter(Boolean)
|
||||
: undefined;
|
||||
|
||||
onSubmit({
|
||||
...rest,
|
||||
excludePatterns,
|
||||
});
|
||||
},
|
||||
[onSubmit],
|
||||
);
|
||||
|
||||
const { data: repositoriesData } = useQuery({
|
||||
...listRepositoriesOptions(),
|
||||
});
|
||||
@@ -102,8 +135,8 @@ export const CreateScheduleForm = ({ initialValues, formId, onSubmit, volume }:
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
className="grid gap-4 xl:grid-cols-[minmax(0,_2.3fr)_minmax(320px,_1fr)]"
|
||||
onSubmit={form.handleSubmit(handleSubmit)}
|
||||
className="grid gap-4 xl:grid-cols-[minmax(0,2.3fr)_minmax(320px,1fr)]"
|
||||
id={formId}
|
||||
>
|
||||
<div className="grid gap-4">
|
||||
@@ -221,7 +254,7 @@ export const CreateScheduleForm = ({ initialValues, formId, onSubmit, volume }:
|
||||
<CardHeader>
|
||||
<CardTitle>Backup paths</CardTitle>
|
||||
<CardDescription>
|
||||
Select which folders to include in the backup. If no paths are selected, the entire volume will be
|
||||
Select which folders or files to include in the backup. If no paths are selected, the entire volume will be
|
||||
backed up.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
@@ -231,8 +264,8 @@ export const CreateScheduleForm = ({ initialValues, formId, onSubmit, volume }:
|
||||
selectedPaths={selectedPaths}
|
||||
onSelectionChange={handleSelectionChange}
|
||||
withCheckboxes={true}
|
||||
foldersOnly={true}
|
||||
className="overflow-auto flex-1 border rounded-md bg-card p-2 min-h-[300px] max-h-[400px]"
|
||||
foldersOnly={false}
|
||||
className="flex-1 border rounded-md bg-card p-2 min-h-[300px] max-h-[400px] overflow-auto"
|
||||
/>
|
||||
{selectedPaths.size > 0 && (
|
||||
<div className="mt-4">
|
||||
@@ -249,6 +282,47 @@ export const CreateScheduleForm = ({ initialValues, formId, onSubmit, volume }:
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Exclude patterns</CardTitle>
|
||||
<CardDescription>
|
||||
Optionally specify patterns to exclude from backups. Enter one pattern per line (e.g., *.tmp,
|
||||
node_modules/**, .cache/).
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="excludePatternsText"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Exclusion patterns</FormLabel>
|
||||
<FormControl>
|
||||
<Textarea
|
||||
{...field}
|
||||
placeholder="*.tmp node_modules/** .cache/ *.log"
|
||||
className="font-mono text-sm min-h-[120px]"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Patterns support glob syntax. See
|
||||
<a
|
||||
href="https://restic.readthedocs.io/en/stable/040_backup.html#excluding-files"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="underline hover:text-foreground"
|
||||
>
|
||||
Restic documentation
|
||||
</a>
|
||||
for more details.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Retention policy</CardTitle>
|
||||
@@ -408,6 +482,33 @@ export const CreateScheduleForm = ({ initialValues, formId, onSubmit, volume }:
|
||||
{repositoriesData?.find((r) => r.id === formValues.repositoryId)?.name || "—"}
|
||||
</p>
|
||||
</div>
|
||||
{formValues.includePatterns && formValues.includePatterns.length > 0 && (
|
||||
<div>
|
||||
<p className="text-xs uppercase text-muted-foreground">Include paths</p>
|
||||
<div className="flex flex-col gap-1">
|
||||
{formValues.includePatterns.map((path) => (
|
||||
<span key={path} className="text-xs font-mono bg-accent px-1.5 py-0.5 rounded">
|
||||
{path}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{formValues.excludePatternsText && (
|
||||
<div>
|
||||
<p className="text-xs uppercase text-muted-foreground">Exclude patterns</p>
|
||||
<div className="flex flex-col gap-1">
|
||||
{formValues.excludePatternsText
|
||||
.split("\n")
|
||||
.filter(Boolean)
|
||||
.map((pattern) => (
|
||||
<span key={pattern} className="text-xs font-mono bg-accent px-1.5 py-0.5 rounded">
|
||||
{pattern.trim()}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<p className="text-xs uppercase text-muted-foreground">Retention</p>
|
||||
<p className="font-medium">
|
||||
@@ -1,8 +1,8 @@
|
||||
import { Pencil, Play, Trash2 } from "lucide-react";
|
||||
import { Eraser, Pencil, Play, Square, Trash2 } from "lucide-react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { OnOff } from "~/components/onoff";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "~/components/ui/card";
|
||||
import { OnOff } from "~/client/components/onoff";
|
||||
import { Button } from "~/client/components/ui/button";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "~/client/components/ui/card";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
@@ -11,20 +11,38 @@ import {
|
||||
AlertDialogDescription,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "~/components/ui/alert-dialog";
|
||||
import type { BackupSchedule } from "~/lib/types";
|
||||
} from "~/client/components/ui/alert-dialog";
|
||||
import type { BackupSchedule } from "~/client/lib/types";
|
||||
import { BackupProgressCard } from "./backup-progress-card";
|
||||
import { runForgetMutation } from "~/client/api-client/@tanstack/react-query.gen";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
import { parseError } from "~/client/lib/errors";
|
||||
|
||||
type Props = {
|
||||
schedule: BackupSchedule;
|
||||
handleToggleEnabled: (enabled: boolean) => void;
|
||||
handleRunBackupNow: () => void;
|
||||
handleStopBackup: () => void;
|
||||
handleDeleteSchedule: () => void;
|
||||
setIsEditMode: (isEdit: boolean) => void;
|
||||
};
|
||||
|
||||
export const ScheduleSummary = (props: Props) => {
|
||||
const { schedule, handleToggleEnabled, handleRunBackupNow, handleDeleteSchedule, setIsEditMode } = props;
|
||||
const { schedule, handleToggleEnabled, handleRunBackupNow, handleStopBackup, handleDeleteSchedule, setIsEditMode } =
|
||||
props;
|
||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
|
||||
const [showForgetConfirm, setShowForgetConfirm] = useState(false);
|
||||
|
||||
const runForget = useMutation({
|
||||
...runForgetMutation(),
|
||||
onSuccess: () => {
|
||||
toast.success("Retention policy applied successfully");
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error("Failed to apply retention policy", { description: parseError(error)?.message });
|
||||
},
|
||||
});
|
||||
|
||||
const summary = useMemo(() => {
|
||||
const scheduleLabel = schedule ? schedule.cronExpression : "-";
|
||||
@@ -53,6 +71,11 @@ export const ScheduleSummary = (props: Props) => {
|
||||
handleDeleteSchedule();
|
||||
};
|
||||
|
||||
const handleConfirmForget = () => {
|
||||
setShowForgetConfirm(false);
|
||||
runForget.mutate({ path: { scheduleId: schedule.id.toString() } });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<Card>
|
||||
@@ -75,10 +98,29 @@ export const ScheduleSummary = (props: Props) => {
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col sm:flex-row gap-2">
|
||||
<Button variant="default" size="sm" onClick={handleRunBackupNow} className="w-full sm:w-auto">
|
||||
<Play className="h-4 w-4 mr-2" />
|
||||
<span className="sm:inline">Backup Now</span>
|
||||
</Button>
|
||||
{schedule.lastBackupStatus === "in_progress" ? (
|
||||
<Button variant="destructive" size="sm" onClick={handleStopBackup} className="w-full sm:w-auto">
|
||||
<Square className="h-4 w-4 mr-2" />
|
||||
<span className="sm:inline">Stop backup</span>
|
||||
</Button>
|
||||
) : (
|
||||
<Button variant="default" size="sm" onClick={handleRunBackupNow} className="w-full sm:w-auto">
|
||||
<Play className="h-4 w-4 mr-2" />
|
||||
<span className="sm:inline">Backup now</span>
|
||||
</Button>
|
||||
)}
|
||||
{schedule.retentionPolicy && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
loading={runForget.isPending}
|
||||
onClick={() => setShowForgetConfirm(true)}
|
||||
className="w-full sm:w-auto"
|
||||
>
|
||||
<Eraser className="h-4 w-4 mr-2" />
|
||||
<span className="sm:inline">Run cleanup</span>
|
||||
</Button>
|
||||
)}
|
||||
<Button variant="outline" size="sm" onClick={() => setIsEditMode(true)} className="w-full sm:w-auto">
|
||||
<Pencil className="h-4 w-4 mr-2" />
|
||||
<span className="sm:inline">Edit schedule</span>
|
||||
@@ -121,12 +163,22 @@ export const ScheduleSummary = (props: Props) => {
|
||||
<p className="font-medium">
|
||||
{schedule.lastBackupStatus === "success" && "✓ Success"}
|
||||
{schedule.lastBackupStatus === "error" && "✗ Error"}
|
||||
{schedule.lastBackupStatus === "in_progress" && "⟳ in progress..."}
|
||||
{!schedule.lastBackupStatus && "—"}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{schedule.lastBackupError && (
|
||||
<div className="md:col-span-2 lg:col-span-4">
|
||||
<p className="text-xs uppercase text-muted-foreground">Error Details</p>
|
||||
<p className="font-mono text-sm text-red-600 whitespace-pre-wrap break-all">{schedule.lastBackupError}</p>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{schedule.lastBackupStatus === "in_progress" && <BackupProgressCard scheduleId={schedule.id} />}
|
||||
|
||||
<AlertDialog open={showDeleteConfirm} onOpenChange={setShowDeleteConfirm}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
@@ -147,6 +199,22 @@ export const ScheduleSummary = (props: Props) => {
|
||||
</div>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
|
||||
<AlertDialog open={showForgetConfirm} onOpenChange={setShowForgetConfirm}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Run retention policy cleanup?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
This will apply the retention policy and permanently delete old snapshots according to the configured
|
||||
rules ({summary.retentionLabel}). This action cannot be undone.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<div className="flex gap-3 justify-end">
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={handleConfirmForget}>Run cleanup</AlertDialogAction>
|
||||
</div>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,12 +1,11 @@
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { useCallback, useState } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { FileIcon } from "lucide-react";
|
||||
import { listSnapshotFilesOptions, restoreSnapshotMutation } from "~/api-client/@tanstack/react-query.gen";
|
||||
import { FileTree, type FileEntry } from "~/components/file-tree";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "~/components/ui/card";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { Checkbox } from "~/components/ui/checkbox";
|
||||
import { Label } from "~/components/ui/label";
|
||||
import { FileTree } from "~/client/components/file-tree";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "~/client/components/ui/card";
|
||||
import { Button } from "~/client/components/ui/button";
|
||||
import { Checkbox } from "~/client/components/ui/checkbox";
|
||||
import { Label } from "~/client/components/ui/label";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
@@ -16,28 +15,30 @@ import {
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "~/components/ui/alert-dialog";
|
||||
import type { Snapshot } from "~/lib/types";
|
||||
} from "~/client/components/ui/alert-dialog";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "~/client/components/ui/tooltip";
|
||||
import type { Snapshot, Volume } from "~/client/lib/types";
|
||||
import { toast } from "sonner";
|
||||
import { listSnapshotFilesOptions, restoreSnapshotMutation } from "~/client/api-client/@tanstack/react-query.gen";
|
||||
import { useFileBrowser } from "~/client/hooks/use-file-browser";
|
||||
|
||||
interface Props {
|
||||
snapshot: Snapshot;
|
||||
repositoryName: string;
|
||||
volume?: Volume;
|
||||
}
|
||||
|
||||
export const SnapshotFileBrowser = (props: Props) => {
|
||||
const { snapshot, repositoryName } = props;
|
||||
const { snapshot, repositoryName, volume } = props;
|
||||
|
||||
const isReadOnly = volume?.config && "readOnly" in volume.config && volume.config.readOnly === true;
|
||||
|
||||
const queryClient = useQueryClient();
|
||||
const [expandedFolders, setExpandedFolders] = useState<Set<string>>(new Set());
|
||||
const [fetchedFolders, setFetchedFolders] = useState<Set<string>>(new Set());
|
||||
const [loadingFolders, setLoadingFolders] = useState<Set<string>>(new Set());
|
||||
const [allFiles, setAllFiles] = useState<Map<string, FileEntry>>(new Map());
|
||||
const [selectedPaths, setSelectedPaths] = useState<Set<string>>(new Set());
|
||||
const [showRestoreDialog, setShowRestoreDialog] = useState(false);
|
||||
const [deleteExtraFiles, setDeleteExtraFiles] = useState(false);
|
||||
|
||||
const volumeBasePath = snapshot.paths[0]?.match(/^(.*?_data)(\/|$)/)?.[1] || "";
|
||||
const volumeBasePath = snapshot.paths[0]?.match(/^(.*?_data)(\/|$)/)?.[1] || "/";
|
||||
|
||||
const { data: filesData, isLoading: filesLoading } = useQuery({
|
||||
...listSnapshotFilesOptions({
|
||||
@@ -68,89 +69,30 @@ export const SnapshotFileBrowser = (props: Props) => {
|
||||
[volumeBasePath],
|
||||
);
|
||||
|
||||
useMemo(() => {
|
||||
if (filesData?.files) {
|
||||
setAllFiles((prev) => {
|
||||
const next = new Map(prev);
|
||||
for (const file of filesData.files) {
|
||||
const strippedPath = stripBasePath(file.path);
|
||||
if (strippedPath !== "/") {
|
||||
next.set(strippedPath, { ...file, path: strippedPath });
|
||||
}
|
||||
}
|
||||
return next;
|
||||
});
|
||||
setFetchedFolders((prev) => new Set(prev).add("/"));
|
||||
}
|
||||
}, [filesData, stripBasePath]);
|
||||
|
||||
const fileArray = useMemo(() => Array.from(allFiles.values()), [allFiles]);
|
||||
|
||||
const handleFolderExpand = useCallback(
|
||||
async (folderPath: string) => {
|
||||
setExpandedFolders((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.add(folderPath);
|
||||
return next;
|
||||
});
|
||||
|
||||
if (!fetchedFolders.has(folderPath)) {
|
||||
setLoadingFolders((prev) => new Set(prev).add(folderPath));
|
||||
|
||||
try {
|
||||
const fullPath = addBasePath(folderPath);
|
||||
|
||||
const result = await queryClient.fetchQuery(
|
||||
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;
|
||||
});
|
||||
}
|
||||
}
|
||||
const fileBrowser = useFileBrowser({
|
||||
initialData: filesData,
|
||||
isLoading: filesLoading,
|
||||
fetchFolder: async (path) => {
|
||||
return await queryClient.ensureQueryData(
|
||||
listSnapshotFilesOptions({
|
||||
path: { name: repositoryName, snapshotId: snapshot.short_id },
|
||||
query: { path },
|
||||
}),
|
||||
);
|
||||
},
|
||||
[repositoryName, snapshot, fetchedFolders, queryClient, stripBasePath, addBasePath],
|
||||
);
|
||||
|
||||
const handleFolderHover = useCallback(
|
||||
(folderPath: string) => {
|
||||
if (!fetchedFolders.has(folderPath) && !loadingFolders.has(folderPath)) {
|
||||
const fullPath = addBasePath(folderPath);
|
||||
|
||||
queryClient.prefetchQuery(
|
||||
listSnapshotFilesOptions({
|
||||
path: { name: repositoryName, snapshotId: snapshot.short_id },
|
||||
query: { path: fullPath },
|
||||
}),
|
||||
);
|
||||
}
|
||||
prefetchFolder: (path) => {
|
||||
queryClient.prefetchQuery(
|
||||
listSnapshotFilesOptions({
|
||||
path: { name: repositoryName, snapshotId: snapshot.short_id },
|
||||
query: { path },
|
||||
}),
|
||||
);
|
||||
},
|
||||
[repositoryName, snapshot, fetchedFolders, loadingFolders, queryClient, addBasePath],
|
||||
);
|
||||
pathTransform: {
|
||||
strip: stripBasePath,
|
||||
add: addBasePath,
|
||||
},
|
||||
});
|
||||
|
||||
const { mutate: restoreSnapshot, isPending: isRestoring } = useMutation({
|
||||
...restoreSnapshotMutation(),
|
||||
@@ -195,36 +137,53 @@ export const SnapshotFileBrowser = (props: Props) => {
|
||||
<CardDescription>{`Viewing snapshot from ${new Date(snapshot?.time ?? 0).toLocaleString()}`}</CardDescription>
|
||||
</div>
|
||||
{selectedPaths.size > 0 && (
|
||||
<Button onClick={handleRestoreClick} variant="primary" size="sm" disabled={isRestoring}>
|
||||
{isRestoring
|
||||
? "Restoring..."
|
||||
: `Restore ${selectedPaths.size} selected ${selectedPaths.size === 1 ? "item" : "items"}`}
|
||||
</Button>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span tabIndex={isReadOnly ? 0 : undefined}>
|
||||
<Button
|
||||
onClick={handleRestoreClick}
|
||||
variant="primary"
|
||||
size="sm"
|
||||
disabled={isRestoring || isReadOnly}
|
||||
>
|
||||
{isRestoring
|
||||
? "Restoring..."
|
||||
: `Restore ${selectedPaths.size} selected ${selectedPaths.size === 1 ? "item" : "items"}`}
|
||||
</Button>
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
{isReadOnly && (
|
||||
<TooltipContent className="text-center">
|
||||
<p>Volume is mounted as read-only.</p>
|
||||
<p>Please remount with read-only disabled to restore files.</p>
|
||||
</TooltipContent>
|
||||
)}
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="flex-1 overflow-hidden flex flex-col p-0">
|
||||
{filesLoading && fileArray.length === 0 && (
|
||||
{fileBrowser.isLoading && (
|
||||
<div className="flex items-center justify-center flex-1">
|
||||
<p className="text-muted-foreground">Loading files...</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{fileArray.length === 0 && !filesLoading && (
|
||||
{fileBrowser.isEmpty && (
|
||||
<div className="flex flex-col items-center justify-center flex-1 text-center p-8">
|
||||
<FileIcon className="w-12 h-12 text-muted-foreground/50 mb-4" />
|
||||
<p className="text-muted-foreground">No files in this snapshot</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{fileArray.length > 0 && (
|
||||
{!fileBrowser.isLoading && !fileBrowser.isEmpty && (
|
||||
<div className="overflow-auto flex-1 border border-border rounded-md bg-card m-4">
|
||||
<FileTree
|
||||
files={fileArray}
|
||||
onFolderExpand={handleFolderExpand}
|
||||
onFolderHover={handleFolderHover}
|
||||
expandedFolders={expandedFolders}
|
||||
loadingFolders={loadingFolders}
|
||||
files={fileBrowser.fileArray}
|
||||
onFolderExpand={fileBrowser.handleFolderExpand}
|
||||
onFolderHover={fileBrowser.handleFolderHover}
|
||||
expandedFolders={fileBrowser.expandedFolders}
|
||||
loadingFolders={fileBrowser.loadingFolders}
|
||||
className="px-2 py-2"
|
||||
withCheckboxes={true}
|
||||
selectedPaths={selectedPaths}
|
||||
@@ -1,24 +1,53 @@
|
||||
import type { ListSnapshotsResponse } from "~/api-client/types.gen";
|
||||
import { cn } from "~/lib/utils";
|
||||
import { Card } from "~/components/ui/card";
|
||||
import { ByteSize } from "~/components/bytes-size";
|
||||
import { cn } from "~/client/lib/utils";
|
||||
import { Card } from "~/client/components/ui/card";
|
||||
import { ByteSize } from "~/client/components/bytes-size";
|
||||
import { useEffect } from "react";
|
||||
import type { ListSnapshotsResponse } from "~/client/api-client";
|
||||
|
||||
interface Props {
|
||||
snapshots: ListSnapshotsResponse;
|
||||
snapshotId: string;
|
||||
snapshotId?: string;
|
||||
loading?: boolean;
|
||||
error?: string;
|
||||
onSnapshotSelect: (snapshotId: string) => void;
|
||||
}
|
||||
|
||||
export const SnapshotTimeline = (props: Props) => {
|
||||
const { snapshots, snapshotId, onSnapshotSelect } = props;
|
||||
const { snapshots, snapshotId, loading, onSnapshotSelect, error } = props;
|
||||
|
||||
useEffect(() => {
|
||||
if (!snapshotId && snapshots.length > 0) {
|
||||
onSnapshotSelect(snapshots[snapshots.length - 1].short_id);
|
||||
}
|
||||
}, [snapshotId, snapshots, onSnapshotSelect]);
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<Card>
|
||||
<div className="flex items-center justify-center h-24 p-4 text-center">
|
||||
<p className="text-destructive">Error loading snapshots: {error}</p>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Card>
|
||||
<div className="flex items-center justify-center h-24">
|
||||
<p className="text-muted-foreground">Loading snapshots...</p>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
if (snapshots.length === 0) {
|
||||
return (
|
||||
<div className="w-full bg-card border-t border-border py-4 px-4">
|
||||
<Card>
|
||||
<div className="flex items-center justify-center h-24">
|
||||
<p className="text-muted-foreground">No snapshots available</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -27,7 +56,7 @@ export const SnapshotTimeline = (props: Props) => {
|
||||
<div className="w-full bg-card">
|
||||
<div className="relative flex items-center">
|
||||
<div className="flex-1 overflow-hidden">
|
||||
<div className="flex gap-4 overflow-x-auto pb-2 [&>:first-child]:ml-2 [&>:last-child]:mr-2">
|
||||
<div className="flex gap-4 overflow-x-auto pb-2 *:first:ml-2 *:last:mr-2">
|
||||
{snapshots.map((snapshot, index) => {
|
||||
const date = new Date(snapshot.time);
|
||||
const isSelected = snapshotId === snapshot.short_id;
|
||||
@@ -2,59 +2,71 @@ import { useId, useState } from "react";
|
||||
import { useQuery, useMutation } from "@tanstack/react-query";
|
||||
import { redirect, useNavigate } from "react-router";
|
||||
import { toast } from "sonner";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { Button } from "~/client/components/ui/button";
|
||||
import {
|
||||
getBackupScheduleOptions,
|
||||
runBackupNowMutation,
|
||||
deleteBackupScheduleMutation,
|
||||
listSnapshotsOptions,
|
||||
updateBackupScheduleMutation,
|
||||
} from "~/api-client/@tanstack/react-query.gen";
|
||||
import { parseError } from "~/lib/errors";
|
||||
stopBackupMutation,
|
||||
} from "~/client/api-client/@tanstack/react-query.gen";
|
||||
import { parseError } from "~/client/lib/errors";
|
||||
import { getCronExpression } from "~/utils/utils";
|
||||
import { CreateScheduleForm, type BackupScheduleFormValues } from "../components/create-schedule-form";
|
||||
import { ScheduleSummary } from "../components/schedule-summary";
|
||||
import { getBackupSchedule, listSnapshots } from "~/api-client";
|
||||
import type { Route } from "./+types/backup-details";
|
||||
import { SnapshotFileBrowser } from "../components/snapshot-file-browser";
|
||||
import { SnapshotTimeline } from "../components/snapshot-timeline";
|
||||
import { getBackupSchedule } from "~/client/api-client";
|
||||
|
||||
export const handle = {
|
||||
breadcrumb: (match: Route.MetaArgs) => [
|
||||
{ label: "Backups", href: "/backups" },
|
||||
{ label: `Schedule #${match.params.id}` },
|
||||
],
|
||||
};
|
||||
|
||||
export function meta(_: Route.MetaArgs) {
|
||||
return [
|
||||
{ title: "Backup Job Details" },
|
||||
{
|
||||
name: "description",
|
||||
content: "View and manage backup job configuration, schedule, and snapshots.",
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
export const clientLoader = async ({ params }: Route.LoaderArgs) => {
|
||||
const { data } = await getBackupSchedule({ path: { scheduleId: params.id } });
|
||||
|
||||
if (!data) return redirect("/backups");
|
||||
|
||||
const snapshots = await listSnapshots({
|
||||
path: { name: data.repository.name },
|
||||
query: { backupId: params.id },
|
||||
});
|
||||
|
||||
if (snapshots.data) return { snapshots: snapshots.data, schedule: data };
|
||||
return { snapshots: [], schedule: data };
|
||||
return data;
|
||||
};
|
||||
|
||||
export default function ScheduleDetailsPage({ params, loaderData }: Route.ComponentProps) {
|
||||
const navigate = useNavigate();
|
||||
const [isEditMode, setIsEditMode] = useState(false);
|
||||
const formId = useId();
|
||||
const [selectedSnapshotId, setSelectedSnapshotId] = useState<string>(loaderData.snapshots.at(-1)?.short_id ?? "");
|
||||
const [selectedSnapshotId, setSelectedSnapshotId] = useState<string>();
|
||||
|
||||
const { data: schedule } = useQuery({
|
||||
...getBackupScheduleOptions({
|
||||
path: { scheduleId: params.id },
|
||||
}),
|
||||
initialData: loaderData.schedule,
|
||||
...getBackupScheduleOptions({ path: { scheduleId: params.id } }),
|
||||
initialData: loaderData,
|
||||
refetchInterval: 10000,
|
||||
refetchOnWindowFocus: true,
|
||||
});
|
||||
|
||||
const { data: snapshots } = useQuery({
|
||||
...listSnapshotsOptions({
|
||||
path: { name: schedule.repository.name },
|
||||
query: { backupId: schedule.id.toString() },
|
||||
}),
|
||||
initialData: loaderData.snapshots,
|
||||
const {
|
||||
data: snapshots,
|
||||
isLoading,
|
||||
failureReason,
|
||||
} = useQuery({
|
||||
...listSnapshotsOptions({ path: { name: schedule.repository.name }, query: { backupId: schedule.id.toString() } }),
|
||||
});
|
||||
|
||||
const upsertSchedule = useMutation({
|
||||
const updateSchedule = useMutation({
|
||||
...updateBackupScheduleMutation(),
|
||||
onSuccess: () => {
|
||||
toast.success("Backup schedule saved successfully");
|
||||
@@ -73,9 +85,17 @@ export default function ScheduleDetailsPage({ params, loaderData }: Route.Compon
|
||||
toast.success("Backup started successfully");
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error("Failed to start backup", {
|
||||
description: parseError(error)?.message,
|
||||
});
|
||||
toast.error("Failed to start backup", { description: parseError(error)?.message });
|
||||
},
|
||||
});
|
||||
|
||||
const stopBackup = useMutation({
|
||||
...stopBackupMutation(),
|
||||
onSuccess: () => {
|
||||
toast.success("Backup stopped successfully");
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error("Failed to stop backup", { description: parseError(error)?.message });
|
||||
},
|
||||
});
|
||||
|
||||
@@ -86,9 +106,7 @@ export default function ScheduleDetailsPage({ params, loaderData }: Route.Compon
|
||||
navigate("/backups");
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error("Failed to delete backup schedule", {
|
||||
description: parseError(error)?.message,
|
||||
});
|
||||
toast.error("Failed to delete backup schedule", { description: parseError(error)?.message });
|
||||
},
|
||||
});
|
||||
|
||||
@@ -105,7 +123,7 @@ export default function ScheduleDetailsPage({ params, loaderData }: Route.Compon
|
||||
if (formValues.keepMonthly) retentionPolicy.keepMonthly = formValues.keepMonthly;
|
||||
if (formValues.keepYearly) retentionPolicy.keepYearly = formValues.keepYearly;
|
||||
|
||||
upsertSchedule.mutate({
|
||||
updateSchedule.mutate({
|
||||
path: { scheduleId: schedule.id.toString() },
|
||||
body: {
|
||||
repositoryId: formValues.repositoryId,
|
||||
@@ -119,9 +137,7 @@ export default function ScheduleDetailsPage({ params, loaderData }: Route.Compon
|
||||
};
|
||||
|
||||
const handleToggleEnabled = (enabled: boolean) => {
|
||||
if (!schedule) return;
|
||||
|
||||
upsertSchedule.mutate({
|
||||
updateSchedule.mutate({
|
||||
path: { scheduleId: schedule.id.toString() },
|
||||
body: {
|
||||
repositoryId: schedule.repositoryId,
|
||||
@@ -134,28 +150,12 @@ export default function ScheduleDetailsPage({ params, loaderData }: Route.Compon
|
||||
});
|
||||
};
|
||||
|
||||
const handleRunBackupNow = () => {
|
||||
if (!schedule) return;
|
||||
|
||||
runBackupNow.mutate({
|
||||
path: {
|
||||
scheduleId: schedule.id.toString(),
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleDeleteSchedule = () => {
|
||||
if (!schedule) return;
|
||||
|
||||
deleteSchedule.mutate({ path: { scheduleId: schedule.id.toString() } });
|
||||
};
|
||||
|
||||
if (isEditMode) {
|
||||
return (
|
||||
<div>
|
||||
<CreateScheduleForm volume={schedule.volume} initialValues={schedule} onSubmit={handleSubmit} formId={formId} />
|
||||
<div className="flex justify-end mt-4 gap-2">
|
||||
<Button type="submit" className="ml-auto" variant="primary" form={formId} loading={upsertSchedule.isPending}>
|
||||
<Button type="submit" className="ml-auto" variant="primary" form={formId} loading={updateSchedule.isPending}>
|
||||
Update schedule
|
||||
</Button>
|
||||
<Button variant="outline" onClick={() => setIsEditMode(false)}>
|
||||
@@ -166,30 +166,32 @@ export default function ScheduleDetailsPage({ params, loaderData }: Route.Compon
|
||||
);
|
||||
}
|
||||
|
||||
const selectedSnapshot = snapshots.find((s) => s.short_id === selectedSnapshotId);
|
||||
const selectedSnapshot = snapshots?.find((s) => s.short_id === selectedSnapshotId);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<ScheduleSummary
|
||||
handleToggleEnabled={handleToggleEnabled}
|
||||
handleRunBackupNow={handleRunBackupNow}
|
||||
handleDeleteSchedule={handleDeleteSchedule}
|
||||
handleRunBackupNow={() => runBackupNow.mutate({ path: { scheduleId: schedule.id.toString() } })}
|
||||
handleStopBackup={() => stopBackup.mutate({ path: { scheduleId: schedule.id.toString() } })}
|
||||
handleDeleteSchedule={() => deleteSchedule.mutate({ path: { scheduleId: schedule.id.toString() } })}
|
||||
setIsEditMode={setIsEditMode}
|
||||
schedule={schedule}
|
||||
/>
|
||||
<SnapshotTimeline
|
||||
loading={isLoading}
|
||||
snapshots={snapshots ?? []}
|
||||
snapshotId={selectedSnapshot?.short_id}
|
||||
error={failureReason?.message}
|
||||
onSnapshotSelect={setSelectedSnapshotId}
|
||||
/>
|
||||
{selectedSnapshot && (
|
||||
<>
|
||||
<SnapshotTimeline
|
||||
snapshots={snapshots}
|
||||
snapshotId={selectedSnapshot.short_id}
|
||||
onSnapshotSelect={setSelectedSnapshotId}
|
||||
/>
|
||||
<SnapshotFileBrowser
|
||||
key={selectedSnapshot.short_id}
|
||||
snapshot={selectedSnapshot}
|
||||
repositoryName={schedule.repository.name}
|
||||
/>
|
||||
</>
|
||||
<SnapshotFileBrowser
|
||||
key={selectedSnapshot?.short_id}
|
||||
snapshot={selectedSnapshot}
|
||||
repositoryName={schedule.repository.name}
|
||||
volume={schedule.volume}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
@@ -1,20 +1,24 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { CalendarClock, Database, HardDrive, Plus } from "lucide-react";
|
||||
import { Link } from "react-router";
|
||||
import { listBackupSchedules } from "~/api-client";
|
||||
import { listBackupSchedulesOptions } from "~/api-client/@tanstack/react-query.gen";
|
||||
import { BackupStatusDot } from "../components/backup-status-dot";
|
||||
import { EmptyState } from "~/components/empty-state";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "~/components/ui/card";
|
||||
import { EmptyState } from "~/client/components/empty-state";
|
||||
import { Button } from "~/client/components/ui/button";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "~/client/components/ui/card";
|
||||
import type { Route } from "./+types/backups";
|
||||
import { listBackupSchedules } from "~/client/api-client";
|
||||
import { listBackupSchedulesOptions } from "~/client/api-client/@tanstack/react-query.gen";
|
||||
|
||||
export const handle = {
|
||||
breadcrumb: () => [{ label: "Backups" }],
|
||||
};
|
||||
|
||||
export function meta(_: Route.MetaArgs) {
|
||||
return [
|
||||
{ title: "Ironmount" },
|
||||
{ title: "Backup Jobs" },
|
||||
{
|
||||
name: "description",
|
||||
content: "Create, manage, monitor, and automate your Docker volumes with ease.",
|
||||
content: "Automate volume backups with scheduled jobs and retention policies.",
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -68,12 +72,16 @@ export default function Backups({ loaderData }: Route.ComponentProps) {
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="flex items-center gap-2 flex-1 min-w-0">
|
||||
<HardDrive className="h-5 w-5 text-muted-foreground flex-shrink-0" />
|
||||
<HardDrive className="h-5 w-5 text-muted-foreground shrink-0" />
|
||||
<CardTitle className="text-lg truncate">
|
||||
Volume <span className="text-strong-accent">{schedule.volume.name}</span>
|
||||
</CardTitle>
|
||||
</div>
|
||||
<BackupStatusDot enabled={schedule.enabled} hasError={!!schedule.lastBackupError} />
|
||||
<BackupStatusDot
|
||||
enabled={schedule.enabled}
|
||||
hasError={!!schedule.lastBackupError}
|
||||
isInProgress={schedule.lastBackupStatus === "in_progress"}
|
||||
/>
|
||||
</div>
|
||||
<CardDescription className="flex items-center gap-2 mt-2">
|
||||
<Database className="h-4 w-4" />
|
||||
@@ -98,12 +106,6 @@ export default function Backups({ loaderData }: Route.ComponentProps) {
|
||||
{schedule.nextBackupAt ? new Date(schedule.nextBackupAt).toLocaleDateString() : "N/A"}
|
||||
</span>
|
||||
</div>
|
||||
{schedule.lastBackupError && (
|
||||
<div className="flex items-start justify-between text-sm gap-2">
|
||||
<span className="text-muted-foreground">Error</span>
|
||||
<span className="text-xs text-red-600 text-right line-clamp-2">{schedule.lastBackupError}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useId, useState } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
import { Database, HardDrive } from "lucide-react";
|
||||
import { Link, useNavigate } from "react-router";
|
||||
import { toast } from "sonner";
|
||||
@@ -7,23 +7,27 @@ import {
|
||||
createBackupScheduleMutation,
|
||||
listRepositoriesOptions,
|
||||
listVolumesOptions,
|
||||
} from "~/api-client/@tanstack/react-query.gen";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { Card, CardContent } from "~/components/ui/card";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "~/components/ui/select";
|
||||
import { parseError } from "~/lib/errors";
|
||||
import { EmptyState } from "~/components/empty-state";
|
||||
} from "~/client/api-client/@tanstack/react-query.gen";
|
||||
import { Button } from "~/client/components/ui/button";
|
||||
import { Card, CardContent } from "~/client/components/ui/card";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "~/client/components/ui/select";
|
||||
import { parseError } from "~/client/lib/errors";
|
||||
import { EmptyState } from "~/client/components/empty-state";
|
||||
import { getCronExpression } from "~/utils/utils";
|
||||
import { CreateScheduleForm, type BackupScheduleFormValues } from "../components/create-schedule-form";
|
||||
import type { Route } from "./+types/create-backup";
|
||||
import { listRepositories, listVolumes } from "~/api-client";
|
||||
import { listRepositories, listVolumes } from "~/client/api-client";
|
||||
|
||||
export const handle = {
|
||||
breadcrumb: () => [{ label: "Backups", href: "/backups" }, { label: "Create" }],
|
||||
};
|
||||
|
||||
export function meta(_: Route.MetaArgs) {
|
||||
return [
|
||||
{ title: "Ironmount" },
|
||||
{ title: "Create Backup Job" },
|
||||
{
|
||||
name: "description",
|
||||
content: "Create, manage, monitor, and automate your Docker volumes with ease.",
|
||||
content: "Create a new automated backup job for your volumes.",
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -168,7 +172,7 @@ export default function CreateBackup({ loaderData }: Route.ComponentProps) {
|
||||
<div className="absolute inset-0 animate-pulse">
|
||||
<div className="w-24 h-24 rounded-full bg-primary/10 blur-2xl" />
|
||||
</div>
|
||||
<div className="relative flex items-center justify-center w-24 h-24 rounded-full bg-gradient-to-br from-primary/20 to-primary/5 border-2 border-primary/20">
|
||||
<div className="relative flex items-center justify-center w-24 h-24 rounded-full bg-linear-to-br from-primary/20 to-primary/5 border-2 border-primary/20">
|
||||
<Database className="w-12 h-12 text-primary/70" strokeWidth={1.5} />
|
||||
</div>
|
||||
</div>
|
||||
@@ -2,9 +2,9 @@ import { useMutation } from "@tanstack/react-query";
|
||||
import { RotateCcw } from "lucide-react";
|
||||
import { useId, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { restoreSnapshotMutation } from "~/api-client/@tanstack/react-query.gen";
|
||||
import { parseError } from "~/lib/errors";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { restoreSnapshotMutation } from "~/client/api-client/@tanstack/react-query.gen";
|
||||
import { parseError } from "~/client/lib/errors";
|
||||
import { Button } from "~/client/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -13,8 +13,8 @@ import {
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "~/components/ui/dialog";
|
||||
import { ScrollArea } from "~/components/ui/scroll-area";
|
||||
} from "~/client/components/ui/dialog";
|
||||
import { ScrollArea } from "~/client/components/ui/scroll-area";
|
||||
import { RestoreSnapshotForm, type RestoreSnapshotFormValues } from "./restore-snapshot-form";
|
||||
|
||||
type Props = {
|
||||
@@ -1,8 +1,16 @@
|
||||
import { arktypeResolver } from "@hookform/resolvers/arktype";
|
||||
import { type } from "arktype";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from "~/components/ui/form";
|
||||
import { Input } from "~/components/ui/input";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "~/client/components/ui/form";
|
||||
import { Input } from "~/client/components/ui/input";
|
||||
|
||||
const restoreSnapshotFormSchema = type({
|
||||
path: "string?",
|
||||
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: "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,26 +1,29 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Database, RotateCcw } from "lucide-react";
|
||||
import { Database, Plus, RotateCcw } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { useNavigate } from "react-router";
|
||||
import { listRepositories } from "~/api-client/sdk.gen";
|
||||
import { listRepositoriesOptions } from "~/api-client/@tanstack/react-query.gen";
|
||||
import { CreateRepositoryDialog } from "~/components/create-repository-dialog";
|
||||
import { RepositoryIcon } from "~/components/repository-icon";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { Card } from "~/components/ui/card";
|
||||
import { Input } from "~/components/ui/input";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "~/components/ui/select";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "~/components/ui/table";
|
||||
import { listRepositories } from "~/client/api-client/sdk.gen";
|
||||
import { listRepositoriesOptions } from "~/client/api-client/@tanstack/react-query.gen";
|
||||
import { RepositoryIcon } from "~/client/components/repository-icon";
|
||||
import { Button } from "~/client/components/ui/button";
|
||||
import { Card } from "~/client/components/ui/card";
|
||||
import { Input } from "~/client/components/ui/input";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "~/client/components/ui/select";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "~/client/components/ui/table";
|
||||
import type { Route } from "./+types/repositories";
|
||||
import { cn } from "~/lib/utils";
|
||||
import { EmptyState } from "~/components/empty-state";
|
||||
import { cn } from "~/client/lib/utils";
|
||||
import { EmptyState } from "~/client/components/empty-state";
|
||||
|
||||
export const handle = {
|
||||
breadcrumb: () => [{ label: "Repositories" }],
|
||||
};
|
||||
|
||||
export function meta(_: Route.MetaArgs) {
|
||||
return [
|
||||
{ title: "Ironmount - Repositories" },
|
||||
{ title: "Repositories" },
|
||||
{
|
||||
name: "description",
|
||||
content: "Manage your backup repositories",
|
||||
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 [statusFilter, setStatusFilter] = useState("");
|
||||
const [backendFilter, setBackendFilter] = useState("");
|
||||
const [createRepositoryOpen, setCreateRepositoryOpen] = useState(false);
|
||||
|
||||
const clearFilters = () => {
|
||||
setSearchQuery("");
|
||||
@@ -69,7 +71,12 @@ export default function Repositories({ loaderData }: Route.ComponentProps) {
|
||||
icon={Database}
|
||||
title="No repository"
|
||||
description="Repositories are remote storage locations where you can backup your volumes securely. Encrypted and optimized for storage efficiency."
|
||||
button={<CreateRepositoryDialog open={createRepositoryOpen} setOpen={setCreateRepositoryOpen} />}
|
||||
button={
|
||||
<Button onClick={() => navigate("/repositories/create")}>
|
||||
<Plus size={16} className="mr-2" />
|
||||
Create Repository
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -79,13 +86,13 @@ export default function Repositories({ loaderData }: Route.ComponentProps) {
|
||||
<div className="flex flex-col lg:flex-row items-stretch lg:items-center gap-2 md:justify-between p-4 bg-card-header py-4">
|
||||
<span className="flex flex-col sm:flex-row items-stretch md:items-center gap-0 flex-wrap ">
|
||||
<Input
|
||||
className="w-full lg:w-[180px] min-w-[180px] mr-[-1px] mt-[-1px]"
|
||||
className="w-full lg:w-[180px] min-w-[180px] -mr-px -mt-px"
|
||||
placeholder="Search repositories…"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
/>
|
||||
<Select value={statusFilter} onValueChange={setStatusFilter}>
|
||||
<SelectTrigger className="w-full lg:w-[180px] min-w-[180px] mr-[-1px] mt-[-1px]">
|
||||
<SelectTrigger className="w-full lg:w-[180px] min-w-[180px] -mr-px -mt-px">
|
||||
<SelectValue placeholder="All status" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
@@ -95,13 +102,14 @@ export default function Repositories({ loaderData }: Route.ComponentProps) {
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select value={backendFilter} onValueChange={setBackendFilter}>
|
||||
<SelectTrigger className="w-full lg:w-[180px] min-w-[180px] mt-[-1px]">
|
||||
<SelectTrigger className="w-full lg:w-[180px] min-w-[180px] -mt-px">
|
||||
<SelectValue placeholder="All backends" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="local">Local</SelectItem>
|
||||
<SelectItem value="sftp">SFTP</SelectItem>
|
||||
<SelectItem value="s3">S3</SelectItem>
|
||||
<SelectItem value="gcs">Google Cloud Storage</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{(searchQuery || statusFilter || backendFilter) && (
|
||||
@@ -111,7 +119,10 @@ export default function Repositories({ loaderData }: Route.ComponentProps) {
|
||||
</Button>
|
||||
)}
|
||||
</span>
|
||||
<CreateRepositoryDialog open={createRepositoryOpen} setOpen={setCreateRepositoryOpen} />
|
||||
<Button onClick={() => navigate("/repositories/create")}>
|
||||
<Plus size={16} className="mr-2" />
|
||||
Create Repository
|
||||
</Button>
|
||||
</div>
|
||||
<div className="overflow-x-auto">
|
||||
<Table className="border-t">
|
||||
241
app/client/modules/repositories/routes/repository-details.tsx
Normal file
241
app/client/modules/repositories/routes/repository-details.tsx
Normal file
@@ -0,0 +1,241 @@
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { redirect, useNavigate, useSearchParams } from "react-router";
|
||||
import { toast } from "sonner";
|
||||
import { useState, useEffect } from "react";
|
||||
import {
|
||||
deleteRepositoryMutation,
|
||||
doctorRepositoryMutation,
|
||||
getRepositoryOptions,
|
||||
listSnapshotsOptions,
|
||||
} from "~/client/api-client/@tanstack/react-query.gen";
|
||||
import { Button } from "~/client/components/ui/button";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "~/client/components/ui/alert-dialog";
|
||||
import { parseError } from "~/client/lib/errors";
|
||||
import { getRepository } from "~/client/api-client/sdk.gen";
|
||||
import type { Route } from "./+types/repository-details";
|
||||
import { cn } from "~/client/lib/utils";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "~/client/components/ui/tabs";
|
||||
import { RepositoryInfoTabContent } from "../tabs/info";
|
||||
import { RepositorySnapshotsTabContent } from "../tabs/snapshots";
|
||||
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) {
|
||||
return [
|
||||
{ title: params.name },
|
||||
{
|
||||
name: "description",
|
||||
content: "View repository configuration, status, and snapshots.",
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
export const clientLoader = async ({ params }: Route.ClientLoaderArgs) => {
|
||||
const repository = await getRepository({ path: { name: params.name ?? "" } });
|
||||
if (repository.data) return repository.data;
|
||||
|
||||
return redirect("/repositories");
|
||||
};
|
||||
|
||||
export default function RepositoryDetailsPage({ loaderData }: Route.ComponentProps) {
|
||||
const [showDoctorResults, setShowDoctorResults] = useState(false);
|
||||
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
|
||||
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const activeTab = searchParams.get("tab") || "info";
|
||||
|
||||
const { data } = useQuery({
|
||||
...getRepositoryOptions({ path: { name: loaderData.name } }),
|
||||
initialData: loaderData,
|
||||
refetchInterval: 10000,
|
||||
refetchOnWindowFocus: true,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
queryClient.prefetchQuery(listSnapshotsOptions({ path: { name: data.name } }));
|
||||
}, [queryClient, data.name]);
|
||||
|
||||
const deleteRepo = useMutation({
|
||||
...deleteRepositoryMutation(),
|
||||
onSuccess: () => {
|
||||
toast.success("Repository deleted successfully");
|
||||
navigate("/repositories");
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error("Failed to delete repository", {
|
||||
description: parseError(error)?.message,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const doctorMutation = useMutation({
|
||||
...doctorRepositoryMutation(),
|
||||
onSuccess: (data) => {
|
||||
if (data) {
|
||||
setShowDoctorResults(true);
|
||||
|
||||
if (data.success) {
|
||||
toast.success("Repository doctor completed successfully");
|
||||
} else {
|
||||
toast.warning("Doctor completed with some issues", {
|
||||
description: "Check the details for more information",
|
||||
richColors: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error("Failed to run doctor", {
|
||||
description: parseError(error)?.message,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const handleConfirmDelete = () => {
|
||||
setShowDeleteConfirm(false);
|
||||
deleteRepo.mutate({ path: { name: data.name } });
|
||||
};
|
||||
|
||||
const getStepLabel = (step: string) => {
|
||||
switch (step) {
|
||||
case "unlock":
|
||||
return "Unlock Repository";
|
||||
case "check":
|
||||
return "Check Repository";
|
||||
case "repair_index":
|
||||
return "Repair Index";
|
||||
case "recheck":
|
||||
return "Re-check Repository";
|
||||
default:
|
||||
return step;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="text-sm font-semibold text-muted-foreground flex items-center gap-2">
|
||||
<span
|
||||
className={cn("inline-flex items-center gap-2 px-2 py-1 rounded-md text-xs bg-gray-500/10 text-gray-500", {
|
||||
"bg-green-500/10 text-green-500": data.status === "healthy",
|
||||
"bg-red-500/10 text-red-500": data.status === "error",
|
||||
})}
|
||||
>
|
||||
{data.status || "unknown"}
|
||||
</span>
|
||||
<span className="text-xs bg-primary/10 rounded-md px-2 py-1">{data.type}</span>
|
||||
</div>
|
||||
<div className="flex gap-4">
|
||||
<Button
|
||||
onClick={() => doctorMutation.mutate({ path: { name: data.name } })}
|
||||
disabled={doctorMutation.isPending}
|
||||
variant={"outline"}
|
||||
>
|
||||
{doctorMutation.isPending ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Running Doctor...
|
||||
</>
|
||||
) : (
|
||||
"Run Doctor"
|
||||
)}
|
||||
</Button>
|
||||
<Button variant="destructive" onClick={() => setShowDeleteConfirm(true)} disabled={deleteRepo.isPending}>
|
||||
Delete
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Tabs value={activeTab} onValueChange={(value) => setSearchParams({ tab: value })}>
|
||||
<TabsList className="mb-2">
|
||||
<TabsTrigger value="info">Configuration</TabsTrigger>
|
||||
<TabsTrigger value="snapshots">Snapshots</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="info">
|
||||
<RepositoryInfoTabContent repository={data} />
|
||||
</TabsContent>
|
||||
<TabsContent value="snapshots">
|
||||
<RepositorySnapshotsTabContent repository={data} />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
<AlertDialog open={showDeleteConfirm} onOpenChange={setShowDeleteConfirm}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Delete repository?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
Are you sure you want to delete the repository <strong>{data.name}</strong>? This action cannot be undone
|
||||
and will remove all backup data.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<div className="flex gap-3 justify-end">
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={handleConfirmDelete}
|
||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
>
|
||||
Delete repository
|
||||
</AlertDialogAction>
|
||||
</div>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
|
||||
<AlertDialog open={showDoctorResults} onOpenChange={setShowDoctorResults}>
|
||||
<AlertDialogContent className="max-w-2xl">
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Doctor Results</AlertDialogTitle>
|
||||
<AlertDialogDescription>Repository doctor operation completed</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
|
||||
{doctorMutation.data && (
|
||||
<div className="space-y-3 max-h-96 overflow-y-auto">
|
||||
{doctorMutation.data.steps.map((step) => (
|
||||
<div
|
||||
key={step.step}
|
||||
className={cn("border rounded-md p-3", {
|
||||
"bg-green-500/10 border-green-500/20": step.success,
|
||||
"bg-yellow-500/10 border-yellow-500/20": !step.success,
|
||||
})}
|
||||
>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="font-medium text-sm">{getStepLabel(step.step)}</span>
|
||||
<span
|
||||
className={cn("text-xs px-2 py-1 rounded", {
|
||||
"bg-green-500/20 text-green-500": step.success,
|
||||
"bg-yellow-500/20 text-yellow-500": !step.success,
|
||||
})}
|
||||
>
|
||||
{step.success ? "Success" : "Warning"}
|
||||
</span>
|
||||
</div>
|
||||
{step.error && <p className="text-xs text-red-500 mt-1">{step.error}</p>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button onClick={() => setShowDoctorResults(false)}>Close</Button>
|
||||
</div>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,21 +1,44 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { redirect, useParams } from "react-router";
|
||||
import { listSnapshotFilesOptions } from "~/api-client/@tanstack/react-query.gen";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
|
||||
import { listSnapshotFilesOptions } from "~/client/api-client/@tanstack/react-query.gen";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "~/client/components/ui/card";
|
||||
import { RestoreSnapshotDialog } from "../components/restore-snapshot-dialog";
|
||||
import { SnapshotFileBrowser } from "~/modules/backups/components/snapshot-file-browser";
|
||||
import { getSnapshotDetails } from "~/api-client";
|
||||
import { SnapshotFileBrowser } from "~/client/modules/backups/components/snapshot-file-browser";
|
||||
import { getSnapshotDetails } from "~/client/api-client";
|
||||
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) {
|
||||
return [
|
||||
{ title: `Snapshot ${params.snapshotId}` },
|
||||
{
|
||||
name: "description",
|
||||
content: "Browse and restore files from a backup snapshot.",
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
export const clientLoader = async ({ params }: Route.ClientLoaderArgs) => {
|
||||
const snapshot = await getSnapshotDetails({ path: { name: params.name, snapshotId: params.snapshotId } });
|
||||
const snapshot = await getSnapshotDetails({
|
||||
path: { name: params.name, snapshotId: params.snapshotId },
|
||||
});
|
||||
if (snapshot.data) return snapshot.data;
|
||||
|
||||
return redirect("/repositories");
|
||||
};
|
||||
|
||||
export default function SnapshotDetailsPage({ loaderData }: Route.ComponentProps) {
|
||||
const { name, snapshotId } = useParams<{ name: string; snapshotId: string }>();
|
||||
const { name, snapshotId } = useParams<{
|
||||
name: string;
|
||||
snapshotId: string;
|
||||
}>();
|
||||
|
||||
const { data } = useQuery({
|
||||
...listSnapshotFilesOptions({
|
||||
@@ -54,11 +77,11 @@ export default function SnapshotDetailsPage({ loaderData }: Route.ComponentProps
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<span className="text-muted-foreground">Snapshot ID:</span>
|
||||
<p className="font-mono">{data.snapshot.id}</p>
|
||||
<p className="font-mono break-all">{data.snapshot.id}</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground">Short ID:</span>
|
||||
<p className="font-mono">{data.snapshot.short_id}</p>
|
||||
<p className="font-mono break-all">{data.snapshot.short_id}</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground">Hostname:</span>
|
||||
@@ -72,7 +95,7 @@ export default function SnapshotDetailsPage({ loaderData }: Route.ComponentProps
|
||||
<span className="text-muted-foreground">Paths:</span>
|
||||
<div className="space-y-1 mt-1">
|
||||
{data.snapshot.paths.map((path) => (
|
||||
<p key={path} className="font-mono text-xs bg-muted px-2 py-1 rounded">
|
||||
<p key={path} className="font-mono text-xs bg-muted px-2 py-1 rounded break-all">
|
||||
{path}
|
||||
</p>
|
||||
))}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Card } from "~/components/ui/card";
|
||||
import type { Repository } from "~/lib/types";
|
||||
import { Card } from "~/client/components/ui/card";
|
||||
import type { Repository } from "~/client/lib/types";
|
||||
|
||||
type Props = {
|
||||
repository: Repository;
|
||||
@@ -30,7 +30,7 @@ export const RepositoryInfoTabContent = ({ repository }: Props) => {
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm font-medium text-muted-foreground">Created At</div>
|
||||
<p className="mt-1 text-sm">{new Date(repository.createdAt).toLocaleString()}</p>
|
||||
<p className="mt-1 text-sm">{new Date(repository.createdAt * 1000).toLocaleString()}</p>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm font-medium text-muted-foreground">Last Checked</div>
|
||||
@@ -40,16 +40,17 @@ export const RepositoryInfoTabContent = ({ repository }: Props) => {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{repository.lastError && (
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold mb-4 text-red-500">Last Error</h3>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-lg font-semibold text-red-500">Last Error</h3>
|
||||
</div>
|
||||
|
||||
<div className="bg-red-500/10 border border-red-500/20 rounded-md p-4">
|
||||
<p className="text-sm text-red-500">{repository.lastError}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold mb-4">Configuration</h3>
|
||||
<div className="bg-muted/50 rounded-md p-4">
|
||||
@@ -1,32 +1,18 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { intervalToDuration } from "date-fns";
|
||||
import { Database } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { listSnapshotsOptions } from "~/api-client/@tanstack/react-query.gen";
|
||||
import { ByteSize } from "~/components/bytes-size";
|
||||
import { SnapshotsTable } from "~/components/snapshots-table";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "~/components/ui/card";
|
||||
import { Input } from "~/components/ui/input";
|
||||
import { Table, TableBody, TableCell, TableRow } from "~/components/ui/table";
|
||||
import type { Repository, Snapshot } from "~/lib/types";
|
||||
import { listSnapshotsOptions } from "~/client/api-client/@tanstack/react-query.gen";
|
||||
import { SnapshotsTable } from "~/client/components/snapshots-table";
|
||||
import { Button } from "~/client/components/ui/button";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "~/client/components/ui/card";
|
||||
import { Input } from "~/client/components/ui/input";
|
||||
import { Table, TableBody, TableCell, TableRow } from "~/client/components/ui/table";
|
||||
import type { Repository, Snapshot } from "~/client/lib/types";
|
||||
|
||||
type Props = {
|
||||
repository: Repository;
|
||||
};
|
||||
|
||||
export const formatSnapshotDuration = (seconds: number) => {
|
||||
const duration = intervalToDuration({ start: 0, end: seconds * 1000 });
|
||||
const parts: string[] = [];
|
||||
|
||||
if (duration.days) parts.push(`${duration.days}d`);
|
||||
if (duration.hours) parts.push(`${duration.hours}h`);
|
||||
if (duration.minutes) parts.push(`${duration.minutes}m`);
|
||||
if (duration.seconds || parts.length === 0) parts.push(`${duration.seconds || 0}s`);
|
||||
|
||||
return parts.join(" ");
|
||||
};
|
||||
|
||||
export const RepositorySnapshotsTabContent = ({ repository }: Props) => {
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
|
||||
@@ -46,7 +32,7 @@ export const RepositorySnapshotsTabContent = ({ repository }: Props) => {
|
||||
);
|
||||
});
|
||||
|
||||
const hasNoFilteredSnapshots = !filteredSnapshots?.length && !data.length;
|
||||
const hasNoFilteredSnapshots = !filteredSnapshots?.length;
|
||||
|
||||
if (repository.status === "error") {
|
||||
return (
|
||||
@@ -67,16 +53,6 @@ export const RepositorySnapshotsTabContent = ({ repository }: Props) => {
|
||||
);
|
||||
}
|
||||
|
||||
if (isFetching && !data.length) {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="flex items-center justify-center py-12">
|
||||
<p className="text-muted-foreground">Loading snapshots</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
if (failureReason) {
|
||||
return (
|
||||
<Card>
|
||||
@@ -89,6 +65,16 @@ export const RepositorySnapshotsTabContent = ({ repository }: Props) => {
|
||||
);
|
||||
}
|
||||
|
||||
if (isFetching && !data.length) {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="flex items-center justify-center py-12">
|
||||
<p className="text-muted-foreground">Loading snapshots</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
if (!data.length) {
|
||||
return (
|
||||
<Card>
|
||||
@@ -97,7 +83,7 @@ export const RepositorySnapshotsTabContent = ({ repository }: Props) => {
|
||||
<div className="absolute inset-0 animate-pulse">
|
||||
<div className="w-32 h-32 rounded-full bg-primary/10 blur-2xl" />
|
||||
</div>
|
||||
<div className="relative flex items-center justify-center w-32 h-32 rounded-full bg-gradient-to-br from-primary/20 to-primary/5 border-2 border-primary/20">
|
||||
<div className="relative flex items-center justify-center w-32 h-32 rounded-full bg-linear-to-br from-primary/20 to-primary/5 border-2 border-primary/20">
|
||||
<Database className="w-16 h-16 text-primary/70" strokeWidth={1.5} />
|
||||
</div>
|
||||
</div>
|
||||
@@ -124,7 +110,7 @@ export const RepositorySnapshotsTabContent = ({ repository }: Props) => {
|
||||
</div>
|
||||
<div className="flex gap-2 items-center">
|
||||
<Input
|
||||
className="w-full lg:w-[240px]"
|
||||
className="w-full lg:w-60"
|
||||
placeholder="Search snapshots..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
@@ -156,18 +142,6 @@ export const RepositorySnapshotsTabContent = ({ repository }: Props) => {
|
||||
? "No snapshots match filters."
|
||||
: `Showing ${filteredSnapshots.length} of ${data.length}`}
|
||||
</span>
|
||||
{!hasNoFilteredSnapshots && (
|
||||
<span>
|
||||
Total size:
|
||||
<span className="text-strong-accent font-medium">
|
||||
<ByteSize
|
||||
bytes={filteredSnapshots.reduce((sum, s) => sum + s.size, 0)}
|
||||
base={1024}
|
||||
maximumFractionDigits={1}
|
||||
/>
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
267
app/client/modules/settings/routes/settings.tsx
Normal file
267
app/client/modules/settings/routes/settings.tsx
Normal file
@@ -0,0 +1,267 @@
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { Download, KeyRound, User } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { useNavigate } from "react-router";
|
||||
import { toast } from "sonner";
|
||||
import { Button } from "~/client/components/ui/button";
|
||||
import { Card, CardContent, CardDescription, CardTitle } from "~/client/components/ui/card";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "~/client/components/ui/dialog";
|
||||
import { Input } from "~/client/components/ui/input";
|
||||
import { Label } from "~/client/components/ui/label";
|
||||
import { appContext } from "~/context";
|
||||
import type { Route } from "./+types/settings";
|
||||
import {
|
||||
changePasswordMutation,
|
||||
downloadResticPasswordMutation,
|
||||
logoutMutation,
|
||||
} from "~/client/api-client/@tanstack/react-query.gen";
|
||||
|
||||
export const handle = {
|
||||
breadcrumb: () => [{ label: "Settings" }],
|
||||
};
|
||||
|
||||
export function meta(_: Route.MetaArgs) {
|
||||
return [
|
||||
{ title: "Settings" },
|
||||
{
|
||||
name: "description",
|
||||
content: "Manage your account settings and preferences.",
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
export async function clientLoader({ context }: Route.LoaderArgs) {
|
||||
const ctx = context.get(appContext);
|
||||
return ctx;
|
||||
}
|
||||
|
||||
export default function Settings({ loaderData }: Route.ComponentProps) {
|
||||
const [currentPassword, setCurrentPassword] = useState("");
|
||||
const [newPassword, setNewPassword] = useState("");
|
||||
const [confirmPassword, setConfirmPassword] = useState("");
|
||||
const [downloadDialogOpen, setDownloadDialogOpen] = useState(false);
|
||||
const [downloadPassword, setDownloadPassword] = useState("");
|
||||
const navigate = useNavigate();
|
||||
|
||||
const logout = useMutation({
|
||||
...logoutMutation(),
|
||||
onSuccess: () => {
|
||||
navigate("/login", { replace: true });
|
||||
},
|
||||
});
|
||||
|
||||
const changePassword = useMutation({
|
||||
...changePasswordMutation(),
|
||||
onSuccess: (data) => {
|
||||
if (data.success) {
|
||||
toast.success("Password changed successfully. You will be logged out.");
|
||||
setTimeout(() => {
|
||||
logout.mutate({});
|
||||
}, 1500);
|
||||
} else {
|
||||
toast.error("Failed to change password", { description: data.message });
|
||||
}
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error("Failed to change password", { description: error.message });
|
||||
},
|
||||
});
|
||||
|
||||
const downloadResticPassword = useMutation({
|
||||
...downloadResticPasswordMutation(),
|
||||
onSuccess: (data) => {
|
||||
const blob = new Blob([data], { type: "text/plain" });
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = "restic.pass";
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
window.URL.revokeObjectURL(url);
|
||||
|
||||
toast.success("Restic password file downloaded successfully");
|
||||
setDownloadDialogOpen(false);
|
||||
setDownloadPassword("");
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error("Failed to download Restic password", { description: error.message });
|
||||
},
|
||||
});
|
||||
|
||||
const handleChangePassword = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (newPassword !== confirmPassword) {
|
||||
toast.error("Passwords do not match");
|
||||
return;
|
||||
}
|
||||
|
||||
if (newPassword.length < 8) {
|
||||
toast.error("Password must be at least 8 characters long");
|
||||
return;
|
||||
}
|
||||
|
||||
changePassword.mutate({
|
||||
body: {
|
||||
currentPassword,
|
||||
newPassword,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleDownloadResticPassword = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!downloadPassword) {
|
||||
toast.error("Password is required");
|
||||
return;
|
||||
}
|
||||
|
||||
downloadResticPassword.mutate({
|
||||
body: {
|
||||
password: downloadPassword,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className="p-0 gap-0">
|
||||
<div className="border-b border-border/50 bg-card-header p-6">
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<User className="size-5" />
|
||||
Account Information
|
||||
</CardTitle>
|
||||
<CardDescription className="mt-1.5">Your account details</CardDescription>
|
||||
</div>
|
||||
<CardContent className="p-6 space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label>Username</Label>
|
||||
<Input value={loaderData.user?.username || ""} disabled className="max-w-md" />
|
||||
</div>
|
||||
</CardContent>
|
||||
|
||||
<div className="border-t border-border/50 bg-card-header p-6">
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<KeyRound className="size-5" />
|
||||
Change Password
|
||||
</CardTitle>
|
||||
<CardDescription className="mt-1.5">Update your password to keep your account secure</CardDescription>
|
||||
</div>
|
||||
<CardContent className="p-6">
|
||||
<form onSubmit={handleChangePassword} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="current-password">Current Password</Label>
|
||||
<Input
|
||||
id="current-password"
|
||||
type="password"
|
||||
value={currentPassword}
|
||||
onChange={(e) => setCurrentPassword(e.target.value)}
|
||||
className="max-w-md"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="new-password">New Password</Label>
|
||||
<Input
|
||||
id="new-password"
|
||||
type="password"
|
||||
value={newPassword}
|
||||
onChange={(e) => setNewPassword(e.target.value)}
|
||||
className="max-w-md"
|
||||
required
|
||||
minLength={8}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">Must be at least 8 characters long</p>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="confirm-password">Confirm New Password</Label>
|
||||
<Input
|
||||
id="confirm-password"
|
||||
type="password"
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
className="max-w-md"
|
||||
required
|
||||
minLength={8}
|
||||
/>
|
||||
</div>
|
||||
<Button type="submit" loading={changePassword.isPending} className="mt-4">
|
||||
Change Password
|
||||
</Button>
|
||||
</form>
|
||||
</CardContent>
|
||||
|
||||
<div className="border-t border-border/50 bg-card-header p-6">
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Download className="size-5" />
|
||||
Backup Recovery Key
|
||||
</CardTitle>
|
||||
<CardDescription className="mt-1.5">Download your Restic password file for disaster recovery</CardDescription>
|
||||
</div>
|
||||
<CardContent className="p-6 space-y-4">
|
||||
<p className="text-sm text-muted-foreground max-w-2xl">
|
||||
This file contains the encryption password used by Restic to secure your backups. Store it in a safe place
|
||||
(like a password manager or encrypted storage). If you lose access to this server, you'll need this file to
|
||||
recover your backup data.
|
||||
</p>
|
||||
|
||||
<Dialog open={downloadDialogOpen} onOpenChange={setDownloadDialogOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="outline">
|
||||
<Download size={16} className="mr-2" />
|
||||
Download Restic Password
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<form onSubmit={handleDownloadResticPassword}>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Download Restic Password</DialogTitle>
|
||||
<DialogDescription>
|
||||
For security reasons, please enter your account password to download the Restic password file.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 py-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="download-password">Your Password</Label>
|
||||
<Input
|
||||
id="download-password"
|
||||
type="password"
|
||||
value={downloadPassword}
|
||||
onChange={(e) => setDownloadPassword(e.target.value)}
|
||||
placeholder="Enter your password"
|
||||
required
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setDownloadDialogOpen(false);
|
||||
setDownloadPassword("");
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" loading={downloadResticPassword.isPending}>
|
||||
Download
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -2,11 +2,11 @@ import { useMutation } from "@tanstack/react-query";
|
||||
import { formatDistanceToNow } from "date-fns";
|
||||
import { HeartIcon } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { healthCheckVolumeMutation, updateVolumeMutation } from "~/api-client/@tanstack/react-query.gen";
|
||||
import { OnOff } from "~/components/onoff";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "~/components/ui/card";
|
||||
import type { Volume } from "~/lib/types";
|
||||
import { healthCheckVolumeMutation, updateVolumeMutation } from "~/client/api-client/@tanstack/react-query.gen";
|
||||
import { OnOff } from "~/client/components/onoff";
|
||||
import { Button } from "~/client/components/ui/button";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "~/client/components/ui/card";
|
||||
import type { Volume } from "~/client/lib/types";
|
||||
|
||||
type Props = {
|
||||
volume: Volume;
|
||||
@@ -54,7 +54,7 @@ export const HealthchecksCard = ({ volume }: Props) => {
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex flex-col flex-1 justify-start">
|
||||
{volume.lastError && <span className="text-sm text-red-500 break-words">{volume.lastError}</span>}
|
||||
{volume.lastError && <span className="text-sm text-red-500 wrap-break-word">{volume.lastError}</span>}
|
||||
{volume.status === "mounted" && <span className="text-md text-emerald-500">Healthy</span>}
|
||||
{volume.status !== "unmounted" && (
|
||||
<span className="text-xs text-muted-foreground mb-4">Checked {timeAgo || "never"}</span>
|
||||
@@ -3,10 +3,10 @@
|
||||
import { HardDrive, Unplug } from "lucide-react";
|
||||
import * as React from "react";
|
||||
import { Label, Pie, PieChart } from "recharts";
|
||||
import { ByteSize } from "~/components/bytes-size";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
|
||||
import { type ChartConfig, ChartContainer, ChartTooltip, ChartTooltipContent } from "~/components/ui/chart";
|
||||
import type { StatFs } from "~/lib/types";
|
||||
import { ByteSize } from "~/client/components/bytes-size";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "~/client/components/ui/card";
|
||||
import { type ChartConfig, ChartContainer, ChartTooltip, ChartTooltipContent } from "~/client/components/ui/chart";
|
||||
import type { StatFs } from "~/client/lib/types";
|
||||
|
||||
type Props = {
|
||||
statfs: StatFs;
|
||||
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: "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>
|
||||
);
|
||||
}
|
||||
@@ -2,15 +2,9 @@ import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
import { useNavigate, useParams, useSearchParams } from "react-router";
|
||||
import { toast } from "sonner";
|
||||
import { useState } from "react";
|
||||
import {
|
||||
deleteVolumeMutation,
|
||||
getVolumeOptions,
|
||||
mountVolumeMutation,
|
||||
unmountVolumeMutation,
|
||||
} from "~/api-client/@tanstack/react-query.gen";
|
||||
import { StatusDot } from "~/components/status-dot";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "~/components/ui/tabs";
|
||||
import { StatusDot } from "~/client/components/status-dot";
|
||||
import { Button } from "~/client/components/ui/button";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "~/client/components/ui/tabs";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
@@ -19,28 +13,40 @@ import {
|
||||
AlertDialogDescription,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "~/components/ui/alert-dialog";
|
||||
import { VolumeIcon } from "~/components/volume-icon";
|
||||
import { parseError } from "~/lib/errors";
|
||||
import { cn } from "~/lib/utils";
|
||||
} from "~/client/components/ui/alert-dialog";
|
||||
import { VolumeIcon } from "~/client/components/volume-icon";
|
||||
import { parseError } from "~/client/lib/errors";
|
||||
import { cn } from "~/client/lib/utils";
|
||||
import type { Route } from "./+types/volume-details";
|
||||
import { getVolume } from "~/api-client";
|
||||
import { VolumeInfoTabContent } from "../tabs/info";
|
||||
import { FilesTabContent } from "../tabs/files";
|
||||
import { DockerTabContent } from "../tabs/docker";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "~/client/components/ui/tooltip";
|
||||
import { useSystemInfo } from "~/client/hooks/use-system-info";
|
||||
import { getVolume } from "~/client/api-client";
|
||||
import {
|
||||
deleteVolumeMutation,
|
||||
getVolumeOptions,
|
||||
mountVolumeMutation,
|
||||
unmountVolumeMutation,
|
||||
} 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) {
|
||||
return [
|
||||
{ title: `Ironmount - ${params.name}` },
|
||||
{ title: params.name },
|
||||
{
|
||||
name: "description",
|
||||
content: "Create, manage, monitor, and automate your Docker volumes with ease.",
|
||||
content: "View and manage volume details, configuration, and files.",
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
export const clientLoader = async ({ params }: Route.ClientLoaderArgs) => {
|
||||
const volume = await getVolume({ path: { name: params.name ?? "" } });
|
||||
const volume = await getVolume({ path: { name: params.name } });
|
||||
if (volume.data) return volume.data;
|
||||
};
|
||||
|
||||
@@ -58,6 +64,8 @@ export default function VolumeDetails({ loaderData }: Route.ComponentProps) {
|
||||
refetchOnWindowFocus: true,
|
||||
});
|
||||
|
||||
const { capabilities } = useSystemInfo();
|
||||
|
||||
const deleteVol = useMutation({
|
||||
...deleteVolumeMutation(),
|
||||
onSuccess: () => {
|
||||
@@ -109,17 +117,16 @@ export default function VolumeDetails({ loaderData }: Route.ComponentProps) {
|
||||
}
|
||||
|
||||
const { volume, statfs } = data;
|
||||
const dockerAvailable = capabilities.docker;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<div className="text-sm font-semibold mb-2 text-muted-foreground flex items-center gap-2">
|
||||
<span className="flex items-center gap-2">
|
||||
<StatusDot status={volume.status} /> {volume.status[0].toUpperCase() + volume.status.slice(1)}
|
||||
</span>
|
||||
<VolumeIcon size={14} backend={volume?.config.backend} />
|
||||
</div>
|
||||
<div className="flex flex-col items-start xs:items-center xs:flex-row xs:justify-between">
|
||||
<div className="text-sm font-semibold mb-2 xs:mb-0 text-muted-foreground flex items-center gap-2">
|
||||
<span className="flex items-center gap-2">
|
||||
<StatusDot status={volume.status} /> {volume.status[0].toUpperCase() + volume.status.slice(1)}
|
||||
</span>
|
||||
<VolumeIcon size={14} backend={volume?.config.backend} />
|
||||
</div>
|
||||
<div className="flex gap-4">
|
||||
<Button
|
||||
@@ -146,7 +153,16 @@ export default function VolumeDetails({ loaderData }: Route.ComponentProps) {
|
||||
<TabsList className="mb-2">
|
||||
<TabsTrigger value="info">Configuration</TabsTrigger>
|
||||
<TabsTrigger value="files">Files</TabsTrigger>
|
||||
<TabsTrigger value="docker">Docker</TabsTrigger>
|
||||
<Tooltip>
|
||||
<TooltipTrigger>
|
||||
<TabsTrigger disabled={!dockerAvailable} value="docker">
|
||||
Docker
|
||||
</TabsTrigger>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className={cn({ hidden: dockerAvailable })}>
|
||||
<p>Enable Docker support to access this tab.</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TabsList>
|
||||
<TabsContent value="info">
|
||||
<VolumeInfoTabContent volume={volume} statfs={statfs} />
|
||||
@@ -154,9 +170,11 @@ export default function VolumeDetails({ loaderData }: Route.ComponentProps) {
|
||||
<TabsContent value="files">
|
||||
<FilesTabContent volume={volume} />
|
||||
</TabsContent>
|
||||
<TabsContent value="docker">
|
||||
<DockerTabContent volume={volume} />
|
||||
</TabsContent>
|
||||
{dockerAvailable && (
|
||||
<TabsContent value="docker">
|
||||
<DockerTabContent volume={volume} />
|
||||
</TabsContent>
|
||||
)}
|
||||
</Tabs>
|
||||
|
||||
<AlertDialog open={showDeleteConfirm} onOpenChange={setShowDeleteConfirm}>
|
||||
@@ -1,23 +1,26 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { HardDrive, RotateCcw } from "lucide-react";
|
||||
import { HardDrive, Plus, RotateCcw } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { useNavigate } from "react-router";
|
||||
import { listVolumes } from "~/api-client";
|
||||
import { listVolumesOptions } from "~/api-client/@tanstack/react-query.gen";
|
||||
import { CreateVolumeDialog } from "~/components/create-volume-dialog";
|
||||
import { EmptyState } from "~/components/empty-state";
|
||||
import { StatusDot } from "~/components/status-dot";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { Card } from "~/components/ui/card";
|
||||
import { Input } from "~/components/ui/input";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "~/components/ui/select";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "~/components/ui/table";
|
||||
import { VolumeIcon } from "~/components/volume-icon";
|
||||
import { EmptyState } from "~/client/components/empty-state";
|
||||
import { StatusDot } from "~/client/components/status-dot";
|
||||
import { Button } from "~/client/components/ui/button";
|
||||
import { Card } from "~/client/components/ui/card";
|
||||
import { Input } from "~/client/components/ui/input";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "~/client/components/ui/select";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "~/client/components/ui/table";
|
||||
import { VolumeIcon } from "~/client/components/volume-icon";
|
||||
import type { Route } from "./+types/volumes";
|
||||
import { listVolumes } from "~/client/api-client";
|
||||
import { listVolumesOptions } from "~/client/api-client/@tanstack/react-query.gen";
|
||||
|
||||
export const handle = {
|
||||
breadcrumb: () => [{ label: "Volumes" }],
|
||||
};
|
||||
|
||||
export function meta(_: Route.MetaArgs) {
|
||||
return [
|
||||
{ title: "Ironmount" },
|
||||
{ title: "Volumes" },
|
||||
{
|
||||
name: "description",
|
||||
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) {
|
||||
const [createVolumeOpen, setCreateVolumeOpen] = useState(false);
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [statusFilter, setStatusFilter] = useState("");
|
||||
const [backendFilter, setBackendFilter] = useState("");
|
||||
@@ -69,7 +71,12 @@ export default function Volumes({ loaderData }: Route.ComponentProps) {
|
||||
icon={HardDrive}
|
||||
title="No volume"
|
||||
description="Manage and monitor all your storage backends in one place with advanced features like automatic mounting and health checks."
|
||||
button={<CreateVolumeDialog open={createVolumeOpen} setOpen={setCreateVolumeOpen} />}
|
||||
button={
|
||||
<Button onClick={() => navigate("/volumes/create")}>
|
||||
<Plus size={16} className="mr-2" />
|
||||
Create Volume
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -79,13 +86,13 @@ export default function Volumes({ loaderData }: Route.ComponentProps) {
|
||||
<div className="flex flex-col lg:flex-row items-stretch lg:items-center gap-2 md:justify-between p-4 bg-card-header py-4">
|
||||
<span className="flex flex-col sm:flex-row items-stretch md:items-center gap-0 flex-wrap ">
|
||||
<Input
|
||||
className="w-full lg:w-[180px] min-w-[180px] mr-[-1px] mt-[-1px]"
|
||||
className="w-full lg:w-[180px] min-w-[180px] -mr-px -mt-px"
|
||||
placeholder="Search volumes…"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
/>
|
||||
<Select value={statusFilter} onValueChange={setStatusFilter}>
|
||||
<SelectTrigger className="w-full lg:w-[180px] min-w-[180px] mr-[-1px] mt-[-1px]">
|
||||
<SelectTrigger className="w-full lg:w-[180px] min-w-[180px] -mr-px -mt-px">
|
||||
<SelectValue placeholder="All status" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
@@ -95,7 +102,7 @@ export default function Volumes({ loaderData }: Route.ComponentProps) {
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select value={backendFilter} onValueChange={setBackendFilter}>
|
||||
<SelectTrigger className="w-full lg:w-[180px] min-w-[180px] mt-[-1px]">
|
||||
<SelectTrigger className="w-full lg:w-[180px] min-w-[180px] -mt-px">
|
||||
<SelectValue placeholder="All backends" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
@@ -111,7 +118,10 @@ export default function Volumes({ loaderData }: Route.ComponentProps) {
|
||||
</Button>
|
||||
)}
|
||||
</span>
|
||||
<CreateVolumeDialog open={createVolumeOpen} setOpen={setCreateVolumeOpen} />
|
||||
<Button onClick={() => navigate("/volumes/create")}>
|
||||
<Plus size={16} className="mr-2" />
|
||||
Create Volume
|
||||
</Button>
|
||||
</div>
|
||||
<div className="overflow-x-auto">
|
||||
<Table className="border-t">
|
||||
@@ -1,11 +1,11 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Unplug } from "lucide-react";
|
||||
import * as YML from "yaml";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "~/components/ui/card";
|
||||
import { CodeBlock } from "~/components/ui/code-block";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "~/components/ui/table";
|
||||
import type { Volume } from "~/lib/types";
|
||||
import { getContainersUsingVolumeOptions } from "../../../api-client/@tanstack/react-query.gen";
|
||||
import { getContainersUsingVolumeOptions } from "~/client/api-client/@tanstack/react-query.gen";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "~/client/components/ui/card";
|
||||
import { CodeBlock } from "~/client/components/ui/code-block";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "~/client/components/ui/table";
|
||||
import type { Volume } from "~/client/lib/types";
|
||||
|
||||
type Props = {
|
||||
volume: Volume;
|
||||
@@ -52,7 +52,7 @@ export const DockerTabContent = ({ volume }: Props) => {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="grid gap-4 xl:grid-cols-[minmax(0,_1fr)_minmax(0,_1fr)]">
|
||||
<div className="grid gap-4 xl:grid-cols-[minmax(0,1fr)_minmax(0,1fr)]">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Plug-and-play Docker integration</CardTitle>
|
||||
@@ -1,7 +1,7 @@
|
||||
import { FolderOpen } from "lucide-react";
|
||||
import { VolumeFileBrowser } from "~/components/volume-file-browser";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "~/components/ui/card";
|
||||
import type { Volume } from "~/lib/types";
|
||||
import { VolumeFileBrowser } from "~/client/components/volume-file-browser";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "~/client/components/ui/card";
|
||||
import type { Volume } from "~/client/lib/types";
|
||||
|
||||
type Props = {
|
||||
volume: Volume;
|
||||
@@ -30,7 +30,6 @@ export const FilesTabContent = ({ volume }: Props) => {
|
||||
<VolumeFileBrowser
|
||||
volumeName={volume.name}
|
||||
enabled={volume.status === "mounted"}
|
||||
refetchInterval={10000}
|
||||
className="overflow-auto flex-1 border rounded-md bg-card p-2"
|
||||
emptyMessage="This volume is empty."
|
||||
emptyDescription="Files and folders will appear here once you add them."
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user