Merge branch 'main' into feat/snapshot-source-info

This commit is contained in:
Nico 2026-03-11 19:29:54 +01:00 committed by GitHub
commit fb523aae44
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
183 changed files with 13797 additions and 5576 deletions

View file

@ -17,20 +17,20 @@ jobs:
uses: actions/checkout@v6 uses: actions/checkout@v6
- name: Log in to Docker Hub - name: Log in to Docker Hub
uses: docker/login-action@v3 uses: docker/login-action@v4
with: with:
username: ${{ secrets.DOCKERHUB_USERNAME }} username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }} password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Set up Docker Buildx - name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3 uses: docker/setup-buildx-action@v4
with: with:
driver: cloud driver: cloud
endpoint: "meienberger/runtipi-builder" endpoint: "meienberger/runtipi-builder"
install: true install: true
- name: Login to GitHub Container Registry - name: Login to GitHub Container Registry
uses: docker/login-action@v3 uses: docker/login-action@v4
with: with:
registry: ghcr.io registry: ghcr.io
username: ${{ github.repository_owner }} username: ${{ github.repository_owner }}
@ -38,14 +38,14 @@ jobs:
- name: Docker meta - name: Docker meta
id: meta id: meta
uses: docker/metadata-action@v5 uses: docker/metadata-action@v6
with: with:
images: ghcr.io/${{ github.repository_owner }}/zerobyte images: ghcr.io/${{ github.repository_owner }}/zerobyte
tags: | tags: |
type=raw,value=nightly type=raw,value=nightly
- name: Push images to GitHub Container Registry - name: Push images to GitHub Container Registry
uses: docker/build-push-action@v6 uses: docker/build-push-action@v7
with: with:
context: . context: .
target: production target: production

View file

@ -49,27 +49,27 @@ jobs:
ref: ${{ github.ref }} ref: ${{ github.ref }}
- name: Log in to Docker Hub - name: Log in to Docker Hub
uses: docker/login-action@v3 uses: docker/login-action@v4
with: with:
username: ${{ secrets.DOCKERHUB_USERNAME }} username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }} password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Set up Docker Buildx - name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3 uses: docker/setup-buildx-action@v4
with: with:
driver: cloud driver: cloud
endpoint: "meienberger/runtipi-builder" endpoint: "meienberger/runtipi-builder"
install: true install: true
- name: Login to GitHub Container Registry - name: Login to GitHub Container Registry
uses: docker/login-action@v3 uses: docker/login-action@v4
with: with:
registry: ghcr.io registry: ghcr.io
username: ${{ github.repository_owner }} username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }} password: ${{ secrets.GITHUB_TOKEN }}
- name: Build docker image - name: Build docker image
uses: docker/build-push-action@v6 uses: docker/build-push-action@v7
with: with:
context: . context: .
target: production target: production
@ -98,7 +98,7 @@ jobs:
- name: Docker meta - name: Docker meta
id: meta id: meta
uses: docker/metadata-action@v5 uses: docker/metadata-action@v6
with: with:
images: ghcr.io/${{ github.repository_owner }}/zerobyte images: ghcr.io/${{ github.repository_owner }}/zerobyte
tags: | tags: |
@ -110,7 +110,7 @@ jobs:
latest=${{ needs.determine-release-type.outputs.release_type == 'release' }} latest=${{ needs.determine-release-type.outputs.release_type == 'release' }}
- name: Push images to GitHub Container Registry - name: Push images to GitHub Container Registry
uses: docker/build-push-action@v6 uses: docker/build-push-action@v7
with: with:
context: . context: .
target: production target: production

1
.gitignore vendored
View file

@ -41,3 +41,4 @@ openapi-ts-error-*.log
tmp/ tmp/
qa-output qa-output
.worktrees/

View file

@ -50,6 +50,7 @@
"no-unused-vars": [ "no-unused-vars": [
"warn", "warn",
{ {
"varsIgnorePattern": "^_",
"caughtErrorsIgnorePattern": "^_", "caughtErrorsIgnorePattern": "^_",
"argsIgnorePattern": "^_" "argsIgnorePattern": "^_"
} }
@ -114,6 +115,7 @@
"unicorn/prefer-set-size": "warn", "unicorn/prefer-set-size": "warn",
"unicorn/prefer-string-starts-ends-with": "warn", "unicorn/prefer-string-starts-ends-with": "warn",
"import/no-cycle": "error", "import/no-cycle": "error",
"import/no-unused-modules": ["error", { "unusedExports": true }],
"eslint/no-console": ["warn", { "allow": ["warn", "error", "info"] }] "eslint/no-console": ["warn", { "allow": ["warn", "error", "info"] }]
}, },
"settings": { "settings": {

3
.vscode/settings.json vendored Normal file
View file

@ -0,0 +1,3 @@
{
"editor.defaultFormatter": "oxc.oxc-vscode"
}

View file

@ -12,7 +12,7 @@ ENV VITE_RESTIC_VERSION=${RESTIC_VERSION} \
RUN apk update --no-cache && \ RUN apk update --no-cache && \
apk upgrade --no-cache && \ apk upgrade --no-cache && \
apk add --no-cache davfs2=1.6.1-r2 openssh-client fuse3 sshfs tini apk add --no-cache davfs2=1.6.1-r2 openssh-client fuse3 sshfs tini tzdata
ENTRYPOINT ["/sbin/tini", "-s", "--"] ENTRYPOINT ["/sbin/tini", "-s", "--"]

View file

@ -40,7 +40,7 @@ In order to run Zerobyte, you need to have Docker and Docker Compose installed o
```yaml ```yaml
services: services:
zerobyte: zerobyte:
image: ghcr.io/nicotsx/zerobyte:v0.29 image: ghcr.io/nicotsx/zerobyte:v0.30
container_name: zerobyte container_name: zerobyte
restart: unless-stopped restart: unless-stopped
cap_add: cap_add:
@ -50,7 +50,7 @@ services:
devices: devices:
- /dev/fuse:/dev/fuse - /dev/fuse:/dev/fuse
environment: environment:
- TZ=Europe/Paris # Set your timezone here - TZ=Europe/Zurich # Set your timezone here
- BASE_URL=http://localhost:4096 # URL you will use to access Zerobyte - BASE_URL=http://localhost:4096 # URL you will use to access Zerobyte
- APP_SECRET=94bad46...c66e25d5c2b # Generate your own secret with `openssl rand -hex 32` - APP_SECRET=94bad46...c66e25d5c2b # Generate your own secret with `openssl rand -hex 32`
volumes: volumes:
@ -95,7 +95,7 @@ Zerobyte can be customized using environment variables. Below are the available
| `APP_SECRET` | **Required.** A random secret key (32+ chars) used to encrypt sensitive data in the database. Generate with `openssl rand -hex 32`. | (none) | | `APP_SECRET` | **Required.** A random secret key (32+ chars) used to encrypt sensitive data in the database. Generate with `openssl rand -hex 32`. | (none) |
| `PORT` | The port the web interface and API will listen on. | `4096` | | `PORT` | The port the web interface and API will listen on. | `4096` |
| `RESTIC_HOSTNAME` | The hostname used by Restic when creating snapshots. Automatically detected if a custom hostname is set in Docker. | `zerobyte` | | `RESTIC_HOSTNAME` | The hostname used by Restic when creating snapshots. Automatically detected if a custom hostname is set in Docker. | `zerobyte` |
| `TZ` | Timezone for the container (e.g., `Europe/Paris`). **Crucial for accurate backup scheduling.** | `UTC` | | `TZ` | Timezone for the container (e.g., `Europe/Zurich`). **Crucial for accurate backup scheduling.** | `UTC` |
| `TRUSTED_ORIGINS` | Comma-separated list of extra trusted origins for CORS (e.g., `http://localhost:3000,http://example.com`). | (none) | | `TRUSTED_ORIGINS` | Comma-separated list of extra trusted origins for CORS (e.g., `http://localhost:3000,http://example.com`). | (none) |
| `LOG_LEVEL` | Logging verbosity. Options: `debug`, `info`, `warn`, `error`. | `info` | | `LOG_LEVEL` | Logging verbosity. Options: `debug`, `info`, `warn`, `error`. | `info` |
| `SERVER_IDLE_TIMEOUT` | Idle timeout for the server in seconds. | `60` | | `SERVER_IDLE_TIMEOUT` | Idle timeout for the server in seconds. | `60` |
@ -118,13 +118,13 @@ If you only need to back up locally mounted folders and don't require remote sha
```yaml ```yaml
services: services:
zerobyte: zerobyte:
image: ghcr.io/nicotsx/zerobyte:v0.29 image: ghcr.io/nicotsx/zerobyte:v0.30
container_name: zerobyte container_name: zerobyte
restart: unless-stopped restart: unless-stopped
ports: ports:
- "4096:4096" - "4096:4096"
environment: environment:
- TZ=Europe/Paris # Set your timezone here - TZ=Europe/Zurich # Set your timezone here
- BASE_URL=http://localhost:4096 # Change this to your actual URL (use https:// for secure cookies) - BASE_URL=http://localhost:4096 # Change this to your actual URL (use https:// for secure cookies)
- APP_SECRET=94bad46...c66e25d5c2b # Generate your own secret with `openssl rand -hex 32` - APP_SECRET=94bad46...c66e25d5c2b # Generate your own secret with `openssl rand -hex 32`
volumes: volumes:
@ -157,7 +157,7 @@ If you want to track a local directory on the same server where Zerobyte is runn
```diff ```diff
services: services:
zerobyte: zerobyte:
image: ghcr.io/nicotsx/zerobyte:v0.29 image: ghcr.io/nicotsx/zerobyte:v0.30
container_name: zerobyte container_name: zerobyte
restart: unless-stopped restart: unless-stopped
cap_add: cap_add:
@ -167,7 +167,7 @@ services:
devices: devices:
- /dev/fuse:/dev/fuse - /dev/fuse:/dev/fuse
environment: environment:
- TZ=Europe/Paris - TZ=Europe/Zurich
- BASE_URL=http://localhost:4096 # URL you will use to access Zerobyte - BASE_URL=http://localhost:4096 # URL you will use to access Zerobyte
- APP_SECRET=94bad46...c66e25d5c2b # Generate your own secret with `openssl rand -hex 32` - APP_SECRET=94bad46...c66e25d5c2b # Generate your own secret with `openssl rand -hex 32`
volumes: volumes:
@ -232,7 +232,7 @@ Zerobyte can use [rclone](https://rclone.org/) to support 40+ cloud storage prov
```diff ```diff
services: services:
zerobyte: zerobyte:
image: ghcr.io/nicotsx/zerobyte:v0.29 image: ghcr.io/nicotsx/zerobyte:v0.30
container_name: zerobyte container_name: zerobyte
restart: unless-stopped restart: unless-stopped
cap_add: cap_add:
@ -242,7 +242,7 @@ Zerobyte can use [rclone](https://rclone.org/) to support 40+ cloud storage prov
devices: devices:
- /dev/fuse:/dev/fuse - /dev/fuse:/dev/fuse
environment: environment:
- TZ=Europe/Paris - TZ=Europe/Zurich
- BASE_URL=http://localhost:4096 # URL you will use to access Zerobyte - BASE_URL=http://localhost:4096 # URL you will use to access Zerobyte
volumes: volumes:
- /etc/localtime:/etc/localtime:ro - /etc/localtime:/etc/localtime:ro

View file

@ -37,7 +37,6 @@ body {
} }
body { body {
@apply bg-[#131313];
min-height: 100dvh; min-height: 100dvh;
} }
@ -67,6 +66,8 @@ body {
--color-accent: var(--accent); --color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground); --color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive); --color-destructive: var(--destructive);
--color-destructive-foreground: var(--destructive-foreground);
--color-success: var(--success);
--color-border: var(--border); --color-border: var(--border);
--color-input: var(--input); --color-input: var(--input);
--color-ring: var(--ring); --color-ring: var(--ring);
@ -88,38 +89,40 @@ body {
:root { :root {
--radius: 0.625rem; --radius: 0.625rem;
--background: oklch(1 0 0); --background: oklch(0.98 0.002 250);
--foreground: oklch(0.145 0 0); --foreground: oklch(0.20 0.01 260);
--card: oklch(1 0 0); --card: #ffffff;
--card-foreground: oklch(0.145 0 0); --card-foreground: oklch(0.20 0.01 260);
--card-header: oklch(0.922 0 0); --card-header: oklch(0.99 0.001 260);
--popover: oklch(1 0 0); --popover: #ffffff;
--popover-foreground: oklch(0.145 0 0); --popover-foreground: oklch(0.20 0.01 260);
--primary: oklch(0.205 0 0); --primary: oklch(0.20 0.01 260);
--primary-foreground: oklch(0.985 0 0); --primary-foreground: #ffffff;
--secondary: oklch(0.97 0 0); --secondary: oklch(0.96 0.005 260);
--secondary-foreground: oklch(0.205 0 0); --secondary-foreground: oklch(0.20 0.01 260);
--muted: oklch(0.97 0 0); --muted: oklch(0.95 0.005 260);
--muted-foreground: oklch(0.556 0 0); --muted-foreground: oklch(0.45 0.01 260);
--accent: oklch(0.97 0 0); --accent: oklch(0.96 0.005 260);
--accent-foreground: oklch(0.205 0 0); --accent-foreground: oklch(0.20 0.01 260);
--destructive: oklch(0.577 0.245 27.325); --destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.922 0 0); --destructive-foreground: #fff;
--input: oklch(0.922 0 0); --success: oklch(0.57 0.16 148);
--ring: oklch(0.708 0 0); --border: oklch(0.90 0.01 260);
--chart-1: oklch(0.646 0.222 41.116); --input: oklch(0.90 0.01 260);
--chart-2: oklch(0.6 0.118 184.704); --ring: oklch(0.7 0.01 260);
--chart-3: oklch(0.398 0.07 227.392); --chart-1: oklch(0.55 0.22 260);
--chart-4: oklch(0.828 0.189 84.429); --chart-2: oklch(0.6 0.15 200);
--chart-5: oklch(0.769 0.188 70.08); --chart-3: oklch(0.45 0.12 280);
--sidebar: oklch(0.985 0 0); --chart-4: oklch(0.7 0.15 190);
--sidebar-foreground: oklch(0.145 0 0); --chart-5: oklch(0.55 0.18 300);
--sidebar-primary: oklch(0.205 0 0); --sidebar: #ffffff;
--sidebar-primary-foreground: oklch(0.985 0 0); --sidebar-foreground: oklch(0.35 0.01 260);
--sidebar-accent: oklch(0.97 0 0); --sidebar-primary: oklch(0.20 0.01 260);
--sidebar-accent-foreground: oklch(0.205 0 0); --sidebar-primary-foreground: #ffffff;
--sidebar-border: oklch(0.922 0 0); --sidebar-accent: oklch(0.97 0.005 260);
--sidebar-ring: oklch(0.708 0 0); --sidebar-accent-foreground: oklch(0.20 0.01 260);
--sidebar-border: oklch(0.90 0.01 260);
--sidebar-ring: oklch(0.7 0.01 260);
--strong-accent: #ff543a; --strong-accent: #ff543a;
} }
@ -144,6 +147,8 @@ body {
--accent: oklch(0.269 0 0); --accent: oklch(0.269 0 0);
--accent-foreground: oklch(0.985 0 0); --accent-foreground: oklch(0.985 0 0);
--destructive: #ff543a; --destructive: #ff543a;
--destructive-foreground: #fff;
--success: oklch(0.696 0.17 162.48);
--border: oklch(1 0 0 / 10%); --border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%); --input: oklch(1 0 0 / 15%);
--ring: oklch(0.556 0 0); --ring: oklch(0.556 0 0);
@ -174,11 +179,11 @@ body {
@layer base { @layer base {
:root { :root {
--chart-1: oklch(0.646 0.222 41.116); --chart-1: oklch(0.55 0.22 260);
--chart-2: oklch(0.6 0.118 184.704); --chart-2: oklch(0.6 0.15 200);
--chart-3: oklch(0.398 0.07 227.392); --chart-3: oklch(0.45 0.12 280);
--chart-4: oklch(0.828 0.189 84.429); --chart-4: oklch(0.7 0.15 190);
--chart-5: oklch(0.769 0.188 70.08); --chart-5: oklch(0.55 0.18 300);
} }
.dark { .dark {
@ -190,6 +195,17 @@ body {
} }
} }
.snapshot-scrollable::-webkit-scrollbar {
height: 6px;
}
.snapshot-scrollable::-webkit-scrollbar-track {
background: transparent;
}
.snapshot-scrollable::-webkit-scrollbar-thumb {
background: var(--border);
border-radius: 999px;
}
/* Hide built-in password reveal/clear controls (notably in Edge on Windows) */ /* Hide built-in password reveal/clear controls (notably in Edge on Windows) */
[data-secret-input] input[type="password"]::-ms-reveal, [data-secret-input] input[type="password"]::-ms-reveal,
[data-secret-input] input[type="password"]::-ms-clear { [data-secret-input] input[type="password"]::-ms-clear {

View file

@ -4,8 +4,8 @@
import { type DefaultError, type InfiniteData, infiniteQueryOptions, queryOptions, type UseMutationOptions } from '@tanstack/react-query'; import { type DefaultError, type InfiniteData, infiniteQueryOptions, queryOptions, type UseMutationOptions } from '@tanstack/react-query';
import { client } from '../client.gen'; import { client } from '../client.gen';
import { browseFilesystem, cancelDoctor, createBackupSchedule, createNotificationDestination, createRepository, createVolume, deleteBackupSchedule, deleteNotificationDestination, deleteRepository, deleteSnapshot, deleteSnapshots, deleteSsoInvitation, deleteSsoProvider, deleteUserAccount, deleteVolume, devPanelExec, downloadResticPassword, dumpSnapshot, getAdminUsers, getBackupProgress, getBackupSchedule, getBackupScheduleForVolume, getDevPanel, getMirrorCompatibility, getNotificationDestination, getOrgMembers, getPublicSsoProviders, getRegistrationStatus, getRepository, getRepositoryStats, getScheduleMirrors, getScheduleNotifications, getSnapshotDetails, getSsoSettings, getStatus, getSystemInfo, getUpdates, getUserDeletionImpact, getVolume, healthCheckVolume, listBackupSchedules, listFiles, listNotificationDestinations, listRcloneRemotes, listRepositories, listSnapshotFiles, listSnapshots, listVolumes, mountVolume, type Options, refreshSnapshots, removeOrgMember, reorderBackupSchedules, restoreSnapshot, runBackupNow, runForget, setRegistrationStatus, startDoctor, stopBackup, tagSnapshots, testConnection, testNotificationDestination, unlockRepository, unmountVolume, updateBackupSchedule, updateMemberRole, updateNotificationDestination, updateRepository, updateScheduleMirrors, updateScheduleNotifications, updateSsoProviderAutoLinking, updateVolume } from '../sdk.gen'; import { browseFilesystem, cancelDoctor, createBackupSchedule, createNotificationDestination, createRepository, createVolume, deleteBackupSchedule, deleteNotificationDestination, deleteRepository, deleteSnapshot, deleteSnapshots, deleteSsoInvitation, deleteSsoProvider, deleteUserAccount, deleteVolume, downloadResticPassword, dumpSnapshot, getAdminUsers, getBackupProgress, getBackupSchedule, getBackupScheduleForVolume, getDevPanel, getMirrorCompatibility, getNotificationDestination, getOrgMembers, getPublicSsoProviders, getRegistrationStatus, getRepository, getRepositoryStats, getScheduleMirrors, getScheduleNotifications, getSnapshotDetails, getSsoSettings, getStatus, getSystemInfo, getUpdates, getUserDeletionImpact, getVolume, healthCheckVolume, listBackupSchedules, listFiles, listNotificationDestinations, listRcloneRemotes, listRepositories, listSnapshotFiles, listSnapshots, listVolumes, mountVolume, type Options, refreshRepositoryStats, refreshSnapshots, removeOrgMember, reorderBackupSchedules, restoreSnapshot, runBackupNow, runForget, setRegistrationStatus, startDoctor, stopBackup, tagSnapshots, testConnection, testNotificationDestination, unlockRepository, unmountVolume, updateBackupSchedule, updateMemberRole, updateNotificationDestination, updateRepository, updateScheduleMirrors, updateScheduleNotifications, updateSsoProviderAutoLinking, updateVolume } from '../sdk.gen';
import type { BrowseFilesystemData, BrowseFilesystemResponse, CancelDoctorData, CancelDoctorResponse, CreateBackupScheduleData, CreateBackupScheduleResponse, CreateNotificationDestinationData, CreateNotificationDestinationResponse, CreateRepositoryData, CreateRepositoryResponse, CreateVolumeData, CreateVolumeResponse, DeleteBackupScheduleData, DeleteBackupScheduleResponse, DeleteNotificationDestinationData, DeleteNotificationDestinationResponse, DeleteRepositoryData, DeleteRepositoryResponse, DeleteSnapshotData, DeleteSnapshotResponse, DeleteSnapshotsData, DeleteSnapshotsResponse, DeleteSsoInvitationData, DeleteSsoProviderData, DeleteUserAccountData, DeleteVolumeData, DeleteVolumeResponse, DevPanelExecData, DevPanelExecResponse, DownloadResticPasswordData, DownloadResticPasswordResponse, DumpSnapshotData, DumpSnapshotResponse, GetAdminUsersData, GetAdminUsersResponse, GetBackupProgressData, GetBackupProgressResponse, GetBackupScheduleData, GetBackupScheduleForVolumeData, GetBackupScheduleForVolumeResponse, GetBackupScheduleResponse, GetDevPanelData, GetDevPanelResponse, GetMirrorCompatibilityData, GetMirrorCompatibilityResponse, GetNotificationDestinationData, GetNotificationDestinationResponse, GetOrgMembersData, GetOrgMembersResponse, GetPublicSsoProvidersData, GetPublicSsoProvidersResponse, GetRegistrationStatusData, GetRegistrationStatusResponse, GetRepositoryData, GetRepositoryResponse, GetRepositoryStatsData, GetRepositoryStatsResponse, GetScheduleMirrorsData, GetScheduleMirrorsResponse, GetScheduleNotificationsData, GetScheduleNotificationsResponse, GetSnapshotDetailsData, GetSnapshotDetailsResponse, GetSsoSettingsData, GetSsoSettingsResponse, GetStatusData, GetStatusResponse, GetSystemInfoData, GetSystemInfoResponse, GetUpdatesData, GetUpdatesResponse, GetUserDeletionImpactData, GetUserDeletionImpactResponse, GetVolumeData, GetVolumeResponse, HealthCheckVolumeData, HealthCheckVolumeResponse, ListBackupSchedulesData, ListBackupSchedulesResponse, ListFilesData, ListFilesResponse, ListNotificationDestinationsData, ListNotificationDestinationsResponse, ListRcloneRemotesData, ListRcloneRemotesResponse, ListRepositoriesData, ListRepositoriesResponse, ListSnapshotFilesData, ListSnapshotFilesResponse, ListSnapshotsData, ListSnapshotsResponse, ListVolumesData, ListVolumesResponse, MountVolumeData, MountVolumeResponse, RefreshSnapshotsData, RefreshSnapshotsResponse, RemoveOrgMemberData, ReorderBackupSchedulesData, ReorderBackupSchedulesResponse, RestoreSnapshotData, RestoreSnapshotResponse, RunBackupNowData, RunBackupNowResponse, RunForgetData, RunForgetResponse, SetRegistrationStatusData, SetRegistrationStatusResponse, StartDoctorData, StartDoctorResponse, StopBackupData, StopBackupResponse, TagSnapshotsData, TagSnapshotsResponse, TestConnectionData, TestConnectionResponse, TestNotificationDestinationData, TestNotificationDestinationResponse, UnlockRepositoryData, UnlockRepositoryResponse, UnmountVolumeData, UnmountVolumeResponse, UpdateBackupScheduleData, UpdateBackupScheduleResponse, UpdateMemberRoleData, UpdateNotificationDestinationData, UpdateNotificationDestinationResponse, UpdateRepositoryData, UpdateRepositoryResponse, UpdateScheduleMirrorsData, UpdateScheduleMirrorsResponse, UpdateScheduleNotificationsData, UpdateScheduleNotificationsResponse, UpdateSsoProviderAutoLinkingData, UpdateVolumeData, UpdateVolumeResponse } from '../types.gen'; import type { BrowseFilesystemData, BrowseFilesystemResponse, CancelDoctorData, CancelDoctorResponse, CreateBackupScheduleData, CreateBackupScheduleResponse, CreateNotificationDestinationData, CreateNotificationDestinationResponse, CreateRepositoryData, CreateRepositoryResponse, CreateVolumeData, CreateVolumeResponse, DeleteBackupScheduleData, DeleteBackupScheduleResponse, DeleteNotificationDestinationData, DeleteNotificationDestinationResponse, DeleteRepositoryData, DeleteRepositoryResponse, DeleteSnapshotData, DeleteSnapshotResponse, DeleteSnapshotsData, DeleteSnapshotsResponse, DeleteSsoInvitationData, DeleteSsoProviderData, DeleteUserAccountData, DeleteVolumeData, DeleteVolumeResponse, DownloadResticPasswordData, DownloadResticPasswordResponse, DumpSnapshotData, DumpSnapshotResponse, GetAdminUsersData, GetAdminUsersResponse, GetBackupProgressData, GetBackupProgressResponse, GetBackupScheduleData, GetBackupScheduleForVolumeData, GetBackupScheduleForVolumeResponse, GetBackupScheduleResponse, GetDevPanelData, GetDevPanelResponse, GetMirrorCompatibilityData, GetMirrorCompatibilityResponse, GetNotificationDestinationData, GetNotificationDestinationResponse, GetOrgMembersData, GetOrgMembersResponse, GetPublicSsoProvidersData, GetPublicSsoProvidersResponse, GetRegistrationStatusData, GetRegistrationStatusResponse, GetRepositoryData, GetRepositoryResponse, GetRepositoryStatsData, GetRepositoryStatsResponse, GetScheduleMirrorsData, GetScheduleMirrorsResponse, GetScheduleNotificationsData, GetScheduleNotificationsResponse, GetSnapshotDetailsData, GetSnapshotDetailsResponse, GetSsoSettingsData, GetSsoSettingsResponse, GetStatusData, GetStatusResponse, GetSystemInfoData, GetSystemInfoResponse, GetUpdatesData, GetUpdatesResponse, GetUserDeletionImpactData, GetUserDeletionImpactResponse, GetVolumeData, GetVolumeResponse, HealthCheckVolumeData, HealthCheckVolumeResponse, ListBackupSchedulesData, ListBackupSchedulesResponse, ListFilesData, ListFilesResponse, ListNotificationDestinationsData, ListNotificationDestinationsResponse, ListRcloneRemotesData, ListRcloneRemotesResponse, ListRepositoriesData, ListRepositoriesResponse, ListSnapshotFilesData, ListSnapshotFilesResponse, ListSnapshotsData, ListSnapshotsResponse, ListVolumesData, ListVolumesResponse, MountVolumeData, MountVolumeResponse, RefreshRepositoryStatsData, RefreshRepositoryStatsResponse, RefreshSnapshotsData, RefreshSnapshotsResponse, RemoveOrgMemberData, ReorderBackupSchedulesData, ReorderBackupSchedulesResponse, RestoreSnapshotData, RestoreSnapshotResponse, RunBackupNowData, RunBackupNowResponse, RunForgetData, RunForgetResponse, SetRegistrationStatusData, SetRegistrationStatusResponse, StartDoctorData, StartDoctorResponse, StopBackupData, StopBackupResponse, TagSnapshotsData, TagSnapshotsResponse, TestConnectionData, TestConnectionResponse, TestNotificationDestinationData, TestNotificationDestinationResponse, UnlockRepositoryData, UnlockRepositoryResponse, UnmountVolumeData, UnmountVolumeResponse, UpdateBackupScheduleData, UpdateBackupScheduleResponse, UpdateMemberRoleData, UpdateNotificationDestinationData, UpdateNotificationDestinationResponse, UpdateRepositoryData, UpdateRepositoryResponse, UpdateScheduleMirrorsData, UpdateScheduleMirrorsResponse, UpdateScheduleNotificationsData, UpdateScheduleNotificationsResponse, UpdateSsoProviderAutoLinkingData, UpdateVolumeData, UpdateVolumeResponse } from '../types.gen';
export type QueryKey<TOptions extends Options> = [ export type QueryKey<TOptions extends Options> = [
Pick<TOptions, 'baseUrl' | 'body' | 'headers' | 'path' | 'query'> & { Pick<TOptions, 'baseUrl' | 'body' | 'headers' | 'path' | 'query'> & {
@ -58,93 +58,6 @@ export const getStatusOptions = (options?: Options<GetStatusData>) => queryOptio
queryKey: getStatusQueryKey(options) queryKey: getStatusQueryKey(options)
}); });
export const getPublicSsoProvidersQueryKey = (options?: Options<GetPublicSsoProvidersData>) => createQueryKey('getPublicSsoProviders', options);
/**
* Get public SSO providers for the instance
*/
export const getPublicSsoProvidersOptions = (options?: Options<GetPublicSsoProvidersData>) => queryOptions<GetPublicSsoProvidersResponse, DefaultError, GetPublicSsoProvidersResponse, ReturnType<typeof getPublicSsoProvidersQueryKey>>({
queryFn: async ({ queryKey, signal }) => {
const { data } = await getPublicSsoProviders({
...options,
...queryKey[0],
signal,
throwOnError: true
});
return data;
},
queryKey: getPublicSsoProvidersQueryKey(options)
});
export const getSsoSettingsQueryKey = (options?: Options<GetSsoSettingsData>) => createQueryKey('getSsoSettings', options);
/**
* Get SSO providers and invitations for the active organization
*/
export const getSsoSettingsOptions = (options?: Options<GetSsoSettingsData>) => queryOptions<GetSsoSettingsResponse, DefaultError, GetSsoSettingsResponse, ReturnType<typeof getSsoSettingsQueryKey>>({
queryFn: async ({ queryKey, signal }) => {
const { data } = await getSsoSettings({
...options,
...queryKey[0],
signal,
throwOnError: true
});
return data;
},
queryKey: getSsoSettingsQueryKey(options)
});
/**
* Delete an SSO provider
*/
export const deleteSsoProviderMutation = (options?: Partial<Options<DeleteSsoProviderData>>): UseMutationOptions<unknown, DefaultError, Options<DeleteSsoProviderData>> => {
const mutationOptions: UseMutationOptions<unknown, DefaultError, Options<DeleteSsoProviderData>> = {
mutationFn: async (fnOptions) => {
const { data } = await deleteSsoProvider({
...options,
...fnOptions,
throwOnError: true
});
return data;
}
};
return mutationOptions;
};
/**
* Update whether SSO sign-in can auto-link existing accounts by email
*/
export const updateSsoProviderAutoLinkingMutation = (options?: Partial<Options<UpdateSsoProviderAutoLinkingData>>): UseMutationOptions<unknown, DefaultError, Options<UpdateSsoProviderAutoLinkingData>> => {
const mutationOptions: UseMutationOptions<unknown, DefaultError, Options<UpdateSsoProviderAutoLinkingData>> = {
mutationFn: async (fnOptions) => {
const { data } = await updateSsoProviderAutoLinking({
...options,
...fnOptions,
throwOnError: true
});
return data;
}
};
return mutationOptions;
};
/**
* Delete an SSO invitation
*/
export const deleteSsoInvitationMutation = (options?: Partial<Options<DeleteSsoInvitationData>>): UseMutationOptions<unknown, DefaultError, Options<DeleteSsoInvitationData>> => {
const mutationOptions: UseMutationOptions<unknown, DefaultError, Options<DeleteSsoInvitationData>> = {
mutationFn: async (fnOptions) => {
const { data } = await deleteSsoInvitation({
...options,
...fnOptions,
throwOnError: true
});
return data;
}
};
return mutationOptions;
};
export const getAdminUsersQueryKey = (options?: Options<GetAdminUsersData>) => createQueryKey('getAdminUsers', options); export const getAdminUsersQueryKey = (options?: Options<GetAdminUsersData>) => createQueryKey('getAdminUsers', options);
/** /**
@ -250,6 +163,93 @@ export const removeOrgMemberMutation = (options?: Partial<Options<RemoveOrgMembe
return mutationOptions; return mutationOptions;
}; };
export const getPublicSsoProvidersQueryKey = (options?: Options<GetPublicSsoProvidersData>) => createQueryKey('getPublicSsoProviders', options);
/**
* Get public SSO providers for the instance
*/
export const getPublicSsoProvidersOptions = (options?: Options<GetPublicSsoProvidersData>) => queryOptions<GetPublicSsoProvidersResponse, DefaultError, GetPublicSsoProvidersResponse, ReturnType<typeof getPublicSsoProvidersQueryKey>>({
queryFn: async ({ queryKey, signal }) => {
const { data } = await getPublicSsoProviders({
...options,
...queryKey[0],
signal,
throwOnError: true
});
return data;
},
queryKey: getPublicSsoProvidersQueryKey(options)
});
export const getSsoSettingsQueryKey = (options?: Options<GetSsoSettingsData>) => createQueryKey('getSsoSettings', options);
/**
* Get SSO providers and invitations for the active organization
*/
export const getSsoSettingsOptions = (options?: Options<GetSsoSettingsData>) => queryOptions<GetSsoSettingsResponse, DefaultError, GetSsoSettingsResponse, ReturnType<typeof getSsoSettingsQueryKey>>({
queryFn: async ({ queryKey, signal }) => {
const { data } = await getSsoSettings({
...options,
...queryKey[0],
signal,
throwOnError: true
});
return data;
},
queryKey: getSsoSettingsQueryKey(options)
});
/**
* Delete an SSO provider
*/
export const deleteSsoProviderMutation = (options?: Partial<Options<DeleteSsoProviderData>>): UseMutationOptions<unknown, DefaultError, Options<DeleteSsoProviderData>> => {
const mutationOptions: UseMutationOptions<unknown, DefaultError, Options<DeleteSsoProviderData>> = {
mutationFn: async (fnOptions) => {
const { data } = await deleteSsoProvider({
...options,
...fnOptions,
throwOnError: true
});
return data;
}
};
return mutationOptions;
};
/**
* Update whether SSO sign-in can auto-link existing accounts by email
*/
export const updateSsoProviderAutoLinkingMutation = (options?: Partial<Options<UpdateSsoProviderAutoLinkingData>>): UseMutationOptions<unknown, DefaultError, Options<UpdateSsoProviderAutoLinkingData>> => {
const mutationOptions: UseMutationOptions<unknown, DefaultError, Options<UpdateSsoProviderAutoLinkingData>> = {
mutationFn: async (fnOptions) => {
const { data } = await updateSsoProviderAutoLinking({
...options,
...fnOptions,
throwOnError: true
});
return data;
}
};
return mutationOptions;
};
/**
* Delete an SSO invitation
*/
export const deleteSsoInvitationMutation = (options?: Partial<Options<DeleteSsoInvitationData>>): UseMutationOptions<unknown, DefaultError, Options<DeleteSsoInvitationData>> => {
const mutationOptions: UseMutationOptions<unknown, DefaultError, Options<DeleteSsoInvitationData>> = {
mutationFn: async (fnOptions) => {
const { data } = await deleteSsoInvitation({
...options,
...fnOptions,
throwOnError: true
});
return data;
}
};
return mutationOptions;
};
export const listVolumesQueryKey = (options?: Options<ListVolumesData>) => createQueryKey('listVolumes', options); export const listVolumesQueryKey = (options?: Options<ListVolumesData>) => createQueryKey('listVolumes', options);
/** /**
@ -457,7 +457,7 @@ export const listFilesInfiniteQueryKey = (options: Options<ListFilesData>): Quer
/** /**
* List files in a volume directory * List files in a volume directory
*/ */
export const listFilesInfiniteOptions = (options: Options<ListFilesData>) => infiniteQueryOptions<ListFilesResponse, DefaultError, InfiniteData<ListFilesResponse>, QueryKey<Options<ListFilesData>>, string | Pick<QueryKey<Options<ListFilesData>>[0], 'body' | 'headers' | 'path' | 'query'>>( export const listFilesInfiniteOptions = (options: Options<ListFilesData>) => infiniteQueryOptions<ListFilesResponse, DefaultError, InfiniteData<ListFilesResponse>, QueryKey<Options<ListFilesData>>, number | Pick<QueryKey<Options<ListFilesData>>[0], 'body' | 'headers' | 'path' | 'query'>>(
// @ts-ignore // @ts-ignore
{ {
queryFn: async ({ pageParam, queryKey, signal }) => { queryFn: async ({ pageParam, queryKey, signal }) => {
@ -620,6 +620,23 @@ export const getRepositoryStatsOptions = (options: Options<GetRepositoryStatsDat
queryKey: getRepositoryStatsQueryKey(options) queryKey: getRepositoryStatsQueryKey(options)
}); });
/**
* Refresh repository storage and compression statistics
*/
export const refreshRepositoryStatsMutation = (options?: Partial<Options<RefreshRepositoryStatsData>>): UseMutationOptions<RefreshRepositoryStatsResponse, DefaultError, Options<RefreshRepositoryStatsData>> => {
const mutationOptions: UseMutationOptions<RefreshRepositoryStatsResponse, DefaultError, Options<RefreshRepositoryStatsData>> = {
mutationFn: async (fnOptions) => {
const { data } = await refreshRepositoryStats({
...options,
...fnOptions,
throwOnError: true
});
return data;
}
};
return mutationOptions;
};
/** /**
* Delete multiple snapshots from a repository * Delete multiple snapshots from a repository
*/ */
@ -730,7 +747,7 @@ export const listSnapshotFilesInfiniteQueryKey = (options: Options<ListSnapshotF
/** /**
* List files and directories in a snapshot * List files and directories in a snapshot
*/ */
export const listSnapshotFilesInfiniteOptions = (options: Options<ListSnapshotFilesData>) => infiniteQueryOptions<ListSnapshotFilesResponse, DefaultError, InfiniteData<ListSnapshotFilesResponse>, QueryKey<Options<ListSnapshotFilesData>>, string | Pick<QueryKey<Options<ListSnapshotFilesData>>[0], 'body' | 'headers' | 'path' | 'query'>>( export const listSnapshotFilesInfiniteOptions = (options: Options<ListSnapshotFilesData>) => infiniteQueryOptions<ListSnapshotFilesResponse, DefaultError, InfiniteData<ListSnapshotFilesResponse>, QueryKey<Options<ListSnapshotFilesData>>, number | Pick<QueryKey<Options<ListSnapshotFilesData>>[0], 'body' | 'headers' | 'path' | 'query'>>(
// @ts-ignore // @ts-ignore
{ {
queryFn: async ({ pageParam, queryKey, signal }) => { queryFn: async ({ pageParam, queryKey, signal }) => {
@ -855,23 +872,6 @@ export const tagSnapshotsMutation = (options?: Partial<Options<TagSnapshotsData>
return mutationOptions; return mutationOptions;
}; };
/**
* Execute a restic command against a repository (dev panel only)
*/
export const devPanelExecMutation = (options?: Partial<Options<DevPanelExecData>>): UseMutationOptions<DevPanelExecResponse, DefaultError, Options<DevPanelExecData>> => {
const mutationOptions: UseMutationOptions<DevPanelExecResponse, DefaultError, Options<DevPanelExecData>> = {
mutationFn: async (fnOptions) => {
const { data } = await devPanelExec({
...options,
...fnOptions,
throwOnError: true
});
return data;
}
};
return mutationOptions;
};
export const listBackupSchedulesQueryKey = (options?: Options<ListBackupSchedulesData>) => createQueryKey('listBackupSchedules', options); export const listBackupSchedulesQueryKey = (options?: Options<ListBackupSchedulesData>) => createQueryKey('listBackupSchedules', options);
/** /**

View file

@ -38,7 +38,7 @@ export const createClient = (config: Config = {}): Client => {
...options, ...options,
fetch: options.fetch ?? _config.fetch ?? globalThis.fetch, fetch: options.fetch ?? _config.fetch ?? globalThis.fetch,
headers: mergeHeaders(_config.headers, options.headers), headers: mergeHeaders(_config.headers, options.headers),
serializedBody: undefined, serializedBody: undefined as string | undefined,
}; };
if (opts.security) { if (opts.security) {
@ -53,7 +53,7 @@ export const createClient = (config: Config = {}): Client => {
} }
if (opts.body !== undefined && opts.bodySerializer) { if (opts.body !== undefined && opts.bodySerializer) {
opts.serializedBody = opts.bodySerializer(opts.body); opts.serializedBody = opts.bodySerializer(opts.body) as string | undefined;
} }
// remove Content-Type header if body is empty to avoid sending invalid requests // remove Content-Type header if body is empty to avoid sending invalid requests
@ -259,8 +259,10 @@ export const createClient = (config: Config = {}): Client => {
}); });
}; };
const _buildUrl: Client['buildUrl'] = (options) => buildUrl({ ..._config, ...options });
return { return {
buildUrl, buildUrl: _buildUrl,
connect: makeMethodFn('CONNECT'), connect: makeMethodFn('CONNECT'),
delete: makeMethodFn('DELETE'), delete: makeMethodFn('DELETE'),
get: makeMethodFn('GET'), get: makeMethodFn('GET'),

View file

@ -5,7 +5,7 @@ import type { ArrayStyle, ObjectStyle, SerializerOptions } from './pathSerialize
export type QuerySerializer = (query: Record<string, unknown>) => string; export type QuerySerializer = (query: Record<string, unknown>) => string;
export type BodySerializer = (body: any) => any; export type BodySerializer = (body: unknown) => unknown;
type QuerySerializerOptionsObject = { type QuerySerializerOptionsObject = {
allowReserved?: boolean; allowReserved?: boolean;
@ -40,12 +40,10 @@ const serializeUrlSearchParamsPair = (data: URLSearchParams, key: string, value:
}; };
export const formDataBodySerializer = { export const formDataBodySerializer = {
bodySerializer: <T extends Record<string, any> | Array<Record<string, any>>>( bodySerializer: (body: unknown): FormData => {
body: T,
): FormData => {
const data = new FormData(); const data = new FormData();
Object.entries(body).forEach(([key, value]) => { Object.entries(body as Record<string, unknown>).forEach(([key, value]) => {
if (value === undefined || value === null) { if (value === undefined || value === null) {
return; return;
} }
@ -61,15 +59,15 @@ export const formDataBodySerializer = {
}; };
export const jsonBodySerializer = { export const jsonBodySerializer = {
bodySerializer: <T>(body: T): string => bodySerializer: (body: unknown): string =>
JSON.stringify(body, (_key, value) => (typeof value === 'bigint' ? value.toString() : value)), JSON.stringify(body, (_key, value) => (typeof value === 'bigint' ? value.toString() : value)),
}; };
export const urlSearchParamsBodySerializer = { export const urlSearchParamsBodySerializer = {
bodySerializer: <T extends Record<string, any> | Array<Record<string, any>>>(body: T): string => { bodySerializer: (body: unknown): string => {
const data = new URLSearchParams(); const data = new URLSearchParams();
Object.entries(body).forEach(([key, value]) => { Object.entries(body as Record<string, unknown>).forEach(([key, value]) => {
if (value === undefined || value === null) { if (value === undefined || value === null) {
return; return;
} }

View file

@ -97,7 +97,7 @@ interface Params {
const stripEmptySlots = (params: Params) => { const stripEmptySlots = (params: Params) => {
for (const [slot, value] of Object.entries(params)) { for (const [slot, value] of Object.entries(params)) {
if (value && typeof value === 'object' && !Object.keys(value).length) { if (value && typeof value === 'object' && !Array.isArray(value) && !Object.keys(value).length) {
delete params[slot as Slot]; delete params[slot as Slot];
} }
} }

View file

@ -52,6 +52,7 @@ export {
listVolumes, listVolumes,
mountVolume, mountVolume,
type Options, type Options,
refreshRepositoryStats,
refreshSnapshots, refreshSnapshots,
removeOrgMember, removeOrgMember,
reorderBackupSchedules, reorderBackupSchedules,
@ -230,6 +231,9 @@ export type {
MountVolumeData, MountVolumeData,
MountVolumeResponse, MountVolumeResponse,
MountVolumeResponses, MountVolumeResponses,
RefreshRepositoryStatsData,
RefreshRepositoryStatsResponse,
RefreshRepositoryStatsResponses,
RefreshSnapshotsData, RefreshSnapshotsData,
RefreshSnapshotsResponse, RefreshSnapshotsResponse,
RefreshSnapshotsResponses, RefreshSnapshotsResponses,

View file

@ -3,7 +3,7 @@
import type { Client, Options as Options2, TDataShape } from './client'; import type { Client, Options as Options2, TDataShape } from './client';
import { client } from './client.gen'; import { client } from './client.gen';
import type { BrowseFilesystemData, BrowseFilesystemResponses, CancelDoctorData, CancelDoctorErrors, CancelDoctorResponses, CreateBackupScheduleData, CreateBackupScheduleResponses, CreateNotificationDestinationData, CreateNotificationDestinationResponses, CreateRepositoryData, CreateRepositoryResponses, CreateVolumeData, CreateVolumeResponses, DeleteBackupScheduleData, DeleteBackupScheduleResponses, DeleteNotificationDestinationData, DeleteNotificationDestinationErrors, DeleteNotificationDestinationResponses, DeleteRepositoryData, DeleteRepositoryResponses, DeleteSnapshotData, DeleteSnapshotResponses, DeleteSnapshotsData, DeleteSnapshotsResponses, DeleteSsoInvitationData, DeleteSsoInvitationErrors, DeleteSsoInvitationResponses, DeleteSsoProviderData, DeleteSsoProviderErrors, DeleteSsoProviderResponses, DeleteUserAccountData, DeleteUserAccountErrors, DeleteUserAccountResponses, DeleteVolumeData, DeleteVolumeResponses, DevPanelExecData, DevPanelExecErrors, DevPanelExecResponses, DownloadResticPasswordData, DownloadResticPasswordResponses, DumpSnapshotData, DumpSnapshotResponses, GetAdminUsersData, GetAdminUsersResponses, GetBackupProgressData, GetBackupProgressResponses, GetBackupScheduleData, GetBackupScheduleForVolumeData, GetBackupScheduleForVolumeResponses, GetBackupScheduleResponses, GetDevPanelData, GetDevPanelResponses, GetMirrorCompatibilityData, GetMirrorCompatibilityResponses, GetNotificationDestinationData, GetNotificationDestinationErrors, GetNotificationDestinationResponses, GetOrgMembersData, GetOrgMembersResponses, GetPublicSsoProvidersData, GetPublicSsoProvidersResponses, GetRegistrationStatusData, GetRegistrationStatusResponses, GetRepositoryData, GetRepositoryResponses, GetRepositoryStatsData, GetRepositoryStatsResponses, GetScheduleMirrorsData, GetScheduleMirrorsResponses, GetScheduleNotificationsData, GetScheduleNotificationsResponses, GetSnapshotDetailsData, GetSnapshotDetailsResponses, GetSsoSettingsData, GetSsoSettingsResponses, GetStatusData, GetStatusResponses, GetSystemInfoData, GetSystemInfoResponses, GetUpdatesData, GetUpdatesResponses, GetUserDeletionImpactData, GetUserDeletionImpactResponses, GetVolumeData, GetVolumeErrors, GetVolumeResponses, HealthCheckVolumeData, HealthCheckVolumeErrors, HealthCheckVolumeResponses, ListBackupSchedulesData, ListBackupSchedulesResponses, ListFilesData, ListFilesResponses, ListNotificationDestinationsData, ListNotificationDestinationsResponses, ListRcloneRemotesData, ListRcloneRemotesResponses, ListRepositoriesData, ListRepositoriesResponses, ListSnapshotFilesData, ListSnapshotFilesResponses, ListSnapshotsData, ListSnapshotsResponses, ListVolumesData, ListVolumesResponses, MountVolumeData, MountVolumeResponses, RefreshSnapshotsData, RefreshSnapshotsResponses, RemoveOrgMemberData, RemoveOrgMemberErrors, RemoveOrgMemberResponses, ReorderBackupSchedulesData, ReorderBackupSchedulesResponses, RestoreSnapshotData, RestoreSnapshotResponses, RunBackupNowData, RunBackupNowResponses, RunForgetData, RunForgetResponses, SetRegistrationStatusData, SetRegistrationStatusResponses, StartDoctorData, StartDoctorErrors, StartDoctorResponses, StopBackupData, StopBackupErrors, StopBackupResponses, TagSnapshotsData, TagSnapshotsResponses, TestConnectionData, TestConnectionResponses, TestNotificationDestinationData, TestNotificationDestinationErrors, TestNotificationDestinationResponses, UnlockRepositoryData, UnlockRepositoryResponses, UnmountVolumeData, UnmountVolumeResponses, UpdateBackupScheduleData, UpdateBackupScheduleResponses, UpdateMemberRoleData, UpdateMemberRoleErrors, UpdateMemberRoleResponses, UpdateNotificationDestinationData, UpdateNotificationDestinationErrors, UpdateNotificationDestinationResponses, UpdateRepositoryData, UpdateRepositoryErrors, UpdateRepositoryResponses, UpdateScheduleMirrorsData, UpdateScheduleMirrorsResponses, UpdateScheduleNotificationsData, UpdateScheduleNotificationsResponses, UpdateSsoProviderAutoLinkingData, UpdateSsoProviderAutoLinkingErrors, UpdateSsoProviderAutoLinkingResponses, UpdateVolumeData, UpdateVolumeErrors, UpdateVolumeResponses } from './types.gen'; import type { BrowseFilesystemData, BrowseFilesystemResponses, CancelDoctorData, CancelDoctorErrors, CancelDoctorResponses, CreateBackupScheduleData, CreateBackupScheduleResponses, CreateNotificationDestinationData, CreateNotificationDestinationResponses, CreateRepositoryData, CreateRepositoryResponses, CreateVolumeData, CreateVolumeResponses, DeleteBackupScheduleData, DeleteBackupScheduleResponses, DeleteNotificationDestinationData, DeleteNotificationDestinationErrors, DeleteNotificationDestinationResponses, DeleteRepositoryData, DeleteRepositoryResponses, DeleteSnapshotData, DeleteSnapshotResponses, DeleteSnapshotsData, DeleteSnapshotsResponses, DeleteSsoInvitationData, DeleteSsoInvitationErrors, DeleteSsoInvitationResponses, DeleteSsoProviderData, DeleteSsoProviderErrors, DeleteSsoProviderResponses, DeleteUserAccountData, DeleteUserAccountErrors, DeleteUserAccountResponses, DeleteVolumeData, DeleteVolumeResponses, DevPanelExecData, DevPanelExecErrors, DevPanelExecResponses, DownloadResticPasswordData, DownloadResticPasswordResponses, DumpSnapshotData, DumpSnapshotResponses, GetAdminUsersData, GetAdminUsersResponses, GetBackupProgressData, GetBackupProgressResponses, GetBackupScheduleData, GetBackupScheduleForVolumeData, GetBackupScheduleForVolumeResponses, GetBackupScheduleResponses, GetDevPanelData, GetDevPanelResponses, GetMirrorCompatibilityData, GetMirrorCompatibilityResponses, GetNotificationDestinationData, GetNotificationDestinationErrors, GetNotificationDestinationResponses, GetOrgMembersData, GetOrgMembersResponses, GetPublicSsoProvidersData, GetPublicSsoProvidersResponses, GetRegistrationStatusData, GetRegistrationStatusResponses, GetRepositoryData, GetRepositoryResponses, GetRepositoryStatsData, GetRepositoryStatsResponses, GetScheduleMirrorsData, GetScheduleMirrorsResponses, GetScheduleNotificationsData, GetScheduleNotificationsResponses, GetSnapshotDetailsData, GetSnapshotDetailsResponses, GetSsoSettingsData, GetSsoSettingsResponses, GetStatusData, GetStatusResponses, GetSystemInfoData, GetSystemInfoResponses, GetUpdatesData, GetUpdatesResponses, GetUserDeletionImpactData, GetUserDeletionImpactResponses, GetVolumeData, GetVolumeErrors, GetVolumeResponses, HealthCheckVolumeData, HealthCheckVolumeErrors, HealthCheckVolumeResponses, ListBackupSchedulesData, ListBackupSchedulesResponses, ListFilesData, ListFilesResponses, ListNotificationDestinationsData, ListNotificationDestinationsResponses, ListRcloneRemotesData, ListRcloneRemotesResponses, ListRepositoriesData, ListRepositoriesResponses, ListSnapshotFilesData, ListSnapshotFilesResponses, ListSnapshotsData, ListSnapshotsResponses, ListVolumesData, ListVolumesResponses, MountVolumeData, MountVolumeResponses, RefreshRepositoryStatsData, RefreshRepositoryStatsResponses, RefreshSnapshotsData, RefreshSnapshotsResponses, RemoveOrgMemberData, RemoveOrgMemberErrors, RemoveOrgMemberResponses, ReorderBackupSchedulesData, ReorderBackupSchedulesResponses, RestoreSnapshotData, RestoreSnapshotResponses, RunBackupNowData, RunBackupNowResponses, RunForgetData, RunForgetResponses, SetRegistrationStatusData, SetRegistrationStatusResponses, StartDoctorData, StartDoctorErrors, StartDoctorResponses, StopBackupData, StopBackupErrors, StopBackupResponses, TagSnapshotsData, TagSnapshotsResponses, TestConnectionData, TestConnectionResponses, TestNotificationDestinationData, TestNotificationDestinationErrors, TestNotificationDestinationResponses, UnlockRepositoryData, UnlockRepositoryResponses, UnmountVolumeData, UnmountVolumeResponses, UpdateBackupScheduleData, UpdateBackupScheduleResponses, UpdateMemberRoleData, UpdateMemberRoleErrors, UpdateMemberRoleResponses, UpdateNotificationDestinationData, UpdateNotificationDestinationErrors, UpdateNotificationDestinationResponses, UpdateRepositoryData, UpdateRepositoryErrors, UpdateRepositoryResponses, UpdateScheduleMirrorsData, UpdateScheduleMirrorsResponses, UpdateScheduleNotificationsData, UpdateScheduleNotificationsResponses, UpdateSsoProviderAutoLinkingData, UpdateSsoProviderAutoLinkingErrors, UpdateSsoProviderAutoLinkingResponses, UpdateVolumeData, UpdateVolumeErrors, UpdateVolumeResponses } from './types.gen';
export type Options<TData extends TDataShape = TDataShape, ThrowOnError extends boolean = boolean> = Options2<TData, ThrowOnError> & { export type Options<TData extends TDataShape = TDataShape, ThrowOnError extends boolean = boolean> = Options2<TData, ThrowOnError> & {
/** /**
@ -24,38 +24,6 @@ export type Options<TData extends TDataShape = TDataShape, ThrowOnError extends
*/ */
export const getStatus = <ThrowOnError extends boolean = false>(options?: Options<GetStatusData, ThrowOnError>) => (options?.client ?? client).get<GetStatusResponses, unknown, ThrowOnError>({ url: '/api/v1/auth/status', ...options }); export const getStatus = <ThrowOnError extends boolean = false>(options?: Options<GetStatusData, ThrowOnError>) => (options?.client ?? client).get<GetStatusResponses, unknown, ThrowOnError>({ url: '/api/v1/auth/status', ...options });
/**
* Get public SSO providers for the instance
*/
export const getPublicSsoProviders = <ThrowOnError extends boolean = false>(options?: Options<GetPublicSsoProvidersData, ThrowOnError>) => (options?.client ?? client).get<GetPublicSsoProvidersResponses, unknown, ThrowOnError>({ url: '/api/v1/auth/sso-providers', ...options });
/**
* Get SSO providers and invitations for the active organization
*/
export const getSsoSettings = <ThrowOnError extends boolean = false>(options?: Options<GetSsoSettingsData, ThrowOnError>) => (options?.client ?? client).get<GetSsoSettingsResponses, unknown, ThrowOnError>({ url: '/api/v1/auth/sso-settings', ...options });
/**
* Delete an SSO provider
*/
export const deleteSsoProvider = <ThrowOnError extends boolean = false>(options: Options<DeleteSsoProviderData, ThrowOnError>) => (options.client ?? client).delete<DeleteSsoProviderResponses, DeleteSsoProviderErrors, ThrowOnError>({ url: '/api/v1/auth/sso-providers/{providerId}', ...options });
/**
* Update whether SSO sign-in can auto-link existing accounts by email
*/
export const updateSsoProviderAutoLinking = <ThrowOnError extends boolean = false>(options: Options<UpdateSsoProviderAutoLinkingData, ThrowOnError>) => (options.client ?? client).patch<UpdateSsoProviderAutoLinkingResponses, UpdateSsoProviderAutoLinkingErrors, ThrowOnError>({
url: '/api/v1/auth/sso-providers/{providerId}/auto-linking',
...options,
headers: {
'Content-Type': 'application/json',
...options.headers
}
});
/**
* Delete an SSO invitation
*/
export const deleteSsoInvitation = <ThrowOnError extends boolean = false>(options: Options<DeleteSsoInvitationData, ThrowOnError>) => (options.client ?? client).delete<DeleteSsoInvitationResponses, DeleteSsoInvitationErrors, ThrowOnError>({ url: '/api/v1/auth/sso-invitations/{invitationId}', ...options });
/** /**
* List admin users for settings management * List admin users for settings management
*/ */
@ -93,6 +61,38 @@ export const updateMemberRole = <ThrowOnError extends boolean = false>(options:
*/ */
export const removeOrgMember = <ThrowOnError extends boolean = false>(options: Options<RemoveOrgMemberData, ThrowOnError>) => (options.client ?? client).delete<RemoveOrgMemberResponses, RemoveOrgMemberErrors, ThrowOnError>({ url: '/api/v1/auth/org-members/{memberId}', ...options }); export const removeOrgMember = <ThrowOnError extends boolean = false>(options: Options<RemoveOrgMemberData, ThrowOnError>) => (options.client ?? client).delete<RemoveOrgMemberResponses, RemoveOrgMemberErrors, ThrowOnError>({ url: '/api/v1/auth/org-members/{memberId}', ...options });
/**
* Get public SSO providers for the instance
*/
export const getPublicSsoProviders = <ThrowOnError extends boolean = false>(options?: Options<GetPublicSsoProvidersData, ThrowOnError>) => (options?.client ?? client).get<GetPublicSsoProvidersResponses, unknown, ThrowOnError>({ url: '/api/v1/auth/sso-providers', ...options });
/**
* Get SSO providers and invitations for the active organization
*/
export const getSsoSettings = <ThrowOnError extends boolean = false>(options?: Options<GetSsoSettingsData, ThrowOnError>) => (options?.client ?? client).get<GetSsoSettingsResponses, unknown, ThrowOnError>({ url: '/api/v1/auth/sso-settings', ...options });
/**
* Delete an SSO provider
*/
export const deleteSsoProvider = <ThrowOnError extends boolean = false>(options: Options<DeleteSsoProviderData, ThrowOnError>) => (options.client ?? client).delete<DeleteSsoProviderResponses, DeleteSsoProviderErrors, ThrowOnError>({ url: '/api/v1/auth/sso-providers/{providerId}', ...options });
/**
* Update whether SSO sign-in can auto-link existing accounts by email
*/
export const updateSsoProviderAutoLinking = <ThrowOnError extends boolean = false>(options: Options<UpdateSsoProviderAutoLinkingData, ThrowOnError>) => (options.client ?? client).patch<UpdateSsoProviderAutoLinkingResponses, UpdateSsoProviderAutoLinkingErrors, ThrowOnError>({
url: '/api/v1/auth/sso-providers/{providerId}/auto-linking',
...options,
headers: {
'Content-Type': 'application/json',
...options.headers
}
});
/**
* Delete an SSO invitation
*/
export const deleteSsoInvitation = <ThrowOnError extends boolean = false>(options: Options<DeleteSsoInvitationData, ThrowOnError>) => (options.client ?? client).delete<DeleteSsoInvitationResponses, DeleteSsoInvitationErrors, ThrowOnError>({ url: '/api/v1/auth/sso-invitations/{invitationId}', ...options });
/** /**
* List all volumes * List all volumes
*/ */
@ -101,24 +101,24 @@ export const listVolumes = <ThrowOnError extends boolean = false>(options?: Opti
/** /**
* Create a new volume * Create a new volume
*/ */
export const createVolume = <ThrowOnError extends boolean = false>(options?: Options<CreateVolumeData, ThrowOnError>) => (options?.client ?? client).post<CreateVolumeResponses, unknown, ThrowOnError>({ export const createVolume = <ThrowOnError extends boolean = false>(options: Options<CreateVolumeData, ThrowOnError>) => (options.client ?? client).post<CreateVolumeResponses, unknown, ThrowOnError>({
url: '/api/v1/volumes', url: '/api/v1/volumes',
...options, ...options,
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
...options?.headers ...options.headers
} }
}); });
/** /**
* Test connection to backend * Test connection to backend
*/ */
export const testConnection = <ThrowOnError extends boolean = false>(options?: Options<TestConnectionData, ThrowOnError>) => (options?.client ?? client).post<TestConnectionResponses, unknown, ThrowOnError>({ export const testConnection = <ThrowOnError extends boolean = false>(options: Options<TestConnectionData, ThrowOnError>) => (options.client ?? client).post<TestConnectionResponses, unknown, ThrowOnError>({
url: '/api/v1/volumes/test-connection', url: '/api/v1/volumes/test-connection',
...options, ...options,
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
...options?.headers ...options.headers
} }
}); });
@ -177,12 +177,12 @@ export const listRepositories = <ThrowOnError extends boolean = false>(options?:
/** /**
* Create a new restic repository * Create a new restic repository
*/ */
export const createRepository = <ThrowOnError extends boolean = false>(options?: Options<CreateRepositoryData, ThrowOnError>) => (options?.client ?? client).post<CreateRepositoryResponses, unknown, ThrowOnError>({ export const createRepository = <ThrowOnError extends boolean = false>(options: Options<CreateRepositoryData, ThrowOnError>) => (options.client ?? client).post<CreateRepositoryResponses, unknown, ThrowOnError>({
url: '/api/v1/repositories', url: '/api/v1/repositories',
...options, ...options,
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
...options?.headers ...options.headers
} }
}); });
@ -218,6 +218,11 @@ export const updateRepository = <ThrowOnError extends boolean = false>(options:
*/ */
export const getRepositoryStats = <ThrowOnError extends boolean = false>(options: Options<GetRepositoryStatsData, ThrowOnError>) => (options.client ?? client).get<GetRepositoryStatsResponses, unknown, ThrowOnError>({ url: '/api/v1/repositories/{shortId}/stats', ...options }); export const getRepositoryStats = <ThrowOnError extends boolean = false>(options: Options<GetRepositoryStatsData, ThrowOnError>) => (options.client ?? client).get<GetRepositoryStatsResponses, unknown, ThrowOnError>({ url: '/api/v1/repositories/{shortId}/stats', ...options });
/**
* Refresh repository storage and compression statistics
*/
export const refreshRepositoryStats = <ThrowOnError extends boolean = false>(options: Options<RefreshRepositoryStatsData, ThrowOnError>) => (options.client ?? client).post<RefreshRepositoryStatsResponses, unknown, ThrowOnError>({ url: '/api/v1/repositories/{shortId}/stats/refresh', ...options });
/** /**
* Delete multiple snapshots from a repository * Delete multiple snapshots from a repository
*/ */
@ -319,12 +324,12 @@ export const listBackupSchedules = <ThrowOnError extends boolean = false>(option
/** /**
* Create a new backup schedule for a volume * Create a new backup schedule for a volume
*/ */
export const createBackupSchedule = <ThrowOnError extends boolean = false>(options?: Options<CreateBackupScheduleData, ThrowOnError>) => (options?.client ?? client).post<CreateBackupScheduleResponses, unknown, ThrowOnError>({ export const createBackupSchedule = <ThrowOnError extends boolean = false>(options: Options<CreateBackupScheduleData, ThrowOnError>) => (options.client ?? client).post<CreateBackupScheduleResponses, unknown, ThrowOnError>({
url: '/api/v1/backups', url: '/api/v1/backups',
...options, ...options,
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
...options?.headers ...options.headers
} }
}); });
@ -412,12 +417,12 @@ export const getMirrorCompatibility = <ThrowOnError extends boolean = false>(opt
/** /**
* Reorder backup schedules by providing an array of schedule short IDs in the desired order * Reorder backup schedules by providing an array of schedule short IDs in the desired order
*/ */
export const reorderBackupSchedules = <ThrowOnError extends boolean = false>(options?: Options<ReorderBackupSchedulesData, ThrowOnError>) => (options?.client ?? client).post<ReorderBackupSchedulesResponses, unknown, ThrowOnError>({ export const reorderBackupSchedules = <ThrowOnError extends boolean = false>(options: Options<ReorderBackupSchedulesData, ThrowOnError>) => (options.client ?? client).post<ReorderBackupSchedulesResponses, unknown, ThrowOnError>({
url: '/api/v1/backups/reorder', url: '/api/v1/backups/reorder',
...options, ...options,
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
...options?.headers ...options.headers
} }
}); });
@ -434,12 +439,12 @@ export const listNotificationDestinations = <ThrowOnError extends boolean = fals
/** /**
* Create a new notification destination * Create a new notification destination
*/ */
export const createNotificationDestination = <ThrowOnError extends boolean = false>(options?: Options<CreateNotificationDestinationData, ThrowOnError>) => (options?.client ?? client).post<CreateNotificationDestinationResponses, unknown, ThrowOnError>({ export const createNotificationDestination = <ThrowOnError extends boolean = false>(options: Options<CreateNotificationDestinationData, ThrowOnError>) => (options.client ?? client).post<CreateNotificationDestinationResponses, unknown, ThrowOnError>({
url: '/api/v1/notifications/destinations', url: '/api/v1/notifications/destinations',
...options, ...options,
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
...options?.headers ...options.headers
} }
}); });
@ -488,24 +493,24 @@ export const getRegistrationStatus = <ThrowOnError extends boolean = false>(opti
/** /**
* Update the registration status for new users. Requires global admin role. * Update the registration status for new users. Requires global admin role.
*/ */
export const setRegistrationStatus = <ThrowOnError extends boolean = false>(options?: Options<SetRegistrationStatusData, ThrowOnError>) => (options?.client ?? client).put<SetRegistrationStatusResponses, unknown, ThrowOnError>({ export const setRegistrationStatus = <ThrowOnError extends boolean = false>(options: Options<SetRegistrationStatusData, ThrowOnError>) => (options.client ?? client).put<SetRegistrationStatusResponses, unknown, ThrowOnError>({
url: '/api/v1/system/registration-status', url: '/api/v1/system/registration-status',
...options, ...options,
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
...options?.headers ...options.headers
} }
}); });
/** /**
* Download the organization's Restic password for backup recovery. Requires organization owner or admin role and password re-authentication. * Download the organization's Restic password for backup recovery. Requires organization owner or admin role and password re-authentication.
*/ */
export const downloadResticPassword = <ThrowOnError extends boolean = false>(options?: Options<DownloadResticPasswordData, ThrowOnError>) => (options?.client ?? client).post<DownloadResticPasswordResponses, unknown, ThrowOnError>({ export const downloadResticPassword = <ThrowOnError extends boolean = false>(options: Options<DownloadResticPasswordData, ThrowOnError>) => (options.client ?? client).post<DownloadResticPasswordResponses, unknown, ThrowOnError>({
url: '/api/v1/system/restic-password', url: '/api/v1/system/restic-password',
...options, ...options,
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
...options?.headers ...options.headers
} }
}); });

File diff suppressed because it is too large Load diff

View file

@ -69,7 +69,7 @@ export function AppSidebar({ isInstanceAdmin }: Props) {
return ( return (
<Sidebar variant="inset" collapsible="icon" className="p-0"> <Sidebar variant="inset" collapsible="icon" className="p-0">
<SidebarHeader className="bg-card-header border-b border-border/50 hidden md:flex h-16.25 flex-row items-center p-4"> <SidebarHeader className="bg-card-header border-b border-border/80 dark:border-border/50 hidden md:flex h-16.25 flex-row items-center p-4">
<Link to="/volumes" className="flex items-center gap-3 font-semibold pl-2"> <Link to="/volumes" className="flex items-center gap-3 font-semibold pl-2">
<img src="/images/zerobyte.png" alt="Zerobyte Logo" className={cn("h-8 w-8 shrink-0 object-contain -ml-2")} /> <img src="/images/zerobyte.png" alt="Zerobyte Logo" className={cn("h-8 w-8 shrink-0 object-contain -ml-2")} />
<span <span
@ -115,7 +115,7 @@ export function AppSidebar({ isInstanceAdmin }: Props) {
/> />
<span <span
className={cn({ className={cn({
"text-white font-medium": isActive, "text-foreground font-medium": isActive,
"text-muted-foreground": !isActive, "text-muted-foreground": !isActive,
})} })}
> >
@ -171,7 +171,7 @@ export function AppSidebar({ isInstanceAdmin }: Props) {
/> />
<span <span
className={cn({ className={cn({
"text-white font-medium": isActive, "text-foreground font-medium": isActive,
"text-muted-foreground": !isActive, "text-muted-foreground": !isActive,
})} })}
> >
@ -194,7 +194,7 @@ export function AppSidebar({ isInstanceAdmin }: Props) {
</> </>
)} )}
</SidebarContent> </SidebarContent>
<SidebarFooter className="p-4 border-r border-border/50"> <SidebarFooter className="p-4 border-r border-border/80 dark:border-border/50">
<OrganizationSwitcher /> <OrganizationSwitcher />
<div className="flex items-center justify-between gap-2"> <div className="flex items-center justify-between gap-2">
<HoverCard openDelay={200}> <HoverCard openDelay={200}>

View file

@ -59,7 +59,7 @@ export function CronInput({ value, onChange, error }: CronInputProps) {
{value && ( {value && (
<div> <div>
{isValid ? ( {isValid ? (
<CheckCircle2 className="h-4 w-4 text-green-500" /> <CheckCircle2 className="h-4 w-4 text-success" />
) : ( ) : (
<AlertCircle className="h-4 w-4 text-destructive" /> <AlertCircle className="h-4 w-4 text-destructive" />
)} )}

View file

@ -74,11 +74,7 @@ export const SnapshotTreeBrowser = ({
return await queryClient.ensureQueryData( return await queryClient.ensureQueryData(
listSnapshotFilesOptions({ listSnapshotFilesOptions({
path: { shortId: repositoryId, snapshotId }, path: { shortId: repositoryId, snapshotId },
query: { query: { path, offset: offset, limit: pageSize },
path,
offset: offset.toString(),
limit: pageSize.toString(),
},
}), }),
); );
}, },
@ -86,11 +82,7 @@ export const SnapshotTreeBrowser = ({
void queryClient.prefetchQuery( void queryClient.prefetchQuery(
listSnapshotFilesOptions({ listSnapshotFilesOptions({
path: { shortId: repositoryId, snapshotId }, path: { shortId: repositoryId, snapshotId },
query: { query: { path, offset: 0, limit: pageSize },
path,
offset: "0",
limit: pageSize.toString(),
},
}), }),
); );
}, },

View file

@ -24,7 +24,7 @@ export const VolumeFileBrowser = ({ volumeId, enabled = true, ...uiProps }: Volu
return await queryClient.ensureQueryData( return await queryClient.ensureQueryData(
listFilesOptions({ listFilesOptions({
path: { shortId: volumeId }, path: { shortId: volumeId },
query: { path, offset: offset?.toString() }, query: { path, offset: offset },
}), }),
); );
}, },

View file

@ -14,7 +14,7 @@ export function GridBackground({ children, className, containerClassName }: Grid
className={cn( className={cn(
"pointer-events-none absolute inset-0 w-full h-full", "pointer-events-none absolute inset-0 w-full h-full",
"bg-[size:40px_40px]", "bg-[size:40px_40px]",
"bg-[linear-gradient(to_right,#e4e4e7_1px,transparent_1px),linear-gradient(to_bottom,#e4e4e7_1px,transparent_1px)]", "bg-[linear-gradient(to_right,rgba(0,0,0,0.04)_1px,transparent_1px),linear-gradient(to_bottom,rgba(0,0,0,0.04)_1px,transparent_1px)]",
"dark:bg-[linear-gradient(to_right,#262626_1px,transparent_1px),linear-gradient(to_bottom,#262626_1px,transparent_1px)]", "dark:bg-[linear-gradient(to_right,#262626_1px,transparent_1px),linear-gradient(to_bottom,#262626_1px,transparent_1px)]",
"[mask-image:radial-gradient(ellipse_at_top,black_70%,transparent_100%)]", "[mask-image:radial-gradient(ellipse_at_top,black_70%,transparent_100%)]",
)} )}

View file

@ -10,6 +10,7 @@ import { DevPanelListener } from "./dev-panel-listener";
import { Outlet, useNavigate } from "@tanstack/react-router"; import { Outlet, useNavigate } from "@tanstack/react-router";
import { AppBreadcrumb } from "./app-breadcrumb"; import { AppBreadcrumb } from "./app-breadcrumb";
import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip"; import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip";
import { ThemeToggle } from "./theme-toggle";
type Props = { type Props = {
loaderData: AppContext; loaderData: AppContext;
@ -35,23 +36,24 @@ export function Layout({ loaderData }: Props) {
<SidebarProvider defaultOpen={loaderData.sidebarOpen}> <SidebarProvider defaultOpen={loaderData.sidebarOpen}>
<AppSidebar isInstanceAdmin={loaderData.user?.role === "admin"} /> <AppSidebar isInstanceAdmin={loaderData.user?.role === "admin"} />
<div className="w-full relative flex flex-col min-h-screen md:h-screen md:overflow-hidden"> <div className="w-full relative flex flex-col min-h-screen md:h-screen md:overflow-hidden">
<header className="z-50 bg-card-header border-b border-border/50 shrink-0 h-16.25"> <header className="z-50 bg-card-header border-b border-border/80 dark:border-border/50 shrink-0 h-16.25">
<div className="flex items-center h-full justify-between px-2 sm:px-8 mx-auto container gap-4"> <div className="flex items-center h-full justify-between px-2 sm:px-8 mx-auto container gap-4">
<div className="flex items-center gap-4 min-w-0"> <div className="flex items-center gap-4 min-w-0">
<SidebarTrigger /> <SidebarTrigger />
<AppBreadcrumb /> <AppBreadcrumb />
</div> </div>
{loaderData.user && ( {loaderData.user && (
<div className="flex items-center bg-muted/30 border border-border/50 px-2 py-1 rounded-full shadow-sm"> <div className="flex items-center bg-card dark:bg-muted/30 border border-border/80 dark:border-border/50 px-2 py-1 rounded-full shadow-[0_2px_10px_-4px_rgba(0,0,0,0.05)] dark:shadow-sm">
<span className="text-sm text-muted-foreground hidden md:inline-flex pl-2 mr-5"> <span className="text-sm text-muted-foreground hidden md:inline-flex pl-2 mr-5">
<span className="text-foreground">{loaderData.user.name}</span> <span className="text-foreground">{loaderData.user.name}</span>
</span> </span>
<ThemeToggle />
<Tooltip> <Tooltip>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
className="rounded-full h-7 text-xs text-muted-foreground hover:text-white" className="rounded-full h-7 text-xs text-muted-foreground hover:text-foreground"
onClick={handleLogout} onClick={handleLogout}
> >
<LogOut className="w-4 h-4" /> <LogOut className="w-4 h-4" />
@ -64,7 +66,7 @@ export function Layout({ loaderData }: Props) {
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
className="relative overflow-hidden hidden lg:inline-flex rounded-full h-7 w-7 text-muted-foreground hover:text-white" className="relative overflow-hidden hidden lg:inline-flex rounded-full h-7 w-7 text-muted-foreground hover:text-foreground"
> >
<a <a
href="https://github.com/nicotsx/zerobyte/issues/new" href="https://github.com/nicotsx/zerobyte/issues/new"

View file

@ -15,7 +15,7 @@ export const OnOff = ({ isOn, toggle, enabledLabel, disabledLabel, disabled }: P
className={cn( className={cn(
"flex items-center gap-2 rounded-full border px-3 py-1.5 text-xs font-semibold uppercase tracking-wide transition-colors", "flex items-center gap-2 rounded-full border px-3 py-1.5 text-xs font-semibold uppercase tracking-wide transition-colors",
isOn isOn
? "border-emerald-200 bg-emerald-50 text-emerald-700 dark:border-emerald-500/40 dark:bg-emerald-500/10 dark:text-emerald-200" ? "border-success/30 bg-success/10 text-success"
: "border-muted bg-muted/40 text-muted-foreground dark:border-muted/60 dark:bg-muted/10", : "border-muted bg-muted/40 text-muted-foreground dark:border-muted/60 dark:bg-muted/10",
)} )}
> >

View file

@ -59,7 +59,7 @@ export function OrganizationSwitcher() {
size="lg" size="lg"
className="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground" className="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground"
> >
<div className="bg-black text-sidebar-primary-foreground flex aspect-square size-8 shrink-0 items-center justify-center overflow-hidden rounded-lg"> <div className="bg-foreground text-sidebar-primary-foreground flex aspect-square size-8 shrink-0 items-center justify-center overflow-hidden rounded-lg">
{activeOrganization?.logo ? ( {activeOrganization?.logo ? (
<img <img
src={activeOrganization.logo} src={activeOrganization.logo}

View file

@ -133,6 +133,7 @@ export function RestoreForm({ repository, snapshotId, returnPath, basePath }: Re
body: { body: {
snapshotId, snapshotId,
include: includePaths.length > 0 ? includePaths : undefined, include: includePaths.length > 0 ? includePaths : undefined,
selectedItemKind: includePaths.length === 1 ? (selectedPathKind ?? undefined) : undefined,
excludeXattr: excludeXattrArray && excludeXattrArray.length > 0 ? excludeXattrArray : undefined, excludeXattr: excludeXattrArray && excludeXattrArray.length > 0 ? excludeXattrArray : undefined,
targetPath, targetPath,
overwrite: overwriteMode, overwrite: overwriteMode,
@ -145,6 +146,7 @@ export function RestoreForm({ repository, snapshotId, returnPath, basePath }: Re
restoreLocation, restoreLocation,
customTargetPath, customTargetPath,
selectedPaths, selectedPaths,
selectedPathKind,
overwriteMode, overwriteMode,
restoreSnapshot, restoreSnapshot,
]); ]);

View file

@ -12,8 +12,8 @@ interface StatusDotProps {
export const StatusDot = ({ variant, label, animated }: StatusDotProps) => { export const StatusDot = ({ variant, label, animated }: StatusDotProps) => {
const statusMapping = { const statusMapping = {
success: { success: {
color: "bg-emerald-500 shadow-[0_0_8px_rgba(16,185,129,0.5)]", color: "bg-success shadow-[0_0_8px_rgba(16,185,129,0.5)]",
colorLight: "bg-emerald-400", colorLight: "bg-success/60",
animated: animated ?? true, animated: animated ?? true,
}, },
neutral: { neutral: {

View 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;
}

View 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>
);
}

View file

@ -11,13 +11,12 @@ const buttonVariants = cva(
{ {
variants: { variants: {
variant: { variant: {
default: default: "bg-transparent text-foreground hover:bg-muted border hover:border-border",
"bg-transparent text-white hover:bg-[#3A3A3A]/80 border hover:border-white/40 dark:text-white dark:hover:bg-[#3A3A3A]/80",
primary: "bg-strong-accent text-white hover:bg-strong-accent/90 focus-visible:ring-strong-accent/50", primary: "bg-strong-accent text-white hover:bg-strong-accent/90 focus-visible:ring-strong-accent/50",
destructive: destructive:
"border border-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/50 text-destructive hover:text-white", "border border-destructive text-destructive hover:bg-destructive hover:text-destructive-foreground focus-visible:ring-destructive/50",
outline: "border border-border bg-background hover:bg-accent hover:text-accent-foreground", outline: "border border-border bg-background hover:bg-accent hover:text-accent-foreground",
secondary: "bg-transparent text-white hover:bg-[#3A3A3A]/80 border dark:text-white dark:hover:bg-[#3A3A3A]/80", secondary: "bg-transparent text-foreground hover:bg-muted border",
ghost: "hover:bg-accent hover:text-accent-foreground", ghost: "hover:bg-accent hover:text-accent-foreground",
link: "text-primary underline-offset-4 hover:underline", link: "text-primary underline-offset-4 hover:underline",
}, },

View file

@ -7,7 +7,7 @@ function Card({ className, children, interactive, ...props }: React.ComponentPro
<div <div
data-slot="card" data-slot="card"
className={cn( className={cn(
"bg-card text-card-foreground group relative flex flex-col gap-6 border border-border py-6 shadow-[inset_0_0_0_1px_rgba(255,255,255,0.02)] transition-colors duration-300 ", "bg-card text-card-foreground group relative flex flex-col gap-6 border border-border py-6 shadow-[0_8px_30px_-15px_rgba(0,0,0,0.04)] dark:shadow-[inset_0_0_0_1px_rgba(255,255,255,0.02)] transition-colors duration-300 ",
className, className,
)} )}
{...props} {...props}
@ -21,14 +21,14 @@ function Card({ className, children, interactive, ...props }: React.ComponentPro
}, },
)} )}
> >
<span className="absolute -left-0.5 -top-0.5 h-0.5 w-4 bg-white/80" /> <span className="absolute -left-0.5 -top-0.5 h-0.5 w-4 bg-foreground" />
<span className="absolute -left-0.5 -top-0.5 h-4 w-0.5 bg-white/80" /> <span className="absolute -left-0.5 -top-0.5 h-4 w-0.5 bg-foreground" />
<span className="absolute -right-0.5 -top-0.5 h-0.5 w-4 bg-white/80" /> <span className="absolute -right-0.5 -top-0.5 h-0.5 w-4 bg-foreground" />
<span className="absolute -right-0.5 -top-0.5 h-4 w-0.5 bg-white/80" /> <span className="absolute -right-0.5 -top-0.5 h-4 w-0.5 bg-foreground" />
<span className="absolute -left-0.5 -bottom-0.5 h-0.5 w-4 bg-white/80" /> <span className="absolute -left-0.5 -bottom-0.5 h-0.5 w-4 bg-foreground" />
<span className="absolute -left-0.5 -bottom-0.5 h-4 w-0.5 bg-white/80" /> <span className="absolute -left-0.5 -bottom-0.5 h-4 w-0.5 bg-foreground" />
<span className="absolute -right-0.5 -bottom-0.5 h-0.5 w-4 bg-white/80" /> <span className="absolute -right-0.5 -bottom-0.5 h-0.5 w-4 bg-foreground" />
<span className="absolute -right-0.5 -bottom-0.5 h-4 w-0.5 bg-white/80" /> <span className="absolute -right-0.5 -bottom-0.5 h-4 w-0.5 bg-foreground" />
</span> </span>
{children} {children}
</div> </div>

View file

@ -1,10 +1,13 @@
import type * as React from "react"; import * as React from "react";
import * as CheckboxPrimitive from "@radix-ui/react-checkbox"; import * as CheckboxPrimitive from "@radix-ui/react-checkbox";
import { CheckIcon } from "lucide-react"; import { CheckIcon } from "lucide-react";
import { useWebHaptics } from "web-haptics/react";
import { cn } from "~/client/lib/utils"; import { cn } from "~/client/lib/utils";
function Checkbox({ className, ...props }: React.ComponentProps<typeof CheckboxPrimitive.Root>) { function Checkbox({ className, ...props }: React.ComponentProps<typeof CheckboxPrimitive.Root>) {
const trigger = useWebHaptics().trigger;
return ( return (
<CheckboxPrimitive.Root <CheckboxPrimitive.Root
data-slot="checkbox" data-slot="checkbox"
@ -12,6 +15,10 @@ function Checkbox({ className, ...props }: React.ComponentProps<typeof CheckboxP
"peer border-input dark:bg-input/30 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground dark:data-[state=checked]:bg-primary data-[state=checked]:border-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive size-4 shrink-0 rounded-[4px] border shadow-xs transition-shadow outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50", "peer border-input dark:bg-input/30 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground dark:data-[state=checked]:bg-primary data-[state=checked]:border-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive size-4 shrink-0 rounded-[4px] border shadow-xs transition-shadow outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
className, className,
)} )}
onCheckedChange={(checked) => {
void trigger([{ duration: 8 }], { intensity: 0.3 });
props.onCheckedChange?.(checked);
}}
{...props} {...props}
> >
<CheckboxPrimitive.Indicator <CheckboxPrimitive.Indicator

View file

@ -8,8 +8,8 @@ interface CodeBlockProps {
export const CodeBlock: React.FC<CodeBlockProps> = ({ code, filename }) => { export const CodeBlock: React.FC<CodeBlockProps> = ({ code, filename }) => {
return ( return (
<div className="overflow-hidden rounded-sm bg-card-header ring-1 ring-white/10"> <div className="overflow-hidden rounded-sm bg-card-header ring-1 ring-border">
<div className="flex items-center justify-between border-b border-white/10 px-4 py-2 text-xs"> <div className="flex items-center justify-between border-b border-border px-4 py-2 text-xs">
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5">
<span className="h-2.5 w-2.5 rounded-full bg-rose-500" /> <span className="h-2.5 w-2.5 rounded-full bg-rose-500" />
<span className="h-2.5 w-2.5 rounded-full bg-amber-500" /> <span className="h-2.5 w-2.5 rounded-full bg-amber-500" />
@ -18,7 +18,7 @@ export const CodeBlock: React.FC<CodeBlockProps> = ({ code, filename }) => {
</div> </div>
</div> </div>
<pre className="text-xs m-0 px-4 py-2 bg-card-header"> <pre className="text-xs m-0 px-4 py-2 bg-card-header">
<code className="text-white/80 select-all">{code}</code> <code className="text-foreground/80 select-all">{code}</code>
</pre> </pre>
</div> </div>
); );

View file

@ -1,4 +1,4 @@
import { useTheme } from "next-themes"; import { useTheme } from "~/client/components/theme-provider";
import { Toaster as Sonner, type ToasterProps } from "sonner"; import { Toaster as Sonner, type ToasterProps } from "sonner";
const Toaster = ({ ...props }: ToasterProps) => { const Toaster = ({ ...props }: ToasterProps) => {

View file

@ -1,9 +1,12 @@
import * as SwitchPrimitive from "@radix-ui/react-switch"; import * as SwitchPrimitive from "@radix-ui/react-switch";
import type * as React from "react"; import * as React from "react";
import { useWebHaptics } from "web-haptics/react";
import { cn } from "~/client/lib/utils"; import { cn } from "~/client/lib/utils";
function Switch({ className, ...props }: React.ComponentProps<typeof SwitchPrimitive.Root>) { function Switch({ className, ...props }: React.ComponentProps<typeof SwitchPrimitive.Root>) {
const trigger = useWebHaptics().trigger;
return ( return (
<SwitchPrimitive.Root <SwitchPrimitive.Root
data-slot="switch" data-slot="switch"
@ -11,6 +14,10 @@ function Switch({ className, ...props }: React.ComponentProps<typeof SwitchPrimi
"cursor-pointer peer data-[state=checked]:bg-primary data-[state=unchecked]:bg-input focus-visible:border-ring focus-visible:ring-ring/50 dark:data-[state=unchecked]:bg-input/80 inline-flex h-[1.15rem] w-8 shrink-0 items-center rounded-full border border-transparent shadow-xs transition-all outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50", "cursor-pointer peer data-[state=checked]:bg-primary data-[state=unchecked]:bg-input focus-visible:border-ring focus-visible:ring-ring/50 dark:data-[state=unchecked]:bg-input/80 inline-flex h-[1.15rem] w-8 shrink-0 items-center rounded-full border border-transparent shadow-xs transition-all outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
className, className,
)} )}
onCheckedChange={(checked) => {
void trigger([{ duration: 8 }], { intensity: 0.3 });
props.onCheckedChange?.(checked);
}}
{...props} {...props}
> >
<SwitchPrimitive.Thumb <SwitchPrimitive.Thumb

View file

@ -1,6 +1,7 @@
import * as TabsPrimitive from "@radix-ui/react-tabs"; import * as TabsPrimitive from "@radix-ui/react-tabs";
import type * as React from "react"; import type * as React from "react";
import { useWebHaptics } from "web-haptics/react";
import { cn } from "~/client/lib/utils"; import { cn } from "~/client/lib/utils";
function Tabs({ className, ...props }: React.ComponentProps<typeof TabsPrimitive.Root>) { function Tabs({ className, ...props }: React.ComponentProps<typeof TabsPrimitive.Root>) {
@ -17,7 +18,14 @@ function TabsList({ className, ...props }: React.ComponentProps<typeof TabsPrimi
); );
} }
function TabsTrigger({ className, ...props }: React.ComponentProps<typeof TabsPrimitive.Trigger>) { function TabsTrigger({ className, onClick, ...props }: React.ComponentProps<typeof TabsPrimitive.Trigger>) {
const { trigger } = useWebHaptics();
const handleClick: React.MouseEventHandler<HTMLButtonElement> = (event) => {
void trigger([{ duration: 8 }], { intensity: 0.3 });
onClick?.(event);
};
return ( return (
<TabsPrimitive.Trigger <TabsPrimitive.Trigger
data-slot="tabs-trigger" data-slot="tabs-trigger"
@ -29,24 +37,25 @@ function TabsTrigger({ className, ...props }: React.ComponentProps<typeof TabsPr
// Padding: 20px horizontal (8px for bracket tick + 12px gap to text) // Padding: 20px horizontal (8px for bracket tick + 12px gap to text)
"px-5", "px-5",
// Transparent orange background for active state // Transparent orange background for active state
"data-[state=active]:bg-[#FF453A]/10", "data-[state=active]:bg-strong-accent/10",
// Left bracket - vertical line // Left bracket - vertical line
"before:absolute before:left-0 before:top-0 before:h-7 before:w-0.5 before:bg-[#5D6570] before:transition-colors data-[state=active]:before:bg-[#FF453A]", "before:absolute before:left-0 before:top-0 before:h-7 before:w-0.5 before:bg-muted-foreground/60 before:transition-colors data-[state=active]:before:bg-strong-accent",
// Left bracket - top tick // Left bracket - top tick
"after:absolute after:left-0 after:-top-px after:w-2 after:h-0.5 after:bg-[#5D6570] after:transition-colors data-[state=active]:after:bg-[#FF453A]", "after:absolute after:left-0 after:-top-px after:w-2 after:h-0.5 after:bg-muted-foreground/60 after:transition-colors data-[state=active]:after:bg-strong-accent",
className, className,
)} )}
onClick={handleClick}
{...props} {...props}
> >
<span className="relative z-10">{props.children}</span> <span className="relative z-10">{props.children}</span>
{/* Left bracket - bottom tick */} {/* Left bracket - bottom tick */}
<span className="absolute left-0 bottom-[-1px] h-0.5 w-2 bg-[#5D6570] transition-colors group-data-[state=active]:bg-[#FF453A]" /> <span className="absolute left-0 bottom-[-1px] h-0.5 w-2 bg-muted-foreground/60 transition-colors group-data-[state=active]:bg-strong-accent" />
{/* Right bracket - top tick */} {/* Right bracket - top tick */}
<span className="absolute right-0 top-[-1px] h-0.5 w-2 bg-[#5D6570] transition-colors group-data-[state=active]:bg-[#FF453A]" /> <span className="absolute right-0 top-[-1px] h-0.5 w-2 bg-muted-foreground/60 transition-colors group-data-[state=active]:bg-strong-accent" />
{/* Right bracket - vertical line */} {/* Right bracket - vertical line */}
<span className="absolute right-0 top-0 h-7 w-0.5 bg-[#5D6570] transition-colors group-data-[state=active]:bg-[#FF453A]" /> <span className="absolute right-0 top-0 h-7 w-0.5 bg-muted-foreground/60 transition-colors group-data-[state=active]:bg-strong-accent" />
{/* Right bracket - bottom tick */} {/* Right bracket - bottom tick */}
<span className="absolute right-0 bottom-[-1px] h-0.5 w-2 bg-[#5D6570] transition-colors group-data-[state=active]:bg-[#FF453A]" /> <span className="absolute right-0 bottom-[-1px] h-0.5 w-2 bg-muted-foreground/60 transition-colors group-data-[state=active]:bg-strong-accent" />
</TabsPrimitive.Trigger> </TabsPrimitive.Trigger>
); );
} }

View 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);
});
});

View file

@ -2,18 +2,20 @@ import * as React from "react";
const MOBILE_BREAKPOINT = 768; const MOBILE_BREAKPOINT = 768;
export function useIsMobile() { function subscribe(callback: () => void) {
const [isMobile, setIsMobile] = React.useState<boolean | undefined>(undefined); const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`);
mql.addEventListener("change", callback);
React.useEffect(() => { return () => mql.removeEventListener("change", callback);
const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`); }
const onChange = () => {
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT); function getSnapshot() {
}; return window.innerWidth < MOBILE_BREAKPOINT;
mql.addEventListener("change", onChange); }
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT);
return () => mql.removeEventListener("change", onChange); function getServerSnapshot() {
}, []); return false;
}
return !!isMobile;
export function useIsMobile() {
return React.useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
} }

View file

@ -1,4 +1,4 @@
import { useQueryClient } from "@tanstack/react-query"; import { type QueryClient, useQueryClient } from "@tanstack/react-query";
import { useCallback, useEffect, useRef } from "react"; import { useCallback, useEffect, useRef } from "react";
import { serverEventNames, type ServerEventPayloadMap } from "~/schemas/server-events"; import { serverEventNames, type ServerEventPayloadMap } from "~/schemas/server-events";
@ -16,6 +16,13 @@ type EventHandlerMap = {
[K in ServerEventType]?: EventHandlerSet<K>; [K in ServerEventType]?: EventHandlerSet<K>;
}; };
type SharedServerEventsState = {
eventSource: EventSource | null;
handlers: EventHandlerMap;
queryClient: QueryClient | null;
subscribers: number;
};
const invalidatingEvents = new Set<ServerEventType>([ const invalidatingEvents = new Set<ServerEventType>([
"backup:completed", "backup:completed",
"volume:updated", "volume:updated",
@ -31,103 +38,151 @@ export type RestoreProgressEvent = ServerEventsPayloadMap["restore:progress"];
export type RestoreCompletedEvent = ServerEventsPayloadMap["restore:completed"]; export type RestoreCompletedEvent = ServerEventsPayloadMap["restore:completed"];
export type BackupProgressEvent = ServerEventsPayloadMap["backup:progress"]; export type BackupProgressEvent = ServerEventsPayloadMap["backup:progress"];
const sharedState: SharedServerEventsState = {
eventSource: null,
handlers: {},
queryClient: null,
subscribers: 0,
};
const parseEventData = <T extends ServerEventType>(event: Event): ServerEventsPayloadMap[T] => const parseEventData = <T extends ServerEventType>(event: Event): ServerEventsPayloadMap[T] =>
JSON.parse((event as MessageEvent<string>).data) as ServerEventsPayloadMap[T]; JSON.parse((event as MessageEvent<string>).data) as ServerEventsPayloadMap[T];
const isAbortError = (error: unknown): error is Error => error instanceof Error && error.name === "AbortError";
const emit = <T extends ServerEventType>(eventName: T, data: ServerEventsPayloadMap[T]) => {
const handlers = sharedState.handlers[eventName] as EventHandlerSet<T> | undefined;
handlers?.forEach((handler) => {
handler(data);
});
};
const refreshQueriesForEvent = (eventName: ServerEventType) => {
if (!invalidatingEvents.has(eventName) || !sharedState.queryClient) {
return;
}
void sharedState.queryClient.invalidateQueries().catch((error) => {
if (!isAbortError(error)) {
console.error(`[SSE] Failed to refresh queries after ${eventName}:`, error);
}
});
};
const connectEventSource = (queryClient: QueryClient) => {
sharedState.queryClient = queryClient;
if (sharedState.eventSource) {
return;
}
const eventSource = new EventSource("/api/v1/events");
sharedState.eventSource = eventSource;
eventSource.addEventListener("connected", (event) => {
const data = parseEventData<"connected">(event);
console.info("[SSE] Connected to server events");
emit("connected", data);
});
eventSource.addEventListener("heartbeat", (event) => {
emit("heartbeat", parseEventData<"heartbeat">(event));
});
for (const eventName of serverEventNames) {
eventSource.addEventListener(eventName, (event) => {
const data = parseEventData<typeof eventName>(event);
console.info(`[SSE] ${eventName}:`, data);
refreshQueriesForEvent(eventName);
if (eventName === "volume:status_changed") {
const statusData = data as ServerEventsPayloadMap["volume:status_changed"];
emit("volume:status_changed", statusData);
emit("volume:updated", statusData);
return;
}
emit(eventName, data);
});
}
eventSource.onerror = (error) => {
console.error("[SSE] Connection error:", error);
};
};
const disconnectEventSource = () => {
if (!sharedState.eventSource) {
return;
}
console.info("[SSE] Disconnecting from server events");
sharedState.eventSource.close();
sharedState.eventSource = null;
sharedState.queryClient = null;
sharedState.handlers = {};
};
const addSharedEventListener = <T extends ServerEventType>(
eventName: T,
handler: EventHandler<T>,
options?: { signal?: AbortSignal },
) => {
if (options?.signal?.aborted) {
return () => {};
}
const existingHandlers = sharedState.handlers[eventName] as EventHandlerSet<T> | undefined;
const eventHandlers = existingHandlers ?? new Set<EventHandler<T>>();
eventHandlers.add(handler);
sharedState.handlers[eventName] = eventHandlers as EventHandlerMap[T];
const unsubscribe = () => {
const handlers = sharedState.handlers[eventName] as EventHandlerSet<T> | undefined;
handlers?.delete(handler);
if (handlers && handlers.size === 0) {
delete sharedState.handlers[eventName];
}
if (options?.signal) {
options.signal.removeEventListener("abort", unsubscribe);
}
};
if (options?.signal) {
options.signal.addEventListener("abort", unsubscribe, { once: true });
}
return unsubscribe;
};
/** /**
* Hook to listen to Server-Sent Events (SSE) from the backend * Hook to listen to Server-Sent Events (SSE) from the backend
* Automatically handles cache invalidation for backup and volume events * Automatically handles cache invalidation for backup and volume events
*/ */
export function useServerEvents() { export function useServerEvents() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const eventSourceRef = useRef<EventSource | null>(null); const addEventListener = useCallback(addSharedEventListener, []);
const handlersRef = useRef<EventHandlerMap>({}); const hasMountedRef = useRef(false);
const emit = useCallback(<T extends ServerEventType>(eventName: T, data: ServerEventsPayloadMap[T]) => {
const handlers = handlersRef.current[eventName] as EventHandlerSet<T> | undefined;
handlers?.forEach((handler) => {
handler(data);
});
}, []);
useEffect(() => { useEffect(() => {
const eventSource = new EventSource("/api/v1/events"); connectEventSource(queryClient);
eventSourceRef.current = eventSource; if (!hasMountedRef.current) {
sharedState.subscribers += 1;
eventSource.addEventListener("connected", (event) => { hasMountedRef.current = true;
const data = parseEventData<"connected">(event);
console.info("[SSE] Connected to server events");
emit("connected", data);
});
eventSource.addEventListener("heartbeat", (event) => {
emit("heartbeat", parseEventData<"heartbeat">(event));
});
for (const eventName of serverEventNames) {
eventSource.addEventListener(eventName, (event) => {
const data = parseEventData<typeof eventName>(event);
console.info(`[SSE] ${eventName}:`, data);
if (invalidatingEvents.has(eventName)) {
void queryClient.invalidateQueries();
}
if (eventName === "backup:completed") {
void queryClient.refetchQueries();
}
if (eventName === "volume:status_changed") {
const statusData = data as ServerEventsPayloadMap["volume:status_changed"];
emit("volume:status_changed", statusData);
emit("volume:updated", statusData);
return;
}
emit(eventName, data);
});
} }
eventSource.onerror = (error) => {
console.error("[SSE] Connection error:", error);
};
return () => { return () => {
console.info("[SSE] Disconnecting from server events"); if (!hasMountedRef.current) {
eventSource.close(); return;
eventSourceRef.current = null; }
hasMountedRef.current = false;
sharedState.subscribers = Math.max(0, sharedState.subscribers - 1);
if (sharedState.subscribers === 0) {
disconnectEventSource();
}
}; };
}, [emit, queryClient]); }, [queryClient]);
const addEventListener = useCallback(
<T extends ServerEventType>(eventName: T, handler: EventHandler<T>, options?: { signal?: AbortSignal }) => {
if (options?.signal?.aborted) {
return () => {};
}
const existingHandlers = handlersRef.current[eventName] as EventHandlerSet<T> | undefined;
const eventHandlers = existingHandlers ?? new Set<EventHandler<T>>();
eventHandlers.add(handler);
handlersRef.current[eventName] = eventHandlers as EventHandlerMap[T];
const unsubscribe = () => {
const handlers = handlersRef.current[eventName] as EventHandlerSet<T> | undefined;
handlers?.delete(handler);
if (handlers && handlers.size === 0) {
delete handlersRef.current[eventName];
}
if (options?.signal) {
options.signal.removeEventListener("abort", unsubscribe);
}
};
if (options?.signal) {
options.signal.addEventListener("abort", unsubscribe, { once: true });
}
return unsubscribe;
},
[],
);
return { addEventListener }; return { addEventListener };
} }

View file

@ -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;
}
}

View 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;
}

View file

@ -69,7 +69,7 @@ describe("LoginPage", () => {
expect( expect(
await screen.findByText( await screen.findByText(
"Your account exists but is not linked to this SSO provider. Sign in with username/password first, then enable auto linking in your provider settings or contact your administrator.", "SSO sign-in was blocked because this email already belongs to another user in this instance. Contact your administrator to resolve the account conflict.",
), ),
).toBeTruthy(); ).toBeTruthy();
}); });

View file

@ -1,5 +1,4 @@
import { arktypeResolver } from "@hookform/resolvers/arktype"; import { zodResolver } from "@hookform/resolvers/zod";
import { type } from "arktype";
import { useState } from "react"; import { useState } from "react";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { toast } from "sonner"; import { toast } from "sonner";
@ -10,20 +9,23 @@ import { Input } from "~/client/components/ui/input";
import { InputOTP, InputOTPGroup, InputOTPSeparator, InputOTPSlot } from "~/client/components/ui/input-otp"; import { InputOTP, InputOTPGroup, InputOTPSeparator, InputOTPSlot } from "~/client/components/ui/input-otp";
import { Label } from "~/client/components/ui/label"; import { Label } from "~/client/components/ui/label";
import { authClient } from "~/client/lib/auth-client"; import { authClient } from "~/client/lib/auth-client";
import { decodeLoginError, getLoginErrorDescription } from "~/client/lib/auth-errors"; import { decodeLoginError, getLoginErrorDescription } from "~/client/lib/sso-errors";
import { ResetPasswordDialog } from "../components/reset-password-dialog"; import { ResetPasswordDialog } from "../components/reset-password-dialog";
import { useNavigate } from "@tanstack/react-router"; import { useNavigate } from "@tanstack/react-router";
import { normalizeUsername } from "~/lib/username"; import { normalizeUsername } from "~/lib/username";
import { useMutation, useSuspenseQuery } from "@tanstack/react-query";
import { getPublicSsoProvidersOptions } from "~/client/api-client/@tanstack/react-query.gen";
import { cn } from "~/client/lib/utils"; import { cn } from "~/client/lib/utils";
import { SsoLoginSection } from "~/client/modules/sso/components/sso-login-section";
import { z } from "zod";
const loginSchema = type({ const loginSchema = z.object({
username: "2<=string<=50", username: z
password: "string>=1", .string()
.min(2, "Username must be at least 2 characters")
.max(50, "Username must be at most 50 characters"),
password: z.string().min(1, "Password is required"),
}); });
type LoginFormValues = typeof loginSchema.inferIn; type LoginFormValues = z.input<typeof loginSchema>;
type LoginPageProps = { type LoginPageProps = {
error?: string; error?: string;
@ -38,14 +40,10 @@ export function LoginPage({ error }: LoginPageProps = {}) {
const [isVerifying2FA, setIsVerifying2FA] = useState(false); const [isVerifying2FA, setIsVerifying2FA] = useState(false);
const [trustDevice, setTrustDevice] = useState(false); const [trustDevice, setTrustDevice] = useState(false);
const errorCode = decodeLoginError(error); const errorCode = decodeLoginError(error);
const errorDescription = getLoginErrorDescription(errorCode); const errorDescription = errorCode ? getLoginErrorDescription(errorCode) : null;
const { data: ssoProviders } = useSuspenseQuery({
...getPublicSsoProvidersOptions(),
});
const form = useForm<LoginFormValues>({ const form = useForm<LoginFormValues>({
resolver: arktypeResolver(loginSchema), resolver: zodResolver(loginSchema),
defaultValues: { defaultValues: {
username: "", username: "",
password: "", password: "",
@ -129,27 +127,6 @@ export function LoginPage({ error }: LoginPageProps = {}) {
form.reset(); form.reset();
}; };
const ssoLoginMutation = useMutation({
mutationFn: async (providerId: string) => {
const callbackPath = "/login";
const { data, error } = await authClient.signIn.sso({
providerId: providerId,
callbackURL: callbackPath,
errorCallbackURL: "/api/v1/auth/login-error",
});
if (error) throw error;
return data;
},
onSuccess: (data) => {
window.location.href = data.url;
},
onError: (error) => {
console.error(error);
toast.error("SSO Login failed", { description: error.message });
},
});
if (requires2FA) { if (requires2FA) {
return ( return (
<AuthLayout title="Two-Factor Authentication" description="Enter the 6-digit code from your authenticator app"> <AuthLayout title="Two-Factor Authentication" description="Enter the 6-digit code from your authenticator app">
@ -265,26 +242,7 @@ export function LoginPage({ error }: LoginPageProps = {}) {
</form> </form>
</Form> </Form>
{ssoProviders.providers.length > 0 && ( <SsoLoginSection />
<div className="pt-4 border-t border-border/60 space-y-3">
<p className="text-sm font-medium">Alternative Sign-in</p>
<div className="flex flex-col gap-2">
{ssoProviders.providers.map((provider) => (
<Button
key={provider.providerId}
type="button"
variant="outline"
className="w-full"
loading={ssoLoginMutation.isPending}
disabled={ssoLoginMutation.isPending}
onClick={() => ssoLoginMutation.mutate(provider.providerId)}
>
Log in with {provider.providerId}
</Button>
))}
</div>
</div>
)}
<ResetPasswordDialog open={showResetDialog} onOpenChange={setShowResetDialog} /> <ResetPasswordDialog open={showResetDialog} onOpenChange={setShowResetDialog} />
</AuthLayout> </AuthLayout>

View file

@ -1,5 +1,4 @@
import { arktypeResolver } from "@hookform/resolvers/arktype"; import { zodResolver } from "@hookform/resolvers/zod";
import { type } from "arktype";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { toast } from "sonner"; import { toast } from "sonner";
import { import {
@ -19,24 +18,28 @@ import { authClient } from "~/client/lib/auth-client";
import { useState } from "react"; import { useState } from "react";
import { useNavigate } from "@tanstack/react-router"; import { useNavigate } from "@tanstack/react-router";
import { normalizeUsername } from "~/lib/username"; import { normalizeUsername } from "~/lib/username";
import { z } from "zod";
export const clientMiddleware = [authMiddleware]; export const clientMiddleware = [authMiddleware];
const onboardingSchema = type({ const onboardingSchema = z.object({
username: type("2<=string<=30").pipe(normalizeUsername), username: z.string().min(2).max(30).transform(normalizeUsername),
email: type("string.email").pipe((str) => str.trim().toLowerCase()), email: z
password: "string>=8", .string()
confirmPassword: "string>=1", .email()
.transform((str) => str.trim().toLowerCase()),
password: z.string().min(8),
confirmPassword: z.string().min(1),
}); });
type OnboardingFormValues = typeof onboardingSchema.inferIn; type OnboardingFormValues = z.input<typeof onboardingSchema>;
export function OnboardingPage() { export function OnboardingPage() {
const navigate = useNavigate(); const navigate = useNavigate();
const [submitting, setSubmitting] = useState(false); const [submitting, setSubmitting] = useState(false);
const form = useForm<OnboardingFormValues>({ const form = useForm<OnboardingFormValues>({
resolver: arktypeResolver(onboardingSchema), resolver: zodResolver(onboardingSchema),
defaultValues: { defaultValues: {
username: "", username: "",
password: "", password: "",

View file

@ -32,21 +32,21 @@ export const BackupCard = ({ schedule }: { schedule: BackupSchedule }) => {
<CardContent className="flex-1 space-y-4"> <CardContent className="flex-1 space-y-4">
<div className="space-y-3"> <div className="space-y-3">
<div className="flex items-center text-sm gap-2"> <div className="flex items-center text-sm gap-2">
<span className="text-zinc-500 shrink-0">Schedule</span> <span className="text-muted-foreground shrink-0">Schedule</span>
<div className="flex-1 border-b border-dashed border-white/10" /> <div className="flex-1 border-b border-dashed border-border/80 dark:border-border/50" />
<code className="text-xs text-zinc-100 font-mono bg-muted px-2 py-1 rounded shrink-0"> <code className="text-xs text-foreground font-mono bg-muted px-2 py-1 rounded shrink-0">
{schedule.cronExpression} {schedule.cronExpression}
</code> </code>
</div> </div>
<div className="flex items-center text-sm gap-2"> <div className="flex items-center text-sm gap-2">
<span className="text-zinc-500 shrink-0">Last backup</span> <span className="text-muted-foreground shrink-0">Last backup</span>
<div className="flex-1 border-b border-dashed border-white/10" /> <div className="flex-1 border-b border-dashed border-border/80 dark:border-border/50" />
<span className="text-zinc-100 font-mono text-sm shrink-0">{formatTimeAgo(schedule.lastBackupAt)}</span> <span className="text-foreground font-mono text-sm shrink-0">{formatTimeAgo(schedule.lastBackupAt)}</span>
</div> </div>
<div className="flex items-center text-sm gap-2"> <div className="flex items-center text-sm gap-2">
<span className="text-zinc-500 shrink-0">Next backup</span> <span className="text-muted-foreground shrink-0">Next backup</span>
<div className="flex-1 border-b border-dashed border-white/10" /> <div className="flex-1 border-b border-dashed border-border/80 dark:border-border/50" />
<span className="text-zinc-100 font-mono text-sm shrink-0"> <span className="text-foreground font-mono text-sm shrink-0">
{formatShortDateTime(schedule.nextBackupAt)} {formatShortDateTime(schedule.nextBackupAt)}
</span> </span>
</div> </div>

View file

@ -46,6 +46,7 @@ export const BackupProgressCard = ({ scheduleShortId, initialProgress }: Props)
const currentFile = progress?.current_files?.[0] || ""; const currentFile = progress?.current_files?.[0] || "";
const fileName = currentFile.split("/").pop() || currentFile; const fileName = currentFile.split("/").pop() || currentFile;
const speed = progress ? formatBytes(progress.bytes_done / progress.seconds_elapsed) : null; const speed = progress ? formatBytes(progress.bytes_done / progress.seconds_elapsed) : null;
const eta = progress?.seconds_remaining ? formatDuration(progress.seconds_remaining) : null;
return ( return (
<Card className="p-4"> <Card className="p-4">
@ -96,6 +97,10 @@ export const BackupProgressCard = ({ scheduleShortId, initialProgress }: Props)
{progress ? (progress.seconds_elapsed > 0 ? `${speed?.text} ${speed?.unit}/s` : "Calculating...") : "—"} {progress ? (progress.seconds_elapsed > 0 ? `${speed?.text} ${speed?.unit}/s` : "Calculating...") : "—"}
</p> </p>
</div> </div>
<div>
<p className="text-xs uppercase text-muted-foreground">ETA</p>
<p className="font-medium">{progress ? (eta ?? "Calculating...") : "—"}</p>
</div>
</div> </div>
<div className="pt-2 border-t border-border"> <div className="pt-2 border-t border-border">

View file

@ -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&#10;--no-scan&#10;--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>
)}
/>
);
};

View file

@ -1,17 +1,19 @@
import { arktypeResolver } from "@hookform/resolvers/arktype"; import { zodResolver } from "@hookform/resolvers/zod";
import { useCallback, useState } from "react"; import { useCallback, useState } from "react";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { useScrollToFormError } from "~/client/hooks/use-scroll-to-form-error"; import { useScrollToFormError } from "~/client/hooks/use-scroll-to-form-error";
import { Form } from "~/client/components/ui/form"; import { Form } from "~/client/components/ui/form";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "~/client/components/ui/card"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "~/client/components/ui/card";
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "~/client/components/ui/collapsible";
import type { BackupSchedule, Volume } from "~/client/lib/types"; import type { BackupSchedule, Volume } from "~/client/lib/types";
import { AdvancedSection } from "./advanced-section";
import { BasicInfoSection } from "./basic-info-section"; import { BasicInfoSection } from "./basic-info-section";
import { ExcludeSection } from "./exclude-section"; import { ExcludeSection } from "./exclude-section";
import { FrequencySection } from "./frequency-section"; import { FrequencySection } from "./frequency-section";
import { PathsSection } from "./paths-section"; import { PathsSection } from "./paths-section";
import { RetentionSection } from "./retention-section"; import { RetentionSection } from "./retention-section";
import { SummarySection } from "./summary-section"; import { SummarySection } from "./summary-section";
import { cleanSchema, type BackupScheduleFormValues, type InternalFormValues } from "./types"; import { internalFormSchema, type BackupScheduleFormValues, type InternalFormValues } from "./types";
import { backupScheduleToFormValues } from "./utils"; import { backupScheduleToFormValues } from "./utils";
export type { BackupScheduleFormValues }; export type { BackupScheduleFormValues };
@ -27,7 +29,7 @@ type Props = {
export const CreateScheduleForm = ({ initialValues, formId, onSubmit, volume }: Props) => { export const CreateScheduleForm = ({ initialValues, formId, onSubmit, volume }: Props) => {
const form = useForm<InternalFormValues>({ const form = useForm<InternalFormValues>({
resolver: arktypeResolver(cleanSchema as unknown as typeof import("./types").internalFormSchema), resolver: zodResolver(internalFormSchema),
defaultValues: backupScheduleToFormValues(initialValues), defaultValues: backupScheduleToFormValues(initialValues),
}); });
@ -39,6 +41,7 @@ export const CreateScheduleForm = ({ initialValues, formId, onSubmit, volume }:
excludePatternsText, excludePatternsText,
excludeIfPresentText, excludeIfPresentText,
includePatternsText, includePatternsText,
customResticParamsText,
includePatterns: fileBrowserPatterns, includePatterns: fileBrowserPatterns,
cronExpression, cronExpression,
...rest ...rest
@ -65,12 +68,20 @@ export const CreateScheduleForm = ({ initialValues, formId, onSubmit, volume }:
: []; : [];
const includePatterns = [...(fileBrowserPatterns || []), ...textPatterns]; const includePatterns = [...(fileBrowserPatterns || []), ...textPatterns];
const customResticParams = customResticParamsText
? customResticParamsText
.split("\n")
.map((p) => p.trim())
.filter(Boolean)
: [];
onSubmit({ onSubmit({
...rest, ...rest,
cronExpression, cronExpression,
includePatterns: includePatterns.length > 0 ? includePatterns : [], includePatterns: includePatterns.length > 0 ? includePatterns : [],
excludePatterns, excludePatterns,
excludeIfPresent, excludeIfPresent,
customResticParams,
}); });
}, },
[onSubmit], [onSubmit],
@ -164,6 +175,17 @@ export const CreateScheduleForm = ({ initialValues, formId, onSubmit, volume }:
<RetentionSection form={form} /> <RetentionSection form={form} />
</CardContent> </CardContent>
</Card> </Card>
<Card className="min-w-0 @container">
<CardContent>
<Collapsible>
<CollapsibleTrigger>Advanced</CollapsibleTrigger>
<CollapsibleContent className="pb-4 space-y-4">
<AdvancedSection form={form} />
</CollapsibleContent>
</Collapsible>
</CardContent>
</Card>
</div> </div>
<div className="xl:sticky xl:top-6 xl:self-start"> <div className="xl:sticky xl:top-6 xl:self-start">
<SummarySection volume={volume} frequency={frequency} formValues={formValues} /> <SummarySection volume={volume} frequency={frequency} formValues={formValues} />

View file

@ -22,7 +22,7 @@ export const RetentionSection = ({ form }: RetentionSectionProps) => {
type="number" type="number"
min={0} min={0}
placeholder="Optional" placeholder="Optional"
onChange={(v) => field.onChange(Number(v.target.value))} onChange={(v) => field.onChange(v.target.value === "" ? undefined : Number(v.target.value))}
/> />
</FormControl> </FormControl>
<FormDescription>Keep the N most recent snapshots.</FormDescription> <FormDescription>Keep the N most recent snapshots.</FormDescription>
@ -43,7 +43,7 @@ export const RetentionSection = ({ form }: RetentionSectionProps) => {
min={0} min={0}
placeholder="Optional" placeholder="Optional"
{...field} {...field}
onChange={(v) => field.onChange(Number(v.target.value))} onChange={(v) => field.onChange(v.target.value === "" ? undefined : Number(v.target.value))}
/> />
</FormControl> </FormControl>
<FormDescription>Keep the last N hourly snapshots.</FormDescription> <FormDescription>Keep the last N hourly snapshots.</FormDescription>
@ -64,7 +64,7 @@ export const RetentionSection = ({ form }: RetentionSectionProps) => {
min={0} min={0}
placeholder="e.g., 7" placeholder="e.g., 7"
{...field} {...field}
onChange={(v) => field.onChange(Number(v.target.value))} onChange={(v) => field.onChange(v.target.value === "" ? undefined : Number(v.target.value))}
/> />
</FormControl> </FormControl>
<FormDescription>Keep the last N daily snapshots.</FormDescription> <FormDescription>Keep the last N daily snapshots.</FormDescription>
@ -85,7 +85,7 @@ export const RetentionSection = ({ form }: RetentionSectionProps) => {
min={0} min={0}
placeholder="e.g., 4" placeholder="e.g., 4"
{...field} {...field}
onChange={(v) => field.onChange(Number(v.target.value))} onChange={(v) => field.onChange(v.target.value === "" ? undefined : Number(v.target.value))}
/> />
</FormControl> </FormControl>
<FormDescription>Keep the last N weekly snapshots.</FormDescription> <FormDescription>Keep the last N weekly snapshots.</FormDescription>
@ -106,7 +106,7 @@ export const RetentionSection = ({ form }: RetentionSectionProps) => {
min={0} min={0}
placeholder="e.g., 6" placeholder="e.g., 6"
{...field} {...field}
onChange={(v) => field.onChange(Number(v.target.value))} onChange={(v) => field.onChange(v.target.value === "" ? undefined : Number(v.target.value))}
/> />
</FormControl> </FormControl>
<FormDescription>Keep the last N monthly snapshots.</FormDescription> <FormDescription>Keep the last N monthly snapshots.</FormDescription>
@ -127,7 +127,7 @@ export const RetentionSection = ({ form }: RetentionSectionProps) => {
min={0} min={0}
placeholder="Optional" placeholder="Optional"
{...field} {...field}
onChange={(v) => field.onChange(Number(v.target.value))} onChange={(v) => field.onChange(v.target.value === "" ? undefined : Number(v.target.value))}
/> />
</FormControl> </FormControl>
<FormDescription>Keep the last N yearly snapshots.</FormDescription> <FormDescription>Keep the last N yearly snapshots.</FormDescription>

View file

@ -1,29 +1,27 @@
import { type } from "arktype"; import { z } from "zod";
import { deepClean } from "~/utils/object";
export const internalFormSchema = type({ export const internalFormSchema = z.object({
name: "1 <= string <= 128", name: z.string().min(1).max(128),
repositoryId: "string", repositoryId: z.string(),
excludePatternsText: "string?", excludePatternsText: z.string().optional(),
excludeIfPresentText: "string?", excludeIfPresentText: z.string().optional(),
includePatternsText: "string?", includePatternsText: z.string().optional(),
includePatterns: "string[]?", includePatterns: z.array(z.string()).optional(),
frequency: "string", frequency: z.string(),
dailyTime: "string?", dailyTime: z.string().optional(),
weeklyDay: "string?", weeklyDay: z.string().optional(),
monthlyDays: "string[]?", monthlyDays: z.array(z.string()).optional(),
cronExpression: "string?", cronExpression: z.string().optional(),
keepLast: "number?", keepLast: z.number().optional(),
keepHourly: "number?", keepHourly: z.number().optional(),
keepDaily: "number?", keepDaily: z.number().optional(),
keepWeekly: "number?", keepWeekly: z.number().optional(),
keepMonthly: "number?", keepMonthly: z.number().optional(),
keepYearly: "number?", keepYearly: z.number().optional(),
oneFileSystem: "boolean?", oneFileSystem: z.boolean().optional(),
customResticParamsText: z.string().optional(),
}); });
export const cleanSchema = type.pipe((d) => internalFormSchema(deepClean(d)));
export const weeklyDays = [ export const weeklyDays = [
{ label: "Monday", value: "1" }, { label: "Monday", value: "1" },
{ label: "Tuesday", value: "2" }, { label: "Tuesday", value: "2" },
@ -34,12 +32,13 @@ export const weeklyDays = [
{ label: "Sunday", value: "0" }, { label: "Sunday", value: "0" },
]; ];
export type InternalFormValues = typeof internalFormSchema.infer; export type InternalFormValues = z.infer<typeof internalFormSchema>;
export type BackupScheduleFormValues = Omit< export type BackupScheduleFormValues = Omit<
InternalFormValues, InternalFormValues,
"excludePatternsText" | "excludeIfPresentText" | "includePatternsText" "excludePatternsText" | "excludeIfPresentText" | "includePatternsText" | "customResticParamsText"
> & { > & {
excludePatterns?: string[]; excludePatterns?: string[];
excludeIfPresent?: string[]; excludeIfPresent?: string[];
customResticParams?: string[];
}; };

View file

@ -22,6 +22,7 @@ export const backupScheduleToFormValues = (schedule?: BackupSchedule): InternalF
excludePatternsText: schedule.excludePatterns?.join("\n") || undefined, excludePatternsText: schedule.excludePatterns?.join("\n") || undefined,
excludeIfPresentText: schedule.excludeIfPresent?.join("\n") || undefined, excludeIfPresentText: schedule.excludeIfPresent?.join("\n") || undefined,
oneFileSystem: schedule.oneFileSystem ?? false, oneFileSystem: schedule.oneFileSystem ?? false,
customResticParamsText: schedule.customResticParams?.join("\n") ?? "",
...cronValues, ...cronValues,
...schedule.retentionPolicy, ...schedule.retentionPolicy,
}; };

View file

@ -1,3 +1,4 @@
import { useRef } from "react";
import type { ListSnapshotsResponse } from "~/client/api-client"; import type { ListSnapshotsResponse } from "~/client/api-client";
import { ByteSize } from "~/client/components/bytes-size"; import { ByteSize } from "~/client/components/bytes-size";
import { Card, CardContent } from "~/client/components/ui/card"; import { Card, CardContent } from "~/client/components/ui/card";
@ -15,6 +16,7 @@ interface Props {
export const SnapshotTimeline = (props: Props) => { export const SnapshotTimeline = (props: Props) => {
const { snapshots, snapshotId, loading, onSnapshotSelect, error } = props; const { snapshots, snapshotId, loading, onSnapshotSelect, error } = props;
const selectedRef = useRef<HTMLButtonElement>(null);
if (error) { if (error) {
return ( return (
@ -51,13 +53,14 @@ export const SnapshotTimeline = (props: Props) => {
<div className="w-full bg-card"> <div className="w-full bg-card">
<div className="relative flex items-center"> <div className="relative flex items-center">
<div className="flex-1 overflow-hidden"> <div className="flex-1 overflow-hidden">
<div className="flex gap-4 overflow-x-auto pb-2 *:first:ml-2 *:last:mr-2"> <div className="snapshot-scrollable flex gap-4 overflow-x-auto pb-2 *:first:ml-2 *:last:mr-2">
{snapshots.map((snapshot) => { {snapshots.map((snapshot) => {
const date = new Date(snapshot.time); const date = new Date(snapshot.time);
const isSelected = snapshotId === snapshot.short_id; const isSelected = snapshotId === snapshot.short_id;
return ( return (
<button <button
ref={isSelected ? selectedRef : undefined}
type="button" type="button"
key={snapshot.short_id} key={snapshot.short_id}
onClick={() => onSnapshotSelect(snapshot.short_id)} onClick={() => onSnapshotSelect(snapshot.short_id)}

View file

@ -65,7 +65,9 @@ export function ScheduleDetailsPage(props: Props) {
const searchParams = useSearch({ from: "/(dashboard)/backups/$backupId/" }); const searchParams = useSearch({ from: "/(dashboard)/backups/$backupId/" });
const [isEditMode, setIsEditMode] = useState(false); const [isEditMode, setIsEditMode] = useState(false);
const formId = useId(); const formId = useId();
const [selectedSnapshotId, setSelectedSnapshotId] = useState<string | undefined>(initialSnapshotId); const [selectedSnapshotId, setSelectedSnapshotId] = useState<string | undefined>(
initialSnapshotId ?? loaderData.snapshots?.at(-1)?.short_id,
);
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false); const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
const [snapshotToDelete, setSnapshotToDelete] = useState<string | null>(null); const [snapshotToDelete, setSnapshotToDelete] = useState<string | null>(null);
@ -183,6 +185,7 @@ export function ScheduleDetailsPage(props: Props) {
excludePatterns: formValues.excludePatterns, excludePatterns: formValues.excludePatterns,
excludeIfPresent: formValues.excludeIfPresent, excludeIfPresent: formValues.excludeIfPresent,
oneFileSystem: formValues.oneFileSystem, oneFileSystem: formValues.oneFileSystem,
customResticParams: formValues.customResticParams,
}, },
}); });
}; };
@ -191,6 +194,7 @@ export function ScheduleDetailsPage(props: Props) {
updateSchedule.mutate({ updateSchedule.mutate({
path: { shortId: schedule.shortId }, path: { shortId: schedule.shortId },
body: { body: {
name: schedule.name,
repositoryId: schedule.repositoryId, repositoryId: schedule.repositoryId,
enabled, enabled,
cronExpression: schedule.cronExpression, cronExpression: schedule.cronExpression,
@ -199,6 +203,7 @@ export function ScheduleDetailsPage(props: Props) {
excludePatterns: schedule.excludePatterns || [], excludePatterns: schedule.excludePatterns || [],
excludeIfPresent: schedule.excludeIfPresent || [], excludeIfPresent: schedule.excludeIfPresent || [],
oneFileSystem: schedule.oneFileSystem, oneFileSystem: schedule.oneFileSystem,
customResticParams: schedule.customResticParams || [],
}, },
}); });
}; };

View file

@ -110,9 +110,9 @@ export function BackupsPage() {
); );
})} })}
<Link to="/backups/create" className="h-full"> <Link to="/backups/create" className="h-full">
<div className="group flex flex-col items-center justify-center h-full min-h-50 border border-dashed border-border/80 bg-card hover:bg-card/50 transition-colors cursor-pointer rounded-s shadow-sm hover:border-border"> <div className="group flex flex-col items-center justify-center h-full min-h-50 border-2 border-dashed border-border/60 bg-muted/20 dark:bg-card hover:bg-muted/40 dark:hover:bg-card/50 transition-all cursor-pointer rounded-xl hover:shadow-[0_8px_30px_-15px_rgba(0,0,0,0.05)] dark:hover:shadow-sm hover:border-border hover:-translate-y-[1px] active:scale-[0.98] duration-300">
<div className="flex flex-col items-center justify-center gap-3"> <div className="flex flex-col items-center justify-center gap-3">
<div className="p-3 rounded-full bg-muted/20 group-hover:bg-muted/50 transition-all group-hover:scale-110 duration-300"> <div className="p-3 rounded-full bg-background/50 dark:bg-muted/20 group-hover:bg-background dark:group-hover:bg-muted/50 transition-all group-hover:scale-110 duration-300 shadow-xs dark:shadow-none">
<Plus className="h-6 w-6 text-muted-foreground group-hover:text-foreground transition-colors" /> <Plus className="h-6 w-6 text-muted-foreground group-hover:text-foreground transition-colors" />
</div> </div>
<span className="text-sm font-medium text-muted-foreground group-hover:text-foreground transition-colors"> <span className="text-sm font-medium text-muted-foreground group-hover:text-foreground transition-colors">

View file

@ -73,6 +73,7 @@ export function CreateBackupPage() {
excludePatterns: formValues.excludePatterns, excludePatterns: formValues.excludePatterns,
excludeIfPresent: formValues.excludeIfPresent, excludeIfPresent: formValues.excludeIfPresent,
oneFileSystem: formValues.oneFileSystem, oneFileSystem: formValues.oneFileSystem,
customResticParams: formValues.customResticParams,
}, },
}); });
}; };

View file

@ -1,8 +1,7 @@
import { arktypeResolver } from "@hookform/resolvers/arktype"; import { zodResolver } from "@hookform/resolvers/zod";
import { type } from "arktype";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { z } from "zod";
import { cn } from "~/client/lib/utils"; import { cn } from "~/client/lib/utils";
import { deepClean } from "~/utils/object";
import { import {
Form, Form,
FormControl, FormControl,
@ -14,7 +13,17 @@ import {
} from "~/client/components/ui/form"; } from "~/client/components/ui/form";
import { Input } from "~/client/components/ui/input"; import { Input } from "~/client/components/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "~/client/components/ui/select"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "~/client/components/ui/select";
import { notificationConfigSchemaBase } from "~/schemas/notifications"; import {
customNotificationConfigSchema,
discordNotificationConfigSchema,
emailNotificationConfigSchema,
genericNotificationConfigSchema,
gotifyNotificationConfigSchema,
ntfyNotificationConfigSchema,
pushoverNotificationConfigSchema,
slackNotificationConfigSchema,
telegramNotificationConfigSchema,
} from "~/schemas/notifications";
import { useScrollToFormError } from "~/client/hooks/use-scroll-to-form-error"; import { useScrollToFormError } from "~/client/hooks/use-scroll-to-form-error";
import { import {
CustomForm, CustomForm,
@ -28,12 +37,21 @@ import {
TelegramForm, TelegramForm,
} from "./notification-forms"; } from "./notification-forms";
export const formSchema = type({ const baseFields = { name: z.string().min(2).max(32) };
name: "2<=string<=32",
}).and(notificationConfigSchemaBase);
const cleanSchema = type.pipe((d) => formSchema(deepClean(d)));
export type NotificationFormValues = typeof formSchema.inferIn; export const formSchema = z.discriminatedUnion("type", [
emailNotificationConfigSchema.extend(baseFields),
slackNotificationConfigSchema.extend(baseFields),
discordNotificationConfigSchema.extend(baseFields),
gotifyNotificationConfigSchema.extend(baseFields),
ntfyNotificationConfigSchema.extend(baseFields),
pushoverNotificationConfigSchema.extend(baseFields),
telegramNotificationConfigSchema.extend(baseFields),
genericNotificationConfigSchema.extend(baseFields),
customNotificationConfigSchema.extend(baseFields),
]);
export type NotificationFormValues = z.input<typeof formSchema>;
type Props = { type Props = {
onSubmit: (values: NotificationFormValues) => void; onSubmit: (values: NotificationFormValues) => void;
@ -106,7 +124,7 @@ const defaultValuesForType = {
export const CreateNotificationForm = ({ onSubmit, mode = "create", initialValues, formId, className }: Props) => { export const CreateNotificationForm = ({ onSubmit, mode = "create", initialValues, formId, className }: Props) => {
const form = useForm<NotificationFormValues>({ const form = useForm<NotificationFormValues>({
resolver: arktypeResolver(cleanSchema as unknown as typeof formSchema), resolver: zodResolver(formSchema, undefined, { raw: true }),
defaultValues: initialValues || { defaultValues: initialValues || {
name: "", name: "",
}, },

View file

@ -97,7 +97,7 @@ export function NotificationDetailsPage({ notificationId }: { notificationId: st
<div className="text-sm font-semibold text-muted-foreground flex items-center gap-2"> <div className="text-sm font-semibold text-muted-foreground flex items-center gap-2">
<span <span
className={cn("inline-flex items-center gap-2 px-2 py-1 rounded-md text-xs bg-gray-500/10 text-gray-500", { className={cn("inline-flex items-center gap-2 px-2 py-1 rounded-md text-xs bg-gray-500/10 text-gray-500", {
"bg-green-500/10 text-emerald-500": data.enabled, "bg-success/10 text-success": data.enabled,
"bg-red-500/10 text-red-500": !data.enabled, "bg-red-500/10 text-red-500": !data.enabled,
})} })}
> >

View file

@ -1,9 +1,16 @@
import { useQuery } from "@tanstack/react-query"; import { useMutation, useQuery } from "@tanstack/react-query";
import { Archive } from "lucide-react"; import { Archive, RefreshCw } from "lucide-react";
import { getRepositoryStatsOptions } from "~/client/api-client/@tanstack/react-query.gen"; import {
import { ByteSize } from "~/client/components/bytes-size"; getRepositoryStatsOptions,
import { Card, CardContent, CardTitle } from "~/client/components/ui/card"; refreshRepositoryStatsMutation,
} from "~/client/api-client/@tanstack/react-query.gen";
import type { GetRepositoryStatsResponse } from "~/client/api-client/types.gen"; import type { GetRepositoryStatsResponse } from "~/client/api-client/types.gen";
import { ByteSize } from "~/client/components/bytes-size";
import { Button } from "~/client/components/ui/button";
import { Card, CardContent, CardTitle } from "~/client/components/ui/card";
import { parseError } from "~/client/lib/errors";
import { cn } from "~/client/lib/utils";
import { toast } from "sonner";
type Props = { type Props = {
repositoryShortId: string; repositoryShortId: string;
@ -19,6 +26,18 @@ const toSafeNumber = (value: number | undefined) => {
}; };
export function CompressionStatsChart({ repositoryShortId, initialStats }: Props) { export function CompressionStatsChart({ repositoryShortId, initialStats }: Props) {
const refreshStats = useMutation({
...refreshRepositoryStatsMutation(),
onSuccess: () => {
toast.success("Repository stats refreshed");
},
onError: (mutationError) => {
toast.error("Failed to refresh repository stats", {
description: parseError(mutationError)?.message,
});
},
});
const { const {
data: stats, data: stats,
isPending, isPending,
@ -43,89 +62,84 @@ export function CompressionStatsChart({ repositoryShortId, initialStats }: Props
const hasStats = !!stats && (storedSize > 0 || uncompressedSize > 0 || snapshotsCount > 0); const hasStats = !!stats && (storedSize > 0 || uncompressedSize > 0 || snapshotsCount > 0);
if (isPending) {
return (
<Card className="p-6">
<p className="text-sm text-muted-foreground">Loading compression statistics...</p>
</Card>
);
}
if (error) {
return (
<Card className="p-6 border-red-500/20 bg-red-500/5">
<p className="text-sm font-medium text-red-500">Failed to load compression statistics</p>
<p className="mt-2 text-sm text-red-500/80 wrap-break-word">{error.message}</p>
</Card>
);
}
if (!hasStats) {
return (
<Card className="p-6">
<p className="text-sm text-muted-foreground">
No compression statistics available yet. Run a backup to populate repository stats.
</p>
</Card>
);
}
return ( return (
<Card className="flex flex-col px-6 py-6"> <Card className="flex flex-col px-6 py-6">
<div className="pb-4"> <div className="flex items-start justify-between gap-3 pb-4">
<CardTitle className="flex items-center gap-2"> <CardTitle className="flex items-center gap-2 w-full">
<Archive className="h-5 w-5 text-muted-foreground" /> <div className="flex items-center gap-2 flex-1">
Compression Statistics <Archive className="h-5 w-5 text-muted-foreground" />
Compression Statistics
</div>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => refreshStats.mutate({ path: { shortId: repositoryShortId } })}
disabled={refreshStats.isPending}
title="Refresh statistics"
>
<RefreshCw className={refreshStats.isPending ? "h-4 w-4 animate-spin" : "h-4 w-4"} />
</Button>
</CardTitle> </CardTitle>
</div> </div>
<div> <p className={cn("text-sm text-muted-foreground", { hidden: !isPending })}>Loading compression statistics...</p>
<CardContent className="grid grid-cols-2 lg:grid-cols-3 gap-y-6 gap-x-4 px-0"> <div className={cn("space-y-2", { hidden: !error || isPending })}>
<div className="flex flex-col gap-1.5"> <p className="text-sm font-medium text-destructive">Failed to load compression statistics</p>
<div className="text-sm font-medium text-muted-foreground">Stored Size</div> <p className="text-sm text-muted-foreground wrap-break-word">{error?.message}</p>
<div className="flex items-baseline gap-2">
<ByteSize base={1024} bytes={storedSize} className="text-xl font-semibold text-foreground font-mono" />
</div>
</div>
<div className="flex flex-col gap-1.5">
<div className="text-sm font-medium text-muted-foreground">Uncompressed</div>
<div className="flex items-baseline gap-2">
<ByteSize
base={1024}
bytes={uncompressedSize}
className="text-xl font-semibold text-foreground font-mono"
/>
</div>
</div>
<div className="flex flex-col gap-1.5">
<div className="text-sm font-medium text-muted-foreground">Space Saved</div>
<div className="flex items-baseline gap-2">
<span className="text-xl font-semibold text-foreground font-mono">{spaceSavingPercent.toFixed(1)}%</span>
<ByteSize base={1024} bytes={savedSize} className="text-xs text-muted-foreground font-mono" />
</div>
</div>
<div className="flex flex-col gap-1.5">
<div className="text-sm font-medium text-muted-foreground">Ratio</div>
<div className="flex items-baseline gap-2">
<span className="text-xl font-semibold text-foreground font-mono">
{compressionRatio > 0 ? `${compressionRatio.toFixed(2)}x` : "—"}
</span>
</div>
</div>
<div className="flex flex-col gap-1.5 lg:col-span-2">
<div className="text-sm font-medium text-muted-foreground">Snapshots</div>
<div className="flex items-baseline gap-2">
<span className="text-xl font-semibold text-foreground font-mono">{snapshotsCount.toLocaleString()}</span>
<span className="text-xs text-muted-foreground font-mono">
{compressionProgressPercent.toFixed(1)}% compressed
</span>
</div>
</div>
</CardContent>
</div> </div>
<p className={cn("text-sm text-muted-foreground", { hidden: isPending || !!error || hasStats })}>
Stats will be populated after your first backup. You can also refresh them manually.
</p>
<CardContent
className={cn("grid grid-cols-2 lg:grid-cols-3 gap-y-6 gap-x-4 px-0", {
hidden: isPending || !!error || !hasStats,
})}
>
<div className="flex flex-col gap-1.5">
<div className="text-sm font-medium text-muted-foreground">Stored Size</div>
<div className="flex items-baseline gap-2">
<ByteSize base={1024} bytes={storedSize} className="text-xl font-semibold text-foreground font-mono" />
</div>
</div>
<div className="flex flex-col gap-1.5">
<div className="text-sm font-medium text-muted-foreground">Uncompressed</div>
<div className="flex items-baseline gap-2">
<ByteSize
base={1024}
bytes={uncompressedSize}
className="text-xl font-semibold text-foreground font-mono"
/>
</div>
</div>
<div className="flex flex-col gap-1.5">
<div className="text-sm font-medium text-muted-foreground">Space Saved</div>
<div className="flex items-baseline gap-2">
<span className="text-xl font-semibold text-foreground font-mono">{spaceSavingPercent.toFixed(1)}%</span>
<ByteSize base={1024} bytes={savedSize} className="text-xs text-muted-foreground font-mono" />
</div>
</div>
<div className="flex flex-col gap-1.5">
<div className="text-sm font-medium text-muted-foreground">Ratio</div>
<div className="flex items-baseline gap-2">
<span className="text-xl font-semibold text-foreground font-mono">
{compressionRatio > 0 ? `${compressionRatio.toFixed(2)}x` : "-"}
</span>
</div>
</div>
<div className="flex flex-col gap-1.5 lg:col-span-2">
<div className="text-sm font-medium text-muted-foreground">Snapshots</div>
<div className="flex items-baseline gap-2">
<span className="text-xl font-semibold text-foreground font-mono">{snapshotsCount.toLocaleString()}</span>
<span className="text-xs text-muted-foreground font-mono">
{compressionProgressPercent.toFixed(1)}% compressed
</span>
</div>
</div>
</CardContent>
</Card> </Card>
); );
} }

View file

@ -1,10 +1,9 @@
import { arktypeResolver } from "@hookform/resolvers/arktype"; import { zodResolver } from "@hookform/resolvers/zod";
import { type } from "arktype";
import { useState } from "react"; import { useState } from "react";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { Save } from "lucide-react"; import { Save } from "lucide-react";
import { z } from "zod";
import { cn } from "~/client/lib/utils"; import { cn } from "~/client/lib/utils";
import { deepClean } from "~/utils/object";
import { Button } from "../../../components/ui/button"; import { Button } from "../../../components/ui/button";
import { import {
Form, Form,
@ -21,7 +20,17 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from ".
import { Tooltip, TooltipContent, TooltipTrigger } from "../../../components/ui/tooltip"; import { Tooltip, TooltipContent, TooltipTrigger } from "../../../components/ui/tooltip";
import { useSystemInfo } from "~/client/hooks/use-system-info"; import { useSystemInfo } from "~/client/hooks/use-system-info";
import { useScrollToFormError } from "~/client/hooks/use-scroll-to-form-error"; import { useScrollToFormError } from "~/client/hooks/use-scroll-to-form-error";
import { COMPRESSION_MODES, repositoryConfigSchemaBase } from "~/schemas/restic"; import {
COMPRESSION_MODES,
azureRepositoryConfigSchema,
gcsRepositoryConfigSchema,
localRepositoryConfigSchema,
r2RepositoryConfigSchema,
rcloneRepositoryConfigSchema,
restRepositoryConfigSchema,
s3RepositoryConfigSchema,
sftpRepositoryConfigSchema,
} from "~/schemas/restic";
import { Checkbox } from "../../../components/ui/checkbox"; import { Checkbox } from "../../../components/ui/checkbox";
import { import {
LocalRepositoryForm, LocalRepositoryForm,
@ -38,13 +47,23 @@ import { useServerFn } from "@tanstack/react-start";
import { getServerConstants } from "~/server/lib/functions/server-constants"; import { getServerConstants } from "~/server/lib/functions/server-constants";
import { useSuspenseQuery } from "@tanstack/react-query"; import { useSuspenseQuery } from "@tanstack/react-query";
export const formSchema = type({ const formBaseFields = {
name: "2<=string<=32", name: z.string().min(2).max(32),
compressionMode: type.valueOf(COMPRESSION_MODES).optional(), compressionMode: z.enum(COMPRESSION_MODES).optional(),
}).and(repositoryConfigSchemaBase); };
const cleanSchema = type.pipe((d) => formSchema(deepClean(d)));
export type RepositoryFormValues = typeof formSchema.inferIn; export const formSchema = z.discriminatedUnion("backend", [
localRepositoryConfigSchema.extend(formBaseFields),
s3RepositoryConfigSchema.extend(formBaseFields),
r2RepositoryConfigSchema.extend(formBaseFields),
gcsRepositoryConfigSchema.extend(formBaseFields),
azureRepositoryConfigSchema.extend(formBaseFields),
rcloneRepositoryConfigSchema.extend(formBaseFields),
restRepositoryConfigSchema.extend(formBaseFields),
sftpRepositoryConfigSchema.extend(formBaseFields),
]);
export type RepositoryFormValues = z.input<typeof formSchema>;
type Props = { type Props = {
onSubmit: (values: RepositoryFormValues) => void; onSubmit: (values: RepositoryFormValues) => void;
@ -81,7 +100,7 @@ export const CreateRepositoryForm = ({
}); });
const form = useForm<RepositoryFormValues>({ const form = useForm<RepositoryFormValues>({
resolver: arktypeResolver(cleanSchema as unknown as typeof formSchema), resolver: zodResolver(formSchema, undefined, { raw: true }),
defaultValues: initialValues, defaultValues: initialValues,
resetOptions: { resetOptions: {
keepDefaultValues: true, keepDefaultValues: true,

View file

@ -41,7 +41,7 @@ export const DoctorReport = ({ result, repositoryStatus }: Props) => {
<CollapsibleTrigger className="w-full flex items-center justify-between p-3 hover:bg-muted/50 transition-colors"> <CollapsibleTrigger className="w-full flex items-center justify-between p-3 hover:bg-muted/50 transition-colors">
<span className="text-sm font-medium capitalize">{step.step.replaceAll("_", " ")}</span> <span className="text-sm font-medium capitalize">{step.step.replaceAll("_", " ")}</span>
{step.success ? ( {step.success ? (
<CheckCircle2 className="h-4 w-4 text-emerald-500" /> <CheckCircle2 className="h-4 w-4 text-success" />
) : ( ) : (
<AlertCircle className="h-4 w-4 text-red-500" /> <AlertCircle className="h-4 w-4 text-red-500" />
)} )}

View file

@ -5,6 +5,7 @@ import { toast } from "sonner";
import { createRepositoryMutation } from "~/client/api-client/@tanstack/react-query.gen"; import { createRepositoryMutation } from "~/client/api-client/@tanstack/react-query.gen";
import { import {
CreateRepositoryForm, CreateRepositoryForm,
formSchema,
type RepositoryFormValues, type RepositoryFormValues,
} from "~/client/modules/repositories/components/create-repository-form"; } from "~/client/modules/repositories/components/create-repository-form";
import { Button } from "~/client/components/ui/button"; import { Button } from "~/client/components/ui/button";
@ -26,11 +27,13 @@ export function CreateRepositoryPage() {
}); });
const handleSubmit = (values: RepositoryFormValues) => { const handleSubmit = (values: RepositoryFormValues) => {
const { name, compressionMode, ...config } = formSchema.parse(values);
createRepository.mutate({ createRepository.mutate({
body: { body: {
config: values, config,
name: values.name, name,
compressionMode: values.compressionMode, compressionMode,
}, },
}); });
}; };

View file

@ -5,6 +5,7 @@ import { toast } from "sonner";
import { getRepositoryOptions, updateRepositoryMutation } from "~/client/api-client/@tanstack/react-query.gen"; import { getRepositoryOptions, updateRepositoryMutation } from "~/client/api-client/@tanstack/react-query.gen";
import { import {
CreateRepositoryForm, CreateRepositoryForm,
formSchema,
type RepositoryFormValues, type RepositoryFormValues,
} from "~/client/modules/repositories/components/create-repository-form"; } from "~/client/modules/repositories/components/create-repository-form";
import { import {
@ -35,11 +36,11 @@ const riskyLocationFieldsByBackend = {
rclone: ["remote", "path"], rclone: ["remote", "path"],
} as const; } as const;
const hasRiskyLocationChange = (initialConfig: RepositoryConfig, nextConfig: RepositoryFormValues): boolean => { const hasRiskyLocationChange = (initialConfig: RepositoryConfig, nextConfig: RepositoryConfig): boolean => {
const fields = riskyLocationFieldsByBackend[initialConfig.backend] ?? []; const fields = riskyLocationFieldsByBackend[initialConfig.backend] ?? [];
return fields.some( return fields.some(
(field) => initialConfig[field as keyof RepositoryConfig] !== nextConfig[field as keyof RepositoryFormValues], (field) => initialConfig[field as keyof RepositoryConfig] !== nextConfig[field as keyof RepositoryConfig],
); );
}; };
@ -75,18 +76,20 @@ export function EditRepositoryPage({ repositoryId }: { repositoryId: string }) {
}; };
const submitUpdate = (values: RepositoryFormValues) => { const submitUpdate = (values: RepositoryFormValues) => {
const { name, compressionMode, ...config } = formSchema.parse(values);
updateRepository.mutate({ updateRepository.mutate({
path: { shortId: repositoryId }, path: { shortId: repositoryId },
body: { body: {
name: values.name, name,
compressionMode: values.compressionMode, compressionMode,
config: values, config,
}, },
}); });
}; };
const handleSubmit = (values: RepositoryFormValues) => { const handleSubmit = (values: RepositoryFormValues) => {
const nextConfig = values; const { name: _name, compressionMode: _compressionMode, ...nextConfig } = formSchema.parse(values);
if (hasRiskyLocationChange(initialConfig, nextConfig)) { if (hasRiskyLocationChange(initialConfig, nextConfig)) {
setPendingValues(values); setPendingValues(values);

View file

@ -143,7 +143,7 @@ export function RepositoriesPage() {
className={cn( className={cn(
"inline-flex items-center gap-2 px-2 py-1 rounded-md text-xs bg-gray-500/10 text-gray-500", "inline-flex items-center gap-2 px-2 py-1 rounded-md text-xs bg-gray-500/10 text-gray-500",
{ {
"bg-green-500/10 text-emerald-500": repository.status === "healthy", "bg-success/10 text-success": repository.status === "healthy",
"bg-red-500/10 text-red-500": repository.status === "error", "bg-red-500/10 text-red-500": repository.status === "error",
}, },
)} )}

View file

@ -4,6 +4,7 @@ import {
getSnapshotDetailsOptions, getSnapshotDetailsOptions,
listBackupSchedulesOptions, listBackupSchedulesOptions,
} from "~/client/api-client/@tanstack/react-query.gen"; } from "~/client/api-client/@tanstack/react-query.gen";
import type { GetSnapshotDetailsResponse } from "~/client/api-client/types.gen";
import { Button } from "~/client/components/ui/button"; import { Button } from "~/client/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "~/client/components/ui/card"; import { Card, CardContent, CardHeader, CardTitle } from "~/client/components/ui/card";
import { SnapshotFileBrowser } from "~/client/modules/backups/components/snapshot-file-browser"; import { SnapshotFileBrowser } from "~/client/modules/backups/components/snapshot-file-browser";
@ -49,7 +50,13 @@ const SnapshotFileBrowserSkeleton = () => (
</div> </div>
); );
export function SnapshotDetailsPage({ repositoryId, snapshotId }: { repositoryId: string; snapshotId: string }) { type Props = {
repositoryId: string;
snapshotId: string;
initialSnapshot?: GetSnapshotDetailsResponse;
};
export function SnapshotDetailsPage({ repositoryId, snapshotId, initialSnapshot }: Props) {
const [showAllPaths, setShowAllPaths] = useState(false); const [showAllPaths, setShowAllPaths] = useState(false);
const { data: repository } = useSuspenseQuery({ const { data: repository } = useSuspenseQuery({
@ -62,6 +69,7 @@ export function SnapshotDetailsPage({ repositoryId, snapshotId }: { repositoryId
const { data, error } = useQuery({ const { data, error } = useQuery({
...getSnapshotDetailsOptions({ path: { shortId: repositoryId, snapshotId: snapshotId } }), ...getSnapshotDetailsOptions({ path: { shortId: repositoryId, snapshotId: snapshotId } }),
initialData: initialSnapshot,
}); });
const backupSchedule = schedules?.find((s) => data?.tags?.includes(s.shortId)); const backupSchedule = schedules?.find((s) => data?.tags?.includes(s.shortId));

View file

@ -187,7 +187,7 @@ export const RepositoryInfoTabContent = ({ repository, initialStats }: Props) =>
<p className="text-sm flex items-center gap-2"> <p className="text-sm flex items-center gap-2">
<span <span
className={cn("w-2 h-2 rounded-full", { className={cn("w-2 h-2 rounded-full", {
"bg-emerald-500": repository.status === "healthy", "bg-success": repository.status === "healthy",
"bg-red-500": repository.status === "error", "bg-red-500": repository.status === "error",
"bg-amber-500": repository.status !== "healthy" && repository.status !== "error", "bg-amber-500": repository.status !== "healthy" && repository.status !== "error",
"animate-pulse": repository.status === "doctor", "animate-pulse": repository.status === "doctor",
@ -209,7 +209,7 @@ export const RepositoryInfoTabContent = ({ repository, initialStats }: Props) =>
{hasCaCert && ( {hasCaCert && (
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
<div className="text-sm font-medium text-muted-foreground">CA Certificate</div> <div className="text-sm font-medium text-muted-foreground">CA Certificate</div>
<p className="text-sm text-green-500">Configured</p> <p className="text-sm text-success">Configured</p>
</div> </div>
)} )}
{hasInsecureTlsConfig && ( {hasInsecureTlsConfig && (
@ -217,7 +217,7 @@ export const RepositoryInfoTabContent = ({ repository, initialStats }: Props) =>
<div className="text-sm font-medium text-muted-foreground">TLS Validation</div> <div className="text-sm font-medium text-muted-foreground">TLS Validation</div>
<p className="text-sm"> <p className="text-sm">
<span className={cn("text-red-500", { hidden: !isTlsValidationDisabled })}>Disabled</span> <span className={cn("text-red-500", { hidden: !isTlsValidationDisabled })}>Disabled</span>
<span className={cn("text-green-500", { hidden: isTlsValidationDisabled })}>Enabled</span> <span className={cn("text-success", { hidden: isTlsValidationDisabled })}>Enabled</span>
</p> </p>
</div> </div>
)} )}

View file

@ -1,10 +1,10 @@
import { arktypeResolver } from "@hookform/resolvers/arktype"; import { zodResolver } from "@hookform/resolvers/zod";
import { useMutation } from "@tanstack/react-query"; import { useMutation } from "@tanstack/react-query";
import { type } from "arktype";
import { Plus, UserPlus } from "lucide-react"; import { Plus, UserPlus } from "lucide-react";
import { useState } from "react"; import { useState } from "react";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { toast } from "sonner"; import { toast } from "sonner";
import { z } from "zod";
import { Button } from "~/client/components/ui/button"; import { Button } from "~/client/components/ui/button";
import { import {
Dialog, Dialog,
@ -20,15 +20,15 @@ import { Input } from "~/client/components/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "~/client/components/ui/select"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "~/client/components/ui/select";
import { authClient } from "~/client/lib/auth-client"; import { authClient } from "~/client/lib/auth-client";
const createUserSchema = type({ const createUserSchema = z.object({
name: "string>=1", name: z.string().min(1),
username: "string>=1", username: z.string().min(1),
email: "string", email: z.string().email(),
password: "string>=8", password: z.string().min(8),
role: "'user'|'admin'", role: z.enum(["user", "admin"]),
}); });
type CreateUserFormValues = typeof createUserSchema.infer; type CreateUserFormValues = z.infer<typeof createUserSchema>;
interface CreateUserDialogProps { interface CreateUserDialogProps {
onUserCreated?: () => void; onUserCreated?: () => void;
@ -38,7 +38,7 @@ export function CreateUserDialog({ onUserCreated }: CreateUserDialogProps) {
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
const form = useForm<CreateUserFormValues>({ const form = useForm<CreateUserFormValues>({
resolver: arktypeResolver(createUserSchema), resolver: zodResolver(createUserSchema),
defaultValues: { defaultValues: {
name: "", name: "",
username: "", username: "",

View file

@ -2,12 +2,12 @@ import { useMutation, useSuspenseQuery } from "@tanstack/react-query";
import { Shield, ShieldAlert, Trash2 } from "lucide-react"; import { Shield, ShieldAlert, Trash2 } from "lucide-react";
import { useState } from "react"; import { useState } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import type { GetOrgMembersResponse } from "~/client/api-client";
import { import {
getOrgMembersOptions, getOrgMembersOptions,
removeOrgMemberMutation, removeOrgMemberMutation,
updateMemberRoleMutation, updateMemberRoleMutation,
} from "~/client/api-client/@tanstack/react-query.gen"; } from "~/client/api-client/@tanstack/react-query.gen";
import type { GetOrgMembersResponse } from "~/client/api-client/types.gen";
import { import {
AlertDialog, AlertDialog,
AlertDialogAction, AlertDialogAction,
@ -25,11 +25,11 @@ import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "~
import { useOrganizationContext } from "~/client/hooks/use-org-context"; import { useOrganizationContext } from "~/client/hooks/use-org-context";
import { cn } from "~/client/lib/utils"; import { cn } from "~/client/lib/utils";
type OrgMembersSectionProps = { type Props = {
initialMembers?: GetOrgMembersResponse; initialMembers?: GetOrgMembersResponse;
}; };
export function OrgMembersSection({ initialMembers }: OrgMembersSectionProps) { export function OrgMembersSection({ initialMembers }: Props) {
const { activeMember } = useOrganizationContext(); const { activeMember } = useOrganizationContext();
const [memberToRemove, setMemberToRemove] = useState<{ id: string; name: string } | null>(null); const [memberToRemove, setMemberToRemove] = useState<{ id: string; name: string } | null>(null);

View file

@ -34,7 +34,7 @@ export const TwoFactorSection = ({ twoFactorEnabled }: TwoFactorSectionProps) =>
<p className="text-sm font-medium"> <p className="text-sm font-medium">
Status:&nbsp; Status:&nbsp;
{twoFactorEnabled ? ( {twoFactorEnabled ? (
<span className="text-green-500">Enabled</span> <span className="text-success">Enabled</span>
) : ( ) : (
<span className="text-muted-foreground">Disabled</span> <span className="text-muted-foreground">Disabled</span>
)} )}

View file

@ -154,7 +154,7 @@ export function UserManagement({ currentUser }: { currentUser: { id: string } |
<Badge variant="outline" className={cn("text-red-500 border-red-500", { hidden: !user.banned })}> <Badge variant="outline" className={cn("text-red-500 border-red-500", { hidden: !user.banned })}>
Banned Banned
</Badge> </Badge>
<Badge variant="outline" className={cn("bg-green-500/10 text-emerald-500", { hidden: user.banned })}> <Badge variant="outline" className={cn("bg-success/10 text-success", { hidden: user.banned })}>
Active Active
</Badge> </Badge>
</TableCell> </TableCell>

View file

@ -3,6 +3,7 @@ import { Download, KeyRound, User, X, Settings as SettingsIcon, Building2 } from
import { useState } from "react"; import { useState } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import { downloadResticPasswordMutation } from "~/client/api-client/@tanstack/react-query.gen"; import { downloadResticPasswordMutation } from "~/client/api-client/@tanstack/react-query.gen";
import type { GetOrgMembersResponse, GetSsoSettingsResponse } from "~/client/api-client/types.gen";
import { Button } from "~/client/components/ui/button"; import { Button } from "~/client/components/ui/button";
import { Card, CardContent, CardDescription, CardTitle } from "~/client/components/ui/card"; import { Card, CardContent, CardDescription, CardTitle } from "~/client/components/ui/card";
import { import {
@ -21,18 +22,18 @@ import { authClient } from "~/client/lib/auth-client";
import { type AppContext } from "~/context"; import { type AppContext } from "~/context";
import { TwoFactorSection } from "../components/two-factor-section"; import { TwoFactorSection } from "../components/two-factor-section";
import { useNavigate, useSearch } from "@tanstack/react-router"; import { useNavigate, useSearch } from "@tanstack/react-router";
import { SsoSettingsSection } from "../components/sso/sso-settings-section"; import { SsoSettingsSection } from "~/client/modules/sso/components/sso-settings-section";
import { OrgMembersSection } from "../components/org-members-section"; import { OrgMembersSection } from "../components/org-members-section";
import { useOrganizationContext } from "~/client/hooks/use-org-context"; import { useOrganizationContext } from "~/client/hooks/use-org-context";
import type { GetOrgMembersResponse, GetSsoSettingsResponse } from "~/client/api-client";
type Props = { type Props = {
appContext: AppContext; appContext: AppContext;
initialMembers?: GetOrgMembersResponse; initialMembers?: GetOrgMembersResponse;
initialSsoSettings?: GetSsoSettingsResponse; initialSsoSettings?: GetSsoSettingsResponse;
initialOrigin?: string;
}; };
export function SettingsPage({ appContext, initialMembers, initialSsoSettings }: Props) { export function SettingsPage({ appContext, initialMembers, initialSsoSettings, initialOrigin }: Props) {
const [currentPassword, setCurrentPassword] = useState(""); const [currentPassword, setCurrentPassword] = useState("");
const [newPassword, setNewPassword] = useState(""); const [newPassword, setNewPassword] = useState("");
const [confirmPassword, setConfirmPassword] = useState(""); const [confirmPassword, setConfirmPassword] = useState("");
@ -163,8 +164,12 @@ export function SettingsPage({ appContext, initialMembers, initialSsoSettings }:
</div> </div>
<CardContent className="p-6 space-y-4"> <CardContent className="p-6 space-y-4">
<div className="space-y-2"> <div className="space-y-2">
<Label>Username</Label> <Label htmlFor="username">Username</Label>
<Input value={appContext.user?.username || ""} disabled className="max-w-md" /> <Input id="username" value={appContext.user?.username} disabled className="max-w-md" />
</div>
<div className="space-y-2">
<Label htmlFor="email">Email</Label>
<Input id="email" type="email" value={appContext.user?.email} disabled className="max-w-md" />
</div> </div>
</CardContent> </CardContent>
@ -312,7 +317,7 @@ export function SettingsPage({ appContext, initialMembers, initialSsoSettings }:
<CardDescription className="mt-1.5">Configure OIDC provider settings</CardDescription> <CardDescription className="mt-1.5">Configure OIDC provider settings</CardDescription>
</div> </div>
<CardContent className="p-6"> <CardContent className="p-6">
<SsoSettingsSection initialSettings={initialSsoSettings} /> <SsoSettingsSection initialSettings={initialSsoSettings} initialOrigin={initialOrigin} />
</CardContent> </CardContent>
</Card> </Card>
</TabsContent> </TabsContent>

View 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>
);
}

View file

@ -9,6 +9,7 @@ import {
getSsoSettingsOptions, getSsoSettingsOptions,
updateSsoProviderAutoLinkingMutation, updateSsoProviderAutoLinkingMutation,
} from "~/client/api-client/@tanstack/react-query.gen"; } from "~/client/api-client/@tanstack/react-query.gen";
import type { GetSsoSettingsResponse } from "~/client/api-client/types.gen";
import { import {
AlertDialog, AlertDialog,
AlertDialogAction, AlertDialogAction,
@ -33,17 +34,17 @@ import { getOrigin } from "~/client/functions/get-origin";
import { authClient } from "~/client/lib/auth-client"; import { authClient } from "~/client/lib/auth-client";
import { cn } from "~/client/lib/utils"; import { cn } from "~/client/lib/utils";
import { useServerFn } from "@tanstack/react-start"; import { useServerFn } from "@tanstack/react-start";
import type { GetSsoSettingsResponse } from "~/client/api-client";
type InvitationRole = "member" | "admin" | "owner"; type InvitationRole = "member" | "admin" | "owner";
type SsoSettingsSectionProps = { type Props = {
initialSettings?: GetSsoSettingsResponse; initialSettings?: GetSsoSettingsResponse;
initialOrigin?: string;
}; };
export function SsoSettingsSection({ initialSettings }: SsoSettingsSectionProps) { export function SsoSettingsSection({ initialSettings, initialOrigin }: Props) {
const originQuery = useServerFn(getOrigin); const originQuery = useServerFn(getOrigin);
const { data } = useSuspenseQuery({ queryKey: ["app-origin"], queryFn: originQuery }); const { data } = useSuspenseQuery({ queryKey: ["app-origin"], queryFn: originQuery, initialData: initialOrigin });
const navigate = useNavigate(); const navigate = useNavigate();
const { activeOrganization } = useOrganizationContext(); const { activeOrganization } = useOrganizationContext();

View file

@ -1,11 +1,11 @@
import { arktypeResolver } from "@hookform/resolvers/arktype"; import { zodResolver } from "@hookform/resolvers/zod";
import { useMutation } from "@tanstack/react-query"; import { useMutation } from "@tanstack/react-query";
import { useNavigate } from "@tanstack/react-router"; import { useNavigate } from "@tanstack/react-router";
import { type } from "arktype";
import { ShieldCheck, Plus } from "lucide-react"; import { ShieldCheck, Plus } from "lucide-react";
import { useId } from "react"; import { useId } from "react";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { toast } from "sonner"; import { toast } from "sonner";
import { z } from "zod";
import { updateSsoProviderAutoLinkingMutation } from "~/client/api-client/@tanstack/react-query.gen"; import { updateSsoProviderAutoLinkingMutation } from "~/client/api-client/@tanstack/react-query.gen";
import { Button } from "~/client/components/ui/button"; import { Button } from "~/client/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "~/client/components/ui/card"; import { Card, CardContent, CardHeader, CardTitle } from "~/client/components/ui/card";
@ -24,17 +24,17 @@ import { authClient } from "~/client/lib/auth-client";
import { parseError } from "~/client/lib/errors"; import { parseError } from "~/client/lib/errors";
import { useOrganizationContext } from "~/client/hooks/use-org-context"; import { useOrganizationContext } from "~/client/hooks/use-org-context";
const ssoProviderSchema = type({ const ssoProviderSchema = z.object({
providerId: "string>=1", providerId: z.string().min(1),
issuer: "string>=1", issuer: z.string().min(1),
domain: "string>=1", domain: z.string().min(1),
clientId: "string>=1", clientId: z.string().min(1),
clientSecret: "string>=1", clientSecret: z.string().min(1),
discoveryEndpoint: "string>=1", discoveryEndpoint: z.string().min(1),
linkMatchingEmails: "boolean", linkMatchingEmails: z.boolean(),
}); });
type ProviderForm = typeof ssoProviderSchema.infer; type ProviderForm = z.infer<typeof ssoProviderSchema>;
export function CreateSsoProviderPage() { export function CreateSsoProviderPage() {
const navigate = useNavigate(); const navigate = useNavigate();
@ -42,7 +42,7 @@ export function CreateSsoProviderPage() {
const { activeOrganization } = useOrganizationContext(); const { activeOrganization } = useOrganizationContext();
const form = useForm<ProviderForm>({ const form = useForm<ProviderForm>({
resolver: arktypeResolver(ssoProviderSchema), resolver: zodResolver(ssoProviderSchema),
defaultValues: { defaultValues: {
providerId: "", providerId: "",
issuer: "", issuer: "",

View file

@ -1,11 +1,10 @@
import { arktypeResolver } from "@hookform/resolvers/arktype"; import { zodResolver } from "@hookform/resolvers/zod";
import { useMutation } from "@tanstack/react-query"; import { useMutation } from "@tanstack/react-query";
import { type } from "arktype";
import { CheckCircle, Loader2, Plug, Save, XCircle } from "lucide-react"; import { CheckCircle, Loader2, Plug, Save, XCircle } from "lucide-react";
import { useState } from "react"; import { useState } from "react";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { z } from "zod";
import { cn } from "~/client/lib/utils"; import { cn } from "~/client/lib/utils";
import { deepClean } from "~/utils/object";
import { Button } from "../../../components/ui/button"; import { Button } from "../../../components/ui/button";
import { import {
Form, Form,
@ -18,19 +17,31 @@ import {
} from "../../../components/ui/form"; } from "../../../components/ui/form";
import { Input } from "../../../components/ui/input"; import { Input } from "../../../components/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "../../../components/ui/select"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "../../../components/ui/select";
import { volumeConfigSchemaBase } from "~/schemas/volumes"; import {
directoryConfigSchema,
nfsConfigSchema,
rcloneConfigSchema,
sftpConfigSchema,
smbConfigSchema,
volumeConfigSchema,
webdavConfigSchema,
} from "~/schemas/volumes";
import { testConnectionMutation } from "../../../api-client/@tanstack/react-query.gen"; import { testConnectionMutation } from "../../../api-client/@tanstack/react-query.gen";
import { Tooltip, TooltipContent, TooltipTrigger } from "../../../components/ui/tooltip"; import { Tooltip, TooltipContent, TooltipTrigger } from "../../../components/ui/tooltip";
import { useSystemInfo } from "~/client/hooks/use-system-info"; import { useSystemInfo } from "~/client/hooks/use-system-info";
import { useScrollToFormError } from "~/client/hooks/use-scroll-to-form-error"; import { useScrollToFormError } from "~/client/hooks/use-scroll-to-form-error";
import { DirectoryForm, NFSForm, SMBForm, WebDAVForm, RcloneForm, SFTPForm } from "./volume-forms"; import { DirectoryForm, NFSForm, SMBForm, WebDAVForm, RcloneForm, SFTPForm } from "./volume-forms";
export const formSchema = type({ export const formSchema = z.discriminatedUnion("backend", [
name: "2<=string<=32", directoryConfigSchema.extend({ name: z.string().min(2).max(32) }),
}).and(volumeConfigSchemaBase); nfsConfigSchema.extend({ name: z.string().min(2).max(32) }),
const cleanSchema = type.pipe((d) => formSchema(deepClean(d))); smbConfigSchema.extend({ name: z.string().min(2).max(32) }),
webdavConfigSchema.extend({ name: z.string().min(2).max(32) }),
rcloneConfigSchema.extend({ name: z.string().min(2).max(32) }),
sftpConfigSchema.extend({ name: z.string().min(2).max(32) }),
]);
export type FormValues = typeof formSchema.inferIn; export type FormValues = z.input<typeof formSchema>;
type Props = { type Props = {
onSubmit: (values: FormValues) => void; onSubmit: (values: FormValues) => void;
@ -52,7 +63,7 @@ const defaultValuesForType = {
export const CreateVolumeForm = ({ onSubmit, mode = "create", initialValues, formId, loading, className }: Props) => { export const CreateVolumeForm = ({ onSubmit, mode = "create", initialValues, formId, loading, className }: Props) => {
const form = useForm<FormValues>({ const form = useForm<FormValues>({
resolver: arktypeResolver(cleanSchema as unknown as typeof formSchema), resolver: zodResolver(formSchema, undefined, { raw: true }),
defaultValues: initialValues || { defaultValues: initialValues || {
name: "", name: "",
backend: "directory", backend: "directory",
@ -89,15 +100,22 @@ export const CreateVolumeForm = ({ onSubmit, mode = "create", initialValues, for
const handleTestConnection = async () => { const handleTestConnection = async () => {
const formValues = getValues(); const formValues = getValues();
const { name: _, ...configCandidate } = formValues;
const parsedConfig = volumeConfigSchema.safeParse(configCandidate);
if (!parsedConfig.success) {
setTestMessage({ success: false, message: "Please fix validation errors before testing the connection." });
return;
}
if ( if (
formValues.backend === "nfs" || parsedConfig.data.backend === "nfs" ||
formValues.backend === "smb" || parsedConfig.data.backend === "smb" ||
formValues.backend === "webdav" || parsedConfig.data.backend === "webdav" ||
formValues.backend === "sftp" parsedConfig.data.backend === "sftp"
) { ) {
testBackendConnection.mutate({ testBackendConnection.mutate({
body: { config: formValues }, body: { config: parsedConfig.data },
}); });
} }
}; };
@ -237,7 +255,7 @@ export const CreateVolumeForm = ({ onSubmit, mode = "create", initialValues, for
> >
{testBackendConnection.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />} {testBackendConnection.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
{!testBackendConnection.isPending && testMessage?.success && ( {!testBackendConnection.isPending && testMessage?.success && (
<CheckCircle className="mr-2 h-4 w-4 text-emerald-500" /> <CheckCircle className="mr-2 h-4 w-4 text-success" />
)} )}
{!testBackendConnection.isPending && testMessage && !testMessage.success && ( {!testBackendConnection.isPending && testMessage && !testMessage.success && (
<XCircle className="mr-2 h-4 w-4 text-red-500" /> <XCircle className="mr-2 h-4 w-4 text-red-500" />
@ -255,7 +273,7 @@ export const CreateVolumeForm = ({ onSubmit, mode = "create", initialValues, for
{testMessage && ( {testMessage && (
<div <div
className={cn("text-xs p-2 rounded-md text-wrap wrap-anywhere", { className={cn("text-xs p-2 rounded-md text-wrap wrap-anywhere", {
"bg-green-50 text-emerald-700 border border-green-200": testMessage.success, "bg-success/10 text-success border border-success/30": testMessage.success,
"bg-red-50 text-red-700 border border-red-200": !testMessage.success, "bg-red-50 text-red-700 border border-red-200": !testMessage.success,
})} })}
> >

View file

@ -51,7 +51,7 @@ export const HealthchecksCard = ({ volume }: Props) => {
<CardContent> <CardContent>
<div className="flex flex-col flex-1 justify-start"> <div className="flex flex-col flex-1 justify-start">
{volume.lastError && <span className="text-sm text-red-500 wrap-break-word">{volume.lastError}</span>} {volume.lastError && <span className="text-sm text-red-500 wrap-break-word">{volume.lastError}</span>}
{volume.status === "mounted" && <span className="text-md text-green-500">Healthy</span>} {volume.status === "mounted" && <span className="text-md text-success">Healthy</span>}
{volume.status !== "unmounted" && ( {volume.status !== "unmounted" && (
<span className="text-xs text-muted-foreground mb-4">Checked {formatTimeAgo(volume.lastHealthCheck)}</span> <span className="text-xs text-muted-foreground mb-4">Checked {formatTimeAgo(volume.lastHealthCheck)}</span>
)} )}

View file

@ -18,7 +18,7 @@ export function StorageChart({ statfs }: Props) {
{ {
name: "Used", name: "Used",
value: statfs.used, value: statfs.used,
fill: "#ff543a", fill: "var(--strong-accent)",
}, },
{ {
name: "Free", name: "Free",

View file

@ -3,7 +3,7 @@ import { HardDrive, Plus } from "lucide-react";
import { useId } from "react"; import { useId } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import { createVolumeMutation } from "~/client/api-client/@tanstack/react-query.gen"; import { createVolumeMutation } from "~/client/api-client/@tanstack/react-query.gen";
import { CreateVolumeForm, type FormValues } from "~/client/modules/volumes/components/create-volume-form"; import { CreateVolumeForm, formSchema, type FormValues } from "~/client/modules/volumes/components/create-volume-form";
import { Button } from "~/client/components/ui/button"; import { Button } from "~/client/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "~/client/components/ui/card"; import { Card, CardContent, CardHeader, CardTitle } from "~/client/components/ui/card";
import { parseError } from "~/client/lib/errors"; import { parseError } from "~/client/lib/errors";
@ -23,10 +23,12 @@ export function CreateVolumePage() {
}); });
const handleSubmit = (values: FormValues) => { const handleSubmit = (values: FormValues) => {
const { name, ...config } = formSchema.parse(values);
createVolume.mutate({ createVolume.mutate({
body: { body: {
config: values, config,
name: values.name, name,
}, },
}); });
}; };

View file

@ -134,7 +134,7 @@ export function VolumesPage() {
{filteredVolumes.map((volume) => ( {filteredVolumes.map((volume) => (
<TableRow <TableRow
key={volume.name} key={volume.name}
className="hover:bg-white/2 hover:cursor-pointer transition-colors h-12" className="hover:bg-muted/50 hover:cursor-pointer transition-colors h-12"
onClick={() => navigate({ to: `/volumes/${volume.shortId}` })} onClick={() => navigate({ to: `/volumes/${volume.shortId}` })}
> >
<TableCell className="font-medium font-mono text-strong-accent">{volume.name}</TableCell> <TableCell className="font-medium font-mono text-strong-accent">{volume.name}</TableCell>

View file

@ -2,7 +2,7 @@ import { useMutation } from "@tanstack/react-query";
import { useState } from "react"; import { useState } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import { Check, Plug, Trash2, Unplug } from "lucide-react"; import { Check, Plug, Trash2, Unplug } from "lucide-react";
import { CreateVolumeForm, type FormValues } from "~/client/modules/volumes/components/create-volume-form"; import { CreateVolumeForm, formSchema, type FormValues } from "~/client/modules/volumes/components/create-volume-form";
import { import {
AlertDialog, AlertDialog,
AlertDialogAction, AlertDialogAction,
@ -102,9 +102,11 @@ export const VolumeInfoTabContent = ({ volume, statfs }: Props) => {
const confirmUpdate = () => { const confirmUpdate = () => {
if (pendingValues) { if (pendingValues) {
const { name, ...config } = formSchema.parse(pendingValues);
updateMutation.mutate({ updateMutation.mutate({
path: { shortId: volume.shortId }, path: { shortId: volume.shortId },
body: { name: pendingValues.name, config: pendingValues }, body: { name, config },
}); });
} }
}; };

View file

@ -0,0 +1 @@
ALTER TABLE `backup_schedules_table` ADD `custom_restic_params` text DEFAULT '[]';

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,2 @@
ALTER TABLE `repositories_table` ADD `stats` text;--> statement-breakpoint
ALTER TABLE `repositories_table` ADD `stats_updated_at` integer;

File diff suppressed because it is too large Load diff

24
app/lib/sso-errors.ts Normal file
View 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.";
}
}

View file

@ -1,11 +1,11 @@
import { createFileRoute } from "@tanstack/react-router"; import { createFileRoute } from "@tanstack/react-router";
import { type } from "arktype"; import { z } from "zod";
import { LoginPage } from "~/client/modules/auth/routes/login"; import { LoginPage } from "~/client/modules/auth/routes/login";
export const Route = createFileRoute("/(auth)/login/error")({ export const Route = createFileRoute("/(auth)/login/error")({
component: RouteComponent, component: RouteComponent,
errorComponent: () => <div>Failed to load login error</div>, errorComponent: () => <div>Failed to load login error</div>,
validateSearch: type({ error: "string?" }), validateSearch: z.object({ error: z.string().optional() }),
head: () => ({ head: () => ({
meta: [ meta: [
{ title: "Zerobyte - Login Error" }, { title: "Zerobyte - Login Error" },

View file

@ -1,11 +1,11 @@
import { Outlet, createFileRoute, useRouterState } from "@tanstack/react-router"; import { Outlet, createFileRoute, useRouterState } from "@tanstack/react-router";
import { type } from "arktype"; import { z } from "zod";
import { LoginPage } from "~/client/modules/auth/routes/login"; import { LoginPage } from "~/client/modules/auth/routes/login";
export const Route = createFileRoute("/(auth)/login")({ export const Route = createFileRoute("/(auth)/login")({
component: RouteComponent, component: RouteComponent,
errorComponent: () => <div>Failed to load login</div>, errorComponent: () => <div>Failed to load login</div>,
validateSearch: type({ error: "string?" }), validateSearch: z.object({ error: z.string().optional() }),
head: () => ({ head: () => ({
meta: [ meta: [
{ title: "Zerobyte - Login" }, { title: "Zerobyte - Login" },

View file

@ -1,12 +1,12 @@
import { createFileRoute, redirect } from "@tanstack/react-router"; import { createFileRoute, redirect } from "@tanstack/react-router";
import { type } from "arktype"; import { z } from "zod";
import { fetchUser } from "../route"; import { fetchUser } from "../route";
import type { AppContext } from "~/context"; import type { AppContext } from "~/context";
import { AdminPage } from "~/client/modules/admin/routes/admin-page"; import { AdminPage } from "~/client/modules/admin/routes/admin-page";
import { getAdminUsersOptions, getRegistrationStatusOptions } from "~/client/api-client/@tanstack/react-query.gen"; import { getAdminUsersOptions, getRegistrationStatusOptions } from "~/client/api-client/@tanstack/react-query.gen";
export const Route = createFileRoute("/(dashboard)/admin/")({ export const Route = createFileRoute("/(dashboard)/admin/")({
validateSearch: type({ tab: "string?" }), validateSearch: z.object({ tab: z.string().optional() }),
component: RouteComponent, component: RouteComponent,
errorComponent: () => <div>Failed to load admin</div>, errorComponent: () => <div>Failed to load admin</div>,
loader: async ({ context }) => { loader: async ({ context }) => {

View file

@ -1,6 +1,7 @@
import { createFileRoute } from "@tanstack/react-router"; import { createFileRoute } from "@tanstack/react-router";
import { type } from "arktype"; import { z } from "zod";
import { import {
getBackupProgressOptions,
getBackupScheduleOptions, getBackupScheduleOptions,
getScheduleMirrorsOptions, getScheduleMirrorsOptions,
getScheduleNotificationsOptions, getScheduleNotificationsOptions,
@ -14,7 +15,7 @@ import { prefetchOrSkip } from "~/utils/prefetch";
export const Route = createFileRoute("/(dashboard)/backups/$backupId/")({ export const Route = createFileRoute("/(dashboard)/backups/$backupId/")({
component: RouteComponent, component: RouteComponent,
errorComponent: () => <div>Failed to load backup</div>, errorComponent: () => <div>Failed to load backup</div>,
validateSearch: type({ snapshot: "string?" }), validateSearch: z.object({ snapshot: z.string().optional() }),
loader: async ({ params, context }) => { loader: async ({ params, context }) => {
const { backupId } = params; const { backupId } = params;
@ -24,6 +25,7 @@ export const Route = createFileRoute("/(dashboard)/backups/$backupId/")({
context.queryClient.ensureQueryData({ ...listRepositoriesOptions() }), context.queryClient.ensureQueryData({ ...listRepositoriesOptions() }),
context.queryClient.ensureQueryData({ ...getScheduleNotificationsOptions({ path: { shortId: backupId } }) }), context.queryClient.ensureQueryData({ ...getScheduleNotificationsOptions({ path: { shortId: backupId } }) }),
context.queryClient.ensureQueryData({ ...getScheduleMirrorsOptions({ path: { shortId: backupId } }) }), context.queryClient.ensureQueryData({ ...getScheduleMirrorsOptions({ path: { shortId: backupId } }) }),
context.queryClient.ensureQueryData({ ...getBackupProgressOptions({ path: { shortId: backupId } }) }),
]); ]);
const snapshotOptions = listSnapshotsOptions({ const snapshotOptions = listSnapshotsOptions({

View file

@ -1,16 +1,30 @@
import { createFileRoute } from "@tanstack/react-router"; import { createFileRoute } from "@tanstack/react-router";
import { getRepositoryOptions } from "~/client/api-client/@tanstack/react-query.gen"; import {
getRepositoryOptions,
getSnapshotDetailsOptions,
listBackupSchedulesOptions,
} from "~/client/api-client/@tanstack/react-query.gen";
import { SnapshotDetailsPage } from "~/client/modules/repositories/routes/snapshot-details"; import { SnapshotDetailsPage } from "~/client/modules/repositories/routes/snapshot-details";
import { prefetchOrSkip } from "~/utils/prefetch";
export const Route = createFileRoute("/(dashboard)/repositories/$repositoryId/$snapshotId/")({ export const Route = createFileRoute("/(dashboard)/repositories/$repositoryId/$snapshotId/")({
component: RouteComponent, component: RouteComponent,
errorComponent: (e) => <div>{e.error.message}</div>, errorComponent: (e) => <div>{e.error.message}</div>,
loader: async ({ params, context }) => { loader: async ({ params, context }) => {
const res = await context.queryClient.ensureQueryData({ const [res] = await Promise.all([
...getRepositoryOptions({ path: { shortId: params.repositoryId } }), context.queryClient.ensureQueryData({ ...getRepositoryOptions({ path: { shortId: params.repositoryId } }) }),
}); context.queryClient.ensureQueryData({ ...listBackupSchedulesOptions() }),
]);
return res; const snapshotOptions = getSnapshotDetailsOptions({
path: { shortId: params.repositoryId, snapshotId: params.snapshotId },
});
await prefetchOrSkip(context.queryClient, snapshotOptions);
return {
...res,
snapshot: context.queryClient.getQueryData(snapshotOptions.queryKey),
};
}, },
staticData: { staticData: {
breadcrumb: (match) => [ breadcrumb: (match) => [
@ -32,6 +46,7 @@ export const Route = createFileRoute("/(dashboard)/repositories/$repositoryId/$s
function RouteComponent() { function RouteComponent() {
const { repositoryId, snapshotId } = Route.useParams(); const { repositoryId, snapshotId } = Route.useParams();
const { snapshot } = Route.useLoaderData();
return <SnapshotDetailsPage repositoryId={repositoryId} snapshotId={snapshotId} />; return <SnapshotDetailsPage repositoryId={repositoryId} snapshotId={snapshotId} initialSnapshot={snapshot} />;
} }

View file

@ -1,5 +1,5 @@
import { createFileRoute } from "@tanstack/react-router"; import { createFileRoute } from "@tanstack/react-router";
import { type } from "arktype"; import { z } from "zod";
import { import {
getRepositoryOptions, getRepositoryOptions,
getRepositoryStatsOptions, getRepositoryStatsOptions,
@ -14,24 +14,23 @@ export const Route = createFileRoute("/(dashboard)/repositories/$repositoryId/")
errorComponent: (e) => <div>{e.error.message}</div>, errorComponent: (e) => <div>{e.error.message}</div>,
loader: async ({ params, context }) => { loader: async ({ params, context }) => {
const snapshotOptions = listSnapshotsOptions({ path: { shortId: params.repositoryId } }); const snapshotOptions = listSnapshotsOptions({ path: { shortId: params.repositoryId } });
const schedulesOptions = listBackupSchedulesOptions();
const statsOptions = getRepositoryStatsOptions({ path: { shortId: params.repositoryId } }); const statsOptions = getRepositoryStatsOptions({ path: { shortId: params.repositoryId } });
const [res] = await Promise.all([ const [res, schedules] = await Promise.all([
context.queryClient.ensureQueryData(getRepositoryOptions({ path: { shortId: params.repositoryId } })), context.queryClient.ensureQueryData(getRepositoryOptions({ path: { shortId: params.repositoryId } })),
context.queryClient.ensureQueryData(listBackupSchedulesOptions()),
prefetchOrSkip(context.queryClient, snapshotOptions), prefetchOrSkip(context.queryClient, snapshotOptions),
prefetchOrSkip(context.queryClient, schedulesOptions),
prefetchOrSkip(context.queryClient, statsOptions), prefetchOrSkip(context.queryClient, statsOptions),
]); ]);
return { return {
...res, ...res,
snapshots: context.queryClient.getQueryData(snapshotOptions.queryKey), snapshots: context.queryClient.getQueryData(snapshotOptions.queryKey),
backupSchedules: context.queryClient.getQueryData(schedulesOptions.queryKey), backupSchedules: schedules,
stats: context.queryClient.getQueryData(statsOptions.queryKey), stats: context.queryClient.getQueryData(statsOptions.queryKey),
}; };
}, },
validateSearch: type({ tab: "string?" }), validateSearch: z.object({ tab: z.string().optional() }),
staticData: { staticData: {
breadcrumb: (match) => [ breadcrumb: (match) => [
{ label: "Repositories", href: "/repositories" }, { label: "Repositories", href: "/repositories" },

View file

@ -6,6 +6,7 @@ import { SIDEBAR_COOKIE_NAME } from "~/client/components/ui/sidebar";
import { authMiddleware } from "~/middleware/auth"; import { authMiddleware } from "~/middleware/auth";
import { auth } from "~/server/lib/auth"; import { auth } from "~/server/lib/auth";
import { getOrganizationContext } from "~/server/lib/functions/organization-context"; import { getOrganizationContext } from "~/server/lib/functions/organization-context";
import { getServerConstants } from "~/server/lib/functions/server-constants";
import { authService } from "~/server/modules/auth/auth.service"; import { authService } from "~/server/modules/auth/auth.service";
export const fetchUser = createServerFn({ method: "GET" }).handler(async () => { export const fetchUser = createServerFn({ method: "GET" }).handler(async () => {
@ -32,6 +33,10 @@ export const Route = createFileRoute("/(dashboard)")({
queryKey: ["organization-context"], queryKey: ["organization-context"],
queryFn: () => getOrganizationContext(), queryFn: () => getOrganizationContext(),
}), }),
context.queryClient.ensureQueryData({
queryKey: ["server-constants"],
queryFn: () => getServerConstants(),
}),
]); ]);
return authContext; return authContext;

View file

@ -2,33 +2,42 @@ import { createFileRoute } from "@tanstack/react-router";
import { fetchUser } from "../route"; import { fetchUser } from "../route";
import type { AppContext } from "~/context"; import type { AppContext } from "~/context";
import { SettingsPage } from "~/client/modules/settings/routes/settings"; import { SettingsPage } from "~/client/modules/settings/routes/settings";
import { type } from "arktype"; import { z } from "zod";
import { getOrganizationContext } from "~/server/lib/functions/organization-context";
import { getOrgMembersOptions, getSsoSettingsOptions } from "~/client/api-client/@tanstack/react-query.gen"; import { getOrgMembersOptions, getSsoSettingsOptions } from "~/client/api-client/@tanstack/react-query.gen";
import { getOrigin } from "~/client/functions/get-origin"; import { getOrigin } from "~/client/functions/get-origin";
import { getOrganizationContext } from "~/server/lib/functions/organization-context";
export const Route = createFileRoute("/(dashboard)/settings/")({ export const Route = createFileRoute("/(dashboard)/settings/")({
component: RouteComponent, component: RouteComponent,
validateSearch: type({ tab: "string?" }), validateSearch: z.object({ tab: z.string().optional() }),
errorComponent: () => <div>Failed to load settings</div>, errorComponent: () => <div>Failed to load settings</div>,
loader: async ({ context }) => { loader: async ({ context }) => {
const authContext = await fetchUser(); const [authContext, orgContext] = await Promise.all([
const orgContext = await getOrganizationContext(); fetchUser(),
context.queryClient.ensureQueryData({
queryKey: ["organization-context"],
queryFn: () => getOrganizationContext(),
}),
]);
const orgRole = orgContext.activeMember?.role; const orgRole = orgContext.activeMember?.role;
const shouldPrefetchOrgQueries = orgRole === "owner" || orgRole === "admin";
let org, members; if (shouldPrefetchOrgQueries) {
const [org, members, appOrigin] = await Promise.all([
if (authContext.user?.role === "admin" || orgRole === "owner" || orgRole === "admin") {
const promises = await Promise.all([
context.queryClient.ensureQueryData({ ...getSsoSettingsOptions() }), context.queryClient.ensureQueryData({ ...getSsoSettingsOptions() }),
context.queryClient.ensureQueryData({ ...getOrgMembersOptions() }), context.queryClient.ensureQueryData({ ...getOrgMembersOptions() }),
context.queryClient.ensureQueryData({ queryKey: ["app-origin"], queryFn: () => getOrigin() }), context.queryClient.ensureQueryData({ queryKey: ["app-origin"], queryFn: () => getOrigin() }),
]); ]);
org = promises[0];
members = promises[1]; return {
authContext: authContext as AppContext,
org,
members,
appOrigin,
};
} }
return { authContext: authContext as AppContext, org, members }; return { authContext: authContext as AppContext };
}, },
staticData: { staticData: {
breadcrumb: () => [{ label: "Settings" }], breadcrumb: () => [{ label: "Settings" }],
@ -36,7 +45,14 @@ export const Route = createFileRoute("/(dashboard)/settings/")({
}); });
function RouteComponent() { function RouteComponent() {
const { authContext, org, members } = Route.useLoaderData(); const { authContext, members, org, appOrigin } = Route.useLoaderData();
return <SettingsPage appContext={authContext} initialMembers={members} initialSsoSettings={org} />; return (
<SettingsPage
appContext={authContext}
initialMembers={members}
initialSsoSettings={org}
initialOrigin={appOrigin}
/>
);
} }

View file

@ -1,5 +1,5 @@
import { createFileRoute, redirect } from "@tanstack/react-router"; import { createFileRoute, redirect } from "@tanstack/react-router";
import { CreateSsoProviderPage } from "~/client/modules/settings/routes/create-sso-provider"; import { CreateSsoProviderPage } from "~/client/modules/sso/routes/create-sso-provider";
import { getOrganizationContext } from "~/server/lib/functions/organization-context"; import { getOrganizationContext } from "~/server/lib/functions/organization-context";
export const Route = createFileRoute("/(dashboard)/settings/sso/new")({ export const Route = createFileRoute("/(dashboard)/settings/sso/new")({

View file

@ -1,5 +1,5 @@
import { createFileRoute } from "@tanstack/react-router"; import { createFileRoute } from "@tanstack/react-router";
import { type } from "arktype"; import { z } from "zod";
import { getVolumeOptions } from "~/client/api-client/@tanstack/react-query.gen"; import { getVolumeOptions } from "~/client/api-client/@tanstack/react-query.gen";
import { VolumeDetails } from "~/client/modules/volumes/routes/volume-details"; import { VolumeDetails } from "~/client/modules/volumes/routes/volume-details";
@ -13,7 +13,7 @@ export const Route = createFileRoute("/(dashboard)/volumes/$volumeId")({
return res; return res;
}, },
validateSearch: type({ tab: "string?" }), validateSearch: z.object({ tab: z.string().optional() }),
staticData: { staticData: {
breadcrumb: (match) => [ breadcrumb: (match) => [
{ label: "Volumes", href: "/volumes" }, { label: "Volumes", href: "/volumes" },

View file

@ -2,14 +2,27 @@ import { Outlet, HeadContent, Scripts, createRootRouteWithContext } from "@tanst
import appCss from "../app.css?url"; import appCss from "../app.css?url";
import { apiClientMiddleware } from "~/middleware/api-client"; import { apiClientMiddleware } from "~/middleware/api-client";
import type { QueryClient } from "@tanstack/react-query"; import type { QueryClient } from "@tanstack/react-query";
import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
import { Toaster } from "~/client/components/ui/sonner"; import { Toaster } from "~/client/components/ui/sonner";
import { useServerEvents } from "~/client/hooks/use-server-events"; import { useServerEvents } from "~/client/hooks/use-server-events";
import { useEffect } from "react"; import { useEffect } from "react";
import { ThemeProvider, THEME_COOKIE_NAME } from "~/client/components/theme-provider";
import { createServerFn } from "@tanstack/react-start";
import { getCookie } from "@tanstack/react-start/server";
const fetchTheme = createServerFn({ method: "GET" }).handler(async () => {
const themeCookie = getCookie(THEME_COOKIE_NAME);
return (themeCookie === "light" ? "light" : "dark") as "light" | "dark";
});
export const Route = createRootRouteWithContext<{ queryClient: QueryClient }>()({ export const Route = createRootRouteWithContext<{ queryClient: QueryClient }>()({
server: { server: {
middleware: [apiClientMiddleware], middleware: [apiClientMiddleware],
}, },
loader: async () => {
const theme = await fetchTheme();
return { theme };
},
head: () => ({ head: () => ({
meta: [{ title: "Zerobyte - Open Source Backup Solution" }], meta: [{ title: "Zerobyte - Open Source Backup Solution" }],
links: [ links: [
@ -34,6 +47,7 @@ export const Route = createRootRouteWithContext<{ queryClient: QueryClient }>()(
}); });
function RootLayout() { function RootLayout() {
const { theme } = Route.useLoaderData();
useServerEvents(); useServerEvents();
useEffect(() => { useEffect(() => {
document.body.setAttribute("data-app-ready", "true"); document.body.setAttribute("data-app-ready", "true");
@ -43,7 +57,7 @@ function RootLayout() {
}, []); }, []);
return ( return (
<html lang="en"> <html lang="en" className={theme === "dark" ? "dark" : undefined} style={{ colorScheme: theme }}>
<head> <head>
<meta charSet="utf-8" /> <meta charSet="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no" /> <meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no" />
@ -55,9 +69,12 @@ function RootLayout() {
<link rel="manifest" href="/images/favicon/site.webmanifest" /> <link rel="manifest" href="/images/favicon/site.webmanifest" />
<HeadContent /> <HeadContent />
</head> </head>
<body className="dark"> <body>
<Outlet /> <ThemeProvider initialTheme={theme}>
<Toaster /> <Outlet />
<Toaster />
<ReactQueryDevtools buttonPosition="bottom-right" />
</ThemeProvider>
<Scripts /> <Scripts />
</body> </body>
</html> </html>

View file

@ -1,9 +1,26 @@
import { createFileRoute } from "@tanstack/react-router"; import { createFileRoute } from "@tanstack/react-router";
import { createApp } from "~/server/app"; import { createApp } from "~/server/app";
import { config } from "~/server/core/config";
const app = createApp(); const app = createApp();
const handle = ({ request }: { request: Request }) => app.fetch(request.clone()); type NodeRuntimeRequest = Request & {
runtime?: {
node?: {
res?: { setTimeout: (timeoutMs: number) => void };
};
};
};
export const prepareApiRequest = (request: Request, timeoutMs: number) => {
const nodeRequest = request as NodeRuntimeRequest;
nodeRequest.runtime?.node?.res?.setTimeout(timeoutMs);
return request.clone();
};
const handle = ({ request }: { request: Request }) =>
app.fetch(prepareApiRequest(request, config.serverIdleTimeout * 1000));
export const Route = createFileRoute("/api/$")({ export const Route = createFileRoute("/api/$")({
server: { server: {

View file

@ -1,85 +1,84 @@
import { type } from "arktype"; import { z } from "zod";
import { resticBackupProgressMetricsSchema, resticBackupRunSummarySchema } from "~/schemas/restic-dto"; import { resticBackupProgressMetricsSchema, resticBackupRunSummarySchema } from "~/schemas/restic-dto";
export const backupEventStatusSchema = type("'success' | 'error' | 'stopped' | 'warning'"); export const backupEventStatusSchema = z.enum(["success", "error", "stopped", "warning"]);
export const restoreEventStatusSchema = type("'success' | 'error'"); export const restoreEventStatusSchema = z.enum(["success", "error"]);
const backupEventBaseSchema = type({ const backupEventBaseSchema = z.object({
scheduleId: "string", scheduleId: z.string(),
volumeName: "string", volumeName: z.string(),
repositoryName: "string", repositoryName: z.string(),
}); });
const organizationScopedSchema = type({ const organizationScopedSchema = z.object({
organizationId: "string", organizationId: z.string(),
}); });
const restoreEventBaseSchema = type({ const restoreEventBaseSchema = z.object({
repositoryId: "string", repositoryId: z.string(),
snapshotId: "string", snapshotId: z.string(),
}); });
const dumpStartedEventSchema = type({ const dumpStartedEventSchema = z.object({
repositoryId: "string", repositoryId: z.string(),
snapshotId: "string", snapshotId: z.string(),
path: "string", path: z.string(),
filename: "string", filename: z.string(),
}); });
const restoreProgressMetricsSchema = type({ const restoreProgressMetricsSchema = z.object({
seconds_elapsed: "number", seconds_elapsed: z.number(),
percent_done: "number", percent_done: z.number(),
total_files: "number", total_files: z.number(),
files_restored: "number", files_restored: z.number(),
total_bytes: "number", total_bytes: z.number(),
bytes_restored: "number", bytes_restored: z.number(),
}); });
export const backupStartedEventSchema = backupEventBaseSchema; export const backupStartedEventSchema = backupEventBaseSchema;
export const backupProgressEventSchema = backupEventBaseSchema.and(resticBackupProgressMetricsSchema); export const backupProgressEventSchema = backupEventBaseSchema.extend(resticBackupProgressMetricsSchema.shape);
export const backupCompletedEventSchema = backupEventBaseSchema.and( export const backupCompletedEventSchema = backupEventBaseSchema.extend({
type({ status: backupEventStatusSchema,
status: backupEventStatusSchema, summary: resticBackupRunSummarySchema.optional(),
summary: resticBackupRunSummarySchema.optional(), });
}),
);
export const restoreStartedEventSchema = restoreEventBaseSchema; export const restoreStartedEventSchema = restoreEventBaseSchema;
export const restoreProgressEventSchema = restoreEventBaseSchema.and(restoreProgressMetricsSchema); export const restoreProgressEventSchema = restoreEventBaseSchema.extend(restoreProgressMetricsSchema.shape);
export const restoreCompletedEventSchema = restoreEventBaseSchema.and( export const restoreCompletedEventSchema = restoreEventBaseSchema.extend({
type({ status: restoreEventStatusSchema, error: "string?" }), status: restoreEventStatusSchema,
); error: z.string().optional(),
});
export const serverBackupStartedEventSchema = organizationScopedSchema.and(backupStartedEventSchema); export const serverBackupStartedEventSchema = organizationScopedSchema.extend(backupStartedEventSchema.shape);
export const serverBackupProgressEventSchema = organizationScopedSchema.and(backupProgressEventSchema); export const serverBackupProgressEventSchema = organizationScopedSchema.extend(backupProgressEventSchema.shape);
export const serverBackupCompletedEventSchema = organizationScopedSchema.and(backupCompletedEventSchema); export const serverBackupCompletedEventSchema = organizationScopedSchema.extend(backupCompletedEventSchema.shape);
export const serverRestoreStartedEventSchema = organizationScopedSchema.and(restoreStartedEventSchema); export const serverRestoreStartedEventSchema = organizationScopedSchema.extend(restoreStartedEventSchema.shape);
export const serverRestoreProgressEventSchema = organizationScopedSchema.and(restoreProgressEventSchema); export const serverRestoreProgressEventSchema = organizationScopedSchema.extend(restoreProgressEventSchema.shape);
export const serverRestoreCompletedEventSchema = organizationScopedSchema.and(restoreCompletedEventSchema); export const serverRestoreCompletedEventSchema = organizationScopedSchema.extend(restoreCompletedEventSchema.shape);
export const serverDumpStartedEventSchema = organizationScopedSchema.and(dumpStartedEventSchema); export const serverDumpStartedEventSchema = organizationScopedSchema.extend(dumpStartedEventSchema.shape);
export type BackupEventStatusDto = typeof backupEventStatusSchema.infer; export type BackupEventStatusDto = z.infer<typeof backupEventStatusSchema>;
export type BackupStartedEventDto = typeof backupStartedEventSchema.infer; export type BackupStartedEventDto = z.infer<typeof backupStartedEventSchema>;
export type BackupProgressEventDto = typeof backupProgressEventSchema.infer; export type BackupProgressEventDto = z.infer<typeof backupProgressEventSchema>;
export type BackupCompletedEventDto = typeof backupCompletedEventSchema.infer; export type BackupCompletedEventDto = z.infer<typeof backupCompletedEventSchema>;
export type RestoreStartedEventDto = typeof restoreStartedEventSchema.infer; export type RestoreStartedEventDto = z.infer<typeof restoreStartedEventSchema>;
export type RestoreProgressEventDto = typeof restoreProgressEventSchema.infer; export type RestoreProgressEventDto = z.infer<typeof restoreProgressEventSchema>;
export type RestoreCompletedEventDto = typeof restoreCompletedEventSchema.infer; export type RestoreCompletedEventDto = z.infer<typeof restoreCompletedEventSchema>;
export type DumpStartedEventDto = typeof dumpStartedEventSchema.infer; export type DumpStartedEventDto = z.infer<typeof dumpStartedEventSchema>;
export type ServerBackupStartedEventDto = typeof serverBackupStartedEventSchema.infer; export type ServerBackupStartedEventDto = z.infer<typeof serverBackupStartedEventSchema>;
export type ServerBackupProgressEventDto = typeof serverBackupProgressEventSchema.infer; export type ServerBackupProgressEventDto = z.infer<typeof serverBackupProgressEventSchema>;
export type ServerBackupCompletedEventDto = typeof serverBackupCompletedEventSchema.infer; export type ServerBackupCompletedEventDto = z.infer<typeof serverBackupCompletedEventSchema>;
export type ServerRestoreStartedEventDto = typeof serverRestoreStartedEventSchema.infer; export type ServerRestoreStartedEventDto = z.infer<typeof serverRestoreStartedEventSchema>;
export type ServerRestoreProgressEventDto = typeof serverRestoreProgressEventSchema.infer; export type ServerRestoreProgressEventDto = z.infer<typeof serverRestoreProgressEventSchema>;
export type ServerRestoreCompletedEventDto = typeof serverRestoreCompletedEventSchema.infer; export type ServerRestoreCompletedEventDto = z.infer<typeof serverRestoreCompletedEventSchema>;
export type ServerDumpStartedEventDto = typeof serverDumpStartedEventSchema.infer; export type ServerDumpStartedEventDto = z.infer<typeof serverDumpStartedEventSchema>;

View file

@ -1,4 +1,4 @@
import { type } from "arktype"; import { z } from "zod";
export const NOTIFICATION_TYPES = { export const NOTIFICATION_TYPES = {
email: "email", email: "email",
@ -14,96 +14,98 @@ export const NOTIFICATION_TYPES = {
export type NotificationType = keyof typeof NOTIFICATION_TYPES; export type NotificationType = keyof typeof NOTIFICATION_TYPES;
export const emailNotificationConfigSchema = type({ export const emailNotificationConfigSchema = z.object({
type: "'email'", type: z.literal("email"),
smtpHost: "string", smtpHost: z.string().min(1),
smtpPort: "1 <= number <= 65535", smtpPort: z.number().int().min(1).max(65535),
username: "string?", username: z.string().optional(),
password: "string?", password: z.string().optional(),
from: "string", from: z.string().min(1),
fromName: "string?", fromName: z.string().optional(),
to: "string[]", to: z.array(z.string()),
useTLS: "boolean", useTLS: z.boolean(),
}); });
export const slackNotificationConfigSchema = type({ export const slackNotificationConfigSchema = z.object({
type: "'slack'", type: z.literal("slack"),
webhookUrl: "string", webhookUrl: z.string().min(1),
channel: "string?", channel: z.string().optional(),
username: "string?", username: z.string().optional(),
iconEmoji: "string?", iconEmoji: z.string().optional(),
}); });
export const discordNotificationConfigSchema = type({ export const discordNotificationConfigSchema = z.object({
type: "'discord'", type: z.literal("discord"),
webhookUrl: "string", webhookUrl: z.string().min(1),
username: "string?", username: z.string().optional(),
avatarUrl: "string?", avatarUrl: z.string().optional(),
threadId: "string?", threadId: z.string().optional(),
}); });
export const gotifyNotificationConfigSchema = type({ export const gotifyNotificationConfigSchema = z.object({
type: "'gotify'", type: z.literal("gotify"),
serverUrl: "string", serverUrl: z.string().min(1),
token: "string", token: z.string().min(1),
path: "string?", path: z.string().optional(),
priority: "0 <= number <= 10", priority: z.number().min(0).max(10),
}); });
export const ntfyNotificationConfigSchema = type({ export const ntfyNotificationConfigSchema = z.object({
type: "'ntfy'", type: z.literal("ntfy"),
serverUrl: "string?", serverUrl: z.string().optional(),
topic: "string", topic: z.string().min(1),
priority: "'max' | 'high' | 'default' | 'low' | 'min'", priority: z.enum(["max", "high", "default", "low", "min"]),
username: "string?", username: z.string().optional(),
password: "string?", password: z.string().optional(),
accessToken: "string?", accessToken: z.string().optional(),
}); });
export const pushoverNotificationConfigSchema = type({ export const pushoverNotificationConfigSchema = z.object({
type: "'pushover'", type: z.literal("pushover"),
userKey: "string", userKey: z.string().min(1),
apiToken: "string", apiToken: z.string().min(1),
devices: "string?", devices: z.string().optional(),
priority: "-1 | 0 | 1", priority: z.union([z.literal(-1), z.literal(0), z.literal(1)]),
}); });
export const telegramNotificationConfigSchema = type({ export const telegramNotificationConfigSchema = z.object({
type: "'telegram'", type: z.literal("telegram"),
botToken: "string", botToken: z.string().min(1),
chatId: "string", chatId: z.string().min(1),
threadId: "string?", threadId: z.string().optional(),
}); });
export const genericNotificationConfigSchema = type({ export const genericNotificationConfigSchema = z.object({
type: "'generic'", type: z.literal("generic"),
url: "string", url: z.string().min(1),
method: "'GET' | 'POST'", method: z.enum(["GET", "POST"]),
contentType: "string?", contentType: z.string().optional(),
headers: "string[]?", headers: z.array(z.string()).optional(),
useJson: "boolean?", useJson: z.boolean().optional(),
titleKey: "string?", titleKey: z.string().optional(),
messageKey: "string?", messageKey: z.string().optional(),
}); });
export const customNotificationConfigSchema = type({ export const customNotificationConfigSchema = z.object({
type: "'custom'", type: z.literal("custom"),
shoutrrrUrl: "string", shoutrrrUrl: z.string().min(1),
}); });
export const notificationConfigSchemaBase = emailNotificationConfigSchema export const notificationConfigSchemaBase = z.discriminatedUnion("type", [
.or(slackNotificationConfigSchema) emailNotificationConfigSchema,
.or(discordNotificationConfigSchema) slackNotificationConfigSchema,
.or(gotifyNotificationConfigSchema) discordNotificationConfigSchema,
.or(ntfyNotificationConfigSchema) gotifyNotificationConfigSchema,
.or(pushoverNotificationConfigSchema) ntfyNotificationConfigSchema,
.or(telegramNotificationConfigSchema) pushoverNotificationConfigSchema,
.or(genericNotificationConfigSchema) telegramNotificationConfigSchema,
.or(customNotificationConfigSchema); genericNotificationConfigSchema,
customNotificationConfigSchema,
]);
export const notificationConfigSchema = notificationConfigSchemaBase.onUndeclaredKey("delete"); export const notificationConfigSchema = notificationConfigSchemaBase;
export type NotificationConfig = typeof notificationConfigSchema.infer; export type NotificationConfig = z.infer<typeof notificationConfigSchema>;
export const NOTIFICATION_EVENTS = { export const NOTIFICATION_EVENTS = {
start: "start", start: "start",

View file

@ -1,82 +1,73 @@
import { type } from "arktype"; import { z } from "zod";
export const resticSummaryBaseSchema = type({ export const resticSummaryBaseSchema = z.object({
files_new: "number", files_new: z.number(),
files_changed: "number", files_changed: z.number(),
files_unmodified: "number", files_unmodified: z.number(),
dirs_new: "number", dirs_new: z.number(),
dirs_changed: "number", dirs_changed: z.number(),
dirs_unmodified: "number", dirs_unmodified: z.number(),
data_blobs: "number", data_blobs: z.number(),
tree_blobs: "number", tree_blobs: z.number(),
data_added: "number", data_added: z.number(),
data_added_packed: "number?", data_added_packed: z.number().optional(),
total_files_processed: "number", total_files_processed: z.number(),
total_bytes_processed: "number", total_bytes_processed: z.number(),
}); });
export const resticSnapshotSummarySchema = resticSummaryBaseSchema.and( export const resticSnapshotSummarySchema = resticSummaryBaseSchema.extend({
type({ backup_start: z.string(),
backup_start: "string", backup_end: z.string(),
backup_end: "string",
}),
);
export const resticBackupRunSummarySchema = resticSummaryBaseSchema.and(
type({
total_duration: "number",
snapshot_id: "string",
}),
);
export const resticBackupOutputSchema = resticBackupRunSummarySchema.and(
type({
message_type: "'summary'",
}),
);
export const resticBackupProgressMetricsSchema = type({
seconds_elapsed: "number",
percent_done: "number",
total_files: "number",
files_done: "number",
total_bytes: "number",
bytes_done: "number",
current_files: type("string")
.array()
.default(() => []),
}); });
export const resticBackupProgressSchema = resticBackupProgressMetricsSchema.and( export const resticBackupRunSummarySchema = resticSummaryBaseSchema.extend({
type({ total_duration: z.number(),
message_type: "'status'", snapshot_id: z.string(),
}),
);
export const resticRestoreOutputSchema = type({
message_type: "'summary'",
total_files: "number?",
files_restored: "number",
files_skipped: "number",
total_bytes: "number?",
bytes_restored: "number?",
bytes_skipped: "number",
}); });
export const resticStatsSchema = type({ export const resticBackupOutputSchema = resticBackupRunSummarySchema.extend({
total_size: "number = 0", message_type: z.literal("summary"),
total_uncompressed_size: "number = 0",
compression_ratio: "number = 0",
compression_progress: "number = 0",
compression_space_saving: "number = 0",
snapshots_count: "number = 0",
}); });
export type ResticSnapshotSummaryDto = typeof resticSnapshotSummarySchema.infer; export const resticBackupProgressMetricsSchema = z.object({
export type ResticBackupRunSummaryDto = typeof resticBackupRunSummarySchema.infer; seconds_elapsed: z.number(),
export type ResticBackupOutputDto = typeof resticBackupOutputSchema.infer; seconds_remaining: z.number().default(0),
export type ResticBackupProgressMetricsDto = typeof resticBackupProgressMetricsSchema.infer; percent_done: z.number(),
export type ResticBackupProgressDto = typeof resticBackupProgressSchema.infer; total_files: z.number(),
files_done: z.number(),
total_bytes: z.number(),
bytes_done: z.number(),
current_files: z.array(z.string()).default([]),
});
export type ResticRestoreOutputDto = typeof resticRestoreOutputSchema.infer; export const resticBackupProgressSchema = resticBackupProgressMetricsSchema.extend({
export type ResticStatsDto = typeof resticStatsSchema.infer; message_type: z.literal("status"),
});
export const resticRestoreOutputSchema = z.object({
message_type: z.literal("summary"),
total_files: z.number().optional(),
files_restored: z.number(),
files_skipped: z.number(),
total_bytes: z.number().optional(),
bytes_restored: z.number().optional(),
bytes_skipped: z.number(),
});
export const resticStatsSchema = z.object({
total_size: z.number().default(0),
total_uncompressed_size: z.number().default(0),
compression_ratio: z.number().default(0),
compression_progress: z.number().default(0),
compression_space_saving: z.number().default(0),
snapshots_count: z.number().default(0),
});
export type ResticSnapshotSummaryDto = z.infer<typeof resticSnapshotSummarySchema>;
export type ResticBackupRunSummaryDto = z.infer<typeof resticBackupRunSummarySchema>;
export type ResticBackupOutputDto = z.infer<typeof resticBackupOutputSchema>;
export type ResticBackupProgressMetricsDto = z.infer<typeof resticBackupProgressMetricsSchema>;
export type ResticBackupProgressDto = z.infer<typeof resticBackupProgressSchema>;
export type ResticRestoreOutputDto = z.infer<typeof resticRestoreOutputSchema>;
export type ResticStatsDto = z.infer<typeof resticStatsSchema>;

View file

@ -1,4 +1,4 @@
import { type } from "arktype"; import { z } from "zod";
export const REPOSITORY_BACKENDS = { export const REPOSITORY_BACKENDS = {
local: "local", local: "local",
@ -21,98 +21,120 @@ export const BANDWIDTH_UNITS = {
export type BandwidthUnit = keyof typeof BANDWIDTH_UNITS; export type BandwidthUnit = keyof typeof BANDWIDTH_UNITS;
export const bandwidthLimitSchema = type({ const bandwidthUnitSchema = z.enum(["Kbps", "Mbps", "Gbps"]);
enabled: "boolean = false",
value: "number > 0 = 1", export const bandwidthLimitSchema = z.object({
unit: type.valueOf(BANDWIDTH_UNITS).default("Mbps"), enabled: z.boolean().default(false),
value: z.number().positive().default(1),
unit: bandwidthUnitSchema.default("Mbps"),
}); });
export type BandwidthLimit = typeof bandwidthLimitSchema.infer; export type BandwidthLimit = z.infer<typeof bandwidthLimitSchema>;
// Common fields for all repository configs const baseRepositoryConfigSchema = z.object({
const baseRepositoryConfigSchema = type({ isExistingRepository: z.boolean().optional(),
isExistingRepository: "boolean?", customPassword: z.string().optional(),
customPassword: "string?", cacert: z.string().optional(),
cacert: "string?", insecureTls: z.boolean().optional(),
insecureTls: "boolean?",
// Bandwidth controls
uploadLimit: bandwidthLimitSchema.optional(), uploadLimit: bandwidthLimitSchema.optional(),
downloadLimit: bandwidthLimitSchema.optional(), downloadLimit: bandwidthLimitSchema.optional(),
}); });
export const s3RepositoryConfigSchema = type({ export const s3RepositoryConfigSchema = z
backend: "'s3'", .object({
endpoint: "string", backend: z.literal("s3"),
bucket: "string", endpoint: z.string().min(1),
accessKeyId: "string", bucket: z.string().min(1),
secretAccessKey: "string", accessKeyId: z.string().min(1),
}).and(baseRepositoryConfigSchema); secretAccessKey: z.string().min(1),
})
.extend(baseRepositoryConfigSchema.shape);
export const r2RepositoryConfigSchema = type({ export const r2RepositoryConfigSchema = z
backend: "'r2'", .object({
endpoint: "string", backend: z.literal("r2"),
bucket: "string", endpoint: z.string().min(1),
accessKeyId: "string", bucket: z.string().min(1),
secretAccessKey: "string", accessKeyId: z.string().min(1),
}).and(baseRepositoryConfigSchema); secretAccessKey: z.string().min(1),
})
.extend(baseRepositoryConfigSchema.shape);
export const localRepositoryConfigSchema = type({ export const localRepositoryConfigSchema = z
backend: "'local'", .object({
path: "string", backend: z.literal("local"),
}).and(baseRepositoryConfigSchema); path: z.string().min(1),
})
.extend(baseRepositoryConfigSchema.shape);
export const gcsRepositoryConfigSchema = type({ export const gcsRepositoryConfigSchema = z
backend: "'gcs'", .object({
bucket: "string", backend: z.literal("gcs"),
projectId: "string", bucket: z.string().min(1),
credentialsJson: "string", projectId: z.string().min(1),
}).and(baseRepositoryConfigSchema); credentialsJson: z.string().min(1),
})
.extend(baseRepositoryConfigSchema.shape);
export const azureRepositoryConfigSchema = type({ export const azureRepositoryConfigSchema = z
backend: "'azure'", .object({
container: "string", backend: z.literal("azure"),
accountName: "string", container: z.string().min(1),
accountKey: "string", accountName: z.string().min(1),
endpointSuffix: "string?", accountKey: z.string().min(1),
}).and(baseRepositoryConfigSchema); endpointSuffix: z.string().optional(),
})
.extend(baseRepositoryConfigSchema.shape);
export const rcloneRepositoryConfigSchema = type({ export const rcloneRepositoryConfigSchema = z
backend: "'rclone'", .object({
remote: "string", backend: z.literal("rclone"),
path: "string", remote: z.string().min(1),
}).and(baseRepositoryConfigSchema); path: z.string().min(1),
})
.extend(baseRepositoryConfigSchema.shape);
export const restRepositoryConfigSchema = type({ export const restRepositoryConfigSchema = z
backend: "'rest'", .object({
url: "string", backend: z.literal("rest"),
username: "string?", url: z.string().min(1),
password: "string?", username: z.string().optional(),
path: "string?", password: z.string().optional(),
}).and(baseRepositoryConfigSchema); path: z.string().optional(),
})
.extend(baseRepositoryConfigSchema.shape);
export const sftpRepositoryConfigSchema = type({ export const sftpRepositoryConfigSchema = z
backend: "'sftp'", .object({
host: "string", backend: z.literal("sftp"),
port: type("string.integer").or(type("number")).to("1 <= number <= 65535").default(22), host: z.string().min(1),
user: "string", port: z
path: "string", .union([z.string(), z.number()])
privateKey: "string", .transform((value) => (typeof value === "string" ? Number.parseInt(value, 10) : value))
skipHostKeyCheck: "boolean = true", .pipe(z.number().int().min(1).max(65535))
knownHosts: "string?", .default(22),
}).and(baseRepositoryConfigSchema); user: z.string().min(1),
path: z.string().min(1),
privateKey: z.string().min(1),
skipHostKeyCheck: z.boolean().default(true),
knownHosts: z.string().optional(),
})
.extend(baseRepositoryConfigSchema.shape);
export const repositoryConfigSchemaBase = s3RepositoryConfigSchema export const repositoryConfigSchemaBase = z.discriminatedUnion("backend", [
.or(r2RepositoryConfigSchema) s3RepositoryConfigSchema,
.or(localRepositoryConfigSchema) r2RepositoryConfigSchema,
.or(gcsRepositoryConfigSchema) localRepositoryConfigSchema,
.or(azureRepositoryConfigSchema) gcsRepositoryConfigSchema,
.or(rcloneRepositoryConfigSchema) azureRepositoryConfigSchema,
.or(restRepositoryConfigSchema) rcloneRepositoryConfigSchema,
.or(sftpRepositoryConfigSchema); restRepositoryConfigSchema,
sftpRepositoryConfigSchema,
]);
export const repositoryConfigSchema = repositoryConfigSchemaBase.onUndeclaredKey("delete"); export const repositoryConfigSchema = repositoryConfigSchemaBase;
export type RepositoryConfig = typeof repositoryConfigSchema.infer; export type RepositoryConfig = z.infer<typeof repositoryConfigSchema>;
export const COMPRESSION_MODES = { export const COMPRESSION_MODES = {
off: "off", off: "off",
@ -132,22 +154,22 @@ export const REPOSITORY_STATUS = {
export type RepositoryStatus = keyof typeof REPOSITORY_STATUS; export type RepositoryStatus = keyof typeof REPOSITORY_STATUS;
export const doctorStepSchema = type({ export const doctorStepSchema = z.object({
step: "string", step: z.string(),
success: "boolean", success: z.boolean(),
output: "string | null", output: z.string().nullable(),
error: "string | null", error: z.string().nullable(),
}); });
export type DoctorStep = typeof doctorStepSchema.infer; export type DoctorStep = z.infer<typeof doctorStepSchema>;
export const doctorResultSchema = type({ export const doctorResultSchema = z.object({
success: "boolean", success: z.boolean(),
steps: doctorStepSchema.array(), steps: doctorStepSchema.array(),
completedAt: "number", completedAt: z.number(),
}); });
export type DoctorResult = typeof doctorResultSchema.infer; export type DoctorResult = z.infer<typeof doctorResultSchema>;
export const OVERWRITE_MODES = { export const OVERWRITE_MODES = {
always: "always", always: "always",

View file

@ -1,4 +1,4 @@
import { type } from "arktype"; import { z } from "zod";
export const BACKEND_TYPES = { export const BACKEND_TYPES = {
nfs: "nfs", nfs: "nfs",
@ -11,75 +11,93 @@ export const BACKEND_TYPES = {
export type BackendType = keyof typeof BACKEND_TYPES; export type BackendType = keyof typeof BACKEND_TYPES;
export const nfsConfigSchema = type({ export const nfsConfigSchema = z.object({
backend: "'nfs'", backend: z.literal("nfs"),
server: "string", server: z.string().min(1),
exportPath: "string", exportPath: z.string().min(1),
port: type("string.integer").or(type("number")).to("1 <= number <= 65536").default(2049), port: z
version: "'3' | '4' | '4.1'", .union([z.string(), z.number()])
readOnly: "boolean?", .transform((value) => (typeof value === "string" ? Number.parseInt(value, 10) : value))
.pipe(z.number().int().min(1).max(65535))
.default(2049),
version: z.enum(["3", "4", "4.1"]),
readOnly: z.boolean().optional(),
}); });
export const smbConfigSchema = type({ export const smbConfigSchema = z.object({
backend: "'smb'", backend: z.literal("smb"),
server: "string", server: z.string().min(1),
share: "string", share: z.string().min(1),
username: "string?", username: z.string().optional(),
password: "string?", password: z.string().optional(),
guest: "boolean?", guest: z.boolean().optional(),
vers: type("'1.0' | '2.0' | '2.1' | '3.0' | 'auto'").default("auto"), vers: z.enum(["1.0", "2.0", "2.1", "3.0", "auto"]).default("auto"),
domain: "string?", domain: z.string().optional(),
port: type("string.integer").or(type("number")).to("1 <= number <= 65535").default(445), port: z
readOnly: "boolean?", .union([z.string(), z.number()])
.transform((value) => (typeof value === "string" ? Number.parseInt(value, 10) : value))
.pipe(z.number().int().min(1).max(65535))
.default(445),
readOnly: z.boolean().optional(),
}); });
export const directoryConfigSchema = type({ export const directoryConfigSchema = z.object({
backend: "'directory'", backend: z.literal("directory"),
path: "string", path: z.string().min(1),
readOnly: "false?", readOnly: z.literal(false).optional(),
}); });
export const webdavConfigSchema = type({ export const webdavConfigSchema = z.object({
backend: "'webdav'", backend: z.literal("webdav"),
server: "string", server: z.string().min(1),
path: "string", path: z.string().min(1),
username: "string?", username: z.string().optional(),
password: "string?", password: z.string().optional(),
port: type("string.integer").or(type("number")).to("1 <= number <= 65536").default(80), port: z
readOnly: "boolean?", .union([z.string(), z.number()])
ssl: "boolean?", .transform((value) => (typeof value === "string" ? Number.parseInt(value, 10) : value))
.pipe(z.number().int().min(1).max(65535))
.default(80),
readOnly: z.boolean().optional(),
ssl: z.boolean().optional(),
}); });
export const rcloneConfigSchema = type({ export const rcloneConfigSchema = z.object({
backend: "'rclone'", backend: z.literal("rclone"),
remote: "string", remote: z.string().min(1),
path: "string", path: z.string().min(1),
readOnly: "boolean?", readOnly: z.boolean().optional(),
}); });
export const sftpConfigSchema = type({ export const sftpConfigSchema = z.object({
backend: "'sftp'", backend: z.literal("sftp"),
host: "string", host: z.string().min(1),
port: type("string.integer").or(type("number")).to("1 <= number <= 65535").default(22), port: z
username: "string", .union([z.string(), z.number()])
password: "string?", .transform((value) => (typeof value === "string" ? Number.parseInt(value, 10) : value))
privateKey: "string?", .pipe(z.number().int().min(1).max(65535))
path: "string", .default(22),
readOnly: "boolean?", username: z.string().min(1),
skipHostKeyCheck: "boolean = true", password: z.string().optional(),
knownHosts: "string?", privateKey: z.string().optional(),
path: z.string().min(1),
readOnly: z.boolean().optional(),
skipHostKeyCheck: z.boolean().default(true),
knownHosts: z.string().optional(),
}); });
export const volumeConfigSchemaBase = nfsConfigSchema export const volumeConfigSchemaBase = z.discriminatedUnion("backend", [
.or(smbConfigSchema) nfsConfigSchema,
.or(webdavConfigSchema) smbConfigSchema,
.or(directoryConfigSchema) webdavConfigSchema,
.or(rcloneConfigSchema) directoryConfigSchema,
.or(sftpConfigSchema); rcloneConfigSchema,
sftpConfigSchema,
]);
export const volumeConfigSchema = volumeConfigSchemaBase.onUndeclaredKey("delete"); export const volumeConfigSchema = volumeConfigSchemaBase;
export type BackendConfig = typeof volumeConfigSchema.infer; export type BackendConfig = z.infer<typeof volumeConfigSchema>;
export const BACKEND_STATUS = { export const BACKEND_STATUS = {
mounted: "mounted", mounted: "mounted",

View file

@ -7,6 +7,7 @@ import { secureHeaders } from "hono/secure-headers";
import { rateLimiter } from "hono-rate-limiter"; import { rateLimiter } from "hono-rate-limiter";
import { openAPIRouteHandler } from "hono-openapi"; import { openAPIRouteHandler } from "hono-openapi";
import { authController } from "./modules/auth/auth.controller"; import { authController } from "./modules/auth/auth.controller";
import { ssoController } from "./modules/sso/sso.controller";
import { requireAuth } from "./modules/auth/auth.middleware"; import { requireAuth } from "./modules/auth/auth.middleware";
import { repositoriesController } from "./modules/repositories/repositories.controller"; import { repositoriesController } from "./modules/repositories/repositories.controller";
import { systemController } from "./modules/system/system.controller"; import { systemController } from "./modules/system/system.controller";
@ -74,6 +75,7 @@ export const createApp = () => {
app app
.get("/api/healthcheck", (c) => c.json({ status: "ok" })) .get("/api/healthcheck", (c) => c.json({ status: "ok" }))
.route("/api/v1/auth", authController) .route("/api/v1/auth", authController)
.route("/api/v1/auth", ssoController)
.route("/api/v1/volumes", volumeController) .route("/api/v1/volumes", volumeController)
.route("/api/v1/repositories", repositoriesController) .route("/api/v1/repositories", repositoriesController)
.route("/api/v1/backups", backupScheduleController) .route("/api/v1/backups", backupScheduleController)

Some files were not shown because too many files have changed in this diff Show more