Merge branch 'main' into feat/snapshot-source-info
This commit is contained in:
commit
fb523aae44
183 changed files with 13797 additions and 5576 deletions
10
.github/workflows/nightly.yml
vendored
10
.github/workflows/nightly.yml
vendored
|
|
@ -17,20 +17,20 @@ jobs:
|
||||||
uses: actions/checkout@v6
|
uses: actions/checkout@v6
|
||||||
|
|
||||||
- name: Log in to Docker Hub
|
- name: Log in to Docker Hub
|
||||||
uses: docker/login-action@v3
|
uses: docker/login-action@v4
|
||||||
with:
|
with:
|
||||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||||
|
|
||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
uses: docker/setup-buildx-action@v3
|
uses: docker/setup-buildx-action@v4
|
||||||
with:
|
with:
|
||||||
driver: cloud
|
driver: cloud
|
||||||
endpoint: "meienberger/runtipi-builder"
|
endpoint: "meienberger/runtipi-builder"
|
||||||
install: true
|
install: true
|
||||||
|
|
||||||
- name: Login to GitHub Container Registry
|
- name: Login to GitHub Container Registry
|
||||||
uses: docker/login-action@v3
|
uses: docker/login-action@v4
|
||||||
with:
|
with:
|
||||||
registry: ghcr.io
|
registry: ghcr.io
|
||||||
username: ${{ github.repository_owner }}
|
username: ${{ github.repository_owner }}
|
||||||
|
|
@ -38,14 +38,14 @@ jobs:
|
||||||
|
|
||||||
- name: Docker meta
|
- name: Docker meta
|
||||||
id: meta
|
id: meta
|
||||||
uses: docker/metadata-action@v5
|
uses: docker/metadata-action@v6
|
||||||
with:
|
with:
|
||||||
images: ghcr.io/${{ github.repository_owner }}/zerobyte
|
images: ghcr.io/${{ github.repository_owner }}/zerobyte
|
||||||
tags: |
|
tags: |
|
||||||
type=raw,value=nightly
|
type=raw,value=nightly
|
||||||
|
|
||||||
- name: Push images to GitHub Container Registry
|
- name: Push images to GitHub Container Registry
|
||||||
uses: docker/build-push-action@v6
|
uses: docker/build-push-action@v7
|
||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
target: production
|
target: production
|
||||||
|
|
|
||||||
12
.github/workflows/release.yml
vendored
12
.github/workflows/release.yml
vendored
|
|
@ -49,27 +49,27 @@ jobs:
|
||||||
ref: ${{ github.ref }}
|
ref: ${{ github.ref }}
|
||||||
|
|
||||||
- name: Log in to Docker Hub
|
- name: Log in to Docker Hub
|
||||||
uses: docker/login-action@v3
|
uses: docker/login-action@v4
|
||||||
with:
|
with:
|
||||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||||
|
|
||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
uses: docker/setup-buildx-action@v3
|
uses: docker/setup-buildx-action@v4
|
||||||
with:
|
with:
|
||||||
driver: cloud
|
driver: cloud
|
||||||
endpoint: "meienberger/runtipi-builder"
|
endpoint: "meienberger/runtipi-builder"
|
||||||
install: true
|
install: true
|
||||||
|
|
||||||
- name: Login to GitHub Container Registry
|
- name: Login to GitHub Container Registry
|
||||||
uses: docker/login-action@v3
|
uses: docker/login-action@v4
|
||||||
with:
|
with:
|
||||||
registry: ghcr.io
|
registry: ghcr.io
|
||||||
username: ${{ github.repository_owner }}
|
username: ${{ github.repository_owner }}
|
||||||
password: ${{ secrets.GITHUB_TOKEN }}
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
- name: Build docker image
|
- name: Build docker image
|
||||||
uses: docker/build-push-action@v6
|
uses: docker/build-push-action@v7
|
||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
target: production
|
target: production
|
||||||
|
|
@ -98,7 +98,7 @@ jobs:
|
||||||
|
|
||||||
- name: Docker meta
|
- name: Docker meta
|
||||||
id: meta
|
id: meta
|
||||||
uses: docker/metadata-action@v5
|
uses: docker/metadata-action@v6
|
||||||
with:
|
with:
|
||||||
images: ghcr.io/${{ github.repository_owner }}/zerobyte
|
images: ghcr.io/${{ github.repository_owner }}/zerobyte
|
||||||
tags: |
|
tags: |
|
||||||
|
|
@ -110,7 +110,7 @@ jobs:
|
||||||
latest=${{ needs.determine-release-type.outputs.release_type == 'release' }}
|
latest=${{ needs.determine-release-type.outputs.release_type == 'release' }}
|
||||||
|
|
||||||
- name: Push images to GitHub Container Registry
|
- name: Push images to GitHub Container Registry
|
||||||
uses: docker/build-push-action@v6
|
uses: docker/build-push-action@v7
|
||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
target: production
|
target: production
|
||||||
|
|
|
||||||
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -41,3 +41,4 @@ openapi-ts-error-*.log
|
||||||
tmp/
|
tmp/
|
||||||
qa-output
|
qa-output
|
||||||
|
|
||||||
|
.worktrees/
|
||||||
|
|
|
||||||
|
|
@ -50,6 +50,7 @@
|
||||||
"no-unused-vars": [
|
"no-unused-vars": [
|
||||||
"warn",
|
"warn",
|
||||||
{
|
{
|
||||||
|
"varsIgnorePattern": "^_",
|
||||||
"caughtErrorsIgnorePattern": "^_",
|
"caughtErrorsIgnorePattern": "^_",
|
||||||
"argsIgnorePattern": "^_"
|
"argsIgnorePattern": "^_"
|
||||||
}
|
}
|
||||||
|
|
@ -114,6 +115,7 @@
|
||||||
"unicorn/prefer-set-size": "warn",
|
"unicorn/prefer-set-size": "warn",
|
||||||
"unicorn/prefer-string-starts-ends-with": "warn",
|
"unicorn/prefer-string-starts-ends-with": "warn",
|
||||||
"import/no-cycle": "error",
|
"import/no-cycle": "error",
|
||||||
|
"import/no-unused-modules": ["error", { "unusedExports": true }],
|
||||||
"eslint/no-console": ["warn", { "allow": ["warn", "error", "info"] }]
|
"eslint/no-console": ["warn", { "allow": ["warn", "error", "info"] }]
|
||||||
},
|
},
|
||||||
"settings": {
|
"settings": {
|
||||||
|
|
|
||||||
3
.vscode/settings.json
vendored
Normal file
3
.vscode/settings.json
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
{
|
||||||
|
"editor.defaultFormatter": "oxc.oxc-vscode"
|
||||||
|
}
|
||||||
|
|
@ -12,7 +12,7 @@ ENV VITE_RESTIC_VERSION=${RESTIC_VERSION} \
|
||||||
|
|
||||||
RUN apk update --no-cache && \
|
RUN apk update --no-cache && \
|
||||||
apk upgrade --no-cache && \
|
apk upgrade --no-cache && \
|
||||||
apk add --no-cache davfs2=1.6.1-r2 openssh-client fuse3 sshfs tini
|
apk add --no-cache davfs2=1.6.1-r2 openssh-client fuse3 sshfs tini tzdata
|
||||||
|
|
||||||
ENTRYPOINT ["/sbin/tini", "-s", "--"]
|
ENTRYPOINT ["/sbin/tini", "-s", "--"]
|
||||||
|
|
||||||
|
|
|
||||||
18
README.md
18
README.md
|
|
@ -40,7 +40,7 @@ In order to run Zerobyte, you need to have Docker and Docker Compose installed o
|
||||||
```yaml
|
```yaml
|
||||||
services:
|
services:
|
||||||
zerobyte:
|
zerobyte:
|
||||||
image: ghcr.io/nicotsx/zerobyte:v0.29
|
image: ghcr.io/nicotsx/zerobyte:v0.30
|
||||||
container_name: zerobyte
|
container_name: zerobyte
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
cap_add:
|
cap_add:
|
||||||
|
|
@ -50,7 +50,7 @@ services:
|
||||||
devices:
|
devices:
|
||||||
- /dev/fuse:/dev/fuse
|
- /dev/fuse:/dev/fuse
|
||||||
environment:
|
environment:
|
||||||
- TZ=Europe/Paris # Set your timezone here
|
- TZ=Europe/Zurich # Set your timezone here
|
||||||
- BASE_URL=http://localhost:4096 # URL you will use to access Zerobyte
|
- BASE_URL=http://localhost:4096 # URL you will use to access Zerobyte
|
||||||
- APP_SECRET=94bad46...c66e25d5c2b # Generate your own secret with `openssl rand -hex 32`
|
- APP_SECRET=94bad46...c66e25d5c2b # Generate your own secret with `openssl rand -hex 32`
|
||||||
volumes:
|
volumes:
|
||||||
|
|
@ -95,7 +95,7 @@ Zerobyte can be customized using environment variables. Below are the available
|
||||||
| `APP_SECRET` | **Required.** A random secret key (32+ chars) used to encrypt sensitive data in the database. Generate with `openssl rand -hex 32`. | (none) |
|
| `APP_SECRET` | **Required.** A random secret key (32+ chars) used to encrypt sensitive data in the database. Generate with `openssl rand -hex 32`. | (none) |
|
||||||
| `PORT` | The port the web interface and API will listen on. | `4096` |
|
| `PORT` | The port the web interface and API will listen on. | `4096` |
|
||||||
| `RESTIC_HOSTNAME` | The hostname used by Restic when creating snapshots. Automatically detected if a custom hostname is set in Docker. | `zerobyte` |
|
| `RESTIC_HOSTNAME` | The hostname used by Restic when creating snapshots. Automatically detected if a custom hostname is set in Docker. | `zerobyte` |
|
||||||
| `TZ` | Timezone for the container (e.g., `Europe/Paris`). **Crucial for accurate backup scheduling.** | `UTC` |
|
| `TZ` | Timezone for the container (e.g., `Europe/Zurich`). **Crucial for accurate backup scheduling.** | `UTC` |
|
||||||
| `TRUSTED_ORIGINS` | Comma-separated list of extra trusted origins for CORS (e.g., `http://localhost:3000,http://example.com`). | (none) |
|
| `TRUSTED_ORIGINS` | Comma-separated list of extra trusted origins for CORS (e.g., `http://localhost:3000,http://example.com`). | (none) |
|
||||||
| `LOG_LEVEL` | Logging verbosity. Options: `debug`, `info`, `warn`, `error`. | `info` |
|
| `LOG_LEVEL` | Logging verbosity. Options: `debug`, `info`, `warn`, `error`. | `info` |
|
||||||
| `SERVER_IDLE_TIMEOUT` | Idle timeout for the server in seconds. | `60` |
|
| `SERVER_IDLE_TIMEOUT` | Idle timeout for the server in seconds. | `60` |
|
||||||
|
|
@ -118,13 +118,13 @@ If you only need to back up locally mounted folders and don't require remote sha
|
||||||
```yaml
|
```yaml
|
||||||
services:
|
services:
|
||||||
zerobyte:
|
zerobyte:
|
||||||
image: ghcr.io/nicotsx/zerobyte:v0.29
|
image: ghcr.io/nicotsx/zerobyte:v0.30
|
||||||
container_name: zerobyte
|
container_name: zerobyte
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
ports:
|
ports:
|
||||||
- "4096:4096"
|
- "4096:4096"
|
||||||
environment:
|
environment:
|
||||||
- TZ=Europe/Paris # Set your timezone here
|
- TZ=Europe/Zurich # Set your timezone here
|
||||||
- BASE_URL=http://localhost:4096 # Change this to your actual URL (use https:// for secure cookies)
|
- BASE_URL=http://localhost:4096 # Change this to your actual URL (use https:// for secure cookies)
|
||||||
- APP_SECRET=94bad46...c66e25d5c2b # Generate your own secret with `openssl rand -hex 32`
|
- APP_SECRET=94bad46...c66e25d5c2b # Generate your own secret with `openssl rand -hex 32`
|
||||||
volumes:
|
volumes:
|
||||||
|
|
@ -157,7 +157,7 @@ If you want to track a local directory on the same server where Zerobyte is runn
|
||||||
```diff
|
```diff
|
||||||
services:
|
services:
|
||||||
zerobyte:
|
zerobyte:
|
||||||
image: ghcr.io/nicotsx/zerobyte:v0.29
|
image: ghcr.io/nicotsx/zerobyte:v0.30
|
||||||
container_name: zerobyte
|
container_name: zerobyte
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
cap_add:
|
cap_add:
|
||||||
|
|
@ -167,7 +167,7 @@ services:
|
||||||
devices:
|
devices:
|
||||||
- /dev/fuse:/dev/fuse
|
- /dev/fuse:/dev/fuse
|
||||||
environment:
|
environment:
|
||||||
- TZ=Europe/Paris
|
- TZ=Europe/Zurich
|
||||||
- BASE_URL=http://localhost:4096 # URL you will use to access Zerobyte
|
- BASE_URL=http://localhost:4096 # URL you will use to access Zerobyte
|
||||||
- APP_SECRET=94bad46...c66e25d5c2b # Generate your own secret with `openssl rand -hex 32`
|
- APP_SECRET=94bad46...c66e25d5c2b # Generate your own secret with `openssl rand -hex 32`
|
||||||
volumes:
|
volumes:
|
||||||
|
|
@ -232,7 +232,7 @@ Zerobyte can use [rclone](https://rclone.org/) to support 40+ cloud storage prov
|
||||||
```diff
|
```diff
|
||||||
services:
|
services:
|
||||||
zerobyte:
|
zerobyte:
|
||||||
image: ghcr.io/nicotsx/zerobyte:v0.29
|
image: ghcr.io/nicotsx/zerobyte:v0.30
|
||||||
container_name: zerobyte
|
container_name: zerobyte
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
cap_add:
|
cap_add:
|
||||||
|
|
@ -242,7 +242,7 @@ Zerobyte can use [rclone](https://rclone.org/) to support 40+ cloud storage prov
|
||||||
devices:
|
devices:
|
||||||
- /dev/fuse:/dev/fuse
|
- /dev/fuse:/dev/fuse
|
||||||
environment:
|
environment:
|
||||||
- TZ=Europe/Paris
|
- TZ=Europe/Zurich
|
||||||
- BASE_URL=http://localhost:4096 # URL you will use to access Zerobyte
|
- BASE_URL=http://localhost:4096 # URL you will use to access Zerobyte
|
||||||
volumes:
|
volumes:
|
||||||
- /etc/localtime:/etc/localtime:ro
|
- /etc/localtime:/etc/localtime:ro
|
||||||
|
|
|
||||||
90
app/app.css
90
app/app.css
|
|
@ -37,7 +37,6 @@ body {
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
@apply bg-[#131313];
|
|
||||||
min-height: 100dvh;
|
min-height: 100dvh;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -67,6 +66,8 @@ body {
|
||||||
--color-accent: var(--accent);
|
--color-accent: var(--accent);
|
||||||
--color-accent-foreground: var(--accent-foreground);
|
--color-accent-foreground: var(--accent-foreground);
|
||||||
--color-destructive: var(--destructive);
|
--color-destructive: var(--destructive);
|
||||||
|
--color-destructive-foreground: var(--destructive-foreground);
|
||||||
|
--color-success: var(--success);
|
||||||
--color-border: var(--border);
|
--color-border: var(--border);
|
||||||
--color-input: var(--input);
|
--color-input: var(--input);
|
||||||
--color-ring: var(--ring);
|
--color-ring: var(--ring);
|
||||||
|
|
@ -88,38 +89,40 @@ body {
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
--radius: 0.625rem;
|
--radius: 0.625rem;
|
||||||
--background: oklch(1 0 0);
|
--background: oklch(0.98 0.002 250);
|
||||||
--foreground: oklch(0.145 0 0);
|
--foreground: oklch(0.20 0.01 260);
|
||||||
--card: oklch(1 0 0);
|
--card: #ffffff;
|
||||||
--card-foreground: oklch(0.145 0 0);
|
--card-foreground: oklch(0.20 0.01 260);
|
||||||
--card-header: oklch(0.922 0 0);
|
--card-header: oklch(0.99 0.001 260);
|
||||||
--popover: oklch(1 0 0);
|
--popover: #ffffff;
|
||||||
--popover-foreground: oklch(0.145 0 0);
|
--popover-foreground: oklch(0.20 0.01 260);
|
||||||
--primary: oklch(0.205 0 0);
|
--primary: oklch(0.20 0.01 260);
|
||||||
--primary-foreground: oklch(0.985 0 0);
|
--primary-foreground: #ffffff;
|
||||||
--secondary: oklch(0.97 0 0);
|
--secondary: oklch(0.96 0.005 260);
|
||||||
--secondary-foreground: oklch(0.205 0 0);
|
--secondary-foreground: oklch(0.20 0.01 260);
|
||||||
--muted: oklch(0.97 0 0);
|
--muted: oklch(0.95 0.005 260);
|
||||||
--muted-foreground: oklch(0.556 0 0);
|
--muted-foreground: oklch(0.45 0.01 260);
|
||||||
--accent: oklch(0.97 0 0);
|
--accent: oklch(0.96 0.005 260);
|
||||||
--accent-foreground: oklch(0.205 0 0);
|
--accent-foreground: oklch(0.20 0.01 260);
|
||||||
--destructive: oklch(0.577 0.245 27.325);
|
--destructive: oklch(0.577 0.245 27.325);
|
||||||
--border: oklch(0.922 0 0);
|
--destructive-foreground: #fff;
|
||||||
--input: oklch(0.922 0 0);
|
--success: oklch(0.57 0.16 148);
|
||||||
--ring: oklch(0.708 0 0);
|
--border: oklch(0.90 0.01 260);
|
||||||
--chart-1: oklch(0.646 0.222 41.116);
|
--input: oklch(0.90 0.01 260);
|
||||||
--chart-2: oklch(0.6 0.118 184.704);
|
--ring: oklch(0.7 0.01 260);
|
||||||
--chart-3: oklch(0.398 0.07 227.392);
|
--chart-1: oklch(0.55 0.22 260);
|
||||||
--chart-4: oklch(0.828 0.189 84.429);
|
--chart-2: oklch(0.6 0.15 200);
|
||||||
--chart-5: oklch(0.769 0.188 70.08);
|
--chart-3: oklch(0.45 0.12 280);
|
||||||
--sidebar: oklch(0.985 0 0);
|
--chart-4: oklch(0.7 0.15 190);
|
||||||
--sidebar-foreground: oklch(0.145 0 0);
|
--chart-5: oklch(0.55 0.18 300);
|
||||||
--sidebar-primary: oklch(0.205 0 0);
|
--sidebar: #ffffff;
|
||||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
--sidebar-foreground: oklch(0.35 0.01 260);
|
||||||
--sidebar-accent: oklch(0.97 0 0);
|
--sidebar-primary: oklch(0.20 0.01 260);
|
||||||
--sidebar-accent-foreground: oklch(0.205 0 0);
|
--sidebar-primary-foreground: #ffffff;
|
||||||
--sidebar-border: oklch(0.922 0 0);
|
--sidebar-accent: oklch(0.97 0.005 260);
|
||||||
--sidebar-ring: oklch(0.708 0 0);
|
--sidebar-accent-foreground: oklch(0.20 0.01 260);
|
||||||
|
--sidebar-border: oklch(0.90 0.01 260);
|
||||||
|
--sidebar-ring: oklch(0.7 0.01 260);
|
||||||
--strong-accent: #ff543a;
|
--strong-accent: #ff543a;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -144,6 +147,8 @@ body {
|
||||||
--accent: oklch(0.269 0 0);
|
--accent: oklch(0.269 0 0);
|
||||||
--accent-foreground: oklch(0.985 0 0);
|
--accent-foreground: oklch(0.985 0 0);
|
||||||
--destructive: #ff543a;
|
--destructive: #ff543a;
|
||||||
|
--destructive-foreground: #fff;
|
||||||
|
--success: oklch(0.696 0.17 162.48);
|
||||||
--border: oklch(1 0 0 / 10%);
|
--border: oklch(1 0 0 / 10%);
|
||||||
--input: oklch(1 0 0 / 15%);
|
--input: oklch(1 0 0 / 15%);
|
||||||
--ring: oklch(0.556 0 0);
|
--ring: oklch(0.556 0 0);
|
||||||
|
|
@ -174,11 +179,11 @@ body {
|
||||||
|
|
||||||
@layer base {
|
@layer base {
|
||||||
:root {
|
:root {
|
||||||
--chart-1: oklch(0.646 0.222 41.116);
|
--chart-1: oklch(0.55 0.22 260);
|
||||||
--chart-2: oklch(0.6 0.118 184.704);
|
--chart-2: oklch(0.6 0.15 200);
|
||||||
--chart-3: oklch(0.398 0.07 227.392);
|
--chart-3: oklch(0.45 0.12 280);
|
||||||
--chart-4: oklch(0.828 0.189 84.429);
|
--chart-4: oklch(0.7 0.15 190);
|
||||||
--chart-5: oklch(0.769 0.188 70.08);
|
--chart-5: oklch(0.55 0.18 300);
|
||||||
}
|
}
|
||||||
|
|
||||||
.dark {
|
.dark {
|
||||||
|
|
@ -190,6 +195,17 @@ body {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.snapshot-scrollable::-webkit-scrollbar {
|
||||||
|
height: 6px;
|
||||||
|
}
|
||||||
|
.snapshot-scrollable::-webkit-scrollbar-track {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
.snapshot-scrollable::-webkit-scrollbar-thumb {
|
||||||
|
background: var(--border);
|
||||||
|
border-radius: 999px;
|
||||||
|
}
|
||||||
|
|
||||||
/* Hide built-in password reveal/clear controls (notably in Edge on Windows) */
|
/* Hide built-in password reveal/clear controls (notably in Edge on Windows) */
|
||||||
[data-secret-input] input[type="password"]::-ms-reveal,
|
[data-secret-input] input[type="password"]::-ms-reveal,
|
||||||
[data-secret-input] input[type="password"]::-ms-clear {
|
[data-secret-input] input[type="password"]::-ms-clear {
|
||||||
|
|
|
||||||
|
|
@ -4,8 +4,8 @@
|
||||||
import { type DefaultError, type InfiniteData, infiniteQueryOptions, queryOptions, type UseMutationOptions } from '@tanstack/react-query';
|
import { type DefaultError, type InfiniteData, infiniteQueryOptions, queryOptions, type UseMutationOptions } from '@tanstack/react-query';
|
||||||
|
|
||||||
import { client } from '../client.gen';
|
import { client } from '../client.gen';
|
||||||
import { browseFilesystem, cancelDoctor, createBackupSchedule, createNotificationDestination, createRepository, createVolume, deleteBackupSchedule, deleteNotificationDestination, deleteRepository, deleteSnapshot, deleteSnapshots, deleteSsoInvitation, deleteSsoProvider, deleteUserAccount, deleteVolume, devPanelExec, downloadResticPassword, dumpSnapshot, getAdminUsers, getBackupProgress, getBackupSchedule, getBackupScheduleForVolume, getDevPanel, getMirrorCompatibility, getNotificationDestination, getOrgMembers, getPublicSsoProviders, getRegistrationStatus, getRepository, getRepositoryStats, getScheduleMirrors, getScheduleNotifications, getSnapshotDetails, getSsoSettings, getStatus, getSystemInfo, getUpdates, getUserDeletionImpact, getVolume, healthCheckVolume, listBackupSchedules, listFiles, listNotificationDestinations, listRcloneRemotes, listRepositories, listSnapshotFiles, listSnapshots, listVolumes, mountVolume, type Options, refreshSnapshots, removeOrgMember, reorderBackupSchedules, restoreSnapshot, runBackupNow, runForget, setRegistrationStatus, startDoctor, stopBackup, tagSnapshots, testConnection, testNotificationDestination, unlockRepository, unmountVolume, updateBackupSchedule, updateMemberRole, updateNotificationDestination, updateRepository, updateScheduleMirrors, updateScheduleNotifications, updateSsoProviderAutoLinking, updateVolume } from '../sdk.gen';
|
import { browseFilesystem, cancelDoctor, createBackupSchedule, createNotificationDestination, createRepository, createVolume, deleteBackupSchedule, deleteNotificationDestination, deleteRepository, deleteSnapshot, deleteSnapshots, deleteSsoInvitation, deleteSsoProvider, deleteUserAccount, deleteVolume, downloadResticPassword, dumpSnapshot, getAdminUsers, getBackupProgress, getBackupSchedule, getBackupScheduleForVolume, getDevPanel, getMirrorCompatibility, getNotificationDestination, getOrgMembers, getPublicSsoProviders, getRegistrationStatus, getRepository, getRepositoryStats, getScheduleMirrors, getScheduleNotifications, getSnapshotDetails, getSsoSettings, getStatus, getSystemInfo, getUpdates, getUserDeletionImpact, getVolume, healthCheckVolume, listBackupSchedules, listFiles, listNotificationDestinations, listRcloneRemotes, listRepositories, listSnapshotFiles, listSnapshots, listVolumes, mountVolume, type Options, refreshRepositoryStats, refreshSnapshots, removeOrgMember, reorderBackupSchedules, restoreSnapshot, runBackupNow, runForget, setRegistrationStatus, startDoctor, stopBackup, tagSnapshots, testConnection, testNotificationDestination, unlockRepository, unmountVolume, updateBackupSchedule, updateMemberRole, updateNotificationDestination, updateRepository, updateScheduleMirrors, updateScheduleNotifications, updateSsoProviderAutoLinking, updateVolume } from '../sdk.gen';
|
||||||
import type { BrowseFilesystemData, BrowseFilesystemResponse, CancelDoctorData, CancelDoctorResponse, CreateBackupScheduleData, CreateBackupScheduleResponse, CreateNotificationDestinationData, CreateNotificationDestinationResponse, CreateRepositoryData, CreateRepositoryResponse, CreateVolumeData, CreateVolumeResponse, DeleteBackupScheduleData, DeleteBackupScheduleResponse, DeleteNotificationDestinationData, DeleteNotificationDestinationResponse, DeleteRepositoryData, DeleteRepositoryResponse, DeleteSnapshotData, DeleteSnapshotResponse, DeleteSnapshotsData, DeleteSnapshotsResponse, DeleteSsoInvitationData, DeleteSsoProviderData, DeleteUserAccountData, DeleteVolumeData, DeleteVolumeResponse, DevPanelExecData, DevPanelExecResponse, DownloadResticPasswordData, DownloadResticPasswordResponse, DumpSnapshotData, DumpSnapshotResponse, GetAdminUsersData, GetAdminUsersResponse, GetBackupProgressData, GetBackupProgressResponse, GetBackupScheduleData, GetBackupScheduleForVolumeData, GetBackupScheduleForVolumeResponse, GetBackupScheduleResponse, GetDevPanelData, GetDevPanelResponse, GetMirrorCompatibilityData, GetMirrorCompatibilityResponse, GetNotificationDestinationData, GetNotificationDestinationResponse, GetOrgMembersData, GetOrgMembersResponse, GetPublicSsoProvidersData, GetPublicSsoProvidersResponse, GetRegistrationStatusData, GetRegistrationStatusResponse, GetRepositoryData, GetRepositoryResponse, GetRepositoryStatsData, GetRepositoryStatsResponse, GetScheduleMirrorsData, GetScheduleMirrorsResponse, GetScheduleNotificationsData, GetScheduleNotificationsResponse, GetSnapshotDetailsData, GetSnapshotDetailsResponse, GetSsoSettingsData, GetSsoSettingsResponse, GetStatusData, GetStatusResponse, GetSystemInfoData, GetSystemInfoResponse, GetUpdatesData, GetUpdatesResponse, GetUserDeletionImpactData, GetUserDeletionImpactResponse, GetVolumeData, GetVolumeResponse, HealthCheckVolumeData, HealthCheckVolumeResponse, ListBackupSchedulesData, ListBackupSchedulesResponse, ListFilesData, ListFilesResponse, ListNotificationDestinationsData, ListNotificationDestinationsResponse, ListRcloneRemotesData, ListRcloneRemotesResponse, ListRepositoriesData, ListRepositoriesResponse, ListSnapshotFilesData, ListSnapshotFilesResponse, ListSnapshotsData, ListSnapshotsResponse, ListVolumesData, ListVolumesResponse, MountVolumeData, MountVolumeResponse, RefreshSnapshotsData, RefreshSnapshotsResponse, RemoveOrgMemberData, ReorderBackupSchedulesData, ReorderBackupSchedulesResponse, RestoreSnapshotData, RestoreSnapshotResponse, RunBackupNowData, RunBackupNowResponse, RunForgetData, RunForgetResponse, SetRegistrationStatusData, SetRegistrationStatusResponse, StartDoctorData, StartDoctorResponse, StopBackupData, StopBackupResponse, TagSnapshotsData, TagSnapshotsResponse, TestConnectionData, TestConnectionResponse, TestNotificationDestinationData, TestNotificationDestinationResponse, UnlockRepositoryData, UnlockRepositoryResponse, UnmountVolumeData, UnmountVolumeResponse, UpdateBackupScheduleData, UpdateBackupScheduleResponse, UpdateMemberRoleData, UpdateNotificationDestinationData, UpdateNotificationDestinationResponse, UpdateRepositoryData, UpdateRepositoryResponse, UpdateScheduleMirrorsData, UpdateScheduleMirrorsResponse, UpdateScheduleNotificationsData, UpdateScheduleNotificationsResponse, UpdateSsoProviderAutoLinkingData, UpdateVolumeData, UpdateVolumeResponse } from '../types.gen';
|
import type { BrowseFilesystemData, BrowseFilesystemResponse, CancelDoctorData, CancelDoctorResponse, CreateBackupScheduleData, CreateBackupScheduleResponse, CreateNotificationDestinationData, CreateNotificationDestinationResponse, CreateRepositoryData, CreateRepositoryResponse, CreateVolumeData, CreateVolumeResponse, DeleteBackupScheduleData, DeleteBackupScheduleResponse, DeleteNotificationDestinationData, DeleteNotificationDestinationResponse, DeleteRepositoryData, DeleteRepositoryResponse, DeleteSnapshotData, DeleteSnapshotResponse, DeleteSnapshotsData, DeleteSnapshotsResponse, DeleteSsoInvitationData, DeleteSsoProviderData, DeleteUserAccountData, DeleteVolumeData, DeleteVolumeResponse, DownloadResticPasswordData, DownloadResticPasswordResponse, DumpSnapshotData, DumpSnapshotResponse, GetAdminUsersData, GetAdminUsersResponse, GetBackupProgressData, GetBackupProgressResponse, GetBackupScheduleData, GetBackupScheduleForVolumeData, GetBackupScheduleForVolumeResponse, GetBackupScheduleResponse, GetDevPanelData, GetDevPanelResponse, GetMirrorCompatibilityData, GetMirrorCompatibilityResponse, GetNotificationDestinationData, GetNotificationDestinationResponse, GetOrgMembersData, GetOrgMembersResponse, GetPublicSsoProvidersData, GetPublicSsoProvidersResponse, GetRegistrationStatusData, GetRegistrationStatusResponse, GetRepositoryData, GetRepositoryResponse, GetRepositoryStatsData, GetRepositoryStatsResponse, GetScheduleMirrorsData, GetScheduleMirrorsResponse, GetScheduleNotificationsData, GetScheduleNotificationsResponse, GetSnapshotDetailsData, GetSnapshotDetailsResponse, GetSsoSettingsData, GetSsoSettingsResponse, GetStatusData, GetStatusResponse, GetSystemInfoData, GetSystemInfoResponse, GetUpdatesData, GetUpdatesResponse, GetUserDeletionImpactData, GetUserDeletionImpactResponse, GetVolumeData, GetVolumeResponse, HealthCheckVolumeData, HealthCheckVolumeResponse, ListBackupSchedulesData, ListBackupSchedulesResponse, ListFilesData, ListFilesResponse, ListNotificationDestinationsData, ListNotificationDestinationsResponse, ListRcloneRemotesData, ListRcloneRemotesResponse, ListRepositoriesData, ListRepositoriesResponse, ListSnapshotFilesData, ListSnapshotFilesResponse, ListSnapshotsData, ListSnapshotsResponse, ListVolumesData, ListVolumesResponse, MountVolumeData, MountVolumeResponse, RefreshRepositoryStatsData, RefreshRepositoryStatsResponse, RefreshSnapshotsData, RefreshSnapshotsResponse, RemoveOrgMemberData, ReorderBackupSchedulesData, ReorderBackupSchedulesResponse, RestoreSnapshotData, RestoreSnapshotResponse, RunBackupNowData, RunBackupNowResponse, RunForgetData, RunForgetResponse, SetRegistrationStatusData, SetRegistrationStatusResponse, StartDoctorData, StartDoctorResponse, StopBackupData, StopBackupResponse, TagSnapshotsData, TagSnapshotsResponse, TestConnectionData, TestConnectionResponse, TestNotificationDestinationData, TestNotificationDestinationResponse, UnlockRepositoryData, UnlockRepositoryResponse, UnmountVolumeData, UnmountVolumeResponse, UpdateBackupScheduleData, UpdateBackupScheduleResponse, UpdateMemberRoleData, UpdateNotificationDestinationData, UpdateNotificationDestinationResponse, UpdateRepositoryData, UpdateRepositoryResponse, UpdateScheduleMirrorsData, UpdateScheduleMirrorsResponse, UpdateScheduleNotificationsData, UpdateScheduleNotificationsResponse, UpdateSsoProviderAutoLinkingData, UpdateVolumeData, UpdateVolumeResponse } from '../types.gen';
|
||||||
|
|
||||||
export type QueryKey<TOptions extends Options> = [
|
export type QueryKey<TOptions extends Options> = [
|
||||||
Pick<TOptions, 'baseUrl' | 'body' | 'headers' | 'path' | 'query'> & {
|
Pick<TOptions, 'baseUrl' | 'body' | 'headers' | 'path' | 'query'> & {
|
||||||
|
|
@ -58,93 +58,6 @@ export const getStatusOptions = (options?: Options<GetStatusData>) => queryOptio
|
||||||
queryKey: getStatusQueryKey(options)
|
queryKey: getStatusQueryKey(options)
|
||||||
});
|
});
|
||||||
|
|
||||||
export const getPublicSsoProvidersQueryKey = (options?: Options<GetPublicSsoProvidersData>) => createQueryKey('getPublicSsoProviders', options);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get public SSO providers for the instance
|
|
||||||
*/
|
|
||||||
export const getPublicSsoProvidersOptions = (options?: Options<GetPublicSsoProvidersData>) => queryOptions<GetPublicSsoProvidersResponse, DefaultError, GetPublicSsoProvidersResponse, ReturnType<typeof getPublicSsoProvidersQueryKey>>({
|
|
||||||
queryFn: async ({ queryKey, signal }) => {
|
|
||||||
const { data } = await getPublicSsoProviders({
|
|
||||||
...options,
|
|
||||||
...queryKey[0],
|
|
||||||
signal,
|
|
||||||
throwOnError: true
|
|
||||||
});
|
|
||||||
return data;
|
|
||||||
},
|
|
||||||
queryKey: getPublicSsoProvidersQueryKey(options)
|
|
||||||
});
|
|
||||||
|
|
||||||
export const getSsoSettingsQueryKey = (options?: Options<GetSsoSettingsData>) => createQueryKey('getSsoSettings', options);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get SSO providers and invitations for the active organization
|
|
||||||
*/
|
|
||||||
export const getSsoSettingsOptions = (options?: Options<GetSsoSettingsData>) => queryOptions<GetSsoSettingsResponse, DefaultError, GetSsoSettingsResponse, ReturnType<typeof getSsoSettingsQueryKey>>({
|
|
||||||
queryFn: async ({ queryKey, signal }) => {
|
|
||||||
const { data } = await getSsoSettings({
|
|
||||||
...options,
|
|
||||||
...queryKey[0],
|
|
||||||
signal,
|
|
||||||
throwOnError: true
|
|
||||||
});
|
|
||||||
return data;
|
|
||||||
},
|
|
||||||
queryKey: getSsoSettingsQueryKey(options)
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Delete an SSO provider
|
|
||||||
*/
|
|
||||||
export const deleteSsoProviderMutation = (options?: Partial<Options<DeleteSsoProviderData>>): UseMutationOptions<unknown, DefaultError, Options<DeleteSsoProviderData>> => {
|
|
||||||
const mutationOptions: UseMutationOptions<unknown, DefaultError, Options<DeleteSsoProviderData>> = {
|
|
||||||
mutationFn: async (fnOptions) => {
|
|
||||||
const { data } = await deleteSsoProvider({
|
|
||||||
...options,
|
|
||||||
...fnOptions,
|
|
||||||
throwOnError: true
|
|
||||||
});
|
|
||||||
return data;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
return mutationOptions;
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Update whether SSO sign-in can auto-link existing accounts by email
|
|
||||||
*/
|
|
||||||
export const updateSsoProviderAutoLinkingMutation = (options?: Partial<Options<UpdateSsoProviderAutoLinkingData>>): UseMutationOptions<unknown, DefaultError, Options<UpdateSsoProviderAutoLinkingData>> => {
|
|
||||||
const mutationOptions: UseMutationOptions<unknown, DefaultError, Options<UpdateSsoProviderAutoLinkingData>> = {
|
|
||||||
mutationFn: async (fnOptions) => {
|
|
||||||
const { data } = await updateSsoProviderAutoLinking({
|
|
||||||
...options,
|
|
||||||
...fnOptions,
|
|
||||||
throwOnError: true
|
|
||||||
});
|
|
||||||
return data;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
return mutationOptions;
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Delete an SSO invitation
|
|
||||||
*/
|
|
||||||
export const deleteSsoInvitationMutation = (options?: Partial<Options<DeleteSsoInvitationData>>): UseMutationOptions<unknown, DefaultError, Options<DeleteSsoInvitationData>> => {
|
|
||||||
const mutationOptions: UseMutationOptions<unknown, DefaultError, Options<DeleteSsoInvitationData>> = {
|
|
||||||
mutationFn: async (fnOptions) => {
|
|
||||||
const { data } = await deleteSsoInvitation({
|
|
||||||
...options,
|
|
||||||
...fnOptions,
|
|
||||||
throwOnError: true
|
|
||||||
});
|
|
||||||
return data;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
return mutationOptions;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const getAdminUsersQueryKey = (options?: Options<GetAdminUsersData>) => createQueryKey('getAdminUsers', options);
|
export const getAdminUsersQueryKey = (options?: Options<GetAdminUsersData>) => createQueryKey('getAdminUsers', options);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -250,6 +163,93 @@ export const removeOrgMemberMutation = (options?: Partial<Options<RemoveOrgMembe
|
||||||
return mutationOptions;
|
return mutationOptions;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const getPublicSsoProvidersQueryKey = (options?: Options<GetPublicSsoProvidersData>) => createQueryKey('getPublicSsoProviders', options);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get public SSO providers for the instance
|
||||||
|
*/
|
||||||
|
export const getPublicSsoProvidersOptions = (options?: Options<GetPublicSsoProvidersData>) => queryOptions<GetPublicSsoProvidersResponse, DefaultError, GetPublicSsoProvidersResponse, ReturnType<typeof getPublicSsoProvidersQueryKey>>({
|
||||||
|
queryFn: async ({ queryKey, signal }) => {
|
||||||
|
const { data } = await getPublicSsoProviders({
|
||||||
|
...options,
|
||||||
|
...queryKey[0],
|
||||||
|
signal,
|
||||||
|
throwOnError: true
|
||||||
|
});
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
queryKey: getPublicSsoProvidersQueryKey(options)
|
||||||
|
});
|
||||||
|
|
||||||
|
export const getSsoSettingsQueryKey = (options?: Options<GetSsoSettingsData>) => createQueryKey('getSsoSettings', options);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get SSO providers and invitations for the active organization
|
||||||
|
*/
|
||||||
|
export const getSsoSettingsOptions = (options?: Options<GetSsoSettingsData>) => queryOptions<GetSsoSettingsResponse, DefaultError, GetSsoSettingsResponse, ReturnType<typeof getSsoSettingsQueryKey>>({
|
||||||
|
queryFn: async ({ queryKey, signal }) => {
|
||||||
|
const { data } = await getSsoSettings({
|
||||||
|
...options,
|
||||||
|
...queryKey[0],
|
||||||
|
signal,
|
||||||
|
throwOnError: true
|
||||||
|
});
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
queryKey: getSsoSettingsQueryKey(options)
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete an SSO provider
|
||||||
|
*/
|
||||||
|
export const deleteSsoProviderMutation = (options?: Partial<Options<DeleteSsoProviderData>>): UseMutationOptions<unknown, DefaultError, Options<DeleteSsoProviderData>> => {
|
||||||
|
const mutationOptions: UseMutationOptions<unknown, DefaultError, Options<DeleteSsoProviderData>> = {
|
||||||
|
mutationFn: async (fnOptions) => {
|
||||||
|
const { data } = await deleteSsoProvider({
|
||||||
|
...options,
|
||||||
|
...fnOptions,
|
||||||
|
throwOnError: true
|
||||||
|
});
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
return mutationOptions;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update whether SSO sign-in can auto-link existing accounts by email
|
||||||
|
*/
|
||||||
|
export const updateSsoProviderAutoLinkingMutation = (options?: Partial<Options<UpdateSsoProviderAutoLinkingData>>): UseMutationOptions<unknown, DefaultError, Options<UpdateSsoProviderAutoLinkingData>> => {
|
||||||
|
const mutationOptions: UseMutationOptions<unknown, DefaultError, Options<UpdateSsoProviderAutoLinkingData>> = {
|
||||||
|
mutationFn: async (fnOptions) => {
|
||||||
|
const { data } = await updateSsoProviderAutoLinking({
|
||||||
|
...options,
|
||||||
|
...fnOptions,
|
||||||
|
throwOnError: true
|
||||||
|
});
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
return mutationOptions;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete an SSO invitation
|
||||||
|
*/
|
||||||
|
export const deleteSsoInvitationMutation = (options?: Partial<Options<DeleteSsoInvitationData>>): UseMutationOptions<unknown, DefaultError, Options<DeleteSsoInvitationData>> => {
|
||||||
|
const mutationOptions: UseMutationOptions<unknown, DefaultError, Options<DeleteSsoInvitationData>> = {
|
||||||
|
mutationFn: async (fnOptions) => {
|
||||||
|
const { data } = await deleteSsoInvitation({
|
||||||
|
...options,
|
||||||
|
...fnOptions,
|
||||||
|
throwOnError: true
|
||||||
|
});
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
return mutationOptions;
|
||||||
|
};
|
||||||
|
|
||||||
export const listVolumesQueryKey = (options?: Options<ListVolumesData>) => createQueryKey('listVolumes', options);
|
export const listVolumesQueryKey = (options?: Options<ListVolumesData>) => createQueryKey('listVolumes', options);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -457,7 +457,7 @@ export const listFilesInfiniteQueryKey = (options: Options<ListFilesData>): Quer
|
||||||
/**
|
/**
|
||||||
* List files in a volume directory
|
* List files in a volume directory
|
||||||
*/
|
*/
|
||||||
export const listFilesInfiniteOptions = (options: Options<ListFilesData>) => infiniteQueryOptions<ListFilesResponse, DefaultError, InfiniteData<ListFilesResponse>, QueryKey<Options<ListFilesData>>, string | Pick<QueryKey<Options<ListFilesData>>[0], 'body' | 'headers' | 'path' | 'query'>>(
|
export const listFilesInfiniteOptions = (options: Options<ListFilesData>) => infiniteQueryOptions<ListFilesResponse, DefaultError, InfiniteData<ListFilesResponse>, QueryKey<Options<ListFilesData>>, number | Pick<QueryKey<Options<ListFilesData>>[0], 'body' | 'headers' | 'path' | 'query'>>(
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
{
|
{
|
||||||
queryFn: async ({ pageParam, queryKey, signal }) => {
|
queryFn: async ({ pageParam, queryKey, signal }) => {
|
||||||
|
|
@ -620,6 +620,23 @@ export const getRepositoryStatsOptions = (options: Options<GetRepositoryStatsDat
|
||||||
queryKey: getRepositoryStatsQueryKey(options)
|
queryKey: getRepositoryStatsQueryKey(options)
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Refresh repository storage and compression statistics
|
||||||
|
*/
|
||||||
|
export const refreshRepositoryStatsMutation = (options?: Partial<Options<RefreshRepositoryStatsData>>): UseMutationOptions<RefreshRepositoryStatsResponse, DefaultError, Options<RefreshRepositoryStatsData>> => {
|
||||||
|
const mutationOptions: UseMutationOptions<RefreshRepositoryStatsResponse, DefaultError, Options<RefreshRepositoryStatsData>> = {
|
||||||
|
mutationFn: async (fnOptions) => {
|
||||||
|
const { data } = await refreshRepositoryStats({
|
||||||
|
...options,
|
||||||
|
...fnOptions,
|
||||||
|
throwOnError: true
|
||||||
|
});
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
return mutationOptions;
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Delete multiple snapshots from a repository
|
* Delete multiple snapshots from a repository
|
||||||
*/
|
*/
|
||||||
|
|
@ -730,7 +747,7 @@ export const listSnapshotFilesInfiniteQueryKey = (options: Options<ListSnapshotF
|
||||||
/**
|
/**
|
||||||
* List files and directories in a snapshot
|
* List files and directories in a snapshot
|
||||||
*/
|
*/
|
||||||
export const listSnapshotFilesInfiniteOptions = (options: Options<ListSnapshotFilesData>) => infiniteQueryOptions<ListSnapshotFilesResponse, DefaultError, InfiniteData<ListSnapshotFilesResponse>, QueryKey<Options<ListSnapshotFilesData>>, string | Pick<QueryKey<Options<ListSnapshotFilesData>>[0], 'body' | 'headers' | 'path' | 'query'>>(
|
export const listSnapshotFilesInfiniteOptions = (options: Options<ListSnapshotFilesData>) => infiniteQueryOptions<ListSnapshotFilesResponse, DefaultError, InfiniteData<ListSnapshotFilesResponse>, QueryKey<Options<ListSnapshotFilesData>>, number | Pick<QueryKey<Options<ListSnapshotFilesData>>[0], 'body' | 'headers' | 'path' | 'query'>>(
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
{
|
{
|
||||||
queryFn: async ({ pageParam, queryKey, signal }) => {
|
queryFn: async ({ pageParam, queryKey, signal }) => {
|
||||||
|
|
@ -855,23 +872,6 @@ export const tagSnapshotsMutation = (options?: Partial<Options<TagSnapshotsData>
|
||||||
return mutationOptions;
|
return mutationOptions;
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
|
||||||
* Execute a restic command against a repository (dev panel only)
|
|
||||||
*/
|
|
||||||
export const devPanelExecMutation = (options?: Partial<Options<DevPanelExecData>>): UseMutationOptions<DevPanelExecResponse, DefaultError, Options<DevPanelExecData>> => {
|
|
||||||
const mutationOptions: UseMutationOptions<DevPanelExecResponse, DefaultError, Options<DevPanelExecData>> = {
|
|
||||||
mutationFn: async (fnOptions) => {
|
|
||||||
const { data } = await devPanelExec({
|
|
||||||
...options,
|
|
||||||
...fnOptions,
|
|
||||||
throwOnError: true
|
|
||||||
});
|
|
||||||
return data;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
return mutationOptions;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const listBackupSchedulesQueryKey = (options?: Options<ListBackupSchedulesData>) => createQueryKey('listBackupSchedules', options);
|
export const listBackupSchedulesQueryKey = (options?: Options<ListBackupSchedulesData>) => createQueryKey('listBackupSchedules', options);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -38,7 +38,7 @@ export const createClient = (config: Config = {}): Client => {
|
||||||
...options,
|
...options,
|
||||||
fetch: options.fetch ?? _config.fetch ?? globalThis.fetch,
|
fetch: options.fetch ?? _config.fetch ?? globalThis.fetch,
|
||||||
headers: mergeHeaders(_config.headers, options.headers),
|
headers: mergeHeaders(_config.headers, options.headers),
|
||||||
serializedBody: undefined,
|
serializedBody: undefined as string | undefined,
|
||||||
};
|
};
|
||||||
|
|
||||||
if (opts.security) {
|
if (opts.security) {
|
||||||
|
|
@ -53,7 +53,7 @@ export const createClient = (config: Config = {}): Client => {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (opts.body !== undefined && opts.bodySerializer) {
|
if (opts.body !== undefined && opts.bodySerializer) {
|
||||||
opts.serializedBody = opts.bodySerializer(opts.body);
|
opts.serializedBody = opts.bodySerializer(opts.body) as string | undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
// remove Content-Type header if body is empty to avoid sending invalid requests
|
// remove Content-Type header if body is empty to avoid sending invalid requests
|
||||||
|
|
@ -259,8 +259,10 @@ export const createClient = (config: Config = {}): Client => {
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const _buildUrl: Client['buildUrl'] = (options) => buildUrl({ ..._config, ...options });
|
||||||
|
|
||||||
return {
|
return {
|
||||||
buildUrl,
|
buildUrl: _buildUrl,
|
||||||
connect: makeMethodFn('CONNECT'),
|
connect: makeMethodFn('CONNECT'),
|
||||||
delete: makeMethodFn('DELETE'),
|
delete: makeMethodFn('DELETE'),
|
||||||
get: makeMethodFn('GET'),
|
get: makeMethodFn('GET'),
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@ import type { ArrayStyle, ObjectStyle, SerializerOptions } from './pathSerialize
|
||||||
|
|
||||||
export type QuerySerializer = (query: Record<string, unknown>) => string;
|
export type QuerySerializer = (query: Record<string, unknown>) => string;
|
||||||
|
|
||||||
export type BodySerializer = (body: any) => any;
|
export type BodySerializer = (body: unknown) => unknown;
|
||||||
|
|
||||||
type QuerySerializerOptionsObject = {
|
type QuerySerializerOptionsObject = {
|
||||||
allowReserved?: boolean;
|
allowReserved?: boolean;
|
||||||
|
|
@ -40,12 +40,10 @@ const serializeUrlSearchParamsPair = (data: URLSearchParams, key: string, value:
|
||||||
};
|
};
|
||||||
|
|
||||||
export const formDataBodySerializer = {
|
export const formDataBodySerializer = {
|
||||||
bodySerializer: <T extends Record<string, any> | Array<Record<string, any>>>(
|
bodySerializer: (body: unknown): FormData => {
|
||||||
body: T,
|
|
||||||
): FormData => {
|
|
||||||
const data = new FormData();
|
const data = new FormData();
|
||||||
|
|
||||||
Object.entries(body).forEach(([key, value]) => {
|
Object.entries(body as Record<string, unknown>).forEach(([key, value]) => {
|
||||||
if (value === undefined || value === null) {
|
if (value === undefined || value === null) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -61,15 +59,15 @@ export const formDataBodySerializer = {
|
||||||
};
|
};
|
||||||
|
|
||||||
export const jsonBodySerializer = {
|
export const jsonBodySerializer = {
|
||||||
bodySerializer: <T>(body: T): string =>
|
bodySerializer: (body: unknown): string =>
|
||||||
JSON.stringify(body, (_key, value) => (typeof value === 'bigint' ? value.toString() : value)),
|
JSON.stringify(body, (_key, value) => (typeof value === 'bigint' ? value.toString() : value)),
|
||||||
};
|
};
|
||||||
|
|
||||||
export const urlSearchParamsBodySerializer = {
|
export const urlSearchParamsBodySerializer = {
|
||||||
bodySerializer: <T extends Record<string, any> | Array<Record<string, any>>>(body: T): string => {
|
bodySerializer: (body: unknown): string => {
|
||||||
const data = new URLSearchParams();
|
const data = new URLSearchParams();
|
||||||
|
|
||||||
Object.entries(body).forEach(([key, value]) => {
|
Object.entries(body as Record<string, unknown>).forEach(([key, value]) => {
|
||||||
if (value === undefined || value === null) {
|
if (value === undefined || value === null) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -97,7 +97,7 @@ interface Params {
|
||||||
|
|
||||||
const stripEmptySlots = (params: Params) => {
|
const stripEmptySlots = (params: Params) => {
|
||||||
for (const [slot, value] of Object.entries(params)) {
|
for (const [slot, value] of Object.entries(params)) {
|
||||||
if (value && typeof value === 'object' && !Object.keys(value).length) {
|
if (value && typeof value === 'object' && !Array.isArray(value) && !Object.keys(value).length) {
|
||||||
delete params[slot as Slot];
|
delete params[slot as Slot];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -52,6 +52,7 @@ export {
|
||||||
listVolumes,
|
listVolumes,
|
||||||
mountVolume,
|
mountVolume,
|
||||||
type Options,
|
type Options,
|
||||||
|
refreshRepositoryStats,
|
||||||
refreshSnapshots,
|
refreshSnapshots,
|
||||||
removeOrgMember,
|
removeOrgMember,
|
||||||
reorderBackupSchedules,
|
reorderBackupSchedules,
|
||||||
|
|
@ -230,6 +231,9 @@ export type {
|
||||||
MountVolumeData,
|
MountVolumeData,
|
||||||
MountVolumeResponse,
|
MountVolumeResponse,
|
||||||
MountVolumeResponses,
|
MountVolumeResponses,
|
||||||
|
RefreshRepositoryStatsData,
|
||||||
|
RefreshRepositoryStatsResponse,
|
||||||
|
RefreshRepositoryStatsResponses,
|
||||||
RefreshSnapshotsData,
|
RefreshSnapshotsData,
|
||||||
RefreshSnapshotsResponse,
|
RefreshSnapshotsResponse,
|
||||||
RefreshSnapshotsResponses,
|
RefreshSnapshotsResponses,
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@
|
||||||
|
|
||||||
import type { Client, Options as Options2, TDataShape } from './client';
|
import type { Client, Options as Options2, TDataShape } from './client';
|
||||||
import { client } from './client.gen';
|
import { client } from './client.gen';
|
||||||
import type { BrowseFilesystemData, BrowseFilesystemResponses, CancelDoctorData, CancelDoctorErrors, CancelDoctorResponses, CreateBackupScheduleData, CreateBackupScheduleResponses, CreateNotificationDestinationData, CreateNotificationDestinationResponses, CreateRepositoryData, CreateRepositoryResponses, CreateVolumeData, CreateVolumeResponses, DeleteBackupScheduleData, DeleteBackupScheduleResponses, DeleteNotificationDestinationData, DeleteNotificationDestinationErrors, DeleteNotificationDestinationResponses, DeleteRepositoryData, DeleteRepositoryResponses, DeleteSnapshotData, DeleteSnapshotResponses, DeleteSnapshotsData, DeleteSnapshotsResponses, DeleteSsoInvitationData, DeleteSsoInvitationErrors, DeleteSsoInvitationResponses, DeleteSsoProviderData, DeleteSsoProviderErrors, DeleteSsoProviderResponses, DeleteUserAccountData, DeleteUserAccountErrors, DeleteUserAccountResponses, DeleteVolumeData, DeleteVolumeResponses, DevPanelExecData, DevPanelExecErrors, DevPanelExecResponses, DownloadResticPasswordData, DownloadResticPasswordResponses, DumpSnapshotData, DumpSnapshotResponses, GetAdminUsersData, GetAdminUsersResponses, GetBackupProgressData, GetBackupProgressResponses, GetBackupScheduleData, GetBackupScheduleForVolumeData, GetBackupScheduleForVolumeResponses, GetBackupScheduleResponses, GetDevPanelData, GetDevPanelResponses, GetMirrorCompatibilityData, GetMirrorCompatibilityResponses, GetNotificationDestinationData, GetNotificationDestinationErrors, GetNotificationDestinationResponses, GetOrgMembersData, GetOrgMembersResponses, GetPublicSsoProvidersData, GetPublicSsoProvidersResponses, GetRegistrationStatusData, GetRegistrationStatusResponses, GetRepositoryData, GetRepositoryResponses, GetRepositoryStatsData, GetRepositoryStatsResponses, GetScheduleMirrorsData, GetScheduleMirrorsResponses, GetScheduleNotificationsData, GetScheduleNotificationsResponses, GetSnapshotDetailsData, GetSnapshotDetailsResponses, GetSsoSettingsData, GetSsoSettingsResponses, GetStatusData, GetStatusResponses, GetSystemInfoData, GetSystemInfoResponses, GetUpdatesData, GetUpdatesResponses, GetUserDeletionImpactData, GetUserDeletionImpactResponses, GetVolumeData, GetVolumeErrors, GetVolumeResponses, HealthCheckVolumeData, HealthCheckVolumeErrors, HealthCheckVolumeResponses, ListBackupSchedulesData, ListBackupSchedulesResponses, ListFilesData, ListFilesResponses, ListNotificationDestinationsData, ListNotificationDestinationsResponses, ListRcloneRemotesData, ListRcloneRemotesResponses, ListRepositoriesData, ListRepositoriesResponses, ListSnapshotFilesData, ListSnapshotFilesResponses, ListSnapshotsData, ListSnapshotsResponses, ListVolumesData, ListVolumesResponses, MountVolumeData, MountVolumeResponses, RefreshSnapshotsData, RefreshSnapshotsResponses, RemoveOrgMemberData, RemoveOrgMemberErrors, RemoveOrgMemberResponses, ReorderBackupSchedulesData, ReorderBackupSchedulesResponses, RestoreSnapshotData, RestoreSnapshotResponses, RunBackupNowData, RunBackupNowResponses, RunForgetData, RunForgetResponses, SetRegistrationStatusData, SetRegistrationStatusResponses, StartDoctorData, StartDoctorErrors, StartDoctorResponses, StopBackupData, StopBackupErrors, StopBackupResponses, TagSnapshotsData, TagSnapshotsResponses, TestConnectionData, TestConnectionResponses, TestNotificationDestinationData, TestNotificationDestinationErrors, TestNotificationDestinationResponses, UnlockRepositoryData, UnlockRepositoryResponses, UnmountVolumeData, UnmountVolumeResponses, UpdateBackupScheduleData, UpdateBackupScheduleResponses, UpdateMemberRoleData, UpdateMemberRoleErrors, UpdateMemberRoleResponses, UpdateNotificationDestinationData, UpdateNotificationDestinationErrors, UpdateNotificationDestinationResponses, UpdateRepositoryData, UpdateRepositoryErrors, UpdateRepositoryResponses, UpdateScheduleMirrorsData, UpdateScheduleMirrorsResponses, UpdateScheduleNotificationsData, UpdateScheduleNotificationsResponses, UpdateSsoProviderAutoLinkingData, UpdateSsoProviderAutoLinkingErrors, UpdateSsoProviderAutoLinkingResponses, UpdateVolumeData, UpdateVolumeErrors, UpdateVolumeResponses } from './types.gen';
|
import type { BrowseFilesystemData, BrowseFilesystemResponses, CancelDoctorData, CancelDoctorErrors, CancelDoctorResponses, CreateBackupScheduleData, CreateBackupScheduleResponses, CreateNotificationDestinationData, CreateNotificationDestinationResponses, CreateRepositoryData, CreateRepositoryResponses, CreateVolumeData, CreateVolumeResponses, DeleteBackupScheduleData, DeleteBackupScheduleResponses, DeleteNotificationDestinationData, DeleteNotificationDestinationErrors, DeleteNotificationDestinationResponses, DeleteRepositoryData, DeleteRepositoryResponses, DeleteSnapshotData, DeleteSnapshotResponses, DeleteSnapshotsData, DeleteSnapshotsResponses, DeleteSsoInvitationData, DeleteSsoInvitationErrors, DeleteSsoInvitationResponses, DeleteSsoProviderData, DeleteSsoProviderErrors, DeleteSsoProviderResponses, DeleteUserAccountData, DeleteUserAccountErrors, DeleteUserAccountResponses, DeleteVolumeData, DeleteVolumeResponses, DevPanelExecData, DevPanelExecErrors, DevPanelExecResponses, DownloadResticPasswordData, DownloadResticPasswordResponses, DumpSnapshotData, DumpSnapshotResponses, GetAdminUsersData, GetAdminUsersResponses, GetBackupProgressData, GetBackupProgressResponses, GetBackupScheduleData, GetBackupScheduleForVolumeData, GetBackupScheduleForVolumeResponses, GetBackupScheduleResponses, GetDevPanelData, GetDevPanelResponses, GetMirrorCompatibilityData, GetMirrorCompatibilityResponses, GetNotificationDestinationData, GetNotificationDestinationErrors, GetNotificationDestinationResponses, GetOrgMembersData, GetOrgMembersResponses, GetPublicSsoProvidersData, GetPublicSsoProvidersResponses, GetRegistrationStatusData, GetRegistrationStatusResponses, GetRepositoryData, GetRepositoryResponses, GetRepositoryStatsData, GetRepositoryStatsResponses, GetScheduleMirrorsData, GetScheduleMirrorsResponses, GetScheduleNotificationsData, GetScheduleNotificationsResponses, GetSnapshotDetailsData, GetSnapshotDetailsResponses, GetSsoSettingsData, GetSsoSettingsResponses, GetStatusData, GetStatusResponses, GetSystemInfoData, GetSystemInfoResponses, GetUpdatesData, GetUpdatesResponses, GetUserDeletionImpactData, GetUserDeletionImpactResponses, GetVolumeData, GetVolumeErrors, GetVolumeResponses, HealthCheckVolumeData, HealthCheckVolumeErrors, HealthCheckVolumeResponses, ListBackupSchedulesData, ListBackupSchedulesResponses, ListFilesData, ListFilesResponses, ListNotificationDestinationsData, ListNotificationDestinationsResponses, ListRcloneRemotesData, ListRcloneRemotesResponses, ListRepositoriesData, ListRepositoriesResponses, ListSnapshotFilesData, ListSnapshotFilesResponses, ListSnapshotsData, ListSnapshotsResponses, ListVolumesData, ListVolumesResponses, MountVolumeData, MountVolumeResponses, RefreshRepositoryStatsData, RefreshRepositoryStatsResponses, RefreshSnapshotsData, RefreshSnapshotsResponses, RemoveOrgMemberData, RemoveOrgMemberErrors, RemoveOrgMemberResponses, ReorderBackupSchedulesData, ReorderBackupSchedulesResponses, RestoreSnapshotData, RestoreSnapshotResponses, RunBackupNowData, RunBackupNowResponses, RunForgetData, RunForgetResponses, SetRegistrationStatusData, SetRegistrationStatusResponses, StartDoctorData, StartDoctorErrors, StartDoctorResponses, StopBackupData, StopBackupErrors, StopBackupResponses, TagSnapshotsData, TagSnapshotsResponses, TestConnectionData, TestConnectionResponses, TestNotificationDestinationData, TestNotificationDestinationErrors, TestNotificationDestinationResponses, UnlockRepositoryData, UnlockRepositoryResponses, UnmountVolumeData, UnmountVolumeResponses, UpdateBackupScheduleData, UpdateBackupScheduleResponses, UpdateMemberRoleData, UpdateMemberRoleErrors, UpdateMemberRoleResponses, UpdateNotificationDestinationData, UpdateNotificationDestinationErrors, UpdateNotificationDestinationResponses, UpdateRepositoryData, UpdateRepositoryErrors, UpdateRepositoryResponses, UpdateScheduleMirrorsData, UpdateScheduleMirrorsResponses, UpdateScheduleNotificationsData, UpdateScheduleNotificationsResponses, UpdateSsoProviderAutoLinkingData, UpdateSsoProviderAutoLinkingErrors, UpdateSsoProviderAutoLinkingResponses, UpdateVolumeData, UpdateVolumeErrors, UpdateVolumeResponses } from './types.gen';
|
||||||
|
|
||||||
export type Options<TData extends TDataShape = TDataShape, ThrowOnError extends boolean = boolean> = Options2<TData, ThrowOnError> & {
|
export type Options<TData extends TDataShape = TDataShape, ThrowOnError extends boolean = boolean> = Options2<TData, ThrowOnError> & {
|
||||||
/**
|
/**
|
||||||
|
|
@ -24,38 +24,6 @@ export type Options<TData extends TDataShape = TDataShape, ThrowOnError extends
|
||||||
*/
|
*/
|
||||||
export const getStatus = <ThrowOnError extends boolean = false>(options?: Options<GetStatusData, ThrowOnError>) => (options?.client ?? client).get<GetStatusResponses, unknown, ThrowOnError>({ url: '/api/v1/auth/status', ...options });
|
export const getStatus = <ThrowOnError extends boolean = false>(options?: Options<GetStatusData, ThrowOnError>) => (options?.client ?? client).get<GetStatusResponses, unknown, ThrowOnError>({ url: '/api/v1/auth/status', ...options });
|
||||||
|
|
||||||
/**
|
|
||||||
* Get public SSO providers for the instance
|
|
||||||
*/
|
|
||||||
export const getPublicSsoProviders = <ThrowOnError extends boolean = false>(options?: Options<GetPublicSsoProvidersData, ThrowOnError>) => (options?.client ?? client).get<GetPublicSsoProvidersResponses, unknown, ThrowOnError>({ url: '/api/v1/auth/sso-providers', ...options });
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get SSO providers and invitations for the active organization
|
|
||||||
*/
|
|
||||||
export const getSsoSettings = <ThrowOnError extends boolean = false>(options?: Options<GetSsoSettingsData, ThrowOnError>) => (options?.client ?? client).get<GetSsoSettingsResponses, unknown, ThrowOnError>({ url: '/api/v1/auth/sso-settings', ...options });
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Delete an SSO provider
|
|
||||||
*/
|
|
||||||
export const deleteSsoProvider = <ThrowOnError extends boolean = false>(options: Options<DeleteSsoProviderData, ThrowOnError>) => (options.client ?? client).delete<DeleteSsoProviderResponses, DeleteSsoProviderErrors, ThrowOnError>({ url: '/api/v1/auth/sso-providers/{providerId}', ...options });
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Update whether SSO sign-in can auto-link existing accounts by email
|
|
||||||
*/
|
|
||||||
export const updateSsoProviderAutoLinking = <ThrowOnError extends boolean = false>(options: Options<UpdateSsoProviderAutoLinkingData, ThrowOnError>) => (options.client ?? client).patch<UpdateSsoProviderAutoLinkingResponses, UpdateSsoProviderAutoLinkingErrors, ThrowOnError>({
|
|
||||||
url: '/api/v1/auth/sso-providers/{providerId}/auto-linking',
|
|
||||||
...options,
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
...options.headers
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Delete an SSO invitation
|
|
||||||
*/
|
|
||||||
export const deleteSsoInvitation = <ThrowOnError extends boolean = false>(options: Options<DeleteSsoInvitationData, ThrowOnError>) => (options.client ?? client).delete<DeleteSsoInvitationResponses, DeleteSsoInvitationErrors, ThrowOnError>({ url: '/api/v1/auth/sso-invitations/{invitationId}', ...options });
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* List admin users for settings management
|
* List admin users for settings management
|
||||||
*/
|
*/
|
||||||
|
|
@ -93,6 +61,38 @@ export const updateMemberRole = <ThrowOnError extends boolean = false>(options:
|
||||||
*/
|
*/
|
||||||
export const removeOrgMember = <ThrowOnError extends boolean = false>(options: Options<RemoveOrgMemberData, ThrowOnError>) => (options.client ?? client).delete<RemoveOrgMemberResponses, RemoveOrgMemberErrors, ThrowOnError>({ url: '/api/v1/auth/org-members/{memberId}', ...options });
|
export const removeOrgMember = <ThrowOnError extends boolean = false>(options: Options<RemoveOrgMemberData, ThrowOnError>) => (options.client ?? client).delete<RemoveOrgMemberResponses, RemoveOrgMemberErrors, ThrowOnError>({ url: '/api/v1/auth/org-members/{memberId}', ...options });
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get public SSO providers for the instance
|
||||||
|
*/
|
||||||
|
export const getPublicSsoProviders = <ThrowOnError extends boolean = false>(options?: Options<GetPublicSsoProvidersData, ThrowOnError>) => (options?.client ?? client).get<GetPublicSsoProvidersResponses, unknown, ThrowOnError>({ url: '/api/v1/auth/sso-providers', ...options });
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get SSO providers and invitations for the active organization
|
||||||
|
*/
|
||||||
|
export const getSsoSettings = <ThrowOnError extends boolean = false>(options?: Options<GetSsoSettingsData, ThrowOnError>) => (options?.client ?? client).get<GetSsoSettingsResponses, unknown, ThrowOnError>({ url: '/api/v1/auth/sso-settings', ...options });
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete an SSO provider
|
||||||
|
*/
|
||||||
|
export const deleteSsoProvider = <ThrowOnError extends boolean = false>(options: Options<DeleteSsoProviderData, ThrowOnError>) => (options.client ?? client).delete<DeleteSsoProviderResponses, DeleteSsoProviderErrors, ThrowOnError>({ url: '/api/v1/auth/sso-providers/{providerId}', ...options });
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update whether SSO sign-in can auto-link existing accounts by email
|
||||||
|
*/
|
||||||
|
export const updateSsoProviderAutoLinking = <ThrowOnError extends boolean = false>(options: Options<UpdateSsoProviderAutoLinkingData, ThrowOnError>) => (options.client ?? client).patch<UpdateSsoProviderAutoLinkingResponses, UpdateSsoProviderAutoLinkingErrors, ThrowOnError>({
|
||||||
|
url: '/api/v1/auth/sso-providers/{providerId}/auto-linking',
|
||||||
|
...options,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
...options.headers
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete an SSO invitation
|
||||||
|
*/
|
||||||
|
export const deleteSsoInvitation = <ThrowOnError extends boolean = false>(options: Options<DeleteSsoInvitationData, ThrowOnError>) => (options.client ?? client).delete<DeleteSsoInvitationResponses, DeleteSsoInvitationErrors, ThrowOnError>({ url: '/api/v1/auth/sso-invitations/{invitationId}', ...options });
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* List all volumes
|
* List all volumes
|
||||||
*/
|
*/
|
||||||
|
|
@ -101,24 +101,24 @@ export const listVolumes = <ThrowOnError extends boolean = false>(options?: Opti
|
||||||
/**
|
/**
|
||||||
* Create a new volume
|
* Create a new volume
|
||||||
*/
|
*/
|
||||||
export const createVolume = <ThrowOnError extends boolean = false>(options?: Options<CreateVolumeData, ThrowOnError>) => (options?.client ?? client).post<CreateVolumeResponses, unknown, ThrowOnError>({
|
export const createVolume = <ThrowOnError extends boolean = false>(options: Options<CreateVolumeData, ThrowOnError>) => (options.client ?? client).post<CreateVolumeResponses, unknown, ThrowOnError>({
|
||||||
url: '/api/v1/volumes',
|
url: '/api/v1/volumes',
|
||||||
...options,
|
...options,
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
...options?.headers
|
...options.headers
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Test connection to backend
|
* Test connection to backend
|
||||||
*/
|
*/
|
||||||
export const testConnection = <ThrowOnError extends boolean = false>(options?: Options<TestConnectionData, ThrowOnError>) => (options?.client ?? client).post<TestConnectionResponses, unknown, ThrowOnError>({
|
export const testConnection = <ThrowOnError extends boolean = false>(options: Options<TestConnectionData, ThrowOnError>) => (options.client ?? client).post<TestConnectionResponses, unknown, ThrowOnError>({
|
||||||
url: '/api/v1/volumes/test-connection',
|
url: '/api/v1/volumes/test-connection',
|
||||||
...options,
|
...options,
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
...options?.headers
|
...options.headers
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -177,12 +177,12 @@ export const listRepositories = <ThrowOnError extends boolean = false>(options?:
|
||||||
/**
|
/**
|
||||||
* Create a new restic repository
|
* Create a new restic repository
|
||||||
*/
|
*/
|
||||||
export const createRepository = <ThrowOnError extends boolean = false>(options?: Options<CreateRepositoryData, ThrowOnError>) => (options?.client ?? client).post<CreateRepositoryResponses, unknown, ThrowOnError>({
|
export const createRepository = <ThrowOnError extends boolean = false>(options: Options<CreateRepositoryData, ThrowOnError>) => (options.client ?? client).post<CreateRepositoryResponses, unknown, ThrowOnError>({
|
||||||
url: '/api/v1/repositories',
|
url: '/api/v1/repositories',
|
||||||
...options,
|
...options,
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
...options?.headers
|
...options.headers
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -218,6 +218,11 @@ export const updateRepository = <ThrowOnError extends boolean = false>(options:
|
||||||
*/
|
*/
|
||||||
export const getRepositoryStats = <ThrowOnError extends boolean = false>(options: Options<GetRepositoryStatsData, ThrowOnError>) => (options.client ?? client).get<GetRepositoryStatsResponses, unknown, ThrowOnError>({ url: '/api/v1/repositories/{shortId}/stats', ...options });
|
export const getRepositoryStats = <ThrowOnError extends boolean = false>(options: Options<GetRepositoryStatsData, ThrowOnError>) => (options.client ?? client).get<GetRepositoryStatsResponses, unknown, ThrowOnError>({ url: '/api/v1/repositories/{shortId}/stats', ...options });
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Refresh repository storage and compression statistics
|
||||||
|
*/
|
||||||
|
export const refreshRepositoryStats = <ThrowOnError extends boolean = false>(options: Options<RefreshRepositoryStatsData, ThrowOnError>) => (options.client ?? client).post<RefreshRepositoryStatsResponses, unknown, ThrowOnError>({ url: '/api/v1/repositories/{shortId}/stats/refresh', ...options });
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Delete multiple snapshots from a repository
|
* Delete multiple snapshots from a repository
|
||||||
*/
|
*/
|
||||||
|
|
@ -319,12 +324,12 @@ export const listBackupSchedules = <ThrowOnError extends boolean = false>(option
|
||||||
/**
|
/**
|
||||||
* Create a new backup schedule for a volume
|
* Create a new backup schedule for a volume
|
||||||
*/
|
*/
|
||||||
export const createBackupSchedule = <ThrowOnError extends boolean = false>(options?: Options<CreateBackupScheduleData, ThrowOnError>) => (options?.client ?? client).post<CreateBackupScheduleResponses, unknown, ThrowOnError>({
|
export const createBackupSchedule = <ThrowOnError extends boolean = false>(options: Options<CreateBackupScheduleData, ThrowOnError>) => (options.client ?? client).post<CreateBackupScheduleResponses, unknown, ThrowOnError>({
|
||||||
url: '/api/v1/backups',
|
url: '/api/v1/backups',
|
||||||
...options,
|
...options,
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
...options?.headers
|
...options.headers
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -412,12 +417,12 @@ export const getMirrorCompatibility = <ThrowOnError extends boolean = false>(opt
|
||||||
/**
|
/**
|
||||||
* Reorder backup schedules by providing an array of schedule short IDs in the desired order
|
* Reorder backup schedules by providing an array of schedule short IDs in the desired order
|
||||||
*/
|
*/
|
||||||
export const reorderBackupSchedules = <ThrowOnError extends boolean = false>(options?: Options<ReorderBackupSchedulesData, ThrowOnError>) => (options?.client ?? client).post<ReorderBackupSchedulesResponses, unknown, ThrowOnError>({
|
export const reorderBackupSchedules = <ThrowOnError extends boolean = false>(options: Options<ReorderBackupSchedulesData, ThrowOnError>) => (options.client ?? client).post<ReorderBackupSchedulesResponses, unknown, ThrowOnError>({
|
||||||
url: '/api/v1/backups/reorder',
|
url: '/api/v1/backups/reorder',
|
||||||
...options,
|
...options,
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
...options?.headers
|
...options.headers
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -434,12 +439,12 @@ export const listNotificationDestinations = <ThrowOnError extends boolean = fals
|
||||||
/**
|
/**
|
||||||
* Create a new notification destination
|
* Create a new notification destination
|
||||||
*/
|
*/
|
||||||
export const createNotificationDestination = <ThrowOnError extends boolean = false>(options?: Options<CreateNotificationDestinationData, ThrowOnError>) => (options?.client ?? client).post<CreateNotificationDestinationResponses, unknown, ThrowOnError>({
|
export const createNotificationDestination = <ThrowOnError extends boolean = false>(options: Options<CreateNotificationDestinationData, ThrowOnError>) => (options.client ?? client).post<CreateNotificationDestinationResponses, unknown, ThrowOnError>({
|
||||||
url: '/api/v1/notifications/destinations',
|
url: '/api/v1/notifications/destinations',
|
||||||
...options,
|
...options,
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
...options?.headers
|
...options.headers
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -488,24 +493,24 @@ export const getRegistrationStatus = <ThrowOnError extends boolean = false>(opti
|
||||||
/**
|
/**
|
||||||
* Update the registration status for new users. Requires global admin role.
|
* Update the registration status for new users. Requires global admin role.
|
||||||
*/
|
*/
|
||||||
export const setRegistrationStatus = <ThrowOnError extends boolean = false>(options?: Options<SetRegistrationStatusData, ThrowOnError>) => (options?.client ?? client).put<SetRegistrationStatusResponses, unknown, ThrowOnError>({
|
export const setRegistrationStatus = <ThrowOnError extends boolean = false>(options: Options<SetRegistrationStatusData, ThrowOnError>) => (options.client ?? client).put<SetRegistrationStatusResponses, unknown, ThrowOnError>({
|
||||||
url: '/api/v1/system/registration-status',
|
url: '/api/v1/system/registration-status',
|
||||||
...options,
|
...options,
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
...options?.headers
|
...options.headers
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Download the organization's Restic password for backup recovery. Requires organization owner or admin role and password re-authentication.
|
* Download the organization's Restic password for backup recovery. Requires organization owner or admin role and password re-authentication.
|
||||||
*/
|
*/
|
||||||
export const downloadResticPassword = <ThrowOnError extends boolean = false>(options?: Options<DownloadResticPasswordData, ThrowOnError>) => (options?.client ?? client).post<DownloadResticPasswordResponses, unknown, ThrowOnError>({
|
export const downloadResticPassword = <ThrowOnError extends boolean = false>(options: Options<DownloadResticPasswordData, ThrowOnError>) => (options.client ?? client).post<DownloadResticPasswordResponses, unknown, ThrowOnError>({
|
||||||
url: '/api/v1/system/restic-password',
|
url: '/api/v1/system/restic-password',
|
||||||
...options,
|
...options,
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
...options?.headers
|
...options.headers
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -69,7 +69,7 @@ export function AppSidebar({ isInstanceAdmin }: Props) {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Sidebar variant="inset" collapsible="icon" className="p-0">
|
<Sidebar variant="inset" collapsible="icon" className="p-0">
|
||||||
<SidebarHeader className="bg-card-header border-b border-border/50 hidden md:flex h-16.25 flex-row items-center p-4">
|
<SidebarHeader className="bg-card-header border-b border-border/80 dark:border-border/50 hidden md:flex h-16.25 flex-row items-center p-4">
|
||||||
<Link to="/volumes" className="flex items-center gap-3 font-semibold pl-2">
|
<Link to="/volumes" className="flex items-center gap-3 font-semibold pl-2">
|
||||||
<img src="/images/zerobyte.png" alt="Zerobyte Logo" className={cn("h-8 w-8 shrink-0 object-contain -ml-2")} />
|
<img src="/images/zerobyte.png" alt="Zerobyte Logo" className={cn("h-8 w-8 shrink-0 object-contain -ml-2")} />
|
||||||
<span
|
<span
|
||||||
|
|
@ -115,7 +115,7 @@ export function AppSidebar({ isInstanceAdmin }: Props) {
|
||||||
/>
|
/>
|
||||||
<span
|
<span
|
||||||
className={cn({
|
className={cn({
|
||||||
"text-white font-medium": isActive,
|
"text-foreground font-medium": isActive,
|
||||||
"text-muted-foreground": !isActive,
|
"text-muted-foreground": !isActive,
|
||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
|
|
@ -171,7 +171,7 @@ export function AppSidebar({ isInstanceAdmin }: Props) {
|
||||||
/>
|
/>
|
||||||
<span
|
<span
|
||||||
className={cn({
|
className={cn({
|
||||||
"text-white font-medium": isActive,
|
"text-foreground font-medium": isActive,
|
||||||
"text-muted-foreground": !isActive,
|
"text-muted-foreground": !isActive,
|
||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
|
|
@ -194,7 +194,7 @@ export function AppSidebar({ isInstanceAdmin }: Props) {
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</SidebarContent>
|
</SidebarContent>
|
||||||
<SidebarFooter className="p-4 border-r border-border/50">
|
<SidebarFooter className="p-4 border-r border-border/80 dark:border-border/50">
|
||||||
<OrganizationSwitcher />
|
<OrganizationSwitcher />
|
||||||
<div className="flex items-center justify-between gap-2">
|
<div className="flex items-center justify-between gap-2">
|
||||||
<HoverCard openDelay={200}>
|
<HoverCard openDelay={200}>
|
||||||
|
|
|
||||||
|
|
@ -59,7 +59,7 @@ export function CronInput({ value, onChange, error }: CronInputProps) {
|
||||||
{value && (
|
{value && (
|
||||||
<div>
|
<div>
|
||||||
{isValid ? (
|
{isValid ? (
|
||||||
<CheckCircle2 className="h-4 w-4 text-green-500" />
|
<CheckCircle2 className="h-4 w-4 text-success" />
|
||||||
) : (
|
) : (
|
||||||
<AlertCircle className="h-4 w-4 text-destructive" />
|
<AlertCircle className="h-4 w-4 text-destructive" />
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -74,11 +74,7 @@ export const SnapshotTreeBrowser = ({
|
||||||
return await queryClient.ensureQueryData(
|
return await queryClient.ensureQueryData(
|
||||||
listSnapshotFilesOptions({
|
listSnapshotFilesOptions({
|
||||||
path: { shortId: repositoryId, snapshotId },
|
path: { shortId: repositoryId, snapshotId },
|
||||||
query: {
|
query: { path, offset: offset, limit: pageSize },
|
||||||
path,
|
|
||||||
offset: offset.toString(),
|
|
||||||
limit: pageSize.toString(),
|
|
||||||
},
|
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|
@ -86,11 +82,7 @@ export const SnapshotTreeBrowser = ({
|
||||||
void queryClient.prefetchQuery(
|
void queryClient.prefetchQuery(
|
||||||
listSnapshotFilesOptions({
|
listSnapshotFilesOptions({
|
||||||
path: { shortId: repositoryId, snapshotId },
|
path: { shortId: repositoryId, snapshotId },
|
||||||
query: {
|
query: { path, offset: 0, limit: pageSize },
|
||||||
path,
|
|
||||||
offset: "0",
|
|
||||||
limit: pageSize.toString(),
|
|
||||||
},
|
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -24,7 +24,7 @@ export const VolumeFileBrowser = ({ volumeId, enabled = true, ...uiProps }: Volu
|
||||||
return await queryClient.ensureQueryData(
|
return await queryClient.ensureQueryData(
|
||||||
listFilesOptions({
|
listFilesOptions({
|
||||||
path: { shortId: volumeId },
|
path: { shortId: volumeId },
|
||||||
query: { path, offset: offset?.toString() },
|
query: { path, offset: offset },
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,7 @@ export function GridBackground({ children, className, containerClassName }: Grid
|
||||||
className={cn(
|
className={cn(
|
||||||
"pointer-events-none absolute inset-0 w-full h-full",
|
"pointer-events-none absolute inset-0 w-full h-full",
|
||||||
"bg-[size:40px_40px]",
|
"bg-[size:40px_40px]",
|
||||||
"bg-[linear-gradient(to_right,#e4e4e7_1px,transparent_1px),linear-gradient(to_bottom,#e4e4e7_1px,transparent_1px)]",
|
"bg-[linear-gradient(to_right,rgba(0,0,0,0.04)_1px,transparent_1px),linear-gradient(to_bottom,rgba(0,0,0,0.04)_1px,transparent_1px)]",
|
||||||
"dark:bg-[linear-gradient(to_right,#262626_1px,transparent_1px),linear-gradient(to_bottom,#262626_1px,transparent_1px)]",
|
"dark:bg-[linear-gradient(to_right,#262626_1px,transparent_1px),linear-gradient(to_bottom,#262626_1px,transparent_1px)]",
|
||||||
"[mask-image:radial-gradient(ellipse_at_top,black_70%,transparent_100%)]",
|
"[mask-image:radial-gradient(ellipse_at_top,black_70%,transparent_100%)]",
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ import { DevPanelListener } from "./dev-panel-listener";
|
||||||
import { Outlet, useNavigate } from "@tanstack/react-router";
|
import { Outlet, useNavigate } from "@tanstack/react-router";
|
||||||
import { AppBreadcrumb } from "./app-breadcrumb";
|
import { AppBreadcrumb } from "./app-breadcrumb";
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip";
|
import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip";
|
||||||
|
import { ThemeToggle } from "./theme-toggle";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
loaderData: AppContext;
|
loaderData: AppContext;
|
||||||
|
|
@ -35,23 +36,24 @@ export function Layout({ loaderData }: Props) {
|
||||||
<SidebarProvider defaultOpen={loaderData.sidebarOpen}>
|
<SidebarProvider defaultOpen={loaderData.sidebarOpen}>
|
||||||
<AppSidebar isInstanceAdmin={loaderData.user?.role === "admin"} />
|
<AppSidebar isInstanceAdmin={loaderData.user?.role === "admin"} />
|
||||||
<div className="w-full relative flex flex-col min-h-screen md:h-screen md:overflow-hidden">
|
<div className="w-full relative flex flex-col min-h-screen md:h-screen md:overflow-hidden">
|
||||||
<header className="z-50 bg-card-header border-b border-border/50 shrink-0 h-16.25">
|
<header className="z-50 bg-card-header border-b border-border/80 dark:border-border/50 shrink-0 h-16.25">
|
||||||
<div className="flex items-center h-full justify-between px-2 sm:px-8 mx-auto container gap-4">
|
<div className="flex items-center h-full justify-between px-2 sm:px-8 mx-auto container gap-4">
|
||||||
<div className="flex items-center gap-4 min-w-0">
|
<div className="flex items-center gap-4 min-w-0">
|
||||||
<SidebarTrigger />
|
<SidebarTrigger />
|
||||||
<AppBreadcrumb />
|
<AppBreadcrumb />
|
||||||
</div>
|
</div>
|
||||||
{loaderData.user && (
|
{loaderData.user && (
|
||||||
<div className="flex items-center bg-muted/30 border border-border/50 px-2 py-1 rounded-full shadow-sm">
|
<div className="flex items-center bg-card dark:bg-muted/30 border border-border/80 dark:border-border/50 px-2 py-1 rounded-full shadow-[0_2px_10px_-4px_rgba(0,0,0,0.05)] dark:shadow-sm">
|
||||||
<span className="text-sm text-muted-foreground hidden md:inline-flex pl-2 mr-5">
|
<span className="text-sm text-muted-foreground hidden md:inline-flex pl-2 mr-5">
|
||||||
<span className="text-foreground">{loaderData.user.name}</span>
|
<span className="text-foreground">{loaderData.user.name}</span>
|
||||||
</span>
|
</span>
|
||||||
|
<ThemeToggle />
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
className="rounded-full h-7 text-xs text-muted-foreground hover:text-white"
|
className="rounded-full h-7 text-xs text-muted-foreground hover:text-foreground"
|
||||||
onClick={handleLogout}
|
onClick={handleLogout}
|
||||||
>
|
>
|
||||||
<LogOut className="w-4 h-4" />
|
<LogOut className="w-4 h-4" />
|
||||||
|
|
@ -64,7 +66,7 @@ export function Layout({ loaderData }: Props) {
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
className="relative overflow-hidden hidden lg:inline-flex rounded-full h-7 w-7 text-muted-foreground hover:text-white"
|
className="relative overflow-hidden hidden lg:inline-flex rounded-full h-7 w-7 text-muted-foreground hover:text-foreground"
|
||||||
>
|
>
|
||||||
<a
|
<a
|
||||||
href="https://github.com/nicotsx/zerobyte/issues/new"
|
href="https://github.com/nicotsx/zerobyte/issues/new"
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,7 @@ export const OnOff = ({ isOn, toggle, enabledLabel, disabledLabel, disabled }: P
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex items-center gap-2 rounded-full border px-3 py-1.5 text-xs font-semibold uppercase tracking-wide transition-colors",
|
"flex items-center gap-2 rounded-full border px-3 py-1.5 text-xs font-semibold uppercase tracking-wide transition-colors",
|
||||||
isOn
|
isOn
|
||||||
? "border-emerald-200 bg-emerald-50 text-emerald-700 dark:border-emerald-500/40 dark:bg-emerald-500/10 dark:text-emerald-200"
|
? "border-success/30 bg-success/10 text-success"
|
||||||
: "border-muted bg-muted/40 text-muted-foreground dark:border-muted/60 dark:bg-muted/10",
|
: "border-muted bg-muted/40 text-muted-foreground dark:border-muted/60 dark:bg-muted/10",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
|
|
||||||
|
|
@ -59,7 +59,7 @@ export function OrganizationSwitcher() {
|
||||||
size="lg"
|
size="lg"
|
||||||
className="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground"
|
className="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground"
|
||||||
>
|
>
|
||||||
<div className="bg-black text-sidebar-primary-foreground flex aspect-square size-8 shrink-0 items-center justify-center overflow-hidden rounded-lg">
|
<div className="bg-foreground text-sidebar-primary-foreground flex aspect-square size-8 shrink-0 items-center justify-center overflow-hidden rounded-lg">
|
||||||
{activeOrganization?.logo ? (
|
{activeOrganization?.logo ? (
|
||||||
<img
|
<img
|
||||||
src={activeOrganization.logo}
|
src={activeOrganization.logo}
|
||||||
|
|
|
||||||
|
|
@ -133,6 +133,7 @@ export function RestoreForm({ repository, snapshotId, returnPath, basePath }: Re
|
||||||
body: {
|
body: {
|
||||||
snapshotId,
|
snapshotId,
|
||||||
include: includePaths.length > 0 ? includePaths : undefined,
|
include: includePaths.length > 0 ? includePaths : undefined,
|
||||||
|
selectedItemKind: includePaths.length === 1 ? (selectedPathKind ?? undefined) : undefined,
|
||||||
excludeXattr: excludeXattrArray && excludeXattrArray.length > 0 ? excludeXattrArray : undefined,
|
excludeXattr: excludeXattrArray && excludeXattrArray.length > 0 ? excludeXattrArray : undefined,
|
||||||
targetPath,
|
targetPath,
|
||||||
overwrite: overwriteMode,
|
overwrite: overwriteMode,
|
||||||
|
|
@ -145,6 +146,7 @@ export function RestoreForm({ repository, snapshotId, returnPath, basePath }: Re
|
||||||
restoreLocation,
|
restoreLocation,
|
||||||
customTargetPath,
|
customTargetPath,
|
||||||
selectedPaths,
|
selectedPaths,
|
||||||
|
selectedPathKind,
|
||||||
overwriteMode,
|
overwriteMode,
|
||||||
restoreSnapshot,
|
restoreSnapshot,
|
||||||
]);
|
]);
|
||||||
|
|
|
||||||
|
|
@ -12,8 +12,8 @@ interface StatusDotProps {
|
||||||
export const StatusDot = ({ variant, label, animated }: StatusDotProps) => {
|
export const StatusDot = ({ variant, label, animated }: StatusDotProps) => {
|
||||||
const statusMapping = {
|
const statusMapping = {
|
||||||
success: {
|
success: {
|
||||||
color: "bg-emerald-500 shadow-[0_0_8px_rgba(16,185,129,0.5)]",
|
color: "bg-success shadow-[0_0_8px_rgba(16,185,129,0.5)]",
|
||||||
colorLight: "bg-emerald-400",
|
colorLight: "bg-success/60",
|
||||||
animated: animated ?? true,
|
animated: animated ?? true,
|
||||||
},
|
},
|
||||||
neutral: {
|
neutral: {
|
||||||
|
|
|
||||||
35
app/client/components/theme-provider.tsx
Normal file
35
app/client/components/theme-provider.tsx
Normal file
|
|
@ -0,0 +1,35 @@
|
||||||
|
import { createContext, useCallback, useContext, useState } from "react";
|
||||||
|
|
||||||
|
type Theme = "light" | "dark";
|
||||||
|
|
||||||
|
type ThemeContextValue = {
|
||||||
|
theme: Theme;
|
||||||
|
setTheme: (theme: Theme) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
const ThemeContext = createContext<ThemeContextValue | null>(null);
|
||||||
|
|
||||||
|
export const THEME_COOKIE_NAME = "theme";
|
||||||
|
export const THEME_COOKIE_MAX_AGE = 60 * 60 * 24 * 365; // 1 year
|
||||||
|
const DEFAULT_THEME: Theme = "dark";
|
||||||
|
|
||||||
|
export function ThemeProvider({ children, initialTheme }: { children: React.ReactNode; initialTheme?: Theme }) {
|
||||||
|
const [theme, setThemeState] = useState<Theme>(initialTheme ?? DEFAULT_THEME);
|
||||||
|
|
||||||
|
const setTheme = useCallback((newTheme: Theme) => {
|
||||||
|
setThemeState(newTheme);
|
||||||
|
document.cookie = `${THEME_COOKIE_NAME}=${newTheme}; path=/; max-age=${THEME_COOKIE_MAX_AGE}`;
|
||||||
|
document.documentElement.classList.toggle("dark", newTheme === "dark");
|
||||||
|
document.documentElement.style.colorScheme = newTheme;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return <ThemeContext value={{ theme, setTheme }}>{children}</ThemeContext>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useTheme() {
|
||||||
|
const context = useContext(ThemeContext);
|
||||||
|
if (!context) {
|
||||||
|
throw new Error("useTheme must be used within a ThemeProvider");
|
||||||
|
}
|
||||||
|
return context;
|
||||||
|
}
|
||||||
26
app/client/components/theme-toggle.tsx
Normal file
26
app/client/components/theme-toggle.tsx
Normal file
|
|
@ -0,0 +1,26 @@
|
||||||
|
import { Moon, Sun } from "lucide-react";
|
||||||
|
import { useTheme } from "./theme-provider";
|
||||||
|
import { Button } from "./ui/button";
|
||||||
|
import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip";
|
||||||
|
|
||||||
|
export function ThemeToggle() {
|
||||||
|
const { theme, setTheme } = useTheme();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="relative rounded-full h-7 w-7 text-muted-foreground hover:text-foreground"
|
||||||
|
onClick={() => setTheme(theme === "dark" ? "light" : "dark")}
|
||||||
|
aria-label="Toggle theme"
|
||||||
|
>
|
||||||
|
<Sun className="h-4 w-4 rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0" />
|
||||||
|
<Moon className="absolute h-4 w-4 rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100" />
|
||||||
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>Toggle theme</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -11,13 +11,12 @@ const buttonVariants = cva(
|
||||||
{
|
{
|
||||||
variants: {
|
variants: {
|
||||||
variant: {
|
variant: {
|
||||||
default:
|
default: "bg-transparent text-foreground hover:bg-muted border hover:border-border",
|
||||||
"bg-transparent text-white hover:bg-[#3A3A3A]/80 border hover:border-white/40 dark:text-white dark:hover:bg-[#3A3A3A]/80",
|
|
||||||
primary: "bg-strong-accent text-white hover:bg-strong-accent/90 focus-visible:ring-strong-accent/50",
|
primary: "bg-strong-accent text-white hover:bg-strong-accent/90 focus-visible:ring-strong-accent/50",
|
||||||
destructive:
|
destructive:
|
||||||
"border border-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/50 text-destructive hover:text-white",
|
"border border-destructive text-destructive hover:bg-destructive hover:text-destructive-foreground focus-visible:ring-destructive/50",
|
||||||
outline: "border border-border bg-background hover:bg-accent hover:text-accent-foreground",
|
outline: "border border-border bg-background hover:bg-accent hover:text-accent-foreground",
|
||||||
secondary: "bg-transparent text-white hover:bg-[#3A3A3A]/80 border dark:text-white dark:hover:bg-[#3A3A3A]/80",
|
secondary: "bg-transparent text-foreground hover:bg-muted border",
|
||||||
ghost: "hover:bg-accent hover:text-accent-foreground",
|
ghost: "hover:bg-accent hover:text-accent-foreground",
|
||||||
link: "text-primary underline-offset-4 hover:underline",
|
link: "text-primary underline-offset-4 hover:underline",
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@ function Card({ className, children, interactive, ...props }: React.ComponentPro
|
||||||
<div
|
<div
|
||||||
data-slot="card"
|
data-slot="card"
|
||||||
className={cn(
|
className={cn(
|
||||||
"bg-card text-card-foreground group relative flex flex-col gap-6 border border-border py-6 shadow-[inset_0_0_0_1px_rgba(255,255,255,0.02)] transition-colors duration-300 ",
|
"bg-card text-card-foreground group relative flex flex-col gap-6 border border-border py-6 shadow-[0_8px_30px_-15px_rgba(0,0,0,0.04)] dark:shadow-[inset_0_0_0_1px_rgba(255,255,255,0.02)] transition-colors duration-300 ",
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|
@ -21,14 +21,14 @@ function Card({ className, children, interactive, ...props }: React.ComponentPro
|
||||||
},
|
},
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<span className="absolute -left-0.5 -top-0.5 h-0.5 w-4 bg-white/80" />
|
<span className="absolute -left-0.5 -top-0.5 h-0.5 w-4 bg-foreground" />
|
||||||
<span className="absolute -left-0.5 -top-0.5 h-4 w-0.5 bg-white/80" />
|
<span className="absolute -left-0.5 -top-0.5 h-4 w-0.5 bg-foreground" />
|
||||||
<span className="absolute -right-0.5 -top-0.5 h-0.5 w-4 bg-white/80" />
|
<span className="absolute -right-0.5 -top-0.5 h-0.5 w-4 bg-foreground" />
|
||||||
<span className="absolute -right-0.5 -top-0.5 h-4 w-0.5 bg-white/80" />
|
<span className="absolute -right-0.5 -top-0.5 h-4 w-0.5 bg-foreground" />
|
||||||
<span className="absolute -left-0.5 -bottom-0.5 h-0.5 w-4 bg-white/80" />
|
<span className="absolute -left-0.5 -bottom-0.5 h-0.5 w-4 bg-foreground" />
|
||||||
<span className="absolute -left-0.5 -bottom-0.5 h-4 w-0.5 bg-white/80" />
|
<span className="absolute -left-0.5 -bottom-0.5 h-4 w-0.5 bg-foreground" />
|
||||||
<span className="absolute -right-0.5 -bottom-0.5 h-0.5 w-4 bg-white/80" />
|
<span className="absolute -right-0.5 -bottom-0.5 h-0.5 w-4 bg-foreground" />
|
||||||
<span className="absolute -right-0.5 -bottom-0.5 h-4 w-0.5 bg-white/80" />
|
<span className="absolute -right-0.5 -bottom-0.5 h-4 w-0.5 bg-foreground" />
|
||||||
</span>
|
</span>
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,13 @@
|
||||||
import type * as React from "react";
|
import * as React from "react";
|
||||||
import * as CheckboxPrimitive from "@radix-ui/react-checkbox";
|
import * as CheckboxPrimitive from "@radix-ui/react-checkbox";
|
||||||
import { CheckIcon } from "lucide-react";
|
import { CheckIcon } from "lucide-react";
|
||||||
|
|
||||||
|
import { useWebHaptics } from "web-haptics/react";
|
||||||
import { cn } from "~/client/lib/utils";
|
import { cn } from "~/client/lib/utils";
|
||||||
|
|
||||||
function Checkbox({ className, ...props }: React.ComponentProps<typeof CheckboxPrimitive.Root>) {
|
function Checkbox({ className, ...props }: React.ComponentProps<typeof CheckboxPrimitive.Root>) {
|
||||||
|
const trigger = useWebHaptics().trigger;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<CheckboxPrimitive.Root
|
<CheckboxPrimitive.Root
|
||||||
data-slot="checkbox"
|
data-slot="checkbox"
|
||||||
|
|
@ -12,6 +15,10 @@ function Checkbox({ className, ...props }: React.ComponentProps<typeof CheckboxP
|
||||||
"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",
|
"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,
|
className,
|
||||||
)}
|
)}
|
||||||
|
onCheckedChange={(checked) => {
|
||||||
|
void trigger([{ duration: 8 }], { intensity: 0.3 });
|
||||||
|
props.onCheckedChange?.(checked);
|
||||||
|
}}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
<CheckboxPrimitive.Indicator
|
<CheckboxPrimitive.Indicator
|
||||||
|
|
|
||||||
|
|
@ -8,8 +8,8 @@ interface CodeBlockProps {
|
||||||
|
|
||||||
export const CodeBlock: React.FC<CodeBlockProps> = ({ code, filename }) => {
|
export const CodeBlock: React.FC<CodeBlockProps> = ({ code, filename }) => {
|
||||||
return (
|
return (
|
||||||
<div className="overflow-hidden rounded-sm bg-card-header ring-1 ring-white/10">
|
<div className="overflow-hidden rounded-sm bg-card-header ring-1 ring-border">
|
||||||
<div className="flex items-center justify-between border-b border-white/10 px-4 py-2 text-xs">
|
<div className="flex items-center justify-between border-b border-border px-4 py-2 text-xs">
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
<span className="h-2.5 w-2.5 rounded-full bg-rose-500" />
|
<span className="h-2.5 w-2.5 rounded-full bg-rose-500" />
|
||||||
<span className="h-2.5 w-2.5 rounded-full bg-amber-500" />
|
<span className="h-2.5 w-2.5 rounded-full bg-amber-500" />
|
||||||
|
|
@ -18,7 +18,7 @@ export const CodeBlock: React.FC<CodeBlockProps> = ({ code, filename }) => {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<pre className="text-xs m-0 px-4 py-2 bg-card-header">
|
<pre className="text-xs m-0 px-4 py-2 bg-card-header">
|
||||||
<code className="text-white/80 select-all">{code}</code>
|
<code className="text-foreground/80 select-all">{code}</code>
|
||||||
</pre>
|
</pre>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { useTheme } from "next-themes";
|
import { useTheme } from "~/client/components/theme-provider";
|
||||||
import { Toaster as Sonner, type ToasterProps } from "sonner";
|
import { Toaster as Sonner, type ToasterProps } from "sonner";
|
||||||
|
|
||||||
const Toaster = ({ ...props }: ToasterProps) => {
|
const Toaster = ({ ...props }: ToasterProps) => {
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,12 @@
|
||||||
import * as SwitchPrimitive from "@radix-ui/react-switch";
|
import * as SwitchPrimitive from "@radix-ui/react-switch";
|
||||||
import type * as React from "react";
|
import * as React from "react";
|
||||||
|
|
||||||
|
import { useWebHaptics } from "web-haptics/react";
|
||||||
import { cn } from "~/client/lib/utils";
|
import { cn } from "~/client/lib/utils";
|
||||||
|
|
||||||
function Switch({ className, ...props }: React.ComponentProps<typeof SwitchPrimitive.Root>) {
|
function Switch({ className, ...props }: React.ComponentProps<typeof SwitchPrimitive.Root>) {
|
||||||
|
const trigger = useWebHaptics().trigger;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SwitchPrimitive.Root
|
<SwitchPrimitive.Root
|
||||||
data-slot="switch"
|
data-slot="switch"
|
||||||
|
|
@ -11,6 +14,10 @@ function Switch({ className, ...props }: React.ComponentProps<typeof SwitchPrimi
|
||||||
"cursor-pointer peer data-[state=checked]:bg-primary data-[state=unchecked]:bg-input focus-visible:border-ring focus-visible:ring-ring/50 dark:data-[state=unchecked]:bg-input/80 inline-flex h-[1.15rem] w-8 shrink-0 items-center rounded-full border border-transparent shadow-xs transition-all outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
|
"cursor-pointer peer data-[state=checked]:bg-primary data-[state=unchecked]:bg-input focus-visible:border-ring focus-visible:ring-ring/50 dark:data-[state=unchecked]:bg-input/80 inline-flex h-[1.15rem] w-8 shrink-0 items-center rounded-full border border-transparent shadow-xs transition-all outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
|
onCheckedChange={(checked) => {
|
||||||
|
void trigger([{ duration: 8 }], { intensity: 0.3 });
|
||||||
|
props.onCheckedChange?.(checked);
|
||||||
|
}}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
<SwitchPrimitive.Thumb
|
<SwitchPrimitive.Thumb
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import * as TabsPrimitive from "@radix-ui/react-tabs";
|
import * as TabsPrimitive from "@radix-ui/react-tabs";
|
||||||
import type * as React from "react";
|
import type * as React from "react";
|
||||||
|
|
||||||
|
import { useWebHaptics } from "web-haptics/react";
|
||||||
import { cn } from "~/client/lib/utils";
|
import { cn } from "~/client/lib/utils";
|
||||||
|
|
||||||
function Tabs({ className, ...props }: React.ComponentProps<typeof TabsPrimitive.Root>) {
|
function Tabs({ className, ...props }: React.ComponentProps<typeof TabsPrimitive.Root>) {
|
||||||
|
|
@ -17,7 +18,14 @@ function TabsList({ className, ...props }: React.ComponentProps<typeof TabsPrimi
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function TabsTrigger({ className, ...props }: React.ComponentProps<typeof TabsPrimitive.Trigger>) {
|
function TabsTrigger({ className, onClick, ...props }: React.ComponentProps<typeof TabsPrimitive.Trigger>) {
|
||||||
|
const { trigger } = useWebHaptics();
|
||||||
|
|
||||||
|
const handleClick: React.MouseEventHandler<HTMLButtonElement> = (event) => {
|
||||||
|
void trigger([{ duration: 8 }], { intensity: 0.3 });
|
||||||
|
onClick?.(event);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TabsPrimitive.Trigger
|
<TabsPrimitive.Trigger
|
||||||
data-slot="tabs-trigger"
|
data-slot="tabs-trigger"
|
||||||
|
|
@ -29,24 +37,25 @@ function TabsTrigger({ className, ...props }: React.ComponentProps<typeof TabsPr
|
||||||
// Padding: 20px horizontal (8px for bracket tick + 12px gap to text)
|
// Padding: 20px horizontal (8px for bracket tick + 12px gap to text)
|
||||||
"px-5",
|
"px-5",
|
||||||
// Transparent orange background for active state
|
// Transparent orange background for active state
|
||||||
"data-[state=active]:bg-[#FF453A]/10",
|
"data-[state=active]:bg-strong-accent/10",
|
||||||
// Left bracket - vertical line
|
// Left bracket - vertical line
|
||||||
"before:absolute before:left-0 before:top-0 before:h-7 before:w-0.5 before:bg-[#5D6570] before:transition-colors data-[state=active]:before:bg-[#FF453A]",
|
"before:absolute before:left-0 before:top-0 before:h-7 before:w-0.5 before:bg-muted-foreground/60 before:transition-colors data-[state=active]:before:bg-strong-accent",
|
||||||
// Left bracket - top tick
|
// Left bracket - top tick
|
||||||
"after:absolute after:left-0 after:-top-px after:w-2 after:h-0.5 after:bg-[#5D6570] after:transition-colors data-[state=active]:after:bg-[#FF453A]",
|
"after:absolute after:left-0 after:-top-px after:w-2 after:h-0.5 after:bg-muted-foreground/60 after:transition-colors data-[state=active]:after:bg-strong-accent",
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
|
onClick={handleClick}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
<span className="relative z-10">{props.children}</span>
|
<span className="relative z-10">{props.children}</span>
|
||||||
{/* Left bracket - bottom tick */}
|
{/* Left bracket - bottom tick */}
|
||||||
<span className="absolute left-0 bottom-[-1px] h-0.5 w-2 bg-[#5D6570] transition-colors group-data-[state=active]:bg-[#FF453A]" />
|
<span className="absolute left-0 bottom-[-1px] h-0.5 w-2 bg-muted-foreground/60 transition-colors group-data-[state=active]:bg-strong-accent" />
|
||||||
{/* Right bracket - top tick */}
|
{/* Right bracket - top tick */}
|
||||||
<span className="absolute right-0 top-[-1px] h-0.5 w-2 bg-[#5D6570] transition-colors group-data-[state=active]:bg-[#FF453A]" />
|
<span className="absolute right-0 top-[-1px] h-0.5 w-2 bg-muted-foreground/60 transition-colors group-data-[state=active]:bg-strong-accent" />
|
||||||
{/* Right bracket - vertical line */}
|
{/* Right bracket - vertical line */}
|
||||||
<span className="absolute right-0 top-0 h-7 w-0.5 bg-[#5D6570] transition-colors group-data-[state=active]:bg-[#FF453A]" />
|
<span className="absolute right-0 top-0 h-7 w-0.5 bg-muted-foreground/60 transition-colors group-data-[state=active]:bg-strong-accent" />
|
||||||
{/* Right bracket - bottom tick */}
|
{/* Right bracket - bottom tick */}
|
||||||
<span className="absolute right-0 bottom-[-1px] h-0.5 w-2 bg-[#5D6570] transition-colors group-data-[state=active]:bg-[#FF453A]" />
|
<span className="absolute right-0 bottom-[-1px] h-0.5 w-2 bg-muted-foreground/60 transition-colors group-data-[state=active]:bg-strong-accent" />
|
||||||
</TabsPrimitive.Trigger>
|
</TabsPrimitive.Trigger>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
132
app/client/hooks/__tests__/use-server-events.test.tsx
Normal file
132
app/client/hooks/__tests__/use-server-events.test.tsx
Normal file
|
|
@ -0,0 +1,132 @@
|
||||||
|
import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test";
|
||||||
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
|
import { cleanup, render, screen } from "@testing-library/react";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { useServerEvents } from "../use-server-events";
|
||||||
|
|
||||||
|
class MockEventSource {
|
||||||
|
static instances: MockEventSource[] = [];
|
||||||
|
|
||||||
|
public onerror: ((event: Event) => void) | null = null;
|
||||||
|
public close = mock(() => {});
|
||||||
|
private listeners = new Map<string, Set<(event: Event) => void>>();
|
||||||
|
|
||||||
|
constructor(public url: string) {
|
||||||
|
MockEventSource.instances.push(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
addEventListener(type: string, listener: EventListenerOrEventListenerObject) {
|
||||||
|
const listeners = this.listeners.get(type) ?? new Set<(event: Event) => void>();
|
||||||
|
const callback = typeof listener === "function" ? listener : (event: Event) => listener.handleEvent(event);
|
||||||
|
listeners.add(callback);
|
||||||
|
this.listeners.set(type, listeners);
|
||||||
|
}
|
||||||
|
|
||||||
|
emit(type: string, data: unknown) {
|
||||||
|
const event = new MessageEvent(type, {
|
||||||
|
data: JSON.stringify(data),
|
||||||
|
});
|
||||||
|
for (const listener of this.listeners.get(type) ?? []) {
|
||||||
|
listener(event);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static reset() {
|
||||||
|
MockEventSource.instances = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const originalEventSource = globalThis.EventSource;
|
||||||
|
const originalConsoleInfo = console.info;
|
||||||
|
const originalConsoleError = console.error;
|
||||||
|
|
||||||
|
const createTestQueryClient = () =>
|
||||||
|
new QueryClient({
|
||||||
|
defaultOptions: {
|
||||||
|
queries: {
|
||||||
|
retry: false,
|
||||||
|
gcTime: Infinity,
|
||||||
|
},
|
||||||
|
mutations: {
|
||||||
|
gcTime: Infinity,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const ConnectionConsumer = () => {
|
||||||
|
useServerEvents();
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const BackupCompletedListener = ({ scheduleId }: { scheduleId: string }) => {
|
||||||
|
const { addEventListener } = useServerEvents();
|
||||||
|
const [status, setStatus] = useState("pending");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const abortController = new AbortController();
|
||||||
|
|
||||||
|
addEventListener(
|
||||||
|
"backup:completed",
|
||||||
|
(event) => {
|
||||||
|
if (event.scheduleId === scheduleId) {
|
||||||
|
setStatus(event.status);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ signal: abortController.signal },
|
||||||
|
);
|
||||||
|
|
||||||
|
return () => abortController.abort();
|
||||||
|
}, [addEventListener, scheduleId]);
|
||||||
|
|
||||||
|
return <div>{status}</div>;
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("useServerEvents", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
MockEventSource.reset();
|
||||||
|
globalThis.EventSource = MockEventSource as unknown as typeof EventSource;
|
||||||
|
console.info = mock(() => {});
|
||||||
|
console.error = mock(() => {});
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
cleanup();
|
||||||
|
globalThis.EventSource = originalEventSource;
|
||||||
|
console.info = originalConsoleInfo;
|
||||||
|
console.error = originalConsoleError;
|
||||||
|
MockEventSource.reset();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("shares one EventSource across consumers and invalidates queries once on backup completion", async () => {
|
||||||
|
const queryClient = createTestQueryClient();
|
||||||
|
const invalidateQueries = mock(async () => undefined);
|
||||||
|
const refetchQueries = mock(async () => undefined);
|
||||||
|
queryClient.invalidateQueries = invalidateQueries as typeof queryClient.invalidateQueries;
|
||||||
|
queryClient.refetchQueries = refetchQueries as typeof queryClient.refetchQueries;
|
||||||
|
|
||||||
|
render(
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<ConnectionConsumer />
|
||||||
|
<BackupCompletedListener scheduleId="0b9c940b" />
|
||||||
|
</QueryClientProvider>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(MockEventSource.instances).toHaveLength(1);
|
||||||
|
|
||||||
|
MockEventSource.instances[0]?.emit("backup:completed", {
|
||||||
|
organizationId: "default-org",
|
||||||
|
scheduleId: "0b9c940b",
|
||||||
|
volumeName: "synology",
|
||||||
|
repositoryName: "swiss-backup",
|
||||||
|
status: "success",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(await screen.findByText("success")).toBeTruthy();
|
||||||
|
expect(invalidateQueries).toHaveBeenCalledTimes(1);
|
||||||
|
expect(refetchQueries).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
cleanup();
|
||||||
|
|
||||||
|
expect(MockEventSource.instances[0]?.close).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -2,18 +2,20 @@ import * as React from "react";
|
||||||
|
|
||||||
const MOBILE_BREAKPOINT = 768;
|
const MOBILE_BREAKPOINT = 768;
|
||||||
|
|
||||||
export function useIsMobile() {
|
function subscribe(callback: () => void) {
|
||||||
const [isMobile, setIsMobile] = React.useState<boolean | undefined>(undefined);
|
const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`);
|
||||||
|
mql.addEventListener("change", callback);
|
||||||
React.useEffect(() => {
|
return () => mql.removeEventListener("change", callback);
|
||||||
const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`);
|
}
|
||||||
const onChange = () => {
|
|
||||||
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT);
|
function getSnapshot() {
|
||||||
};
|
return window.innerWidth < MOBILE_BREAKPOINT;
|
||||||
mql.addEventListener("change", onChange);
|
}
|
||||||
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT);
|
|
||||||
return () => mql.removeEventListener("change", onChange);
|
function getServerSnapshot() {
|
||||||
}, []);
|
return false;
|
||||||
|
}
|
||||||
return !!isMobile;
|
|
||||||
|
export function useIsMobile() {
|
||||||
|
return React.useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { useQueryClient } from "@tanstack/react-query";
|
import { type QueryClient, useQueryClient } from "@tanstack/react-query";
|
||||||
import { useCallback, useEffect, useRef } from "react";
|
import { useCallback, useEffect, useRef } from "react";
|
||||||
import { serverEventNames, type ServerEventPayloadMap } from "~/schemas/server-events";
|
import { serverEventNames, type ServerEventPayloadMap } from "~/schemas/server-events";
|
||||||
|
|
||||||
|
|
@ -16,6 +16,13 @@ type EventHandlerMap = {
|
||||||
[K in ServerEventType]?: EventHandlerSet<K>;
|
[K in ServerEventType]?: EventHandlerSet<K>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type SharedServerEventsState = {
|
||||||
|
eventSource: EventSource | null;
|
||||||
|
handlers: EventHandlerMap;
|
||||||
|
queryClient: QueryClient | null;
|
||||||
|
subscribers: number;
|
||||||
|
};
|
||||||
|
|
||||||
const invalidatingEvents = new Set<ServerEventType>([
|
const invalidatingEvents = new Set<ServerEventType>([
|
||||||
"backup:completed",
|
"backup:completed",
|
||||||
"volume:updated",
|
"volume:updated",
|
||||||
|
|
@ -31,103 +38,151 @@ export type RestoreProgressEvent = ServerEventsPayloadMap["restore:progress"];
|
||||||
export type RestoreCompletedEvent = ServerEventsPayloadMap["restore:completed"];
|
export type RestoreCompletedEvent = ServerEventsPayloadMap["restore:completed"];
|
||||||
export type BackupProgressEvent = ServerEventsPayloadMap["backup:progress"];
|
export type BackupProgressEvent = ServerEventsPayloadMap["backup:progress"];
|
||||||
|
|
||||||
|
const sharedState: SharedServerEventsState = {
|
||||||
|
eventSource: null,
|
||||||
|
handlers: {},
|
||||||
|
queryClient: null,
|
||||||
|
subscribers: 0,
|
||||||
|
};
|
||||||
|
|
||||||
const parseEventData = <T extends ServerEventType>(event: Event): ServerEventsPayloadMap[T] =>
|
const parseEventData = <T extends ServerEventType>(event: Event): ServerEventsPayloadMap[T] =>
|
||||||
JSON.parse((event as MessageEvent<string>).data) as ServerEventsPayloadMap[T];
|
JSON.parse((event as MessageEvent<string>).data) as ServerEventsPayloadMap[T];
|
||||||
|
|
||||||
|
const isAbortError = (error: unknown): error is Error => error instanceof Error && error.name === "AbortError";
|
||||||
|
|
||||||
|
const emit = <T extends ServerEventType>(eventName: T, data: ServerEventsPayloadMap[T]) => {
|
||||||
|
const handlers = sharedState.handlers[eventName] as EventHandlerSet<T> | undefined;
|
||||||
|
handlers?.forEach((handler) => {
|
||||||
|
handler(data);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const refreshQueriesForEvent = (eventName: ServerEventType) => {
|
||||||
|
if (!invalidatingEvents.has(eventName) || !sharedState.queryClient) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
void sharedState.queryClient.invalidateQueries().catch((error) => {
|
||||||
|
if (!isAbortError(error)) {
|
||||||
|
console.error(`[SSE] Failed to refresh queries after ${eventName}:`, error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const connectEventSource = (queryClient: QueryClient) => {
|
||||||
|
sharedState.queryClient = queryClient;
|
||||||
|
if (sharedState.eventSource) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const eventSource = new EventSource("/api/v1/events");
|
||||||
|
sharedState.eventSource = eventSource;
|
||||||
|
|
||||||
|
eventSource.addEventListener("connected", (event) => {
|
||||||
|
const data = parseEventData<"connected">(event);
|
||||||
|
console.info("[SSE] Connected to server events");
|
||||||
|
emit("connected", data);
|
||||||
|
});
|
||||||
|
|
||||||
|
eventSource.addEventListener("heartbeat", (event) => {
|
||||||
|
emit("heartbeat", parseEventData<"heartbeat">(event));
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const eventName of serverEventNames) {
|
||||||
|
eventSource.addEventListener(eventName, (event) => {
|
||||||
|
const data = parseEventData<typeof eventName>(event);
|
||||||
|
console.info(`[SSE] ${eventName}:`, data);
|
||||||
|
|
||||||
|
refreshQueriesForEvent(eventName);
|
||||||
|
|
||||||
|
if (eventName === "volume:status_changed") {
|
||||||
|
const statusData = data as ServerEventsPayloadMap["volume:status_changed"];
|
||||||
|
emit("volume:status_changed", statusData);
|
||||||
|
emit("volume:updated", statusData);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
emit(eventName, data);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
eventSource.onerror = (error) => {
|
||||||
|
console.error("[SSE] Connection error:", error);
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const disconnectEventSource = () => {
|
||||||
|
if (!sharedState.eventSource) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.info("[SSE] Disconnecting from server events");
|
||||||
|
sharedState.eventSource.close();
|
||||||
|
sharedState.eventSource = null;
|
||||||
|
sharedState.queryClient = null;
|
||||||
|
sharedState.handlers = {};
|
||||||
|
};
|
||||||
|
|
||||||
|
const addSharedEventListener = <T extends ServerEventType>(
|
||||||
|
eventName: T,
|
||||||
|
handler: EventHandler<T>,
|
||||||
|
options?: { signal?: AbortSignal },
|
||||||
|
) => {
|
||||||
|
if (options?.signal?.aborted) {
|
||||||
|
return () => {};
|
||||||
|
}
|
||||||
|
|
||||||
|
const existingHandlers = sharedState.handlers[eventName] as EventHandlerSet<T> | undefined;
|
||||||
|
const eventHandlers = existingHandlers ?? new Set<EventHandler<T>>();
|
||||||
|
eventHandlers.add(handler);
|
||||||
|
sharedState.handlers[eventName] = eventHandlers as EventHandlerMap[T];
|
||||||
|
|
||||||
|
const unsubscribe = () => {
|
||||||
|
const handlers = sharedState.handlers[eventName] as EventHandlerSet<T> | undefined;
|
||||||
|
handlers?.delete(handler);
|
||||||
|
if (handlers && handlers.size === 0) {
|
||||||
|
delete sharedState.handlers[eventName];
|
||||||
|
}
|
||||||
|
if (options?.signal) {
|
||||||
|
options.signal.removeEventListener("abort", unsubscribe);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (options?.signal) {
|
||||||
|
options.signal.addEventListener("abort", unsubscribe, { once: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
return unsubscribe;
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Hook to listen to Server-Sent Events (SSE) from the backend
|
* Hook to listen to Server-Sent Events (SSE) from the backend
|
||||||
* Automatically handles cache invalidation for backup and volume events
|
* Automatically handles cache invalidation for backup and volume events
|
||||||
*/
|
*/
|
||||||
export function useServerEvents() {
|
export function useServerEvents() {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const eventSourceRef = useRef<EventSource | null>(null);
|
const addEventListener = useCallback(addSharedEventListener, []);
|
||||||
const handlersRef = useRef<EventHandlerMap>({});
|
const hasMountedRef = useRef(false);
|
||||||
const emit = useCallback(<T extends ServerEventType>(eventName: T, data: ServerEventsPayloadMap[T]) => {
|
|
||||||
const handlers = handlersRef.current[eventName] as EventHandlerSet<T> | undefined;
|
|
||||||
handlers?.forEach((handler) => {
|
|
||||||
handler(data);
|
|
||||||
});
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const eventSource = new EventSource("/api/v1/events");
|
connectEventSource(queryClient);
|
||||||
eventSourceRef.current = eventSource;
|
if (!hasMountedRef.current) {
|
||||||
|
sharedState.subscribers += 1;
|
||||||
eventSource.addEventListener("connected", (event) => {
|
hasMountedRef.current = true;
|
||||||
const data = parseEventData<"connected">(event);
|
|
||||||
console.info("[SSE] Connected to server events");
|
|
||||||
emit("connected", data);
|
|
||||||
});
|
|
||||||
|
|
||||||
eventSource.addEventListener("heartbeat", (event) => {
|
|
||||||
emit("heartbeat", parseEventData<"heartbeat">(event));
|
|
||||||
});
|
|
||||||
|
|
||||||
for (const eventName of serverEventNames) {
|
|
||||||
eventSource.addEventListener(eventName, (event) => {
|
|
||||||
const data = parseEventData<typeof eventName>(event);
|
|
||||||
console.info(`[SSE] ${eventName}:`, data);
|
|
||||||
|
|
||||||
if (invalidatingEvents.has(eventName)) {
|
|
||||||
void queryClient.invalidateQueries();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (eventName === "backup:completed") {
|
|
||||||
void queryClient.refetchQueries();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (eventName === "volume:status_changed") {
|
|
||||||
const statusData = data as ServerEventsPayloadMap["volume:status_changed"];
|
|
||||||
emit("volume:status_changed", statusData);
|
|
||||||
emit("volume:updated", statusData);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
emit(eventName, data);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
eventSource.onerror = (error) => {
|
|
||||||
console.error("[SSE] Connection error:", error);
|
|
||||||
};
|
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
console.info("[SSE] Disconnecting from server events");
|
if (!hasMountedRef.current) {
|
||||||
eventSource.close();
|
return;
|
||||||
eventSourceRef.current = null;
|
}
|
||||||
|
|
||||||
|
hasMountedRef.current = false;
|
||||||
|
sharedState.subscribers = Math.max(0, sharedState.subscribers - 1);
|
||||||
|
if (sharedState.subscribers === 0) {
|
||||||
|
disconnectEventSource();
|
||||||
|
}
|
||||||
};
|
};
|
||||||
}, [emit, queryClient]);
|
}, [queryClient]);
|
||||||
|
|
||||||
const addEventListener = useCallback(
|
|
||||||
<T extends ServerEventType>(eventName: T, handler: EventHandler<T>, options?: { signal?: AbortSignal }) => {
|
|
||||||
if (options?.signal?.aborted) {
|
|
||||||
return () => {};
|
|
||||||
}
|
|
||||||
|
|
||||||
const existingHandlers = handlersRef.current[eventName] as EventHandlerSet<T> | undefined;
|
|
||||||
const eventHandlers = existingHandlers ?? new Set<EventHandler<T>>();
|
|
||||||
eventHandlers.add(handler);
|
|
||||||
handlersRef.current[eventName] = eventHandlers as EventHandlerMap[T];
|
|
||||||
|
|
||||||
const unsubscribe = () => {
|
|
||||||
const handlers = handlersRef.current[eventName] as EventHandlerSet<T> | undefined;
|
|
||||||
handlers?.delete(handler);
|
|
||||||
if (handlers && handlers.size === 0) {
|
|
||||||
delete handlersRef.current[eventName];
|
|
||||||
}
|
|
||||||
if (options?.signal) {
|
|
||||||
options.signal.removeEventListener("abort", unsubscribe);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if (options?.signal) {
|
|
||||||
options.signal.addEventListener("abort", unsubscribe, { once: true });
|
|
||||||
}
|
|
||||||
|
|
||||||
return unsubscribe;
|
|
||||||
},
|
|
||||||
[],
|
|
||||||
);
|
|
||||||
|
|
||||||
return { addEventListener };
|
return { addEventListener };
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,45 +0,0 @@
|
||||||
export type LoginErrorCode =
|
|
||||||
| "ACCOUNT_LINK_REQUIRED"
|
|
||||||
| "EMAIL_NOT_VERIFIED"
|
|
||||||
| "INVITE_REQUIRED"
|
|
||||||
| "BANNED_USER"
|
|
||||||
| "SSO_LOGIN_FAILED";
|
|
||||||
|
|
||||||
const VALID_ERROR_CODES: Set<LoginErrorCode> = new Set([
|
|
||||||
"ACCOUNT_LINK_REQUIRED",
|
|
||||||
"EMAIL_NOT_VERIFIED",
|
|
||||||
"INVITE_REQUIRED",
|
|
||||||
"BANNED_USER",
|
|
||||||
"SSO_LOGIN_FAILED",
|
|
||||||
]);
|
|
||||||
|
|
||||||
export function decodeLoginError(error?: string): LoginErrorCode | null {
|
|
||||||
if (!error) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const code = decodeURIComponent(error);
|
|
||||||
|
|
||||||
if (VALID_ERROR_CODES.has(code as LoginErrorCode)) {
|
|
||||||
return code as LoginErrorCode;
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getLoginErrorDescription(errorCode: LoginErrorCode | null): string | null {
|
|
||||||
switch (errorCode) {
|
|
||||||
case "ACCOUNT_LINK_REQUIRED":
|
|
||||||
return "Your account exists but is not linked to this SSO provider. Sign in with username/password first, then enable auto linking in your provider settings or contact your administrator.";
|
|
||||||
case "EMAIL_NOT_VERIFIED":
|
|
||||||
return "Your identity provider did not mark your email as verified.";
|
|
||||||
case "INVITE_REQUIRED":
|
|
||||||
return "Access is invite-only. Ask an organization admin to send you an invitation before signing in with SSO.";
|
|
||||||
case "BANNED_USER":
|
|
||||||
return "You have been banned from this application. Please contact support if you believe this is an error.";
|
|
||||||
case "SSO_LOGIN_FAILED":
|
|
||||||
return "SSO authentication failed. Please try again.";
|
|
||||||
default:
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
20
app/client/lib/sso-errors.ts
Normal file
20
app/client/lib/sso-errors.ts
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
import { LOGIN_ERROR_CODES, type LoginErrorCode } from "~/lib/sso-errors";
|
||||||
|
|
||||||
|
export type { LoginErrorCode } from "~/lib/sso-errors";
|
||||||
|
export { getLoginErrorDescription } from "~/lib/sso-errors";
|
||||||
|
|
||||||
|
const VALID_ERROR_CODES = new Set<LoginErrorCode>(LOGIN_ERROR_CODES);
|
||||||
|
|
||||||
|
export function decodeLoginError(error?: string): LoginErrorCode | null {
|
||||||
|
if (!error) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const code = decodeURIComponent(error);
|
||||||
|
|
||||||
|
if (VALID_ERROR_CODES.has(code as LoginErrorCode)) {
|
||||||
|
return code as LoginErrorCode;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
@ -69,7 +69,7 @@ describe("LoginPage", () => {
|
||||||
|
|
||||||
expect(
|
expect(
|
||||||
await screen.findByText(
|
await screen.findByText(
|
||||||
"Your account exists but is not linked to this SSO provider. Sign in with username/password first, then enable auto linking in your provider settings or contact your administrator.",
|
"SSO sign-in was blocked because this email already belongs to another user in this instance. Contact your administrator to resolve the account conflict.",
|
||||||
),
|
),
|
||||||
).toBeTruthy();
|
).toBeTruthy();
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,4 @@
|
||||||
import { arktypeResolver } from "@hookform/resolvers/arktype";
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
import { type } from "arktype";
|
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
|
@ -10,20 +9,23 @@ import { Input } from "~/client/components/ui/input";
|
||||||
import { InputOTP, InputOTPGroup, InputOTPSeparator, InputOTPSlot } from "~/client/components/ui/input-otp";
|
import { InputOTP, InputOTPGroup, InputOTPSeparator, InputOTPSlot } from "~/client/components/ui/input-otp";
|
||||||
import { Label } from "~/client/components/ui/label";
|
import { Label } from "~/client/components/ui/label";
|
||||||
import { authClient } from "~/client/lib/auth-client";
|
import { authClient } from "~/client/lib/auth-client";
|
||||||
import { decodeLoginError, getLoginErrorDescription } from "~/client/lib/auth-errors";
|
import { decodeLoginError, getLoginErrorDescription } from "~/client/lib/sso-errors";
|
||||||
import { ResetPasswordDialog } from "../components/reset-password-dialog";
|
import { ResetPasswordDialog } from "../components/reset-password-dialog";
|
||||||
import { useNavigate } from "@tanstack/react-router";
|
import { useNavigate } from "@tanstack/react-router";
|
||||||
import { normalizeUsername } from "~/lib/username";
|
import { normalizeUsername } from "~/lib/username";
|
||||||
import { useMutation, useSuspenseQuery } from "@tanstack/react-query";
|
|
||||||
import { getPublicSsoProvidersOptions } from "~/client/api-client/@tanstack/react-query.gen";
|
|
||||||
import { cn } from "~/client/lib/utils";
|
import { cn } from "~/client/lib/utils";
|
||||||
|
import { SsoLoginSection } from "~/client/modules/sso/components/sso-login-section";
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
const loginSchema = type({
|
const loginSchema = z.object({
|
||||||
username: "2<=string<=50",
|
username: z
|
||||||
password: "string>=1",
|
.string()
|
||||||
|
.min(2, "Username must be at least 2 characters")
|
||||||
|
.max(50, "Username must be at most 50 characters"),
|
||||||
|
password: z.string().min(1, "Password is required"),
|
||||||
});
|
});
|
||||||
|
|
||||||
type LoginFormValues = typeof loginSchema.inferIn;
|
type LoginFormValues = z.input<typeof loginSchema>;
|
||||||
|
|
||||||
type LoginPageProps = {
|
type LoginPageProps = {
|
||||||
error?: string;
|
error?: string;
|
||||||
|
|
@ -38,14 +40,10 @@ export function LoginPage({ error }: LoginPageProps = {}) {
|
||||||
const [isVerifying2FA, setIsVerifying2FA] = useState(false);
|
const [isVerifying2FA, setIsVerifying2FA] = useState(false);
|
||||||
const [trustDevice, setTrustDevice] = useState(false);
|
const [trustDevice, setTrustDevice] = useState(false);
|
||||||
const errorCode = decodeLoginError(error);
|
const errorCode = decodeLoginError(error);
|
||||||
const errorDescription = getLoginErrorDescription(errorCode);
|
const errorDescription = errorCode ? getLoginErrorDescription(errorCode) : null;
|
||||||
|
|
||||||
const { data: ssoProviders } = useSuspenseQuery({
|
|
||||||
...getPublicSsoProvidersOptions(),
|
|
||||||
});
|
|
||||||
|
|
||||||
const form = useForm<LoginFormValues>({
|
const form = useForm<LoginFormValues>({
|
||||||
resolver: arktypeResolver(loginSchema),
|
resolver: zodResolver(loginSchema),
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
username: "",
|
username: "",
|
||||||
password: "",
|
password: "",
|
||||||
|
|
@ -129,27 +127,6 @@ export function LoginPage({ error }: LoginPageProps = {}) {
|
||||||
form.reset();
|
form.reset();
|
||||||
};
|
};
|
||||||
|
|
||||||
const ssoLoginMutation = useMutation({
|
|
||||||
mutationFn: async (providerId: string) => {
|
|
||||||
const callbackPath = "/login";
|
|
||||||
const { data, error } = await authClient.signIn.sso({
|
|
||||||
providerId: providerId,
|
|
||||||
callbackURL: callbackPath,
|
|
||||||
errorCallbackURL: "/api/v1/auth/login-error",
|
|
||||||
});
|
|
||||||
if (error) throw error;
|
|
||||||
|
|
||||||
return data;
|
|
||||||
},
|
|
||||||
onSuccess: (data) => {
|
|
||||||
window.location.href = data.url;
|
|
||||||
},
|
|
||||||
onError: (error) => {
|
|
||||||
console.error(error);
|
|
||||||
toast.error("SSO Login failed", { description: error.message });
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (requires2FA) {
|
if (requires2FA) {
|
||||||
return (
|
return (
|
||||||
<AuthLayout title="Two-Factor Authentication" description="Enter the 6-digit code from your authenticator app">
|
<AuthLayout title="Two-Factor Authentication" description="Enter the 6-digit code from your authenticator app">
|
||||||
|
|
@ -265,26 +242,7 @@ export function LoginPage({ error }: LoginPageProps = {}) {
|
||||||
</form>
|
</form>
|
||||||
</Form>
|
</Form>
|
||||||
|
|
||||||
{ssoProviders.providers.length > 0 && (
|
<SsoLoginSection />
|
||||||
<div className="pt-4 border-t border-border/60 space-y-3">
|
|
||||||
<p className="text-sm font-medium">Alternative Sign-in</p>
|
|
||||||
<div className="flex flex-col gap-2">
|
|
||||||
{ssoProviders.providers.map((provider) => (
|
|
||||||
<Button
|
|
||||||
key={provider.providerId}
|
|
||||||
type="button"
|
|
||||||
variant="outline"
|
|
||||||
className="w-full"
|
|
||||||
loading={ssoLoginMutation.isPending}
|
|
||||||
disabled={ssoLoginMutation.isPending}
|
|
||||||
onClick={() => ssoLoginMutation.mutate(provider.providerId)}
|
|
||||||
>
|
|
||||||
Log in with {provider.providerId}
|
|
||||||
</Button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<ResetPasswordDialog open={showResetDialog} onOpenChange={setShowResetDialog} />
|
<ResetPasswordDialog open={showResetDialog} onOpenChange={setShowResetDialog} />
|
||||||
</AuthLayout>
|
</AuthLayout>
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,4 @@
|
||||||
import { arktypeResolver } from "@hookform/resolvers/arktype";
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
import { type } from "arktype";
|
|
||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import {
|
import {
|
||||||
|
|
@ -19,24 +18,28 @@ import { authClient } from "~/client/lib/auth-client";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useNavigate } from "@tanstack/react-router";
|
import { useNavigate } from "@tanstack/react-router";
|
||||||
import { normalizeUsername } from "~/lib/username";
|
import { normalizeUsername } from "~/lib/username";
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
export const clientMiddleware = [authMiddleware];
|
export const clientMiddleware = [authMiddleware];
|
||||||
|
|
||||||
const onboardingSchema = type({
|
const onboardingSchema = z.object({
|
||||||
username: type("2<=string<=30").pipe(normalizeUsername),
|
username: z.string().min(2).max(30).transform(normalizeUsername),
|
||||||
email: type("string.email").pipe((str) => str.trim().toLowerCase()),
|
email: z
|
||||||
password: "string>=8",
|
.string()
|
||||||
confirmPassword: "string>=1",
|
.email()
|
||||||
|
.transform((str) => str.trim().toLowerCase()),
|
||||||
|
password: z.string().min(8),
|
||||||
|
confirmPassword: z.string().min(1),
|
||||||
});
|
});
|
||||||
|
|
||||||
type OnboardingFormValues = typeof onboardingSchema.inferIn;
|
type OnboardingFormValues = z.input<typeof onboardingSchema>;
|
||||||
|
|
||||||
export function OnboardingPage() {
|
export function OnboardingPage() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [submitting, setSubmitting] = useState(false);
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
|
||||||
const form = useForm<OnboardingFormValues>({
|
const form = useForm<OnboardingFormValues>({
|
||||||
resolver: arktypeResolver(onboardingSchema),
|
resolver: zodResolver(onboardingSchema),
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
username: "",
|
username: "",
|
||||||
password: "",
|
password: "",
|
||||||
|
|
|
||||||
|
|
@ -32,21 +32,21 @@ export const BackupCard = ({ schedule }: { schedule: BackupSchedule }) => {
|
||||||
<CardContent className="flex-1 space-y-4">
|
<CardContent className="flex-1 space-y-4">
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<div className="flex items-center text-sm gap-2">
|
<div className="flex items-center text-sm gap-2">
|
||||||
<span className="text-zinc-500 shrink-0">Schedule</span>
|
<span className="text-muted-foreground shrink-0">Schedule</span>
|
||||||
<div className="flex-1 border-b border-dashed border-white/10" />
|
<div className="flex-1 border-b border-dashed border-border/80 dark:border-border/50" />
|
||||||
<code className="text-xs text-zinc-100 font-mono bg-muted px-2 py-1 rounded shrink-0">
|
<code className="text-xs text-foreground font-mono bg-muted px-2 py-1 rounded shrink-0">
|
||||||
{schedule.cronExpression}
|
{schedule.cronExpression}
|
||||||
</code>
|
</code>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center text-sm gap-2">
|
<div className="flex items-center text-sm gap-2">
|
||||||
<span className="text-zinc-500 shrink-0">Last backup</span>
|
<span className="text-muted-foreground shrink-0">Last backup</span>
|
||||||
<div className="flex-1 border-b border-dashed border-white/10" />
|
<div className="flex-1 border-b border-dashed border-border/80 dark:border-border/50" />
|
||||||
<span className="text-zinc-100 font-mono text-sm shrink-0">{formatTimeAgo(schedule.lastBackupAt)}</span>
|
<span className="text-foreground font-mono text-sm shrink-0">{formatTimeAgo(schedule.lastBackupAt)}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center text-sm gap-2">
|
<div className="flex items-center text-sm gap-2">
|
||||||
<span className="text-zinc-500 shrink-0">Next backup</span>
|
<span className="text-muted-foreground shrink-0">Next backup</span>
|
||||||
<div className="flex-1 border-b border-dashed border-white/10" />
|
<div className="flex-1 border-b border-dashed border-border/80 dark:border-border/50" />
|
||||||
<span className="text-zinc-100 font-mono text-sm shrink-0">
|
<span className="text-foreground font-mono text-sm shrink-0">
|
||||||
{formatShortDateTime(schedule.nextBackupAt)}
|
{formatShortDateTime(schedule.nextBackupAt)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -46,6 +46,7 @@ export const BackupProgressCard = ({ scheduleShortId, initialProgress }: Props)
|
||||||
const currentFile = progress?.current_files?.[0] || "";
|
const currentFile = progress?.current_files?.[0] || "";
|
||||||
const fileName = currentFile.split("/").pop() || currentFile;
|
const fileName = currentFile.split("/").pop() || currentFile;
|
||||||
const speed = progress ? formatBytes(progress.bytes_done / progress.seconds_elapsed) : null;
|
const speed = progress ? formatBytes(progress.bytes_done / progress.seconds_elapsed) : null;
|
||||||
|
const eta = progress?.seconds_remaining ? formatDuration(progress.seconds_remaining) : null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className="p-4">
|
<Card className="p-4">
|
||||||
|
|
@ -96,6 +97,10 @@ export const BackupProgressCard = ({ scheduleShortId, initialProgress }: Props)
|
||||||
{progress ? (progress.seconds_elapsed > 0 ? `${speed?.text} ${speed?.unit}/s` : "Calculating...") : "—"}
|
{progress ? (progress.seconds_elapsed > 0 ? `${speed?.text} ${speed?.unit}/s` : "Calculating...") : "—"}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-xs uppercase text-muted-foreground">ETA</p>
|
||||||
|
<p className="font-medium">{progress ? (eta ?? "Calculating...") : "—"}</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="pt-2 border-t border-border">
|
<div className="pt-2 border-t border-border">
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,35 @@
|
||||||
|
import { FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from "~/client/components/ui/form";
|
||||||
|
import { Textarea } from "~/client/components/ui/textarea";
|
||||||
|
import type { UseFormReturn } from "react-hook-form";
|
||||||
|
import type { InternalFormValues } from "./types";
|
||||||
|
|
||||||
|
type AdvancedSectionProps = {
|
||||||
|
form: UseFormReturn<InternalFormValues>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const AdvancedSection = ({ form }: AdvancedSectionProps) => {
|
||||||
|
return (
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="customResticParamsText"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Custom restic parameters</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Textarea
|
||||||
|
{...field}
|
||||||
|
placeholder="--exclude-larger-than 500M --no-scan --read-concurrency 8"
|
||||||
|
className="font-mono text-sm min-h-24"
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormDescription>
|
||||||
|
Advanced: enter one restic flag per line (e.g.{" "}
|
||||||
|
<code className="bg-muted px-1 rounded">--exclude-larger-than 500M</code>). Only the supported flag list is
|
||||||
|
accepted.
|
||||||
|
</FormDescription>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -1,17 +1,19 @@
|
||||||
import { arktypeResolver } from "@hookform/resolvers/arktype";
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
import { useCallback, useState } from "react";
|
import { useCallback, useState } from "react";
|
||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
import { useScrollToFormError } from "~/client/hooks/use-scroll-to-form-error";
|
import { useScrollToFormError } from "~/client/hooks/use-scroll-to-form-error";
|
||||||
import { Form } from "~/client/components/ui/form";
|
import { Form } from "~/client/components/ui/form";
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "~/client/components/ui/card";
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "~/client/components/ui/card";
|
||||||
|
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "~/client/components/ui/collapsible";
|
||||||
import type { BackupSchedule, Volume } from "~/client/lib/types";
|
import type { BackupSchedule, Volume } from "~/client/lib/types";
|
||||||
|
import { AdvancedSection } from "./advanced-section";
|
||||||
import { BasicInfoSection } from "./basic-info-section";
|
import { BasicInfoSection } from "./basic-info-section";
|
||||||
import { ExcludeSection } from "./exclude-section";
|
import { ExcludeSection } from "./exclude-section";
|
||||||
import { FrequencySection } from "./frequency-section";
|
import { FrequencySection } from "./frequency-section";
|
||||||
import { PathsSection } from "./paths-section";
|
import { PathsSection } from "./paths-section";
|
||||||
import { RetentionSection } from "./retention-section";
|
import { RetentionSection } from "./retention-section";
|
||||||
import { SummarySection } from "./summary-section";
|
import { SummarySection } from "./summary-section";
|
||||||
import { cleanSchema, type BackupScheduleFormValues, type InternalFormValues } from "./types";
|
import { internalFormSchema, type BackupScheduleFormValues, type InternalFormValues } from "./types";
|
||||||
import { backupScheduleToFormValues } from "./utils";
|
import { backupScheduleToFormValues } from "./utils";
|
||||||
|
|
||||||
export type { BackupScheduleFormValues };
|
export type { BackupScheduleFormValues };
|
||||||
|
|
@ -27,7 +29,7 @@ type Props = {
|
||||||
|
|
||||||
export const CreateScheduleForm = ({ initialValues, formId, onSubmit, volume }: Props) => {
|
export const CreateScheduleForm = ({ initialValues, formId, onSubmit, volume }: Props) => {
|
||||||
const form = useForm<InternalFormValues>({
|
const form = useForm<InternalFormValues>({
|
||||||
resolver: arktypeResolver(cleanSchema as unknown as typeof import("./types").internalFormSchema),
|
resolver: zodResolver(internalFormSchema),
|
||||||
defaultValues: backupScheduleToFormValues(initialValues),
|
defaultValues: backupScheduleToFormValues(initialValues),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -39,6 +41,7 @@ export const CreateScheduleForm = ({ initialValues, formId, onSubmit, volume }:
|
||||||
excludePatternsText,
|
excludePatternsText,
|
||||||
excludeIfPresentText,
|
excludeIfPresentText,
|
||||||
includePatternsText,
|
includePatternsText,
|
||||||
|
customResticParamsText,
|
||||||
includePatterns: fileBrowserPatterns,
|
includePatterns: fileBrowserPatterns,
|
||||||
cronExpression,
|
cronExpression,
|
||||||
...rest
|
...rest
|
||||||
|
|
@ -65,12 +68,20 @@ export const CreateScheduleForm = ({ initialValues, formId, onSubmit, volume }:
|
||||||
: [];
|
: [];
|
||||||
const includePatterns = [...(fileBrowserPatterns || []), ...textPatterns];
|
const includePatterns = [...(fileBrowserPatterns || []), ...textPatterns];
|
||||||
|
|
||||||
|
const customResticParams = customResticParamsText
|
||||||
|
? customResticParamsText
|
||||||
|
.split("\n")
|
||||||
|
.map((p) => p.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
: [];
|
||||||
|
|
||||||
onSubmit({
|
onSubmit({
|
||||||
...rest,
|
...rest,
|
||||||
cronExpression,
|
cronExpression,
|
||||||
includePatterns: includePatterns.length > 0 ? includePatterns : [],
|
includePatterns: includePatterns.length > 0 ? includePatterns : [],
|
||||||
excludePatterns,
|
excludePatterns,
|
||||||
excludeIfPresent,
|
excludeIfPresent,
|
||||||
|
customResticParams,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
[onSubmit],
|
[onSubmit],
|
||||||
|
|
@ -164,6 +175,17 @@ export const CreateScheduleForm = ({ initialValues, formId, onSubmit, volume }:
|
||||||
<RetentionSection form={form} />
|
<RetentionSection form={form} />
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
<Card className="min-w-0 @container">
|
||||||
|
<CardContent>
|
||||||
|
<Collapsible>
|
||||||
|
<CollapsibleTrigger>Advanced</CollapsibleTrigger>
|
||||||
|
<CollapsibleContent className="pb-4 space-y-4">
|
||||||
|
<AdvancedSection form={form} />
|
||||||
|
</CollapsibleContent>
|
||||||
|
</Collapsible>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
<div className="xl:sticky xl:top-6 xl:self-start">
|
<div className="xl:sticky xl:top-6 xl:self-start">
|
||||||
<SummarySection volume={volume} frequency={frequency} formValues={formValues} />
|
<SummarySection volume={volume} frequency={frequency} formValues={formValues} />
|
||||||
|
|
|
||||||
|
|
@ -22,7 +22,7 @@ export const RetentionSection = ({ form }: RetentionSectionProps) => {
|
||||||
type="number"
|
type="number"
|
||||||
min={0}
|
min={0}
|
||||||
placeholder="Optional"
|
placeholder="Optional"
|
||||||
onChange={(v) => field.onChange(Number(v.target.value))}
|
onChange={(v) => field.onChange(v.target.value === "" ? undefined : Number(v.target.value))}
|
||||||
/>
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormDescription>Keep the N most recent snapshots.</FormDescription>
|
<FormDescription>Keep the N most recent snapshots.</FormDescription>
|
||||||
|
|
@ -43,7 +43,7 @@ export const RetentionSection = ({ form }: RetentionSectionProps) => {
|
||||||
min={0}
|
min={0}
|
||||||
placeholder="Optional"
|
placeholder="Optional"
|
||||||
{...field}
|
{...field}
|
||||||
onChange={(v) => field.onChange(Number(v.target.value))}
|
onChange={(v) => field.onChange(v.target.value === "" ? undefined : Number(v.target.value))}
|
||||||
/>
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormDescription>Keep the last N hourly snapshots.</FormDescription>
|
<FormDescription>Keep the last N hourly snapshots.</FormDescription>
|
||||||
|
|
@ -64,7 +64,7 @@ export const RetentionSection = ({ form }: RetentionSectionProps) => {
|
||||||
min={0}
|
min={0}
|
||||||
placeholder="e.g., 7"
|
placeholder="e.g., 7"
|
||||||
{...field}
|
{...field}
|
||||||
onChange={(v) => field.onChange(Number(v.target.value))}
|
onChange={(v) => field.onChange(v.target.value === "" ? undefined : Number(v.target.value))}
|
||||||
/>
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormDescription>Keep the last N daily snapshots.</FormDescription>
|
<FormDescription>Keep the last N daily snapshots.</FormDescription>
|
||||||
|
|
@ -85,7 +85,7 @@ export const RetentionSection = ({ form }: RetentionSectionProps) => {
|
||||||
min={0}
|
min={0}
|
||||||
placeholder="e.g., 4"
|
placeholder="e.g., 4"
|
||||||
{...field}
|
{...field}
|
||||||
onChange={(v) => field.onChange(Number(v.target.value))}
|
onChange={(v) => field.onChange(v.target.value === "" ? undefined : Number(v.target.value))}
|
||||||
/>
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormDescription>Keep the last N weekly snapshots.</FormDescription>
|
<FormDescription>Keep the last N weekly snapshots.</FormDescription>
|
||||||
|
|
@ -106,7 +106,7 @@ export const RetentionSection = ({ form }: RetentionSectionProps) => {
|
||||||
min={0}
|
min={0}
|
||||||
placeholder="e.g., 6"
|
placeholder="e.g., 6"
|
||||||
{...field}
|
{...field}
|
||||||
onChange={(v) => field.onChange(Number(v.target.value))}
|
onChange={(v) => field.onChange(v.target.value === "" ? undefined : Number(v.target.value))}
|
||||||
/>
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormDescription>Keep the last N monthly snapshots.</FormDescription>
|
<FormDescription>Keep the last N monthly snapshots.</FormDescription>
|
||||||
|
|
@ -127,7 +127,7 @@ export const RetentionSection = ({ form }: RetentionSectionProps) => {
|
||||||
min={0}
|
min={0}
|
||||||
placeholder="Optional"
|
placeholder="Optional"
|
||||||
{...field}
|
{...field}
|
||||||
onChange={(v) => field.onChange(Number(v.target.value))}
|
onChange={(v) => field.onChange(v.target.value === "" ? undefined : Number(v.target.value))}
|
||||||
/>
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormDescription>Keep the last N yearly snapshots.</FormDescription>
|
<FormDescription>Keep the last N yearly snapshots.</FormDescription>
|
||||||
|
|
|
||||||
|
|
@ -1,29 +1,27 @@
|
||||||
import { type } from "arktype";
|
import { z } from "zod";
|
||||||
import { deepClean } from "~/utils/object";
|
|
||||||
|
|
||||||
export const internalFormSchema = type({
|
export const internalFormSchema = z.object({
|
||||||
name: "1 <= string <= 128",
|
name: z.string().min(1).max(128),
|
||||||
repositoryId: "string",
|
repositoryId: z.string(),
|
||||||
excludePatternsText: "string?",
|
excludePatternsText: z.string().optional(),
|
||||||
excludeIfPresentText: "string?",
|
excludeIfPresentText: z.string().optional(),
|
||||||
includePatternsText: "string?",
|
includePatternsText: z.string().optional(),
|
||||||
includePatterns: "string[]?",
|
includePatterns: z.array(z.string()).optional(),
|
||||||
frequency: "string",
|
frequency: z.string(),
|
||||||
dailyTime: "string?",
|
dailyTime: z.string().optional(),
|
||||||
weeklyDay: "string?",
|
weeklyDay: z.string().optional(),
|
||||||
monthlyDays: "string[]?",
|
monthlyDays: z.array(z.string()).optional(),
|
||||||
cronExpression: "string?",
|
cronExpression: z.string().optional(),
|
||||||
keepLast: "number?",
|
keepLast: z.number().optional(),
|
||||||
keepHourly: "number?",
|
keepHourly: z.number().optional(),
|
||||||
keepDaily: "number?",
|
keepDaily: z.number().optional(),
|
||||||
keepWeekly: "number?",
|
keepWeekly: z.number().optional(),
|
||||||
keepMonthly: "number?",
|
keepMonthly: z.number().optional(),
|
||||||
keepYearly: "number?",
|
keepYearly: z.number().optional(),
|
||||||
oneFileSystem: "boolean?",
|
oneFileSystem: z.boolean().optional(),
|
||||||
|
customResticParamsText: z.string().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const cleanSchema = type.pipe((d) => internalFormSchema(deepClean(d)));
|
|
||||||
|
|
||||||
export const weeklyDays = [
|
export const weeklyDays = [
|
||||||
{ label: "Monday", value: "1" },
|
{ label: "Monday", value: "1" },
|
||||||
{ label: "Tuesday", value: "2" },
|
{ label: "Tuesday", value: "2" },
|
||||||
|
|
@ -34,12 +32,13 @@ export const weeklyDays = [
|
||||||
{ label: "Sunday", value: "0" },
|
{ label: "Sunday", value: "0" },
|
||||||
];
|
];
|
||||||
|
|
||||||
export type InternalFormValues = typeof internalFormSchema.infer;
|
export type InternalFormValues = z.infer<typeof internalFormSchema>;
|
||||||
|
|
||||||
export type BackupScheduleFormValues = Omit<
|
export type BackupScheduleFormValues = Omit<
|
||||||
InternalFormValues,
|
InternalFormValues,
|
||||||
"excludePatternsText" | "excludeIfPresentText" | "includePatternsText"
|
"excludePatternsText" | "excludeIfPresentText" | "includePatternsText" | "customResticParamsText"
|
||||||
> & {
|
> & {
|
||||||
excludePatterns?: string[];
|
excludePatterns?: string[];
|
||||||
excludeIfPresent?: string[];
|
excludeIfPresent?: string[];
|
||||||
|
customResticParams?: string[];
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,7 @@ export const backupScheduleToFormValues = (schedule?: BackupSchedule): InternalF
|
||||||
excludePatternsText: schedule.excludePatterns?.join("\n") || undefined,
|
excludePatternsText: schedule.excludePatterns?.join("\n") || undefined,
|
||||||
excludeIfPresentText: schedule.excludeIfPresent?.join("\n") || undefined,
|
excludeIfPresentText: schedule.excludeIfPresent?.join("\n") || undefined,
|
||||||
oneFileSystem: schedule.oneFileSystem ?? false,
|
oneFileSystem: schedule.oneFileSystem ?? false,
|
||||||
|
customResticParamsText: schedule.customResticParams?.join("\n") ?? "",
|
||||||
...cronValues,
|
...cronValues,
|
||||||
...schedule.retentionPolicy,
|
...schedule.retentionPolicy,
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
import { useRef } from "react";
|
||||||
import type { ListSnapshotsResponse } from "~/client/api-client";
|
import type { ListSnapshotsResponse } from "~/client/api-client";
|
||||||
import { ByteSize } from "~/client/components/bytes-size";
|
import { ByteSize } from "~/client/components/bytes-size";
|
||||||
import { Card, CardContent } from "~/client/components/ui/card";
|
import { Card, CardContent } from "~/client/components/ui/card";
|
||||||
|
|
@ -15,6 +16,7 @@ interface Props {
|
||||||
|
|
||||||
export const SnapshotTimeline = (props: Props) => {
|
export const SnapshotTimeline = (props: Props) => {
|
||||||
const { snapshots, snapshotId, loading, onSnapshotSelect, error } = props;
|
const { snapshots, snapshotId, loading, onSnapshotSelect, error } = props;
|
||||||
|
const selectedRef = useRef<HTMLButtonElement>(null);
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
return (
|
return (
|
||||||
|
|
@ -51,13 +53,14 @@ export const SnapshotTimeline = (props: Props) => {
|
||||||
<div className="w-full bg-card">
|
<div className="w-full bg-card">
|
||||||
<div className="relative flex items-center">
|
<div className="relative flex items-center">
|
||||||
<div className="flex-1 overflow-hidden">
|
<div className="flex-1 overflow-hidden">
|
||||||
<div className="flex gap-4 overflow-x-auto pb-2 *:first:ml-2 *:last:mr-2">
|
<div className="snapshot-scrollable flex gap-4 overflow-x-auto pb-2 *:first:ml-2 *:last:mr-2">
|
||||||
{snapshots.map((snapshot) => {
|
{snapshots.map((snapshot) => {
|
||||||
const date = new Date(snapshot.time);
|
const date = new Date(snapshot.time);
|
||||||
const isSelected = snapshotId === snapshot.short_id;
|
const isSelected = snapshotId === snapshot.short_id;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
|
ref={isSelected ? selectedRef : undefined}
|
||||||
type="button"
|
type="button"
|
||||||
key={snapshot.short_id}
|
key={snapshot.short_id}
|
||||||
onClick={() => onSnapshotSelect(snapshot.short_id)}
|
onClick={() => onSnapshotSelect(snapshot.short_id)}
|
||||||
|
|
|
||||||
|
|
@ -65,7 +65,9 @@ export function ScheduleDetailsPage(props: Props) {
|
||||||
const searchParams = useSearch({ from: "/(dashboard)/backups/$backupId/" });
|
const searchParams = useSearch({ from: "/(dashboard)/backups/$backupId/" });
|
||||||
const [isEditMode, setIsEditMode] = useState(false);
|
const [isEditMode, setIsEditMode] = useState(false);
|
||||||
const formId = useId();
|
const formId = useId();
|
||||||
const [selectedSnapshotId, setSelectedSnapshotId] = useState<string | undefined>(initialSnapshotId);
|
const [selectedSnapshotId, setSelectedSnapshotId] = useState<string | undefined>(
|
||||||
|
initialSnapshotId ?? loaderData.snapshots?.at(-1)?.short_id,
|
||||||
|
);
|
||||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
|
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
|
||||||
const [snapshotToDelete, setSnapshotToDelete] = useState<string | null>(null);
|
const [snapshotToDelete, setSnapshotToDelete] = useState<string | null>(null);
|
||||||
|
|
||||||
|
|
@ -183,6 +185,7 @@ export function ScheduleDetailsPage(props: Props) {
|
||||||
excludePatterns: formValues.excludePatterns,
|
excludePatterns: formValues.excludePatterns,
|
||||||
excludeIfPresent: formValues.excludeIfPresent,
|
excludeIfPresent: formValues.excludeIfPresent,
|
||||||
oneFileSystem: formValues.oneFileSystem,
|
oneFileSystem: formValues.oneFileSystem,
|
||||||
|
customResticParams: formValues.customResticParams,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
@ -191,6 +194,7 @@ export function ScheduleDetailsPage(props: Props) {
|
||||||
updateSchedule.mutate({
|
updateSchedule.mutate({
|
||||||
path: { shortId: schedule.shortId },
|
path: { shortId: schedule.shortId },
|
||||||
body: {
|
body: {
|
||||||
|
name: schedule.name,
|
||||||
repositoryId: schedule.repositoryId,
|
repositoryId: schedule.repositoryId,
|
||||||
enabled,
|
enabled,
|
||||||
cronExpression: schedule.cronExpression,
|
cronExpression: schedule.cronExpression,
|
||||||
|
|
@ -199,6 +203,7 @@ export function ScheduleDetailsPage(props: Props) {
|
||||||
excludePatterns: schedule.excludePatterns || [],
|
excludePatterns: schedule.excludePatterns || [],
|
||||||
excludeIfPresent: schedule.excludeIfPresent || [],
|
excludeIfPresent: schedule.excludeIfPresent || [],
|
||||||
oneFileSystem: schedule.oneFileSystem,
|
oneFileSystem: schedule.oneFileSystem,
|
||||||
|
customResticParams: schedule.customResticParams || [],
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -110,9 +110,9 @@ export function BackupsPage() {
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
<Link to="/backups/create" className="h-full">
|
<Link to="/backups/create" className="h-full">
|
||||||
<div className="group flex flex-col items-center justify-center h-full min-h-50 border border-dashed border-border/80 bg-card hover:bg-card/50 transition-colors cursor-pointer rounded-s shadow-sm hover:border-border">
|
<div className="group flex flex-col items-center justify-center h-full min-h-50 border-2 border-dashed border-border/60 bg-muted/20 dark:bg-card hover:bg-muted/40 dark:hover:bg-card/50 transition-all cursor-pointer rounded-xl hover:shadow-[0_8px_30px_-15px_rgba(0,0,0,0.05)] dark:hover:shadow-sm hover:border-border hover:-translate-y-[1px] active:scale-[0.98] duration-300">
|
||||||
<div className="flex flex-col items-center justify-center gap-3">
|
<div className="flex flex-col items-center justify-center gap-3">
|
||||||
<div className="p-3 rounded-full bg-muted/20 group-hover:bg-muted/50 transition-all group-hover:scale-110 duration-300">
|
<div className="p-3 rounded-full bg-background/50 dark:bg-muted/20 group-hover:bg-background dark:group-hover:bg-muted/50 transition-all group-hover:scale-110 duration-300 shadow-xs dark:shadow-none">
|
||||||
<Plus className="h-6 w-6 text-muted-foreground group-hover:text-foreground transition-colors" />
|
<Plus className="h-6 w-6 text-muted-foreground group-hover:text-foreground transition-colors" />
|
||||||
</div>
|
</div>
|
||||||
<span className="text-sm font-medium text-muted-foreground group-hover:text-foreground transition-colors">
|
<span className="text-sm font-medium text-muted-foreground group-hover:text-foreground transition-colors">
|
||||||
|
|
|
||||||
|
|
@ -73,6 +73,7 @@ export function CreateBackupPage() {
|
||||||
excludePatterns: formValues.excludePatterns,
|
excludePatterns: formValues.excludePatterns,
|
||||||
excludeIfPresent: formValues.excludeIfPresent,
|
excludeIfPresent: formValues.excludeIfPresent,
|
||||||
oneFileSystem: formValues.oneFileSystem,
|
oneFileSystem: formValues.oneFileSystem,
|
||||||
|
customResticParams: formValues.customResticParams,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,7 @@
|
||||||
import { arktypeResolver } from "@hookform/resolvers/arktype";
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
import { type } from "arktype";
|
|
||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
|
import { z } from "zod";
|
||||||
import { cn } from "~/client/lib/utils";
|
import { cn } from "~/client/lib/utils";
|
||||||
import { deepClean } from "~/utils/object";
|
|
||||||
import {
|
import {
|
||||||
Form,
|
Form,
|
||||||
FormControl,
|
FormControl,
|
||||||
|
|
@ -14,7 +13,17 @@ import {
|
||||||
} from "~/client/components/ui/form";
|
} from "~/client/components/ui/form";
|
||||||
import { Input } from "~/client/components/ui/input";
|
import { Input } from "~/client/components/ui/input";
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "~/client/components/ui/select";
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "~/client/components/ui/select";
|
||||||
import { notificationConfigSchemaBase } from "~/schemas/notifications";
|
import {
|
||||||
|
customNotificationConfigSchema,
|
||||||
|
discordNotificationConfigSchema,
|
||||||
|
emailNotificationConfigSchema,
|
||||||
|
genericNotificationConfigSchema,
|
||||||
|
gotifyNotificationConfigSchema,
|
||||||
|
ntfyNotificationConfigSchema,
|
||||||
|
pushoverNotificationConfigSchema,
|
||||||
|
slackNotificationConfigSchema,
|
||||||
|
telegramNotificationConfigSchema,
|
||||||
|
} from "~/schemas/notifications";
|
||||||
import { useScrollToFormError } from "~/client/hooks/use-scroll-to-form-error";
|
import { useScrollToFormError } from "~/client/hooks/use-scroll-to-form-error";
|
||||||
import {
|
import {
|
||||||
CustomForm,
|
CustomForm,
|
||||||
|
|
@ -28,12 +37,21 @@ import {
|
||||||
TelegramForm,
|
TelegramForm,
|
||||||
} from "./notification-forms";
|
} from "./notification-forms";
|
||||||
|
|
||||||
export const formSchema = type({
|
const baseFields = { name: z.string().min(2).max(32) };
|
||||||
name: "2<=string<=32",
|
|
||||||
}).and(notificationConfigSchemaBase);
|
|
||||||
const cleanSchema = type.pipe((d) => formSchema(deepClean(d)));
|
|
||||||
|
|
||||||
export type NotificationFormValues = typeof formSchema.inferIn;
|
export const formSchema = z.discriminatedUnion("type", [
|
||||||
|
emailNotificationConfigSchema.extend(baseFields),
|
||||||
|
slackNotificationConfigSchema.extend(baseFields),
|
||||||
|
discordNotificationConfigSchema.extend(baseFields),
|
||||||
|
gotifyNotificationConfigSchema.extend(baseFields),
|
||||||
|
ntfyNotificationConfigSchema.extend(baseFields),
|
||||||
|
pushoverNotificationConfigSchema.extend(baseFields),
|
||||||
|
telegramNotificationConfigSchema.extend(baseFields),
|
||||||
|
genericNotificationConfigSchema.extend(baseFields),
|
||||||
|
customNotificationConfigSchema.extend(baseFields),
|
||||||
|
]);
|
||||||
|
|
||||||
|
export type NotificationFormValues = z.input<typeof formSchema>;
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
onSubmit: (values: NotificationFormValues) => void;
|
onSubmit: (values: NotificationFormValues) => void;
|
||||||
|
|
@ -106,7 +124,7 @@ const defaultValuesForType = {
|
||||||
|
|
||||||
export const CreateNotificationForm = ({ onSubmit, mode = "create", initialValues, formId, className }: Props) => {
|
export const CreateNotificationForm = ({ onSubmit, mode = "create", initialValues, formId, className }: Props) => {
|
||||||
const form = useForm<NotificationFormValues>({
|
const form = useForm<NotificationFormValues>({
|
||||||
resolver: arktypeResolver(cleanSchema as unknown as typeof formSchema),
|
resolver: zodResolver(formSchema, undefined, { raw: true }),
|
||||||
defaultValues: initialValues || {
|
defaultValues: initialValues || {
|
||||||
name: "",
|
name: "",
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -97,7 +97,7 @@ export function NotificationDetailsPage({ notificationId }: { notificationId: st
|
||||||
<div className="text-sm font-semibold text-muted-foreground flex items-center gap-2">
|
<div className="text-sm font-semibold text-muted-foreground flex items-center gap-2">
|
||||||
<span
|
<span
|
||||||
className={cn("inline-flex items-center gap-2 px-2 py-1 rounded-md text-xs bg-gray-500/10 text-gray-500", {
|
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-emerald-500": data.enabled,
|
"bg-success/10 text-success": data.enabled,
|
||||||
"bg-red-500/10 text-red-500": !data.enabled,
|
"bg-red-500/10 text-red-500": !data.enabled,
|
||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,16 @@
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||||
import { Archive } from "lucide-react";
|
import { Archive, RefreshCw } from "lucide-react";
|
||||||
import { getRepositoryStatsOptions } from "~/client/api-client/@tanstack/react-query.gen";
|
import {
|
||||||
import { ByteSize } from "~/client/components/bytes-size";
|
getRepositoryStatsOptions,
|
||||||
import { Card, CardContent, CardTitle } from "~/client/components/ui/card";
|
refreshRepositoryStatsMutation,
|
||||||
|
} from "~/client/api-client/@tanstack/react-query.gen";
|
||||||
import type { GetRepositoryStatsResponse } from "~/client/api-client/types.gen";
|
import type { GetRepositoryStatsResponse } from "~/client/api-client/types.gen";
|
||||||
|
import { ByteSize } from "~/client/components/bytes-size";
|
||||||
|
import { Button } from "~/client/components/ui/button";
|
||||||
|
import { Card, CardContent, CardTitle } from "~/client/components/ui/card";
|
||||||
|
import { parseError } from "~/client/lib/errors";
|
||||||
|
import { cn } from "~/client/lib/utils";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
repositoryShortId: string;
|
repositoryShortId: string;
|
||||||
|
|
@ -19,6 +26,18 @@ const toSafeNumber = (value: number | undefined) => {
|
||||||
};
|
};
|
||||||
|
|
||||||
export function CompressionStatsChart({ repositoryShortId, initialStats }: Props) {
|
export function CompressionStatsChart({ repositoryShortId, initialStats }: Props) {
|
||||||
|
const refreshStats = useMutation({
|
||||||
|
...refreshRepositoryStatsMutation(),
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success("Repository stats refreshed");
|
||||||
|
},
|
||||||
|
onError: (mutationError) => {
|
||||||
|
toast.error("Failed to refresh repository stats", {
|
||||||
|
description: parseError(mutationError)?.message,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
const {
|
const {
|
||||||
data: stats,
|
data: stats,
|
||||||
isPending,
|
isPending,
|
||||||
|
|
@ -43,89 +62,84 @@ export function CompressionStatsChart({ repositoryShortId, initialStats }: Props
|
||||||
|
|
||||||
const hasStats = !!stats && (storedSize > 0 || uncompressedSize > 0 || snapshotsCount > 0);
|
const hasStats = !!stats && (storedSize > 0 || uncompressedSize > 0 || snapshotsCount > 0);
|
||||||
|
|
||||||
if (isPending) {
|
|
||||||
return (
|
|
||||||
<Card className="p-6">
|
|
||||||
<p className="text-sm text-muted-foreground">Loading compression statistics...</p>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (error) {
|
|
||||||
return (
|
|
||||||
<Card className="p-6 border-red-500/20 bg-red-500/5">
|
|
||||||
<p className="text-sm font-medium text-red-500">Failed to load compression statistics</p>
|
|
||||||
<p className="mt-2 text-sm text-red-500/80 wrap-break-word">{error.message}</p>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!hasStats) {
|
|
||||||
return (
|
|
||||||
<Card className="p-6">
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
No compression statistics available yet. Run a backup to populate repository stats.
|
|
||||||
</p>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className="flex flex-col px-6 py-6">
|
<Card className="flex flex-col px-6 py-6">
|
||||||
<div className="pb-4">
|
<div className="flex items-start justify-between gap-3 pb-4">
|
||||||
<CardTitle className="flex items-center gap-2">
|
<CardTitle className="flex items-center gap-2 w-full">
|
||||||
<Archive className="h-5 w-5 text-muted-foreground" />
|
<div className="flex items-center gap-2 flex-1">
|
||||||
Compression Statistics
|
<Archive className="h-5 w-5 text-muted-foreground" />
|
||||||
|
Compression Statistics
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => refreshStats.mutate({ path: { shortId: repositoryShortId } })}
|
||||||
|
disabled={refreshStats.isPending}
|
||||||
|
title="Refresh statistics"
|
||||||
|
>
|
||||||
|
<RefreshCw className={refreshStats.isPending ? "h-4 w-4 animate-spin" : "h-4 w-4"} />
|
||||||
|
</Button>
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<p className={cn("text-sm text-muted-foreground", { hidden: !isPending })}>Loading compression statistics...</p>
|
||||||
<CardContent className="grid grid-cols-2 lg:grid-cols-3 gap-y-6 gap-x-4 px-0">
|
<div className={cn("space-y-2", { hidden: !error || isPending })}>
|
||||||
<div className="flex flex-col gap-1.5">
|
<p className="text-sm font-medium text-destructive">Failed to load compression statistics</p>
|
||||||
<div className="text-sm font-medium text-muted-foreground">Stored Size</div>
|
<p className="text-sm text-muted-foreground wrap-break-word">{error?.message}</p>
|
||||||
<div className="flex items-baseline gap-2">
|
|
||||||
<ByteSize base={1024} bytes={storedSize} className="text-xl font-semibold text-foreground font-mono" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex flex-col gap-1.5">
|
|
||||||
<div className="text-sm font-medium text-muted-foreground">Uncompressed</div>
|
|
||||||
<div className="flex items-baseline gap-2">
|
|
||||||
<ByteSize
|
|
||||||
base={1024}
|
|
||||||
bytes={uncompressedSize}
|
|
||||||
className="text-xl font-semibold text-foreground font-mono"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex flex-col gap-1.5">
|
|
||||||
<div className="text-sm font-medium text-muted-foreground">Space Saved</div>
|
|
||||||
<div className="flex items-baseline gap-2">
|
|
||||||
<span className="text-xl font-semibold text-foreground font-mono">{spaceSavingPercent.toFixed(1)}%</span>
|
|
||||||
<ByteSize base={1024} bytes={savedSize} className="text-xs text-muted-foreground font-mono" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex flex-col gap-1.5">
|
|
||||||
<div className="text-sm font-medium text-muted-foreground">Ratio</div>
|
|
||||||
<div className="flex items-baseline gap-2">
|
|
||||||
<span className="text-xl font-semibold text-foreground font-mono">
|
|
||||||
{compressionRatio > 0 ? `${compressionRatio.toFixed(2)}x` : "—"}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex flex-col gap-1.5 lg:col-span-2">
|
|
||||||
<div className="text-sm font-medium text-muted-foreground">Snapshots</div>
|
|
||||||
<div className="flex items-baseline gap-2">
|
|
||||||
<span className="text-xl font-semibold text-foreground font-mono">{snapshotsCount.toLocaleString()}</span>
|
|
||||||
<span className="text-xs text-muted-foreground font-mono">
|
|
||||||
{compressionProgressPercent.toFixed(1)}% compressed
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</div>
|
</div>
|
||||||
|
<p className={cn("text-sm text-muted-foreground", { hidden: isPending || !!error || hasStats })}>
|
||||||
|
Stats will be populated after your first backup. You can also refresh them manually.
|
||||||
|
</p>
|
||||||
|
<CardContent
|
||||||
|
className={cn("grid grid-cols-2 lg:grid-cols-3 gap-y-6 gap-x-4 px-0", {
|
||||||
|
hidden: isPending || !!error || !hasStats,
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<div className="text-sm font-medium text-muted-foreground">Stored Size</div>
|
||||||
|
<div className="flex items-baseline gap-2">
|
||||||
|
<ByteSize base={1024} bytes={storedSize} className="text-xl font-semibold text-foreground font-mono" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<div className="text-sm font-medium text-muted-foreground">Uncompressed</div>
|
||||||
|
<div className="flex items-baseline gap-2">
|
||||||
|
<ByteSize
|
||||||
|
base={1024}
|
||||||
|
bytes={uncompressedSize}
|
||||||
|
className="text-xl font-semibold text-foreground font-mono"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<div className="text-sm font-medium text-muted-foreground">Space Saved</div>
|
||||||
|
<div className="flex items-baseline gap-2">
|
||||||
|
<span className="text-xl font-semibold text-foreground font-mono">{spaceSavingPercent.toFixed(1)}%</span>
|
||||||
|
<ByteSize base={1024} bytes={savedSize} className="text-xs text-muted-foreground font-mono" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<div className="text-sm font-medium text-muted-foreground">Ratio</div>
|
||||||
|
<div className="flex items-baseline gap-2">
|
||||||
|
<span className="text-xl font-semibold text-foreground font-mono">
|
||||||
|
{compressionRatio > 0 ? `${compressionRatio.toFixed(2)}x` : "-"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-1.5 lg:col-span-2">
|
||||||
|
<div className="text-sm font-medium text-muted-foreground">Snapshots</div>
|
||||||
|
<div className="flex items-baseline gap-2">
|
||||||
|
<span className="text-xl font-semibold text-foreground font-mono">{snapshotsCount.toLocaleString()}</span>
|
||||||
|
<span className="text-xs text-muted-foreground font-mono">
|
||||||
|
{compressionProgressPercent.toFixed(1)}% compressed
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,9 @@
|
||||||
import { arktypeResolver } from "@hookform/resolvers/arktype";
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
import { type } from "arktype";
|
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
import { Save } from "lucide-react";
|
import { Save } from "lucide-react";
|
||||||
|
import { z } from "zod";
|
||||||
import { cn } from "~/client/lib/utils";
|
import { cn } from "~/client/lib/utils";
|
||||||
import { deepClean } from "~/utils/object";
|
|
||||||
import { Button } from "../../../components/ui/button";
|
import { Button } from "../../../components/ui/button";
|
||||||
import {
|
import {
|
||||||
Form,
|
Form,
|
||||||
|
|
@ -21,7 +20,17 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from ".
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from "../../../components/ui/tooltip";
|
import { Tooltip, TooltipContent, TooltipTrigger } from "../../../components/ui/tooltip";
|
||||||
import { useSystemInfo } from "~/client/hooks/use-system-info";
|
import { useSystemInfo } from "~/client/hooks/use-system-info";
|
||||||
import { useScrollToFormError } from "~/client/hooks/use-scroll-to-form-error";
|
import { useScrollToFormError } from "~/client/hooks/use-scroll-to-form-error";
|
||||||
import { COMPRESSION_MODES, repositoryConfigSchemaBase } from "~/schemas/restic";
|
import {
|
||||||
|
COMPRESSION_MODES,
|
||||||
|
azureRepositoryConfigSchema,
|
||||||
|
gcsRepositoryConfigSchema,
|
||||||
|
localRepositoryConfigSchema,
|
||||||
|
r2RepositoryConfigSchema,
|
||||||
|
rcloneRepositoryConfigSchema,
|
||||||
|
restRepositoryConfigSchema,
|
||||||
|
s3RepositoryConfigSchema,
|
||||||
|
sftpRepositoryConfigSchema,
|
||||||
|
} from "~/schemas/restic";
|
||||||
import { Checkbox } from "../../../components/ui/checkbox";
|
import { Checkbox } from "../../../components/ui/checkbox";
|
||||||
import {
|
import {
|
||||||
LocalRepositoryForm,
|
LocalRepositoryForm,
|
||||||
|
|
@ -38,13 +47,23 @@ import { useServerFn } from "@tanstack/react-start";
|
||||||
import { getServerConstants } from "~/server/lib/functions/server-constants";
|
import { getServerConstants } from "~/server/lib/functions/server-constants";
|
||||||
import { useSuspenseQuery } from "@tanstack/react-query";
|
import { useSuspenseQuery } from "@tanstack/react-query";
|
||||||
|
|
||||||
export const formSchema = type({
|
const formBaseFields = {
|
||||||
name: "2<=string<=32",
|
name: z.string().min(2).max(32),
|
||||||
compressionMode: type.valueOf(COMPRESSION_MODES).optional(),
|
compressionMode: z.enum(COMPRESSION_MODES).optional(),
|
||||||
}).and(repositoryConfigSchemaBase);
|
};
|
||||||
const cleanSchema = type.pipe((d) => formSchema(deepClean(d)));
|
|
||||||
|
|
||||||
export type RepositoryFormValues = typeof formSchema.inferIn;
|
export const formSchema = z.discriminatedUnion("backend", [
|
||||||
|
localRepositoryConfigSchema.extend(formBaseFields),
|
||||||
|
s3RepositoryConfigSchema.extend(formBaseFields),
|
||||||
|
r2RepositoryConfigSchema.extend(formBaseFields),
|
||||||
|
gcsRepositoryConfigSchema.extend(formBaseFields),
|
||||||
|
azureRepositoryConfigSchema.extend(formBaseFields),
|
||||||
|
rcloneRepositoryConfigSchema.extend(formBaseFields),
|
||||||
|
restRepositoryConfigSchema.extend(formBaseFields),
|
||||||
|
sftpRepositoryConfigSchema.extend(formBaseFields),
|
||||||
|
]);
|
||||||
|
|
||||||
|
export type RepositoryFormValues = z.input<typeof formSchema>;
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
onSubmit: (values: RepositoryFormValues) => void;
|
onSubmit: (values: RepositoryFormValues) => void;
|
||||||
|
|
@ -81,7 +100,7 @@ export const CreateRepositoryForm = ({
|
||||||
});
|
});
|
||||||
|
|
||||||
const form = useForm<RepositoryFormValues>({
|
const form = useForm<RepositoryFormValues>({
|
||||||
resolver: arktypeResolver(cleanSchema as unknown as typeof formSchema),
|
resolver: zodResolver(formSchema, undefined, { raw: true }),
|
||||||
defaultValues: initialValues,
|
defaultValues: initialValues,
|
||||||
resetOptions: {
|
resetOptions: {
|
||||||
keepDefaultValues: true,
|
keepDefaultValues: true,
|
||||||
|
|
|
||||||
|
|
@ -41,7 +41,7 @@ export const DoctorReport = ({ result, repositoryStatus }: Props) => {
|
||||||
<CollapsibleTrigger className="w-full flex items-center justify-between p-3 hover:bg-muted/50 transition-colors">
|
<CollapsibleTrigger className="w-full flex items-center justify-between p-3 hover:bg-muted/50 transition-colors">
|
||||||
<span className="text-sm font-medium capitalize">{step.step.replaceAll("_", " ")}</span>
|
<span className="text-sm font-medium capitalize">{step.step.replaceAll("_", " ")}</span>
|
||||||
{step.success ? (
|
{step.success ? (
|
||||||
<CheckCircle2 className="h-4 w-4 text-emerald-500" />
|
<CheckCircle2 className="h-4 w-4 text-success" />
|
||||||
) : (
|
) : (
|
||||||
<AlertCircle className="h-4 w-4 text-red-500" />
|
<AlertCircle className="h-4 w-4 text-red-500" />
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ import { toast } from "sonner";
|
||||||
import { createRepositoryMutation } from "~/client/api-client/@tanstack/react-query.gen";
|
import { createRepositoryMutation } from "~/client/api-client/@tanstack/react-query.gen";
|
||||||
import {
|
import {
|
||||||
CreateRepositoryForm,
|
CreateRepositoryForm,
|
||||||
|
formSchema,
|
||||||
type RepositoryFormValues,
|
type RepositoryFormValues,
|
||||||
} from "~/client/modules/repositories/components/create-repository-form";
|
} from "~/client/modules/repositories/components/create-repository-form";
|
||||||
import { Button } from "~/client/components/ui/button";
|
import { Button } from "~/client/components/ui/button";
|
||||||
|
|
@ -26,11 +27,13 @@ export function CreateRepositoryPage() {
|
||||||
});
|
});
|
||||||
|
|
||||||
const handleSubmit = (values: RepositoryFormValues) => {
|
const handleSubmit = (values: RepositoryFormValues) => {
|
||||||
|
const { name, compressionMode, ...config } = formSchema.parse(values);
|
||||||
|
|
||||||
createRepository.mutate({
|
createRepository.mutate({
|
||||||
body: {
|
body: {
|
||||||
config: values,
|
config,
|
||||||
name: values.name,
|
name,
|
||||||
compressionMode: values.compressionMode,
|
compressionMode,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ import { toast } from "sonner";
|
||||||
import { getRepositoryOptions, updateRepositoryMutation } from "~/client/api-client/@tanstack/react-query.gen";
|
import { getRepositoryOptions, updateRepositoryMutation } from "~/client/api-client/@tanstack/react-query.gen";
|
||||||
import {
|
import {
|
||||||
CreateRepositoryForm,
|
CreateRepositoryForm,
|
||||||
|
formSchema,
|
||||||
type RepositoryFormValues,
|
type RepositoryFormValues,
|
||||||
} from "~/client/modules/repositories/components/create-repository-form";
|
} from "~/client/modules/repositories/components/create-repository-form";
|
||||||
import {
|
import {
|
||||||
|
|
@ -35,11 +36,11 @@ const riskyLocationFieldsByBackend = {
|
||||||
rclone: ["remote", "path"],
|
rclone: ["remote", "path"],
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
const hasRiskyLocationChange = (initialConfig: RepositoryConfig, nextConfig: RepositoryFormValues): boolean => {
|
const hasRiskyLocationChange = (initialConfig: RepositoryConfig, nextConfig: RepositoryConfig): boolean => {
|
||||||
const fields = riskyLocationFieldsByBackend[initialConfig.backend] ?? [];
|
const fields = riskyLocationFieldsByBackend[initialConfig.backend] ?? [];
|
||||||
|
|
||||||
return fields.some(
|
return fields.some(
|
||||||
(field) => initialConfig[field as keyof RepositoryConfig] !== nextConfig[field as keyof RepositoryFormValues],
|
(field) => initialConfig[field as keyof RepositoryConfig] !== nextConfig[field as keyof RepositoryConfig],
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -75,18 +76,20 @@ export function EditRepositoryPage({ repositoryId }: { repositoryId: string }) {
|
||||||
};
|
};
|
||||||
|
|
||||||
const submitUpdate = (values: RepositoryFormValues) => {
|
const submitUpdate = (values: RepositoryFormValues) => {
|
||||||
|
const { name, compressionMode, ...config } = formSchema.parse(values);
|
||||||
|
|
||||||
updateRepository.mutate({
|
updateRepository.mutate({
|
||||||
path: { shortId: repositoryId },
|
path: { shortId: repositoryId },
|
||||||
body: {
|
body: {
|
||||||
name: values.name,
|
name,
|
||||||
compressionMode: values.compressionMode,
|
compressionMode,
|
||||||
config: values,
|
config,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSubmit = (values: RepositoryFormValues) => {
|
const handleSubmit = (values: RepositoryFormValues) => {
|
||||||
const nextConfig = values;
|
const { name: _name, compressionMode: _compressionMode, ...nextConfig } = formSchema.parse(values);
|
||||||
|
|
||||||
if (hasRiskyLocationChange(initialConfig, nextConfig)) {
|
if (hasRiskyLocationChange(initialConfig, nextConfig)) {
|
||||||
setPendingValues(values);
|
setPendingValues(values);
|
||||||
|
|
|
||||||
|
|
@ -143,7 +143,7 @@ export function RepositoriesPage() {
|
||||||
className={cn(
|
className={cn(
|
||||||
"inline-flex items-center gap-2 px-2 py-1 rounded-md text-xs bg-gray-500/10 text-gray-500",
|
"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-emerald-500": repository.status === "healthy",
|
"bg-success/10 text-success": repository.status === "healthy",
|
||||||
"bg-red-500/10 text-red-500": repository.status === "error",
|
"bg-red-500/10 text-red-500": repository.status === "error",
|
||||||
},
|
},
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import {
|
||||||
getSnapshotDetailsOptions,
|
getSnapshotDetailsOptions,
|
||||||
listBackupSchedulesOptions,
|
listBackupSchedulesOptions,
|
||||||
} from "~/client/api-client/@tanstack/react-query.gen";
|
} from "~/client/api-client/@tanstack/react-query.gen";
|
||||||
|
import type { GetSnapshotDetailsResponse } from "~/client/api-client/types.gen";
|
||||||
import { Button } from "~/client/components/ui/button";
|
import { Button } from "~/client/components/ui/button";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "~/client/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "~/client/components/ui/card";
|
||||||
import { SnapshotFileBrowser } from "~/client/modules/backups/components/snapshot-file-browser";
|
import { SnapshotFileBrowser } from "~/client/modules/backups/components/snapshot-file-browser";
|
||||||
|
|
@ -49,7 +50,13 @@ const SnapshotFileBrowserSkeleton = () => (
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
export function SnapshotDetailsPage({ repositoryId, snapshotId }: { repositoryId: string; snapshotId: string }) {
|
type Props = {
|
||||||
|
repositoryId: string;
|
||||||
|
snapshotId: string;
|
||||||
|
initialSnapshot?: GetSnapshotDetailsResponse;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function SnapshotDetailsPage({ repositoryId, snapshotId, initialSnapshot }: Props) {
|
||||||
const [showAllPaths, setShowAllPaths] = useState(false);
|
const [showAllPaths, setShowAllPaths] = useState(false);
|
||||||
|
|
||||||
const { data: repository } = useSuspenseQuery({
|
const { data: repository } = useSuspenseQuery({
|
||||||
|
|
@ -62,6 +69,7 @@ export function SnapshotDetailsPage({ repositoryId, snapshotId }: { repositoryId
|
||||||
|
|
||||||
const { data, error } = useQuery({
|
const { data, error } = useQuery({
|
||||||
...getSnapshotDetailsOptions({ path: { shortId: repositoryId, snapshotId: snapshotId } }),
|
...getSnapshotDetailsOptions({ path: { shortId: repositoryId, snapshotId: snapshotId } }),
|
||||||
|
initialData: initialSnapshot,
|
||||||
});
|
});
|
||||||
const backupSchedule = schedules?.find((s) => data?.tags?.includes(s.shortId));
|
const backupSchedule = schedules?.find((s) => data?.tags?.includes(s.shortId));
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -187,7 +187,7 @@ export const RepositoryInfoTabContent = ({ repository, initialStats }: Props) =>
|
||||||
<p className="text-sm flex items-center gap-2">
|
<p className="text-sm flex items-center gap-2">
|
||||||
<span
|
<span
|
||||||
className={cn("w-2 h-2 rounded-full", {
|
className={cn("w-2 h-2 rounded-full", {
|
||||||
"bg-emerald-500": repository.status === "healthy",
|
"bg-success": repository.status === "healthy",
|
||||||
"bg-red-500": repository.status === "error",
|
"bg-red-500": repository.status === "error",
|
||||||
"bg-amber-500": repository.status !== "healthy" && repository.status !== "error",
|
"bg-amber-500": repository.status !== "healthy" && repository.status !== "error",
|
||||||
"animate-pulse": repository.status === "doctor",
|
"animate-pulse": repository.status === "doctor",
|
||||||
|
|
@ -209,7 +209,7 @@ export const RepositoryInfoTabContent = ({ repository, initialStats }: Props) =>
|
||||||
{hasCaCert && (
|
{hasCaCert && (
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
<div className="text-sm font-medium text-muted-foreground">CA Certificate</div>
|
<div className="text-sm font-medium text-muted-foreground">CA Certificate</div>
|
||||||
<p className="text-sm text-green-500">Configured</p>
|
<p className="text-sm text-success">Configured</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{hasInsecureTlsConfig && (
|
{hasInsecureTlsConfig && (
|
||||||
|
|
@ -217,7 +217,7 @@ export const RepositoryInfoTabContent = ({ repository, initialStats }: Props) =>
|
||||||
<div className="text-sm font-medium text-muted-foreground">TLS Validation</div>
|
<div className="text-sm font-medium text-muted-foreground">TLS Validation</div>
|
||||||
<p className="text-sm">
|
<p className="text-sm">
|
||||||
<span className={cn("text-red-500", { hidden: !isTlsValidationDisabled })}>Disabled</span>
|
<span className={cn("text-red-500", { hidden: !isTlsValidationDisabled })}>Disabled</span>
|
||||||
<span className={cn("text-green-500", { hidden: isTlsValidationDisabled })}>Enabled</span>
|
<span className={cn("text-success", { hidden: isTlsValidationDisabled })}>Enabled</span>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,10 @@
|
||||||
import { arktypeResolver } from "@hookform/resolvers/arktype";
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
import { useMutation } from "@tanstack/react-query";
|
import { useMutation } from "@tanstack/react-query";
|
||||||
import { type } from "arktype";
|
|
||||||
import { Plus, UserPlus } from "lucide-react";
|
import { Plus, UserPlus } from "lucide-react";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
import { z } from "zod";
|
||||||
import { Button } from "~/client/components/ui/button";
|
import { Button } from "~/client/components/ui/button";
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
|
|
@ -20,15 +20,15 @@ import { Input } from "~/client/components/ui/input";
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "~/client/components/ui/select";
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "~/client/components/ui/select";
|
||||||
import { authClient } from "~/client/lib/auth-client";
|
import { authClient } from "~/client/lib/auth-client";
|
||||||
|
|
||||||
const createUserSchema = type({
|
const createUserSchema = z.object({
|
||||||
name: "string>=1",
|
name: z.string().min(1),
|
||||||
username: "string>=1",
|
username: z.string().min(1),
|
||||||
email: "string",
|
email: z.string().email(),
|
||||||
password: "string>=8",
|
password: z.string().min(8),
|
||||||
role: "'user'|'admin'",
|
role: z.enum(["user", "admin"]),
|
||||||
});
|
});
|
||||||
|
|
||||||
type CreateUserFormValues = typeof createUserSchema.infer;
|
type CreateUserFormValues = z.infer<typeof createUserSchema>;
|
||||||
|
|
||||||
interface CreateUserDialogProps {
|
interface CreateUserDialogProps {
|
||||||
onUserCreated?: () => void;
|
onUserCreated?: () => void;
|
||||||
|
|
@ -38,7 +38,7 @@ export function CreateUserDialog({ onUserCreated }: CreateUserDialogProps) {
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
|
||||||
const form = useForm<CreateUserFormValues>({
|
const form = useForm<CreateUserFormValues>({
|
||||||
resolver: arktypeResolver(createUserSchema),
|
resolver: zodResolver(createUserSchema),
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
name: "",
|
name: "",
|
||||||
username: "",
|
username: "",
|
||||||
|
|
|
||||||
|
|
@ -2,12 +2,12 @@ import { useMutation, useSuspenseQuery } from "@tanstack/react-query";
|
||||||
import { Shield, ShieldAlert, Trash2 } from "lucide-react";
|
import { Shield, ShieldAlert, Trash2 } from "lucide-react";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import type { GetOrgMembersResponse } from "~/client/api-client";
|
|
||||||
import {
|
import {
|
||||||
getOrgMembersOptions,
|
getOrgMembersOptions,
|
||||||
removeOrgMemberMutation,
|
removeOrgMemberMutation,
|
||||||
updateMemberRoleMutation,
|
updateMemberRoleMutation,
|
||||||
} from "~/client/api-client/@tanstack/react-query.gen";
|
} from "~/client/api-client/@tanstack/react-query.gen";
|
||||||
|
import type { GetOrgMembersResponse } from "~/client/api-client/types.gen";
|
||||||
import {
|
import {
|
||||||
AlertDialog,
|
AlertDialog,
|
||||||
AlertDialogAction,
|
AlertDialogAction,
|
||||||
|
|
@ -25,11 +25,11 @@ import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "~
|
||||||
import { useOrganizationContext } from "~/client/hooks/use-org-context";
|
import { useOrganizationContext } from "~/client/hooks/use-org-context";
|
||||||
import { cn } from "~/client/lib/utils";
|
import { cn } from "~/client/lib/utils";
|
||||||
|
|
||||||
type OrgMembersSectionProps = {
|
type Props = {
|
||||||
initialMembers?: GetOrgMembersResponse;
|
initialMembers?: GetOrgMembersResponse;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function OrgMembersSection({ initialMembers }: OrgMembersSectionProps) {
|
export function OrgMembersSection({ initialMembers }: Props) {
|
||||||
const { activeMember } = useOrganizationContext();
|
const { activeMember } = useOrganizationContext();
|
||||||
const [memberToRemove, setMemberToRemove] = useState<{ id: string; name: string } | null>(null);
|
const [memberToRemove, setMemberToRemove] = useState<{ id: string; name: string } | null>(null);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -34,7 +34,7 @@ export const TwoFactorSection = ({ twoFactorEnabled }: TwoFactorSectionProps) =>
|
||||||
<p className="text-sm font-medium">
|
<p className="text-sm font-medium">
|
||||||
Status:
|
Status:
|
||||||
{twoFactorEnabled ? (
|
{twoFactorEnabled ? (
|
||||||
<span className="text-green-500">Enabled</span>
|
<span className="text-success">Enabled</span>
|
||||||
) : (
|
) : (
|
||||||
<span className="text-muted-foreground">Disabled</span>
|
<span className="text-muted-foreground">Disabled</span>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -154,7 +154,7 @@ export function UserManagement({ currentUser }: { currentUser: { id: string } |
|
||||||
<Badge variant="outline" className={cn("text-red-500 border-red-500", { hidden: !user.banned })}>
|
<Badge variant="outline" className={cn("text-red-500 border-red-500", { hidden: !user.banned })}>
|
||||||
Banned
|
Banned
|
||||||
</Badge>
|
</Badge>
|
||||||
<Badge variant="outline" className={cn("bg-green-500/10 text-emerald-500", { hidden: user.banned })}>
|
<Badge variant="outline" className={cn("bg-success/10 text-success", { hidden: user.banned })}>
|
||||||
Active
|
Active
|
||||||
</Badge>
|
</Badge>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ import { Download, KeyRound, User, X, Settings as SettingsIcon, Building2 } from
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { downloadResticPasswordMutation } from "~/client/api-client/@tanstack/react-query.gen";
|
import { downloadResticPasswordMutation } from "~/client/api-client/@tanstack/react-query.gen";
|
||||||
|
import type { GetOrgMembersResponse, GetSsoSettingsResponse } from "~/client/api-client/types.gen";
|
||||||
import { Button } from "~/client/components/ui/button";
|
import { Button } from "~/client/components/ui/button";
|
||||||
import { Card, CardContent, CardDescription, CardTitle } from "~/client/components/ui/card";
|
import { Card, CardContent, CardDescription, CardTitle } from "~/client/components/ui/card";
|
||||||
import {
|
import {
|
||||||
|
|
@ -21,18 +22,18 @@ import { authClient } from "~/client/lib/auth-client";
|
||||||
import { type AppContext } from "~/context";
|
import { type AppContext } from "~/context";
|
||||||
import { TwoFactorSection } from "../components/two-factor-section";
|
import { TwoFactorSection } from "../components/two-factor-section";
|
||||||
import { useNavigate, useSearch } from "@tanstack/react-router";
|
import { useNavigate, useSearch } from "@tanstack/react-router";
|
||||||
import { SsoSettingsSection } from "../components/sso/sso-settings-section";
|
import { SsoSettingsSection } from "~/client/modules/sso/components/sso-settings-section";
|
||||||
import { OrgMembersSection } from "../components/org-members-section";
|
import { OrgMembersSection } from "../components/org-members-section";
|
||||||
import { useOrganizationContext } from "~/client/hooks/use-org-context";
|
import { useOrganizationContext } from "~/client/hooks/use-org-context";
|
||||||
import type { GetOrgMembersResponse, GetSsoSettingsResponse } from "~/client/api-client";
|
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
appContext: AppContext;
|
appContext: AppContext;
|
||||||
initialMembers?: GetOrgMembersResponse;
|
initialMembers?: GetOrgMembersResponse;
|
||||||
initialSsoSettings?: GetSsoSettingsResponse;
|
initialSsoSettings?: GetSsoSettingsResponse;
|
||||||
|
initialOrigin?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function SettingsPage({ appContext, initialMembers, initialSsoSettings }: Props) {
|
export function SettingsPage({ appContext, initialMembers, initialSsoSettings, initialOrigin }: Props) {
|
||||||
const [currentPassword, setCurrentPassword] = useState("");
|
const [currentPassword, setCurrentPassword] = useState("");
|
||||||
const [newPassword, setNewPassword] = useState("");
|
const [newPassword, setNewPassword] = useState("");
|
||||||
const [confirmPassword, setConfirmPassword] = useState("");
|
const [confirmPassword, setConfirmPassword] = useState("");
|
||||||
|
|
@ -163,8 +164,12 @@ export function SettingsPage({ appContext, initialMembers, initialSsoSettings }:
|
||||||
</div>
|
</div>
|
||||||
<CardContent className="p-6 space-y-4">
|
<CardContent className="p-6 space-y-4">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label>Username</Label>
|
<Label htmlFor="username">Username</Label>
|
||||||
<Input value={appContext.user?.username || ""} disabled className="max-w-md" />
|
<Input id="username" value={appContext.user?.username} disabled className="max-w-md" />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="email">Email</Label>
|
||||||
|
<Input id="email" type="email" value={appContext.user?.email} disabled className="max-w-md" />
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|
||||||
|
|
@ -312,7 +317,7 @@ export function SettingsPage({ appContext, initialMembers, initialSsoSettings }:
|
||||||
<CardDescription className="mt-1.5">Configure OIDC provider settings</CardDescription>
|
<CardDescription className="mt-1.5">Configure OIDC provider settings</CardDescription>
|
||||||
</div>
|
</div>
|
||||||
<CardContent className="p-6">
|
<CardContent className="p-6">
|
||||||
<SsoSettingsSection initialSettings={initialSsoSettings} />
|
<SsoSettingsSection initialSettings={initialSsoSettings} initialOrigin={initialOrigin} />
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
|
||||||
57
app/client/modules/sso/components/sso-login-section.tsx
Normal file
57
app/client/modules/sso/components/sso-login-section.tsx
Normal file
|
|
@ -0,0 +1,57 @@
|
||||||
|
import { useMutation, useSuspenseQuery } from "@tanstack/react-query";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { Button } from "~/client/components/ui/button";
|
||||||
|
import { authClient } from "~/client/lib/auth-client";
|
||||||
|
import { getPublicSsoProvidersOptions } from "~/client/api-client/@tanstack/react-query.gen";
|
||||||
|
|
||||||
|
export function SsoLoginSection() {
|
||||||
|
const { data: ssoProviders } = useSuspenseQuery({
|
||||||
|
...getPublicSsoProvidersOptions(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const ssoLoginMutation = useMutation({
|
||||||
|
mutationFn: async (providerId: string) => {
|
||||||
|
const callbackPath = "/login";
|
||||||
|
const { data, error } = await authClient.signIn.sso({
|
||||||
|
providerId: providerId,
|
||||||
|
callbackURL: callbackPath,
|
||||||
|
errorCallbackURL: "/api/v1/auth/login-error",
|
||||||
|
});
|
||||||
|
if (error) throw error;
|
||||||
|
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
onSuccess: (data) => {
|
||||||
|
window.location.href = data.url;
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
console.error(error);
|
||||||
|
toast.error("SSO Login failed", { description: error.message });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (ssoProviders.providers.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="pt-4 border-t border-border/60 space-y-3">
|
||||||
|
<p className="text-sm font-medium">Alternative Sign-in</p>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
{ssoProviders.providers.map((provider) => (
|
||||||
|
<Button
|
||||||
|
key={provider.providerId}
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
className="w-full"
|
||||||
|
loading={ssoLoginMutation.isPending}
|
||||||
|
disabled={ssoLoginMutation.isPending}
|
||||||
|
onClick={() => ssoLoginMutation.mutate(provider.providerId)}
|
||||||
|
>
|
||||||
|
Log in with {provider.providerId}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -9,6 +9,7 @@ import {
|
||||||
getSsoSettingsOptions,
|
getSsoSettingsOptions,
|
||||||
updateSsoProviderAutoLinkingMutation,
|
updateSsoProviderAutoLinkingMutation,
|
||||||
} from "~/client/api-client/@tanstack/react-query.gen";
|
} from "~/client/api-client/@tanstack/react-query.gen";
|
||||||
|
import type { GetSsoSettingsResponse } from "~/client/api-client/types.gen";
|
||||||
import {
|
import {
|
||||||
AlertDialog,
|
AlertDialog,
|
||||||
AlertDialogAction,
|
AlertDialogAction,
|
||||||
|
|
@ -33,17 +34,17 @@ import { getOrigin } from "~/client/functions/get-origin";
|
||||||
import { authClient } from "~/client/lib/auth-client";
|
import { authClient } from "~/client/lib/auth-client";
|
||||||
import { cn } from "~/client/lib/utils";
|
import { cn } from "~/client/lib/utils";
|
||||||
import { useServerFn } from "@tanstack/react-start";
|
import { useServerFn } from "@tanstack/react-start";
|
||||||
import type { GetSsoSettingsResponse } from "~/client/api-client";
|
|
||||||
|
|
||||||
type InvitationRole = "member" | "admin" | "owner";
|
type InvitationRole = "member" | "admin" | "owner";
|
||||||
|
|
||||||
type SsoSettingsSectionProps = {
|
type Props = {
|
||||||
initialSettings?: GetSsoSettingsResponse;
|
initialSettings?: GetSsoSettingsResponse;
|
||||||
|
initialOrigin?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function SsoSettingsSection({ initialSettings }: SsoSettingsSectionProps) {
|
export function SsoSettingsSection({ initialSettings, initialOrigin }: Props) {
|
||||||
const originQuery = useServerFn(getOrigin);
|
const originQuery = useServerFn(getOrigin);
|
||||||
const { data } = useSuspenseQuery({ queryKey: ["app-origin"], queryFn: originQuery });
|
const { data } = useSuspenseQuery({ queryKey: ["app-origin"], queryFn: originQuery, initialData: initialOrigin });
|
||||||
|
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { activeOrganization } = useOrganizationContext();
|
const { activeOrganization } = useOrganizationContext();
|
||||||
|
|
@ -1,11 +1,11 @@
|
||||||
import { arktypeResolver } from "@hookform/resolvers/arktype";
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
import { useMutation } from "@tanstack/react-query";
|
import { useMutation } from "@tanstack/react-query";
|
||||||
import { useNavigate } from "@tanstack/react-router";
|
import { useNavigate } from "@tanstack/react-router";
|
||||||
import { type } from "arktype";
|
|
||||||
import { ShieldCheck, Plus } from "lucide-react";
|
import { ShieldCheck, Plus } from "lucide-react";
|
||||||
import { useId } from "react";
|
import { useId } from "react";
|
||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
import { z } from "zod";
|
||||||
import { updateSsoProviderAutoLinkingMutation } from "~/client/api-client/@tanstack/react-query.gen";
|
import { updateSsoProviderAutoLinkingMutation } from "~/client/api-client/@tanstack/react-query.gen";
|
||||||
import { Button } from "~/client/components/ui/button";
|
import { Button } from "~/client/components/ui/button";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "~/client/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "~/client/components/ui/card";
|
||||||
|
|
@ -24,17 +24,17 @@ import { authClient } from "~/client/lib/auth-client";
|
||||||
import { parseError } from "~/client/lib/errors";
|
import { parseError } from "~/client/lib/errors";
|
||||||
import { useOrganizationContext } from "~/client/hooks/use-org-context";
|
import { useOrganizationContext } from "~/client/hooks/use-org-context";
|
||||||
|
|
||||||
const ssoProviderSchema = type({
|
const ssoProviderSchema = z.object({
|
||||||
providerId: "string>=1",
|
providerId: z.string().min(1),
|
||||||
issuer: "string>=1",
|
issuer: z.string().min(1),
|
||||||
domain: "string>=1",
|
domain: z.string().min(1),
|
||||||
clientId: "string>=1",
|
clientId: z.string().min(1),
|
||||||
clientSecret: "string>=1",
|
clientSecret: z.string().min(1),
|
||||||
discoveryEndpoint: "string>=1",
|
discoveryEndpoint: z.string().min(1),
|
||||||
linkMatchingEmails: "boolean",
|
linkMatchingEmails: z.boolean(),
|
||||||
});
|
});
|
||||||
|
|
||||||
type ProviderForm = typeof ssoProviderSchema.infer;
|
type ProviderForm = z.infer<typeof ssoProviderSchema>;
|
||||||
|
|
||||||
export function CreateSsoProviderPage() {
|
export function CreateSsoProviderPage() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
@ -42,7 +42,7 @@ export function CreateSsoProviderPage() {
|
||||||
const { activeOrganization } = useOrganizationContext();
|
const { activeOrganization } = useOrganizationContext();
|
||||||
|
|
||||||
const form = useForm<ProviderForm>({
|
const form = useForm<ProviderForm>({
|
||||||
resolver: arktypeResolver(ssoProviderSchema),
|
resolver: zodResolver(ssoProviderSchema),
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
providerId: "",
|
providerId: "",
|
||||||
issuer: "",
|
issuer: "",
|
||||||
|
|
@ -1,11 +1,10 @@
|
||||||
import { arktypeResolver } from "@hookform/resolvers/arktype";
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
import { useMutation } from "@tanstack/react-query";
|
import { useMutation } from "@tanstack/react-query";
|
||||||
import { type } from "arktype";
|
|
||||||
import { CheckCircle, Loader2, Plug, Save, XCircle } from "lucide-react";
|
import { CheckCircle, Loader2, Plug, Save, XCircle } from "lucide-react";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
|
import { z } from "zod";
|
||||||
import { cn } from "~/client/lib/utils";
|
import { cn } from "~/client/lib/utils";
|
||||||
import { deepClean } from "~/utils/object";
|
|
||||||
import { Button } from "../../../components/ui/button";
|
import { Button } from "../../../components/ui/button";
|
||||||
import {
|
import {
|
||||||
Form,
|
Form,
|
||||||
|
|
@ -18,19 +17,31 @@ import {
|
||||||
} from "../../../components/ui/form";
|
} from "../../../components/ui/form";
|
||||||
import { Input } from "../../../components/ui/input";
|
import { Input } from "../../../components/ui/input";
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "../../../components/ui/select";
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "../../../components/ui/select";
|
||||||
import { volumeConfigSchemaBase } from "~/schemas/volumes";
|
import {
|
||||||
|
directoryConfigSchema,
|
||||||
|
nfsConfigSchema,
|
||||||
|
rcloneConfigSchema,
|
||||||
|
sftpConfigSchema,
|
||||||
|
smbConfigSchema,
|
||||||
|
volumeConfigSchema,
|
||||||
|
webdavConfigSchema,
|
||||||
|
} from "~/schemas/volumes";
|
||||||
import { testConnectionMutation } from "../../../api-client/@tanstack/react-query.gen";
|
import { testConnectionMutation } from "../../../api-client/@tanstack/react-query.gen";
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from "../../../components/ui/tooltip";
|
import { Tooltip, TooltipContent, TooltipTrigger } from "../../../components/ui/tooltip";
|
||||||
import { useSystemInfo } from "~/client/hooks/use-system-info";
|
import { useSystemInfo } from "~/client/hooks/use-system-info";
|
||||||
import { useScrollToFormError } from "~/client/hooks/use-scroll-to-form-error";
|
import { useScrollToFormError } from "~/client/hooks/use-scroll-to-form-error";
|
||||||
import { DirectoryForm, NFSForm, SMBForm, WebDAVForm, RcloneForm, SFTPForm } from "./volume-forms";
|
import { DirectoryForm, NFSForm, SMBForm, WebDAVForm, RcloneForm, SFTPForm } from "./volume-forms";
|
||||||
|
|
||||||
export const formSchema = type({
|
export const formSchema = z.discriminatedUnion("backend", [
|
||||||
name: "2<=string<=32",
|
directoryConfigSchema.extend({ name: z.string().min(2).max(32) }),
|
||||||
}).and(volumeConfigSchemaBase);
|
nfsConfigSchema.extend({ name: z.string().min(2).max(32) }),
|
||||||
const cleanSchema = type.pipe((d) => formSchema(deepClean(d)));
|
smbConfigSchema.extend({ name: z.string().min(2).max(32) }),
|
||||||
|
webdavConfigSchema.extend({ name: z.string().min(2).max(32) }),
|
||||||
|
rcloneConfigSchema.extend({ name: z.string().min(2).max(32) }),
|
||||||
|
sftpConfigSchema.extend({ name: z.string().min(2).max(32) }),
|
||||||
|
]);
|
||||||
|
|
||||||
export type FormValues = typeof formSchema.inferIn;
|
export type FormValues = z.input<typeof formSchema>;
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
onSubmit: (values: FormValues) => void;
|
onSubmit: (values: FormValues) => void;
|
||||||
|
|
@ -52,7 +63,7 @@ const defaultValuesForType = {
|
||||||
|
|
||||||
export const CreateVolumeForm = ({ onSubmit, mode = "create", initialValues, formId, loading, className }: Props) => {
|
export const CreateVolumeForm = ({ onSubmit, mode = "create", initialValues, formId, loading, className }: Props) => {
|
||||||
const form = useForm<FormValues>({
|
const form = useForm<FormValues>({
|
||||||
resolver: arktypeResolver(cleanSchema as unknown as typeof formSchema),
|
resolver: zodResolver(formSchema, undefined, { raw: true }),
|
||||||
defaultValues: initialValues || {
|
defaultValues: initialValues || {
|
||||||
name: "",
|
name: "",
|
||||||
backend: "directory",
|
backend: "directory",
|
||||||
|
|
@ -89,15 +100,22 @@ export const CreateVolumeForm = ({ onSubmit, mode = "create", initialValues, for
|
||||||
|
|
||||||
const handleTestConnection = async () => {
|
const handleTestConnection = async () => {
|
||||||
const formValues = getValues();
|
const formValues = getValues();
|
||||||
|
const { name: _, ...configCandidate } = formValues;
|
||||||
|
const parsedConfig = volumeConfigSchema.safeParse(configCandidate);
|
||||||
|
|
||||||
|
if (!parsedConfig.success) {
|
||||||
|
setTestMessage({ success: false, message: "Please fix validation errors before testing the connection." });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
formValues.backend === "nfs" ||
|
parsedConfig.data.backend === "nfs" ||
|
||||||
formValues.backend === "smb" ||
|
parsedConfig.data.backend === "smb" ||
|
||||||
formValues.backend === "webdav" ||
|
parsedConfig.data.backend === "webdav" ||
|
||||||
formValues.backend === "sftp"
|
parsedConfig.data.backend === "sftp"
|
||||||
) {
|
) {
|
||||||
testBackendConnection.mutate({
|
testBackendConnection.mutate({
|
||||||
body: { config: formValues },
|
body: { config: parsedConfig.data },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
@ -237,7 +255,7 @@ export const CreateVolumeForm = ({ onSubmit, mode = "create", initialValues, for
|
||||||
>
|
>
|
||||||
{testBackendConnection.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
{testBackendConnection.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||||
{!testBackendConnection.isPending && testMessage?.success && (
|
{!testBackendConnection.isPending && testMessage?.success && (
|
||||||
<CheckCircle className="mr-2 h-4 w-4 text-emerald-500" />
|
<CheckCircle className="mr-2 h-4 w-4 text-success" />
|
||||||
)}
|
)}
|
||||||
{!testBackendConnection.isPending && testMessage && !testMessage.success && (
|
{!testBackendConnection.isPending && testMessage && !testMessage.success && (
|
||||||
<XCircle className="mr-2 h-4 w-4 text-red-500" />
|
<XCircle className="mr-2 h-4 w-4 text-red-500" />
|
||||||
|
|
@ -255,7 +273,7 @@ export const CreateVolumeForm = ({ onSubmit, mode = "create", initialValues, for
|
||||||
{testMessage && (
|
{testMessage && (
|
||||||
<div
|
<div
|
||||||
className={cn("text-xs p-2 rounded-md text-wrap wrap-anywhere", {
|
className={cn("text-xs p-2 rounded-md text-wrap wrap-anywhere", {
|
||||||
"bg-green-50 text-emerald-700 border border-green-200": testMessage.success,
|
"bg-success/10 text-success border border-success/30": testMessage.success,
|
||||||
"bg-red-50 text-red-700 border border-red-200": !testMessage.success,
|
"bg-red-50 text-red-700 border border-red-200": !testMessage.success,
|
||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
|
|
|
||||||
|
|
@ -51,7 +51,7 @@ export const HealthchecksCard = ({ volume }: Props) => {
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="flex flex-col flex-1 justify-start">
|
<div className="flex flex-col flex-1 justify-start">
|
||||||
{volume.lastError && <span className="text-sm text-red-500 wrap-break-word">{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-green-500">Healthy</span>}
|
{volume.status === "mounted" && <span className="text-md text-success">Healthy</span>}
|
||||||
{volume.status !== "unmounted" && (
|
{volume.status !== "unmounted" && (
|
||||||
<span className="text-xs text-muted-foreground mb-4">Checked {formatTimeAgo(volume.lastHealthCheck)}</span>
|
<span className="text-xs text-muted-foreground mb-4">Checked {formatTimeAgo(volume.lastHealthCheck)}</span>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,7 @@ export function StorageChart({ statfs }: Props) {
|
||||||
{
|
{
|
||||||
name: "Used",
|
name: "Used",
|
||||||
value: statfs.used,
|
value: statfs.used,
|
||||||
fill: "#ff543a",
|
fill: "var(--strong-accent)",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "Free",
|
name: "Free",
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@ import { HardDrive, Plus } from "lucide-react";
|
||||||
import { useId } from "react";
|
import { useId } from "react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { createVolumeMutation } from "~/client/api-client/@tanstack/react-query.gen";
|
import { createVolumeMutation } from "~/client/api-client/@tanstack/react-query.gen";
|
||||||
import { CreateVolumeForm, type FormValues } from "~/client/modules/volumes/components/create-volume-form";
|
import { CreateVolumeForm, formSchema, type FormValues } from "~/client/modules/volumes/components/create-volume-form";
|
||||||
import { Button } from "~/client/components/ui/button";
|
import { Button } from "~/client/components/ui/button";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "~/client/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "~/client/components/ui/card";
|
||||||
import { parseError } from "~/client/lib/errors";
|
import { parseError } from "~/client/lib/errors";
|
||||||
|
|
@ -23,10 +23,12 @@ export function CreateVolumePage() {
|
||||||
});
|
});
|
||||||
|
|
||||||
const handleSubmit = (values: FormValues) => {
|
const handleSubmit = (values: FormValues) => {
|
||||||
|
const { name, ...config } = formSchema.parse(values);
|
||||||
|
|
||||||
createVolume.mutate({
|
createVolume.mutate({
|
||||||
body: {
|
body: {
|
||||||
config: values,
|
config,
|
||||||
name: values.name,
|
name,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -134,7 +134,7 @@ export function VolumesPage() {
|
||||||
{filteredVolumes.map((volume) => (
|
{filteredVolumes.map((volume) => (
|
||||||
<TableRow
|
<TableRow
|
||||||
key={volume.name}
|
key={volume.name}
|
||||||
className="hover:bg-white/2 hover:cursor-pointer transition-colors h-12"
|
className="hover:bg-muted/50 hover:cursor-pointer transition-colors h-12"
|
||||||
onClick={() => navigate({ to: `/volumes/${volume.shortId}` })}
|
onClick={() => navigate({ to: `/volumes/${volume.shortId}` })}
|
||||||
>
|
>
|
||||||
<TableCell className="font-medium font-mono text-strong-accent">{volume.name}</TableCell>
|
<TableCell className="font-medium font-mono text-strong-accent">{volume.name}</TableCell>
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@ import { useMutation } from "@tanstack/react-query";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { Check, Plug, Trash2, Unplug } from "lucide-react";
|
import { Check, Plug, Trash2, Unplug } from "lucide-react";
|
||||||
import { CreateVolumeForm, type FormValues } from "~/client/modules/volumes/components/create-volume-form";
|
import { CreateVolumeForm, formSchema, type FormValues } from "~/client/modules/volumes/components/create-volume-form";
|
||||||
import {
|
import {
|
||||||
AlertDialog,
|
AlertDialog,
|
||||||
AlertDialogAction,
|
AlertDialogAction,
|
||||||
|
|
@ -102,9 +102,11 @@ export const VolumeInfoTabContent = ({ volume, statfs }: Props) => {
|
||||||
|
|
||||||
const confirmUpdate = () => {
|
const confirmUpdate = () => {
|
||||||
if (pendingValues) {
|
if (pendingValues) {
|
||||||
|
const { name, ...config } = formSchema.parse(pendingValues);
|
||||||
|
|
||||||
updateMutation.mutate({
|
updateMutation.mutate({
|
||||||
path: { shortId: volume.shortId },
|
path: { shortId: volume.shortId },
|
||||||
body: { name: pendingValues.name, config: pendingValues },
|
body: { name, config },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
|
||||||
1
app/drizzle/20260304175134_woozy_zemo/migration.sql
Normal file
1
app/drizzle/20260304175134_woozy_zemo/migration.sql
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
ALTER TABLE `backup_schedules_table` ADD `custom_restic_params` text DEFAULT '[]';
|
||||||
2195
app/drizzle/20260304175134_woozy_zemo/snapshot.json
Normal file
2195
app/drizzle/20260304175134_woozy_zemo/snapshot.json
Normal file
File diff suppressed because it is too large
Load diff
2
app/drizzle/20260305160806_watery_iron_lad/migration.sql
Normal file
2
app/drizzle/20260305160806_watery_iron_lad/migration.sql
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
ALTER TABLE `repositories_table` ADD `stats` text;--> statement-breakpoint
|
||||||
|
ALTER TABLE `repositories_table` ADD `stats_updated_at` integer;
|
||||||
2215
app/drizzle/20260305160806_watery_iron_lad/snapshot.json
Normal file
2215
app/drizzle/20260305160806_watery_iron_lad/snapshot.json
Normal file
File diff suppressed because it is too large
Load diff
24
app/lib/sso-errors.ts
Normal file
24
app/lib/sso-errors.ts
Normal file
|
|
@ -0,0 +1,24 @@
|
||||||
|
export const LOGIN_ERROR_CODES = [
|
||||||
|
"ACCOUNT_LINK_REQUIRED",
|
||||||
|
"EMAIL_NOT_VERIFIED",
|
||||||
|
"INVITE_REQUIRED",
|
||||||
|
"BANNED_USER",
|
||||||
|
"SSO_LOGIN_FAILED",
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export type LoginErrorCode = (typeof LOGIN_ERROR_CODES)[number];
|
||||||
|
|
||||||
|
export function getLoginErrorDescription(errorCode: LoginErrorCode): string {
|
||||||
|
switch (errorCode) {
|
||||||
|
case "ACCOUNT_LINK_REQUIRED":
|
||||||
|
return "SSO sign-in was blocked because this email already belongs to another user in this instance. Contact your administrator to resolve the account conflict.";
|
||||||
|
case "EMAIL_NOT_VERIFIED":
|
||||||
|
return "Your identity provider did not mark your email as verified.";
|
||||||
|
case "INVITE_REQUIRED":
|
||||||
|
return "Access is invite-only. Ask an organization admin to send you an invitation before signing in with SSO.";
|
||||||
|
case "BANNED_USER":
|
||||||
|
return "You have been banned from this application. Please contact support if you believe this is an error.";
|
||||||
|
case "SSO_LOGIN_FAILED":
|
||||||
|
return "SSO authentication failed. Please try again.";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,11 +1,11 @@
|
||||||
import { createFileRoute } from "@tanstack/react-router";
|
import { createFileRoute } from "@tanstack/react-router";
|
||||||
import { type } from "arktype";
|
import { z } from "zod";
|
||||||
import { LoginPage } from "~/client/modules/auth/routes/login";
|
import { LoginPage } from "~/client/modules/auth/routes/login";
|
||||||
|
|
||||||
export const Route = createFileRoute("/(auth)/login/error")({
|
export const Route = createFileRoute("/(auth)/login/error")({
|
||||||
component: RouteComponent,
|
component: RouteComponent,
|
||||||
errorComponent: () => <div>Failed to load login error</div>,
|
errorComponent: () => <div>Failed to load login error</div>,
|
||||||
validateSearch: type({ error: "string?" }),
|
validateSearch: z.object({ error: z.string().optional() }),
|
||||||
head: () => ({
|
head: () => ({
|
||||||
meta: [
|
meta: [
|
||||||
{ title: "Zerobyte - Login Error" },
|
{ title: "Zerobyte - Login Error" },
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,11 @@
|
||||||
import { Outlet, createFileRoute, useRouterState } from "@tanstack/react-router";
|
import { Outlet, createFileRoute, useRouterState } from "@tanstack/react-router";
|
||||||
import { type } from "arktype";
|
import { z } from "zod";
|
||||||
import { LoginPage } from "~/client/modules/auth/routes/login";
|
import { LoginPage } from "~/client/modules/auth/routes/login";
|
||||||
|
|
||||||
export const Route = createFileRoute("/(auth)/login")({
|
export const Route = createFileRoute("/(auth)/login")({
|
||||||
component: RouteComponent,
|
component: RouteComponent,
|
||||||
errorComponent: () => <div>Failed to load login</div>,
|
errorComponent: () => <div>Failed to load login</div>,
|
||||||
validateSearch: type({ error: "string?" }),
|
validateSearch: z.object({ error: z.string().optional() }),
|
||||||
head: () => ({
|
head: () => ({
|
||||||
meta: [
|
meta: [
|
||||||
{ title: "Zerobyte - Login" },
|
{ title: "Zerobyte - Login" },
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,12 @@
|
||||||
import { createFileRoute, redirect } from "@tanstack/react-router";
|
import { createFileRoute, redirect } from "@tanstack/react-router";
|
||||||
import { type } from "arktype";
|
import { z } from "zod";
|
||||||
import { fetchUser } from "../route";
|
import { fetchUser } from "../route";
|
||||||
import type { AppContext } from "~/context";
|
import type { AppContext } from "~/context";
|
||||||
import { AdminPage } from "~/client/modules/admin/routes/admin-page";
|
import { AdminPage } from "~/client/modules/admin/routes/admin-page";
|
||||||
import { getAdminUsersOptions, getRegistrationStatusOptions } from "~/client/api-client/@tanstack/react-query.gen";
|
import { getAdminUsersOptions, getRegistrationStatusOptions } from "~/client/api-client/@tanstack/react-query.gen";
|
||||||
|
|
||||||
export const Route = createFileRoute("/(dashboard)/admin/")({
|
export const Route = createFileRoute("/(dashboard)/admin/")({
|
||||||
validateSearch: type({ tab: "string?" }),
|
validateSearch: z.object({ tab: z.string().optional() }),
|
||||||
component: RouteComponent,
|
component: RouteComponent,
|
||||||
errorComponent: () => <div>Failed to load admin</div>,
|
errorComponent: () => <div>Failed to load admin</div>,
|
||||||
loader: async ({ context }) => {
|
loader: async ({ context }) => {
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import { createFileRoute } from "@tanstack/react-router";
|
import { createFileRoute } from "@tanstack/react-router";
|
||||||
import { type } from "arktype";
|
import { z } from "zod";
|
||||||
import {
|
import {
|
||||||
|
getBackupProgressOptions,
|
||||||
getBackupScheduleOptions,
|
getBackupScheduleOptions,
|
||||||
getScheduleMirrorsOptions,
|
getScheduleMirrorsOptions,
|
||||||
getScheduleNotificationsOptions,
|
getScheduleNotificationsOptions,
|
||||||
|
|
@ -14,7 +15,7 @@ import { prefetchOrSkip } from "~/utils/prefetch";
|
||||||
export const Route = createFileRoute("/(dashboard)/backups/$backupId/")({
|
export const Route = createFileRoute("/(dashboard)/backups/$backupId/")({
|
||||||
component: RouteComponent,
|
component: RouteComponent,
|
||||||
errorComponent: () => <div>Failed to load backup</div>,
|
errorComponent: () => <div>Failed to load backup</div>,
|
||||||
validateSearch: type({ snapshot: "string?" }),
|
validateSearch: z.object({ snapshot: z.string().optional() }),
|
||||||
loader: async ({ params, context }) => {
|
loader: async ({ params, context }) => {
|
||||||
const { backupId } = params;
|
const { backupId } = params;
|
||||||
|
|
||||||
|
|
@ -24,6 +25,7 @@ export const Route = createFileRoute("/(dashboard)/backups/$backupId/")({
|
||||||
context.queryClient.ensureQueryData({ ...listRepositoriesOptions() }),
|
context.queryClient.ensureQueryData({ ...listRepositoriesOptions() }),
|
||||||
context.queryClient.ensureQueryData({ ...getScheduleNotificationsOptions({ path: { shortId: backupId } }) }),
|
context.queryClient.ensureQueryData({ ...getScheduleNotificationsOptions({ path: { shortId: backupId } }) }),
|
||||||
context.queryClient.ensureQueryData({ ...getScheduleMirrorsOptions({ path: { shortId: backupId } }) }),
|
context.queryClient.ensureQueryData({ ...getScheduleMirrorsOptions({ path: { shortId: backupId } }) }),
|
||||||
|
context.queryClient.ensureQueryData({ ...getBackupProgressOptions({ path: { shortId: backupId } }) }),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const snapshotOptions = listSnapshotsOptions({
|
const snapshotOptions = listSnapshotsOptions({
|
||||||
|
|
|
||||||
|
|
@ -1,16 +1,30 @@
|
||||||
import { createFileRoute } from "@tanstack/react-router";
|
import { createFileRoute } from "@tanstack/react-router";
|
||||||
import { getRepositoryOptions } from "~/client/api-client/@tanstack/react-query.gen";
|
import {
|
||||||
|
getRepositoryOptions,
|
||||||
|
getSnapshotDetailsOptions,
|
||||||
|
listBackupSchedulesOptions,
|
||||||
|
} from "~/client/api-client/@tanstack/react-query.gen";
|
||||||
import { SnapshotDetailsPage } from "~/client/modules/repositories/routes/snapshot-details";
|
import { SnapshotDetailsPage } from "~/client/modules/repositories/routes/snapshot-details";
|
||||||
|
import { prefetchOrSkip } from "~/utils/prefetch";
|
||||||
|
|
||||||
export const Route = createFileRoute("/(dashboard)/repositories/$repositoryId/$snapshotId/")({
|
export const Route = createFileRoute("/(dashboard)/repositories/$repositoryId/$snapshotId/")({
|
||||||
component: RouteComponent,
|
component: RouteComponent,
|
||||||
errorComponent: (e) => <div>{e.error.message}</div>,
|
errorComponent: (e) => <div>{e.error.message}</div>,
|
||||||
loader: async ({ params, context }) => {
|
loader: async ({ params, context }) => {
|
||||||
const res = await context.queryClient.ensureQueryData({
|
const [res] = await Promise.all([
|
||||||
...getRepositoryOptions({ path: { shortId: params.repositoryId } }),
|
context.queryClient.ensureQueryData({ ...getRepositoryOptions({ path: { shortId: params.repositoryId } }) }),
|
||||||
});
|
context.queryClient.ensureQueryData({ ...listBackupSchedulesOptions() }),
|
||||||
|
]);
|
||||||
|
|
||||||
return res;
|
const snapshotOptions = getSnapshotDetailsOptions({
|
||||||
|
path: { shortId: params.repositoryId, snapshotId: params.snapshotId },
|
||||||
|
});
|
||||||
|
await prefetchOrSkip(context.queryClient, snapshotOptions);
|
||||||
|
|
||||||
|
return {
|
||||||
|
...res,
|
||||||
|
snapshot: context.queryClient.getQueryData(snapshotOptions.queryKey),
|
||||||
|
};
|
||||||
},
|
},
|
||||||
staticData: {
|
staticData: {
|
||||||
breadcrumb: (match) => [
|
breadcrumb: (match) => [
|
||||||
|
|
@ -32,6 +46,7 @@ export const Route = createFileRoute("/(dashboard)/repositories/$repositoryId/$s
|
||||||
|
|
||||||
function RouteComponent() {
|
function RouteComponent() {
|
||||||
const { repositoryId, snapshotId } = Route.useParams();
|
const { repositoryId, snapshotId } = Route.useParams();
|
||||||
|
const { snapshot } = Route.useLoaderData();
|
||||||
|
|
||||||
return <SnapshotDetailsPage repositoryId={repositoryId} snapshotId={snapshotId} />;
|
return <SnapshotDetailsPage repositoryId={repositoryId} snapshotId={snapshotId} initialSnapshot={snapshot} />;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { createFileRoute } from "@tanstack/react-router";
|
import { createFileRoute } from "@tanstack/react-router";
|
||||||
import { type } from "arktype";
|
import { z } from "zod";
|
||||||
import {
|
import {
|
||||||
getRepositoryOptions,
|
getRepositoryOptions,
|
||||||
getRepositoryStatsOptions,
|
getRepositoryStatsOptions,
|
||||||
|
|
@ -14,24 +14,23 @@ export const Route = createFileRoute("/(dashboard)/repositories/$repositoryId/")
|
||||||
errorComponent: (e) => <div>{e.error.message}</div>,
|
errorComponent: (e) => <div>{e.error.message}</div>,
|
||||||
loader: async ({ params, context }) => {
|
loader: async ({ params, context }) => {
|
||||||
const snapshotOptions = listSnapshotsOptions({ path: { shortId: params.repositoryId } });
|
const snapshotOptions = listSnapshotsOptions({ path: { shortId: params.repositoryId } });
|
||||||
const schedulesOptions = listBackupSchedulesOptions();
|
|
||||||
const statsOptions = getRepositoryStatsOptions({ path: { shortId: params.repositoryId } });
|
const statsOptions = getRepositoryStatsOptions({ path: { shortId: params.repositoryId } });
|
||||||
|
|
||||||
const [res] = await Promise.all([
|
const [res, schedules] = await Promise.all([
|
||||||
context.queryClient.ensureQueryData(getRepositoryOptions({ path: { shortId: params.repositoryId } })),
|
context.queryClient.ensureQueryData(getRepositoryOptions({ path: { shortId: params.repositoryId } })),
|
||||||
|
context.queryClient.ensureQueryData(listBackupSchedulesOptions()),
|
||||||
prefetchOrSkip(context.queryClient, snapshotOptions),
|
prefetchOrSkip(context.queryClient, snapshotOptions),
|
||||||
prefetchOrSkip(context.queryClient, schedulesOptions),
|
|
||||||
prefetchOrSkip(context.queryClient, statsOptions),
|
prefetchOrSkip(context.queryClient, statsOptions),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...res,
|
...res,
|
||||||
snapshots: context.queryClient.getQueryData(snapshotOptions.queryKey),
|
snapshots: context.queryClient.getQueryData(snapshotOptions.queryKey),
|
||||||
backupSchedules: context.queryClient.getQueryData(schedulesOptions.queryKey),
|
backupSchedules: schedules,
|
||||||
stats: context.queryClient.getQueryData(statsOptions.queryKey),
|
stats: context.queryClient.getQueryData(statsOptions.queryKey),
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
validateSearch: type({ tab: "string?" }),
|
validateSearch: z.object({ tab: z.string().optional() }),
|
||||||
staticData: {
|
staticData: {
|
||||||
breadcrumb: (match) => [
|
breadcrumb: (match) => [
|
||||||
{ label: "Repositories", href: "/repositories" },
|
{ label: "Repositories", href: "/repositories" },
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ import { SIDEBAR_COOKIE_NAME } from "~/client/components/ui/sidebar";
|
||||||
import { authMiddleware } from "~/middleware/auth";
|
import { authMiddleware } from "~/middleware/auth";
|
||||||
import { auth } from "~/server/lib/auth";
|
import { auth } from "~/server/lib/auth";
|
||||||
import { getOrganizationContext } from "~/server/lib/functions/organization-context";
|
import { getOrganizationContext } from "~/server/lib/functions/organization-context";
|
||||||
|
import { getServerConstants } from "~/server/lib/functions/server-constants";
|
||||||
import { authService } from "~/server/modules/auth/auth.service";
|
import { authService } from "~/server/modules/auth/auth.service";
|
||||||
|
|
||||||
export const fetchUser = createServerFn({ method: "GET" }).handler(async () => {
|
export const fetchUser = createServerFn({ method: "GET" }).handler(async () => {
|
||||||
|
|
@ -32,6 +33,10 @@ export const Route = createFileRoute("/(dashboard)")({
|
||||||
queryKey: ["organization-context"],
|
queryKey: ["organization-context"],
|
||||||
queryFn: () => getOrganizationContext(),
|
queryFn: () => getOrganizationContext(),
|
||||||
}),
|
}),
|
||||||
|
context.queryClient.ensureQueryData({
|
||||||
|
queryKey: ["server-constants"],
|
||||||
|
queryFn: () => getServerConstants(),
|
||||||
|
}),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return authContext;
|
return authContext;
|
||||||
|
|
|
||||||
|
|
@ -2,33 +2,42 @@ import { createFileRoute } from "@tanstack/react-router";
|
||||||
import { fetchUser } from "../route";
|
import { fetchUser } from "../route";
|
||||||
import type { AppContext } from "~/context";
|
import type { AppContext } from "~/context";
|
||||||
import { SettingsPage } from "~/client/modules/settings/routes/settings";
|
import { SettingsPage } from "~/client/modules/settings/routes/settings";
|
||||||
import { type } from "arktype";
|
import { z } from "zod";
|
||||||
|
import { getOrganizationContext } from "~/server/lib/functions/organization-context";
|
||||||
import { getOrgMembersOptions, getSsoSettingsOptions } from "~/client/api-client/@tanstack/react-query.gen";
|
import { getOrgMembersOptions, getSsoSettingsOptions } from "~/client/api-client/@tanstack/react-query.gen";
|
||||||
import { getOrigin } from "~/client/functions/get-origin";
|
import { getOrigin } from "~/client/functions/get-origin";
|
||||||
import { getOrganizationContext } from "~/server/lib/functions/organization-context";
|
|
||||||
|
|
||||||
export const Route = createFileRoute("/(dashboard)/settings/")({
|
export const Route = createFileRoute("/(dashboard)/settings/")({
|
||||||
component: RouteComponent,
|
component: RouteComponent,
|
||||||
validateSearch: type({ tab: "string?" }),
|
validateSearch: z.object({ tab: z.string().optional() }),
|
||||||
errorComponent: () => <div>Failed to load settings</div>,
|
errorComponent: () => <div>Failed to load settings</div>,
|
||||||
loader: async ({ context }) => {
|
loader: async ({ context }) => {
|
||||||
const authContext = await fetchUser();
|
const [authContext, orgContext] = await Promise.all([
|
||||||
const orgContext = await getOrganizationContext();
|
fetchUser(),
|
||||||
|
context.queryClient.ensureQueryData({
|
||||||
|
queryKey: ["organization-context"],
|
||||||
|
queryFn: () => getOrganizationContext(),
|
||||||
|
}),
|
||||||
|
]);
|
||||||
const orgRole = orgContext.activeMember?.role;
|
const orgRole = orgContext.activeMember?.role;
|
||||||
|
const shouldPrefetchOrgQueries = orgRole === "owner" || orgRole === "admin";
|
||||||
|
|
||||||
let org, members;
|
if (shouldPrefetchOrgQueries) {
|
||||||
|
const [org, members, appOrigin] = await Promise.all([
|
||||||
if (authContext.user?.role === "admin" || orgRole === "owner" || orgRole === "admin") {
|
|
||||||
const promises = await Promise.all([
|
|
||||||
context.queryClient.ensureQueryData({ ...getSsoSettingsOptions() }),
|
context.queryClient.ensureQueryData({ ...getSsoSettingsOptions() }),
|
||||||
context.queryClient.ensureQueryData({ ...getOrgMembersOptions() }),
|
context.queryClient.ensureQueryData({ ...getOrgMembersOptions() }),
|
||||||
context.queryClient.ensureQueryData({ queryKey: ["app-origin"], queryFn: () => getOrigin() }),
|
context.queryClient.ensureQueryData({ queryKey: ["app-origin"], queryFn: () => getOrigin() }),
|
||||||
]);
|
]);
|
||||||
org = promises[0];
|
|
||||||
members = promises[1];
|
return {
|
||||||
|
authContext: authContext as AppContext,
|
||||||
|
org,
|
||||||
|
members,
|
||||||
|
appOrigin,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
return { authContext: authContext as AppContext, org, members };
|
return { authContext: authContext as AppContext };
|
||||||
},
|
},
|
||||||
staticData: {
|
staticData: {
|
||||||
breadcrumb: () => [{ label: "Settings" }],
|
breadcrumb: () => [{ label: "Settings" }],
|
||||||
|
|
@ -36,7 +45,14 @@ export const Route = createFileRoute("/(dashboard)/settings/")({
|
||||||
});
|
});
|
||||||
|
|
||||||
function RouteComponent() {
|
function RouteComponent() {
|
||||||
const { authContext, org, members } = Route.useLoaderData();
|
const { authContext, members, org, appOrigin } = Route.useLoaderData();
|
||||||
|
|
||||||
return <SettingsPage appContext={authContext} initialMembers={members} initialSsoSettings={org} />;
|
return (
|
||||||
|
<SettingsPage
|
||||||
|
appContext={authContext}
|
||||||
|
initialMembers={members}
|
||||||
|
initialSsoSettings={org}
|
||||||
|
initialOrigin={appOrigin}
|
||||||
|
/>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { createFileRoute, redirect } from "@tanstack/react-router";
|
import { createFileRoute, redirect } from "@tanstack/react-router";
|
||||||
import { CreateSsoProviderPage } from "~/client/modules/settings/routes/create-sso-provider";
|
import { CreateSsoProviderPage } from "~/client/modules/sso/routes/create-sso-provider";
|
||||||
import { getOrganizationContext } from "~/server/lib/functions/organization-context";
|
import { getOrganizationContext } from "~/server/lib/functions/organization-context";
|
||||||
|
|
||||||
export const Route = createFileRoute("/(dashboard)/settings/sso/new")({
|
export const Route = createFileRoute("/(dashboard)/settings/sso/new")({
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { createFileRoute } from "@tanstack/react-router";
|
import { createFileRoute } from "@tanstack/react-router";
|
||||||
import { type } from "arktype";
|
import { z } from "zod";
|
||||||
import { getVolumeOptions } from "~/client/api-client/@tanstack/react-query.gen";
|
import { getVolumeOptions } from "~/client/api-client/@tanstack/react-query.gen";
|
||||||
import { VolumeDetails } from "~/client/modules/volumes/routes/volume-details";
|
import { VolumeDetails } from "~/client/modules/volumes/routes/volume-details";
|
||||||
|
|
||||||
|
|
@ -13,7 +13,7 @@ export const Route = createFileRoute("/(dashboard)/volumes/$volumeId")({
|
||||||
|
|
||||||
return res;
|
return res;
|
||||||
},
|
},
|
||||||
validateSearch: type({ tab: "string?" }),
|
validateSearch: z.object({ tab: z.string().optional() }),
|
||||||
staticData: {
|
staticData: {
|
||||||
breadcrumb: (match) => [
|
breadcrumb: (match) => [
|
||||||
{ label: "Volumes", href: "/volumes" },
|
{ label: "Volumes", href: "/volumes" },
|
||||||
|
|
|
||||||
|
|
@ -2,14 +2,27 @@ import { Outlet, HeadContent, Scripts, createRootRouteWithContext } from "@tanst
|
||||||
import appCss from "../app.css?url";
|
import appCss from "../app.css?url";
|
||||||
import { apiClientMiddleware } from "~/middleware/api-client";
|
import { apiClientMiddleware } from "~/middleware/api-client";
|
||||||
import type { QueryClient } from "@tanstack/react-query";
|
import type { QueryClient } from "@tanstack/react-query";
|
||||||
|
import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
|
||||||
import { Toaster } from "~/client/components/ui/sonner";
|
import { Toaster } from "~/client/components/ui/sonner";
|
||||||
import { useServerEvents } from "~/client/hooks/use-server-events";
|
import { useServerEvents } from "~/client/hooks/use-server-events";
|
||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
|
import { ThemeProvider, THEME_COOKIE_NAME } from "~/client/components/theme-provider";
|
||||||
|
import { createServerFn } from "@tanstack/react-start";
|
||||||
|
import { getCookie } from "@tanstack/react-start/server";
|
||||||
|
|
||||||
|
const fetchTheme = createServerFn({ method: "GET" }).handler(async () => {
|
||||||
|
const themeCookie = getCookie(THEME_COOKIE_NAME);
|
||||||
|
return (themeCookie === "light" ? "light" : "dark") as "light" | "dark";
|
||||||
|
});
|
||||||
|
|
||||||
export const Route = createRootRouteWithContext<{ queryClient: QueryClient }>()({
|
export const Route = createRootRouteWithContext<{ queryClient: QueryClient }>()({
|
||||||
server: {
|
server: {
|
||||||
middleware: [apiClientMiddleware],
|
middleware: [apiClientMiddleware],
|
||||||
},
|
},
|
||||||
|
loader: async () => {
|
||||||
|
const theme = await fetchTheme();
|
||||||
|
return { theme };
|
||||||
|
},
|
||||||
head: () => ({
|
head: () => ({
|
||||||
meta: [{ title: "Zerobyte - Open Source Backup Solution" }],
|
meta: [{ title: "Zerobyte - Open Source Backup Solution" }],
|
||||||
links: [
|
links: [
|
||||||
|
|
@ -34,6 +47,7 @@ export const Route = createRootRouteWithContext<{ queryClient: QueryClient }>()(
|
||||||
});
|
});
|
||||||
|
|
||||||
function RootLayout() {
|
function RootLayout() {
|
||||||
|
const { theme } = Route.useLoaderData();
|
||||||
useServerEvents();
|
useServerEvents();
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
document.body.setAttribute("data-app-ready", "true");
|
document.body.setAttribute("data-app-ready", "true");
|
||||||
|
|
@ -43,7 +57,7 @@ function RootLayout() {
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<html lang="en">
|
<html lang="en" className={theme === "dark" ? "dark" : undefined} style={{ colorScheme: theme }}>
|
||||||
<head>
|
<head>
|
||||||
<meta charSet="utf-8" />
|
<meta charSet="utf-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no" />
|
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no" />
|
||||||
|
|
@ -55,9 +69,12 @@ function RootLayout() {
|
||||||
<link rel="manifest" href="/images/favicon/site.webmanifest" />
|
<link rel="manifest" href="/images/favicon/site.webmanifest" />
|
||||||
<HeadContent />
|
<HeadContent />
|
||||||
</head>
|
</head>
|
||||||
<body className="dark">
|
<body>
|
||||||
<Outlet />
|
<ThemeProvider initialTheme={theme}>
|
||||||
<Toaster />
|
<Outlet />
|
||||||
|
<Toaster />
|
||||||
|
<ReactQueryDevtools buttonPosition="bottom-right" />
|
||||||
|
</ThemeProvider>
|
||||||
<Scripts />
|
<Scripts />
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,26 @@
|
||||||
import { createFileRoute } from "@tanstack/react-router";
|
import { createFileRoute } from "@tanstack/react-router";
|
||||||
import { createApp } from "~/server/app";
|
import { createApp } from "~/server/app";
|
||||||
|
import { config } from "~/server/core/config";
|
||||||
|
|
||||||
const app = createApp();
|
const app = createApp();
|
||||||
|
|
||||||
const handle = ({ request }: { request: Request }) => app.fetch(request.clone());
|
type NodeRuntimeRequest = Request & {
|
||||||
|
runtime?: {
|
||||||
|
node?: {
|
||||||
|
res?: { setTimeout: (timeoutMs: number) => void };
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const prepareApiRequest = (request: Request, timeoutMs: number) => {
|
||||||
|
const nodeRequest = request as NodeRuntimeRequest;
|
||||||
|
nodeRequest.runtime?.node?.res?.setTimeout(timeoutMs);
|
||||||
|
|
||||||
|
return request.clone();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handle = ({ request }: { request: Request }) =>
|
||||||
|
app.fetch(prepareApiRequest(request, config.serverIdleTimeout * 1000));
|
||||||
|
|
||||||
export const Route = createFileRoute("/api/$")({
|
export const Route = createFileRoute("/api/$")({
|
||||||
server: {
|
server: {
|
||||||
|
|
|
||||||
|
|
@ -1,85 +1,84 @@
|
||||||
import { type } from "arktype";
|
import { z } from "zod";
|
||||||
import { resticBackupProgressMetricsSchema, resticBackupRunSummarySchema } from "~/schemas/restic-dto";
|
import { resticBackupProgressMetricsSchema, resticBackupRunSummarySchema } from "~/schemas/restic-dto";
|
||||||
|
|
||||||
export const backupEventStatusSchema = type("'success' | 'error' | 'stopped' | 'warning'");
|
export const backupEventStatusSchema = z.enum(["success", "error", "stopped", "warning"]);
|
||||||
export const restoreEventStatusSchema = type("'success' | 'error'");
|
export const restoreEventStatusSchema = z.enum(["success", "error"]);
|
||||||
|
|
||||||
const backupEventBaseSchema = type({
|
const backupEventBaseSchema = z.object({
|
||||||
scheduleId: "string",
|
scheduleId: z.string(),
|
||||||
volumeName: "string",
|
volumeName: z.string(),
|
||||||
repositoryName: "string",
|
repositoryName: z.string(),
|
||||||
});
|
});
|
||||||
|
|
||||||
const organizationScopedSchema = type({
|
const organizationScopedSchema = z.object({
|
||||||
organizationId: "string",
|
organizationId: z.string(),
|
||||||
});
|
});
|
||||||
|
|
||||||
const restoreEventBaseSchema = type({
|
const restoreEventBaseSchema = z.object({
|
||||||
repositoryId: "string",
|
repositoryId: z.string(),
|
||||||
snapshotId: "string",
|
snapshotId: z.string(),
|
||||||
});
|
});
|
||||||
|
|
||||||
const dumpStartedEventSchema = type({
|
const dumpStartedEventSchema = z.object({
|
||||||
repositoryId: "string",
|
repositoryId: z.string(),
|
||||||
snapshotId: "string",
|
snapshotId: z.string(),
|
||||||
path: "string",
|
path: z.string(),
|
||||||
filename: "string",
|
filename: z.string(),
|
||||||
});
|
});
|
||||||
|
|
||||||
const restoreProgressMetricsSchema = type({
|
const restoreProgressMetricsSchema = z.object({
|
||||||
seconds_elapsed: "number",
|
seconds_elapsed: z.number(),
|
||||||
percent_done: "number",
|
percent_done: z.number(),
|
||||||
total_files: "number",
|
total_files: z.number(),
|
||||||
files_restored: "number",
|
files_restored: z.number(),
|
||||||
total_bytes: "number",
|
total_bytes: z.number(),
|
||||||
bytes_restored: "number",
|
bytes_restored: z.number(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const backupStartedEventSchema = backupEventBaseSchema;
|
export const backupStartedEventSchema = backupEventBaseSchema;
|
||||||
|
|
||||||
export const backupProgressEventSchema = backupEventBaseSchema.and(resticBackupProgressMetricsSchema);
|
export const backupProgressEventSchema = backupEventBaseSchema.extend(resticBackupProgressMetricsSchema.shape);
|
||||||
|
|
||||||
export const backupCompletedEventSchema = backupEventBaseSchema.and(
|
export const backupCompletedEventSchema = backupEventBaseSchema.extend({
|
||||||
type({
|
status: backupEventStatusSchema,
|
||||||
status: backupEventStatusSchema,
|
summary: resticBackupRunSummarySchema.optional(),
|
||||||
summary: resticBackupRunSummarySchema.optional(),
|
});
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
export const restoreStartedEventSchema = restoreEventBaseSchema;
|
export const restoreStartedEventSchema = restoreEventBaseSchema;
|
||||||
|
|
||||||
export const restoreProgressEventSchema = restoreEventBaseSchema.and(restoreProgressMetricsSchema);
|
export const restoreProgressEventSchema = restoreEventBaseSchema.extend(restoreProgressMetricsSchema.shape);
|
||||||
|
|
||||||
export const restoreCompletedEventSchema = restoreEventBaseSchema.and(
|
export const restoreCompletedEventSchema = restoreEventBaseSchema.extend({
|
||||||
type({ status: restoreEventStatusSchema, error: "string?" }),
|
status: restoreEventStatusSchema,
|
||||||
);
|
error: z.string().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
export const serverBackupStartedEventSchema = organizationScopedSchema.and(backupStartedEventSchema);
|
export const serverBackupStartedEventSchema = organizationScopedSchema.extend(backupStartedEventSchema.shape);
|
||||||
|
|
||||||
export const serverBackupProgressEventSchema = organizationScopedSchema.and(backupProgressEventSchema);
|
export const serverBackupProgressEventSchema = organizationScopedSchema.extend(backupProgressEventSchema.shape);
|
||||||
|
|
||||||
export const serverBackupCompletedEventSchema = organizationScopedSchema.and(backupCompletedEventSchema);
|
export const serverBackupCompletedEventSchema = organizationScopedSchema.extend(backupCompletedEventSchema.shape);
|
||||||
|
|
||||||
export const serverRestoreStartedEventSchema = organizationScopedSchema.and(restoreStartedEventSchema);
|
export const serverRestoreStartedEventSchema = organizationScopedSchema.extend(restoreStartedEventSchema.shape);
|
||||||
|
|
||||||
export const serverRestoreProgressEventSchema = organizationScopedSchema.and(restoreProgressEventSchema);
|
export const serverRestoreProgressEventSchema = organizationScopedSchema.extend(restoreProgressEventSchema.shape);
|
||||||
|
|
||||||
export const serverRestoreCompletedEventSchema = organizationScopedSchema.and(restoreCompletedEventSchema);
|
export const serverRestoreCompletedEventSchema = organizationScopedSchema.extend(restoreCompletedEventSchema.shape);
|
||||||
|
|
||||||
export const serverDumpStartedEventSchema = organizationScopedSchema.and(dumpStartedEventSchema);
|
export const serverDumpStartedEventSchema = organizationScopedSchema.extend(dumpStartedEventSchema.shape);
|
||||||
|
|
||||||
export type BackupEventStatusDto = typeof backupEventStatusSchema.infer;
|
export type BackupEventStatusDto = z.infer<typeof backupEventStatusSchema>;
|
||||||
export type BackupStartedEventDto = typeof backupStartedEventSchema.infer;
|
export type BackupStartedEventDto = z.infer<typeof backupStartedEventSchema>;
|
||||||
export type BackupProgressEventDto = typeof backupProgressEventSchema.infer;
|
export type BackupProgressEventDto = z.infer<typeof backupProgressEventSchema>;
|
||||||
export type BackupCompletedEventDto = typeof backupCompletedEventSchema.infer;
|
export type BackupCompletedEventDto = z.infer<typeof backupCompletedEventSchema>;
|
||||||
export type RestoreStartedEventDto = typeof restoreStartedEventSchema.infer;
|
export type RestoreStartedEventDto = z.infer<typeof restoreStartedEventSchema>;
|
||||||
export type RestoreProgressEventDto = typeof restoreProgressEventSchema.infer;
|
export type RestoreProgressEventDto = z.infer<typeof restoreProgressEventSchema>;
|
||||||
export type RestoreCompletedEventDto = typeof restoreCompletedEventSchema.infer;
|
export type RestoreCompletedEventDto = z.infer<typeof restoreCompletedEventSchema>;
|
||||||
export type DumpStartedEventDto = typeof dumpStartedEventSchema.infer;
|
export type DumpStartedEventDto = z.infer<typeof dumpStartedEventSchema>;
|
||||||
export type ServerBackupStartedEventDto = typeof serverBackupStartedEventSchema.infer;
|
export type ServerBackupStartedEventDto = z.infer<typeof serverBackupStartedEventSchema>;
|
||||||
export type ServerBackupProgressEventDto = typeof serverBackupProgressEventSchema.infer;
|
export type ServerBackupProgressEventDto = z.infer<typeof serverBackupProgressEventSchema>;
|
||||||
export type ServerBackupCompletedEventDto = typeof serverBackupCompletedEventSchema.infer;
|
export type ServerBackupCompletedEventDto = z.infer<typeof serverBackupCompletedEventSchema>;
|
||||||
export type ServerRestoreStartedEventDto = typeof serverRestoreStartedEventSchema.infer;
|
export type ServerRestoreStartedEventDto = z.infer<typeof serverRestoreStartedEventSchema>;
|
||||||
export type ServerRestoreProgressEventDto = typeof serverRestoreProgressEventSchema.infer;
|
export type ServerRestoreProgressEventDto = z.infer<typeof serverRestoreProgressEventSchema>;
|
||||||
export type ServerRestoreCompletedEventDto = typeof serverRestoreCompletedEventSchema.infer;
|
export type ServerRestoreCompletedEventDto = z.infer<typeof serverRestoreCompletedEventSchema>;
|
||||||
export type ServerDumpStartedEventDto = typeof serverDumpStartedEventSchema.infer;
|
export type ServerDumpStartedEventDto = z.infer<typeof serverDumpStartedEventSchema>;
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { type } from "arktype";
|
import { z } from "zod";
|
||||||
|
|
||||||
export const NOTIFICATION_TYPES = {
|
export const NOTIFICATION_TYPES = {
|
||||||
email: "email",
|
email: "email",
|
||||||
|
|
@ -14,96 +14,98 @@ export const NOTIFICATION_TYPES = {
|
||||||
|
|
||||||
export type NotificationType = keyof typeof NOTIFICATION_TYPES;
|
export type NotificationType = keyof typeof NOTIFICATION_TYPES;
|
||||||
|
|
||||||
export const emailNotificationConfigSchema = type({
|
export const emailNotificationConfigSchema = z.object({
|
||||||
type: "'email'",
|
type: z.literal("email"),
|
||||||
smtpHost: "string",
|
smtpHost: z.string().min(1),
|
||||||
smtpPort: "1 <= number <= 65535",
|
smtpPort: z.number().int().min(1).max(65535),
|
||||||
username: "string?",
|
username: z.string().optional(),
|
||||||
password: "string?",
|
password: z.string().optional(),
|
||||||
from: "string",
|
from: z.string().min(1),
|
||||||
fromName: "string?",
|
fromName: z.string().optional(),
|
||||||
to: "string[]",
|
to: z.array(z.string()),
|
||||||
useTLS: "boolean",
|
useTLS: z.boolean(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const slackNotificationConfigSchema = type({
|
export const slackNotificationConfigSchema = z.object({
|
||||||
type: "'slack'",
|
type: z.literal("slack"),
|
||||||
webhookUrl: "string",
|
webhookUrl: z.string().min(1),
|
||||||
channel: "string?",
|
channel: z.string().optional(),
|
||||||
username: "string?",
|
username: z.string().optional(),
|
||||||
iconEmoji: "string?",
|
iconEmoji: z.string().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const discordNotificationConfigSchema = type({
|
export const discordNotificationConfigSchema = z.object({
|
||||||
type: "'discord'",
|
type: z.literal("discord"),
|
||||||
webhookUrl: "string",
|
webhookUrl: z.string().min(1),
|
||||||
username: "string?",
|
username: z.string().optional(),
|
||||||
avatarUrl: "string?",
|
avatarUrl: z.string().optional(),
|
||||||
threadId: "string?",
|
threadId: z.string().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const gotifyNotificationConfigSchema = type({
|
export const gotifyNotificationConfigSchema = z.object({
|
||||||
type: "'gotify'",
|
type: z.literal("gotify"),
|
||||||
serverUrl: "string",
|
serverUrl: z.string().min(1),
|
||||||
token: "string",
|
token: z.string().min(1),
|
||||||
path: "string?",
|
path: z.string().optional(),
|
||||||
priority: "0 <= number <= 10",
|
priority: z.number().min(0).max(10),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const ntfyNotificationConfigSchema = type({
|
export const ntfyNotificationConfigSchema = z.object({
|
||||||
type: "'ntfy'",
|
type: z.literal("ntfy"),
|
||||||
serverUrl: "string?",
|
serverUrl: z.string().optional(),
|
||||||
topic: "string",
|
topic: z.string().min(1),
|
||||||
priority: "'max' | 'high' | 'default' | 'low' | 'min'",
|
priority: z.enum(["max", "high", "default", "low", "min"]),
|
||||||
username: "string?",
|
username: z.string().optional(),
|
||||||
password: "string?",
|
password: z.string().optional(),
|
||||||
accessToken: "string?",
|
accessToken: z.string().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const pushoverNotificationConfigSchema = type({
|
export const pushoverNotificationConfigSchema = z.object({
|
||||||
type: "'pushover'",
|
type: z.literal("pushover"),
|
||||||
userKey: "string",
|
userKey: z.string().min(1),
|
||||||
apiToken: "string",
|
apiToken: z.string().min(1),
|
||||||
devices: "string?",
|
devices: z.string().optional(),
|
||||||
priority: "-1 | 0 | 1",
|
priority: z.union([z.literal(-1), z.literal(0), z.literal(1)]),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const telegramNotificationConfigSchema = type({
|
export const telegramNotificationConfigSchema = z.object({
|
||||||
type: "'telegram'",
|
type: z.literal("telegram"),
|
||||||
botToken: "string",
|
botToken: z.string().min(1),
|
||||||
chatId: "string",
|
chatId: z.string().min(1),
|
||||||
threadId: "string?",
|
threadId: z.string().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const genericNotificationConfigSchema = type({
|
export const genericNotificationConfigSchema = z.object({
|
||||||
type: "'generic'",
|
type: z.literal("generic"),
|
||||||
url: "string",
|
url: z.string().min(1),
|
||||||
method: "'GET' | 'POST'",
|
method: z.enum(["GET", "POST"]),
|
||||||
contentType: "string?",
|
contentType: z.string().optional(),
|
||||||
headers: "string[]?",
|
headers: z.array(z.string()).optional(),
|
||||||
useJson: "boolean?",
|
useJson: z.boolean().optional(),
|
||||||
titleKey: "string?",
|
titleKey: z.string().optional(),
|
||||||
messageKey: "string?",
|
messageKey: z.string().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const customNotificationConfigSchema = type({
|
export const customNotificationConfigSchema = z.object({
|
||||||
type: "'custom'",
|
type: z.literal("custom"),
|
||||||
shoutrrrUrl: "string",
|
shoutrrrUrl: z.string().min(1),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const notificationConfigSchemaBase = emailNotificationConfigSchema
|
export const notificationConfigSchemaBase = z.discriminatedUnion("type", [
|
||||||
.or(slackNotificationConfigSchema)
|
emailNotificationConfigSchema,
|
||||||
.or(discordNotificationConfigSchema)
|
slackNotificationConfigSchema,
|
||||||
.or(gotifyNotificationConfigSchema)
|
discordNotificationConfigSchema,
|
||||||
.or(ntfyNotificationConfigSchema)
|
gotifyNotificationConfigSchema,
|
||||||
.or(pushoverNotificationConfigSchema)
|
ntfyNotificationConfigSchema,
|
||||||
.or(telegramNotificationConfigSchema)
|
pushoverNotificationConfigSchema,
|
||||||
.or(genericNotificationConfigSchema)
|
telegramNotificationConfigSchema,
|
||||||
.or(customNotificationConfigSchema);
|
genericNotificationConfigSchema,
|
||||||
|
customNotificationConfigSchema,
|
||||||
|
]);
|
||||||
|
|
||||||
export const notificationConfigSchema = notificationConfigSchemaBase.onUndeclaredKey("delete");
|
export const notificationConfigSchema = notificationConfigSchemaBase;
|
||||||
|
|
||||||
export type NotificationConfig = typeof notificationConfigSchema.infer;
|
export type NotificationConfig = z.infer<typeof notificationConfigSchema>;
|
||||||
|
|
||||||
export const NOTIFICATION_EVENTS = {
|
export const NOTIFICATION_EVENTS = {
|
||||||
start: "start",
|
start: "start",
|
||||||
|
|
|
||||||
|
|
@ -1,82 +1,73 @@
|
||||||
import { type } from "arktype";
|
import { z } from "zod";
|
||||||
|
|
||||||
export const resticSummaryBaseSchema = type({
|
export const resticSummaryBaseSchema = z.object({
|
||||||
files_new: "number",
|
files_new: z.number(),
|
||||||
files_changed: "number",
|
files_changed: z.number(),
|
||||||
files_unmodified: "number",
|
files_unmodified: z.number(),
|
||||||
dirs_new: "number",
|
dirs_new: z.number(),
|
||||||
dirs_changed: "number",
|
dirs_changed: z.number(),
|
||||||
dirs_unmodified: "number",
|
dirs_unmodified: z.number(),
|
||||||
data_blobs: "number",
|
data_blobs: z.number(),
|
||||||
tree_blobs: "number",
|
tree_blobs: z.number(),
|
||||||
data_added: "number",
|
data_added: z.number(),
|
||||||
data_added_packed: "number?",
|
data_added_packed: z.number().optional(),
|
||||||
total_files_processed: "number",
|
total_files_processed: z.number(),
|
||||||
total_bytes_processed: "number",
|
total_bytes_processed: z.number(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const resticSnapshotSummarySchema = resticSummaryBaseSchema.and(
|
export const resticSnapshotSummarySchema = resticSummaryBaseSchema.extend({
|
||||||
type({
|
backup_start: z.string(),
|
||||||
backup_start: "string",
|
backup_end: z.string(),
|
||||||
backup_end: "string",
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
export const resticBackupRunSummarySchema = resticSummaryBaseSchema.and(
|
|
||||||
type({
|
|
||||||
total_duration: "number",
|
|
||||||
snapshot_id: "string",
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
export const resticBackupOutputSchema = resticBackupRunSummarySchema.and(
|
|
||||||
type({
|
|
||||||
message_type: "'summary'",
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
export const resticBackupProgressMetricsSchema = type({
|
|
||||||
seconds_elapsed: "number",
|
|
||||||
percent_done: "number",
|
|
||||||
total_files: "number",
|
|
||||||
files_done: "number",
|
|
||||||
total_bytes: "number",
|
|
||||||
bytes_done: "number",
|
|
||||||
current_files: type("string")
|
|
||||||
.array()
|
|
||||||
.default(() => []),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
export const resticBackupProgressSchema = resticBackupProgressMetricsSchema.and(
|
export const resticBackupRunSummarySchema = resticSummaryBaseSchema.extend({
|
||||||
type({
|
total_duration: z.number(),
|
||||||
message_type: "'status'",
|
snapshot_id: z.string(),
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
export const resticRestoreOutputSchema = type({
|
|
||||||
message_type: "'summary'",
|
|
||||||
total_files: "number?",
|
|
||||||
files_restored: "number",
|
|
||||||
files_skipped: "number",
|
|
||||||
total_bytes: "number?",
|
|
||||||
bytes_restored: "number?",
|
|
||||||
bytes_skipped: "number",
|
|
||||||
});
|
});
|
||||||
|
|
||||||
export const resticStatsSchema = type({
|
export const resticBackupOutputSchema = resticBackupRunSummarySchema.extend({
|
||||||
total_size: "number = 0",
|
message_type: z.literal("summary"),
|
||||||
total_uncompressed_size: "number = 0",
|
|
||||||
compression_ratio: "number = 0",
|
|
||||||
compression_progress: "number = 0",
|
|
||||||
compression_space_saving: "number = 0",
|
|
||||||
snapshots_count: "number = 0",
|
|
||||||
});
|
});
|
||||||
|
|
||||||
export type ResticSnapshotSummaryDto = typeof resticSnapshotSummarySchema.infer;
|
export const resticBackupProgressMetricsSchema = z.object({
|
||||||
export type ResticBackupRunSummaryDto = typeof resticBackupRunSummarySchema.infer;
|
seconds_elapsed: z.number(),
|
||||||
export type ResticBackupOutputDto = typeof resticBackupOutputSchema.infer;
|
seconds_remaining: z.number().default(0),
|
||||||
export type ResticBackupProgressMetricsDto = typeof resticBackupProgressMetricsSchema.infer;
|
percent_done: z.number(),
|
||||||
export type ResticBackupProgressDto = typeof resticBackupProgressSchema.infer;
|
total_files: z.number(),
|
||||||
|
files_done: z.number(),
|
||||||
|
total_bytes: z.number(),
|
||||||
|
bytes_done: z.number(),
|
||||||
|
current_files: z.array(z.string()).default([]),
|
||||||
|
});
|
||||||
|
|
||||||
export type ResticRestoreOutputDto = typeof resticRestoreOutputSchema.infer;
|
export const resticBackupProgressSchema = resticBackupProgressMetricsSchema.extend({
|
||||||
export type ResticStatsDto = typeof resticStatsSchema.infer;
|
message_type: z.literal("status"),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const resticRestoreOutputSchema = z.object({
|
||||||
|
message_type: z.literal("summary"),
|
||||||
|
total_files: z.number().optional(),
|
||||||
|
files_restored: z.number(),
|
||||||
|
files_skipped: z.number(),
|
||||||
|
total_bytes: z.number().optional(),
|
||||||
|
bytes_restored: z.number().optional(),
|
||||||
|
bytes_skipped: z.number(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const resticStatsSchema = z.object({
|
||||||
|
total_size: z.number().default(0),
|
||||||
|
total_uncompressed_size: z.number().default(0),
|
||||||
|
compression_ratio: z.number().default(0),
|
||||||
|
compression_progress: z.number().default(0),
|
||||||
|
compression_space_saving: z.number().default(0),
|
||||||
|
snapshots_count: z.number().default(0),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type ResticSnapshotSummaryDto = z.infer<typeof resticSnapshotSummarySchema>;
|
||||||
|
export type ResticBackupRunSummaryDto = z.infer<typeof resticBackupRunSummarySchema>;
|
||||||
|
export type ResticBackupOutputDto = z.infer<typeof resticBackupOutputSchema>;
|
||||||
|
export type ResticBackupProgressMetricsDto = z.infer<typeof resticBackupProgressMetricsSchema>;
|
||||||
|
export type ResticBackupProgressDto = z.infer<typeof resticBackupProgressSchema>;
|
||||||
|
|
||||||
|
export type ResticRestoreOutputDto = z.infer<typeof resticRestoreOutputSchema>;
|
||||||
|
export type ResticStatsDto = z.infer<typeof resticStatsSchema>;
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { type } from "arktype";
|
import { z } from "zod";
|
||||||
|
|
||||||
export const REPOSITORY_BACKENDS = {
|
export const REPOSITORY_BACKENDS = {
|
||||||
local: "local",
|
local: "local",
|
||||||
|
|
@ -21,98 +21,120 @@ export const BANDWIDTH_UNITS = {
|
||||||
|
|
||||||
export type BandwidthUnit = keyof typeof BANDWIDTH_UNITS;
|
export type BandwidthUnit = keyof typeof BANDWIDTH_UNITS;
|
||||||
|
|
||||||
export const bandwidthLimitSchema = type({
|
const bandwidthUnitSchema = z.enum(["Kbps", "Mbps", "Gbps"]);
|
||||||
enabled: "boolean = false",
|
|
||||||
value: "number > 0 = 1",
|
export const bandwidthLimitSchema = z.object({
|
||||||
unit: type.valueOf(BANDWIDTH_UNITS).default("Mbps"),
|
enabled: z.boolean().default(false),
|
||||||
|
value: z.number().positive().default(1),
|
||||||
|
unit: bandwidthUnitSchema.default("Mbps"),
|
||||||
});
|
});
|
||||||
|
|
||||||
export type BandwidthLimit = typeof bandwidthLimitSchema.infer;
|
export type BandwidthLimit = z.infer<typeof bandwidthLimitSchema>;
|
||||||
|
|
||||||
// Common fields for all repository configs
|
const baseRepositoryConfigSchema = z.object({
|
||||||
const baseRepositoryConfigSchema = type({
|
isExistingRepository: z.boolean().optional(),
|
||||||
isExistingRepository: "boolean?",
|
customPassword: z.string().optional(),
|
||||||
customPassword: "string?",
|
cacert: z.string().optional(),
|
||||||
cacert: "string?",
|
insecureTls: z.boolean().optional(),
|
||||||
insecureTls: "boolean?",
|
|
||||||
// Bandwidth controls
|
|
||||||
uploadLimit: bandwidthLimitSchema.optional(),
|
uploadLimit: bandwidthLimitSchema.optional(),
|
||||||
downloadLimit: bandwidthLimitSchema.optional(),
|
downloadLimit: bandwidthLimitSchema.optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const s3RepositoryConfigSchema = type({
|
export const s3RepositoryConfigSchema = z
|
||||||
backend: "'s3'",
|
.object({
|
||||||
endpoint: "string",
|
backend: z.literal("s3"),
|
||||||
bucket: "string",
|
endpoint: z.string().min(1),
|
||||||
accessKeyId: "string",
|
bucket: z.string().min(1),
|
||||||
secretAccessKey: "string",
|
accessKeyId: z.string().min(1),
|
||||||
}).and(baseRepositoryConfigSchema);
|
secretAccessKey: z.string().min(1),
|
||||||
|
})
|
||||||
|
.extend(baseRepositoryConfigSchema.shape);
|
||||||
|
|
||||||
export const r2RepositoryConfigSchema = type({
|
export const r2RepositoryConfigSchema = z
|
||||||
backend: "'r2'",
|
.object({
|
||||||
endpoint: "string",
|
backend: z.literal("r2"),
|
||||||
bucket: "string",
|
endpoint: z.string().min(1),
|
||||||
accessKeyId: "string",
|
bucket: z.string().min(1),
|
||||||
secretAccessKey: "string",
|
accessKeyId: z.string().min(1),
|
||||||
}).and(baseRepositoryConfigSchema);
|
secretAccessKey: z.string().min(1),
|
||||||
|
})
|
||||||
|
.extend(baseRepositoryConfigSchema.shape);
|
||||||
|
|
||||||
export const localRepositoryConfigSchema = type({
|
export const localRepositoryConfigSchema = z
|
||||||
backend: "'local'",
|
.object({
|
||||||
path: "string",
|
backend: z.literal("local"),
|
||||||
}).and(baseRepositoryConfigSchema);
|
path: z.string().min(1),
|
||||||
|
})
|
||||||
|
.extend(baseRepositoryConfigSchema.shape);
|
||||||
|
|
||||||
export const gcsRepositoryConfigSchema = type({
|
export const gcsRepositoryConfigSchema = z
|
||||||
backend: "'gcs'",
|
.object({
|
||||||
bucket: "string",
|
backend: z.literal("gcs"),
|
||||||
projectId: "string",
|
bucket: z.string().min(1),
|
||||||
credentialsJson: "string",
|
projectId: z.string().min(1),
|
||||||
}).and(baseRepositoryConfigSchema);
|
credentialsJson: z.string().min(1),
|
||||||
|
})
|
||||||
|
.extend(baseRepositoryConfigSchema.shape);
|
||||||
|
|
||||||
export const azureRepositoryConfigSchema = type({
|
export const azureRepositoryConfigSchema = z
|
||||||
backend: "'azure'",
|
.object({
|
||||||
container: "string",
|
backend: z.literal("azure"),
|
||||||
accountName: "string",
|
container: z.string().min(1),
|
||||||
accountKey: "string",
|
accountName: z.string().min(1),
|
||||||
endpointSuffix: "string?",
|
accountKey: z.string().min(1),
|
||||||
}).and(baseRepositoryConfigSchema);
|
endpointSuffix: z.string().optional(),
|
||||||
|
})
|
||||||
|
.extend(baseRepositoryConfigSchema.shape);
|
||||||
|
|
||||||
export const rcloneRepositoryConfigSchema = type({
|
export const rcloneRepositoryConfigSchema = z
|
||||||
backend: "'rclone'",
|
.object({
|
||||||
remote: "string",
|
backend: z.literal("rclone"),
|
||||||
path: "string",
|
remote: z.string().min(1),
|
||||||
}).and(baseRepositoryConfigSchema);
|
path: z.string().min(1),
|
||||||
|
})
|
||||||
|
.extend(baseRepositoryConfigSchema.shape);
|
||||||
|
|
||||||
export const restRepositoryConfigSchema = type({
|
export const restRepositoryConfigSchema = z
|
||||||
backend: "'rest'",
|
.object({
|
||||||
url: "string",
|
backend: z.literal("rest"),
|
||||||
username: "string?",
|
url: z.string().min(1),
|
||||||
password: "string?",
|
username: z.string().optional(),
|
||||||
path: "string?",
|
password: z.string().optional(),
|
||||||
}).and(baseRepositoryConfigSchema);
|
path: z.string().optional(),
|
||||||
|
})
|
||||||
|
.extend(baseRepositoryConfigSchema.shape);
|
||||||
|
|
||||||
export const sftpRepositoryConfigSchema = type({
|
export const sftpRepositoryConfigSchema = z
|
||||||
backend: "'sftp'",
|
.object({
|
||||||
host: "string",
|
backend: z.literal("sftp"),
|
||||||
port: type("string.integer").or(type("number")).to("1 <= number <= 65535").default(22),
|
host: z.string().min(1),
|
||||||
user: "string",
|
port: z
|
||||||
path: "string",
|
.union([z.string(), z.number()])
|
||||||
privateKey: "string",
|
.transform((value) => (typeof value === "string" ? Number.parseInt(value, 10) : value))
|
||||||
skipHostKeyCheck: "boolean = true",
|
.pipe(z.number().int().min(1).max(65535))
|
||||||
knownHosts: "string?",
|
.default(22),
|
||||||
}).and(baseRepositoryConfigSchema);
|
user: z.string().min(1),
|
||||||
|
path: z.string().min(1),
|
||||||
|
privateKey: z.string().min(1),
|
||||||
|
skipHostKeyCheck: z.boolean().default(true),
|
||||||
|
knownHosts: z.string().optional(),
|
||||||
|
})
|
||||||
|
.extend(baseRepositoryConfigSchema.shape);
|
||||||
|
|
||||||
export const repositoryConfigSchemaBase = s3RepositoryConfigSchema
|
export const repositoryConfigSchemaBase = z.discriminatedUnion("backend", [
|
||||||
.or(r2RepositoryConfigSchema)
|
s3RepositoryConfigSchema,
|
||||||
.or(localRepositoryConfigSchema)
|
r2RepositoryConfigSchema,
|
||||||
.or(gcsRepositoryConfigSchema)
|
localRepositoryConfigSchema,
|
||||||
.or(azureRepositoryConfigSchema)
|
gcsRepositoryConfigSchema,
|
||||||
.or(rcloneRepositoryConfigSchema)
|
azureRepositoryConfigSchema,
|
||||||
.or(restRepositoryConfigSchema)
|
rcloneRepositoryConfigSchema,
|
||||||
.or(sftpRepositoryConfigSchema);
|
restRepositoryConfigSchema,
|
||||||
|
sftpRepositoryConfigSchema,
|
||||||
|
]);
|
||||||
|
|
||||||
export const repositoryConfigSchema = repositoryConfigSchemaBase.onUndeclaredKey("delete");
|
export const repositoryConfigSchema = repositoryConfigSchemaBase;
|
||||||
|
|
||||||
export type RepositoryConfig = typeof repositoryConfigSchema.infer;
|
export type RepositoryConfig = z.infer<typeof repositoryConfigSchema>;
|
||||||
|
|
||||||
export const COMPRESSION_MODES = {
|
export const COMPRESSION_MODES = {
|
||||||
off: "off",
|
off: "off",
|
||||||
|
|
@ -132,22 +154,22 @@ export const REPOSITORY_STATUS = {
|
||||||
|
|
||||||
export type RepositoryStatus = keyof typeof REPOSITORY_STATUS;
|
export type RepositoryStatus = keyof typeof REPOSITORY_STATUS;
|
||||||
|
|
||||||
export const doctorStepSchema = type({
|
export const doctorStepSchema = z.object({
|
||||||
step: "string",
|
step: z.string(),
|
||||||
success: "boolean",
|
success: z.boolean(),
|
||||||
output: "string | null",
|
output: z.string().nullable(),
|
||||||
error: "string | null",
|
error: z.string().nullable(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export type DoctorStep = typeof doctorStepSchema.infer;
|
export type DoctorStep = z.infer<typeof doctorStepSchema>;
|
||||||
|
|
||||||
export const doctorResultSchema = type({
|
export const doctorResultSchema = z.object({
|
||||||
success: "boolean",
|
success: z.boolean(),
|
||||||
steps: doctorStepSchema.array(),
|
steps: doctorStepSchema.array(),
|
||||||
completedAt: "number",
|
completedAt: z.number(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export type DoctorResult = typeof doctorResultSchema.infer;
|
export type DoctorResult = z.infer<typeof doctorResultSchema>;
|
||||||
|
|
||||||
export const OVERWRITE_MODES = {
|
export const OVERWRITE_MODES = {
|
||||||
always: "always",
|
always: "always",
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { type } from "arktype";
|
import { z } from "zod";
|
||||||
|
|
||||||
export const BACKEND_TYPES = {
|
export const BACKEND_TYPES = {
|
||||||
nfs: "nfs",
|
nfs: "nfs",
|
||||||
|
|
@ -11,75 +11,93 @@ export const BACKEND_TYPES = {
|
||||||
|
|
||||||
export type BackendType = keyof typeof BACKEND_TYPES;
|
export type BackendType = keyof typeof BACKEND_TYPES;
|
||||||
|
|
||||||
export const nfsConfigSchema = type({
|
export const nfsConfigSchema = z.object({
|
||||||
backend: "'nfs'",
|
backend: z.literal("nfs"),
|
||||||
server: "string",
|
server: z.string().min(1),
|
||||||
exportPath: "string",
|
exportPath: z.string().min(1),
|
||||||
port: type("string.integer").or(type("number")).to("1 <= number <= 65536").default(2049),
|
port: z
|
||||||
version: "'3' | '4' | '4.1'",
|
.union([z.string(), z.number()])
|
||||||
readOnly: "boolean?",
|
.transform((value) => (typeof value === "string" ? Number.parseInt(value, 10) : value))
|
||||||
|
.pipe(z.number().int().min(1).max(65535))
|
||||||
|
.default(2049),
|
||||||
|
version: z.enum(["3", "4", "4.1"]),
|
||||||
|
readOnly: z.boolean().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const smbConfigSchema = type({
|
export const smbConfigSchema = z.object({
|
||||||
backend: "'smb'",
|
backend: z.literal("smb"),
|
||||||
server: "string",
|
server: z.string().min(1),
|
||||||
share: "string",
|
share: z.string().min(1),
|
||||||
username: "string?",
|
username: z.string().optional(),
|
||||||
password: "string?",
|
password: z.string().optional(),
|
||||||
guest: "boolean?",
|
guest: z.boolean().optional(),
|
||||||
vers: type("'1.0' | '2.0' | '2.1' | '3.0' | 'auto'").default("auto"),
|
vers: z.enum(["1.0", "2.0", "2.1", "3.0", "auto"]).default("auto"),
|
||||||
domain: "string?",
|
domain: z.string().optional(),
|
||||||
port: type("string.integer").or(type("number")).to("1 <= number <= 65535").default(445),
|
port: z
|
||||||
readOnly: "boolean?",
|
.union([z.string(), z.number()])
|
||||||
|
.transform((value) => (typeof value === "string" ? Number.parseInt(value, 10) : value))
|
||||||
|
.pipe(z.number().int().min(1).max(65535))
|
||||||
|
.default(445),
|
||||||
|
readOnly: z.boolean().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const directoryConfigSchema = type({
|
export const directoryConfigSchema = z.object({
|
||||||
backend: "'directory'",
|
backend: z.literal("directory"),
|
||||||
path: "string",
|
path: z.string().min(1),
|
||||||
readOnly: "false?",
|
readOnly: z.literal(false).optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const webdavConfigSchema = type({
|
export const webdavConfigSchema = z.object({
|
||||||
backend: "'webdav'",
|
backend: z.literal("webdav"),
|
||||||
server: "string",
|
server: z.string().min(1),
|
||||||
path: "string",
|
path: z.string().min(1),
|
||||||
username: "string?",
|
username: z.string().optional(),
|
||||||
password: "string?",
|
password: z.string().optional(),
|
||||||
port: type("string.integer").or(type("number")).to("1 <= number <= 65536").default(80),
|
port: z
|
||||||
readOnly: "boolean?",
|
.union([z.string(), z.number()])
|
||||||
ssl: "boolean?",
|
.transform((value) => (typeof value === "string" ? Number.parseInt(value, 10) : value))
|
||||||
|
.pipe(z.number().int().min(1).max(65535))
|
||||||
|
.default(80),
|
||||||
|
readOnly: z.boolean().optional(),
|
||||||
|
ssl: z.boolean().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const rcloneConfigSchema = type({
|
export const rcloneConfigSchema = z.object({
|
||||||
backend: "'rclone'",
|
backend: z.literal("rclone"),
|
||||||
remote: "string",
|
remote: z.string().min(1),
|
||||||
path: "string",
|
path: z.string().min(1),
|
||||||
readOnly: "boolean?",
|
readOnly: z.boolean().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const sftpConfigSchema = type({
|
export const sftpConfigSchema = z.object({
|
||||||
backend: "'sftp'",
|
backend: z.literal("sftp"),
|
||||||
host: "string",
|
host: z.string().min(1),
|
||||||
port: type("string.integer").or(type("number")).to("1 <= number <= 65535").default(22),
|
port: z
|
||||||
username: "string",
|
.union([z.string(), z.number()])
|
||||||
password: "string?",
|
.transform((value) => (typeof value === "string" ? Number.parseInt(value, 10) : value))
|
||||||
privateKey: "string?",
|
.pipe(z.number().int().min(1).max(65535))
|
||||||
path: "string",
|
.default(22),
|
||||||
readOnly: "boolean?",
|
username: z.string().min(1),
|
||||||
skipHostKeyCheck: "boolean = true",
|
password: z.string().optional(),
|
||||||
knownHosts: "string?",
|
privateKey: z.string().optional(),
|
||||||
|
path: z.string().min(1),
|
||||||
|
readOnly: z.boolean().optional(),
|
||||||
|
skipHostKeyCheck: z.boolean().default(true),
|
||||||
|
knownHosts: z.string().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const volumeConfigSchemaBase = nfsConfigSchema
|
export const volumeConfigSchemaBase = z.discriminatedUnion("backend", [
|
||||||
.or(smbConfigSchema)
|
nfsConfigSchema,
|
||||||
.or(webdavConfigSchema)
|
smbConfigSchema,
|
||||||
.or(directoryConfigSchema)
|
webdavConfigSchema,
|
||||||
.or(rcloneConfigSchema)
|
directoryConfigSchema,
|
||||||
.or(sftpConfigSchema);
|
rcloneConfigSchema,
|
||||||
|
sftpConfigSchema,
|
||||||
|
]);
|
||||||
|
|
||||||
export const volumeConfigSchema = volumeConfigSchemaBase.onUndeclaredKey("delete");
|
export const volumeConfigSchema = volumeConfigSchemaBase;
|
||||||
|
|
||||||
export type BackendConfig = typeof volumeConfigSchema.infer;
|
export type BackendConfig = z.infer<typeof volumeConfigSchema>;
|
||||||
|
|
||||||
export const BACKEND_STATUS = {
|
export const BACKEND_STATUS = {
|
||||||
mounted: "mounted",
|
mounted: "mounted",
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ import { secureHeaders } from "hono/secure-headers";
|
||||||
import { rateLimiter } from "hono-rate-limiter";
|
import { rateLimiter } from "hono-rate-limiter";
|
||||||
import { openAPIRouteHandler } from "hono-openapi";
|
import { openAPIRouteHandler } from "hono-openapi";
|
||||||
import { authController } from "./modules/auth/auth.controller";
|
import { authController } from "./modules/auth/auth.controller";
|
||||||
|
import { ssoController } from "./modules/sso/sso.controller";
|
||||||
import { requireAuth } from "./modules/auth/auth.middleware";
|
import { requireAuth } from "./modules/auth/auth.middleware";
|
||||||
import { repositoriesController } from "./modules/repositories/repositories.controller";
|
import { repositoriesController } from "./modules/repositories/repositories.controller";
|
||||||
import { systemController } from "./modules/system/system.controller";
|
import { systemController } from "./modules/system/system.controller";
|
||||||
|
|
@ -74,6 +75,7 @@ export const createApp = () => {
|
||||||
app
|
app
|
||||||
.get("/api/healthcheck", (c) => c.json({ status: "ok" }))
|
.get("/api/healthcheck", (c) => c.json({ status: "ok" }))
|
||||||
.route("/api/v1/auth", authController)
|
.route("/api/v1/auth", authController)
|
||||||
|
.route("/api/v1/auth", ssoController)
|
||||||
.route("/api/v1/volumes", volumeController)
|
.route("/api/v1/volumes", volumeController)
|
||||||
.route("/api/v1/repositories", repositoriesController)
|
.route("/api/v1/repositories", repositoriesController)
|
||||||
.route("/api/v1/backups", backupScheduleController)
|
.route("/api/v1/backups", backupScheduleController)
|
||||||
|
|
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue