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
|
||||
|
||||
- name: Log in to Docker Hub
|
||||
uses: docker/login-action@v3
|
||||
uses: docker/login-action@v4
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
uses: docker/setup-buildx-action@v4
|
||||
with:
|
||||
driver: cloud
|
||||
endpoint: "meienberger/runtipi-builder"
|
||||
install: true
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@v3
|
||||
uses: docker/login-action@v4
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.repository_owner }}
|
||||
|
|
@ -38,14 +38,14 @@ jobs:
|
|||
|
||||
- name: Docker meta
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
uses: docker/metadata-action@v6
|
||||
with:
|
||||
images: ghcr.io/${{ github.repository_owner }}/zerobyte
|
||||
tags: |
|
||||
type=raw,value=nightly
|
||||
|
||||
- name: Push images to GitHub Container Registry
|
||||
uses: docker/build-push-action@v6
|
||||
uses: docker/build-push-action@v7
|
||||
with:
|
||||
context: .
|
||||
target: production
|
||||
|
|
|
|||
12
.github/workflows/release.yml
vendored
12
.github/workflows/release.yml
vendored
|
|
@ -49,27 +49,27 @@ jobs:
|
|||
ref: ${{ github.ref }}
|
||||
|
||||
- name: Log in to Docker Hub
|
||||
uses: docker/login-action@v3
|
||||
uses: docker/login-action@v4
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
uses: docker/setup-buildx-action@v4
|
||||
with:
|
||||
driver: cloud
|
||||
endpoint: "meienberger/runtipi-builder"
|
||||
install: true
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@v3
|
||||
uses: docker/login-action@v4
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.repository_owner }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Build docker image
|
||||
uses: docker/build-push-action@v6
|
||||
uses: docker/build-push-action@v7
|
||||
with:
|
||||
context: .
|
||||
target: production
|
||||
|
|
@ -98,7 +98,7 @@ jobs:
|
|||
|
||||
- name: Docker meta
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
uses: docker/metadata-action@v6
|
||||
with:
|
||||
images: ghcr.io/${{ github.repository_owner }}/zerobyte
|
||||
tags: |
|
||||
|
|
@ -110,7 +110,7 @@ jobs:
|
|||
latest=${{ needs.determine-release-type.outputs.release_type == 'release' }}
|
||||
|
||||
- name: Push images to GitHub Container Registry
|
||||
uses: docker/build-push-action@v6
|
||||
uses: docker/build-push-action@v7
|
||||
with:
|
||||
context: .
|
||||
target: production
|
||||
|
|
|
|||
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -41,3 +41,4 @@ openapi-ts-error-*.log
|
|||
tmp/
|
||||
qa-output
|
||||
|
||||
.worktrees/
|
||||
|
|
|
|||
|
|
@ -50,6 +50,7 @@
|
|||
"no-unused-vars": [
|
||||
"warn",
|
||||
{
|
||||
"varsIgnorePattern": "^_",
|
||||
"caughtErrorsIgnorePattern": "^_",
|
||||
"argsIgnorePattern": "^_"
|
||||
}
|
||||
|
|
@ -114,6 +115,7 @@
|
|||
"unicorn/prefer-set-size": "warn",
|
||||
"unicorn/prefer-string-starts-ends-with": "warn",
|
||||
"import/no-cycle": "error",
|
||||
"import/no-unused-modules": ["error", { "unusedExports": true }],
|
||||
"eslint/no-console": ["warn", { "allow": ["warn", "error", "info"] }]
|
||||
},
|
||||
"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 && \
|
||||
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", "--"]
|
||||
|
||||
|
|
|
|||
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
|
||||
services:
|
||||
zerobyte:
|
||||
image: ghcr.io/nicotsx/zerobyte:v0.29
|
||||
image: ghcr.io/nicotsx/zerobyte:v0.30
|
||||
container_name: zerobyte
|
||||
restart: unless-stopped
|
||||
cap_add:
|
||||
|
|
@ -50,7 +50,7 @@ services:
|
|||
devices:
|
||||
- /dev/fuse:/dev/fuse
|
||||
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
|
||||
- APP_SECRET=94bad46...c66e25d5c2b # Generate your own secret with `openssl rand -hex 32`
|
||||
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) |
|
||||
| `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` |
|
||||
| `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) |
|
||||
| `LOG_LEVEL` | Logging verbosity. Options: `debug`, `info`, `warn`, `error`. | `info` |
|
||||
| `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
|
||||
services:
|
||||
zerobyte:
|
||||
image: ghcr.io/nicotsx/zerobyte:v0.29
|
||||
image: ghcr.io/nicotsx/zerobyte:v0.30
|
||||
container_name: zerobyte
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "4096:4096"
|
||||
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)
|
||||
- APP_SECRET=94bad46...c66e25d5c2b # Generate your own secret with `openssl rand -hex 32`
|
||||
volumes:
|
||||
|
|
@ -157,7 +157,7 @@ If you want to track a local directory on the same server where Zerobyte is runn
|
|||
```diff
|
||||
services:
|
||||
zerobyte:
|
||||
image: ghcr.io/nicotsx/zerobyte:v0.29
|
||||
image: ghcr.io/nicotsx/zerobyte:v0.30
|
||||
container_name: zerobyte
|
||||
restart: unless-stopped
|
||||
cap_add:
|
||||
|
|
@ -167,7 +167,7 @@ services:
|
|||
devices:
|
||||
- /dev/fuse:/dev/fuse
|
||||
environment:
|
||||
- TZ=Europe/Paris
|
||||
- TZ=Europe/Zurich
|
||||
- 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`
|
||||
volumes:
|
||||
|
|
@ -232,7 +232,7 @@ Zerobyte can use [rclone](https://rclone.org/) to support 40+ cloud storage prov
|
|||
```diff
|
||||
services:
|
||||
zerobyte:
|
||||
image: ghcr.io/nicotsx/zerobyte:v0.29
|
||||
image: ghcr.io/nicotsx/zerobyte:v0.30
|
||||
container_name: zerobyte
|
||||
restart: unless-stopped
|
||||
cap_add:
|
||||
|
|
@ -242,7 +242,7 @@ Zerobyte can use [rclone](https://rclone.org/) to support 40+ cloud storage prov
|
|||
devices:
|
||||
- /dev/fuse:/dev/fuse
|
||||
environment:
|
||||
- TZ=Europe/Paris
|
||||
- TZ=Europe/Zurich
|
||||
- BASE_URL=http://localhost:4096 # URL you will use to access Zerobyte
|
||||
volumes:
|
||||
- /etc/localtime:/etc/localtime:ro
|
||||
|
|
|
|||
90
app/app.css
90
app/app.css
|
|
@ -37,7 +37,6 @@ body {
|
|||
}
|
||||
|
||||
body {
|
||||
@apply bg-[#131313];
|
||||
min-height: 100dvh;
|
||||
}
|
||||
|
||||
|
|
@ -67,6 +66,8 @@ body {
|
|||
--color-accent: var(--accent);
|
||||
--color-accent-foreground: var(--accent-foreground);
|
||||
--color-destructive: var(--destructive);
|
||||
--color-destructive-foreground: var(--destructive-foreground);
|
||||
--color-success: var(--success);
|
||||
--color-border: var(--border);
|
||||
--color-input: var(--input);
|
||||
--color-ring: var(--ring);
|
||||
|
|
@ -88,38 +89,40 @@ body {
|
|||
|
||||
:root {
|
||||
--radius: 0.625rem;
|
||||
--background: oklch(1 0 0);
|
||||
--foreground: oklch(0.145 0 0);
|
||||
--card: oklch(1 0 0);
|
||||
--card-foreground: oklch(0.145 0 0);
|
||||
--card-header: oklch(0.922 0 0);
|
||||
--popover: oklch(1 0 0);
|
||||
--popover-foreground: oklch(0.145 0 0);
|
||||
--primary: oklch(0.205 0 0);
|
||||
--primary-foreground: oklch(0.985 0 0);
|
||||
--secondary: oklch(0.97 0 0);
|
||||
--secondary-foreground: oklch(0.205 0 0);
|
||||
--muted: oklch(0.97 0 0);
|
||||
--muted-foreground: oklch(0.556 0 0);
|
||||
--accent: oklch(0.97 0 0);
|
||||
--accent-foreground: oklch(0.205 0 0);
|
||||
--background: oklch(0.98 0.002 250);
|
||||
--foreground: oklch(0.20 0.01 260);
|
||||
--card: #ffffff;
|
||||
--card-foreground: oklch(0.20 0.01 260);
|
||||
--card-header: oklch(0.99 0.001 260);
|
||||
--popover: #ffffff;
|
||||
--popover-foreground: oklch(0.20 0.01 260);
|
||||
--primary: oklch(0.20 0.01 260);
|
||||
--primary-foreground: #ffffff;
|
||||
--secondary: oklch(0.96 0.005 260);
|
||||
--secondary-foreground: oklch(0.20 0.01 260);
|
||||
--muted: oklch(0.95 0.005 260);
|
||||
--muted-foreground: oklch(0.45 0.01 260);
|
||||
--accent: oklch(0.96 0.005 260);
|
||||
--accent-foreground: oklch(0.20 0.01 260);
|
||||
--destructive: oklch(0.577 0.245 27.325);
|
||||
--border: oklch(0.922 0 0);
|
||||
--input: oklch(0.922 0 0);
|
||||
--ring: oklch(0.708 0 0);
|
||||
--chart-1: oklch(0.646 0.222 41.116);
|
||||
--chart-2: oklch(0.6 0.118 184.704);
|
||||
--chart-3: oklch(0.398 0.07 227.392);
|
||||
--chart-4: oklch(0.828 0.189 84.429);
|
||||
--chart-5: oklch(0.769 0.188 70.08);
|
||||
--sidebar: oklch(0.985 0 0);
|
||||
--sidebar-foreground: oklch(0.145 0 0);
|
||||
--sidebar-primary: oklch(0.205 0 0);
|
||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||
--sidebar-accent: oklch(0.97 0 0);
|
||||
--sidebar-accent-foreground: oklch(0.205 0 0);
|
||||
--sidebar-border: oklch(0.922 0 0);
|
||||
--sidebar-ring: oklch(0.708 0 0);
|
||||
--destructive-foreground: #fff;
|
||||
--success: oklch(0.57 0.16 148);
|
||||
--border: oklch(0.90 0.01 260);
|
||||
--input: oklch(0.90 0.01 260);
|
||||
--ring: oklch(0.7 0.01 260);
|
||||
--chart-1: oklch(0.55 0.22 260);
|
||||
--chart-2: oklch(0.6 0.15 200);
|
||||
--chart-3: oklch(0.45 0.12 280);
|
||||
--chart-4: oklch(0.7 0.15 190);
|
||||
--chart-5: oklch(0.55 0.18 300);
|
||||
--sidebar: #ffffff;
|
||||
--sidebar-foreground: oklch(0.35 0.01 260);
|
||||
--sidebar-primary: oklch(0.20 0.01 260);
|
||||
--sidebar-primary-foreground: #ffffff;
|
||||
--sidebar-accent: oklch(0.97 0.005 260);
|
||||
--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;
|
||||
}
|
||||
|
||||
|
|
@ -144,6 +147,8 @@ body {
|
|||
--accent: oklch(0.269 0 0);
|
||||
--accent-foreground: oklch(0.985 0 0);
|
||||
--destructive: #ff543a;
|
||||
--destructive-foreground: #fff;
|
||||
--success: oklch(0.696 0.17 162.48);
|
||||
--border: oklch(1 0 0 / 10%);
|
||||
--input: oklch(1 0 0 / 15%);
|
||||
--ring: oklch(0.556 0 0);
|
||||
|
|
@ -174,11 +179,11 @@ body {
|
|||
|
||||
@layer base {
|
||||
:root {
|
||||
--chart-1: oklch(0.646 0.222 41.116);
|
||||
--chart-2: oklch(0.6 0.118 184.704);
|
||||
--chart-3: oklch(0.398 0.07 227.392);
|
||||
--chart-4: oklch(0.828 0.189 84.429);
|
||||
--chart-5: oklch(0.769 0.188 70.08);
|
||||
--chart-1: oklch(0.55 0.22 260);
|
||||
--chart-2: oklch(0.6 0.15 200);
|
||||
--chart-3: oklch(0.45 0.12 280);
|
||||
--chart-4: oklch(0.7 0.15 190);
|
||||
--chart-5: oklch(0.55 0.18 300);
|
||||
}
|
||||
|
||||
.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) */
|
||||
[data-secret-input] input[type="password"]::-ms-reveal,
|
||||
[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 { 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 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 { 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, 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> = [
|
||||
Pick<TOptions, 'baseUrl' | 'body' | 'headers' | 'path' | 'query'> & {
|
||||
|
|
@ -58,93 +58,6 @@ export const getStatusOptions = (options?: Options<GetStatusData>) => queryOptio
|
|||
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);
|
||||
|
||||
/**
|
||||
|
|
@ -250,6 +163,93 @@ export const removeOrgMemberMutation = (options?: Partial<Options<RemoveOrgMembe
|
|||
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);
|
||||
|
||||
/**
|
||||
|
|
@ -457,7 +457,7 @@ export const listFilesInfiniteQueryKey = (options: Options<ListFilesData>): Quer
|
|||
/**
|
||||
* 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
|
||||
{
|
||||
queryFn: async ({ pageParam, queryKey, signal }) => {
|
||||
|
|
@ -620,6 +620,23 @@ export const getRepositoryStatsOptions = (options: Options<GetRepositoryStatsDat
|
|||
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
|
||||
*/
|
||||
|
|
@ -730,7 +747,7 @@ export const listSnapshotFilesInfiniteQueryKey = (options: Options<ListSnapshotF
|
|||
/**
|
||||
* 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
|
||||
{
|
||||
queryFn: async ({ pageParam, queryKey, signal }) => {
|
||||
|
|
@ -855,23 +872,6 @@ export const tagSnapshotsMutation = (options?: Partial<Options<TagSnapshotsData>
|
|||
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);
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -38,7 +38,7 @@ export const createClient = (config: Config = {}): Client => {
|
|||
...options,
|
||||
fetch: options.fetch ?? _config.fetch ?? globalThis.fetch,
|
||||
headers: mergeHeaders(_config.headers, options.headers),
|
||||
serializedBody: undefined,
|
||||
serializedBody: undefined as string | undefined,
|
||||
};
|
||||
|
||||
if (opts.security) {
|
||||
|
|
@ -53,7 +53,7 @@ export const createClient = (config: Config = {}): Client => {
|
|||
}
|
||||
|
||||
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
|
||||
|
|
@ -259,8 +259,10 @@ export const createClient = (config: Config = {}): Client => {
|
|||
});
|
||||
};
|
||||
|
||||
const _buildUrl: Client['buildUrl'] = (options) => buildUrl({ ..._config, ...options });
|
||||
|
||||
return {
|
||||
buildUrl,
|
||||
buildUrl: _buildUrl,
|
||||
connect: makeMethodFn('CONNECT'),
|
||||
delete: makeMethodFn('DELETE'),
|
||||
get: makeMethodFn('GET'),
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ import type { ArrayStyle, ObjectStyle, SerializerOptions } from './pathSerialize
|
|||
|
||||
export type QuerySerializer = (query: Record<string, unknown>) => string;
|
||||
|
||||
export type BodySerializer = (body: any) => any;
|
||||
export type BodySerializer = (body: unknown) => unknown;
|
||||
|
||||
type QuerySerializerOptionsObject = {
|
||||
allowReserved?: boolean;
|
||||
|
|
@ -40,12 +40,10 @@ const serializeUrlSearchParamsPair = (data: URLSearchParams, key: string, value:
|
|||
};
|
||||
|
||||
export const formDataBodySerializer = {
|
||||
bodySerializer: <T extends Record<string, any> | Array<Record<string, any>>>(
|
||||
body: T,
|
||||
): FormData => {
|
||||
bodySerializer: (body: unknown): 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) {
|
||||
return;
|
||||
}
|
||||
|
|
@ -61,15 +59,15 @@ export const formDataBodySerializer = {
|
|||
};
|
||||
|
||||
export const jsonBodySerializer = {
|
||||
bodySerializer: <T>(body: T): string =>
|
||||
bodySerializer: (body: unknown): string =>
|
||||
JSON.stringify(body, (_key, value) => (typeof value === 'bigint' ? value.toString() : value)),
|
||||
};
|
||||
|
||||
export const urlSearchParamsBodySerializer = {
|
||||
bodySerializer: <T extends Record<string, any> | Array<Record<string, any>>>(body: T): string => {
|
||||
bodySerializer: (body: unknown): string => {
|
||||
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) {
|
||||
return;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -97,7 +97,7 @@ interface Params {
|
|||
|
||||
const stripEmptySlots = (params: Params) => {
|
||||
for (const [slot, value] of Object.entries(params)) {
|
||||
if (value && typeof value === 'object' && !Object.keys(value).length) {
|
||||
if (value && typeof value === 'object' && !Array.isArray(value) && !Object.keys(value).length) {
|
||||
delete params[slot as Slot];
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -52,6 +52,7 @@ export {
|
|||
listVolumes,
|
||||
mountVolume,
|
||||
type Options,
|
||||
refreshRepositoryStats,
|
||||
refreshSnapshots,
|
||||
removeOrgMember,
|
||||
reorderBackupSchedules,
|
||||
|
|
@ -230,6 +231,9 @@ export type {
|
|||
MountVolumeData,
|
||||
MountVolumeResponse,
|
||||
MountVolumeResponses,
|
||||
RefreshRepositoryStatsData,
|
||||
RefreshRepositoryStatsResponse,
|
||||
RefreshRepositoryStatsResponses,
|
||||
RefreshSnapshotsData,
|
||||
RefreshSnapshotsResponse,
|
||||
RefreshSnapshotsResponses,
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
|
||||
import type { Client, Options as Options2, TDataShape } from './client';
|
||||
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> & {
|
||||
/**
|
||||
|
|
@ -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 });
|
||||
|
||||
/**
|
||||
* 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
|
||||
*/
|
||||
|
|
@ -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 });
|
||||
|
||||
/**
|
||||
* 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
|
||||
*/
|
||||
|
|
@ -101,24 +101,24 @@ export const listVolumes = <ThrowOnError extends boolean = false>(options?: Opti
|
|||
/**
|
||||
* 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',
|
||||
...options,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...options?.headers
|
||||
...options.headers
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* 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',
|
||||
...options,
|
||||
headers: {
|
||||
'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
|
||||
*/
|
||||
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',
|
||||
...options,
|
||||
headers: {
|
||||
'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 });
|
||||
|
||||
/**
|
||||
* 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
|
||||
*/
|
||||
|
|
@ -319,12 +324,12 @@ export const listBackupSchedules = <ThrowOnError extends boolean = false>(option
|
|||
/**
|
||||
* 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',
|
||||
...options,
|
||||
headers: {
|
||||
'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
|
||||
*/
|
||||
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',
|
||||
...options,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...options?.headers
|
||||
...options.headers
|
||||
}
|
||||
});
|
||||
|
||||
|
|
@ -434,12 +439,12 @@ export const listNotificationDestinations = <ThrowOnError extends boolean = fals
|
|||
/**
|
||||
* 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',
|
||||
...options,
|
||||
headers: {
|
||||
'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.
|
||||
*/
|
||||
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',
|
||||
...options,
|
||||
headers: {
|
||||
'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.
|
||||
*/
|
||||
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',
|
||||
...options,
|
||||
headers: {
|
||||
'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 (
|
||||
<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">
|
||||
<img src="/images/zerobyte.png" alt="Zerobyte Logo" className={cn("h-8 w-8 shrink-0 object-contain -ml-2")} />
|
||||
<span
|
||||
|
|
@ -115,7 +115,7 @@ export function AppSidebar({ isInstanceAdmin }: Props) {
|
|||
/>
|
||||
<span
|
||||
className={cn({
|
||||
"text-white font-medium": isActive,
|
||||
"text-foreground font-medium": isActive,
|
||||
"text-muted-foreground": !isActive,
|
||||
})}
|
||||
>
|
||||
|
|
@ -171,7 +171,7 @@ export function AppSidebar({ isInstanceAdmin }: Props) {
|
|||
/>
|
||||
<span
|
||||
className={cn({
|
||||
"text-white font-medium": isActive,
|
||||
"text-foreground font-medium": isActive,
|
||||
"text-muted-foreground": !isActive,
|
||||
})}
|
||||
>
|
||||
|
|
@ -194,7 +194,7 @@ export function AppSidebar({ isInstanceAdmin }: Props) {
|
|||
</>
|
||||
)}
|
||||
</SidebarContent>
|
||||
<SidebarFooter className="p-4 border-r border-border/50">
|
||||
<SidebarFooter className="p-4 border-r border-border/80 dark:border-border/50">
|
||||
<OrganizationSwitcher />
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<HoverCard openDelay={200}>
|
||||
|
|
|
|||
|
|
@ -59,7 +59,7 @@ export function CronInput({ value, onChange, error }: CronInputProps) {
|
|||
{value && (
|
||||
<div>
|
||||
{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" />
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -74,11 +74,7 @@ export const SnapshotTreeBrowser = ({
|
|||
return await queryClient.ensureQueryData(
|
||||
listSnapshotFilesOptions({
|
||||
path: { shortId: repositoryId, snapshotId },
|
||||
query: {
|
||||
path,
|
||||
offset: offset.toString(),
|
||||
limit: pageSize.toString(),
|
||||
},
|
||||
query: { path, offset: offset, limit: pageSize },
|
||||
}),
|
||||
);
|
||||
},
|
||||
|
|
@ -86,11 +82,7 @@ export const SnapshotTreeBrowser = ({
|
|||
void queryClient.prefetchQuery(
|
||||
listSnapshotFilesOptions({
|
||||
path: { shortId: repositoryId, snapshotId },
|
||||
query: {
|
||||
path,
|
||||
offset: "0",
|
||||
limit: pageSize.toString(),
|
||||
},
|
||||
query: { path, offset: 0, limit: pageSize },
|
||||
}),
|
||||
);
|
||||
},
|
||||
|
|
|
|||
|
|
@ -24,7 +24,7 @@ export const VolumeFileBrowser = ({ volumeId, enabled = true, ...uiProps }: Volu
|
|||
return await queryClient.ensureQueryData(
|
||||
listFilesOptions({
|
||||
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(
|
||||
"pointer-events-none absolute inset-0 w-full h-full",
|
||||
"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)]",
|
||||
"[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 { AppBreadcrumb } from "./app-breadcrumb";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip";
|
||||
import { ThemeToggle } from "./theme-toggle";
|
||||
|
||||
type Props = {
|
||||
loaderData: AppContext;
|
||||
|
|
@ -35,23 +36,24 @@ export function Layout({ loaderData }: Props) {
|
|||
<SidebarProvider defaultOpen={loaderData.sidebarOpen}>
|
||||
<AppSidebar isInstanceAdmin={loaderData.user?.role === "admin"} />
|
||||
<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 gap-4 min-w-0">
|
||||
<SidebarTrigger />
|
||||
<AppBreadcrumb />
|
||||
</div>
|
||||
{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-foreground">{loaderData.user.name}</span>
|
||||
</span>
|
||||
<ThemeToggle />
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
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}
|
||||
>
|
||||
<LogOut className="w-4 h-4" />
|
||||
|
|
@ -64,7 +66,7 @@ export function Layout({ loaderData }: Props) {
|
|||
<Button
|
||||
variant="ghost"
|
||||
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
|
||||
href="https://github.com/nicotsx/zerobyte/issues/new"
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ export const OnOff = ({ isOn, toggle, enabledLabel, disabledLabel, disabled }: P
|
|||
className={cn(
|
||||
"flex items-center gap-2 rounded-full border px-3 py-1.5 text-xs font-semibold uppercase tracking-wide transition-colors",
|
||||
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",
|
||||
)}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -59,7 +59,7 @@ export function OrganizationSwitcher() {
|
|||
size="lg"
|
||||
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 ? (
|
||||
<img
|
||||
src={activeOrganization.logo}
|
||||
|
|
|
|||
|
|
@ -133,6 +133,7 @@ export function RestoreForm({ repository, snapshotId, returnPath, basePath }: Re
|
|||
body: {
|
||||
snapshotId,
|
||||
include: includePaths.length > 0 ? includePaths : undefined,
|
||||
selectedItemKind: includePaths.length === 1 ? (selectedPathKind ?? undefined) : undefined,
|
||||
excludeXattr: excludeXattrArray && excludeXattrArray.length > 0 ? excludeXattrArray : undefined,
|
||||
targetPath,
|
||||
overwrite: overwriteMode,
|
||||
|
|
@ -145,6 +146,7 @@ export function RestoreForm({ repository, snapshotId, returnPath, basePath }: Re
|
|||
restoreLocation,
|
||||
customTargetPath,
|
||||
selectedPaths,
|
||||
selectedPathKind,
|
||||
overwriteMode,
|
||||
restoreSnapshot,
|
||||
]);
|
||||
|
|
|
|||
|
|
@ -12,8 +12,8 @@ interface StatusDotProps {
|
|||
export const StatusDot = ({ variant, label, animated }: StatusDotProps) => {
|
||||
const statusMapping = {
|
||||
success: {
|
||||
color: "bg-emerald-500 shadow-[0_0_8px_rgba(16,185,129,0.5)]",
|
||||
colorLight: "bg-emerald-400",
|
||||
color: "bg-success shadow-[0_0_8px_rgba(16,185,129,0.5)]",
|
||||
colorLight: "bg-success/60",
|
||||
animated: animated ?? true,
|
||||
},
|
||||
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: {
|
||||
variant: {
|
||||
default:
|
||||
"bg-transparent text-white hover:bg-[#3A3A3A]/80 border hover:border-white/40 dark:text-white dark:hover:bg-[#3A3A3A]/80",
|
||||
default: "bg-transparent text-foreground hover:bg-muted border hover:border-border",
|
||||
primary: "bg-strong-accent text-white hover:bg-strong-accent/90 focus-visible:ring-strong-accent/50",
|
||||
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",
|
||||
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",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
},
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ function Card({ className, children, interactive, ...props }: React.ComponentPro
|
|||
<div
|
||||
data-slot="card"
|
||||
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,
|
||||
)}
|
||||
{...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-4 w-0.5 bg-white/80" />
|
||||
<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-4 w-0.5 bg-white/80" />
|
||||
<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-4 w-0.5 bg-white/80" />
|
||||
<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-4 w-0.5 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-foreground" />
|
||||
<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-foreground" />
|
||||
<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-foreground" />
|
||||
<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-foreground" />
|
||||
</span>
|
||||
{children}
|
||||
</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 { CheckIcon } from "lucide-react";
|
||||
|
||||
import { useWebHaptics } from "web-haptics/react";
|
||||
import { cn } from "~/client/lib/utils";
|
||||
|
||||
function Checkbox({ className, ...props }: React.ComponentProps<typeof CheckboxPrimitive.Root>) {
|
||||
const trigger = useWebHaptics().trigger;
|
||||
|
||||
return (
|
||||
<CheckboxPrimitive.Root
|
||||
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",
|
||||
className,
|
||||
)}
|
||||
onCheckedChange={(checked) => {
|
||||
void trigger([{ duration: 8 }], { intensity: 0.3 });
|
||||
props.onCheckedChange?.(checked);
|
||||
}}
|
||||
{...props}
|
||||
>
|
||||
<CheckboxPrimitive.Indicator
|
||||
|
|
|
|||
|
|
@ -8,8 +8,8 @@ interface CodeBlockProps {
|
|||
|
||||
export const CodeBlock: React.FC<CodeBlockProps> = ({ code, filename }) => {
|
||||
return (
|
||||
<div className="overflow-hidden rounded-sm bg-card-header ring-1 ring-white/10">
|
||||
<div className="flex items-center justify-between border-b border-white/10 px-4 py-2 text-xs">
|
||||
<div className="overflow-hidden rounded-sm bg-card-header ring-1 ring-border">
|
||||
<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">
|
||||
<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" />
|
||||
|
|
@ -18,7 +18,7 @@ export const CodeBlock: React.FC<CodeBlockProps> = ({ code, filename }) => {
|
|||
</div>
|
||||
</div>
|
||||
<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>
|
||||
</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";
|
||||
|
||||
const Toaster = ({ ...props }: ToasterProps) => {
|
||||
|
|
|
|||
|
|
@ -1,9 +1,12 @@
|
|||
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";
|
||||
|
||||
function Switch({ className, ...props }: React.ComponentProps<typeof SwitchPrimitive.Root>) {
|
||||
const trigger = useWebHaptics().trigger;
|
||||
|
||||
return (
|
||||
<SwitchPrimitive.Root
|
||||
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",
|
||||
className,
|
||||
)}
|
||||
onCheckedChange={(checked) => {
|
||||
void trigger([{ duration: 8 }], { intensity: 0.3 });
|
||||
props.onCheckedChange?.(checked);
|
||||
}}
|
||||
{...props}
|
||||
>
|
||||
<SwitchPrimitive.Thumb
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import * as TabsPrimitive from "@radix-ui/react-tabs";
|
||||
import type * as React from "react";
|
||||
|
||||
import { useWebHaptics } from "web-haptics/react";
|
||||
import { cn } from "~/client/lib/utils";
|
||||
|
||||
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 (
|
||||
<TabsPrimitive.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)
|
||||
"px-5",
|
||||
// Transparent orange background for active state
|
||||
"data-[state=active]:bg-[#FF453A]/10",
|
||||
"data-[state=active]:bg-strong-accent/10",
|
||||
// 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
|
||||
"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,
|
||||
)}
|
||||
onClick={handleClick}
|
||||
{...props}
|
||||
>
|
||||
<span className="relative z-10">{props.children}</span>
|
||||
{/* 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 */}
|
||||
<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 */}
|
||||
<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 */}
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
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;
|
||||
|
||||
export function useIsMobile() {
|
||||
const [isMobile, setIsMobile] = React.useState<boolean | undefined>(undefined);
|
||||
|
||||
React.useEffect(() => {
|
||||
const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`);
|
||||
const onChange = () => {
|
||||
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT);
|
||||
};
|
||||
mql.addEventListener("change", onChange);
|
||||
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT);
|
||||
return () => mql.removeEventListener("change", onChange);
|
||||
}, []);
|
||||
|
||||
return !!isMobile;
|
||||
function subscribe(callback: () => void) {
|
||||
const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`);
|
||||
mql.addEventListener("change", callback);
|
||||
return () => mql.removeEventListener("change", callback);
|
||||
}
|
||||
|
||||
function getSnapshot() {
|
||||
return window.innerWidth < MOBILE_BREAKPOINT;
|
||||
}
|
||||
|
||||
function getServerSnapshot() {
|
||||
return false;
|
||||
}
|
||||
|
||||
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 { serverEventNames, type ServerEventPayloadMap } from "~/schemas/server-events";
|
||||
|
||||
|
|
@ -16,6 +16,13 @@ type EventHandlerMap = {
|
|||
[K in ServerEventType]?: EventHandlerSet<K>;
|
||||
};
|
||||
|
||||
type SharedServerEventsState = {
|
||||
eventSource: EventSource | null;
|
||||
handlers: EventHandlerMap;
|
||||
queryClient: QueryClient | null;
|
||||
subscribers: number;
|
||||
};
|
||||
|
||||
const invalidatingEvents = new Set<ServerEventType>([
|
||||
"backup:completed",
|
||||
"volume:updated",
|
||||
|
|
@ -31,103 +38,151 @@ export type RestoreProgressEvent = ServerEventsPayloadMap["restore:progress"];
|
|||
export type RestoreCompletedEvent = ServerEventsPayloadMap["restore:completed"];
|
||||
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] =>
|
||||
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
|
||||
* Automatically handles cache invalidation for backup and volume events
|
||||
*/
|
||||
export function useServerEvents() {
|
||||
const queryClient = useQueryClient();
|
||||
const eventSourceRef = useRef<EventSource | null>(null);
|
||||
const handlersRef = useRef<EventHandlerMap>({});
|
||||
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);
|
||||
});
|
||||
}, []);
|
||||
const addEventListener = useCallback(addSharedEventListener, []);
|
||||
const hasMountedRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
const eventSource = new EventSource("/api/v1/events");
|
||||
eventSourceRef.current = 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);
|
||||
|
||||
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);
|
||||
});
|
||||
connectEventSource(queryClient);
|
||||
if (!hasMountedRef.current) {
|
||||
sharedState.subscribers += 1;
|
||||
hasMountedRef.current = true;
|
||||
}
|
||||
|
||||
eventSource.onerror = (error) => {
|
||||
console.error("[SSE] Connection error:", error);
|
||||
};
|
||||
|
||||
return () => {
|
||||
console.info("[SSE] Disconnecting from server events");
|
||||
eventSource.close();
|
||||
eventSourceRef.current = null;
|
||||
if (!hasMountedRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
hasMountedRef.current = false;
|
||||
sharedState.subscribers = Math.max(0, sharedState.subscribers - 1);
|
||||
if (sharedState.subscribers === 0) {
|
||||
disconnectEventSource();
|
||||
}
|
||||
};
|
||||
}, [emit, 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;
|
||||
},
|
||||
[],
|
||||
);
|
||||
}, [queryClient]);
|
||||
|
||||
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(
|
||||
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();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
import { arktypeResolver } from "@hookform/resolvers/arktype";
|
||||
import { type } from "arktype";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
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 { Label } from "~/client/components/ui/label";
|
||||
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 { useNavigate } from "@tanstack/react-router";
|
||||
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 { SsoLoginSection } from "~/client/modules/sso/components/sso-login-section";
|
||||
import { z } from "zod";
|
||||
|
||||
const loginSchema = type({
|
||||
username: "2<=string<=50",
|
||||
password: "string>=1",
|
||||
const loginSchema = z.object({
|
||||
username: z
|
||||
.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 = {
|
||||
error?: string;
|
||||
|
|
@ -38,14 +40,10 @@ export function LoginPage({ error }: LoginPageProps = {}) {
|
|||
const [isVerifying2FA, setIsVerifying2FA] = useState(false);
|
||||
const [trustDevice, setTrustDevice] = useState(false);
|
||||
const errorCode = decodeLoginError(error);
|
||||
const errorDescription = getLoginErrorDescription(errorCode);
|
||||
|
||||
const { data: ssoProviders } = useSuspenseQuery({
|
||||
...getPublicSsoProvidersOptions(),
|
||||
});
|
||||
const errorDescription = errorCode ? getLoginErrorDescription(errorCode) : null;
|
||||
|
||||
const form = useForm<LoginFormValues>({
|
||||
resolver: arktypeResolver(loginSchema),
|
||||
resolver: zodResolver(loginSchema),
|
||||
defaultValues: {
|
||||
username: "",
|
||||
password: "",
|
||||
|
|
@ -129,27 +127,6 @@ export function LoginPage({ error }: LoginPageProps = {}) {
|
|||
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) {
|
||||
return (
|
||||
<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>
|
||||
|
||||
{ssoProviders.providers.length > 0 && (
|
||||
<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>
|
||||
)}
|
||||
<SsoLoginSection />
|
||||
|
||||
<ResetPasswordDialog open={showResetDialog} onOpenChange={setShowResetDialog} />
|
||||
</AuthLayout>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
import { arktypeResolver } from "@hookform/resolvers/arktype";
|
||||
import { type } from "arktype";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
|
|
@ -19,24 +18,28 @@ import { authClient } from "~/client/lib/auth-client";
|
|||
import { useState } from "react";
|
||||
import { useNavigate } from "@tanstack/react-router";
|
||||
import { normalizeUsername } from "~/lib/username";
|
||||
import { z } from "zod";
|
||||
|
||||
export const clientMiddleware = [authMiddleware];
|
||||
|
||||
const onboardingSchema = type({
|
||||
username: type("2<=string<=30").pipe(normalizeUsername),
|
||||
email: type("string.email").pipe((str) => str.trim().toLowerCase()),
|
||||
password: "string>=8",
|
||||
confirmPassword: "string>=1",
|
||||
const onboardingSchema = z.object({
|
||||
username: z.string().min(2).max(30).transform(normalizeUsername),
|
||||
email: z
|
||||
.string()
|
||||
.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() {
|
||||
const navigate = useNavigate();
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
const form = useForm<OnboardingFormValues>({
|
||||
resolver: arktypeResolver(onboardingSchema),
|
||||
resolver: zodResolver(onboardingSchema),
|
||||
defaultValues: {
|
||||
username: "",
|
||||
password: "",
|
||||
|
|
|
|||
|
|
@ -32,21 +32,21 @@ export const BackupCard = ({ schedule }: { schedule: BackupSchedule }) => {
|
|||
<CardContent className="flex-1 space-y-4">
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center text-sm gap-2">
|
||||
<span className="text-zinc-500 shrink-0">Schedule</span>
|
||||
<div className="flex-1 border-b border-dashed border-white/10" />
|
||||
<code className="text-xs text-zinc-100 font-mono bg-muted px-2 py-1 rounded shrink-0">
|
||||
<span className="text-muted-foreground shrink-0">Schedule</span>
|
||||
<div className="flex-1 border-b border-dashed border-border/80 dark:border-border/50" />
|
||||
<code className="text-xs text-foreground font-mono bg-muted px-2 py-1 rounded shrink-0">
|
||||
{schedule.cronExpression}
|
||||
</code>
|
||||
</div>
|
||||
<div className="flex items-center text-sm gap-2">
|
||||
<span className="text-zinc-500 shrink-0">Last backup</span>
|
||||
<div className="flex-1 border-b border-dashed border-white/10" />
|
||||
<span className="text-zinc-100 font-mono text-sm shrink-0">{formatTimeAgo(schedule.lastBackupAt)}</span>
|
||||
<span className="text-muted-foreground shrink-0">Last backup</span>
|
||||
<div className="flex-1 border-b border-dashed border-border/80 dark:border-border/50" />
|
||||
<span className="text-foreground font-mono text-sm shrink-0">{formatTimeAgo(schedule.lastBackupAt)}</span>
|
||||
</div>
|
||||
<div className="flex items-center text-sm gap-2">
|
||||
<span className="text-zinc-500 shrink-0">Next backup</span>
|
||||
<div className="flex-1 border-b border-dashed border-white/10" />
|
||||
<span className="text-zinc-100 font-mono text-sm shrink-0">
|
||||
<span className="text-muted-foreground shrink-0">Next backup</span>
|
||||
<div className="flex-1 border-b border-dashed border-border/80 dark:border-border/50" />
|
||||
<span className="text-foreground font-mono text-sm shrink-0">
|
||||
{formatShortDateTime(schedule.nextBackupAt)}
|
||||
</span>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -46,6 +46,7 @@ export const BackupProgressCard = ({ scheduleShortId, initialProgress }: Props)
|
|||
const currentFile = progress?.current_files?.[0] || "";
|
||||
const fileName = currentFile.split("/").pop() || currentFile;
|
||||
const speed = progress ? formatBytes(progress.bytes_done / progress.seconds_elapsed) : null;
|
||||
const eta = progress?.seconds_remaining ? formatDuration(progress.seconds_remaining) : null;
|
||||
|
||||
return (
|
||||
<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...") : "—"}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs uppercase text-muted-foreground">ETA</p>
|
||||
<p className="font-medium">{progress ? (eta ?? "Calculating...") : "—"}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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 { useForm } from "react-hook-form";
|
||||
import { useScrollToFormError } from "~/client/hooks/use-scroll-to-form-error";
|
||||
import { Form } from "~/client/components/ui/form";
|
||||
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 { AdvancedSection } from "./advanced-section";
|
||||
import { BasicInfoSection } from "./basic-info-section";
|
||||
import { ExcludeSection } from "./exclude-section";
|
||||
import { FrequencySection } from "./frequency-section";
|
||||
import { PathsSection } from "./paths-section";
|
||||
import { RetentionSection } from "./retention-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";
|
||||
|
||||
export type { BackupScheduleFormValues };
|
||||
|
|
@ -27,7 +29,7 @@ type Props = {
|
|||
|
||||
export const CreateScheduleForm = ({ initialValues, formId, onSubmit, volume }: Props) => {
|
||||
const form = useForm<InternalFormValues>({
|
||||
resolver: arktypeResolver(cleanSchema as unknown as typeof import("./types").internalFormSchema),
|
||||
resolver: zodResolver(internalFormSchema),
|
||||
defaultValues: backupScheduleToFormValues(initialValues),
|
||||
});
|
||||
|
||||
|
|
@ -39,6 +41,7 @@ export const CreateScheduleForm = ({ initialValues, formId, onSubmit, volume }:
|
|||
excludePatternsText,
|
||||
excludeIfPresentText,
|
||||
includePatternsText,
|
||||
customResticParamsText,
|
||||
includePatterns: fileBrowserPatterns,
|
||||
cronExpression,
|
||||
...rest
|
||||
|
|
@ -65,12 +68,20 @@ export const CreateScheduleForm = ({ initialValues, formId, onSubmit, volume }:
|
|||
: [];
|
||||
const includePatterns = [...(fileBrowserPatterns || []), ...textPatterns];
|
||||
|
||||
const customResticParams = customResticParamsText
|
||||
? customResticParamsText
|
||||
.split("\n")
|
||||
.map((p) => p.trim())
|
||||
.filter(Boolean)
|
||||
: [];
|
||||
|
||||
onSubmit({
|
||||
...rest,
|
||||
cronExpression,
|
||||
includePatterns: includePatterns.length > 0 ? includePatterns : [],
|
||||
excludePatterns,
|
||||
excludeIfPresent,
|
||||
customResticParams,
|
||||
});
|
||||
},
|
||||
[onSubmit],
|
||||
|
|
@ -164,6 +175,17 @@ export const CreateScheduleForm = ({ initialValues, formId, onSubmit, volume }:
|
|||
<RetentionSection form={form} />
|
||||
</CardContent>
|
||||
</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 className="xl:sticky xl:top-6 xl:self-start">
|
||||
<SummarySection volume={volume} frequency={frequency} formValues={formValues} />
|
||||
|
|
|
|||
|
|
@ -22,7 +22,7 @@ export const RetentionSection = ({ form }: RetentionSectionProps) => {
|
|||
type="number"
|
||||
min={0}
|
||||
placeholder="Optional"
|
||||
onChange={(v) => field.onChange(Number(v.target.value))}
|
||||
onChange={(v) => field.onChange(v.target.value === "" ? undefined : Number(v.target.value))}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>Keep the N most recent snapshots.</FormDescription>
|
||||
|
|
@ -43,7 +43,7 @@ export const RetentionSection = ({ form }: RetentionSectionProps) => {
|
|||
min={0}
|
||||
placeholder="Optional"
|
||||
{...field}
|
||||
onChange={(v) => field.onChange(Number(v.target.value))}
|
||||
onChange={(v) => field.onChange(v.target.value === "" ? undefined : Number(v.target.value))}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>Keep the last N hourly snapshots.</FormDescription>
|
||||
|
|
@ -64,7 +64,7 @@ export const RetentionSection = ({ form }: RetentionSectionProps) => {
|
|||
min={0}
|
||||
placeholder="e.g., 7"
|
||||
{...field}
|
||||
onChange={(v) => field.onChange(Number(v.target.value))}
|
||||
onChange={(v) => field.onChange(v.target.value === "" ? undefined : Number(v.target.value))}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>Keep the last N daily snapshots.</FormDescription>
|
||||
|
|
@ -85,7 +85,7 @@ export const RetentionSection = ({ form }: RetentionSectionProps) => {
|
|||
min={0}
|
||||
placeholder="e.g., 4"
|
||||
{...field}
|
||||
onChange={(v) => field.onChange(Number(v.target.value))}
|
||||
onChange={(v) => field.onChange(v.target.value === "" ? undefined : Number(v.target.value))}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>Keep the last N weekly snapshots.</FormDescription>
|
||||
|
|
@ -106,7 +106,7 @@ export const RetentionSection = ({ form }: RetentionSectionProps) => {
|
|||
min={0}
|
||||
placeholder="e.g., 6"
|
||||
{...field}
|
||||
onChange={(v) => field.onChange(Number(v.target.value))}
|
||||
onChange={(v) => field.onChange(v.target.value === "" ? undefined : Number(v.target.value))}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>Keep the last N monthly snapshots.</FormDescription>
|
||||
|
|
@ -127,7 +127,7 @@ export const RetentionSection = ({ form }: RetentionSectionProps) => {
|
|||
min={0}
|
||||
placeholder="Optional"
|
||||
{...field}
|
||||
onChange={(v) => field.onChange(Number(v.target.value))}
|
||||
onChange={(v) => field.onChange(v.target.value === "" ? undefined : Number(v.target.value))}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>Keep the last N yearly snapshots.</FormDescription>
|
||||
|
|
|
|||
|
|
@ -1,29 +1,27 @@
|
|||
import { type } from "arktype";
|
||||
import { deepClean } from "~/utils/object";
|
||||
import { z } from "zod";
|
||||
|
||||
export const internalFormSchema = type({
|
||||
name: "1 <= string <= 128",
|
||||
repositoryId: "string",
|
||||
excludePatternsText: "string?",
|
||||
excludeIfPresentText: "string?",
|
||||
includePatternsText: "string?",
|
||||
includePatterns: "string[]?",
|
||||
frequency: "string",
|
||||
dailyTime: "string?",
|
||||
weeklyDay: "string?",
|
||||
monthlyDays: "string[]?",
|
||||
cronExpression: "string?",
|
||||
keepLast: "number?",
|
||||
keepHourly: "number?",
|
||||
keepDaily: "number?",
|
||||
keepWeekly: "number?",
|
||||
keepMonthly: "number?",
|
||||
keepYearly: "number?",
|
||||
oneFileSystem: "boolean?",
|
||||
export const internalFormSchema = z.object({
|
||||
name: z.string().min(1).max(128),
|
||||
repositoryId: z.string(),
|
||||
excludePatternsText: z.string().optional(),
|
||||
excludeIfPresentText: z.string().optional(),
|
||||
includePatternsText: z.string().optional(),
|
||||
includePatterns: z.array(z.string()).optional(),
|
||||
frequency: z.string(),
|
||||
dailyTime: z.string().optional(),
|
||||
weeklyDay: z.string().optional(),
|
||||
monthlyDays: z.array(z.string()).optional(),
|
||||
cronExpression: z.string().optional(),
|
||||
keepLast: z.number().optional(),
|
||||
keepHourly: z.number().optional(),
|
||||
keepDaily: z.number().optional(),
|
||||
keepWeekly: z.number().optional(),
|
||||
keepMonthly: z.number().optional(),
|
||||
keepYearly: z.number().optional(),
|
||||
oneFileSystem: z.boolean().optional(),
|
||||
customResticParamsText: z.string().optional(),
|
||||
});
|
||||
|
||||
export const cleanSchema = type.pipe((d) => internalFormSchema(deepClean(d)));
|
||||
|
||||
export const weeklyDays = [
|
||||
{ label: "Monday", value: "1" },
|
||||
{ label: "Tuesday", value: "2" },
|
||||
|
|
@ -34,12 +32,13 @@ export const weeklyDays = [
|
|||
{ label: "Sunday", value: "0" },
|
||||
];
|
||||
|
||||
export type InternalFormValues = typeof internalFormSchema.infer;
|
||||
export type InternalFormValues = z.infer<typeof internalFormSchema>;
|
||||
|
||||
export type BackupScheduleFormValues = Omit<
|
||||
InternalFormValues,
|
||||
"excludePatternsText" | "excludeIfPresentText" | "includePatternsText"
|
||||
"excludePatternsText" | "excludeIfPresentText" | "includePatternsText" | "customResticParamsText"
|
||||
> & {
|
||||
excludePatterns?: string[];
|
||||
excludeIfPresent?: string[];
|
||||
customResticParams?: string[];
|
||||
};
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ export const backupScheduleToFormValues = (schedule?: BackupSchedule): InternalF
|
|||
excludePatternsText: schedule.excludePatterns?.join("\n") || undefined,
|
||||
excludeIfPresentText: schedule.excludeIfPresent?.join("\n") || undefined,
|
||||
oneFileSystem: schedule.oneFileSystem ?? false,
|
||||
customResticParamsText: schedule.customResticParams?.join("\n") ?? "",
|
||||
...cronValues,
|
||||
...schedule.retentionPolicy,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import { useRef } from "react";
|
||||
import type { ListSnapshotsResponse } from "~/client/api-client";
|
||||
import { ByteSize } from "~/client/components/bytes-size";
|
||||
import { Card, CardContent } from "~/client/components/ui/card";
|
||||
|
|
@ -15,6 +16,7 @@ interface Props {
|
|||
|
||||
export const SnapshotTimeline = (props: Props) => {
|
||||
const { snapshots, snapshotId, loading, onSnapshotSelect, error } = props;
|
||||
const selectedRef = useRef<HTMLButtonElement>(null);
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
|
|
@ -51,13 +53,14 @@ export const SnapshotTimeline = (props: Props) => {
|
|||
<div className="w-full bg-card">
|
||||
<div className="relative flex items-center">
|
||||
<div className="flex-1 overflow-hidden">
|
||||
<div className="flex gap-4 overflow-x-auto pb-2 *:first: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) => {
|
||||
const date = new Date(snapshot.time);
|
||||
const isSelected = snapshotId === snapshot.short_id;
|
||||
|
||||
return (
|
||||
<button
|
||||
ref={isSelected ? selectedRef : undefined}
|
||||
type="button"
|
||||
key={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 [isEditMode, setIsEditMode] = useState(false);
|
||||
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 [snapshotToDelete, setSnapshotToDelete] = useState<string | null>(null);
|
||||
|
||||
|
|
@ -183,6 +185,7 @@ export function ScheduleDetailsPage(props: Props) {
|
|||
excludePatterns: formValues.excludePatterns,
|
||||
excludeIfPresent: formValues.excludeIfPresent,
|
||||
oneFileSystem: formValues.oneFileSystem,
|
||||
customResticParams: formValues.customResticParams,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
|
@ -191,6 +194,7 @@ export function ScheduleDetailsPage(props: Props) {
|
|||
updateSchedule.mutate({
|
||||
path: { shortId: schedule.shortId },
|
||||
body: {
|
||||
name: schedule.name,
|
||||
repositoryId: schedule.repositoryId,
|
||||
enabled,
|
||||
cronExpression: schedule.cronExpression,
|
||||
|
|
@ -199,6 +203,7 @@ export function ScheduleDetailsPage(props: Props) {
|
|||
excludePatterns: schedule.excludePatterns || [],
|
||||
excludeIfPresent: schedule.excludeIfPresent || [],
|
||||
oneFileSystem: schedule.oneFileSystem,
|
||||
customResticParams: schedule.customResticParams || [],
|
||||
},
|
||||
});
|
||||
};
|
||||
|
|
|
|||
|
|
@ -110,9 +110,9 @@ export function BackupsPage() {
|
|||
);
|
||||
})}
|
||||
<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="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" />
|
||||
</div>
|
||||
<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,
|
||||
excludeIfPresent: formValues.excludeIfPresent,
|
||||
oneFileSystem: formValues.oneFileSystem,
|
||||
customResticParams: formValues.customResticParams,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,8 +1,7 @@
|
|||
import { arktypeResolver } from "@hookform/resolvers/arktype";
|
||||
import { type } from "arktype";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { z } from "zod";
|
||||
import { cn } from "~/client/lib/utils";
|
||||
import { deepClean } from "~/utils/object";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
|
|
@ -14,7 +13,17 @@ import {
|
|||
} from "~/client/components/ui/form";
|
||||
import { Input } from "~/client/components/ui/input";
|
||||
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 {
|
||||
CustomForm,
|
||||
|
|
@ -28,12 +37,21 @@ import {
|
|||
TelegramForm,
|
||||
} from "./notification-forms";
|
||||
|
||||
export const formSchema = type({
|
||||
name: "2<=string<=32",
|
||||
}).and(notificationConfigSchemaBase);
|
||||
const cleanSchema = type.pipe((d) => formSchema(deepClean(d)));
|
||||
const baseFields = { name: z.string().min(2).max(32) };
|
||||
|
||||
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 = {
|
||||
onSubmit: (values: NotificationFormValues) => void;
|
||||
|
|
@ -106,7 +124,7 @@ const defaultValuesForType = {
|
|||
|
||||
export const CreateNotificationForm = ({ onSubmit, mode = "create", initialValues, formId, className }: Props) => {
|
||||
const form = useForm<NotificationFormValues>({
|
||||
resolver: arktypeResolver(cleanSchema as unknown as typeof formSchema),
|
||||
resolver: zodResolver(formSchema, undefined, { raw: true }),
|
||||
defaultValues: initialValues || {
|
||||
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">
|
||||
<span
|
||||
className={cn("inline-flex items-center gap-2 px-2 py-1 rounded-md text-xs bg-gray-500/10 text-gray-500", {
|
||||
"bg-green-500/10 text-emerald-500": data.enabled,
|
||||
"bg-success/10 text-success": data.enabled,
|
||||
"bg-red-500/10 text-red-500": !data.enabled,
|
||||
})}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -1,9 +1,16 @@
|
|||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Archive } from "lucide-react";
|
||||
import { getRepositoryStatsOptions } from "~/client/api-client/@tanstack/react-query.gen";
|
||||
import { ByteSize } from "~/client/components/bytes-size";
|
||||
import { Card, CardContent, CardTitle } from "~/client/components/ui/card";
|
||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
import { Archive, RefreshCw } from "lucide-react";
|
||||
import {
|
||||
getRepositoryStatsOptions,
|
||||
refreshRepositoryStatsMutation,
|
||||
} from "~/client/api-client/@tanstack/react-query.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 = {
|
||||
repositoryShortId: string;
|
||||
|
|
@ -19,6 +26,18 @@ const toSafeNumber = (value: number | undefined) => {
|
|||
};
|
||||
|
||||
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 {
|
||||
data: stats,
|
||||
isPending,
|
||||
|
|
@ -43,89 +62,84 @@ export function CompressionStatsChart({ repositoryShortId, initialStats }: Props
|
|||
|
||||
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 (
|
||||
<Card className="flex flex-col px-6 py-6">
|
||||
<div className="pb-4">
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Archive className="h-5 w-5 text-muted-foreground" />
|
||||
Compression Statistics
|
||||
<div className="flex items-start justify-between gap-3 pb-4">
|
||||
<CardTitle className="flex items-center gap-2 w-full">
|
||||
<div className="flex items-center gap-2 flex-1">
|
||||
<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>
|
||||
</div>
|
||||
<div>
|
||||
<CardContent className="grid grid-cols-2 lg:grid-cols-3 gap-y-6 gap-x-4 px-0">
|
||||
<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>
|
||||
<p className={cn("text-sm text-muted-foreground", { hidden: !isPending })}>Loading compression statistics...</p>
|
||||
<div className={cn("space-y-2", { hidden: !error || isPending })}>
|
||||
<p className="text-sm font-medium text-destructive">Failed to load compression statistics</p>
|
||||
<p className="text-sm text-muted-foreground wrap-break-word">{error?.message}</p>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,10 +1,9 @@
|
|||
import { arktypeResolver } from "@hookform/resolvers/arktype";
|
||||
import { type } from "arktype";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { Save } from "lucide-react";
|
||||
import { z } from "zod";
|
||||
import { cn } from "~/client/lib/utils";
|
||||
import { deepClean } from "~/utils/object";
|
||||
import { Button } from "../../../components/ui/button";
|
||||
import {
|
||||
Form,
|
||||
|
|
@ -21,7 +20,17 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from ".
|
|||
import { Tooltip, TooltipContent, TooltipTrigger } from "../../../components/ui/tooltip";
|
||||
import { useSystemInfo } from "~/client/hooks/use-system-info";
|
||||
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 {
|
||||
LocalRepositoryForm,
|
||||
|
|
@ -38,13 +47,23 @@ import { useServerFn } from "@tanstack/react-start";
|
|||
import { getServerConstants } from "~/server/lib/functions/server-constants";
|
||||
import { useSuspenseQuery } from "@tanstack/react-query";
|
||||
|
||||
export const formSchema = type({
|
||||
name: "2<=string<=32",
|
||||
compressionMode: type.valueOf(COMPRESSION_MODES).optional(),
|
||||
}).and(repositoryConfigSchemaBase);
|
||||
const cleanSchema = type.pipe((d) => formSchema(deepClean(d)));
|
||||
const formBaseFields = {
|
||||
name: z.string().min(2).max(32),
|
||||
compressionMode: z.enum(COMPRESSION_MODES).optional(),
|
||||
};
|
||||
|
||||
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 = {
|
||||
onSubmit: (values: RepositoryFormValues) => void;
|
||||
|
|
@ -81,7 +100,7 @@ export const CreateRepositoryForm = ({
|
|||
});
|
||||
|
||||
const form = useForm<RepositoryFormValues>({
|
||||
resolver: arktypeResolver(cleanSchema as unknown as typeof formSchema),
|
||||
resolver: zodResolver(formSchema, undefined, { raw: true }),
|
||||
defaultValues: initialValues,
|
||||
resetOptions: {
|
||||
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">
|
||||
<span className="text-sm font-medium capitalize">{step.step.replaceAll("_", " ")}</span>
|
||||
{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" />
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import { toast } from "sonner";
|
|||
import { createRepositoryMutation } from "~/client/api-client/@tanstack/react-query.gen";
|
||||
import {
|
||||
CreateRepositoryForm,
|
||||
formSchema,
|
||||
type RepositoryFormValues,
|
||||
} from "~/client/modules/repositories/components/create-repository-form";
|
||||
import { Button } from "~/client/components/ui/button";
|
||||
|
|
@ -26,11 +27,13 @@ export function CreateRepositoryPage() {
|
|||
});
|
||||
|
||||
const handleSubmit = (values: RepositoryFormValues) => {
|
||||
const { name, compressionMode, ...config } = formSchema.parse(values);
|
||||
|
||||
createRepository.mutate({
|
||||
body: {
|
||||
config: values,
|
||||
name: values.name,
|
||||
compressionMode: values.compressionMode,
|
||||
config,
|
||||
name,
|
||||
compressionMode,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import { toast } from "sonner";
|
|||
import { getRepositoryOptions, updateRepositoryMutation } from "~/client/api-client/@tanstack/react-query.gen";
|
||||
import {
|
||||
CreateRepositoryForm,
|
||||
formSchema,
|
||||
type RepositoryFormValues,
|
||||
} from "~/client/modules/repositories/components/create-repository-form";
|
||||
import {
|
||||
|
|
@ -35,11 +36,11 @@ const riskyLocationFieldsByBackend = {
|
|||
rclone: ["remote", "path"],
|
||||
} as const;
|
||||
|
||||
const hasRiskyLocationChange = (initialConfig: RepositoryConfig, nextConfig: RepositoryFormValues): boolean => {
|
||||
const hasRiskyLocationChange = (initialConfig: RepositoryConfig, nextConfig: RepositoryConfig): boolean => {
|
||||
const fields = riskyLocationFieldsByBackend[initialConfig.backend] ?? [];
|
||||
|
||||
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 { name, compressionMode, ...config } = formSchema.parse(values);
|
||||
|
||||
updateRepository.mutate({
|
||||
path: { shortId: repositoryId },
|
||||
body: {
|
||||
name: values.name,
|
||||
compressionMode: values.compressionMode,
|
||||
config: values,
|
||||
name,
|
||||
compressionMode,
|
||||
config,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleSubmit = (values: RepositoryFormValues) => {
|
||||
const nextConfig = values;
|
||||
const { name: _name, compressionMode: _compressionMode, ...nextConfig } = formSchema.parse(values);
|
||||
|
||||
if (hasRiskyLocationChange(initialConfig, nextConfig)) {
|
||||
setPendingValues(values);
|
||||
|
|
|
|||
|
|
@ -143,7 +143,7 @@ export function RepositoriesPage() {
|
|||
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": repository.status === "healthy",
|
||||
"bg-success/10 text-success": repository.status === "healthy",
|
||||
"bg-red-500/10 text-red-500": repository.status === "error",
|
||||
},
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import {
|
|||
getSnapshotDetailsOptions,
|
||||
listBackupSchedulesOptions,
|
||||
} 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 { Card, CardContent, CardHeader, CardTitle } from "~/client/components/ui/card";
|
||||
import { SnapshotFileBrowser } from "~/client/modules/backups/components/snapshot-file-browser";
|
||||
|
|
@ -49,7 +50,13 @@ const SnapshotFileBrowserSkeleton = () => (
|
|||
</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 { data: repository } = useSuspenseQuery({
|
||||
|
|
@ -62,6 +69,7 @@ export function SnapshotDetailsPage({ repositoryId, snapshotId }: { repositoryId
|
|||
|
||||
const { data, error } = useQuery({
|
||||
...getSnapshotDetailsOptions({ path: { shortId: repositoryId, snapshotId: snapshotId } }),
|
||||
initialData: initialSnapshot,
|
||||
});
|
||||
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">
|
||||
<span
|
||||
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-amber-500": repository.status !== "healthy" && repository.status !== "error",
|
||||
"animate-pulse": repository.status === "doctor",
|
||||
|
|
@ -209,7 +209,7 @@ export const RepositoryInfoTabContent = ({ repository, initialStats }: Props) =>
|
|||
{hasCaCert && (
|
||||
<div className="flex flex-col gap-1">
|
||||
<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>
|
||||
)}
|
||||
{hasInsecureTlsConfig && (
|
||||
|
|
@ -217,7 +217,7 @@ export const RepositoryInfoTabContent = ({ repository, initialStats }: Props) =>
|
|||
<div className="text-sm font-medium text-muted-foreground">TLS Validation</div>
|
||||
<p className="text-sm">
|
||||
<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>
|
||||
</div>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -1,10 +1,10 @@
|
|||
import { arktypeResolver } from "@hookform/resolvers/arktype";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { type } from "arktype";
|
||||
import { Plus, UserPlus } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
import { z } from "zod";
|
||||
import { Button } from "~/client/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
|
|
@ -20,15 +20,15 @@ import { Input } from "~/client/components/ui/input";
|
|||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "~/client/components/ui/select";
|
||||
import { authClient } from "~/client/lib/auth-client";
|
||||
|
||||
const createUserSchema = type({
|
||||
name: "string>=1",
|
||||
username: "string>=1",
|
||||
email: "string",
|
||||
password: "string>=8",
|
||||
role: "'user'|'admin'",
|
||||
const createUserSchema = z.object({
|
||||
name: z.string().min(1),
|
||||
username: z.string().min(1),
|
||||
email: z.string().email(),
|
||||
password: z.string().min(8),
|
||||
role: z.enum(["user", "admin"]),
|
||||
});
|
||||
|
||||
type CreateUserFormValues = typeof createUserSchema.infer;
|
||||
type CreateUserFormValues = z.infer<typeof createUserSchema>;
|
||||
|
||||
interface CreateUserDialogProps {
|
||||
onUserCreated?: () => void;
|
||||
|
|
@ -38,7 +38,7 @@ export function CreateUserDialog({ onUserCreated }: CreateUserDialogProps) {
|
|||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
const form = useForm<CreateUserFormValues>({
|
||||
resolver: arktypeResolver(createUserSchema),
|
||||
resolver: zodResolver(createUserSchema),
|
||||
defaultValues: {
|
||||
name: "",
|
||||
username: "",
|
||||
|
|
|
|||
|
|
@ -2,12 +2,12 @@ import { useMutation, useSuspenseQuery } from "@tanstack/react-query";
|
|||
import { Shield, ShieldAlert, Trash2 } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import type { GetOrgMembersResponse } from "~/client/api-client";
|
||||
import {
|
||||
getOrgMembersOptions,
|
||||
removeOrgMemberMutation,
|
||||
updateMemberRoleMutation,
|
||||
} from "~/client/api-client/@tanstack/react-query.gen";
|
||||
import type { GetOrgMembersResponse } from "~/client/api-client/types.gen";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
|
|
@ -25,11 +25,11 @@ import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "~
|
|||
import { useOrganizationContext } from "~/client/hooks/use-org-context";
|
||||
import { cn } from "~/client/lib/utils";
|
||||
|
||||
type OrgMembersSectionProps = {
|
||||
type Props = {
|
||||
initialMembers?: GetOrgMembersResponse;
|
||||
};
|
||||
|
||||
export function OrgMembersSection({ initialMembers }: OrgMembersSectionProps) {
|
||||
export function OrgMembersSection({ initialMembers }: Props) {
|
||||
const { activeMember } = useOrganizationContext();
|
||||
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">
|
||||
Status:
|
||||
{twoFactorEnabled ? (
|
||||
<span className="text-green-500">Enabled</span>
|
||||
<span className="text-success">Enabled</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 })}>
|
||||
Banned
|
||||
</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
|
||||
</Badge>
|
||||
</TableCell>
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import { Download, KeyRound, User, X, Settings as SettingsIcon, Building2 } from
|
|||
import { useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
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 { Card, CardContent, CardDescription, CardTitle } from "~/client/components/ui/card";
|
||||
import {
|
||||
|
|
@ -21,18 +22,18 @@ import { authClient } from "~/client/lib/auth-client";
|
|||
import { type AppContext } from "~/context";
|
||||
import { TwoFactorSection } from "../components/two-factor-section";
|
||||
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 { useOrganizationContext } from "~/client/hooks/use-org-context";
|
||||
import type { GetOrgMembersResponse, GetSsoSettingsResponse } from "~/client/api-client";
|
||||
|
||||
type Props = {
|
||||
appContext: AppContext;
|
||||
initialMembers?: GetOrgMembersResponse;
|
||||
initialSsoSettings?: GetSsoSettingsResponse;
|
||||
initialOrigin?: string;
|
||||
};
|
||||
|
||||
export function SettingsPage({ appContext, initialMembers, initialSsoSettings }: Props) {
|
||||
export function SettingsPage({ appContext, initialMembers, initialSsoSettings, initialOrigin }: Props) {
|
||||
const [currentPassword, setCurrentPassword] = useState("");
|
||||
const [newPassword, setNewPassword] = useState("");
|
||||
const [confirmPassword, setConfirmPassword] = useState("");
|
||||
|
|
@ -163,8 +164,12 @@ export function SettingsPage({ appContext, initialMembers, initialSsoSettings }:
|
|||
</div>
|
||||
<CardContent className="p-6 space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label>Username</Label>
|
||||
<Input value={appContext.user?.username || ""} disabled className="max-w-md" />
|
||||
<Label htmlFor="username">Username</Label>
|
||||
<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>
|
||||
</CardContent>
|
||||
|
||||
|
|
@ -312,7 +317,7 @@ export function SettingsPage({ appContext, initialMembers, initialSsoSettings }:
|
|||
<CardDescription className="mt-1.5">Configure OIDC provider settings</CardDescription>
|
||||
</div>
|
||||
<CardContent className="p-6">
|
||||
<SsoSettingsSection initialSettings={initialSsoSettings} />
|
||||
<SsoSettingsSection initialSettings={initialSsoSettings} initialOrigin={initialOrigin} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</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,
|
||||
updateSsoProviderAutoLinkingMutation,
|
||||
} from "~/client/api-client/@tanstack/react-query.gen";
|
||||
import type { GetSsoSettingsResponse } from "~/client/api-client/types.gen";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
|
|
@ -33,17 +34,17 @@ import { getOrigin } from "~/client/functions/get-origin";
|
|||
import { authClient } from "~/client/lib/auth-client";
|
||||
import { cn } from "~/client/lib/utils";
|
||||
import { useServerFn } from "@tanstack/react-start";
|
||||
import type { GetSsoSettingsResponse } from "~/client/api-client";
|
||||
|
||||
type InvitationRole = "member" | "admin" | "owner";
|
||||
|
||||
type SsoSettingsSectionProps = {
|
||||
type Props = {
|
||||
initialSettings?: GetSsoSettingsResponse;
|
||||
initialOrigin?: string;
|
||||
};
|
||||
|
||||
export function SsoSettingsSection({ initialSettings }: SsoSettingsSectionProps) {
|
||||
export function SsoSettingsSection({ initialSettings, initialOrigin }: Props) {
|
||||
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 { 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 { useNavigate } from "@tanstack/react-router";
|
||||
import { type } from "arktype";
|
||||
import { ShieldCheck, Plus } from "lucide-react";
|
||||
import { useId } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
import { z } from "zod";
|
||||
import { updateSsoProviderAutoLinkingMutation } from "~/client/api-client/@tanstack/react-query.gen";
|
||||
import { Button } from "~/client/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "~/client/components/ui/card";
|
||||
|
|
@ -24,17 +24,17 @@ import { authClient } from "~/client/lib/auth-client";
|
|||
import { parseError } from "~/client/lib/errors";
|
||||
import { useOrganizationContext } from "~/client/hooks/use-org-context";
|
||||
|
||||
const ssoProviderSchema = type({
|
||||
providerId: "string>=1",
|
||||
issuer: "string>=1",
|
||||
domain: "string>=1",
|
||||
clientId: "string>=1",
|
||||
clientSecret: "string>=1",
|
||||
discoveryEndpoint: "string>=1",
|
||||
linkMatchingEmails: "boolean",
|
||||
const ssoProviderSchema = z.object({
|
||||
providerId: z.string().min(1),
|
||||
issuer: z.string().min(1),
|
||||
domain: z.string().min(1),
|
||||
clientId: z.string().min(1),
|
||||
clientSecret: z.string().min(1),
|
||||
discoveryEndpoint: z.string().min(1),
|
||||
linkMatchingEmails: z.boolean(),
|
||||
});
|
||||
|
||||
type ProviderForm = typeof ssoProviderSchema.infer;
|
||||
type ProviderForm = z.infer<typeof ssoProviderSchema>;
|
||||
|
||||
export function CreateSsoProviderPage() {
|
||||
const navigate = useNavigate();
|
||||
|
|
@ -42,7 +42,7 @@ export function CreateSsoProviderPage() {
|
|||
const { activeOrganization } = useOrganizationContext();
|
||||
|
||||
const form = useForm<ProviderForm>({
|
||||
resolver: arktypeResolver(ssoProviderSchema),
|
||||
resolver: zodResolver(ssoProviderSchema),
|
||||
defaultValues: {
|
||||
providerId: "",
|
||||
issuer: "",
|
||||
|
|
@ -1,11 +1,10 @@
|
|||
import { arktypeResolver } from "@hookform/resolvers/arktype";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { type } from "arktype";
|
||||
import { CheckCircle, Loader2, Plug, Save, XCircle } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { z } from "zod";
|
||||
import { cn } from "~/client/lib/utils";
|
||||
import { deepClean } from "~/utils/object";
|
||||
import { Button } from "../../../components/ui/button";
|
||||
import {
|
||||
Form,
|
||||
|
|
@ -18,19 +17,31 @@ import {
|
|||
} from "../../../components/ui/form";
|
||||
import { Input } from "../../../components/ui/input";
|
||||
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 { Tooltip, TooltipContent, TooltipTrigger } from "../../../components/ui/tooltip";
|
||||
import { useSystemInfo } from "~/client/hooks/use-system-info";
|
||||
import { useScrollToFormError } from "~/client/hooks/use-scroll-to-form-error";
|
||||
import { DirectoryForm, NFSForm, SMBForm, WebDAVForm, RcloneForm, SFTPForm } from "./volume-forms";
|
||||
|
||||
export const formSchema = type({
|
||||
name: "2<=string<=32",
|
||||
}).and(volumeConfigSchemaBase);
|
||||
const cleanSchema = type.pipe((d) => formSchema(deepClean(d)));
|
||||
export const formSchema = z.discriminatedUnion("backend", [
|
||||
directoryConfigSchema.extend({ name: z.string().min(2).max(32) }),
|
||||
nfsConfigSchema.extend({ name: z.string().min(2).max(32) }),
|
||||
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 = {
|
||||
onSubmit: (values: FormValues) => void;
|
||||
|
|
@ -52,7 +63,7 @@ const defaultValuesForType = {
|
|||
|
||||
export const CreateVolumeForm = ({ onSubmit, mode = "create", initialValues, formId, loading, className }: Props) => {
|
||||
const form = useForm<FormValues>({
|
||||
resolver: arktypeResolver(cleanSchema as unknown as typeof formSchema),
|
||||
resolver: zodResolver(formSchema, undefined, { raw: true }),
|
||||
defaultValues: initialValues || {
|
||||
name: "",
|
||||
backend: "directory",
|
||||
|
|
@ -89,15 +100,22 @@ export const CreateVolumeForm = ({ onSubmit, mode = "create", initialValues, for
|
|||
|
||||
const handleTestConnection = async () => {
|
||||
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 (
|
||||
formValues.backend === "nfs" ||
|
||||
formValues.backend === "smb" ||
|
||||
formValues.backend === "webdav" ||
|
||||
formValues.backend === "sftp"
|
||||
parsedConfig.data.backend === "nfs" ||
|
||||
parsedConfig.data.backend === "smb" ||
|
||||
parsedConfig.data.backend === "webdav" ||
|
||||
parsedConfig.data.backend === "sftp"
|
||||
) {
|
||||
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 && 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 && (
|
||||
<XCircle className="mr-2 h-4 w-4 text-red-500" />
|
||||
|
|
@ -255,7 +273,7 @@ export const CreateVolumeForm = ({ onSubmit, mode = "create", initialValues, for
|
|||
{testMessage && (
|
||||
<div
|
||||
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,
|
||||
})}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -51,7 +51,7 @@ export const HealthchecksCard = ({ volume }: Props) => {
|
|||
<CardContent>
|
||||
<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.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" && (
|
||||
<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",
|
||||
value: statfs.used,
|
||||
fill: "#ff543a",
|
||||
fill: "var(--strong-accent)",
|
||||
},
|
||||
{
|
||||
name: "Free",
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import { HardDrive, Plus } from "lucide-react";
|
|||
import { useId } from "react";
|
||||
import { toast } from "sonner";
|
||||
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 { Card, CardContent, CardHeader, CardTitle } from "~/client/components/ui/card";
|
||||
import { parseError } from "~/client/lib/errors";
|
||||
|
|
@ -23,10 +23,12 @@ export function CreateVolumePage() {
|
|||
});
|
||||
|
||||
const handleSubmit = (values: FormValues) => {
|
||||
const { name, ...config } = formSchema.parse(values);
|
||||
|
||||
createVolume.mutate({
|
||||
body: {
|
||||
config: values,
|
||||
name: values.name,
|
||||
config,
|
||||
name,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
|
|
|||
|
|
@ -134,7 +134,7 @@ export function VolumesPage() {
|
|||
{filteredVolumes.map((volume) => (
|
||||
<TableRow
|
||||
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}` })}
|
||||
>
|
||||
<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 { toast } from "sonner";
|
||||
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 {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
|
|
@ -102,9 +102,11 @@ export const VolumeInfoTabContent = ({ volume, statfs }: Props) => {
|
|||
|
||||
const confirmUpdate = () => {
|
||||
if (pendingValues) {
|
||||
const { name, ...config } = formSchema.parse(pendingValues);
|
||||
|
||||
updateMutation.mutate({
|
||||
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 { type } from "arktype";
|
||||
import { z } from "zod";
|
||||
import { LoginPage } from "~/client/modules/auth/routes/login";
|
||||
|
||||
export const Route = createFileRoute("/(auth)/login/error")({
|
||||
component: RouteComponent,
|
||||
errorComponent: () => <div>Failed to load login error</div>,
|
||||
validateSearch: type({ error: "string?" }),
|
||||
validateSearch: z.object({ error: z.string().optional() }),
|
||||
head: () => ({
|
||||
meta: [
|
||||
{ title: "Zerobyte - Login Error" },
|
||||
|
|
|
|||
|
|
@ -1,11 +1,11 @@
|
|||
import { Outlet, createFileRoute, useRouterState } from "@tanstack/react-router";
|
||||
import { type } from "arktype";
|
||||
import { z } from "zod";
|
||||
import { LoginPage } from "~/client/modules/auth/routes/login";
|
||||
|
||||
export const Route = createFileRoute("/(auth)/login")({
|
||||
component: RouteComponent,
|
||||
errorComponent: () => <div>Failed to load login</div>,
|
||||
validateSearch: type({ error: "string?" }),
|
||||
validateSearch: z.object({ error: z.string().optional() }),
|
||||
head: () => ({
|
||||
meta: [
|
||||
{ title: "Zerobyte - Login" },
|
||||
|
|
|
|||
|
|
@ -1,12 +1,12 @@
|
|||
import { createFileRoute, redirect } from "@tanstack/react-router";
|
||||
import { type } from "arktype";
|
||||
import { z } from "zod";
|
||||
import { fetchUser } from "../route";
|
||||
import type { AppContext } from "~/context";
|
||||
import { AdminPage } from "~/client/modules/admin/routes/admin-page";
|
||||
import { getAdminUsersOptions, getRegistrationStatusOptions } from "~/client/api-client/@tanstack/react-query.gen";
|
||||
|
||||
export const Route = createFileRoute("/(dashboard)/admin/")({
|
||||
validateSearch: type({ tab: "string?" }),
|
||||
validateSearch: z.object({ tab: z.string().optional() }),
|
||||
component: RouteComponent,
|
||||
errorComponent: () => <div>Failed to load admin</div>,
|
||||
loader: async ({ context }) => {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { type } from "arktype";
|
||||
import { z } from "zod";
|
||||
import {
|
||||
getBackupProgressOptions,
|
||||
getBackupScheduleOptions,
|
||||
getScheduleMirrorsOptions,
|
||||
getScheduleNotificationsOptions,
|
||||
|
|
@ -14,7 +15,7 @@ import { prefetchOrSkip } from "~/utils/prefetch";
|
|||
export const Route = createFileRoute("/(dashboard)/backups/$backupId/")({
|
||||
component: RouteComponent,
|
||||
errorComponent: () => <div>Failed to load backup</div>,
|
||||
validateSearch: type({ snapshot: "string?" }),
|
||||
validateSearch: z.object({ snapshot: z.string().optional() }),
|
||||
loader: async ({ params, context }) => {
|
||||
const { backupId } = params;
|
||||
|
||||
|
|
@ -24,6 +25,7 @@ export const Route = createFileRoute("/(dashboard)/backups/$backupId/")({
|
|||
context.queryClient.ensureQueryData({ ...listRepositoriesOptions() }),
|
||||
context.queryClient.ensureQueryData({ ...getScheduleNotificationsOptions({ path: { shortId: backupId } }) }),
|
||||
context.queryClient.ensureQueryData({ ...getScheduleMirrorsOptions({ path: { shortId: backupId } }) }),
|
||||
context.queryClient.ensureQueryData({ ...getBackupProgressOptions({ path: { shortId: backupId } }) }),
|
||||
]);
|
||||
|
||||
const snapshotOptions = listSnapshotsOptions({
|
||||
|
|
|
|||
|
|
@ -1,16 +1,30 @@
|
|||
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 { prefetchOrSkip } from "~/utils/prefetch";
|
||||
|
||||
export const Route = createFileRoute("/(dashboard)/repositories/$repositoryId/$snapshotId/")({
|
||||
component: RouteComponent,
|
||||
errorComponent: (e) => <div>{e.error.message}</div>,
|
||||
loader: async ({ params, context }) => {
|
||||
const res = await context.queryClient.ensureQueryData({
|
||||
...getRepositoryOptions({ path: { shortId: params.repositoryId } }),
|
||||
});
|
||||
const [res] = await Promise.all([
|
||||
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: {
|
||||
breadcrumb: (match) => [
|
||||
|
|
@ -32,6 +46,7 @@ export const Route = createFileRoute("/(dashboard)/repositories/$repositoryId/$s
|
|||
|
||||
function RouteComponent() {
|
||||
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 { type } from "arktype";
|
||||
import { z } from "zod";
|
||||
import {
|
||||
getRepositoryOptions,
|
||||
getRepositoryStatsOptions,
|
||||
|
|
@ -14,24 +14,23 @@ export const Route = createFileRoute("/(dashboard)/repositories/$repositoryId/")
|
|||
errorComponent: (e) => <div>{e.error.message}</div>,
|
||||
loader: async ({ params, context }) => {
|
||||
const snapshotOptions = listSnapshotsOptions({ path: { shortId: params.repositoryId } });
|
||||
const schedulesOptions = listBackupSchedulesOptions();
|
||||
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(listBackupSchedulesOptions()),
|
||||
prefetchOrSkip(context.queryClient, snapshotOptions),
|
||||
prefetchOrSkip(context.queryClient, schedulesOptions),
|
||||
prefetchOrSkip(context.queryClient, statsOptions),
|
||||
]);
|
||||
|
||||
return {
|
||||
...res,
|
||||
snapshots: context.queryClient.getQueryData(snapshotOptions.queryKey),
|
||||
backupSchedules: context.queryClient.getQueryData(schedulesOptions.queryKey),
|
||||
backupSchedules: schedules,
|
||||
stats: context.queryClient.getQueryData(statsOptions.queryKey),
|
||||
};
|
||||
},
|
||||
validateSearch: type({ tab: "string?" }),
|
||||
validateSearch: z.object({ tab: z.string().optional() }),
|
||||
staticData: {
|
||||
breadcrumb: (match) => [
|
||||
{ label: "Repositories", href: "/repositories" },
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import { SIDEBAR_COOKIE_NAME } from "~/client/components/ui/sidebar";
|
|||
import { authMiddleware } from "~/middleware/auth";
|
||||
import { auth } from "~/server/lib/auth";
|
||||
import { getOrganizationContext } from "~/server/lib/functions/organization-context";
|
||||
import { getServerConstants } from "~/server/lib/functions/server-constants";
|
||||
import { authService } from "~/server/modules/auth/auth.service";
|
||||
|
||||
export const fetchUser = createServerFn({ method: "GET" }).handler(async () => {
|
||||
|
|
@ -32,6 +33,10 @@ export const Route = createFileRoute("/(dashboard)")({
|
|||
queryKey: ["organization-context"],
|
||||
queryFn: () => getOrganizationContext(),
|
||||
}),
|
||||
context.queryClient.ensureQueryData({
|
||||
queryKey: ["server-constants"],
|
||||
queryFn: () => getServerConstants(),
|
||||
}),
|
||||
]);
|
||||
|
||||
return authContext;
|
||||
|
|
|
|||
|
|
@ -2,33 +2,42 @@ import { createFileRoute } from "@tanstack/react-router";
|
|||
import { fetchUser } from "../route";
|
||||
import type { AppContext } from "~/context";
|
||||
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 { getOrigin } from "~/client/functions/get-origin";
|
||||
import { getOrganizationContext } from "~/server/lib/functions/organization-context";
|
||||
|
||||
export const Route = createFileRoute("/(dashboard)/settings/")({
|
||||
component: RouteComponent,
|
||||
validateSearch: type({ tab: "string?" }),
|
||||
validateSearch: z.object({ tab: z.string().optional() }),
|
||||
errorComponent: () => <div>Failed to load settings</div>,
|
||||
loader: async ({ context }) => {
|
||||
const authContext = await fetchUser();
|
||||
const orgContext = await getOrganizationContext();
|
||||
const [authContext, orgContext] = await Promise.all([
|
||||
fetchUser(),
|
||||
context.queryClient.ensureQueryData({
|
||||
queryKey: ["organization-context"],
|
||||
queryFn: () => getOrganizationContext(),
|
||||
}),
|
||||
]);
|
||||
const orgRole = orgContext.activeMember?.role;
|
||||
const shouldPrefetchOrgQueries = orgRole === "owner" || orgRole === "admin";
|
||||
|
||||
let org, members;
|
||||
|
||||
if (authContext.user?.role === "admin" || orgRole === "owner" || orgRole === "admin") {
|
||||
const promises = await Promise.all([
|
||||
if (shouldPrefetchOrgQueries) {
|
||||
const [org, members, appOrigin] = await Promise.all([
|
||||
context.queryClient.ensureQueryData({ ...getSsoSettingsOptions() }),
|
||||
context.queryClient.ensureQueryData({ ...getOrgMembersOptions() }),
|
||||
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: {
|
||||
breadcrumb: () => [{ label: "Settings" }],
|
||||
|
|
@ -36,7 +45,14 @@ export const Route = createFileRoute("/(dashboard)/settings/")({
|
|||
});
|
||||
|
||||
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 { 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";
|
||||
|
||||
export const Route = createFileRoute("/(dashboard)/settings/sso/new")({
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
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 { VolumeDetails } from "~/client/modules/volumes/routes/volume-details";
|
||||
|
||||
|
|
@ -13,7 +13,7 @@ export const Route = createFileRoute("/(dashboard)/volumes/$volumeId")({
|
|||
|
||||
return res;
|
||||
},
|
||||
validateSearch: type({ tab: "string?" }),
|
||||
validateSearch: z.object({ tab: z.string().optional() }),
|
||||
staticData: {
|
||||
breadcrumb: (match) => [
|
||||
{ label: "Volumes", href: "/volumes" },
|
||||
|
|
|
|||
|
|
@ -2,14 +2,27 @@ import { Outlet, HeadContent, Scripts, createRootRouteWithContext } from "@tanst
|
|||
import appCss from "../app.css?url";
|
||||
import { apiClientMiddleware } from "~/middleware/api-client";
|
||||
import type { QueryClient } from "@tanstack/react-query";
|
||||
import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
|
||||
import { Toaster } from "~/client/components/ui/sonner";
|
||||
import { useServerEvents } from "~/client/hooks/use-server-events";
|
||||
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 }>()({
|
||||
server: {
|
||||
middleware: [apiClientMiddleware],
|
||||
},
|
||||
loader: async () => {
|
||||
const theme = await fetchTheme();
|
||||
return { theme };
|
||||
},
|
||||
head: () => ({
|
||||
meta: [{ title: "Zerobyte - Open Source Backup Solution" }],
|
||||
links: [
|
||||
|
|
@ -34,6 +47,7 @@ export const Route = createRootRouteWithContext<{ queryClient: QueryClient }>()(
|
|||
});
|
||||
|
||||
function RootLayout() {
|
||||
const { theme } = Route.useLoaderData();
|
||||
useServerEvents();
|
||||
useEffect(() => {
|
||||
document.body.setAttribute("data-app-ready", "true");
|
||||
|
|
@ -43,7 +57,7 @@ function RootLayout() {
|
|||
}, []);
|
||||
|
||||
return (
|
||||
<html lang="en">
|
||||
<html lang="en" className={theme === "dark" ? "dark" : undefined} style={{ colorScheme: theme }}>
|
||||
<head>
|
||||
<meta charSet="utf-8" />
|
||||
<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" />
|
||||
<HeadContent />
|
||||
</head>
|
||||
<body className="dark">
|
||||
<Outlet />
|
||||
<Toaster />
|
||||
<body>
|
||||
<ThemeProvider initialTheme={theme}>
|
||||
<Outlet />
|
||||
<Toaster />
|
||||
<ReactQueryDevtools buttonPosition="bottom-right" />
|
||||
</ThemeProvider>
|
||||
<Scripts />
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
|
|
@ -1,9 +1,26 @@
|
|||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { createApp } from "~/server/app";
|
||||
import { config } from "~/server/core/config";
|
||||
|
||||
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/$")({
|
||||
server: {
|
||||
|
|
|
|||
|
|
@ -1,85 +1,84 @@
|
|||
import { type } from "arktype";
|
||||
import { z } from "zod";
|
||||
import { resticBackupProgressMetricsSchema, resticBackupRunSummarySchema } from "~/schemas/restic-dto";
|
||||
|
||||
export const backupEventStatusSchema = type("'success' | 'error' | 'stopped' | 'warning'");
|
||||
export const restoreEventStatusSchema = type("'success' | 'error'");
|
||||
export const backupEventStatusSchema = z.enum(["success", "error", "stopped", "warning"]);
|
||||
export const restoreEventStatusSchema = z.enum(["success", "error"]);
|
||||
|
||||
const backupEventBaseSchema = type({
|
||||
scheduleId: "string",
|
||||
volumeName: "string",
|
||||
repositoryName: "string",
|
||||
const backupEventBaseSchema = z.object({
|
||||
scheduleId: z.string(),
|
||||
volumeName: z.string(),
|
||||
repositoryName: z.string(),
|
||||
});
|
||||
|
||||
const organizationScopedSchema = type({
|
||||
organizationId: "string",
|
||||
const organizationScopedSchema = z.object({
|
||||
organizationId: z.string(),
|
||||
});
|
||||
|
||||
const restoreEventBaseSchema = type({
|
||||
repositoryId: "string",
|
||||
snapshotId: "string",
|
||||
const restoreEventBaseSchema = z.object({
|
||||
repositoryId: z.string(),
|
||||
snapshotId: z.string(),
|
||||
});
|
||||
|
||||
const dumpStartedEventSchema = type({
|
||||
repositoryId: "string",
|
||||
snapshotId: "string",
|
||||
path: "string",
|
||||
filename: "string",
|
||||
const dumpStartedEventSchema = z.object({
|
||||
repositoryId: z.string(),
|
||||
snapshotId: z.string(),
|
||||
path: z.string(),
|
||||
filename: z.string(),
|
||||
});
|
||||
|
||||
const restoreProgressMetricsSchema = type({
|
||||
seconds_elapsed: "number",
|
||||
percent_done: "number",
|
||||
total_files: "number",
|
||||
files_restored: "number",
|
||||
total_bytes: "number",
|
||||
bytes_restored: "number",
|
||||
const restoreProgressMetricsSchema = z.object({
|
||||
seconds_elapsed: z.number(),
|
||||
percent_done: z.number(),
|
||||
total_files: z.number(),
|
||||
files_restored: z.number(),
|
||||
total_bytes: z.number(),
|
||||
bytes_restored: z.number(),
|
||||
});
|
||||
|
||||
export const backupStartedEventSchema = backupEventBaseSchema;
|
||||
|
||||
export const backupProgressEventSchema = backupEventBaseSchema.and(resticBackupProgressMetricsSchema);
|
||||
export const backupProgressEventSchema = backupEventBaseSchema.extend(resticBackupProgressMetricsSchema.shape);
|
||||
|
||||
export const backupCompletedEventSchema = backupEventBaseSchema.and(
|
||||
type({
|
||||
status: backupEventStatusSchema,
|
||||
summary: resticBackupRunSummarySchema.optional(),
|
||||
}),
|
||||
);
|
||||
export const backupCompletedEventSchema = backupEventBaseSchema.extend({
|
||||
status: backupEventStatusSchema,
|
||||
summary: resticBackupRunSummarySchema.optional(),
|
||||
});
|
||||
|
||||
export const restoreStartedEventSchema = restoreEventBaseSchema;
|
||||
|
||||
export const restoreProgressEventSchema = restoreEventBaseSchema.and(restoreProgressMetricsSchema);
|
||||
export const restoreProgressEventSchema = restoreEventBaseSchema.extend(restoreProgressMetricsSchema.shape);
|
||||
|
||||
export const restoreCompletedEventSchema = restoreEventBaseSchema.and(
|
||||
type({ status: restoreEventStatusSchema, error: "string?" }),
|
||||
);
|
||||
export const restoreCompletedEventSchema = restoreEventBaseSchema.extend({
|
||||
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 BackupStartedEventDto = typeof backupStartedEventSchema.infer;
|
||||
export type BackupProgressEventDto = typeof backupProgressEventSchema.infer;
|
||||
export type BackupCompletedEventDto = typeof backupCompletedEventSchema.infer;
|
||||
export type RestoreStartedEventDto = typeof restoreStartedEventSchema.infer;
|
||||
export type RestoreProgressEventDto = typeof restoreProgressEventSchema.infer;
|
||||
export type RestoreCompletedEventDto = typeof restoreCompletedEventSchema.infer;
|
||||
export type DumpStartedEventDto = typeof dumpStartedEventSchema.infer;
|
||||
export type ServerBackupStartedEventDto = typeof serverBackupStartedEventSchema.infer;
|
||||
export type ServerBackupProgressEventDto = typeof serverBackupProgressEventSchema.infer;
|
||||
export type ServerBackupCompletedEventDto = typeof serverBackupCompletedEventSchema.infer;
|
||||
export type ServerRestoreStartedEventDto = typeof serverRestoreStartedEventSchema.infer;
|
||||
export type ServerRestoreProgressEventDto = typeof serverRestoreProgressEventSchema.infer;
|
||||
export type ServerRestoreCompletedEventDto = typeof serverRestoreCompletedEventSchema.infer;
|
||||
export type ServerDumpStartedEventDto = typeof serverDumpStartedEventSchema.infer;
|
||||
export type BackupEventStatusDto = z.infer<typeof backupEventStatusSchema>;
|
||||
export type BackupStartedEventDto = z.infer<typeof backupStartedEventSchema>;
|
||||
export type BackupProgressEventDto = z.infer<typeof backupProgressEventSchema>;
|
||||
export type BackupCompletedEventDto = z.infer<typeof backupCompletedEventSchema>;
|
||||
export type RestoreStartedEventDto = z.infer<typeof restoreStartedEventSchema>;
|
||||
export type RestoreProgressEventDto = z.infer<typeof restoreProgressEventSchema>;
|
||||
export type RestoreCompletedEventDto = z.infer<typeof restoreCompletedEventSchema>;
|
||||
export type DumpStartedEventDto = z.infer<typeof dumpStartedEventSchema>;
|
||||
export type ServerBackupStartedEventDto = z.infer<typeof serverBackupStartedEventSchema>;
|
||||
export type ServerBackupProgressEventDto = z.infer<typeof serverBackupProgressEventSchema>;
|
||||
export type ServerBackupCompletedEventDto = z.infer<typeof serverBackupCompletedEventSchema>;
|
||||
export type ServerRestoreStartedEventDto = z.infer<typeof serverRestoreStartedEventSchema>;
|
||||
export type ServerRestoreProgressEventDto = z.infer<typeof serverRestoreProgressEventSchema>;
|
||||
export type ServerRestoreCompletedEventDto = z.infer<typeof serverRestoreCompletedEventSchema>;
|
||||
export type ServerDumpStartedEventDto = z.infer<typeof serverDumpStartedEventSchema>;
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { type } from "arktype";
|
||||
import { z } from "zod";
|
||||
|
||||
export const NOTIFICATION_TYPES = {
|
||||
email: "email",
|
||||
|
|
@ -14,96 +14,98 @@ export const NOTIFICATION_TYPES = {
|
|||
|
||||
export type NotificationType = keyof typeof NOTIFICATION_TYPES;
|
||||
|
||||
export const emailNotificationConfigSchema = type({
|
||||
type: "'email'",
|
||||
smtpHost: "string",
|
||||
smtpPort: "1 <= number <= 65535",
|
||||
username: "string?",
|
||||
password: "string?",
|
||||
from: "string",
|
||||
fromName: "string?",
|
||||
to: "string[]",
|
||||
useTLS: "boolean",
|
||||
export const emailNotificationConfigSchema = z.object({
|
||||
type: z.literal("email"),
|
||||
smtpHost: z.string().min(1),
|
||||
smtpPort: z.number().int().min(1).max(65535),
|
||||
username: z.string().optional(),
|
||||
password: z.string().optional(),
|
||||
from: z.string().min(1),
|
||||
fromName: z.string().optional(),
|
||||
to: z.array(z.string()),
|
||||
useTLS: z.boolean(),
|
||||
});
|
||||
|
||||
export const slackNotificationConfigSchema = type({
|
||||
type: "'slack'",
|
||||
webhookUrl: "string",
|
||||
channel: "string?",
|
||||
username: "string?",
|
||||
iconEmoji: "string?",
|
||||
export const slackNotificationConfigSchema = z.object({
|
||||
type: z.literal("slack"),
|
||||
webhookUrl: z.string().min(1),
|
||||
channel: z.string().optional(),
|
||||
username: z.string().optional(),
|
||||
iconEmoji: z.string().optional(),
|
||||
});
|
||||
|
||||
export const discordNotificationConfigSchema = type({
|
||||
type: "'discord'",
|
||||
webhookUrl: "string",
|
||||
username: "string?",
|
||||
avatarUrl: "string?",
|
||||
threadId: "string?",
|
||||
export const discordNotificationConfigSchema = z.object({
|
||||
type: z.literal("discord"),
|
||||
webhookUrl: z.string().min(1),
|
||||
username: z.string().optional(),
|
||||
avatarUrl: z.string().optional(),
|
||||
threadId: z.string().optional(),
|
||||
});
|
||||
|
||||
export const gotifyNotificationConfigSchema = type({
|
||||
type: "'gotify'",
|
||||
serverUrl: "string",
|
||||
token: "string",
|
||||
path: "string?",
|
||||
priority: "0 <= number <= 10",
|
||||
export const gotifyNotificationConfigSchema = z.object({
|
||||
type: z.literal("gotify"),
|
||||
serverUrl: z.string().min(1),
|
||||
token: z.string().min(1),
|
||||
path: z.string().optional(),
|
||||
priority: z.number().min(0).max(10),
|
||||
});
|
||||
|
||||
export const ntfyNotificationConfigSchema = type({
|
||||
type: "'ntfy'",
|
||||
serverUrl: "string?",
|
||||
topic: "string",
|
||||
priority: "'max' | 'high' | 'default' | 'low' | 'min'",
|
||||
username: "string?",
|
||||
password: "string?",
|
||||
accessToken: "string?",
|
||||
export const ntfyNotificationConfigSchema = z.object({
|
||||
type: z.literal("ntfy"),
|
||||
serverUrl: z.string().optional(),
|
||||
topic: z.string().min(1),
|
||||
priority: z.enum(["max", "high", "default", "low", "min"]),
|
||||
username: z.string().optional(),
|
||||
password: z.string().optional(),
|
||||
accessToken: z.string().optional(),
|
||||
});
|
||||
|
||||
export const pushoverNotificationConfigSchema = type({
|
||||
type: "'pushover'",
|
||||
userKey: "string",
|
||||
apiToken: "string",
|
||||
devices: "string?",
|
||||
priority: "-1 | 0 | 1",
|
||||
export const pushoverNotificationConfigSchema = z.object({
|
||||
type: z.literal("pushover"),
|
||||
userKey: z.string().min(1),
|
||||
apiToken: z.string().min(1),
|
||||
devices: z.string().optional(),
|
||||
priority: z.union([z.literal(-1), z.literal(0), z.literal(1)]),
|
||||
});
|
||||
|
||||
export const telegramNotificationConfigSchema = type({
|
||||
type: "'telegram'",
|
||||
botToken: "string",
|
||||
chatId: "string",
|
||||
threadId: "string?",
|
||||
export const telegramNotificationConfigSchema = z.object({
|
||||
type: z.literal("telegram"),
|
||||
botToken: z.string().min(1),
|
||||
chatId: z.string().min(1),
|
||||
threadId: z.string().optional(),
|
||||
});
|
||||
|
||||
export const genericNotificationConfigSchema = type({
|
||||
type: "'generic'",
|
||||
url: "string",
|
||||
method: "'GET' | 'POST'",
|
||||
contentType: "string?",
|
||||
headers: "string[]?",
|
||||
useJson: "boolean?",
|
||||
titleKey: "string?",
|
||||
messageKey: "string?",
|
||||
export const genericNotificationConfigSchema = z.object({
|
||||
type: z.literal("generic"),
|
||||
url: z.string().min(1),
|
||||
method: z.enum(["GET", "POST"]),
|
||||
contentType: z.string().optional(),
|
||||
headers: z.array(z.string()).optional(),
|
||||
useJson: z.boolean().optional(),
|
||||
titleKey: z.string().optional(),
|
||||
messageKey: z.string().optional(),
|
||||
});
|
||||
|
||||
export const customNotificationConfigSchema = type({
|
||||
type: "'custom'",
|
||||
shoutrrrUrl: "string",
|
||||
export const customNotificationConfigSchema = z.object({
|
||||
type: z.literal("custom"),
|
||||
shoutrrrUrl: z.string().min(1),
|
||||
});
|
||||
|
||||
export const notificationConfigSchemaBase = emailNotificationConfigSchema
|
||||
.or(slackNotificationConfigSchema)
|
||||
.or(discordNotificationConfigSchema)
|
||||
.or(gotifyNotificationConfigSchema)
|
||||
.or(ntfyNotificationConfigSchema)
|
||||
.or(pushoverNotificationConfigSchema)
|
||||
.or(telegramNotificationConfigSchema)
|
||||
.or(genericNotificationConfigSchema)
|
||||
.or(customNotificationConfigSchema);
|
||||
export const notificationConfigSchemaBase = z.discriminatedUnion("type", [
|
||||
emailNotificationConfigSchema,
|
||||
slackNotificationConfigSchema,
|
||||
discordNotificationConfigSchema,
|
||||
gotifyNotificationConfigSchema,
|
||||
ntfyNotificationConfigSchema,
|
||||
pushoverNotificationConfigSchema,
|
||||
telegramNotificationConfigSchema,
|
||||
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 = {
|
||||
start: "start",
|
||||
|
|
|
|||
|
|
@ -1,82 +1,73 @@
|
|||
import { type } from "arktype";
|
||||
import { z } from "zod";
|
||||
|
||||
export const resticSummaryBaseSchema = type({
|
||||
files_new: "number",
|
||||
files_changed: "number",
|
||||
files_unmodified: "number",
|
||||
dirs_new: "number",
|
||||
dirs_changed: "number",
|
||||
dirs_unmodified: "number",
|
||||
data_blobs: "number",
|
||||
tree_blobs: "number",
|
||||
data_added: "number",
|
||||
data_added_packed: "number?",
|
||||
total_files_processed: "number",
|
||||
total_bytes_processed: "number",
|
||||
export const resticSummaryBaseSchema = z.object({
|
||||
files_new: z.number(),
|
||||
files_changed: z.number(),
|
||||
files_unmodified: z.number(),
|
||||
dirs_new: z.number(),
|
||||
dirs_changed: z.number(),
|
||||
dirs_unmodified: z.number(),
|
||||
data_blobs: z.number(),
|
||||
tree_blobs: z.number(),
|
||||
data_added: z.number(),
|
||||
data_added_packed: z.number().optional(),
|
||||
total_files_processed: z.number(),
|
||||
total_bytes_processed: z.number(),
|
||||
});
|
||||
|
||||
export const resticSnapshotSummarySchema = resticSummaryBaseSchema.and(
|
||||
type({
|
||||
backup_start: "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 resticSnapshotSummarySchema = resticSummaryBaseSchema.extend({
|
||||
backup_start: z.string(),
|
||||
backup_end: z.string(),
|
||||
});
|
||||
|
||||
export const resticBackupProgressSchema = resticBackupProgressMetricsSchema.and(
|
||||
type({
|
||||
message_type: "'status'",
|
||||
}),
|
||||
);
|
||||
|
||||
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 resticBackupRunSummarySchema = resticSummaryBaseSchema.extend({
|
||||
total_duration: z.number(),
|
||||
snapshot_id: z.string(),
|
||||
});
|
||||
|
||||
export const resticStatsSchema = type({
|
||||
total_size: "number = 0",
|
||||
total_uncompressed_size: "number = 0",
|
||||
compression_ratio: "number = 0",
|
||||
compression_progress: "number = 0",
|
||||
compression_space_saving: "number = 0",
|
||||
snapshots_count: "number = 0",
|
||||
export const resticBackupOutputSchema = resticBackupRunSummarySchema.extend({
|
||||
message_type: z.literal("summary"),
|
||||
});
|
||||
|
||||
export type ResticSnapshotSummaryDto = typeof resticSnapshotSummarySchema.infer;
|
||||
export type ResticBackupRunSummaryDto = typeof resticBackupRunSummarySchema.infer;
|
||||
export type ResticBackupOutputDto = typeof resticBackupOutputSchema.infer;
|
||||
export type ResticBackupProgressMetricsDto = typeof resticBackupProgressMetricsSchema.infer;
|
||||
export type ResticBackupProgressDto = typeof resticBackupProgressSchema.infer;
|
||||
export const resticBackupProgressMetricsSchema = z.object({
|
||||
seconds_elapsed: z.number(),
|
||||
seconds_remaining: z.number().default(0),
|
||||
percent_done: z.number(),
|
||||
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 type ResticStatsDto = typeof resticStatsSchema.infer;
|
||||
export const resticBackupProgressSchema = resticBackupProgressMetricsSchema.extend({
|
||||
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 = {
|
||||
local: "local",
|
||||
|
|
@ -21,98 +21,120 @@ export const BANDWIDTH_UNITS = {
|
|||
|
||||
export type BandwidthUnit = keyof typeof BANDWIDTH_UNITS;
|
||||
|
||||
export const bandwidthLimitSchema = type({
|
||||
enabled: "boolean = false",
|
||||
value: "number > 0 = 1",
|
||||
unit: type.valueOf(BANDWIDTH_UNITS).default("Mbps"),
|
||||
const bandwidthUnitSchema = z.enum(["Kbps", "Mbps", "Gbps"]);
|
||||
|
||||
export const bandwidthLimitSchema = z.object({
|
||||
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 = type({
|
||||
isExistingRepository: "boolean?",
|
||||
customPassword: "string?",
|
||||
cacert: "string?",
|
||||
insecureTls: "boolean?",
|
||||
// Bandwidth controls
|
||||
const baseRepositoryConfigSchema = z.object({
|
||||
isExistingRepository: z.boolean().optional(),
|
||||
customPassword: z.string().optional(),
|
||||
cacert: z.string().optional(),
|
||||
insecureTls: z.boolean().optional(),
|
||||
uploadLimit: bandwidthLimitSchema.optional(),
|
||||
downloadLimit: bandwidthLimitSchema.optional(),
|
||||
});
|
||||
|
||||
export const s3RepositoryConfigSchema = type({
|
||||
backend: "'s3'",
|
||||
endpoint: "string",
|
||||
bucket: "string",
|
||||
accessKeyId: "string",
|
||||
secretAccessKey: "string",
|
||||
}).and(baseRepositoryConfigSchema);
|
||||
export const s3RepositoryConfigSchema = z
|
||||
.object({
|
||||
backend: z.literal("s3"),
|
||||
endpoint: z.string().min(1),
|
||||
bucket: z.string().min(1),
|
||||
accessKeyId: z.string().min(1),
|
||||
secretAccessKey: z.string().min(1),
|
||||
})
|
||||
.extend(baseRepositoryConfigSchema.shape);
|
||||
|
||||
export const r2RepositoryConfigSchema = type({
|
||||
backend: "'r2'",
|
||||
endpoint: "string",
|
||||
bucket: "string",
|
||||
accessKeyId: "string",
|
||||
secretAccessKey: "string",
|
||||
}).and(baseRepositoryConfigSchema);
|
||||
export const r2RepositoryConfigSchema = z
|
||||
.object({
|
||||
backend: z.literal("r2"),
|
||||
endpoint: z.string().min(1),
|
||||
bucket: z.string().min(1),
|
||||
accessKeyId: z.string().min(1),
|
||||
secretAccessKey: z.string().min(1),
|
||||
})
|
||||
.extend(baseRepositoryConfigSchema.shape);
|
||||
|
||||
export const localRepositoryConfigSchema = type({
|
||||
backend: "'local'",
|
||||
path: "string",
|
||||
}).and(baseRepositoryConfigSchema);
|
||||
export const localRepositoryConfigSchema = z
|
||||
.object({
|
||||
backend: z.literal("local"),
|
||||
path: z.string().min(1),
|
||||
})
|
||||
.extend(baseRepositoryConfigSchema.shape);
|
||||
|
||||
export const gcsRepositoryConfigSchema = type({
|
||||
backend: "'gcs'",
|
||||
bucket: "string",
|
||||
projectId: "string",
|
||||
credentialsJson: "string",
|
||||
}).and(baseRepositoryConfigSchema);
|
||||
export const gcsRepositoryConfigSchema = z
|
||||
.object({
|
||||
backend: z.literal("gcs"),
|
||||
bucket: z.string().min(1),
|
||||
projectId: z.string().min(1),
|
||||
credentialsJson: z.string().min(1),
|
||||
})
|
||||
.extend(baseRepositoryConfigSchema.shape);
|
||||
|
||||
export const azureRepositoryConfigSchema = type({
|
||||
backend: "'azure'",
|
||||
container: "string",
|
||||
accountName: "string",
|
||||
accountKey: "string",
|
||||
endpointSuffix: "string?",
|
||||
}).and(baseRepositoryConfigSchema);
|
||||
export const azureRepositoryConfigSchema = z
|
||||
.object({
|
||||
backend: z.literal("azure"),
|
||||
container: z.string().min(1),
|
||||
accountName: z.string().min(1),
|
||||
accountKey: z.string().min(1),
|
||||
endpointSuffix: z.string().optional(),
|
||||
})
|
||||
.extend(baseRepositoryConfigSchema.shape);
|
||||
|
||||
export const rcloneRepositoryConfigSchema = type({
|
||||
backend: "'rclone'",
|
||||
remote: "string",
|
||||
path: "string",
|
||||
}).and(baseRepositoryConfigSchema);
|
||||
export const rcloneRepositoryConfigSchema = z
|
||||
.object({
|
||||
backend: z.literal("rclone"),
|
||||
remote: z.string().min(1),
|
||||
path: z.string().min(1),
|
||||
})
|
||||
.extend(baseRepositoryConfigSchema.shape);
|
||||
|
||||
export const restRepositoryConfigSchema = type({
|
||||
backend: "'rest'",
|
||||
url: "string",
|
||||
username: "string?",
|
||||
password: "string?",
|
||||
path: "string?",
|
||||
}).and(baseRepositoryConfigSchema);
|
||||
export const restRepositoryConfigSchema = z
|
||||
.object({
|
||||
backend: z.literal("rest"),
|
||||
url: z.string().min(1),
|
||||
username: z.string().optional(),
|
||||
password: z.string().optional(),
|
||||
path: z.string().optional(),
|
||||
})
|
||||
.extend(baseRepositoryConfigSchema.shape);
|
||||
|
||||
export const sftpRepositoryConfigSchema = type({
|
||||
backend: "'sftp'",
|
||||
host: "string",
|
||||
port: type("string.integer").or(type("number")).to("1 <= number <= 65535").default(22),
|
||||
user: "string",
|
||||
path: "string",
|
||||
privateKey: "string",
|
||||
skipHostKeyCheck: "boolean = true",
|
||||
knownHosts: "string?",
|
||||
}).and(baseRepositoryConfigSchema);
|
||||
export const sftpRepositoryConfigSchema = z
|
||||
.object({
|
||||
backend: z.literal("sftp"),
|
||||
host: z.string().min(1),
|
||||
port: z
|
||||
.union([z.string(), z.number()])
|
||||
.transform((value) => (typeof value === "string" ? Number.parseInt(value, 10) : value))
|
||||
.pipe(z.number().int().min(1).max(65535))
|
||||
.default(22),
|
||||
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
|
||||
.or(r2RepositoryConfigSchema)
|
||||
.or(localRepositoryConfigSchema)
|
||||
.or(gcsRepositoryConfigSchema)
|
||||
.or(azureRepositoryConfigSchema)
|
||||
.or(rcloneRepositoryConfigSchema)
|
||||
.or(restRepositoryConfigSchema)
|
||||
.or(sftpRepositoryConfigSchema);
|
||||
export const repositoryConfigSchemaBase = z.discriminatedUnion("backend", [
|
||||
s3RepositoryConfigSchema,
|
||||
r2RepositoryConfigSchema,
|
||||
localRepositoryConfigSchema,
|
||||
gcsRepositoryConfigSchema,
|
||||
azureRepositoryConfigSchema,
|
||||
rcloneRepositoryConfigSchema,
|
||||
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 = {
|
||||
off: "off",
|
||||
|
|
@ -132,22 +154,22 @@ export const REPOSITORY_STATUS = {
|
|||
|
||||
export type RepositoryStatus = keyof typeof REPOSITORY_STATUS;
|
||||
|
||||
export const doctorStepSchema = type({
|
||||
step: "string",
|
||||
success: "boolean",
|
||||
output: "string | null",
|
||||
error: "string | null",
|
||||
export const doctorStepSchema = z.object({
|
||||
step: z.string(),
|
||||
success: z.boolean(),
|
||||
output: z.string().nullable(),
|
||||
error: z.string().nullable(),
|
||||
});
|
||||
|
||||
export type DoctorStep = typeof doctorStepSchema.infer;
|
||||
export type DoctorStep = z.infer<typeof doctorStepSchema>;
|
||||
|
||||
export const doctorResultSchema = type({
|
||||
success: "boolean",
|
||||
export const doctorResultSchema = z.object({
|
||||
success: z.boolean(),
|
||||
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 = {
|
||||
always: "always",
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { type } from "arktype";
|
||||
import { z } from "zod";
|
||||
|
||||
export const BACKEND_TYPES = {
|
||||
nfs: "nfs",
|
||||
|
|
@ -11,75 +11,93 @@ export const BACKEND_TYPES = {
|
|||
|
||||
export type BackendType = keyof typeof BACKEND_TYPES;
|
||||
|
||||
export const nfsConfigSchema = type({
|
||||
backend: "'nfs'",
|
||||
server: "string",
|
||||
exportPath: "string",
|
||||
port: type("string.integer").or(type("number")).to("1 <= number <= 65536").default(2049),
|
||||
version: "'3' | '4' | '4.1'",
|
||||
readOnly: "boolean?",
|
||||
export const nfsConfigSchema = z.object({
|
||||
backend: z.literal("nfs"),
|
||||
server: z.string().min(1),
|
||||
exportPath: z.string().min(1),
|
||||
port: z
|
||||
.union([z.string(), z.number()])
|
||||
.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({
|
||||
backend: "'smb'",
|
||||
server: "string",
|
||||
share: "string",
|
||||
username: "string?",
|
||||
password: "string?",
|
||||
guest: "boolean?",
|
||||
vers: type("'1.0' | '2.0' | '2.1' | '3.0' | 'auto'").default("auto"),
|
||||
domain: "string?",
|
||||
port: type("string.integer").or(type("number")).to("1 <= number <= 65535").default(445),
|
||||
readOnly: "boolean?",
|
||||
export const smbConfigSchema = z.object({
|
||||
backend: z.literal("smb"),
|
||||
server: z.string().min(1),
|
||||
share: z.string().min(1),
|
||||
username: z.string().optional(),
|
||||
password: z.string().optional(),
|
||||
guest: z.boolean().optional(),
|
||||
vers: z.enum(["1.0", "2.0", "2.1", "3.0", "auto"]).default("auto"),
|
||||
domain: z.string().optional(),
|
||||
port: z
|
||||
.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({
|
||||
backend: "'directory'",
|
||||
path: "string",
|
||||
readOnly: "false?",
|
||||
export const directoryConfigSchema = z.object({
|
||||
backend: z.literal("directory"),
|
||||
path: z.string().min(1),
|
||||
readOnly: z.literal(false).optional(),
|
||||
});
|
||||
|
||||
export const webdavConfigSchema = type({
|
||||
backend: "'webdav'",
|
||||
server: "string",
|
||||
path: "string",
|
||||
username: "string?",
|
||||
password: "string?",
|
||||
port: type("string.integer").or(type("number")).to("1 <= number <= 65536").default(80),
|
||||
readOnly: "boolean?",
|
||||
ssl: "boolean?",
|
||||
export const webdavConfigSchema = z.object({
|
||||
backend: z.literal("webdav"),
|
||||
server: z.string().min(1),
|
||||
path: z.string().min(1),
|
||||
username: z.string().optional(),
|
||||
password: z.string().optional(),
|
||||
port: z
|
||||
.union([z.string(), z.number()])
|
||||
.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({
|
||||
backend: "'rclone'",
|
||||
remote: "string",
|
||||
path: "string",
|
||||
readOnly: "boolean?",
|
||||
export const rcloneConfigSchema = z.object({
|
||||
backend: z.literal("rclone"),
|
||||
remote: z.string().min(1),
|
||||
path: z.string().min(1),
|
||||
readOnly: z.boolean().optional(),
|
||||
});
|
||||
|
||||
export const sftpConfigSchema = type({
|
||||
backend: "'sftp'",
|
||||
host: "string",
|
||||
port: type("string.integer").or(type("number")).to("1 <= number <= 65535").default(22),
|
||||
username: "string",
|
||||
password: "string?",
|
||||
privateKey: "string?",
|
||||
path: "string",
|
||||
readOnly: "boolean?",
|
||||
skipHostKeyCheck: "boolean = true",
|
||||
knownHosts: "string?",
|
||||
export const sftpConfigSchema = z.object({
|
||||
backend: z.literal("sftp"),
|
||||
host: z.string().min(1),
|
||||
port: z
|
||||
.union([z.string(), z.number()])
|
||||
.transform((value) => (typeof value === "string" ? Number.parseInt(value, 10) : value))
|
||||
.pipe(z.number().int().min(1).max(65535))
|
||||
.default(22),
|
||||
username: z.string().min(1),
|
||||
password: z.string().optional(),
|
||||
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
|
||||
.or(smbConfigSchema)
|
||||
.or(webdavConfigSchema)
|
||||
.or(directoryConfigSchema)
|
||||
.or(rcloneConfigSchema)
|
||||
.or(sftpConfigSchema);
|
||||
export const volumeConfigSchemaBase = z.discriminatedUnion("backend", [
|
||||
nfsConfigSchema,
|
||||
smbConfigSchema,
|
||||
webdavConfigSchema,
|
||||
directoryConfigSchema,
|
||||
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 = {
|
||||
mounted: "mounted",
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import { secureHeaders } from "hono/secure-headers";
|
|||
import { rateLimiter } from "hono-rate-limiter";
|
||||
import { openAPIRouteHandler } from "hono-openapi";
|
||||
import { authController } from "./modules/auth/auth.controller";
|
||||
import { ssoController } from "./modules/sso/sso.controller";
|
||||
import { requireAuth } from "./modules/auth/auth.middleware";
|
||||
import { repositoriesController } from "./modules/repositories/repositories.controller";
|
||||
import { systemController } from "./modules/system/system.controller";
|
||||
|
|
@ -74,6 +75,7 @@ export const createApp = () => {
|
|||
app
|
||||
.get("/api/healthcheck", (c) => c.json({ status: "ok" }))
|
||||
.route("/api/v1/auth", authController)
|
||||
.route("/api/v1/auth", ssoController)
|
||||
.route("/api/v1/volumes", volumeController)
|
||||
.route("/api/v1/repositories", repositoriesController)
|
||||
.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