Merge remote-tracking branch 'origin/main' into config-import-json

This commit is contained in:
Jakub Trávník 2025-12-17 18:27:32 +01:00
commit 0b2576af91
97 changed files with 4627 additions and 2351 deletions

View file

@ -1,4 +1,4 @@
* **
!turbo.json !turbo.json
!bun.lock !bun.lock

View file

@ -62,8 +62,6 @@ jobs:
type=semver,pattern={{major}}.{{minor}}.{{patch}},prefix=v,enable=${{ needs.determine-release-type.outputs.release_type == 'release' }} type=semver,pattern={{major}}.{{minor}}.{{patch}},prefix=v,enable=${{ needs.determine-release-type.outputs.release_type == 'release' }}
flavor: | flavor: |
latest=${{ needs.determine-release-type.outputs.release_type == 'release' }} latest=${{ needs.determine-release-type.outputs.release_type == 'release' }}
cache-from: type=registry,ref=ghcr.io/nicotsx/zerobyte:buildcache
cache-to: type=registry,ref=ghcr.io/nicotsx/zerobyte:buildcache,mode=max
- name: Build and push images - name: Build and push images
uses: docker/build-push-action@v6 uses: docker/build-push-action@v6
@ -76,6 +74,8 @@ jobs:
labels: ${{ steps.meta.outputs.labels }} labels: ${{ steps.meta.outputs.labels }}
build-args: | build-args: |
APP_VERSION=${{ needs.determine-release-type.outputs.tagname }} APP_VERSION=${{ needs.determine-release-type.outputs.tagname }}
cache-from: type=registry,ref=ghcr.io/nicotsx/zerobyte:buildcache
cache-to: type=registry,ref=ghcr.io/nicotsx/zerobyte:buildcache,mode=max
publish-release: publish-release:
runs-on: ubuntu-latest runs-on: ubuntu-latest

1
.gitignore vendored
View file

@ -12,3 +12,4 @@ CLAUDE.md
mutagen.yml.lock mutagen.yml.lock
notes.md notes.md
smb-password.txt

View file

@ -19,7 +19,6 @@ Zerobyte is a backup automation tool built on top of Restic that provides a web
- **Styling**: Tailwind CSS v4 + Radix UI components - **Styling**: Tailwind CSS v4 + Radix UI components
- **Architecture**: Unified application structure (not a monorepo) - **Architecture**: Unified application structure (not a monorepo)
- **Code Quality**: Biome (formatter & linter) - **Code Quality**: Biome (formatter & linter)
- **Containerization**: Docker with multi-stage builds
## Repository Structure ## Repository Structure
@ -84,9 +83,7 @@ The server follows a modular service-oriented architecture:
**Entry Point**: `app/server/index.ts` **Entry Point**: `app/server/index.ts`
- Initializes servers using `react-router-hono-server`: - Initializes main API server on port 4096 (REST API + serves static frontend)
1. Main API server on port 4096 (REST API + serves static frontend)
2. Docker volume plugin server on Unix socket `/run/docker/plugins/zerobyte.sock` (optional, if Docker is available)
**Modules** (`app/server/modules/`): **Modules** (`app/server/modules/`):
Each module follows a controller <20> service <20> database pattern: Each module follows a controller <20> service <20> database pattern:
@ -116,7 +113,7 @@ Cron-based background jobs managed by the Scheduler:
**Core** (`app/server/core/`): **Core** (`app/server/core/`):
- `scheduler.ts` - Job scheduling system using node-cron - `scheduler.ts` - Job scheduling system using node-cron
- `capabilities.ts` - Detects available system features (Docker support, etc.) - `capabilities.ts` - Detects available system features
- `constants.ts` - Application-wide constants - `constants.ts` - Application-wide constants
**Utils** (`app/server/utils/`): **Utils** (`app/server/utils/`):
@ -188,17 +185,6 @@ Zerobyte is a wrapper around Restic for backup operations. Key integration point
- Dynamically generates rclone config and passes via environment variables - Dynamically generates rclone config and passes via environment variables
- Supports backends like Dropbox, Google Drive, OneDrive, Backblaze B2, etc. - Supports backends like Dropbox, Google Drive, OneDrive, Backblaze B2, etc.
## Docker Volume Plugin
When Docker socket is available (`/var/run/docker.sock`), Zerobyte registers as a Docker volume plugin:
**Plugin Location**: `/run/docker/plugins/zerobyte.sock`
**Implementation**: `app/server/modules/driver/driver.controller.ts`
This allows other containers to mount Zerobyte volumes using Docker.
The plugin implements the Docker Volume Plugin API v1.
## Environment & Configuration ## Environment & Configuration
**Runtime Environment Variables**: **Runtime Environment Variables**:
@ -212,7 +198,7 @@ The plugin implements the Docker Volume Plugin API v1.
**Capabilities Detection**: **Capabilities Detection**:
On startup, the server detects available capabilities (see `core/capabilities.ts`): On startup, the server detects available capabilities (see `core/capabilities.ts`):
- **Docker**: Requires `/var/run/docker.sock` access - **rclone**: Requires `/root/.config/rclone` directory access
- System will gracefully degrade if capabilities are unavailable - System will gracefully degrade if capabilities are unavailable
## Common Workflows ## Common Workflows
@ -252,16 +238,3 @@ On startup, the server detects available capabilities (see `core/capabilities.ts
- **Security**: Restic password file has 0600 permissions - never expose it - **Security**: Restic password file has 0600 permissions - never expose it
- **Mounting**: Requires privileged container or CAP_SYS_ADMIN for FUSE mounts - **Mounting**: Requires privileged container or CAP_SYS_ADMIN for FUSE mounts
- **API Documentation**: OpenAPI spec auto-generated at `/api/v1/openapi.json`, docs at `/api/v1/docs` - **API Documentation**: OpenAPI spec auto-generated at `/api/v1/openapi.json`, docs at `/api/v1/docs`
## Docker Development Setup
The `docker-compose.yml` defines two services:
- `zerobyte-dev` - Development with hot reload (uses `development` stage)
- `zerobyte-prod` - Production build (uses `production` stage)
Both mount:
- `/var/lib/zerobyte` for persistent data
- `/dev/fuse` device for FUSE mounting
- Optionally `/var/run/docker.sock` for Docker plugin functionality

View file

@ -2,7 +2,7 @@ ARG BUN_VERSION="1.3.3"
FROM oven/bun:${BUN_VERSION}-alpine AS base FROM oven/bun:${BUN_VERSION}-alpine AS base
RUN apk add --no-cache davfs2=1.6.1-r2 openssh-client RUN apk add --no-cache davfs2=1.6.1-r2 openssh-client fuse3
# ------------------------------ # ------------------------------

125
README.md
View file

@ -413,7 +413,7 @@ Secrets/credentials in the config file can reference environment variables using
```yaml ```yaml
services: services:
zerobyte: zerobyte:
image: ghcr.io/nicotsx/zerobyte:v0.15 image: ghcr.io/nicotsx/zerobyte:v0.19
container_name: zerobyte container_name: zerobyte
restart: unless-stopped restart: unless-stopped
cap_add: cap_add:
@ -441,6 +441,34 @@ docker compose up -d
Once the container is running, you can access the web interface at `http://<your-server-ip>:4096`. Once the container is running, you can access the web interface at `http://<your-server-ip>:4096`.
### Simplified setup (No remote mounts)
If you only need to back up locally mounted folders and don't require remote share mounting capabilities, you can remove the `SYS_ADMIN` capability and FUSE device from your `docker-compose.yml`:
```yaml
services:
zerobyte:
image: ghcr.io/nicotsx/zerobyte:v0.19
container_name: zerobyte
restart: unless-stopped
ports:
- "4096:4096"
environment:
- TZ=Europe/Paris # Set your timezone here
volumes:
- /etc/localtime:/etc/localtime:ro
- /var/lib/zerobyte:/var/lib/zerobyte
- /path/to/your/directory:/mydata
```
**Trade-offs:**
- ✅ Improved security by reducing container capabilities
- ✅ Support for local directories
- ✅ Keep support all repository types (local, S3, GCS, Azure, rclone)
- ❌ Cannot mount NFS, SMB, or WebDAV shares directly from Zerobyte
If you need remote mount capabilities, keep the original configuration with `cap_add: SYS_ADMIN` and `devices: /dev/fuse:/dev/fuse`.
## Adding your first volume ## Adding your first volume
Zerobyte supports multiple volume backends including NFS, SMB, WebDAV, and local directories. A volume represents the source data you want to back up and monitor. Zerobyte supports multiple volume backends including NFS, SMB, WebDAV, and local directories. A volume represents the source data you want to back up and monitor.
@ -452,7 +480,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.15 image: ghcr.io/nicotsx/zerobyte:v0.19
container_name: zerobyte container_name: zerobyte
restart: unless-stopped restart: unless-stopped
cap_add: cap_add:
@ -520,7 +548,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.15 image: ghcr.io/nicotsx/zerobyte:v0.19
container_name: zerobyte container_name: zerobyte
restart: unless-stopped restart: unless-stopped
cap_add: cap_add:
@ -570,97 +598,6 @@ Zerobyte allows you to easily restore your data from backups. To restore data, n
![Preview](https://github.com/nicotsx/zerobyte/blob/main/screenshots/restoring.png?raw=true) ![Preview](https://github.com/nicotsx/zerobyte/blob/main/screenshots/restoring.png?raw=true)
## Propagating mounts to host
Zerobyte is capable of propagating mounted volumes from within the container to the host system. This is particularly useful when you want to access the mounted data directly from the host to use it with other applications or services.
In order to enable this feature, you need to change your bind mount `/var/lib/zerobyte` to use the `:rshared` flag. Here is an example of how to set this up in your `docker-compose.yml` file:
```diff
services:
zerobyte:
image: ghcr.io/nicotsx/zerobyte:v0.15
container_name: zerobyte
restart: unless-stopped
ports:
- "4096:4096"
devices:
- /dev/fuse:/dev/fuse
environment:
- TZ=Europe/Paris
volumes:
- /etc/localtime:/etc/localtime:ro
- - /var/lib/zerobyte:/var/lib/zerobyte
+ - /var/lib/zerobyte:/var/lib/zerobyte:rshared
```
Restart the Zerobyte container to apply the changes:
```bash
docker compose down
docker compose up -d
```
## Docker plugin
Zerobyte can also be used as a Docker volume plugin, allowing you to mount your volumes directly into other Docker containers. This enables seamless integration with your containerized applications.
In order to enable this feature, you need to run Zerobyte with several items shared from the host. Here is an example of how to set this up in your `docker-compose.yml` file:
```diff
services:
zerobyte:
image: ghcr.io/nicotsx/zerobyte:v0.15
container_name: zerobyte
restart: unless-stopped
cap_add:
- SYS_ADMIN
ports:
- "4096:4096"
devices:
- /dev/fuse:/dev/fuse
environment:
- TZ=Europe/Paris
volumes:
- /etc/localtime:/etc/localtime:ro
- - /var/lib/zerobyte:/var/lib/zerobyte
+ - /var/lib/zerobyte:/var/lib/zerobyte:rshared
+ - /run/docker/plugins:/run/docker/plugins
+ - /var/run/docker.sock:/var/run/docker.sock
```
Restart the Zerobyte container to apply the changes:
```bash
docker compose down
docker compose up -d
```
Your Zerobyte volumes will now be available as Docker volumes that you can mount into other containers using the `--volume` flag:
```bash
docker run -v zb-abc12:/path/in/container nginx:latest
```
Or using Docker Compose:
```yaml
services:
myservice:
image: nginx:latest
volumes:
- zb-abc12:/path/in/container
volumes:
zb-abc12:
external: true
```
The volume name format is `zb-<short-id>` where `<short-id>` is the unique identifier shown on the volume's Docker tab in Zerobyte. This short ID remains stable even if you rename the volume. You can verify that the volume is available by running:
```bash
docker volume ls
```
## Third-Party Software ## Third-Party Software
This project includes the following third-party software components: This project includes the following third-party software components:

View file

@ -173,3 +173,9 @@ body {
--chart-5: oklch(0.645 0.246 16.439); --chart-5: oklch(0.645 0.246 16.439);
} }
} }
/* Hide built-in password reveal/clear controls (notably in Edge on Windows) */
[data-secret-input] input[type="password"]::-ms-reveal,
[data-secret-input] input[type="password"]::-ms-clear {
display: none;
}

View file

@ -3,8 +3,8 @@
import { type DefaultError, queryOptions, type UseMutationOptions } from '@tanstack/react-query'; import { type DefaultError, queryOptions, type UseMutationOptions } from '@tanstack/react-query';
import { client } from '../client.gen'; import { client } from '../client.gen';
import { browseFilesystem, changePassword, createBackupSchedule, createNotificationDestination, createRepository, createVolume, deleteBackupSchedule, deleteNotificationDestination, deleteRepository, deleteSnapshot, deleteVolume, doctorRepository, downloadResticPassword, getBackupSchedule, getBackupScheduleForVolume, getContainersUsingVolume, getMe, getMirrorCompatibility, getNotificationDestination, getRepository, getScheduleMirrors, getScheduleNotifications, getSnapshotDetails, getStatus, getSystemInfo, getVolume, healthCheckVolume, listBackupSchedules, listFiles, listNotificationDestinations, listRcloneRemotes, listRepositories, listSnapshotFiles, listSnapshots, listVolumes, login, logout, mountVolume, type Options, register, restoreSnapshot, runBackupNow, runForget, stopBackup, testConnection, testNotificationDestination, unmountVolume, updateBackupSchedule, updateNotificationDestination, updateRepository, updateScheduleMirrors, updateScheduleNotifications, updateVolume } from '../sdk.gen'; import { browseFilesystem, changePassword, createBackupSchedule, createNotificationDestination, createRepository, createVolume, deleteBackupSchedule, deleteNotificationDestination, deleteRepository, deleteSnapshot, deleteVolume, doctorRepository, downloadResticPassword, getBackupSchedule, getBackupScheduleForVolume, getMe, getMirrorCompatibility, getNotificationDestination, getRepository, getScheduleMirrors, getScheduleNotifications, getSnapshotDetails, getStatus, getSystemInfo, getVolume, healthCheckVolume, listBackupSchedules, listFiles, listNotificationDestinations, listRcloneRemotes, listRepositories, listSnapshotFiles, listSnapshots, listVolumes, login, logout, mountVolume, type Options, register, reorderBackupSchedules, restoreSnapshot, runBackupNow, runForget, stopBackup, testConnection, testNotificationDestination, unmountVolume, updateBackupSchedule, updateNotificationDestination, updateRepository, updateScheduleMirrors, updateScheduleNotifications, updateVolume } from '../sdk.gen';
import type { BrowseFilesystemData, BrowseFilesystemResponse, ChangePasswordData, ChangePasswordResponse, CreateBackupScheduleData, CreateBackupScheduleResponse, CreateNotificationDestinationData, CreateNotificationDestinationResponse, CreateRepositoryData, CreateRepositoryResponse, CreateVolumeData, CreateVolumeResponse, DeleteBackupScheduleData, DeleteBackupScheduleResponse, DeleteNotificationDestinationData, DeleteNotificationDestinationResponse, DeleteRepositoryData, DeleteRepositoryResponse, DeleteSnapshotData, DeleteSnapshotResponse, DeleteVolumeData, DeleteVolumeResponse, DoctorRepositoryData, DoctorRepositoryResponse, DownloadResticPasswordData, DownloadResticPasswordResponse, GetBackupScheduleData, GetBackupScheduleForVolumeData, GetBackupScheduleForVolumeResponse, GetBackupScheduleResponse, GetContainersUsingVolumeData, GetContainersUsingVolumeResponse, GetMeData, GetMeResponse, GetMirrorCompatibilityData, GetMirrorCompatibilityResponse, GetNotificationDestinationData, GetNotificationDestinationResponse, GetRepositoryData, GetRepositoryResponse, GetScheduleMirrorsData, GetScheduleMirrorsResponse, GetScheduleNotificationsData, GetScheduleNotificationsResponse, GetSnapshotDetailsData, GetSnapshotDetailsResponse, GetStatusData, GetStatusResponse, GetSystemInfoData, GetSystemInfoResponse, GetVolumeData, GetVolumeResponse, HealthCheckVolumeData, HealthCheckVolumeResponse, ListBackupSchedulesData, ListBackupSchedulesResponse, ListFilesData, ListFilesResponse, ListNotificationDestinationsData, ListNotificationDestinationsResponse, ListRcloneRemotesData, ListRcloneRemotesResponse, ListRepositoriesData, ListRepositoriesResponse, ListSnapshotFilesData, ListSnapshotFilesResponse, ListSnapshotsData, ListSnapshotsResponse, ListVolumesData, ListVolumesResponse, LoginData, LoginResponse, LogoutData, LogoutResponse, MountVolumeData, MountVolumeResponse, RegisterData, RegisterResponse, RestoreSnapshotData, RestoreSnapshotResponse, RunBackupNowData, RunBackupNowResponse, RunForgetData, RunForgetResponse, StopBackupData, StopBackupResponse, TestConnectionData, TestConnectionResponse, TestNotificationDestinationData, TestNotificationDestinationResponse, UnmountVolumeData, UnmountVolumeResponse, UpdateBackupScheduleData, UpdateBackupScheduleResponse, UpdateNotificationDestinationData, UpdateNotificationDestinationResponse, UpdateRepositoryData, UpdateRepositoryResponse, UpdateScheduleMirrorsData, UpdateScheduleMirrorsResponse, UpdateScheduleNotificationsData, UpdateScheduleNotificationsResponse, UpdateVolumeData, UpdateVolumeResponse } from '../types.gen'; import type { BrowseFilesystemData, BrowseFilesystemResponse, ChangePasswordData, ChangePasswordResponse, CreateBackupScheduleData, CreateBackupScheduleResponse, CreateNotificationDestinationData, CreateNotificationDestinationResponse, CreateRepositoryData, CreateRepositoryResponse, CreateVolumeData, CreateVolumeResponse, DeleteBackupScheduleData, DeleteBackupScheduleResponse, DeleteNotificationDestinationData, DeleteNotificationDestinationResponse, DeleteRepositoryData, DeleteRepositoryResponse, DeleteSnapshotData, DeleteSnapshotResponse, DeleteVolumeData, DeleteVolumeResponse, DoctorRepositoryData, DoctorRepositoryResponse, DownloadResticPasswordData, DownloadResticPasswordResponse, GetBackupScheduleData, GetBackupScheduleForVolumeData, GetBackupScheduleForVolumeResponse, GetBackupScheduleResponse, GetMeData, GetMeResponse, GetMirrorCompatibilityData, GetMirrorCompatibilityResponse, GetNotificationDestinationData, GetNotificationDestinationResponse, GetRepositoryData, GetRepositoryResponse, GetScheduleMirrorsData, GetScheduleMirrorsResponse, GetScheduleNotificationsData, GetScheduleNotificationsResponse, GetSnapshotDetailsData, GetSnapshotDetailsResponse, GetStatusData, GetStatusResponse, GetSystemInfoData, GetSystemInfoResponse, GetVolumeData, GetVolumeResponse, HealthCheckVolumeData, HealthCheckVolumeResponse, ListBackupSchedulesData, ListBackupSchedulesResponse, ListFilesData, ListFilesResponse, ListNotificationDestinationsData, ListNotificationDestinationsResponse, ListRcloneRemotesData, ListRcloneRemotesResponse, ListRepositoriesData, ListRepositoriesResponse, ListSnapshotFilesData, ListSnapshotFilesResponse, ListSnapshotsData, ListSnapshotsResponse, ListVolumesData, ListVolumesResponse, LoginData, LoginResponse, LogoutData, LogoutResponse, MountVolumeData, MountVolumeResponse, RegisterData, RegisterResponse, ReorderBackupSchedulesData, ReorderBackupSchedulesResponse, RestoreSnapshotData, RestoreSnapshotResponse, RunBackupNowData, RunBackupNowResponse, RunForgetData, RunForgetResponse, StopBackupData, StopBackupResponse, TestConnectionData, TestConnectionResponse, TestNotificationDestinationData, TestNotificationDestinationResponse, UnmountVolumeData, UnmountVolumeResponse, UpdateBackupScheduleData, UpdateBackupScheduleResponse, UpdateNotificationDestinationData, UpdateNotificationDestinationResponse, UpdateRepositoryData, UpdateRepositoryResponse, UpdateScheduleMirrorsData, UpdateScheduleMirrorsResponse, UpdateScheduleNotificationsData, UpdateScheduleNotificationsResponse, UpdateVolumeData, UpdateVolumeResponse } from '../types.gen';
/** /**
* Register a new user * Register a new user
@ -247,24 +247,6 @@ export const updateVolumeMutation = (options?: Partial<Options<UpdateVolumeData>
return mutationOptions; return mutationOptions;
}; };
export const getContainersUsingVolumeQueryKey = (options: Options<GetContainersUsingVolumeData>) => createQueryKey('getContainersUsingVolume', options);
/**
* Get containers using a volume by name
*/
export const getContainersUsingVolumeOptions = (options: Options<GetContainersUsingVolumeData>) => queryOptions<GetContainersUsingVolumeResponse, DefaultError, GetContainersUsingVolumeResponse, ReturnType<typeof getContainersUsingVolumeQueryKey>>({
queryFn: async ({ queryKey, signal }) => {
const { data } = await getContainersUsingVolume({
...options,
...queryKey[0],
signal,
throwOnError: true
});
return data;
},
queryKey: getContainersUsingVolumeQueryKey(options)
});
/** /**
* Mount a volume * Mount a volume
*/ */
@ -806,6 +788,23 @@ export const getMirrorCompatibilityOptions = (options: Options<GetMirrorCompatib
queryKey: getMirrorCompatibilityQueryKey(options) queryKey: getMirrorCompatibilityQueryKey(options)
}); });
/**
* Reorder backup schedules by providing an array of schedule IDs in the desired order
*/
export const reorderBackupSchedulesMutation = (options?: Partial<Options<ReorderBackupSchedulesData>>): UseMutationOptions<ReorderBackupSchedulesResponse, DefaultError, Options<ReorderBackupSchedulesData>> => {
const mutationOptions: UseMutationOptions<ReorderBackupSchedulesResponse, DefaultError, Options<ReorderBackupSchedulesData>> = {
mutationFn: async (fnOptions) => {
const { data } = await reorderBackupSchedules({
...options,
...fnOptions,
throwOnError: true
});
return data;
}
};
return mutationOptions;
};
export const listNotificationDestinationsQueryKey = (options?: Options<ListNotificationDestinationsData>) => createQueryKey('listNotificationDestinations', options); export const listNotificationDestinationsQueryKey = (options?: Options<ListNotificationDestinationsData>) => createQueryKey('listNotificationDestinations', options);
/** /**

View file

@ -13,4 +13,4 @@ import type { ClientOptions as ClientOptions2 } from './types.gen';
*/ */
export type CreateClientConfig<T extends ClientOptions = ClientOptions2> = (override?: Config<ClientOptions & T>) => Config<Required<ClientOptions> & T>; export type CreateClientConfig<T extends ClientOptions = ClientOptions2> = (override?: Config<ClientOptions & T>) => Config<Required<ClientOptions> & T>;
export const client = createClient(createConfig<ClientOptions2>({ baseUrl: 'http://192.168.2.42:4096' })); export const client = createClient(createConfig<ClientOptions2>({ baseUrl: 'http://localhost:4096' }));

View file

@ -2,7 +2,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, ChangePasswordData, ChangePasswordResponses, CreateBackupScheduleData, CreateBackupScheduleResponses, CreateNotificationDestinationData, CreateNotificationDestinationResponses, CreateRepositoryData, CreateRepositoryResponses, CreateVolumeData, CreateVolumeResponses, DeleteBackupScheduleData, DeleteBackupScheduleResponses, DeleteNotificationDestinationData, DeleteNotificationDestinationErrors, DeleteNotificationDestinationResponses, DeleteRepositoryData, DeleteRepositoryResponses, DeleteSnapshotData, DeleteSnapshotResponses, DeleteVolumeData, DeleteVolumeResponses, DoctorRepositoryData, DoctorRepositoryResponses, DownloadResticPasswordData, DownloadResticPasswordResponses, GetBackupScheduleData, GetBackupScheduleForVolumeData, GetBackupScheduleForVolumeResponses, GetBackupScheduleResponses, GetContainersUsingVolumeData, GetContainersUsingVolumeErrors, GetContainersUsingVolumeResponses, GetMeData, GetMeResponses, GetMirrorCompatibilityData, GetMirrorCompatibilityResponses, GetNotificationDestinationData, GetNotificationDestinationErrors, GetNotificationDestinationResponses, GetRepositoryData, GetRepositoryResponses, GetScheduleMirrorsData, GetScheduleMirrorsResponses, GetScheduleNotificationsData, GetScheduleNotificationsResponses, GetSnapshotDetailsData, GetSnapshotDetailsResponses, GetStatusData, GetStatusResponses, GetSystemInfoData, GetSystemInfoResponses, GetVolumeData, GetVolumeErrors, GetVolumeResponses, HealthCheckVolumeData, HealthCheckVolumeErrors, HealthCheckVolumeResponses, ListBackupSchedulesData, ListBackupSchedulesResponses, ListFilesData, ListFilesResponses, ListNotificationDestinationsData, ListNotificationDestinationsResponses, ListRcloneRemotesData, ListRcloneRemotesResponses, ListRepositoriesData, ListRepositoriesResponses, ListSnapshotFilesData, ListSnapshotFilesResponses, ListSnapshotsData, ListSnapshotsResponses, ListVolumesData, ListVolumesResponses, LoginData, LoginResponses, LogoutData, LogoutResponses, MountVolumeData, MountVolumeResponses, RegisterData, RegisterResponses, RestoreSnapshotData, RestoreSnapshotResponses, RunBackupNowData, RunBackupNowResponses, RunForgetData, RunForgetResponses, StopBackupData, StopBackupErrors, StopBackupResponses, TestConnectionData, TestConnectionResponses, TestNotificationDestinationData, TestNotificationDestinationErrors, TestNotificationDestinationResponses, UnmountVolumeData, UnmountVolumeResponses, UpdateBackupScheduleData, UpdateBackupScheduleResponses, UpdateNotificationDestinationData, UpdateNotificationDestinationErrors, UpdateNotificationDestinationResponses, UpdateRepositoryData, UpdateRepositoryErrors, UpdateRepositoryResponses, UpdateScheduleMirrorsData, UpdateScheduleMirrorsResponses, UpdateScheduleNotificationsData, UpdateScheduleNotificationsResponses, UpdateVolumeData, UpdateVolumeErrors, UpdateVolumeResponses } from './types.gen'; import type { BrowseFilesystemData, BrowseFilesystemResponses, ChangePasswordData, ChangePasswordResponses, CreateBackupScheduleData, CreateBackupScheduleResponses, CreateNotificationDestinationData, CreateNotificationDestinationResponses, CreateRepositoryData, CreateRepositoryResponses, CreateVolumeData, CreateVolumeResponses, DeleteBackupScheduleData, DeleteBackupScheduleResponses, DeleteNotificationDestinationData, DeleteNotificationDestinationErrors, DeleteNotificationDestinationResponses, DeleteRepositoryData, DeleteRepositoryResponses, DeleteSnapshotData, DeleteSnapshotResponses, DeleteVolumeData, DeleteVolumeResponses, DoctorRepositoryData, DoctorRepositoryResponses, DownloadResticPasswordData, DownloadResticPasswordResponses, GetBackupScheduleData, GetBackupScheduleForVolumeData, GetBackupScheduleForVolumeResponses, GetBackupScheduleResponses, GetMeData, GetMeResponses, GetMirrorCompatibilityData, GetMirrorCompatibilityResponses, GetNotificationDestinationData, GetNotificationDestinationErrors, GetNotificationDestinationResponses, GetRepositoryData, GetRepositoryResponses, GetScheduleMirrorsData, GetScheduleMirrorsResponses, GetScheduleNotificationsData, GetScheduleNotificationsResponses, GetSnapshotDetailsData, GetSnapshotDetailsResponses, GetStatusData, GetStatusResponses, GetSystemInfoData, GetSystemInfoResponses, GetVolumeData, GetVolumeErrors, GetVolumeResponses, HealthCheckVolumeData, HealthCheckVolumeErrors, HealthCheckVolumeResponses, ListBackupSchedulesData, ListBackupSchedulesResponses, ListFilesData, ListFilesResponses, ListNotificationDestinationsData, ListNotificationDestinationsResponses, ListRcloneRemotesData, ListRcloneRemotesResponses, ListRepositoriesData, ListRepositoriesResponses, ListSnapshotFilesData, ListSnapshotFilesResponses, ListSnapshotsData, ListSnapshotsResponses, ListVolumesData, ListVolumesResponses, LoginData, LoginResponses, LogoutData, LogoutResponses, MountVolumeData, MountVolumeResponses, RegisterData, RegisterResponses, ReorderBackupSchedulesData, ReorderBackupSchedulesResponses, RestoreSnapshotData, RestoreSnapshotResponses, RunBackupNowData, RunBackupNowResponses, RunForgetData, RunForgetResponses, StopBackupData, StopBackupErrors, StopBackupResponses, TestConnectionData, TestConnectionResponses, TestNotificationDestinationData, TestNotificationDestinationErrors, TestNotificationDestinationResponses, UnmountVolumeData, UnmountVolumeResponses, UpdateBackupScheduleData, UpdateBackupScheduleResponses, UpdateNotificationDestinationData, UpdateNotificationDestinationErrors, UpdateNotificationDestinationResponses, UpdateRepositoryData, UpdateRepositoryErrors, UpdateRepositoryResponses, UpdateScheduleMirrorsData, UpdateScheduleMirrorsResponses, UpdateScheduleNotificationsData, UpdateScheduleNotificationsResponses, 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> & {
/** /**
@ -120,11 +120,6 @@ export const updateVolume = <ThrowOnError extends boolean = false>(options: Opti
} }
}); });
/**
* Get containers using a volume by name
*/
export const getContainersUsingVolume = <ThrowOnError extends boolean = false>(options: Options<GetContainersUsingVolumeData, ThrowOnError>) => (options.client ?? client).get<GetContainersUsingVolumeResponses, GetContainersUsingVolumeErrors, ThrowOnError>({ url: '/api/v1/volumes/{name}/containers', ...options });
/** /**
* Mount a volume * Mount a volume
*/ */
@ -329,6 +324,18 @@ export const updateScheduleMirrors = <ThrowOnError extends boolean = false>(opti
*/ */
export const getMirrorCompatibility = <ThrowOnError extends boolean = false>(options: Options<GetMirrorCompatibilityData, ThrowOnError>) => (options.client ?? client).get<GetMirrorCompatibilityResponses, unknown, ThrowOnError>({ url: '/api/v1/backups/{scheduleId}/mirrors/compatibility', ...options }); export const getMirrorCompatibility = <ThrowOnError extends boolean = false>(options: Options<GetMirrorCompatibilityData, ThrowOnError>) => (options.client ?? client).get<GetMirrorCompatibilityResponses, unknown, ThrowOnError>({ url: '/api/v1/backups/{scheduleId}/mirrors/compatibility', ...options });
/**
* Reorder backup schedules by providing an array of schedule IDs in the desired order
*/
export const reorderBackupSchedules = <ThrowOnError extends boolean = false>(options?: Options<ReorderBackupSchedulesData, ThrowOnError>) => (options?.client ?? client).post<ReorderBackupSchedulesResponses, unknown, ThrowOnError>({
url: '/api/v1/backups/reorder',
...options,
headers: {
'Content-Type': 'application/json',
...options?.headers
}
});
/** /**
* List all notification destinations * List all notification destinations
*/ */

View file

@ -1,7 +1,7 @@
// This file is auto-generated by @hey-api/openapi-ts // This file is auto-generated by @hey-api/openapi-ts
export type ClientOptions = { export type ClientOptions = {
baseUrl: 'http://192.168.2.42:4096' | (string & {}); baseUrl: 'http://localhost:4096' | (string & {});
}; };
export type RegisterData = { export type RegisterData = {
@ -164,6 +164,11 @@ export type ListVolumesResponses = {
version: '3' | '4' | '4.1'; version: '3' | '4' | '4.1';
port?: number; port?: number;
readOnly?: boolean; readOnly?: boolean;
} | {
backend: 'rclone';
path: string;
remote: string;
readOnly?: boolean;
} | { } | {
backend: 'smb'; backend: 'smb';
password: string; password: string;
@ -191,7 +196,7 @@ export type ListVolumesResponses = {
name: string; name: string;
shortId: string; shortId: string;
status: 'error' | 'mounted' | 'unmounted'; status: 'error' | 'mounted' | 'unmounted';
type: 'directory' | 'nfs' | 'smb' | 'webdav'; type: 'directory' | 'nfs' | 'rclone' | 'smb' | 'webdav';
updatedAt: number; updatedAt: number;
}>; }>;
}; };
@ -211,6 +216,11 @@ export type CreateVolumeData = {
version: '3' | '4' | '4.1'; version: '3' | '4' | '4.1';
port?: number; port?: number;
readOnly?: boolean; readOnly?: boolean;
} | {
backend: 'rclone';
path: string;
remote: string;
readOnly?: boolean;
} | { } | {
backend: 'smb'; backend: 'smb';
password: string; password: string;
@ -255,6 +265,11 @@ export type CreateVolumeResponses = {
version: '3' | '4' | '4.1'; version: '3' | '4' | '4.1';
port?: number; port?: number;
readOnly?: boolean; readOnly?: boolean;
} | {
backend: 'rclone';
path: string;
remote: string;
readOnly?: boolean;
} | { } | {
backend: 'smb'; backend: 'smb';
password: string; password: string;
@ -282,7 +297,7 @@ export type CreateVolumeResponses = {
name: string; name: string;
shortId: string; shortId: string;
status: 'error' | 'mounted' | 'unmounted'; status: 'error' | 'mounted' | 'unmounted';
type: 'directory' | 'nfs' | 'smb' | 'webdav'; type: 'directory' | 'nfs' | 'rclone' | 'smb' | 'webdav';
updatedAt: number; updatedAt: number;
}; };
}; };
@ -302,6 +317,11 @@ export type TestConnectionData = {
version: '3' | '4' | '4.1'; version: '3' | '4' | '4.1';
port?: number; port?: number;
readOnly?: boolean; readOnly?: boolean;
} | {
backend: 'rclone';
path: string;
remote: string;
readOnly?: boolean;
} | { } | {
backend: 'smb'; backend: 'smb';
password: string; password: string;
@ -399,6 +419,11 @@ export type GetVolumeResponses = {
version: '3' | '4' | '4.1'; version: '3' | '4' | '4.1';
port?: number; port?: number;
readOnly?: boolean; readOnly?: boolean;
} | {
backend: 'rclone';
path: string;
remote: string;
readOnly?: boolean;
} | { } | {
backend: 'smb'; backend: 'smb';
password: string; password: string;
@ -426,7 +451,7 @@ export type GetVolumeResponses = {
name: string; name: string;
shortId: string; shortId: string;
status: 'error' | 'mounted' | 'unmounted'; status: 'error' | 'mounted' | 'unmounted';
type: 'directory' | 'nfs' | 'smb' | 'webdav'; type: 'directory' | 'nfs' | 'rclone' | 'smb' | 'webdav';
updatedAt: number; updatedAt: number;
}; };
}; };
@ -448,6 +473,11 @@ export type UpdateVolumeData = {
version: '3' | '4' | '4.1'; version: '3' | '4' | '4.1';
port?: number; port?: number;
readOnly?: boolean; readOnly?: boolean;
} | {
backend: 'rclone';
path: string;
remote: string;
readOnly?: boolean;
} | { } | {
backend: 'smb'; backend: 'smb';
password: string; password: string;
@ -501,6 +531,11 @@ export type UpdateVolumeResponses = {
version: '3' | '4' | '4.1'; version: '3' | '4' | '4.1';
port?: number; port?: number;
readOnly?: boolean; readOnly?: boolean;
} | {
backend: 'rclone';
path: string;
remote: string;
readOnly?: boolean;
} | { } | {
backend: 'smb'; backend: 'smb';
password: string; password: string;
@ -528,43 +563,13 @@ export type UpdateVolumeResponses = {
name: string; name: string;
shortId: string; shortId: string;
status: 'error' | 'mounted' | 'unmounted'; status: 'error' | 'mounted' | 'unmounted';
type: 'directory' | 'nfs' | 'smb' | 'webdav'; type: 'directory' | 'nfs' | 'rclone' | 'smb' | 'webdav';
updatedAt: number; updatedAt: number;
}; };
}; };
export type UpdateVolumeResponse = UpdateVolumeResponses[keyof UpdateVolumeResponses]; export type UpdateVolumeResponse = UpdateVolumeResponses[keyof UpdateVolumeResponses];
export type GetContainersUsingVolumeData = {
body?: never;
path: {
name: string;
};
query?: never;
url: '/api/v1/volumes/{name}/containers';
};
export type GetContainersUsingVolumeErrors = {
/**
* Volume not found
*/
404: unknown;
};
export type GetContainersUsingVolumeResponses = {
/**
* List of containers using the volume
*/
200: Array<{
id: string;
image: string;
name: string;
state: string;
}>;
};
export type GetContainersUsingVolumeResponse = GetContainersUsingVolumeResponses[keyof GetContainersUsingVolumeResponses];
export type MountVolumeData = { export type MountVolumeData = {
body?: never; body?: never;
path: { path: {
@ -1397,6 +1402,11 @@ export type ListBackupSchedulesResponses = {
version: '3' | '4' | '4.1'; version: '3' | '4' | '4.1';
port?: number; port?: number;
readOnly?: boolean; readOnly?: boolean;
} | {
backend: 'rclone';
path: string;
remote: string;
readOnly?: boolean;
} | { } | {
backend: 'smb'; backend: 'smb';
password: string; password: string;
@ -1424,7 +1434,7 @@ export type ListBackupSchedulesResponses = {
name: string; name: string;
shortId: string; shortId: string;
status: 'error' | 'mounted' | 'unmounted'; status: 'error' | 'mounted' | 'unmounted';
type: 'directory' | 'nfs' | 'smb' | 'webdav'; type: 'directory' | 'nfs' | 'rclone' | 'smb' | 'webdav';
updatedAt: number; updatedAt: number;
}; };
volumeId: number; volumeId: number;
@ -1636,6 +1646,11 @@ export type GetBackupScheduleResponses = {
version: '3' | '4' | '4.1'; version: '3' | '4' | '4.1';
port?: number; port?: number;
readOnly?: boolean; readOnly?: boolean;
} | {
backend: 'rclone';
path: string;
remote: string;
readOnly?: boolean;
} | { } | {
backend: 'smb'; backend: 'smb';
password: string; password: string;
@ -1663,7 +1678,7 @@ export type GetBackupScheduleResponses = {
name: string; name: string;
shortId: string; shortId: string;
status: 'error' | 'mounted' | 'unmounted'; status: 'error' | 'mounted' | 'unmounted';
type: 'directory' | 'nfs' | 'smb' | 'webdav'; type: 'directory' | 'nfs' | 'rclone' | 'smb' | 'webdav';
updatedAt: number; updatedAt: number;
}; };
volumeId: number; volumeId: number;
@ -1856,6 +1871,11 @@ export type GetBackupScheduleForVolumeResponses = {
version: '3' | '4' | '4.1'; version: '3' | '4' | '4.1';
port?: number; port?: number;
readOnly?: boolean; readOnly?: boolean;
} | {
backend: 'rclone';
path: string;
remote: string;
readOnly?: boolean;
} | { } | {
backend: 'smb'; backend: 'smb';
password: string; password: string;
@ -1883,7 +1903,7 @@ export type GetBackupScheduleForVolumeResponses = {
name: string; name: string;
shortId: string; shortId: string;
status: 'error' | 'mounted' | 'unmounted'; status: 'error' | 'mounted' | 'unmounted';
type: 'directory' | 'nfs' | 'smb' | 'webdav'; type: 'directory' | 'nfs' | 'rclone' | 'smb' | 'webdav';
updatedAt: number; updatedAt: number;
}; };
volumeId: number; volumeId: number;
@ -1987,13 +2007,13 @@ export type GetScheduleNotificationsResponses = {
type: 'telegram'; type: 'telegram';
} | { } | {
from: string; from: string;
password: string;
smtpHost: string; smtpHost: string;
smtpPort: number; smtpPort: number;
to: Array<string>; to: Array<string>;
type: 'email'; type: 'email';
useTLS: boolean; useTLS: boolean;
username: string; password?: string;
username?: string;
} | { } | {
priority: 'default' | 'high' | 'low' | 'max' | 'min'; priority: 'default' | 'high' | 'low' | 'max' | 'min';
topic: string; topic: string;
@ -2034,6 +2054,7 @@ export type GetScheduleNotificationsResponses = {
notifyOnFailure: boolean; notifyOnFailure: boolean;
notifyOnStart: boolean; notifyOnStart: boolean;
notifyOnSuccess: boolean; notifyOnSuccess: boolean;
notifyOnWarning: boolean;
scheduleId: number; scheduleId: number;
}>; }>;
}; };
@ -2047,6 +2068,7 @@ export type UpdateScheduleNotificationsData = {
notifyOnFailure: boolean; notifyOnFailure: boolean;
notifyOnStart: boolean; notifyOnStart: boolean;
notifyOnSuccess: boolean; notifyOnSuccess: boolean;
notifyOnWarning: boolean;
}>; }>;
}; };
path: { path: {
@ -2075,13 +2097,13 @@ export type UpdateScheduleNotificationsResponses = {
type: 'telegram'; type: 'telegram';
} | { } | {
from: string; from: string;
password: string;
smtpHost: string; smtpHost: string;
smtpPort: number; smtpPort: number;
to: Array<string>; to: Array<string>;
type: 'email'; type: 'email';
useTLS: boolean; useTLS: boolean;
username: string; password?: string;
username?: string;
} | { } | {
priority: 'default' | 'high' | 'low' | 'max' | 'min'; priority: 'default' | 'high' | 'low' | 'max' | 'min';
topic: string; topic: string;
@ -2122,6 +2144,7 @@ export type UpdateScheduleNotificationsResponses = {
notifyOnFailure: boolean; notifyOnFailure: boolean;
notifyOnStart: boolean; notifyOnStart: boolean;
notifyOnSuccess: boolean; notifyOnSuccess: boolean;
notifyOnWarning: boolean;
scheduleId: number; scheduleId: number;
}>; }>;
}; };
@ -2353,6 +2376,26 @@ export type GetMirrorCompatibilityResponses = {
export type GetMirrorCompatibilityResponse = GetMirrorCompatibilityResponses[keyof GetMirrorCompatibilityResponses]; export type GetMirrorCompatibilityResponse = GetMirrorCompatibilityResponses[keyof GetMirrorCompatibilityResponses];
export type ReorderBackupSchedulesData = {
body?: {
scheduleIds: Array<number>;
};
path?: never;
query?: never;
url: '/api/v1/backups/reorder';
};
export type ReorderBackupSchedulesResponses = {
/**
* Backup schedules reordered successfully
*/
200: {
success: boolean;
};
};
export type ReorderBackupSchedulesResponse = ReorderBackupSchedulesResponses[keyof ReorderBackupSchedulesResponses];
export type ListNotificationDestinationsData = { export type ListNotificationDestinationsData = {
body?: never; body?: never;
path?: never; path?: never;
@ -2377,13 +2420,13 @@ export type ListNotificationDestinationsResponses = {
type: 'telegram'; type: 'telegram';
} | { } | {
from: string; from: string;
password: string;
smtpHost: string; smtpHost: string;
smtpPort: number; smtpPort: number;
to: Array<string>; to: Array<string>;
type: 'email'; type: 'email';
useTLS: boolean; useTLS: boolean;
username: string; password?: string;
username?: string;
} | { } | {
priority: 'default' | 'high' | 'low' | 'max' | 'min'; priority: 'default' | 'high' | 'low' | 'max' | 'min';
topic: string; topic: string;
@ -2438,13 +2481,13 @@ export type CreateNotificationDestinationData = {
type: 'telegram'; type: 'telegram';
} | { } | {
from: string; from: string;
password: string;
smtpHost: string; smtpHost: string;
smtpPort: number; smtpPort: number;
to: Array<string>; to: Array<string>;
type: 'email'; type: 'email';
useTLS: boolean; useTLS: boolean;
username: string; password?: string;
username?: string;
} | { } | {
priority: 'default' | 'high' | 'low' | 'max' | 'min'; priority: 'default' | 'high' | 'low' | 'max' | 'min';
topic: string; topic: string;
@ -2498,13 +2541,13 @@ export type CreateNotificationDestinationResponses = {
type: 'telegram'; type: 'telegram';
} | { } | {
from: string; from: string;
password: string;
smtpHost: string; smtpHost: string;
smtpPort: number; smtpPort: number;
to: Array<string>; to: Array<string>;
type: 'email'; type: 'email';
useTLS: boolean; useTLS: boolean;
username: string; password?: string;
username?: string;
} | { } | {
priority: 'default' | 'high' | 'low' | 'max' | 'min'; priority: 'default' | 'high' | 'low' | 'max' | 'min';
topic: string; topic: string;
@ -2605,13 +2648,13 @@ export type GetNotificationDestinationResponses = {
type: 'telegram'; type: 'telegram';
} | { } | {
from: string; from: string;
password: string;
smtpHost: string; smtpHost: string;
smtpPort: number; smtpPort: number;
to: Array<string>; to: Array<string>;
type: 'email'; type: 'email';
useTLS: boolean; useTLS: boolean;
username: string; password?: string;
username?: string;
} | { } | {
priority: 'default' | 'high' | 'low' | 'max' | 'min'; priority: 'default' | 'high' | 'low' | 'max' | 'min';
topic: string; topic: string;
@ -2666,13 +2709,13 @@ export type UpdateNotificationDestinationData = {
type: 'telegram'; type: 'telegram';
} | { } | {
from: string; from: string;
password: string;
smtpHost: string; smtpHost: string;
smtpPort: number; smtpPort: number;
to: Array<string>; to: Array<string>;
type: 'email'; type: 'email';
useTLS: boolean; useTLS: boolean;
username: string; password?: string;
username?: string;
} | { } | {
priority: 'default' | 'high' | 'low' | 'max' | 'min'; priority: 'default' | 'high' | 'low' | 'max' | 'min';
topic: string; topic: string;
@ -2736,13 +2779,13 @@ export type UpdateNotificationDestinationResponses = {
type: 'telegram'; type: 'telegram';
} | { } | {
from: string; from: string;
password: string;
smtpHost: string; smtpHost: string;
smtpPort: number; smtpPort: number;
to: Array<string>; to: Array<string>;
type: 'email'; type: 'email';
useTLS: boolean; useTLS: boolean;
username: string; password?: string;
username?: string;
} | { } | {
priority: 'default' | 'high' | 'low' | 'max' | 'min'; priority: 'default' | 'high' | 'low' | 'max' | 'min';
topic: string; topic: string;
@ -2831,8 +2874,8 @@ export type GetSystemInfoResponses = {
*/ */
200: { 200: {
capabilities: { capabilities: {
docker: boolean;
rclone: boolean; rclone: boolean;
sysAdmin: boolean;
}; };
}; };
}; };

View file

@ -1,784 +0,0 @@
import { arktypeResolver } from "@hookform/resolvers/arktype";
import { type } from "arktype";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { cn, slugify } from "~/client/lib/utils";
import { deepClean } from "~/utils/object";
import { Button } from "./ui/button";
import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from "./ui/form";
import { Input } from "./ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select";
import { useQuery } from "@tanstack/react-query";
import { Alert, AlertDescription } from "./ui/alert";
import { ExternalLink, AlertTriangle } from "lucide-react";
import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip";
import { useSystemInfo } from "~/client/hooks/use-system-info";
import { COMPRESSION_MODES, repositoryConfigSchema } from "~/schemas/restic";
import { listRcloneRemotesOptions } from "../api-client/@tanstack/react-query.gen";
import { Checkbox } from "./ui/checkbox";
import { DirectoryBrowser } from "./directory-browser";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "./ui/alert-dialog";
import { Textarea } from "./ui/textarea";
export const formSchema = type({
name: "2<=string<=32",
compressionMode: type.valueOf(COMPRESSION_MODES).optional(),
}).and(repositoryConfigSchema);
const cleanSchema = type.pipe((d) => formSchema(deepClean(d)));
export type RepositoryFormValues = typeof formSchema.inferIn;
type Props = {
onSubmit: (values: RepositoryFormValues) => void;
mode?: "create" | "update";
initialValues?: Partial<RepositoryFormValues>;
formId?: string;
loading?: boolean;
className?: string;
};
const defaultValuesForType = {
local: { backend: "local" as const, compressionMode: "auto" as const },
s3: { backend: "s3" as const, compressionMode: "auto" as const },
r2: { backend: "r2" as const, compressionMode: "auto" as const },
gcs: { backend: "gcs" as const, compressionMode: "auto" as const },
azure: { backend: "azure" as const, compressionMode: "auto" as const },
rclone: { backend: "rclone" as const, compressionMode: "auto" as const },
rest: { backend: "rest" as const, compressionMode: "auto" as const },
sftp: { backend: "sftp" as const, compressionMode: "auto" as const, port: 22 },
};
export const CreateRepositoryForm = ({
onSubmit,
mode = "create",
initialValues,
formId,
loading,
className,
}: Props) => {
const form = useForm<RepositoryFormValues>({
resolver: arktypeResolver(cleanSchema as unknown as typeof formSchema),
defaultValues: initialValues,
resetOptions: {
keepDefaultValues: true,
keepDirtyValues: false,
},
});
const { watch, setValue } = form;
const watchedBackend = watch("backend");
const watchedIsExistingRepository = watch("isExistingRepository");
const [passwordMode, setPasswordMode] = useState<"default" | "custom">("default");
const [showPathBrowser, setShowPathBrowser] = useState(false);
const [showPathWarning, setShowPathWarning] = useState(false);
const { capabilities } = useSystemInfo();
const { data: rcloneRemotes, isLoading: isLoadingRemotes } = useQuery({
...listRcloneRemotesOptions(),
enabled: capabilities.rclone,
});
useEffect(() => {
form.reset({
name: form.getValues().name,
isExistingRepository: form.getValues().isExistingRepository,
customPassword: form.getValues().customPassword,
...defaultValuesForType[watchedBackend as keyof typeof defaultValuesForType],
});
}, [watchedBackend, form]);
return (
<Form {...form}>
<form id={formId} onSubmit={form.handleSubmit(onSubmit)} className={cn("space-y-4", className)}>
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>Name</FormLabel>
<FormControl>
<Input
{...field}
placeholder="Repository name"
onChange={(e) => field.onChange(slugify(e.target.value))}
max={32}
min={2}
/>
</FormControl>
<FormDescription>Unique identifier for the repository.</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="backend"
render={({ field }) => (
<FormItem>
<FormLabel>Backend</FormLabel>
<Select onValueChange={field.onChange} defaultValue={field.value} value={field.value}>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select a backend" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="local">Local</SelectItem>
<SelectItem value="s3">S3</SelectItem>
<SelectItem value="r2">Cloudflare R2</SelectItem>
<SelectItem value="gcs">Google Cloud Storage</SelectItem>
<SelectItem value="azure">Azure Blob Storage</SelectItem>
<SelectItem value="rest">REST Server</SelectItem>
<SelectItem value="sftp">SFTP</SelectItem>
<Tooltip>
<TooltipTrigger>
<SelectItem disabled={!capabilities.rclone} value="rclone">
rclone (40+ cloud providers)
</SelectItem>
</TooltipTrigger>
<TooltipContent className={cn({ hidden: capabilities.rclone })}>
<p>Setup rclone to use this backend</p>
</TooltipContent>
</Tooltip>
</SelectContent>
</Select>
<FormDescription>Choose the storage backend for this repository.</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="compressionMode"
render={({ field }) => (
<FormItem>
<FormLabel>Compression Mode</FormLabel>
<Select onValueChange={field.onChange} defaultValue={field.value} value={field.value}>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select compression mode" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="off">Off</SelectItem>
<SelectItem value="auto">Auto (fast)</SelectItem>
<SelectItem value="max">Max (slower, better compression)</SelectItem>
</SelectContent>
</Select>
<FormDescription>Compression mode for backups stored in this repository.</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="isExistingRepository"
render={({ field }) => (
<FormItem className="flex flex-row items-center space-x-3">
<FormControl>
<Checkbox
checked={field.value}
onCheckedChange={(checked) => {
field.onChange(checked);
if (!checked) {
setPasswordMode("default");
setValue("customPassword", undefined);
}
}}
/>
</FormControl>
<div className="space-y-1">
<FormLabel>Import existing repository</FormLabel>
<FormDescription>Check this if the repository already exists at the specified location</FormDescription>
</div>
</FormItem>
)}
/>
{watchedIsExistingRepository && (
<>
<FormItem>
<FormLabel>Repository Password</FormLabel>
<Select
onValueChange={(value) => {
setPasswordMode(value as "default" | "custom");
if (value === "default") {
setValue("customPassword", undefined);
}
}}
defaultValue={passwordMode}
value={passwordMode}
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select password option" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="default">Use Zerobyte's password</SelectItem>
<SelectItem value="custom">Enter password manually</SelectItem>
</SelectContent>
</Select>
<FormDescription>
Choose whether to use Zerobyte's master password or enter a custom password for the existing repository.
</FormDescription>
</FormItem>
{passwordMode === "custom" && (
<FormField
control={form.control}
name="customPassword"
render={({ field }) => (
<FormItem>
<FormLabel>Repository Password</FormLabel>
<FormControl>
<Input type="password" placeholder="Enter repository password" {...field} />
</FormControl>
<FormDescription>
The password used to encrypt this repository. It will be stored securely.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
)}
</>
)}
{watchedBackend === "local" && (
<>
<FormItem>
<FormLabel>Repository Directory</FormLabel>
<div className="flex items-center gap-2">
<div className="flex-1 text-sm font-mono bg-muted px-3 py-2 rounded-md border">
{form.watch("path") || "/var/lib/zerobyte/repositories"}
</div>
<Button type="button" variant="outline" onClick={() => setShowPathWarning(true)} size="sm">
Change
</Button>
</div>
<FormDescription>The directory where the repository will be stored.</FormDescription>
</FormItem>
<AlertDialog open={showPathWarning} onOpenChange={setShowPathWarning}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle className="flex items-center gap-2">
<AlertTriangle className="h-5 w-5 text-yellow-500" />
Important: Host Mount Required
</AlertDialogTitle>
<AlertDialogDescription className="space-y-3">
<p>When selecting a custom path, ensure it is mounted from the host machine into the container.</p>
<p className="font-medium">
If the path is not a host mount, you will lose your repository data when the container restarts.
</p>
<p className="text-sm text-muted-foreground">
The default path <code className="bg-muted px-1 rounded">/var/lib/zerobyte/repositories</code> is
already mounted from the host and is safe to use.
</p>
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={() => {
setShowPathBrowser(true);
setShowPathWarning(false);
}}
>
I Understand, Continue
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<AlertDialog open={showPathBrowser} onOpenChange={setShowPathBrowser}>
<AlertDialogContent className="max-w-2xl">
<AlertDialogHeader>
<AlertDialogTitle>Select Repository Directory</AlertDialogTitle>
<AlertDialogDescription>
Choose a directory from the filesystem to store the repository.
</AlertDialogDescription>
</AlertDialogHeader>
<div className="py-4">
<DirectoryBrowser
onSelectPath={(path) => form.setValue("path", path)}
selectedPath={form.watch("path") || "/var/lib/zerobyte/repositories"}
/>
</div>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction onClick={() => setShowPathBrowser(false)}>Done</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
)}
{watchedBackend === "s3" && (
<>
<FormField
control={form.control}
name="endpoint"
render={({ field }) => (
<FormItem>
<FormLabel>Endpoint</FormLabel>
<FormControl>
<Input placeholder="s3.amazonaws.com" {...field} />
</FormControl>
<FormDescription>S3-compatible endpoint URL.</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="bucket"
render={({ field }) => (
<FormItem>
<FormLabel>Bucket</FormLabel>
<FormControl>
<Input placeholder="my-backup-bucket" {...field} />
</FormControl>
<FormDescription>S3 bucket name for storing backups.</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="accessKeyId"
render={({ field }) => (
<FormItem>
<FormLabel>Access Key ID</FormLabel>
<FormControl>
<Input placeholder="AKIAIOSFODNN7EXAMPLE" {...field} />
</FormControl>
<FormDescription>S3 access key ID for authentication.</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="secretAccessKey"
render={({ field }) => (
<FormItem>
<FormLabel>Secret Access Key</FormLabel>
<FormControl>
<Input type="password" placeholder="••••••••" {...field} />
</FormControl>
<FormDescription>S3 secret access key for authentication.</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</>
)}
{watchedBackend === "r2" && (
<>
<FormField
control={form.control}
name="endpoint"
render={({ field }) => (
<FormItem>
<FormLabel>Endpoint</FormLabel>
<FormControl>
<Input placeholder="<account-id>.r2.cloudflarestorage.com" {...field} />
</FormControl>
<FormDescription>
R2 endpoint (without https://). Find in R2 dashboard under bucket settings.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="bucket"
render={({ field }) => (
<FormItem>
<FormLabel>Bucket</FormLabel>
<FormControl>
<Input placeholder="my-backup-bucket" {...field} />
</FormControl>
<FormDescription>R2 bucket name for storing backups.</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="accessKeyId"
render={({ field }) => (
<FormItem>
<FormLabel>Access Key ID</FormLabel>
<FormControl>
<Input placeholder="Access Key ID from R2 API tokens" {...field} />
</FormControl>
<FormDescription>R2 API token Access Key ID (create in Cloudflare R2 dashboard).</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="secretAccessKey"
render={({ field }) => (
<FormItem>
<FormLabel>Secret Access Key</FormLabel>
<FormControl>
<Input type="password" placeholder="••••••••" {...field} />
</FormControl>
<FormDescription>R2 API token Secret Access Key (shown once when creating token).</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</>
)}
{watchedBackend === "gcs" && (
<>
<FormField
control={form.control}
name="bucket"
render={({ field }) => (
<FormItem>
<FormLabel>Bucket</FormLabel>
<FormControl>
<Input placeholder="my-backup-bucket" {...field} />
</FormControl>
<FormDescription>GCS bucket name for storing backups.</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="projectId"
render={({ field }) => (
<FormItem>
<FormLabel>Project ID</FormLabel>
<FormControl>
<Input placeholder="my-gcp-project-123" {...field} />
</FormControl>
<FormDescription>Google Cloud project ID.</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="credentialsJson"
render={({ field }) => (
<FormItem>
<FormLabel>Service Account JSON</FormLabel>
<FormControl>
<Input type="password" placeholder="Paste service account JSON key..." {...field} />
</FormControl>
<FormDescription>Service account JSON credentials for authentication.</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</>
)}
{watchedBackend === "azure" && (
<>
<FormField
control={form.control}
name="container"
render={({ field }) => (
<FormItem>
<FormLabel>Container</FormLabel>
<FormControl>
<Input placeholder="my-backup-container" {...field} />
</FormControl>
<FormDescription>Azure Blob Storage container name for storing backups.</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="accountName"
render={({ field }) => (
<FormItem>
<FormLabel>Account Name</FormLabel>
<FormControl>
<Input placeholder="mystorageaccount" {...field} />
</FormControl>
<FormDescription>Azure Storage account name.</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="accountKey"
render={({ field }) => (
<FormItem>
<FormLabel>Account Key</FormLabel>
<FormControl>
<Input type="password" placeholder="••••••••" {...field} />
</FormControl>
<FormDescription>Azure Storage account key for authentication.</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="endpointSuffix"
render={({ field }) => (
<FormItem>
<FormLabel>Endpoint Suffix (Optional)</FormLabel>
<FormControl>
<Input placeholder="core.windows.net" {...field} />
</FormControl>
<FormDescription>Custom Azure endpoint suffix (defaults to core.windows.net).</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</>
)}
{watchedBackend === "rclone" &&
(!rcloneRemotes || rcloneRemotes.length === 0 ? (
<Alert>
<AlertDescription className="space-y-2">
<p className="font-medium">No rclone remotes configured</p>
<p className="text-sm text-muted-foreground">
To use rclone, you need to configure remotes on your host system
</p>
<a
href="https://rclone.org/docs/"
target="_blank"
rel="noopener noreferrer"
className="text-sm text-strong-accent inline-flex items-center gap-1"
>
View rclone documentation
<ExternalLink className="w-3 h-3" />
</a>
</AlertDescription>
</Alert>
) : (
<>
<FormField
control={form.control}
name="remote"
render={({ field }) => (
<FormItem>
<FormLabel>Remote</FormLabel>
<Select onValueChange={(v) => field.onChange(v)} defaultValue={field.value} value={field.value}>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select an rclone remote" />
</SelectTrigger>
</FormControl>
<SelectContent>
{isLoadingRemotes ? (
<SelectItem value="loading" disabled>
Loading remotes...
</SelectItem>
) : (
rcloneRemotes.map((remote: { name: string; type: string }) => (
<SelectItem key={remote.name} value={remote.name}>
{remote.name} ({remote.type})
</SelectItem>
))
)}
</SelectContent>
</Select>
<FormDescription>Select the rclone remote configured on your host system.</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="path"
render={({ field }) => (
<FormItem>
<FormLabel>Path</FormLabel>
<FormControl>
<Input placeholder="backups/zerobyte" {...field} />
</FormControl>
<FormDescription>Path within the remote where backups will be stored.</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</>
))}
{watchedBackend === "rest" && (
<>
<FormField
control={form.control}
name="url"
render={({ field }) => (
<FormItem>
<FormLabel>REST Server URL</FormLabel>
<FormControl>
<Input placeholder="http://192.168.1.30:8000" {...field} />
</FormControl>
<FormDescription>URL of the REST server.</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="path"
render={({ field }) => (
<FormItem>
<FormLabel>Repository Path (Optional)</FormLabel>
<FormControl>
<Input placeholder="my-backup-repo" {...field} />
</FormControl>
<FormDescription>Path to the repository on the REST server (leave empty for root).</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="username"
render={({ field }) => (
<FormItem>
<FormLabel>Username (Optional)</FormLabel>
<FormControl>
<Input placeholder="username" {...field} />
</FormControl>
<FormDescription>Username for REST server authentication.</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="password"
render={({ field }) => (
<FormItem>
<FormLabel>Password (Optional)</FormLabel>
<FormControl>
<Input type="password" placeholder="••••••••" {...field} />
</FormControl>
<FormDescription>Password for REST server authentication.</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</>
)}
{watchedBackend === "sftp" && (
<>
<FormField
control={form.control}
name="host"
render={({ field }) => (
<FormItem>
<FormLabel>Host</FormLabel>
<FormControl>
<Input placeholder="192.168.1.100" {...field} />
</FormControl>
<FormDescription>SFTP server hostname or IP address.</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="port"
render={({ field }) => (
<FormItem>
<FormLabel>Port</FormLabel>
<FormControl>
<Input
type="number"
placeholder="22"
{...field}
onChange={(e) => field.onChange(parseInt(e.target.value, 10))}
/>
</FormControl>
<FormDescription>SSH port (default: 22).</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="user"
render={({ field }) => (
<FormItem>
<FormLabel>User</FormLabel>
<FormControl>
<Input placeholder="backup-user" {...field} />
</FormControl>
<FormDescription>SSH username for authentication.</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="path"
render={({ field }) => (
<FormItem>
<FormLabel>Path</FormLabel>
<FormControl>
<Input placeholder="backups/ironmount" {...field} />
</FormControl>
<FormDescription>Repository path on the SFTP server. </FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="privateKey"
render={({ field }) => (
<FormItem>
<FormLabel>SSH Private Key</FormLabel>
<FormControl>
<Textarea
{...field}
placeholder="-----BEGIN OPENSSH PRIVATE KEY-----&#10;...&#10;-----END OPENSSH PRIVATE KEY-----"
/>
</FormControl>
<FormDescription>Paste the contents of your SSH private key.</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</>
)}
{mode === "update" && (
<Button type="submit" className="w-full" loading={loading}>
Save Changes
</Button>
)}
</form>
</Form>
);
};

View file

@ -1,593 +0,0 @@
import { arktypeResolver } from "@hookform/resolvers/arktype";
import { useMutation } from "@tanstack/react-query";
import { type } from "arktype";
import { CheckCircle, Loader2, XCircle } from "lucide-react";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { cn, slugify } from "~/client/lib/utils";
import { deepClean } from "~/utils/object";
import { DirectoryBrowser } from "./directory-browser";
import { Button } from "./ui/button";
import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from "./ui/form";
import { Input } from "./ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select";
import { volumeConfigSchema } from "~/schemas/volumes";
import { testConnectionMutation } from "../api-client/@tanstack/react-query.gen";
export const formSchema = type({
name: "2<=string<=32",
}).and(volumeConfigSchema);
const cleanSchema = type.pipe((d) => formSchema(deepClean(d)));
export type FormValues = typeof formSchema.inferIn;
type Props = {
onSubmit: (values: FormValues) => void;
mode?: "create" | "update";
initialValues?: Partial<FormValues>;
formId?: string;
loading?: boolean;
className?: string;
};
const defaultValuesForType = {
directory: { backend: "directory" as const, path: "/" },
nfs: { backend: "nfs" as const, port: 2049, version: "4.1" as const },
smb: { backend: "smb" as const, port: 445, vers: "3.0" as const },
webdav: { backend: "webdav" as const, port: 80, ssl: false },
};
export const CreateVolumeForm = ({ onSubmit, mode = "create", initialValues, formId, loading, className }: Props) => {
const form = useForm<FormValues>({
resolver: arktypeResolver(cleanSchema as unknown as typeof formSchema),
defaultValues: initialValues,
resetOptions: {
keepDefaultValues: true,
keepDirtyValues: false,
},
});
const { watch, getValues } = form;
const watchedBackend = watch("backend");
useEffect(() => {
if (mode === "create") {
form.reset({
name: form.getValues().name,
...defaultValuesForType[watchedBackend as keyof typeof defaultValuesForType],
});
}
}, [watchedBackend, form, mode]);
const [testMessage, setTestMessage] = useState<{ success: boolean; message: string } | null>(null);
const testBackendConnection = useMutation({
...testConnectionMutation(),
onMutate: () => {
setTestMessage(null);
},
onError: (error) => {
setTestMessage({
success: false,
message: error?.message || "Failed to test connection. Please try again.",
});
},
onSuccess: (data) => {
setTestMessage(data);
},
});
const handleTestConnection = async () => {
const formValues = getValues();
if (formValues.backend === "nfs" || formValues.backend === "smb" || formValues.backend === "webdav") {
testBackendConnection.mutate({
body: { config: formValues },
});
}
};
return (
<Form {...form}>
<form id={formId} onSubmit={form.handleSubmit(onSubmit)} className={cn("space-y-4", className)}>
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>Name</FormLabel>
<FormControl>
<Input
{...field}
placeholder="Volume name"
onChange={(e) => field.onChange(slugify(e.target.value))}
max={32}
min={1}
/>
</FormControl>
<FormDescription>Unique identifier for the volume.</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="backend"
render={({ field }) => (
<FormItem>
<FormLabel>Backend</FormLabel>
<Select onValueChange={field.onChange} defaultValue={field.value} value={field.value}>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select a backend" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="directory">Directory</SelectItem>
<SelectItem value="nfs">NFS</SelectItem>
<SelectItem value="smb">SMB</SelectItem>
<SelectItem value="webdav">WebDAV</SelectItem>
</SelectContent>
</Select>
<FormDescription>Choose the storage backend for this volume.</FormDescription>
<FormMessage />
</FormItem>
)}
/>
{watchedBackend === "directory" && (
<FormField
control={form.control}
name="path"
render={({ field }) => {
return (
<FormItem>
<FormLabel>Directory Path</FormLabel>
<FormControl>
{field.value ? (
<div className="flex items-center gap-2">
<div className="flex-1 border rounded-md p-3 bg-muted/50">
<div className="text-xs font-medium text-muted-foreground mb-1">Selected path:</div>
<div className="text-sm font-mono break-all">{field.value}</div>
</div>
<Button type="button" variant="outline" size="sm" onClick={() => field.onChange("")}>
Change
</Button>
</div>
) : (
<DirectoryBrowser onSelectPath={(path) => field.onChange(path)} selectedPath={field.value} />
)}
</FormControl>
<FormDescription>Browse and select a directory on the host filesystem to track.</FormDescription>
<FormMessage />
</FormItem>
);
}}
/>
)}
{watchedBackend === "nfs" && (
<>
<FormField
control={form.control}
name="server"
render={({ field }) => (
<FormItem>
<FormLabel>Server</FormLabel>
<FormControl>
<Input placeholder="192.168.1.100" {...field} />
</FormControl>
<FormDescription>NFS server IP address or hostname.</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="exportPath"
render={({ field }) => (
<FormItem>
<FormLabel>Export Path</FormLabel>
<FormControl>
<Input placeholder="/export/data" {...field} />
</FormControl>
<FormDescription>Path to the NFS export on the server.</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="port"
defaultValue={2049}
render={({ field }) => (
<FormItem>
<FormLabel>Port</FormLabel>
<FormControl>
<Input
type="number"
placeholder="2049"
{...field}
onChange={(e) => field.onChange(parseInt(e.target.value, 10) || undefined)}
/>
</FormControl>
<FormDescription>NFS server port (default: 2049).</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="version"
defaultValue="4.1"
render={({ field }) => (
<FormItem>
<FormLabel>Version</FormLabel>
<Select onValueChange={field.onChange} defaultValue="4.1">
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select NFS version" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="3">NFS v3</SelectItem>
<SelectItem value="4">NFS v4</SelectItem>
<SelectItem value="4.1">NFS v4.1</SelectItem>
</SelectContent>
</Select>
<FormDescription>NFS protocol version to use.</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="readOnly"
defaultValue={false}
render={({ field }) => (
<FormItem>
<FormLabel>Read-only Mode</FormLabel>
<FormControl>
<div className="flex items-center space-x-2">
<input
type="checkbox"
checked={field.value ?? false}
onChange={(e) => field.onChange(e.target.checked)}
className="rounded border-gray-300"
/>
<span className="text-sm">Mount volume as read-only</span>
</div>
</FormControl>
<FormDescription>
Prevent any modifications to the volume. Recommended for backup sources and sensitive data.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</>
)}
{watchedBackend === "webdav" && (
<>
<FormField
control={form.control}
name="server"
render={({ field }) => (
<FormItem>
<FormLabel>Server</FormLabel>
<FormControl>
<Input placeholder="example.com" {...field} />
</FormControl>
<FormDescription>WebDAV server hostname or IP address.</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="path"
render={({ field }) => (
<FormItem>
<FormLabel>Path</FormLabel>
<FormControl>
<Input placeholder="/webdav" {...field} />
</FormControl>
<FormDescription>Path to the WebDAV directory on the server.</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="username"
render={({ field }) => (
<FormItem>
<FormLabel>Username (Optional)</FormLabel>
<FormControl>
<Input placeholder="admin" {...field} />
</FormControl>
<FormDescription>Username for WebDAV authentication (optional).</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="password"
render={({ field }) => (
<FormItem>
<FormLabel>Password (Optional)</FormLabel>
<FormControl>
<Input type="password" placeholder="••••••••" {...field} />
</FormControl>
<FormDescription>Password for WebDAV authentication (optional).</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="port"
defaultValue={80}
render={({ field }) => (
<FormItem>
<FormLabel>Port</FormLabel>
<FormControl>
<Input
type="number"
placeholder="80"
{...field}
onChange={(e) => field.onChange(parseInt(e.target.value, 10) || undefined)}
/>
</FormControl>
<FormDescription>WebDAV server port (default: 80 for HTTP, 443 for HTTPS).</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="ssl"
defaultValue={false}
render={({ field }) => (
<FormItem>
<FormLabel>Use SSL/HTTPS</FormLabel>
<FormControl>
<div className="flex items-center space-x-2">
<input
type="checkbox"
checked={field.value ?? false}
onChange={(e) => field.onChange(e.target.checked)}
className="rounded border-gray-300"
/>
<span className="text-sm">Enable HTTPS for secure connections</span>
</div>
</FormControl>
<FormDescription>Use HTTPS instead of HTTP for secure connections.</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="readOnly"
defaultValue={false}
render={({ field }) => (
<FormItem>
<FormLabel>Read-only Mode</FormLabel>
<FormControl>
<div className="flex items-center space-x-2">
<input
type="checkbox"
checked={field.value ?? false}
onChange={(e) => field.onChange(e.target.checked)}
className="rounded border-gray-300"
/>
<span className="text-sm">Mount volume as read-only</span>
</div>
</FormControl>
<FormDescription>
Prevent any modifications to the volume. Recommended for backup sources and sensitive data.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</>
)}
{watchedBackend === "smb" && (
<>
<FormField
control={form.control}
name="server"
render={({ field }) => (
<FormItem>
<FormLabel>Server</FormLabel>
<FormControl>
<Input placeholder="192.168.1.100" value={field.value} onChange={field.onChange} />
</FormControl>
<FormDescription>SMB server IP address or hostname.</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="share"
render={({ field }) => (
<FormItem>
<FormLabel>Share</FormLabel>
<FormControl>
<Input placeholder="myshare" value={field.value} onChange={field.onChange} />
</FormControl>
<FormDescription>SMB share name on the server.</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="username"
render={({ field }) => (
<FormItem>
<FormLabel>Username</FormLabel>
<FormControl>
<Input placeholder="admin" value={field.value} onChange={field.onChange} />
</FormControl>
<FormDescription>Username for SMB authentication.</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="password"
render={({ field }) => (
<FormItem>
<FormLabel>Password</FormLabel>
<FormControl>
<Input type="password" placeholder="••••••••" value={field.value} onChange={field.onChange} />
</FormControl>
<FormDescription>Password for SMB authentication.</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="vers"
defaultValue="3.0"
render={({ field }) => (
<FormItem>
<FormLabel>SMB Version</FormLabel>
<Select onValueChange={field.onChange} defaultValue={field.value || "3.0"}>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select SMB version" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="1.0">SMB v1.0</SelectItem>
<SelectItem value="2.0">SMB v2.0</SelectItem>
<SelectItem value="2.1">SMB v2.1</SelectItem>
<SelectItem value="3.0">SMB v3.0</SelectItem>
</SelectContent>
</Select>
<FormDescription>SMB protocol version to use (default: 3.0).</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="domain"
render={({ field }) => (
<FormItem>
<FormLabel>Domain (Optional)</FormLabel>
<FormControl>
<Input placeholder="WORKGROUP" value={field.value} onChange={field.onChange} />
</FormControl>
<FormDescription>Domain or workgroup for authentication (optional).</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="port"
defaultValue={445}
render={({ field }) => (
<FormItem>
<FormLabel>Port</FormLabel>
<FormControl>
<Input
type="number"
placeholder="445"
value={field.value}
defaultValue={445}
onChange={(e) => field.onChange(parseInt(e.target.value, 10) || undefined)}
/>
</FormControl>
<FormDescription>SMB server port (default: 445).</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="readOnly"
defaultValue={false}
render={({ field }) => (
<FormItem>
<FormLabel>Read-only Mode</FormLabel>
<FormControl>
<div className="flex items-center space-x-2">
<input
type="checkbox"
checked={field.value ?? false}
onChange={(e) => field.onChange(e.target.checked)}
className="rounded border-gray-300"
/>
<span className="text-sm">Mount volume as read-only</span>
</div>
</FormControl>
<FormDescription>
Prevent any modifications to the volume. Recommended for backup sources and sensitive data.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</>
)}
{watchedBackend && watchedBackend !== "directory" && (
<div className="space-y-3">
<div className="flex items-center gap-2">
<Button
type="button"
variant="outline"
onClick={handleTestConnection}
disabled={testBackendConnection.isPending}
className="flex-1"
>
{testBackendConnection.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
{!testBackendConnection.isPending && testMessage?.success && (
<CheckCircle className="mr-2 h-4 w-4 text-green-500" />
)}
{!testBackendConnection.isPending && testMessage && !testMessage.success && (
<XCircle className="mr-2 h-4 w-4 text-red-500" />
)}
{testBackendConnection.isPending
? "Testing..."
: testMessage
? testMessage.success
? "Connection Successful"
: "Test Failed"
: "Test Connection"}
</Button>
</div>
{testMessage && (
<div
className={cn("text-xs p-2 rounded-md text-wrap wrap-anywhere", {
"bg-green-50 text-green-700 border border-green-200": testMessage.success,
"bg-red-50 text-red-700 border border-red-200": !testMessage.success,
})}
>
{testMessage.message}
</div>
)}
</div>
)}
{mode === "update" && (
<Button type="submit" className="w-full" loading={loading}>
Save Changes
</Button>
)}
</form>
</Form>
);
};

View file

@ -164,6 +164,7 @@ export function RestoreForm({ snapshot, repositoryName, snapshotId, returnPath }
Cancel Cancel
</Button> </Button>
<Button variant="primary" onClick={handleRestore} disabled={isRestoring || !canRestore}> <Button variant="primary" onClick={handleRestore} disabled={isRestoring || !canRestore}>
<RotateCcw className="h-4 w-4 mr-2" />
{isRestoring {isRestoring
? "Restoring..." ? "Restoring..."
: selectedPaths.size > 0 : selectedPaths.size > 0

View file

@ -0,0 +1,34 @@
import { useSortable } from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import { GripVertical } from "lucide-react";
import type { PropsWithChildren } from "react";
interface SortableBackupCardProps {
isDragging?: boolean;
uniqueId: number;
}
export function SortableCard({ isDragging, uniqueId, children }: PropsWithChildren<SortableBackupCardProps>) {
const { attributes, listeners, setNodeRef, transform, transition } = useSortable({
id: uniqueId,
});
const style = {
transform: CSS.Transform.toString(transform),
transition,
opacity: isDragging ? 0.5 : 1,
};
return (
<div ref={setNodeRef} style={style} className="relative group">
<div
{...attributes}
{...listeners}
className="absolute left-1/2 -translate-x-1/2 top-1 z-10 cursor-grab active:cursor-grabbing opacity-100 md:opacity-0 group-hover:opacity-100 transition-opacity p-1 rounded hover:bg-muted/50 bg-background/80 backdrop-blur-sm"
>
<GripVertical className="h-4 w-4 text-muted-foreground rotate-90" />
</div>
{children}
</div>
);
}

View file

@ -0,0 +1,59 @@
import { Eye, EyeOff } from "lucide-react";
import type * as React from "react";
import { useMemo, useState } from "react";
import { cn } from "~/client/lib/utils";
import { Button } from "./button";
import { Input } from "./input";
export const isStoredSecretValue = (value?: string): boolean => {
if (typeof value !== "string" || value.length === 0) {
return false;
}
return value.startsWith("env://") || value.startsWith("file://") || value.startsWith("encv1:");
};
type SecretInputProps = Omit<React.ComponentProps<typeof Input>, "type">
export const SecretInput = ({ className, value, ...props }: SecretInputProps) => {
const [revealed, setRevealed] = useState(false);
const showAsPlaintext = useMemo(() => {
if (typeof value !== "string") {
return false;
}
return isStoredSecretValue(value);
}, [value]);
const type = useMemo(() => {
if (showAsPlaintext) {
return "text";
}
return revealed ? "text" : "password";
}, [showAsPlaintext, revealed]);
return (
<div className="relative" data-secret-input>
<Input
{...props}
value={value}
type={type}
className={cn(!showAsPlaintext && "pr-10", className)}
/>
{!showAsPlaintext && (
<Button
type="button"
variant="ghost"
size="icon"
className="absolute right-1 top-1/2 size-7 -translate-y-1/2"
onClick={() => setRevealed((v) => !v)}
aria-label={revealed ? "Hide secret" : "Show secret"}
>
{revealed ? <EyeOff className="size-4" /> : <Eye className="size-4" />}
</Button>
)}
</div>
);
};

View file

@ -31,6 +31,12 @@ const getIconAndColor = (backend: BackendType) => {
color: "text-green-600 dark:text-green-400", color: "text-green-600 dark:text-green-400",
label: "WebDAV", label: "WebDAV",
}; };
case "rclone":
return {
icon: Cloud,
color: "text-cyan-600 dark:text-cyan-400",
label: "Rclone",
};
default: default:
return { return {
icon: Folder, icon: Folder,

View file

@ -10,7 +10,7 @@ export function useSystemInfo() {
}); });
return { return {
capabilities: data?.capabilities ?? { docker: false, rclone: false }, capabilities: data?.capabilities ?? { rclone: false, sysAdmin: false },
isLoading, isLoading,
error, error,
systemInfo: data, systemInfo: data,

View file

@ -0,0 +1,54 @@
import { CalendarClock, Database, HardDrive } from "lucide-react";
import { Link } from "react-router";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "~/client/components/ui/card";
import type { BackupSchedule } from "~/client/lib/types";
import { BackupStatusDot } from "./backup-status-dot";
export const BackupCard = ({ schedule }: { schedule: BackupSchedule }) => {
return (
<Link key={schedule.id} to={`/backups/${schedule.id}`}>
<Card key={schedule.id} className="flex flex-col h-full">
<CardHeader className="pb-3 overflow-hidden">
<div className="flex items-center justify-between gap-2 w-full">
<div className="flex items-center gap-2 flex-1 min-w-0 w-0">
<CalendarClock className="h-5 w-5 text-muted-foreground shrink-0" />
<CardTitle className="text-lg truncate">{schedule.name}</CardTitle>
</div>
<BackupStatusDot
enabled={schedule.enabled}
hasError={!!schedule.lastBackupError}
isInProgress={schedule.lastBackupStatus === "in_progress"}
/>
</div>
<CardDescription className="ml-0.5 flex items-center gap-2 text-xs">
<HardDrive className="h-3.5 w-3.5" />
<span className="truncate">{schedule.volume.name}</span>
<span className="text-muted-foreground"></span>
<Database className="h-3.5 w-3.5 text-strong-accent" />
<span className="truncate text-strong-accent">{schedule.repository.name}</span>
</CardDescription>
</CardHeader>
<CardContent className="flex-1 space-y-4">
<div className="space-y-2">
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">Schedule</span>
<code className="text-xs bg-muted px-2 py-1 rounded">{schedule.cronExpression}</code>
</div>
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">Last backup</span>
<span className="font-medium">
{schedule.lastBackupAt ? new Date(schedule.lastBackupAt).toLocaleDateString() : "Never"}
</span>
</div>
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">Next backup</span>
<span className="font-medium">
{schedule.nextBackupAt ? new Date(schedule.nextBackupAt).toLocaleDateString() : "N/A"}
</span>
</div>
</div>
</CardContent>
</Card>
</Link>
);
};

View file

@ -24,6 +24,7 @@ type NotificationAssignment = {
destinationId: number; destinationId: number;
notifyOnStart: boolean; notifyOnStart: boolean;
notifyOnSuccess: boolean; notifyOnSuccess: boolean;
notifyOnWarning: boolean;
notifyOnFailure: boolean; notifyOnFailure: boolean;
}; };
@ -57,6 +58,7 @@ export const ScheduleNotificationsConfig = ({ scheduleId, destinations }: Props)
destinationId: assignment.destinationId, destinationId: assignment.destinationId,
notifyOnStart: assignment.notifyOnStart, notifyOnStart: assignment.notifyOnStart,
notifyOnSuccess: assignment.notifyOnSuccess, notifyOnSuccess: assignment.notifyOnSuccess,
notifyOnWarning: assignment.notifyOnWarning,
notifyOnFailure: assignment.notifyOnFailure, notifyOnFailure: assignment.notifyOnFailure,
}); });
} }
@ -72,6 +74,7 @@ export const ScheduleNotificationsConfig = ({ scheduleId, destinations }: Props)
destinationId: id, destinationId: id,
notifyOnStart: false, notifyOnStart: false,
notifyOnSuccess: false, notifyOnSuccess: false,
notifyOnWarning: true,
notifyOnFailure: true, notifyOnFailure: true,
}); });
@ -87,7 +90,10 @@ export const ScheduleNotificationsConfig = ({ scheduleId, destinations }: Props)
setHasChanges(true); setHasChanges(true);
}; };
const toggleEvent = (destinationId: number, event: "notifyOnStart" | "notifyOnSuccess" | "notifyOnFailure") => { const toggleEvent = (
destinationId: number,
event: "notifyOnStart" | "notifyOnSuccess" | "notifyOnWarning" | "notifyOnFailure",
) => {
const assignment = assignments.get(destinationId); const assignment = assignments.get(destinationId);
if (!assignment) return; if (!assignment) return;
@ -119,6 +125,7 @@ export const ScheduleNotificationsConfig = ({ scheduleId, destinations }: Props)
destinationId: assignment.destinationId, destinationId: assignment.destinationId,
notifyOnStart: assignment.notifyOnStart, notifyOnStart: assignment.notifyOnStart,
notifyOnSuccess: assignment.notifyOnSuccess, notifyOnSuccess: assignment.notifyOnSuccess,
notifyOnWarning: assignment.notifyOnWarning,
notifyOnFailure: assignment.notifyOnFailure, notifyOnFailure: assignment.notifyOnFailure,
}); });
} }
@ -193,7 +200,8 @@ export const ScheduleNotificationsConfig = ({ scheduleId, destinations }: Props)
<TableHead>Destination</TableHead> <TableHead>Destination</TableHead>
<TableHead className="text-center w-[100px]">Start</TableHead> <TableHead className="text-center w-[100px]">Start</TableHead>
<TableHead className="text-center w-[100px]">Success</TableHead> <TableHead className="text-center w-[100px]">Success</TableHead>
<TableHead className="text-center w-[100px]">Failure</TableHead> <TableHead className="text-center w-[100px]">Warnings</TableHead>
<TableHead className="text-center w-[100px]">Failures</TableHead>
<TableHead className="w-[50px]"></TableHead> <TableHead className="w-[50px]"></TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
@ -226,6 +234,13 @@ export const ScheduleNotificationsConfig = ({ scheduleId, destinations }: Props)
onCheckedChange={() => toggleEvent(destination.id, "notifyOnSuccess")} onCheckedChange={() => toggleEvent(destination.id, "notifyOnSuccess")}
/> />
</TableCell> </TableCell>
<TableCell className="text-center">
<Switch
className="align-middle"
checked={assignment.notifyOnWarning}
onCheckedChange={() => toggleEvent(destination.id, "notifyOnWarning")}
/>
</TableCell>
<TableCell className="text-center"> <TableCell className="text-center">
<Switch <Switch
className="align-middle" className="align-middle"

View file

@ -1,4 +1,4 @@
import { Database, Eraser, HardDrive, Pencil, Play, Square, Trash2 } from "lucide-react"; import { Check, Database, Eraser, HardDrive, Pencil, Play, Square, Trash2, X } from "lucide-react";
import { useMemo, useState } from "react"; import { useMemo, useState } from "react";
import { OnOff } from "~/client/components/onoff"; import { OnOff } from "~/client/components/onoff";
import { Button } from "~/client/components/ui/button"; import { Button } from "~/client/components/ui/button";
@ -228,8 +228,14 @@ export const ScheduleSummary = (props: Props) => {
</AlertDialogDescription> </AlertDialogDescription>
</AlertDialogHeader> </AlertDialogHeader>
<div className="flex gap-3 justify-end"> <div className="flex gap-3 justify-end">
<AlertDialogCancel>Cancel</AlertDialogCancel> <AlertDialogCancel>
<AlertDialogAction onClick={handleConfirmForget}>Run cleanup</AlertDialogAction> <X className="h-4 w-4 mr-2" />
Cancel
</AlertDialogCancel>
<AlertDialogAction onClick={handleConfirmForget}>
<Check className="h-4 w-4 mr-2" />
Run cleanup
</AlertDialogAction>
</div> </div>
</AlertDialogContent> </AlertDialogContent>
</AlertDialog> </AlertDialog>

View file

@ -1,6 +1,6 @@
import { useCallback } from "react"; import { useCallback } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useQuery, useQueryClient } from "@tanstack/react-query";
import { FileIcon } from "lucide-react"; import { FileIcon, RotateCcw, Trash2 } from "lucide-react";
import { Link } from "react-router"; import { Link } from "react-router";
import { FileTree } from "~/client/components/file-tree"; import { FileTree } from "~/client/components/file-tree";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "~/client/components/ui/card"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "~/client/components/ui/card";
@ -98,6 +98,7 @@ export const SnapshotFileBrowser = (props: Props) => {
} }
className={buttonVariants({ variant: "primary", size: "sm" })} className={buttonVariants({ variant: "primary", size: "sm" })}
> >
<RotateCcw className="h-4 w-4" />
Restore Restore
</Link> </Link>
{onDeleteSnapshot && ( {onDeleteSnapshot && (
@ -108,6 +109,7 @@ export const SnapshotFileBrowser = (props: Props) => {
disabled={isDeletingSnapshot} disabled={isDeletingSnapshot}
loading={isDeletingSnapshot} loading={isDeletingSnapshot}
> >
<Trash2 className="h-4 w-4 mr-2" />
{isDeletingSnapshot ? "Deleting..." : "Delete Snapshot"} {isDeletingSnapshot ? "Deleting..." : "Delete Snapshot"}
</Button> </Button>
)} )}

View file

@ -2,6 +2,7 @@ import { useId, useState } from "react";
import { useQuery, useMutation } from "@tanstack/react-query"; import { useQuery, useMutation } from "@tanstack/react-query";
import { redirect, useNavigate } from "react-router"; import { redirect, useNavigate } from "react-router";
import { toast } from "sonner"; import { toast } from "sonner";
import { Save, X } from "lucide-react";
import { Button } from "~/client/components/ui/button"; import { Button } from "~/client/components/ui/button";
import { import {
AlertDialog, AlertDialog,
@ -206,9 +207,11 @@ export default function ScheduleDetailsPage({ params, loaderData }: Route.Compon
<CreateScheduleForm volume={schedule.volume} initialValues={schedule} onSubmit={handleSubmit} formId={formId} /> <CreateScheduleForm volume={schedule.volume} initialValues={schedule} onSubmit={handleSubmit} formId={formId} />
<div className="flex justify-end mt-4 gap-2"> <div className="flex justify-end mt-4 gap-2">
<Button type="submit" className="ml-auto" variant="primary" form={formId} loading={updateSchedule.isPending}> <Button type="submit" className="ml-auto" variant="primary" form={formId} loading={updateSchedule.isPending}>
<Save className="h-4 w-4 mr-2" />
Update schedule Update schedule
</Button> </Button>
<Button variant="outline" onClick={() => setIsEditMode(false)}> <Button variant="outline" onClick={() => setIsEditMode(false)}>
<X className="h-4 w-4 mr-2" />
Cancel Cancel
</Button> </Button>
</div> </div>

View file

@ -1,13 +1,28 @@
import { useQuery } from "@tanstack/react-query"; import { useMutation, useQuery } from "@tanstack/react-query";
import { CalendarClock, Database, HardDrive, Plus } from "lucide-react"; import {
DndContext,
closestCenter,
KeyboardSensor,
PointerSensor,
useSensor,
useSensors,
type DragEndEvent,
} from "@dnd-kit/core";
import { arrayMove, SortableContext, sortableKeyboardCoordinates, rectSortingStrategy } from "@dnd-kit/sortable";
import { CalendarClock, Plus } from "lucide-react";
import { Link } from "react-router"; import { Link } from "react-router";
import { BackupStatusDot } from "../components/backup-status-dot"; import { useState, useEffect } from "react";
import { EmptyState } from "~/client/components/empty-state"; import { EmptyState } from "~/client/components/empty-state";
import { Button } from "~/client/components/ui/button"; import { Button } from "~/client/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "~/client/components/ui/card"; import { Card, CardContent } from "~/client/components/ui/card";
import type { Route } from "./+types/backups"; import type { Route } from "./+types/backups";
import { listBackupSchedules } from "~/client/api-client"; import { listBackupSchedules } from "~/client/api-client";
import { listBackupSchedulesOptions } from "~/client/api-client/@tanstack/react-query.gen"; import {
listBackupSchedulesOptions,
reorderBackupSchedulesMutation,
} from "~/client/api-client/@tanstack/react-query.gen";
import { SortableCard } from "~/client/components/sortable-card";
import { BackupCard } from "../components/backup-card";
export const handle = { export const handle = {
breadcrumb: () => [{ label: "Backups" }], breadcrumb: () => [{ label: "Backups" }],
@ -35,6 +50,41 @@ export default function Backups({ loaderData }: Route.ComponentProps) {
initialData: loaderData, initialData: loaderData,
}); });
const [items, setItems] = useState(schedules?.map((s) => s.id) ?? []);
useEffect(() => {
if (schedules) {
setItems(schedules.map((s) => s.id));
}
}, [schedules]);
const sensors = useSensors(
useSensor(PointerSensor, {
activationConstraint: { distance: 8 },
}),
useSensor(KeyboardSensor, {
coordinateGetter: sortableKeyboardCoordinates,
}),
);
const reorderMutation = useMutation({
...reorderBackupSchedulesMutation(),
});
const handleDragEnd = (event: DragEndEvent) => {
const { active, over } = event;
if (over && active.id !== over.id) {
setItems((items) => {
const oldIndex = items.indexOf(active.id as number);
const newIndex = items.indexOf(over.id as number);
const newItems = arrayMove(items, oldIndex, newIndex);
reorderMutation.mutate({ body: { scheduleIds: newItems } });
return newItems;
});
}
};
if (isLoading) { if (isLoading) {
return ( return (
<div className="flex items-center justify-center h-full"> <div className="flex items-center justify-center h-full">
@ -61,64 +111,33 @@ export default function Backups({ loaderData }: Route.ComponentProps) {
); );
} }
const scheduleMap = new Map(schedules.map((s) => [s.id, s]));
return ( return (
<div className="container mx-auto space-y-6"> <div className="container mx-auto space-y-6">
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3 auto-rows-fr"> <DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
{schedules.map((schedule) => ( <SortableContext items={items} strategy={rectSortingStrategy}>
<Link key={schedule.id} to={`/backups/${schedule.id}`}> <div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3 auto-rows-fr">
<Card key={schedule.id} className="flex flex-col h-full"> {items.map((id) => {
<CardHeader className="pb-3 overflow-hidden"> const schedule = scheduleMap.get(id);
<div className="flex items-center justify-between gap-2 w-full"> if (!schedule) return null;
<div className="flex items-center gap-2 flex-1 min-w-0 w-0"> return (
<CalendarClock className="h-5 w-5 text-muted-foreground shrink-0" /> <SortableCard uniqueId={id} key={schedule.id}>
<CardTitle className="text-lg truncate">{schedule.name}</CardTitle> <BackupCard schedule={schedule} />
</div> </SortableCard>
<BackupStatusDot );
enabled={schedule.enabled} })}
hasError={!!schedule.lastBackupError} <Link to="/backups/create">
isInProgress={schedule.lastBackupStatus === "in_progress"} <Card className="flex flex-col items-center justify-center h-full hover:bg-muted/50 transition-colors cursor-pointer">
/> <CardContent className="flex flex-col items-center justify-center gap-2">
</div> <Plus className="h-8 w-8 text-muted-foreground" />
<CardDescription className="ml-0.5 flex items-center gap-2 text-xs"> <span className="text-sm font-medium text-muted-foreground">Create a backup job</span>
<HardDrive className="h-3.5 w-3.5" /> </CardContent>
<span className="truncate">{schedule.volume.name}</span> </Card>
<span className="text-muted-foreground"></span> </Link>
<Database className="h-3.5 w-3.5 text-strong-accent" /> </div>
<span className="truncate text-strong-accent">{schedule.repository.name}</span> </SortableContext>
</CardDescription> </DndContext>
</CardHeader>
<CardContent className="flex-1 space-y-4">
<div className="space-y-2">
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">Schedule</span>
<code className="text-xs bg-muted px-2 py-1 rounded">{schedule.cronExpression}</code>
</div>
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">Last backup</span>
<span className="font-medium">
{schedule.lastBackupAt ? new Date(schedule.lastBackupAt).toLocaleDateString() : "Never"}
</span>
</div>
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">Next backup</span>
<span className="font-medium">
{schedule.nextBackupAt ? new Date(schedule.nextBackupAt).toLocaleDateString() : "N/A"}
</span>
</div>
</div>
</CardContent>
</Card>
</Link>
))}
<Link to="/backups/create">
<Card className="flex flex-col items-center justify-center h-full hover:bg-muted/50 transition-colors cursor-pointer">
<CardContent className="flex flex-col items-center justify-center gap-2">
<Plus className="h-8 w-8 text-muted-foreground" />
<span className="text-sm font-medium text-muted-foreground">Create a backup job</span>
</CardContent>
</Card>
</Link>
</div>
</div> </div>
); );
} }

View file

@ -1,6 +1,6 @@
import { useId, useState } from "react"; import { useId, useState } from "react";
import { useMutation, useQuery } from "@tanstack/react-query"; import { useMutation, useQuery } from "@tanstack/react-query";
import { Database, HardDrive } from "lucide-react"; import { Database, HardDrive, Plus } from "lucide-react";
import { Link, useNavigate } from "react-router"; import { Link, useNavigate } from "react-router";
import { toast } from "sonner"; import { toast } from "sonner";
import { import {
@ -162,6 +162,7 @@ export default function CreateBackup({ loaderData }: Route.ComponentProps) {
<CreateScheduleForm volume={selectedVolume} onSubmit={handleSubmit} formId={formId} /> <CreateScheduleForm volume={selectedVolume} onSubmit={handleSubmit} formId={formId} />
<div className="flex justify-end mt-4 gap-2"> <div className="flex justify-end mt-4 gap-2">
<Button type="submit" variant="primary" form={formId} loading={createSchedule.isPending}> <Button type="submit" variant="primary" form={formId} loading={createSchedule.isPending}>
<Plus className="h-4 w-4 mr-2" />
Create Create
</Button> </Button>
</div> </div>

View file

@ -14,6 +14,7 @@ import {
FormMessage, FormMessage,
} 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 { SecretInput } from "~/client/components/ui/secret-input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "~/client/components/ui/select"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "~/client/components/ui/select";
import { Checkbox } from "~/client/components/ui/checkbox"; import { Checkbox } from "~/client/components/ui/checkbox";
import { notificationConfigSchema } from "~/schemas/notifications"; import { notificationConfigSchema } from "~/schemas/notifications";
@ -198,7 +199,7 @@ export const CreateNotificationForm = ({ onSubmit, mode = "create", initialValue
name="username" name="username"
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel>Username</FormLabel> <FormLabel>Username (Optional)</FormLabel>
<FormControl> <FormControl>
<Input {...field} placeholder="user@example.com" /> <Input {...field} placeholder="user@example.com" />
</FormControl> </FormControl>
@ -211,9 +212,9 @@ export const CreateNotificationForm = ({ onSubmit, mode = "create", initialValue
name="password" name="password"
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel>Password</FormLabel> <FormLabel>Password (Optional)</FormLabel>
<FormControl> <FormControl>
<Input {...field} type="password" placeholder="••••••••" /> <SecretInput {...field} placeholder="••••••••" />
</FormControl> </FormControl>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
@ -415,7 +416,7 @@ export const CreateNotificationForm = ({ onSubmit, mode = "create", initialValue
<FormItem> <FormItem>
<FormLabel>App Token</FormLabel> <FormLabel>App Token</FormLabel>
<FormControl> <FormControl>
<Input {...field} type="password" placeholder="••••••••" /> <SecretInput {...field} placeholder="••••••••" />
</FormControl> </FormControl>
<FormDescription>Application token from Gotify.</FormDescription> <FormDescription>Application token from Gotify.</FormDescription>
<FormMessage /> <FormMessage />
@ -510,7 +511,7 @@ export const CreateNotificationForm = ({ onSubmit, mode = "create", initialValue
<FormItem> <FormItem>
<FormLabel>Password (Optional)</FormLabel> <FormLabel>Password (Optional)</FormLabel>
<FormControl> <FormControl>
<Input {...field} type="password" placeholder="••••••••" /> <SecretInput {...field} placeholder="••••••••" />
</FormControl> </FormControl>
<FormDescription>Password for server authentication, if required.</FormDescription> <FormDescription>Password for server authentication, if required.</FormDescription>
<FormMessage /> <FormMessage />
@ -567,7 +568,7 @@ export const CreateNotificationForm = ({ onSubmit, mode = "create", initialValue
<FormItem> <FormItem>
<FormLabel>API Token</FormLabel> <FormLabel>API Token</FormLabel>
<FormControl> <FormControl>
<Input {...field} type="password" placeholder="••••••••" /> <SecretInput {...field} placeholder="••••••••" />
</FormControl> </FormControl>
<FormDescription>Application API token from your Pushover application.</FormDescription> <FormDescription>Application API token from your Pushover application.</FormDescription>
<FormMessage /> <FormMessage />
@ -627,7 +628,7 @@ export const CreateNotificationForm = ({ onSubmit, mode = "create", initialValue
<FormItem> <FormItem>
<FormLabel>Bot Token</FormLabel> <FormLabel>Bot Token</FormLabel>
<FormControl> <FormControl>
<Input {...field} type="password" placeholder="123456789:ABC-DEF1234ghIkl-zyx57W2v1u123ew11" /> <SecretInput {...field} placeholder="123456789:ABC-DEF1234ghIkl-zyx57W2v1u123ew11" />
</FormControl> </FormControl>
<FormDescription> <FormDescription>
Telegram bot token. Get this from BotFather when you create your bot. Telegram bot token. Get this from BotFather when you create your bot.

View file

@ -1,5 +1,5 @@
import { useMutation } from "@tanstack/react-query"; import { useMutation } from "@tanstack/react-query";
import { Bell } from "lucide-react"; import { Bell, Plus } from "lucide-react";
import { useId } from "react"; import { useId } from "react";
import { useNavigate } from "react-router"; import { useNavigate } from "react-router";
import { toast } from "sonner"; import { toast } from "sonner";
@ -68,6 +68,7 @@ export default function CreateNotification() {
Cancel Cancel
</Button> </Button>
<Button type="submit" form={formId} loading={createNotification.isPending}> <Button type="submit" form={formId} loading={createNotification.isPending}>
<Plus className="h-4 w-4 mr-2" />
Create Destination Create Destination
</Button> </Button>
</div> </div>

View file

@ -24,7 +24,7 @@ import { getNotificationDestination } from "~/client/api-client/sdk.gen";
import type { Route } from "./+types/notification-details"; import type { Route } from "./+types/notification-details";
import { cn } from "~/client/lib/utils"; import { cn } from "~/client/lib/utils";
import { Card, CardContent, CardHeader, CardTitle } from "~/client/components/ui/card"; import { Card, CardContent, CardHeader, CardTitle } from "~/client/components/ui/card";
import { Bell, TestTube2 } from "lucide-react"; import { Bell, Save, TestTube2, Trash2, X } from "lucide-react";
import { Alert, AlertDescription } from "~/client/components/ui/alert"; import { Alert, AlertDescription } from "~/client/components/ui/alert";
import { CreateNotificationForm, type NotificationFormValues } from "../components/create-notification-form"; import { CreateNotificationForm, type NotificationFormValues } from "../components/create-notification-form";
@ -147,6 +147,7 @@ export default function NotificationDetailsPage({ loaderData }: Route.ComponentP
variant="destructive" variant="destructive"
loading={deleteDestination.isPending} loading={deleteDestination.isPending}
> >
<Trash2 className="h-4 w-4 mr-2" />
Delete Delete
</Button> </Button>
</div> </div>
@ -174,6 +175,7 @@ export default function NotificationDetailsPage({ loaderData }: Route.ComponentP
<CreateNotificationForm mode="update" formId={formId} onSubmit={handleSubmit} initialValues={data.config} /> <CreateNotificationForm mode="update" formId={formId} onSubmit={handleSubmit} initialValues={data.config} />
<div className="flex justify-end gap-2 pt-4 border-t"> <div className="flex justify-end gap-2 pt-4 border-t">
<Button type="submit" form={formId} loading={updateDestination.isPending}> <Button type="submit" form={formId} loading={updateDestination.isPending}>
<Save className="h-4 w-4 mr-2" />
Save Changes Save Changes
</Button> </Button>
</div> </div>
@ -190,8 +192,14 @@ export default function NotificationDetailsPage({ loaderData }: Route.ComponentP
</AlertDialogDescription> </AlertDialogDescription>
</AlertDialogHeader> </AlertDialogHeader>
<AlertDialogFooter> <AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel> <AlertDialogCancel>
<AlertDialogAction onClick={handleConfirmDelete}>Delete</AlertDialogAction> <X className="h-4 w-4 mr-2" />
Cancel
</AlertDialogCancel>
<AlertDialogAction onClick={handleConfirmDelete}>
<Trash2 className="h-4 w-4 mr-2" />
Delete
</AlertDialogAction>
</AlertDialogFooter> </AlertDialogFooter>
</AlertDialogContent> </AlertDialogContent>
</AlertDialog> </AlertDialog>

View file

@ -0,0 +1,280 @@
import { arktypeResolver } from "@hookform/resolvers/arktype";
import { type } from "arktype";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { Save } from "lucide-react";
import { cn, slugify } from "~/client/lib/utils";
import { deepClean } from "~/utils/object";
import { Button } from "../../../components/ui/button";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "../../../components/ui/form";
import { Input } from "../../../components/ui/input";
import { SecretInput } from "../../../components/ui/secret-input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "../../../components/ui/select";
import { Tooltip, TooltipContent, TooltipTrigger } from "../../../components/ui/tooltip";
import { useSystemInfo } from "~/client/hooks/use-system-info";
import { COMPRESSION_MODES, repositoryConfigSchema } from "~/schemas/restic";
import { Checkbox } from "../../../components/ui/checkbox";
import {
LocalRepositoryForm,
S3RepositoryForm,
R2RepositoryForm,
GCSRepositoryForm,
AzureRepositoryForm,
RcloneRepositoryForm,
RestRepositoryForm,
SftpRepositoryForm,
} from "./repository-forms";
export const formSchema = type({
name: "2<=string<=32",
compressionMode: type.valueOf(COMPRESSION_MODES).optional(),
}).and(repositoryConfigSchema);
const cleanSchema = type.pipe((d) => formSchema(deepClean(d)));
export type RepositoryFormValues = typeof formSchema.inferIn;
type Props = {
onSubmit: (values: RepositoryFormValues) => void;
mode?: "create" | "update";
initialValues?: Partial<RepositoryFormValues>;
formId?: string;
loading?: boolean;
className?: string;
};
const defaultValuesForType = {
local: { backend: "local" as const, compressionMode: "auto" as const },
s3: { backend: "s3" as const, compressionMode: "auto" as const },
r2: { backend: "r2" as const, compressionMode: "auto" as const },
gcs: { backend: "gcs" as const, compressionMode: "auto" as const },
azure: { backend: "azure" as const, compressionMode: "auto" as const },
rclone: { backend: "rclone" as const, compressionMode: "auto" as const },
rest: { backend: "rest" as const, compressionMode: "auto" as const },
sftp: { backend: "sftp" as const, compressionMode: "auto" as const, port: 22 },
};
export const CreateRepositoryForm = ({
onSubmit,
mode = "create",
initialValues,
formId,
loading,
className,
}: Props) => {
const form = useForm<RepositoryFormValues>({
resolver: arktypeResolver(cleanSchema as unknown as typeof formSchema),
defaultValues: initialValues,
resetOptions: {
keepDefaultValues: true,
keepDirtyValues: false,
},
});
const { watch, setValue } = form;
const watchedBackend = watch("backend");
const watchedIsExistingRepository = watch("isExistingRepository");
const [passwordMode, setPasswordMode] = useState<"default" | "custom">("default");
const { capabilities } = useSystemInfo();
useEffect(() => {
form.reset({
name: form.getValues().name,
isExistingRepository: form.getValues().isExistingRepository,
customPassword: form.getValues().customPassword,
...defaultValuesForType[watchedBackend as keyof typeof defaultValuesForType],
});
}, [watchedBackend, form]);
return (
<Form {...form}>
<form id={formId} onSubmit={form.handleSubmit(onSubmit)} className={cn("space-y-4", className)}>
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>Name</FormLabel>
<FormControl>
<Input
{...field}
placeholder="Repository name"
onChange={(e) => field.onChange(slugify(e.target.value))}
maxLength={32}
minLength={2}
/>
</FormControl>
<FormDescription>Unique identifier for the repository.</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="backend"
render={({ field }) => (
<FormItem>
<FormLabel>Backend</FormLabel>
<Select onValueChange={field.onChange} defaultValue={field.value} value={field.value}>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select a backend" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="local">Local</SelectItem>
<SelectItem value="s3">S3</SelectItem>
<SelectItem value="r2">Cloudflare R2</SelectItem>
<SelectItem value="gcs">Google Cloud Storage</SelectItem>
<SelectItem value="azure">Azure Blob Storage</SelectItem>
<SelectItem value="rest">REST Server</SelectItem>
<SelectItem value="sftp">SFTP</SelectItem>
<Tooltip>
<TooltipTrigger>
<SelectItem disabled={!capabilities.rclone} value="rclone">
rclone (40+ cloud providers)
</SelectItem>
</TooltipTrigger>
<TooltipContent className={cn({ hidden: capabilities.rclone })}>
<p>Setup rclone to use this backend</p>
</TooltipContent>
</Tooltip>
</SelectContent>
</Select>
<FormDescription>Choose the storage backend for this repository.</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="compressionMode"
render={({ field }) => (
<FormItem>
<FormLabel>Compression Mode</FormLabel>
<Select onValueChange={field.onChange} defaultValue={field.value} value={field.value}>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select compression mode" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="off">Off</SelectItem>
<SelectItem value="auto">Auto (fast)</SelectItem>
<SelectItem value="max">Max (slower, better compression)</SelectItem>
</SelectContent>
</Select>
<FormDescription>Compression mode for backups stored in this repository.</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="isExistingRepository"
render={({ field }) => (
<FormItem className="flex flex-row items-center space-x-3">
<FormControl>
<Checkbox
checked={field.value}
onCheckedChange={(checked) => {
field.onChange(checked);
if (!checked) {
setPasswordMode("default");
setValue("customPassword", undefined);
}
}}
/>
</FormControl>
<div className="space-y-1">
<FormLabel>Import existing repository</FormLabel>
<FormDescription>Check this if the repository already exists at the specified location</FormDescription>
</div>
</FormItem>
)}
/>
{watchedIsExistingRepository && (
<>
<FormItem>
<FormLabel>Repository Password</FormLabel>
<Select
onValueChange={(value) => {
setPasswordMode(value as "default" | "custom");
if (value === "default") {
setValue("customPassword", undefined);
}
}}
defaultValue={passwordMode}
value={passwordMode}
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select password option" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="default">Use Zerobyte's password</SelectItem>
<SelectItem value="custom">Enter password manually</SelectItem>
</SelectContent>
</Select>
<FormDescription>
Choose whether to use Zerobyte's master password or enter a custom password for the existing repository.
</FormDescription>
</FormItem>
{passwordMode === "custom" && (
<FormField
control={form.control}
name="customPassword"
render={({ field }) => (
<FormItem>
<FormLabel>Repository Password</FormLabel>
<FormControl>
<SecretInput
placeholder="Enter repository password"
value={field.value ?? ""}
onChange={field.onChange}
/>
</FormControl>
<FormDescription>
The password used to encrypt this repository. It will be stored securely.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
)}
</>
)}
{watchedBackend === "local" && <LocalRepositoryForm form={form} />}
{watchedBackend === "s3" && <S3RepositoryForm form={form} />}
{watchedBackend === "r2" && <R2RepositoryForm form={form} />}
{watchedBackend === "gcs" && <GCSRepositoryForm form={form} />}
{watchedBackend === "azure" && <AzureRepositoryForm form={form} />}
{watchedBackend === "rclone" && <RcloneRepositoryForm form={form} />}
{watchedBackend === "rest" && <RestRepositoryForm form={form} />}
{watchedBackend === "sftp" && <SftpRepositoryForm form={form} />}
{mode === "update" && (
<Button type="submit" className="w-full" loading={loading}>
<Save className="h-4 w-4 mr-2" />
Save Changes
</Button>
)}
</form>
</Form>
);
};

View file

@ -0,0 +1,79 @@
import type { UseFormReturn } from "react-hook-form";
import {
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "../../../../components/ui/form";
import { Input } from "../../../../components/ui/input";
import { SecretInput } from "../../../../components/ui/secret-input";
import type { RepositoryFormValues } from "../create-repository-form";
type Props = {
form: UseFormReturn<RepositoryFormValues>;
};
export const AzureRepositoryForm = ({ form }: Props) => {
return (
<>
<FormField
control={form.control}
name="container"
render={({ field }) => (
<FormItem>
<FormLabel>Container</FormLabel>
<FormControl>
<Input placeholder="my-backup-container" {...field} />
</FormControl>
<FormDescription>Azure Blob Storage container name for storing backups.</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="accountName"
render={({ field }) => (
<FormItem>
<FormLabel>Account Name</FormLabel>
<FormControl>
<Input placeholder="mystorageaccount" {...field} />
</FormControl>
<FormDescription>Azure Storage account name.</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="accountKey"
render={({ field }) => (
<FormItem>
<FormLabel>Account Key</FormLabel>
<FormControl>
<SecretInput placeholder="••••••••" value={field.value ?? ""} onChange={field.onChange} />
</FormControl>
<FormDescription>Azure Storage account key for authentication.</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="endpointSuffix"
render={({ field }) => (
<FormItem>
<FormLabel>Endpoint Suffix (Optional)</FormLabel>
<FormControl>
<Input placeholder="core.windows.net" {...field} />
</FormControl>
<FormDescription>Custom Azure endpoint suffix (defaults to core.windows.net).</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</>
);
};

View file

@ -0,0 +1,65 @@
import type { UseFormReturn } from "react-hook-form";
import {
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "../../../../components/ui/form";
import { Input } from "../../../../components/ui/input";
import { Textarea } from "../../../../components/ui/textarea";
import type { RepositoryFormValues } from "../create-repository-form";
type Props = {
form: UseFormReturn<RepositoryFormValues>;
};
export const GCSRepositoryForm = ({ form }: Props) => {
return (
<>
<FormField
control={form.control}
name="bucket"
render={({ field }) => (
<FormItem>
<FormLabel>Bucket</FormLabel>
<FormControl>
<Input placeholder="my-backup-bucket" {...field} />
</FormControl>
<FormDescription>GCS bucket name for storing backups.</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="projectId"
render={({ field }) => (
<FormItem>
<FormLabel>Project ID</FormLabel>
<FormControl>
<Input placeholder="my-gcp-project-123" {...field} />
</FormControl>
<FormDescription>Google Cloud project ID.</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="credentialsJson"
render={({ field }) => (
<FormItem>
<FormLabel>Service Account JSON</FormLabel>
<FormControl>
<Textarea placeholder="Paste service account JSON key..." {...field} />
</FormControl>
<FormDescription>Service account JSON credentials for authentication.</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</>
);
};

View file

@ -0,0 +1,8 @@
export { LocalRepositoryForm } from "./local-repository-form";
export { S3RepositoryForm } from "./s3-repository-form";
export { R2RepositoryForm } from "./r2-repository-form";
export { GCSRepositoryForm } from "./gcs-repository-form";
export { AzureRepositoryForm } from "./azure-repository-form";
export { RcloneRepositoryForm } from "./rclone-repository-form";
export { RestRepositoryForm } from "./rest-repository-form";
export { SftpRepositoryForm } from "./sftp-repository-form";

View file

@ -0,0 +1,103 @@
import { useState } from "react";
import type { UseFormReturn } from "react-hook-form";
import { Check, Pencil, X, AlertTriangle } from "lucide-react";
import { Button } from "../../../../components/ui/button";
import { FormItem, FormLabel, FormDescription } from "../../../../components/ui/form";
import { DirectoryBrowser } from "../../../../components/directory-browser";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "../../../../components/ui/alert-dialog";
import type { RepositoryFormValues } from "../create-repository-form";
type Props = {
form: UseFormReturn<RepositoryFormValues>;
};
export const LocalRepositoryForm = ({ form }: Props) => {
const [showPathBrowser, setShowPathBrowser] = useState(false);
const [showPathWarning, setShowPathWarning] = useState(false);
return (
<>
<FormItem>
<FormLabel>Repository Directory</FormLabel>
<div className="flex items-center gap-2">
<div className="flex-1 text-sm font-mono bg-muted px-3 py-2 rounded-md border">
{form.watch("path") || "/var/lib/zerobyte/repositories"}
</div>
<Button type="button" variant="outline" onClick={() => setShowPathWarning(true)} size="sm">
<Pencil className="h-4 w-4 mr-2" />
Change
</Button>
</div>
<FormDescription>The directory where the repository will be stored.</FormDescription>
</FormItem>
<AlertDialog open={showPathWarning} onOpenChange={setShowPathWarning}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle className="flex items-center gap-2">
<AlertTriangle className="h-5 w-5 text-yellow-500" />
Important: Host mount required
</AlertDialogTitle>
<AlertDialogDescription className="space-y-3">
<p>When selecting a custom path, ensure it is mounted from the host machine into the container.</p>
<p className="font-medium">
If the path is not a host mount, you will lose your repository data when the container restarts.
</p>
<p className="text-sm text-muted-foreground">
The default path <code className="bg-muted px-1 rounded">/var/lib/zerobyte/repositories</code> is
already mounted from the host and is safe to use.
</p>
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={() => {
setShowPathBrowser(true);
setShowPathWarning(false);
}}
>
I Understand, Continue
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<AlertDialog open={showPathBrowser} onOpenChange={setShowPathBrowser}>
<AlertDialogContent className="max-w-2xl">
<AlertDialogHeader>
<AlertDialogTitle>Select Repository Directory</AlertDialogTitle>
<AlertDialogDescription>
Choose a directory from the filesystem to store the repository.
</AlertDialogDescription>
</AlertDialogHeader>
<div className="py-4">
<DirectoryBrowser
onSelectPath={(path) => form.setValue("path", path)}
selectedPath={form.watch("path") || "/var/lib/zerobyte/repositories"}
/>
</div>
<AlertDialogFooter>
<AlertDialogCancel>
<X className="h-4 w-4 mr-2" />
Cancel
</AlertDialogCancel>
<AlertDialogAction onClick={() => setShowPathBrowser(false)}>
<Check className="h-4 w-4 mr-2" />
Done
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
);
};

View file

@ -0,0 +1,81 @@
import type { UseFormReturn } from "react-hook-form";
import {
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "../../../../components/ui/form";
import { Input } from "../../../../components/ui/input";
import { SecretInput } from "../../../../components/ui/secret-input";
import type { RepositoryFormValues } from "../create-repository-form";
type Props = {
form: UseFormReturn<RepositoryFormValues>;
};
export const R2RepositoryForm = ({ form }: Props) => {
return (
<>
<FormField
control={form.control}
name="endpoint"
render={({ field }) => (
<FormItem>
<FormLabel>Endpoint</FormLabel>
<FormControl>
<Input placeholder="<account-id>.r2.cloudflarestorage.com" {...field} />
</FormControl>
<FormDescription>
R2 endpoint (without https://). Find in R2 dashboard under bucket settings.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="bucket"
render={({ field }) => (
<FormItem>
<FormLabel>Bucket</FormLabel>
<FormControl>
<Input placeholder="my-backup-bucket" {...field} />
</FormControl>
<FormDescription>R2 bucket name for storing backups.</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="accessKeyId"
render={({ field }) => (
<FormItem>
<FormLabel>Access Key ID</FormLabel>
<FormControl>
<Input placeholder="Access Key ID from R2 API tokens" {...field} />
</FormControl>
<FormDescription>R2 API token Access Key ID (create in Cloudflare R2 dashboard).</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="secretAccessKey"
render={({ field }) => (
<FormItem>
<FormLabel>Secret Access Key</FormLabel>
<FormControl>
<SecretInput placeholder="••••••••" value={field.value ?? ""} onChange={field.onChange} />
</FormControl>
<FormDescription>R2 API token Secret Access Key (shown once when creating token).</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</>
);
};

View file

@ -0,0 +1,96 @@
import type { UseFormReturn } from "react-hook-form";
import { useQuery } from "@tanstack/react-query";
import { ExternalLink } from "lucide-react";
import {
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "../../../../components/ui/form";
import { Input } from "../../../../components/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "../../../../components/ui/select";
import { Alert, AlertDescription } from "../../../../components/ui/alert";
import { listRcloneRemotesOptions } from "../../../../api-client/@tanstack/react-query.gen";
import type { RepositoryFormValues } from "../create-repository-form";
type Props = {
form: UseFormReturn<RepositoryFormValues>;
};
export const RcloneRepositoryForm = ({ form }: Props) => {
const { data: rcloneRemotes, isLoading: isLoadingRemotes } = useQuery(listRcloneRemotesOptions());
if (!isLoadingRemotes && (!rcloneRemotes || rcloneRemotes.length === 0)) {
return (
<Alert>
<AlertDescription className="space-y-2">
<p className="font-medium">No rclone remotes configured</p>
<p className="text-sm text-muted-foreground">
To use rclone, you need to configure remotes on your host system
</p>
<a
href="https://rclone.org/docs/"
target="_blank"
rel="noopener noreferrer"
className="text-sm text-strong-accent inline-flex items-center gap-1"
>
View rclone documentation
<ExternalLink className="w-3 h-3" />
</a>
</AlertDescription>
</Alert>
);
}
return (
<>
<FormField
control={form.control}
name="remote"
render={({ field }) => (
<FormItem>
<FormLabel>Remote</FormLabel>
<Select onValueChange={(v) => field.onChange(v)} defaultValue={field.value} value={field.value}>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select an rclone remote" />
</SelectTrigger>
</FormControl>
<SelectContent>
{isLoadingRemotes ? (
<SelectItem value="loading" disabled>
Loading remotes...
</SelectItem>
) : (
rcloneRemotes?.map((remote: { name: string; type: string }) => (
<SelectItem key={remote.name} value={remote.name}>
{remote.name} ({remote.type})
</SelectItem>
))
)}
</SelectContent>
</Select>
<FormDescription>Select the rclone remote configured on your host system.</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="path"
render={({ field }) => (
<FormItem>
<FormLabel>Path</FormLabel>
<FormControl>
<Input placeholder="backups/zerobyte" {...field} />
</FormControl>
<FormDescription>Path within the remote where backups will be stored.</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</>
);
};

View file

@ -0,0 +1,79 @@
import type { UseFormReturn } from "react-hook-form";
import {
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "../../../../components/ui/form";
import { Input } from "../../../../components/ui/input";
import { SecretInput } from "../../../../components/ui/secret-input";
import type { RepositoryFormValues } from "../create-repository-form";
type Props = {
form: UseFormReturn<RepositoryFormValues>;
};
export const RestRepositoryForm = ({ form }: Props) => {
return (
<>
<FormField
control={form.control}
name="url"
render={({ field }) => (
<FormItem>
<FormLabel>REST Server URL</FormLabel>
<FormControl>
<Input placeholder="http://192.168.1.30:8000" {...field} />
</FormControl>
<FormDescription>URL of the REST server.</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="path"
render={({ field }) => (
<FormItem>
<FormLabel>Repository Path (Optional)</FormLabel>
<FormControl>
<Input placeholder="my-backup-repo" {...field} />
</FormControl>
<FormDescription>Path to the repository on the REST server (leave empty for root).</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="username"
render={({ field }) => (
<FormItem>
<FormLabel>Username (Optional)</FormLabel>
<FormControl>
<Input placeholder="username" {...field} />
</FormControl>
<FormDescription>Username for REST server authentication.</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="password"
render={({ field }) => (
<FormItem>
<FormLabel>Password (Optional)</FormLabel>
<FormControl>
<SecretInput placeholder="••••••••" value={field.value ?? ""} onChange={field.onChange} />
</FormControl>
<FormDescription>Password for REST server authentication.</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</>
);
};

View file

@ -0,0 +1,79 @@
import type { UseFormReturn } from "react-hook-form";
import {
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "../../../../components/ui/form";
import { Input } from "../../../../components/ui/input";
import { SecretInput } from "../../../../components/ui/secret-input";
import type { RepositoryFormValues } from "../create-repository-form";
type Props = {
form: UseFormReturn<RepositoryFormValues>;
};
export const S3RepositoryForm = ({ form }: Props) => {
return (
<>
<FormField
control={form.control}
name="endpoint"
render={({ field }) => (
<FormItem>
<FormLabel>Endpoint</FormLabel>
<FormControl>
<Input placeholder="s3.amazonaws.com" {...field} />
</FormControl>
<FormDescription>S3-compatible endpoint URL.</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="bucket"
render={({ field }) => (
<FormItem>
<FormLabel>Bucket</FormLabel>
<FormControl>
<Input placeholder="my-backup-bucket" {...field} />
</FormControl>
<FormDescription>S3 bucket name for storing backups.</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="accessKeyId"
render={({ field }) => (
<FormItem>
<FormLabel>Access Key ID</FormLabel>
<FormControl>
<Input placeholder="AKIAIOSFODNN7EXAMPLE" {...field} />
</FormControl>
<FormDescription>S3 access key ID for authentication.</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="secretAccessKey"
render={({ field }) => (
<FormItem>
<FormLabel>Secret Access Key</FormLabel>
<FormControl>
<SecretInput placeholder="••••••••" value={field.value ?? ""} onChange={field.onChange} />
</FormControl>
<FormDescription>S3 secret access key for authentication.</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</>
);
};

View file

@ -0,0 +1,101 @@
import type { UseFormReturn } from "react-hook-form";
import {
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "../../../../components/ui/form";
import { Input } from "../../../../components/ui/input";
import { Textarea } from "../../../../components/ui/textarea";
import type { RepositoryFormValues } from "../create-repository-form";
type Props = {
form: UseFormReturn<RepositoryFormValues>;
};
export const SftpRepositoryForm = ({ form }: Props) => {
return (
<>
<FormField
control={form.control}
name="host"
render={({ field }) => (
<FormItem>
<FormLabel>Host</FormLabel>
<FormControl>
<Input placeholder="192.168.1.100" {...field} />
</FormControl>
<FormDescription>SFTP server hostname or IP address.</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="port"
render={({ field }) => (
<FormItem>
<FormLabel>Port</FormLabel>
<FormControl>
<Input
type="number"
placeholder="22"
{...field}
onChange={(e) => field.onChange(parseInt(e.target.value, 10))}
/>
</FormControl>
<FormDescription>SSH port (default: 22).</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="user"
render={({ field }) => (
<FormItem>
<FormLabel>User</FormLabel>
<FormControl>
<Input placeholder="backup-user" {...field} />
</FormControl>
<FormDescription>SSH username for authentication.</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="path"
render={({ field }) => (
<FormItem>
<FormLabel>Path</FormLabel>
<FormControl>
<Input placeholder="backups/ironmount" {...field} />
</FormControl>
<FormDescription>Repository path on the SFTP server. </FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="privateKey"
render={({ field }) => (
<FormItem>
<FormLabel>SSH Private Key</FormLabel>
<FormControl>
<Textarea
{...field}
placeholder="-----BEGIN OPENSSH PRIVATE KEY-----&#10;...&#10;-----END OPENSSH PRIVATE KEY-----"
/>
</FormControl>
<FormDescription>Paste the contents of your SSH private key.</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</>
);
};

View file

@ -1,10 +1,13 @@
import { useMutation } from "@tanstack/react-query"; import { useMutation } from "@tanstack/react-query";
import { Database } from "lucide-react"; import { Database, Plus } from "lucide-react";
import { useId } from "react"; import { useId } from "react";
import { useNavigate } from "react-router"; import { useNavigate } from "react-router";
import { toast } from "sonner"; 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 { CreateRepositoryForm, type RepositoryFormValues } from "~/client/components/create-repository-form"; import {
CreateRepositoryForm,
type RepositoryFormValues,
} from "~/client/modules/repositories/components/create-repository-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";
@ -79,7 +82,8 @@ export default function CreateRepository() {
Cancel Cancel
</Button> </Button>
<Button type="submit" form={formId} loading={createRepository.isPending}> <Button type="submit" form={formId} loading={createRepository.isPending}>
Create Repository <Plus className="h-4 w-4 mr-2" />
Create repository
</Button> </Button>
</div> </div>
</CardContent> </CardContent>

View file

@ -72,7 +72,7 @@ export default function Repositories({ loaderData }: Route.ComponentProps) {
button={ button={
<Button onClick={() => navigate("/repositories/create")}> <Button onClick={() => navigate("/repositories/create")}>
<Plus size={16} className="mr-2" /> <Plus size={16} className="mr-2" />
Create Repository Create repository
</Button> </Button>
} }
/> />

View file

@ -25,7 +25,7 @@ import { cn } from "~/client/lib/utils";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "~/client/components/ui/tabs"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "~/client/components/ui/tabs";
import { RepositoryInfoTabContent } from "../tabs/info"; import { RepositoryInfoTabContent } from "../tabs/info";
import { RepositorySnapshotsTabContent } from "../tabs/snapshots"; import { RepositorySnapshotsTabContent } from "../tabs/snapshots";
import { Loader2 } from "lucide-react"; import { Loader2, Stethoscope, Trash2, X } from "lucide-react";
export const handle = { export const handle = {
breadcrumb: (match: Route.MetaArgs) => [ breadcrumb: (match: Route.MetaArgs) => [
@ -149,13 +149,17 @@ export default function RepositoryDetailsPage({ loaderData }: Route.ComponentPro
{doctorMutation.isPending ? ( {doctorMutation.isPending ? (
<> <>
<Loader2 className="mr-2 h-4 w-4 animate-spin" /> <Loader2 className="mr-2 h-4 w-4 animate-spin" />
Running Doctor... Running doctor...
</> </>
) : ( ) : (
"Run Doctor" <>
<Stethoscope className="h-4 w-4 mr-2" />
Run doctor
</>
)} )}
</Button> </Button>
<Button variant="destructive" onClick={() => setShowDeleteConfirm(true)} disabled={deleteRepo.isPending}> <Button variant="destructive" onClick={() => setShowDeleteConfirm(true)} disabled={deleteRepo.isPending}>
<Trash2 className="h-4 w-4 mr-2" />
Delete Delete
</Button> </Button>
</div> </div>
@ -184,11 +188,15 @@ export default function RepositoryDetailsPage({ loaderData }: Route.ComponentPro
</AlertDialogDescription> </AlertDialogDescription>
</AlertDialogHeader> </AlertDialogHeader>
<div className="flex gap-3 justify-end"> <div className="flex gap-3 justify-end">
<AlertDialogCancel>Cancel</AlertDialogCancel> <AlertDialogCancel>
<X className="h-4 w-4 mr-2" />
Cancel
</AlertDialogCancel>
<AlertDialogAction <AlertDialogAction
onClick={handleConfirmDelete} onClick={handleConfirmDelete}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90" className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
> >
<Trash2 className="h-4 w-4 mr-2" />
Delete repository Delete repository
</AlertDialogAction> </AlertDialogAction>
</div> </div>
@ -198,7 +206,7 @@ export default function RepositoryDetailsPage({ loaderData }: Route.ComponentPro
<AlertDialog open={showDoctorResults} onOpenChange={setShowDoctorResults}> <AlertDialog open={showDoctorResults} onOpenChange={setShowDoctorResults}>
<AlertDialogContent className="max-w-2xl"> <AlertDialogContent className="max-w-2xl">
<AlertDialogHeader> <AlertDialogHeader>
<AlertDialogTitle>Doctor Results</AlertDialogTitle> <AlertDialogTitle>Doctor results</AlertDialogTitle>
<AlertDialogDescription>Repository doctor operation completed</AlertDialogDescription> <AlertDialogDescription>Repository doctor operation completed</AlertDialogDescription>
</AlertDialogHeader> </AlertDialogHeader>

View file

@ -2,6 +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 { useNavigate } from "react-router"; import { useNavigate } from "react-router";
import { Check, Save } from "lucide-react";
import { Card } from "~/client/components/ui/card"; import { Card } from "~/client/components/ui/card";
import { Button } from "~/client/components/ui/button"; import { Button } from "~/client/components/ui/button";
import { Input } from "~/client/components/ui/input"; import { Input } from "~/client/components/ui/input";
@ -86,7 +87,7 @@ export const RepositoryInfoTabContent = ({ repository }: Props) => {
<p className="text-sm text-muted-foreground">Unique identifier for the repository.</p> <p className="text-sm text-muted-foreground">Unique identifier for the repository.</p>
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="compressionMode">Compression Mode</Label> <Label htmlFor="compressionMode">Compression mode</Label>
<Select value={compressionMode} onValueChange={(val) => setCompressionMode(val as CompressionMode)}> <Select value={compressionMode} onValueChange={(val) => setCompressionMode(val as CompressionMode)}>
<SelectTrigger id="compressionMode"> <SelectTrigger id="compressionMode">
<SelectValue placeholder="Select compression mode" /> <SelectValue placeholder="Select compression mode" />
@ -114,11 +115,11 @@ export const RepositoryInfoTabContent = ({ repository }: Props) => {
<p className="mt-1 text-sm">{repository.status || "unknown"}</p> <p className="mt-1 text-sm">{repository.status || "unknown"}</p>
</div> </div>
<div> <div>
<div className="text-sm font-medium text-muted-foreground">Created At</div> <div className="text-sm font-medium text-muted-foreground">Created at</div>
<p className="mt-1 text-sm">{new Date(repository.createdAt * 1000).toLocaleString()}</p> <p className="mt-1 text-sm">{new Date(repository.createdAt).toLocaleString()}</p>
</div> </div>
<div> <div>
<div className="text-sm font-medium text-muted-foreground">Last Checked</div> <div className="text-sm font-medium text-muted-foreground">Last checked</div>
<p className="mt-1 text-sm"> <p className="mt-1 text-sm">
{repository.lastChecked ? new Date(repository.lastChecked).toLocaleString() : "Never"} {repository.lastChecked ? new Date(repository.lastChecked).toLocaleString() : "Never"}
</p> </p>
@ -146,6 +147,7 @@ export const RepositoryInfoTabContent = ({ repository }: Props) => {
<div className="flex justify-end pt-4 border-t"> <div className="flex justify-end pt-4 border-t">
<Button type="submit" disabled={!hasChanges || updateMutation.isPending} loading={updateMutation.isPending}> <Button type="submit" disabled={!hasChanges || updateMutation.isPending} loading={updateMutation.isPending}>
<Save className="h-4 w-4 mr-2" />
Save Changes Save Changes
</Button> </Button>
</div> </div>
@ -155,12 +157,15 @@ export const RepositoryInfoTabContent = ({ repository }: Props) => {
<AlertDialog open={showConfirmDialog} onOpenChange={setShowConfirmDialog}> <AlertDialog open={showConfirmDialog} onOpenChange={setShowConfirmDialog}>
<AlertDialogContent> <AlertDialogContent>
<AlertDialogHeader> <AlertDialogHeader>
<AlertDialogTitle>Update Repository</AlertDialogTitle> <AlertDialogTitle>Update repository</AlertDialogTitle>
<AlertDialogDescription>Are you sure you want to update the repository settings?</AlertDialogDescription> <AlertDialogDescription>Are you sure you want to update the repository settings?</AlertDialogDescription>
</AlertDialogHeader> </AlertDialogHeader>
<AlertDialogFooter> <AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel> <AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction onClick={confirmUpdate}>Update</AlertDialogAction> <AlertDialogAction onClick={confirmUpdate}>
<Check className="h-4 w-4" />
Update
</AlertDialogAction>
</AlertDialogFooter> </AlertDialogFooter>
</AlertDialogContent> </AlertDialogContent>
</AlertDialog> </AlertDialog>

View file

@ -1,5 +1,5 @@
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { Database } from "lucide-react"; import { Database, X } from "lucide-react";
import { useState } from "react"; import { useState } from "react";
import { listBackupSchedulesOptions, listSnapshotsOptions } from "~/client/api-client/@tanstack/react-query.gen"; import { listBackupSchedulesOptions, listSnapshotsOptions } from "~/client/api-client/@tanstack/react-query.gen";
import { SnapshotsTable } from "~/client/components/snapshots-table"; import { SnapshotsTable } from "~/client/components/snapshots-table";
@ -128,6 +128,7 @@ export const RepositorySnapshotsTabContent = ({ repository }: Props) => {
<div className="flex flex-col items-center gap-3"> <div className="flex flex-col items-center gap-3">
<p className="text-muted-foreground">No snapshots match your search.</p> <p className="text-muted-foreground">No snapshots match your search.</p>
<Button onClick={() => setSearchQuery("")} variant="outline" size="sm"> <Button onClick={() => setSearchQuery("")} variant="outline" size="sm">
<X className="h-4 w-4 mr-2" />
Clear search Clear search
</Button> </Button>
</div> </div>

View file

@ -1,5 +1,5 @@
import { useMutation } from "@tanstack/react-query"; import { useMutation } from "@tanstack/react-query";
import { Download, KeyRound, User } from "lucide-react"; import { Download, KeyRound, User, X } from "lucide-react";
import { useState } from "react"; import { useState } from "react";
import { useNavigate } from "react-router"; import { useNavigate } from "react-router";
import { toast } from "sonner"; import { toast } from "sonner";
@ -195,6 +195,7 @@ export default function Settings({ loaderData }: Route.ComponentProps) {
/> />
</div> </div>
<Button type="submit" loading={changePassword.isPending} className="mt-4"> <Button type="submit" loading={changePassword.isPending} className="mt-4">
<KeyRound className="h-4 w-4 mr-2" />
Change Password Change Password
</Button> </Button>
</form> </form>
@ -252,9 +253,11 @@ export default function Settings({ loaderData }: Route.ComponentProps) {
setDownloadPassword(""); setDownloadPassword("");
}} }}
> >
<X className="h-4 w-4 mr-2" />
Cancel Cancel
</Button> </Button>
<Button type="submit" loading={downloadResticPassword.isPending}> <Button type="submit" loading={downloadResticPassword.isPending}>
<Download className="h-4 w-4 mr-2" />
Download Download
</Button> </Button>
</DialogFooter> </DialogFooter>

View file

@ -0,0 +1,255 @@
import { arktypeResolver } from "@hookform/resolvers/arktype";
import { useMutation } from "@tanstack/react-query";
import { type } from "arktype";
import { CheckCircle, Loader2, Plug, Save, XCircle } from "lucide-react";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { cn, slugify } from "~/client/lib/utils";
import { deepClean } from "~/utils/object";
import { Button } from "../../../components/ui/button";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "../../../components/ui/form";
import { Input } from "../../../components/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "../../../components/ui/select";
import { volumeConfigSchema } from "~/schemas/volumes";
import { testConnectionMutation } from "../../../api-client/@tanstack/react-query.gen";
import { Tooltip, TooltipContent, TooltipTrigger } from "../../../components/ui/tooltip";
import { useSystemInfo } from "~/client/hooks/use-system-info";
import { DirectoryForm, NFSForm, SMBForm, WebDAVForm, RcloneForm } from "./volume-forms";
export const formSchema = type({
name: "2<=string<=32",
}).and(volumeConfigSchema);
const cleanSchema = type.pipe((d) => formSchema(deepClean(d)));
export type FormValues = typeof formSchema.inferIn;
type Props = {
onSubmit: (values: FormValues) => void;
mode?: "create" | "update";
initialValues?: Partial<FormValues>;
formId?: string;
loading?: boolean;
className?: string;
};
const defaultValuesForType = {
directory: { backend: "directory" as const, path: "/" },
nfs: { backend: "nfs" as const, port: 2049, version: "4.1" as const },
smb: { backend: "smb" as const, port: 445, vers: "3.0" as const },
webdav: { backend: "webdav" as const, port: 80, ssl: false },
rclone: { backend: "rclone" as const },
};
export const CreateVolumeForm = ({ onSubmit, mode = "create", initialValues, formId, loading, className }: Props) => {
const form = useForm<FormValues>({
resolver: arktypeResolver(cleanSchema as unknown as typeof formSchema),
defaultValues: initialValues || {
name: "",
backend: "directory",
},
resetOptions: {
keepDefaultValues: true,
keepDirtyValues: false,
},
});
const { watch, getValues } = form;
const { capabilities } = useSystemInfo();
const watchedBackend = watch("backend");
useEffect(() => {
if (mode === "create") {
form.reset({
name: form.getValues().name,
...defaultValuesForType[watchedBackend as keyof typeof defaultValuesForType],
});
}
}, [watchedBackend, form, mode]);
const [testMessage, setTestMessage] = useState<{ success: boolean; message: string } | null>(null);
const testBackendConnection = useMutation({
...testConnectionMutation(),
onMutate: () => {
setTestMessage(null);
},
onError: (error) => {
setTestMessage({
success: false,
message: error?.message || "Failed to test connection. Please try again.",
});
},
onSuccess: (data) => {
setTestMessage(data);
},
});
const handleTestConnection = async () => {
const formValues = getValues();
if (formValues.backend === "nfs" || formValues.backend === "smb" || formValues.backend === "webdav") {
testBackendConnection.mutate({
body: { config: formValues },
});
}
};
return (
<Form {...form}>
<form id={formId} onSubmit={form.handleSubmit(onSubmit)} className={cn("space-y-4", className)}>
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>Name</FormLabel>
<FormControl>
<Input
{...field}
placeholder="Volume name"
onChange={(e) => field.onChange(slugify(e.target.value))}
maxLength={32}
minLength={2}
/>
</FormControl>
<FormDescription>Unique identifier for the volume.</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="backend"
defaultValue="directory"
render={({ field }) => (
<FormItem>
<FormLabel>Backend</FormLabel>
<Select onValueChange={field.onChange} value={field.value}>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select a backend" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="directory">Directory</SelectItem>
<Tooltip>
<TooltipTrigger asChild>
<div>
<SelectItem disabled={!capabilities.sysAdmin} value="nfs">
NFS
</SelectItem>
</div>
</TooltipTrigger>
<TooltipContent className={cn({ hidden: capabilities.sysAdmin })}>
<p>Remote mounts require SYS_ADMIN capability</p>
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<div>
<SelectItem disabled={!capabilities.sysAdmin} value="smb">
SMB
</SelectItem>
</div>
</TooltipTrigger>
<TooltipContent className={cn({ hidden: capabilities.sysAdmin })}>
<p>Remote mounts require SYS_ADMIN capability</p>
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<div>
<SelectItem disabled={!capabilities.sysAdmin} value="webdav">
WebDAV
</SelectItem>
</div>
</TooltipTrigger>
<TooltipContent className={cn({ hidden: capabilities.sysAdmin })}>
<p>Remote mounts require SYS_ADMIN capability</p>
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<div>
<SelectItem disabled={!capabilities.rclone || !capabilities.sysAdmin} value="rclone">
rclone
</SelectItem>
</div>
</TooltipTrigger>
<TooltipContent className={cn({ hidden: capabilities.sysAdmin })}>
<p>Remote mounts require SYS_ADMIN capability</p>
</TooltipContent>
<TooltipContent className={cn({ hidden: !capabilities.sysAdmin || capabilities.rclone })}>
<p>Setup rclone to use this backend</p>
</TooltipContent>
</Tooltip>
</SelectContent>
</Select>
<FormDescription>Choose the storage backend for this volume.</FormDescription>
<FormMessage />
</FormItem>
)}
/>
{watchedBackend === "directory" && <DirectoryForm form={form} />}
{watchedBackend === "nfs" && <NFSForm form={form} />}
{watchedBackend === "webdav" && <WebDAVForm form={form} />}
{watchedBackend === "smb" && <SMBForm form={form} />}
{watchedBackend === "rclone" && <RcloneForm form={form} />}
{watchedBackend && watchedBackend !== "directory" && watchedBackend !== "rclone" && (
<div className="space-y-3">
<div className="flex items-center gap-2">
<Button
type="button"
variant="outline"
onClick={handleTestConnection}
disabled={testBackendConnection.isPending}
className="flex-1"
>
{testBackendConnection.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
{!testBackendConnection.isPending && testMessage?.success && (
<CheckCircle className="mr-2 h-4 w-4 text-green-500" />
)}
{!testBackendConnection.isPending && testMessage && !testMessage.success && (
<XCircle className="mr-2 h-4 w-4 text-red-500" />
)}
{!testBackendConnection.isPending && !testMessage && <Plug className="mr-2 h-4 w-4" />}
{testBackendConnection.isPending
? "Testing..."
: testMessage
? testMessage.success
? "Connection Successful"
: "Test Failed"
: "Test Connection"}
</Button>
</div>
{testMessage && (
<div
className={cn("text-xs p-2 rounded-md text-wrap wrap-anywhere", {
"bg-green-50 text-green-700 border border-green-200": testMessage.success,
"bg-red-50 text-red-700 border border-red-200": !testMessage.success,
})}
>
{testMessage.message}
</div>
)}
</div>
)}
{mode === "update" && (
<Button type="submit" className="w-full" loading={loading}>
<Save className="h-4 w-4 mr-2" />
Save changes
</Button>
)}
</form>
</Form>
);
};

View file

@ -1,6 +1,6 @@
import { useMutation } from "@tanstack/react-query"; import { useMutation } from "@tanstack/react-query";
import { formatDistanceToNow } from "date-fns"; import { formatDistanceToNow } from "date-fns";
import { HeartIcon } from "lucide-react"; import { Activity, HeartIcon } from "lucide-react";
import { toast } from "sonner"; import { toast } from "sonner";
import { healthCheckVolumeMutation, updateVolumeMutation } from "~/client/api-client/@tanstack/react-query.gen"; import { healthCheckVolumeMutation, updateVolumeMutation } from "~/client/api-client/@tanstack/react-query.gen";
import { OnOff } from "~/client/components/onoff"; import { OnOff } from "~/client/components/onoff";
@ -80,6 +80,7 @@ export const HealthchecksCard = ({ volume }: Props) => {
loading={healthcheck.isPending} loading={healthcheck.isPending}
onClick={() => healthcheck.mutate({ path: { name: volume.name } })} onClick={() => healthcheck.mutate({ path: { name: volume.name } })}
> >
<Activity className="h-4 w-4 mr-2" />
Run Health Check Run Health Check
</Button> </Button>
</div> </div>

View file

@ -100,7 +100,7 @@ export function StorageChart({ statfs }: Props) {
<div className="flex items-center justify-between p-3 rounded-lg bg-muted/50"> <div className="flex items-center justify-between p-3 rounded-lg bg-muted/50">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<HardDrive className="h-4 w-4 text-muted-foreground" /> <HardDrive className="h-4 w-4 text-muted-foreground" />
<span className="font-medium">Total Capacity</span> <span className="font-medium">Total capacity</span>
</div> </div>
<ByteSize bytes={statfs.total} className="font-mono text-sm" /> <ByteSize bytes={statfs.total} className="font-mono text-sm" />
</div> </div>
@ -108,7 +108,7 @@ export function StorageChart({ statfs }: Props) {
<div className="flex items-center justify-between p-3 rounded-lg bg-strong-accent/10"> <div className="flex items-center justify-between p-3 rounded-lg bg-strong-accent/10">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<div className="h-4 w-4 rounded-full bg-strong-accent" /> <div className="h-4 w-4 rounded-full bg-strong-accent" />
<span className="font-medium">Used Space</span> <span className="font-medium">Used space</span>
</div> </div>
<div className="text-right"> <div className="text-right">
<ByteSize bytes={statfs.used} className="font-mono text-sm" /> <ByteSize bytes={statfs.used} className="font-mono text-sm" />
@ -118,7 +118,7 @@ export function StorageChart({ statfs }: Props) {
<div className="flex items-center justify-between p-3 rounded-lg bg-primary/10"> <div className="flex items-center justify-between p-3 rounded-lg bg-primary/10">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<div className="h-4 w-4 rounded-full bg-primary" /> <div className="h-4 w-4 rounded-full bg-primary" />
<span className="font-medium">Free Space</span> <span className="font-medium">Free space</span>
</div> </div>
<div className="text-right"> <div className="text-right">
<ByteSize bytes={statfs.free} className="font-mono text-sm" /> <ByteSize bytes={statfs.free} className="font-mono text-sm" />

View file

@ -0,0 +1,51 @@
import { Pencil } from "lucide-react";
import type { UseFormReturn } from "react-hook-form";
import type { FormValues } from "../create-volume-form";
import { DirectoryBrowser } from "../../../../components/directory-browser";
import { Button } from "../../../../components/ui/button";
import {
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "../../../../components/ui/form";
type Props = {
form: UseFormReturn<FormValues>;
};
export const DirectoryForm = ({ form }: Props) => {
return (
<FormField
control={form.control}
name="path"
render={({ field }) => {
return (
<FormItem>
<FormLabel>Directory Path</FormLabel>
<FormControl>
{field.value ? (
<div className="flex items-center gap-2">
<div className="flex-1 border rounded-md p-3 bg-muted/50">
<div className="text-xs font-medium text-muted-foreground mb-1">Selected path:</div>
<div className="text-sm font-mono break-all">{field.value}</div>
</div>
<Button type="button" variant="outline" size="sm" onClick={() => field.onChange("")}>
<Pencil className="h-4 w-4 mr-2" />
Change
</Button>
</div>
) : (
<DirectoryBrowser onSelectPath={(path) => field.onChange(path)} selectedPath={field.value} />
)}
</FormControl>
<FormDescription>Browse and select a directory on the host filesystem to track.</FormDescription>
<FormMessage />
</FormItem>
);
}}
/>
);
};

View file

@ -0,0 +1,5 @@
export { DirectoryForm } from "./directory-form";
export { NFSForm } from "./nfs-form";
export { SMBForm } from "./smb-form";
export { WebDAVForm } from "./webdav-form";
export { RcloneForm } from "./rclone-form";

View file

@ -0,0 +1,120 @@
import type { UseFormReturn } from "react-hook-form";
import type { FormValues } from "../create-volume-form";
import {
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "../../../../components/ui/form";
import { Input } from "../../../../components/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "../../../../components/ui/select";
type Props = {
form: UseFormReturn<FormValues>;
};
export const NFSForm = ({ form }: Props) => {
return (
<>
<FormField
control={form.control}
name="server"
render={({ field }) => (
<FormItem>
<FormLabel>Server</FormLabel>
<FormControl>
<Input placeholder="192.168.1.100" {...field} />
</FormControl>
<FormDescription>NFS server IP address or hostname.</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="exportPath"
render={({ field }) => (
<FormItem>
<FormLabel>Export Path</FormLabel>
<FormControl>
<Input placeholder="/export/data" {...field} />
</FormControl>
<FormDescription>Path to the NFS export on the server.</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="port"
defaultValue={2049}
render={({ field }) => (
<FormItem>
<FormLabel>Port</FormLabel>
<FormControl>
<Input
type="number"
placeholder="2049"
{...field}
onChange={(e) => field.onChange(parseInt(e.target.value, 10) || undefined)}
/>
</FormControl>
<FormDescription>NFS server port (default: 2049).</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="version"
defaultValue="4.1"
render={({ field }) => (
<FormItem>
<FormLabel>Version</FormLabel>
<Select onValueChange={field.onChange} defaultValue="4.1">
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select NFS version" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="3">NFS v3</SelectItem>
<SelectItem value="4">NFS v4</SelectItem>
<SelectItem value="4.1">NFS v4.1</SelectItem>
</SelectContent>
</Select>
<FormDescription>NFS protocol version to use.</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="readOnly"
defaultValue={false}
render={({ field }) => (
<FormItem>
<FormLabel>Read-only Mode</FormLabel>
<FormControl>
<div className="flex items-center space-x-2">
<input
type="checkbox"
checked={field.value ?? false}
onChange={(e) => field.onChange(e.target.checked)}
className="rounded border-gray-300"
/>
<span className="text-sm">Mount volume as read-only</span>
</div>
</FormControl>
<FormDescription>
Prevent any modifications to the volume. Recommended for backup sources and sensitive data.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</>
);
};

View file

@ -0,0 +1,127 @@
import { ExternalLink } from "lucide-react";
import type { UseFormReturn } from "react-hook-form";
import type { FormValues } from "../create-volume-form";
import {
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "../../../../components/ui/form";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "../../../../components/ui/select";
import { Alert, AlertDescription } from "../../../../components/ui/alert";
import { Input } from "../../../../components/ui/input";
import { listRcloneRemotesOptions } from "~/client/api-client/@tanstack/react-query.gen";
import { useSystemInfo } from "~/client/hooks/use-system-info";
import { useQuery } from "@tanstack/react-query";
type Props = {
form: UseFormReturn<FormValues>;
};
export const RcloneForm = ({ form }: Props) => {
const { capabilities } = useSystemInfo();
const { data: rcloneRemotes, isPending } = useQuery({
...listRcloneRemotesOptions(),
enabled: capabilities.rclone,
});
if (!isPending && !rcloneRemotes?.length) {
return (
<Alert>
<AlertDescription className="space-y-2">
<p className="font-medium">No rclone remotes configured</p>
<p className="text-sm text-muted-foreground">
To use rclone, you need to configure remotes on your host system
</p>
<a
href="https://rclone.org/docs/"
target="_blank"
rel="noopener noreferrer"
className="text-sm text-strong-accent inline-flex items-center gap-1"
>
View rclone documentation
<ExternalLink className="w-3 h-3" />
</a>
</AlertDescription>
</Alert>
);
}
return (
<>
<FormField
control={form.control}
name="remote"
render={({ field }) => (
<FormItem>
<FormLabel>Remote</FormLabel>
<Select onValueChange={(v) => field.onChange(v)} value={field.value}>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select an rclone remote" />
</SelectTrigger>
</FormControl>
<SelectContent>
{isPending ? (
<SelectItem value="loading" disabled>
Loading remotes...
</SelectItem>
) : (
rcloneRemotes?.map((remote) => (
<SelectItem key={remote.name} value={remote.name}>
{remote.name} ({remote.type})
</SelectItem>
))
)}
</SelectContent>
</Select>
<FormDescription>Select the rclone remote configured on your host system.</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="path"
render={({ field }) => (
<FormItem>
<FormLabel>Path</FormLabel>
<FormControl>
<Input placeholder="/" {...field} />
</FormControl>
<FormDescription>Path on the remote to mount. Use "/" for the root.</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="readOnly"
defaultValue={false}
render={({ field }) => (
<FormItem>
<FormLabel>Read-only Mode</FormLabel>
<FormControl>
<div className="flex items-center space-x-2">
<input
type="checkbox"
checked={field.value ?? false}
onChange={(e) => field.onChange(e.target.checked)}
className="rounded border-gray-300"
/>
<span className="text-sm">Mount volume as read-only</span>
</div>
</FormControl>
<FormDescription>
Prevent any modifications to the volume. Recommended for backup sources and sensitive data.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</>
);
};

View file

@ -0,0 +1,164 @@
import type { UseFormReturn } from "react-hook-form";
import type { FormValues } from "../create-volume-form";
import {
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "../../../../components/ui/form";
import { Input } from "../../../../components/ui/input";
import { SecretInput } from "../../../../components/ui/secret-input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "../../../../components/ui/select";
type Props = {
form: UseFormReturn<FormValues>;
};
export const SMBForm = ({ form }: Props) => {
return (
<>
<FormField
control={form.control}
name="server"
render={({ field }) => (
<FormItem>
<FormLabel>Server</FormLabel>
<FormControl>
<Input placeholder="192.168.1.100" value={field.value} onChange={field.onChange} />
</FormControl>
<FormDescription>SMB server IP address or hostname.</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="share"
render={({ field }) => (
<FormItem>
<FormLabel>Share</FormLabel>
<FormControl>
<Input placeholder="myshare" value={field.value} onChange={field.onChange} />
</FormControl>
<FormDescription>SMB share name on the server.</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="username"
render={({ field }) => (
<FormItem>
<FormLabel>Username</FormLabel>
<FormControl>
<Input placeholder="admin" value={field.value} onChange={field.onChange} />
</FormControl>
<FormDescription>Username for SMB authentication.</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="password"
render={({ field }) => (
<FormItem>
<FormLabel>Password</FormLabel>
<FormControl>
<SecretInput placeholder="••••••••" value={field.value ?? ""} onChange={field.onChange} />
</FormControl>
<FormDescription>Password for SMB authentication.</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="vers"
defaultValue="3.0"
render={({ field }) => (
<FormItem>
<FormLabel>SMB Version</FormLabel>
<Select onValueChange={field.onChange} value={field.value}>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select SMB version" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="1.0">SMB v1.0</SelectItem>
<SelectItem value="2.0">SMB v2.0</SelectItem>
<SelectItem value="2.1">SMB v2.1</SelectItem>
<SelectItem value="3.0">SMB v3.0</SelectItem>
</SelectContent>
</Select>
<FormDescription>SMB protocol version to use (default: 3.0).</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="domain"
render={({ field }) => (
<FormItem>
<FormLabel>Domain (Optional)</FormLabel>
<FormControl>
<Input placeholder="WORKGROUP" value={field.value} onChange={field.onChange} />
</FormControl>
<FormDescription>Domain or workgroup for authentication (optional).</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="port"
defaultValue={445}
render={({ field }) => (
<FormItem>
<FormLabel>Port</FormLabel>
<FormControl>
<Input
type="number"
placeholder="445"
value={field.value}
onChange={(e) => field.onChange(parseInt(e.target.value, 10) || undefined)}
/>
</FormControl>
<FormDescription>SMB server port (default: 445).</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="readOnly"
defaultValue={false}
render={({ field }) => (
<FormItem>
<FormLabel>Read-only Mode</FormLabel>
<FormControl>
<div className="flex items-center space-x-2">
<input
type="checkbox"
checked={field.value ?? false}
onChange={(e) => field.onChange(e.target.checked)}
className="rounded border-gray-300"
/>
<span className="text-sm">Mount volume as read-only</span>
</div>
</FormControl>
<FormDescription>
Prevent any modifications to the volume. Recommended for backup sources and sensitive data.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</>
);
};

View file

@ -0,0 +1,147 @@
import type { UseFormReturn } from "react-hook-form";
import type { FormValues } from "../create-volume-form";
import {
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "../../../../components/ui/form";
import { Input } from "../../../../components/ui/input";
import { SecretInput } from "../../../../components/ui/secret-input";
type Props = {
form: UseFormReturn<FormValues>;
};
export const WebDAVForm = ({ form }: Props) => {
return (
<>
<FormField
control={form.control}
name="server"
render={({ field }) => (
<FormItem>
<FormLabel>Server</FormLabel>
<FormControl>
<Input placeholder="example.com" {...field} />
</FormControl>
<FormDescription>WebDAV server hostname or IP address.</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="path"
render={({ field }) => (
<FormItem>
<FormLabel>Path</FormLabel>
<FormControl>
<Input placeholder="/webdav" {...field} />
</FormControl>
<FormDescription>Path to the WebDAV directory on the server.</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="username"
render={({ field }) => (
<FormItem>
<FormLabel>Username (Optional)</FormLabel>
<FormControl>
<Input placeholder="admin" {...field} />
</FormControl>
<FormDescription>Username for WebDAV authentication (optional).</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="password"
render={({ field }) => (
<FormItem>
<FormLabel>Password (Optional)</FormLabel>
<FormControl>
<SecretInput placeholder="••••••••" value={field.value ?? ""} onChange={field.onChange} />
</FormControl>
<FormDescription>Password for WebDAV authentication (optional).</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="port"
defaultValue={80}
render={({ field }) => (
<FormItem>
<FormLabel>Port</FormLabel>
<FormControl>
<Input
type="number"
placeholder="80"
{...field}
onChange={(e) => field.onChange(parseInt(e.target.value, 10) || undefined)}
/>
</FormControl>
<FormDescription>WebDAV server port (default: 80 for HTTP, 443 for HTTPS).</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="ssl"
defaultValue={false}
render={({ field }) => (
<FormItem>
<FormLabel>Use SSL/HTTPS</FormLabel>
<FormControl>
<div className="flex items-center space-x-2">
<input
type="checkbox"
checked={field.value ?? false}
onChange={(e) => field.onChange(e.target.checked)}
className="rounded border-gray-300"
/>
<span className="text-sm">Enable HTTPS for secure connections</span>
</div>
</FormControl>
<FormDescription>Use HTTPS instead of HTTP for secure connections.</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="readOnly"
defaultValue={false}
render={({ field }) => (
<FormItem>
<FormLabel>Read-only Mode</FormLabel>
<FormControl>
<div className="flex items-center space-x-2">
<input
type="checkbox"
checked={field.value ?? false}
onChange={(e) => field.onChange(e.target.checked)}
className="rounded border-gray-300"
/>
<span className="text-sm">Mount volume as read-only</span>
</div>
</FormControl>
<FormDescription>
Prevent any modifications to the volume. Recommended for backup sources and sensitive data.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</>
);
};

View file

@ -1,10 +1,10 @@
import { useMutation } from "@tanstack/react-query"; import { useMutation } from "@tanstack/react-query";
import { HardDrive } from "lucide-react"; import { HardDrive, Plus } from "lucide-react";
import { useId } from "react"; import { useId } from "react";
import { useNavigate } from "react-router"; import { useNavigate } from "react-router";
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/components/create-volume-form"; import { CreateVolumeForm, 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";
@ -73,6 +73,7 @@ export default function CreateVolume() {
Cancel Cancel
</Button> </Button>
<Button type="submit" form={formId} loading={createVolume.isPending}> <Button type="submit" form={formId} loading={createVolume.isPending}>
<Plus className="h-4 w-4 mr-2" />
Create Volume Create Volume
</Button> </Button>
</div> </div>

View file

@ -2,6 +2,7 @@ import { useMutation, useQuery } from "@tanstack/react-query";
import { useNavigate, useParams, useSearchParams } from "react-router"; import { useNavigate, useParams, useSearchParams } from "react-router";
import { toast } from "sonner"; import { toast } from "sonner";
import { useState } from "react"; import { useState } from "react";
import { Plug, Unplug } from "lucide-react";
import { StatusDot } from "~/client/components/status-dot"; import { StatusDot } from "~/client/components/status-dot";
import { Button } from "~/client/components/ui/button"; import { Button } from "~/client/components/ui/button";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "~/client/components/ui/tabs"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "~/client/components/ui/tabs";
@ -20,9 +21,6 @@ import { cn } from "~/client/lib/utils";
import type { Route } from "./+types/volume-details"; import type { Route } from "./+types/volume-details";
import { VolumeInfoTabContent } from "../tabs/info"; import { VolumeInfoTabContent } from "../tabs/info";
import { FilesTabContent } from "../tabs/files"; import { FilesTabContent } from "../tabs/files";
import { DockerTabContent } from "../tabs/docker";
import { Tooltip, TooltipContent, TooltipTrigger } from "~/client/components/ui/tooltip";
import { useSystemInfo } from "~/client/hooks/use-system-info";
import { getVolume } from "~/client/api-client"; import { getVolume } from "~/client/api-client";
import type { VolumeStatus } from "~/client/lib/types"; import type { VolumeStatus } from "~/client/lib/types";
import { import {
@ -73,8 +71,6 @@ export default function VolumeDetails({ loaderData }: Route.ComponentProps) {
initialData: loaderData, initialData: loaderData,
}); });
const { capabilities } = useSystemInfo();
const deleteVol = useMutation({ const deleteVol = useMutation({
...deleteVolumeMutation(), ...deleteVolumeMutation(),
onSuccess: () => { onSuccess: () => {
@ -126,7 +122,6 @@ export default function VolumeDetails({ loaderData }: Route.ComponentProps) {
} }
const { volume, statfs } = data; const { volume, statfs } = data;
const dockerAvailable = capabilities.docker;
return ( return (
<> <>
@ -148,6 +143,7 @@ export default function VolumeDetails({ loaderData }: Route.ComponentProps) {
loading={mountVol.isPending} loading={mountVol.isPending}
className={cn({ hidden: volume.status === "mounted" })} className={cn({ hidden: volume.status === "mounted" })}
> >
<Plug className="h-4 w-4 mr-2" />
Mount Mount
</Button> </Button>
<Button <Button
@ -156,6 +152,7 @@ export default function VolumeDetails({ loaderData }: Route.ComponentProps) {
loading={unmountVol.isPending} loading={unmountVol.isPending}
className={cn({ hidden: volume.status !== "mounted" })} className={cn({ hidden: volume.status !== "mounted" })}
> >
<Unplug className="h-4 w-4 mr-2" />
Unmount Unmount
</Button> </Button>
<Button variant="destructive" onClick={() => setShowDeleteConfirm(true)} disabled={deleteVol.isPending}> <Button variant="destructive" onClick={() => setShowDeleteConfirm(true)} disabled={deleteVol.isPending}>
@ -167,16 +164,6 @@ export default function VolumeDetails({ loaderData }: Route.ComponentProps) {
<TabsList className="mb-2"> <TabsList className="mb-2">
<TabsTrigger value="info">Configuration</TabsTrigger> <TabsTrigger value="info">Configuration</TabsTrigger>
<TabsTrigger value="files">Files</TabsTrigger> <TabsTrigger value="files">Files</TabsTrigger>
<Tooltip>
<TooltipTrigger>
<TabsTrigger disabled={!dockerAvailable} value="docker">
Docker
</TabsTrigger>
</TooltipTrigger>
<TooltipContent className={cn({ hidden: dockerAvailable })}>
<p>Enable Docker support to access this tab.</p>
</TooltipContent>
</Tooltip>
</TabsList> </TabsList>
<TabsContent value="info"> <TabsContent value="info">
<VolumeInfoTabContent volume={volume} statfs={statfs} /> <VolumeInfoTabContent volume={volume} statfs={statfs} />
@ -184,11 +171,6 @@ export default function VolumeDetails({ loaderData }: Route.ComponentProps) {
<TabsContent value="files"> <TabsContent value="files">
<FilesTabContent volume={volume} /> <FilesTabContent volume={volume} />
</TabsContent> </TabsContent>
{dockerAvailable && (
<TabsContent value="docker">
<DockerTabContent volume={volume} />
</TabsContent>
)}
</Tabs> </Tabs>
<AlertDialog open={showDeleteConfirm} onOpenChange={setShowDeleteConfirm}> <AlertDialog open={showDeleteConfirm} onOpenChange={setShowDeleteConfirm}>

View file

@ -1,133 +0,0 @@
import { useQuery } from "@tanstack/react-query";
import { Unplug } from "lucide-react";
import * as YML from "yaml";
import { getContainersUsingVolumeOptions } from "~/client/api-client/@tanstack/react-query.gen";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "~/client/components/ui/card";
import { CodeBlock } from "~/client/components/ui/code-block";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "~/client/components/ui/table";
import type { Volume } from "~/client/lib/types";
type Props = {
volume: Volume;
};
export const DockerTabContent = ({ volume }: Props) => {
const yamlString = YML.stringify({
services: {
nginx: {
image: "nginx:latest",
volumes: [`zb-${volume.shortId}:/path/in/container`],
},
},
volumes: {
[`zb-${volume.shortId}`]: {
external: true,
},
},
});
const dockerRunCommand = `docker run -v zb-${volume.shortId}:/path/in/container nginx:latest`;
const {
data: containersData,
isLoading,
error,
} = useQuery({
...getContainersUsingVolumeOptions({ path: { name: volume.name } }),
refetchInterval: 10000,
refetchOnWindowFocus: true,
});
const containers = containersData || [];
const getStateClass = (state: string) => {
switch (state) {
case "running":
return "bg-green-100 text-green-800";
case "exited":
return "bg-orange-100 text-orange-800";
default:
return "bg-gray-100 text-gray-800";
}
};
return (
<div className="grid gap-4 xl:grid-cols-[minmax(0,1fr)_minmax(0,1fr)]">
<Card>
<CardHeader>
<CardTitle>Plug-and-play Docker integration</CardTitle>
<CardDescription>
This volume can be used in your Docker Compose files by referencing it as an external volume. The example
demonstrates how to mount the volume to a service (nginx in this case). Make sure to adjust the path inside
the container to fit your application's needs
</CardDescription>
</CardHeader>
<CardContent>
<div className="relative space-y-6">
<div className="space-y-4">
<div className="flex flex-col gap-4">
<CodeBlock code={yamlString} language="yaml" filename="docker-compose.yml" />
</div>
<div className="text-sm text-muted-foreground">
Alternatively, you can use the following command to run a Docker container with the volume mounted
</div>
<div className="flex flex-col gap-4">
<CodeBlock code={dockerRunCommand} filename="CLI one-liner" />
</div>
</div>
</div>
</CardContent>
</Card>
<div className="grid">
<Card>
<CardHeader>
<CardTitle>Containers Using This Volume</CardTitle>
<CardDescription>List of Docker containers mounting this volume.</CardDescription>
</CardHeader>
<CardContent className="space-y-4 text-sm h-full">
{isLoading && <div>Loading containers...</div>}
{error && <div className="text-destructive">Failed to load containers: {String(error)}</div>}
{!isLoading && !error && containers.length === 0 && (
<div className="flex flex-col items-center justify-center text-center h-full">
<Unplug className="mb-4 h-5 w-5 text-muted-foreground" />
<p className="text-muted-foreground">No Docker containers are currently using this volume.</p>
</div>
)}
{!isLoading && !error && containers.length > 0 && (
<div className="max-h-130 overflow-y-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead>ID</TableHead>
<TableHead>State</TableHead>
<TableHead>Image</TableHead>
</TableRow>
</TableHeader>
<TableBody className="text-sm">
{containers.map((container) => (
<TableRow key={container.id}>
<TableCell>{container.name}</TableCell>
<TableCell>{container.id.slice(0, 12)}</TableCell>
<TableCell>
<span
className={`px-2 py-1 rounded-full text-xs font-medium ${getStateClass(container.state)}`}
>
{container.state}
</span>
</TableCell>
<TableCell>{container.image}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
)}
</CardContent>
</Card>
</div>
</div>
);
};

View file

@ -2,7 +2,8 @@ import { useMutation } from "@tanstack/react-query";
import { useState } from "react"; import { useState } from "react";
import { useNavigate } from "react-router"; import { useNavigate } from "react-router";
import { toast } from "sonner"; import { toast } from "sonner";
import { CreateVolumeForm, type FormValues } from "~/client/components/create-volume-form"; import { Check } from "lucide-react";
import { CreateVolumeForm, type FormValues } from "~/client/modules/volumes/components/create-volume-form";
import { import {
AlertDialog, AlertDialog,
AlertDialogAction, AlertDialogAction,
@ -94,7 +95,10 @@ export const VolumeInfoTabContent = ({ volume, statfs }: Props) => {
</AlertDialogHeader> </AlertDialogHeader>
<AlertDialogFooter> <AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel> <AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction onClick={confirmUpdate}>Update</AlertDialogAction> <AlertDialogAction onClick={confirmUpdate}>
<Check className="h-4 w-4" />
Update
</AlertDialogAction>
</AlertDialogFooter> </AlertDialogFooter>
</AlertDialogContent> </AlertDialogContent>
</AlertDialog> </AlertDialog>

View file

@ -0,0 +1 @@
ALTER TABLE `backup_schedule_notifications_table` ADD `notify_on_warning` integer DEFAULT true NOT NULL;

View file

@ -0,0 +1 @@
ALTER TABLE `backup_schedules_table` ADD `sort_order` integer DEFAULT 0 NOT NULL;

View file

@ -0,0 +1,823 @@
{
"version": "6",
"dialect": "sqlite",
"id": "e7c02f6c-e255-402e-9f18-d50a3fef8e4d",
"prevId": "729d3ce9-b4b9-41f6-a270-d74c96510238",
"tables": {
"app_metadata": {
"name": "app_metadata",
"columns": {
"key": {
"name": "key",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"value": {
"name": "value",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(unixepoch() * 1000)"
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(unixepoch() * 1000)"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"backup_schedule_mirrors_table": {
"name": "backup_schedule_mirrors_table",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"schedule_id": {
"name": "schedule_id",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"repository_id": {
"name": "repository_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"enabled": {
"name": "enabled",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": true
},
"last_copy_at": {
"name": "last_copy_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"last_copy_status": {
"name": "last_copy_status",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"last_copy_error": {
"name": "last_copy_error",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(unixepoch() * 1000)"
}
},
"indexes": {
"backup_schedule_mirrors_table_schedule_id_repository_id_unique": {
"name": "backup_schedule_mirrors_table_schedule_id_repository_id_unique",
"columns": [
"schedule_id",
"repository_id"
],
"isUnique": true
}
},
"foreignKeys": {
"backup_schedule_mirrors_table_schedule_id_backup_schedules_table_id_fk": {
"name": "backup_schedule_mirrors_table_schedule_id_backup_schedules_table_id_fk",
"tableFrom": "backup_schedule_mirrors_table",
"tableTo": "backup_schedules_table",
"columnsFrom": [
"schedule_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
},
"backup_schedule_mirrors_table_repository_id_repositories_table_id_fk": {
"name": "backup_schedule_mirrors_table_repository_id_repositories_table_id_fk",
"tableFrom": "backup_schedule_mirrors_table",
"tableTo": "repositories_table",
"columnsFrom": [
"repository_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"backup_schedule_notifications_table": {
"name": "backup_schedule_notifications_table",
"columns": {
"schedule_id": {
"name": "schedule_id",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"destination_id": {
"name": "destination_id",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"notify_on_start": {
"name": "notify_on_start",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": false
},
"notify_on_success": {
"name": "notify_on_success",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": false
},
"notify_on_warning": {
"name": "notify_on_warning",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": true
},
"notify_on_failure": {
"name": "notify_on_failure",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": true
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(unixepoch() * 1000)"
}
},
"indexes": {},
"foreignKeys": {
"backup_schedule_notifications_table_schedule_id_backup_schedules_table_id_fk": {
"name": "backup_schedule_notifications_table_schedule_id_backup_schedules_table_id_fk",
"tableFrom": "backup_schedule_notifications_table",
"tableTo": "backup_schedules_table",
"columnsFrom": [
"schedule_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
},
"backup_schedule_notifications_table_destination_id_notification_destinations_table_id_fk": {
"name": "backup_schedule_notifications_table_destination_id_notification_destinations_table_id_fk",
"tableFrom": "backup_schedule_notifications_table",
"tableTo": "notification_destinations_table",
"columnsFrom": [
"destination_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {
"backup_schedule_notifications_table_schedule_id_destination_id_pk": {
"columns": [
"schedule_id",
"destination_id"
],
"name": "backup_schedule_notifications_table_schedule_id_destination_id_pk"
}
},
"uniqueConstraints": {},
"checkConstraints": {}
},
"backup_schedules_table": {
"name": "backup_schedules_table",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"volume_id": {
"name": "volume_id",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"repository_id": {
"name": "repository_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"enabled": {
"name": "enabled",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": true
},
"cron_expression": {
"name": "cron_expression",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"retention_policy": {
"name": "retention_policy",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"exclude_patterns": {
"name": "exclude_patterns",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false,
"default": "'[]'"
},
"exclude_if_present": {
"name": "exclude_if_present",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false,
"default": "'[]'"
},
"include_patterns": {
"name": "include_patterns",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false,
"default": "'[]'"
},
"last_backup_at": {
"name": "last_backup_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"last_backup_status": {
"name": "last_backup_status",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"last_backup_error": {
"name": "last_backup_error",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"next_backup_at": {
"name": "next_backup_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(unixepoch() * 1000)"
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(unixepoch() * 1000)"
}
},
"indexes": {
"backup_schedules_table_name_unique": {
"name": "backup_schedules_table_name_unique",
"columns": [
"name"
],
"isUnique": true
}
},
"foreignKeys": {
"backup_schedules_table_volume_id_volumes_table_id_fk": {
"name": "backup_schedules_table_volume_id_volumes_table_id_fk",
"tableFrom": "backup_schedules_table",
"tableTo": "volumes_table",
"columnsFrom": [
"volume_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
},
"backup_schedules_table_repository_id_repositories_table_id_fk": {
"name": "backup_schedules_table_repository_id_repositories_table_id_fk",
"tableFrom": "backup_schedules_table",
"tableTo": "repositories_table",
"columnsFrom": [
"repository_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"notification_destinations_table": {
"name": "notification_destinations_table",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"enabled": {
"name": "enabled",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": true
},
"type": {
"name": "type",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"config": {
"name": "config",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(unixepoch() * 1000)"
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(unixepoch() * 1000)"
}
},
"indexes": {
"notification_destinations_table_name_unique": {
"name": "notification_destinations_table_name_unique",
"columns": [
"name"
],
"isUnique": true
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"repositories_table": {
"name": "repositories_table",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"short_id": {
"name": "short_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"type": {
"name": "type",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"config": {
"name": "config",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"compression_mode": {
"name": "compression_mode",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false,
"default": "'auto'"
},
"status": {
"name": "status",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false,
"default": "'unknown'"
},
"last_checked": {
"name": "last_checked",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"last_error": {
"name": "last_error",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(unixepoch() * 1000)"
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(unixepoch() * 1000)"
}
},
"indexes": {
"repositories_table_short_id_unique": {
"name": "repositories_table_short_id_unique",
"columns": [
"short_id"
],
"isUnique": true
},
"repositories_table_name_unique": {
"name": "repositories_table_name_unique",
"columns": [
"name"
],
"isUnique": true
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"sessions_table": {
"name": "sessions_table",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"user_id": {
"name": "user_id",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"expires_at": {
"name": "expires_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(unixepoch() * 1000)"
}
},
"indexes": {},
"foreignKeys": {
"sessions_table_user_id_users_table_id_fk": {
"name": "sessions_table_user_id_users_table_id_fk",
"tableFrom": "sessions_table",
"tableTo": "users_table",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"users_table": {
"name": "users_table",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"username": {
"name": "username",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"password_hash": {
"name": "password_hash",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"has_downloaded_restic_password": {
"name": "has_downloaded_restic_password",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(unixepoch() * 1000)"
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(unixepoch() * 1000)"
}
},
"indexes": {
"users_table_username_unique": {
"name": "users_table_username_unique",
"columns": [
"username"
],
"isUnique": true
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"volumes_table": {
"name": "volumes_table",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"short_id": {
"name": "short_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"type": {
"name": "type",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"status": {
"name": "status",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'unmounted'"
},
"last_error": {
"name": "last_error",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"last_health_check": {
"name": "last_health_check",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(unixepoch() * 1000)"
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(unixepoch() * 1000)"
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(unixepoch() * 1000)"
},
"config": {
"name": "config",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"auto_remount": {
"name": "auto_remount",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": true
}
},
"indexes": {
"volumes_table_short_id_unique": {
"name": "volumes_table_short_id_unique",
"columns": [
"short_id"
],
"isUnique": true
},
"volumes_table_name_unique": {
"name": "volumes_table_name_unique",
"columns": [
"name"
],
"isUnique": true
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
}
},
"views": {},
"enums": {},
"_meta": {
"schemas": {},
"tables": {},
"columns": {}
},
"internal": {
"indexes": {}
}
}

View file

@ -0,0 +1,831 @@
{
"version": "6",
"dialect": "sqlite",
"id": "11c24867-3186-4578-b8dd-cee4c48a28d1",
"prevId": "e7c02f6c-e255-402e-9f18-d50a3fef8e4d",
"tables": {
"app_metadata": {
"name": "app_metadata",
"columns": {
"key": {
"name": "key",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"value": {
"name": "value",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(unixepoch() * 1000)"
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(unixepoch() * 1000)"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"backup_schedule_mirrors_table": {
"name": "backup_schedule_mirrors_table",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"schedule_id": {
"name": "schedule_id",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"repository_id": {
"name": "repository_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"enabled": {
"name": "enabled",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": true
},
"last_copy_at": {
"name": "last_copy_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"last_copy_status": {
"name": "last_copy_status",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"last_copy_error": {
"name": "last_copy_error",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(unixepoch() * 1000)"
}
},
"indexes": {
"backup_schedule_mirrors_table_schedule_id_repository_id_unique": {
"name": "backup_schedule_mirrors_table_schedule_id_repository_id_unique",
"columns": [
"schedule_id",
"repository_id"
],
"isUnique": true
}
},
"foreignKeys": {
"backup_schedule_mirrors_table_schedule_id_backup_schedules_table_id_fk": {
"name": "backup_schedule_mirrors_table_schedule_id_backup_schedules_table_id_fk",
"tableFrom": "backup_schedule_mirrors_table",
"tableTo": "backup_schedules_table",
"columnsFrom": [
"schedule_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
},
"backup_schedule_mirrors_table_repository_id_repositories_table_id_fk": {
"name": "backup_schedule_mirrors_table_repository_id_repositories_table_id_fk",
"tableFrom": "backup_schedule_mirrors_table",
"tableTo": "repositories_table",
"columnsFrom": [
"repository_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"backup_schedule_notifications_table": {
"name": "backup_schedule_notifications_table",
"columns": {
"schedule_id": {
"name": "schedule_id",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"destination_id": {
"name": "destination_id",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"notify_on_start": {
"name": "notify_on_start",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": false
},
"notify_on_success": {
"name": "notify_on_success",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": false
},
"notify_on_warning": {
"name": "notify_on_warning",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": true
},
"notify_on_failure": {
"name": "notify_on_failure",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": true
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(unixepoch() * 1000)"
}
},
"indexes": {},
"foreignKeys": {
"backup_schedule_notifications_table_schedule_id_backup_schedules_table_id_fk": {
"name": "backup_schedule_notifications_table_schedule_id_backup_schedules_table_id_fk",
"tableFrom": "backup_schedule_notifications_table",
"tableTo": "backup_schedules_table",
"columnsFrom": [
"schedule_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
},
"backup_schedule_notifications_table_destination_id_notification_destinations_table_id_fk": {
"name": "backup_schedule_notifications_table_destination_id_notification_destinations_table_id_fk",
"tableFrom": "backup_schedule_notifications_table",
"tableTo": "notification_destinations_table",
"columnsFrom": [
"destination_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {
"backup_schedule_notifications_table_schedule_id_destination_id_pk": {
"columns": [
"schedule_id",
"destination_id"
],
"name": "backup_schedule_notifications_table_schedule_id_destination_id_pk"
}
},
"uniqueConstraints": {},
"checkConstraints": {}
},
"backup_schedules_table": {
"name": "backup_schedules_table",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"volume_id": {
"name": "volume_id",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"repository_id": {
"name": "repository_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"enabled": {
"name": "enabled",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": true
},
"cron_expression": {
"name": "cron_expression",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"retention_policy": {
"name": "retention_policy",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"exclude_patterns": {
"name": "exclude_patterns",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false,
"default": "'[]'"
},
"exclude_if_present": {
"name": "exclude_if_present",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false,
"default": "'[]'"
},
"include_patterns": {
"name": "include_patterns",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false,
"default": "'[]'"
},
"last_backup_at": {
"name": "last_backup_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"last_backup_status": {
"name": "last_backup_status",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"last_backup_error": {
"name": "last_backup_error",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"next_backup_at": {
"name": "next_backup_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"sort_order": {
"name": "sort_order",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(unixepoch() * 1000)"
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(unixepoch() * 1000)"
}
},
"indexes": {
"backup_schedules_table_name_unique": {
"name": "backup_schedules_table_name_unique",
"columns": [
"name"
],
"isUnique": true
}
},
"foreignKeys": {
"backup_schedules_table_volume_id_volumes_table_id_fk": {
"name": "backup_schedules_table_volume_id_volumes_table_id_fk",
"tableFrom": "backup_schedules_table",
"tableTo": "volumes_table",
"columnsFrom": [
"volume_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
},
"backup_schedules_table_repository_id_repositories_table_id_fk": {
"name": "backup_schedules_table_repository_id_repositories_table_id_fk",
"tableFrom": "backup_schedules_table",
"tableTo": "repositories_table",
"columnsFrom": [
"repository_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"notification_destinations_table": {
"name": "notification_destinations_table",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"enabled": {
"name": "enabled",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": true
},
"type": {
"name": "type",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"config": {
"name": "config",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(unixepoch() * 1000)"
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(unixepoch() * 1000)"
}
},
"indexes": {
"notification_destinations_table_name_unique": {
"name": "notification_destinations_table_name_unique",
"columns": [
"name"
],
"isUnique": true
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"repositories_table": {
"name": "repositories_table",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"short_id": {
"name": "short_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"type": {
"name": "type",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"config": {
"name": "config",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"compression_mode": {
"name": "compression_mode",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false,
"default": "'auto'"
},
"status": {
"name": "status",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false,
"default": "'unknown'"
},
"last_checked": {
"name": "last_checked",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"last_error": {
"name": "last_error",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(unixepoch() * 1000)"
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(unixepoch() * 1000)"
}
},
"indexes": {
"repositories_table_short_id_unique": {
"name": "repositories_table_short_id_unique",
"columns": [
"short_id"
],
"isUnique": true
},
"repositories_table_name_unique": {
"name": "repositories_table_name_unique",
"columns": [
"name"
],
"isUnique": true
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"sessions_table": {
"name": "sessions_table",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"user_id": {
"name": "user_id",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"expires_at": {
"name": "expires_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(unixepoch() * 1000)"
}
},
"indexes": {},
"foreignKeys": {
"sessions_table_user_id_users_table_id_fk": {
"name": "sessions_table_user_id_users_table_id_fk",
"tableFrom": "sessions_table",
"tableTo": "users_table",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"users_table": {
"name": "users_table",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"username": {
"name": "username",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"password_hash": {
"name": "password_hash",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"has_downloaded_restic_password": {
"name": "has_downloaded_restic_password",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(unixepoch() * 1000)"
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(unixepoch() * 1000)"
}
},
"indexes": {
"users_table_username_unique": {
"name": "users_table_username_unique",
"columns": [
"username"
],
"isUnique": true
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"volumes_table": {
"name": "volumes_table",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"short_id": {
"name": "short_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"type": {
"name": "type",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"status": {
"name": "status",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'unmounted'"
},
"last_error": {
"name": "last_error",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"last_health_check": {
"name": "last_health_check",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(unixepoch() * 1000)"
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(unixepoch() * 1000)"
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(unixepoch() * 1000)"
},
"config": {
"name": "config",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"auto_remount": {
"name": "auto_remount",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": true
}
},
"indexes": {
"volumes_table_short_id_unique": {
"name": "volumes_table_short_id_unique",
"columns": [
"short_id"
],
"isUnique": true
},
"volumes_table_name_unique": {
"name": "volumes_table_name_unique",
"columns": [
"name"
],
"isUnique": true
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
}
},
"views": {},
"enums": {},
"_meta": {
"schemas": {},
"tables": {},
"columns": {}
},
"internal": {
"indexes": {}
}
}

View file

@ -148,6 +148,20 @@
"when": 1764847918249, "when": 1764847918249,
"tag": "0020_even_dexter_bennett", "tag": "0020_even_dexter_bennett",
"breakpoints": true "breakpoints": true
},
{
"idx": 21,
"version": "6",
"when": 1765307881092,
"tag": "0021_steady_viper",
"breakpoints": true
},
{
"idx": 22,
"version": "6",
"when": 1765794552191,
"tag": "0022_woozy_shen",
"breakpoints": true
} }
] ]
} }

View file

@ -17,8 +17,8 @@ export const emailNotificationConfigSchema = type({
type: "'email'", type: "'email'",
smtpHost: "string", smtpHost: "string",
smtpPort: "1 <= number <= 65535", smtpPort: "1 <= number <= 65535",
username: "string", username: "string?",
password: "string", password: "string?",
from: "string", from: "string",
to: "string[]", to: "string[]",
useTLS: "boolean", useTLS: "boolean",

View file

@ -5,6 +5,7 @@ export const BACKEND_TYPES = {
smb: "smb", smb: "smb",
directory: "directory", directory: "directory",
webdav: "webdav", webdav: "webdav",
rclone: "rclone",
} as const; } as const;
export type BackendType = keyof typeof BACKEND_TYPES; export type BackendType = keyof typeof BACKEND_TYPES;
@ -47,7 +48,14 @@ export const webdavConfigSchema = type({
ssl: "boolean?", ssl: "boolean?",
}); });
export const volumeConfigSchema = nfsConfigSchema.or(smbConfigSchema).or(webdavConfigSchema).or(directoryConfigSchema); export const rcloneConfigSchema = type({
backend: "'rclone'",
remote: "string",
path: "string",
readOnly: "boolean?",
});
export const volumeConfigSchema = nfsConfigSchema.or(smbConfigSchema).or(webdavConfigSchema).or(directoryConfigSchema).or(rcloneConfigSchema);
export type BackendConfig = typeof volumeConfigSchema.infer; export type BackendConfig = typeof volumeConfigSchema.infer;

View file

@ -1,10 +1,9 @@
import * as fs from "node:fs/promises"; import * as fs from "node:fs/promises";
import Docker from "dockerode";
import { logger } from "../utils/logger"; import { logger } from "../utils/logger";
export type SystemCapabilities = { export type SystemCapabilities = {
docker: boolean;
rclone: boolean; rclone: boolean;
sysAdmin: boolean;
}; };
let capabilitiesPromise: Promise<SystemCapabilities> | null = null; let capabilitiesPromise: Promise<SystemCapabilities> | null = null;
@ -28,44 +27,11 @@ export async function getCapabilities(): Promise<SystemCapabilities> {
*/ */
async function detectCapabilities(): Promise<SystemCapabilities> { async function detectCapabilities(): Promise<SystemCapabilities> {
return { return {
docker: await detectDocker(),
rclone: await detectRclone(), rclone: await detectRclone(),
sysAdmin: await detectSysAdmin(),
}; };
} }
export const parseDockerHost = (dockerHost?: string) => {
const match = dockerHost?.match(/^(ssh|http|https):\/\/([^:]+)(?::(\d+))?$/);
if (match) {
const protocol = match[1] as "ssh" | "http" | "https";
const host = match[2];
const port = match[3] ? parseInt(match[3], 10) : undefined;
return { protocol, host, port };
}
return {};
};
/**
* Checks if Docker is available by:
* 1. Checking if /var/run/docker.sock exists and is accessible
* 2. Attempting to ping the Docker daemon
*/
async function detectDocker(): Promise<boolean> {
try {
const docker = new Docker(parseDockerHost(process.env.DOCKER_HOST));
await docker.ping();
logger.info("Docker capability: enabled");
return true;
} catch (_) {
logger.warn(
"Docker capability: disabled. " +
"To enable: mount /var/run/docker.sock and /run/docker/plugins in docker-compose.yml",
);
return false;
}
}
/** /**
* Checks if rclone is available by: * Checks if rclone is available by:
* 1. Checking if /root/.config/rclone directory exists and is accessible * 1. Checking if /root/.config/rclone directory exists and is accessible
@ -74,6 +40,12 @@ async function detectRclone(): Promise<boolean> {
try { try {
await fs.access("/root/.config/rclone"); await fs.access("/root/.config/rclone");
// Make sure the folder is not empty
const files = await fs.readdir("/root/.config/rclone");
if (files.length === 0) {
throw new Error("rclone config directory is empty");
}
logger.info("rclone capability: enabled"); logger.info("rclone capability: enabled");
return true; return true;
} catch (_) { } catch (_) {
@ -81,3 +53,38 @@ async function detectRclone(): Promise<boolean> {
return false; return false;
} }
} }
async function detectSysAdmin(): Promise<boolean> {
try {
const procStatus = await fs.readFile("/proc/self/status", "utf-8");
const capEffLine = procStatus.split("\n").find((line) => line.startsWith("CapEff:"));
if (!capEffLine) {
logger.warn("sysAdmin capability: disabled. Could not read CapEff from /proc/self/status");
return false;
}
// Extract the hex value (e.g., "00000000a80425fb")
const capEffHex = capEffLine.split(/\s+/)[1];
if (!capEffHex) {
logger.warn("sysAdmin capability: disabled. Could not parse CapEff value");
return false;
}
// Check if bit 21 (CAP_SYS_ADMIN) is set
const capValue = parseInt(capEffHex, 16) & (1 << 21);
if (capValue !== 0) {
logger.info("sysAdmin capability: enabled (CAP_SYS_ADMIN detected)");
return true;
}
logger.warn("sysAdmin capability: disabled. " + "To enable: add 'cap_add: SYS_ADMIN' in docker-compose.yml");
return false;
} catch (_error) {
logger.warn("sysAdmin capability: disabled. " + "To enable: add 'cap_add: SYS_ADMIN' in docker-compose.yml");
return false;
}
}

View file

@ -3,9 +3,11 @@ import "dotenv/config";
const envSchema = type({ const envSchema = type({
NODE_ENV: type.enumerated("development", "production", "test").default("production"), NODE_ENV: type.enumerated("development", "production", "test").default("production"),
SERVER_IP: 'string = "localhost"',
}).pipe((s) => ({ }).pipe((s) => ({
__prod__: s.NODE_ENV === "production", __prod__: s.NODE_ENV === "production",
environment: s.NODE_ENV, environment: s.NODE_ENV,
serverIp: s.SERVER_IP,
})); }));
const parseConfig = (env: unknown) => { const parseConfig = (env: unknown) => {

View file

@ -3,6 +3,5 @@ export const VOLUME_MOUNT_BASE = "/var/lib/zerobyte/volumes";
export const REPOSITORY_BASE = "/var/lib/zerobyte/repositories"; export const REPOSITORY_BASE = "/var/lib/zerobyte/repositories";
export const DATABASE_URL = "/var/lib/zerobyte/data/ironmount.db"; export const DATABASE_URL = "/var/lib/zerobyte/data/ironmount.db";
export const RESTIC_PASS_FILE = "/var/lib/zerobyte/data/restic.pass"; export const RESTIC_PASS_FILE = "/var/lib/zerobyte/data/restic.pass";
export const SOCKET_PATH = "/run/docker/plugins/zerobyte.sock";
export const REQUIRED_MIGRATIONS = ["v0.14.0"]; export const REQUIRED_MIGRATIONS = ["v0.14.0"];

View file

@ -92,6 +92,7 @@ export const backupSchedulesTable = sqliteTable("backup_schedules_table", {
lastBackupStatus: text("last_backup_status").$type<"success" | "error" | "in_progress" | "warning">(), lastBackupStatus: text("last_backup_status").$type<"success" | "error" | "in_progress" | "warning">(),
lastBackupError: text("last_backup_error"), lastBackupError: text("last_backup_error"),
nextBackupAt: int("next_backup_at", { mode: "number" }), nextBackupAt: int("next_backup_at", { mode: "number" }),
sortOrder: int("sort_order", { mode: "number" }).notNull().default(0),
createdAt: int("created_at", { mode: "number" }).notNull().default(sql`(unixepoch() * 1000)`), createdAt: int("created_at", { mode: "number" }).notNull().default(sql`(unixepoch() * 1000)`),
updatedAt: int("updated_at", { mode: "number" }).notNull().default(sql`(unixepoch() * 1000)`), updatedAt: int("updated_at", { mode: "number" }).notNull().default(sql`(unixepoch() * 1000)`),
}); });
@ -141,6 +142,7 @@ export const backupScheduleNotificationsTable = sqliteTable(
.references(() => notificationDestinationsTable.id, { onDelete: "cascade" }), .references(() => notificationDestinationsTable.id, { onDelete: "cascade" }),
notifyOnStart: int("notify_on_start", { mode: "boolean" }).notNull().default(false), notifyOnStart: int("notify_on_start", { mode: "boolean" }).notNull().default(false),
notifyOnSuccess: int("notify_on_success", { mode: "boolean" }).notNull().default(false), notifyOnSuccess: int("notify_on_success", { mode: "boolean" }).notNull().default(false),
notifyOnWarning: int("notify_on_warning", { mode: "boolean" }).notNull().default(true),
notifyOnFailure: int("notify_on_failure", { mode: "boolean" }).notNull().default(true), notifyOnFailure: int("notify_on_failure", { mode: "boolean" }).notNull().default(true),
createdAt: int("created_at", { mode: "number" }).notNull().default(sql`(unixepoch() * 1000)`), createdAt: int("created_at", { mode: "number" }).notNull().default(sql`(unixepoch() * 1000)`),
}, },

View file

@ -1,14 +1,11 @@
import { createHonoServer } from "react-router-hono-server/bun"; import { createHonoServer } from "react-router-hono-server/bun";
import * as fs from "node:fs/promises";
import { Scalar } from "@scalar/hono-api-reference"; import { Scalar } from "@scalar/hono-api-reference";
import { Hono } from "hono"; import { Hono } from "hono";
import { logger as honoLogger } from "hono/logger"; import { logger as honoLogger } from "hono/logger";
import { openAPIRouteHandler } from "hono-openapi"; import { openAPIRouteHandler } from "hono-openapi";
import { getCapabilities } from "./core/capabilities";
import { runDbMigrations } from "./db/db"; import { runDbMigrations } from "./db/db";
import { authController } from "./modules/auth/auth.controller"; import { authController } from "./modules/auth/auth.controller";
import { requireAuth } from "./modules/auth/auth.middleware"; import { requireAuth } from "./modules/auth/auth.middleware";
import { driverController } from "./modules/driver/driver.controller";
import { startup } from "./modules/lifecycle/startup"; import { startup } from "./modules/lifecycle/startup";
import { migrateToShortIds } from "./modules/lifecycle/migration"; import { migrateToShortIds } from "./modules/lifecycle/migration";
import { repositoriesController } from "./modules/repositories/repositories.controller"; import { repositoriesController } from "./modules/repositories/repositories.controller";
@ -20,8 +17,9 @@ import { notificationsController } from "./modules/notifications/notifications.c
import { handleServiceError } from "./utils/errors"; import { handleServiceError } from "./utils/errors";
import { logger } from "./utils/logger"; import { logger } from "./utils/logger";
import { shutdown } from "./modules/lifecycle/shutdown"; import { shutdown } from "./modules/lifecycle/shutdown";
import { REQUIRED_MIGRATIONS, SOCKET_PATH } from "./core/constants"; import { REQUIRED_MIGRATIONS } from "./core/constants";
import { validateRequiredMigrations } from "./modules/lifecycle/checkpoint"; import { validateRequiredMigrations } from "./modules/lifecycle/checkpoint";
import { config } from "./core/config";
export const generalDescriptor = (app: Hono) => export const generalDescriptor = (app: Hono) =>
openAPIRouteHandler(app, { openAPIRouteHandler(app, {
@ -31,7 +29,7 @@ export const generalDescriptor = (app: Hono) =>
version: "1.0.0", version: "1.0.0",
description: "API for managing volumes", description: "API for managing volumes",
}, },
servers: [{ url: "http://192.168.2.42:4096", description: "Development Server" }], servers: [{ url: `http://${config.serverIp}:4096`, description: "Development Server" }],
}, },
}); });
@ -41,17 +39,22 @@ export const scalarDescriptor = Scalar({
url: "/api/v1/openapi.json", url: "/api/v1/openapi.json",
}); });
const driver = new Hono().use(honoLogger()).route("/", driverController);
const app = new Hono() const app = new Hono()
.use(honoLogger()) .use(honoLogger())
.get("healthcheck", (c) => c.json({ status: "ok" })) .get("healthcheck", (c) => c.json({ status: "ok" }))
.route("/api/v1/auth", authController.basePath("/api/v1")) .route("/api/v1/auth", authController.basePath("/api/v1"))
.route("/api/v1/volumes", volumeController.use(requireAuth)) .use("/api/v1/volumes/*", requireAuth)
.route("/api/v1/repositories", repositoriesController.use(requireAuth)) .use("/api/v1/repositories/*", requireAuth)
.route("/api/v1/backups", backupScheduleController.use(requireAuth)) .use("/api/v1/backups/*", requireAuth)
.route("/api/v1/notifications", notificationsController.use(requireAuth)) .use("/api/v1/notifications/*", requireAuth)
.route("/api/v1/system", systemController.use(requireAuth)) .use("/api/v1/system/*", requireAuth)
.route("/api/v1/events", eventsController.use(requireAuth)); .use("/api/v1/events/*", requireAuth)
.route("/api/v1/volumes", volumeController)
.route("/api/v1/repositories", repositoriesController)
.route("/api/v1/backups", backupScheduleController)
.route("/api/v1/notifications", notificationsController)
.route("/api/v1/system", systemController)
.route("/api/v1/events", eventsController);
app.get("/api/v1/openapi.json", generalDescriptor(app)); app.get("/api/v1/openapi.json", generalDescriptor(app));
app.get("/api/v1/docs", scalarDescriptor); app.get("/api/v1/docs", scalarDescriptor);
@ -73,23 +76,6 @@ runDbMigrations();
await migrateToShortIds(); await migrateToShortIds();
await validateRequiredMigrations(REQUIRED_MIGRATIONS); await validateRequiredMigrations(REQUIRED_MIGRATIONS);
const { docker } = await getCapabilities();
if (docker) {
try {
await fs.mkdir("/run/docker/plugins", { recursive: true });
Bun.serve({
unix: SOCKET_PATH,
fetch: driver.fetch,
});
logger.info(`Docker volume plugin server running at ${SOCKET_PATH}`);
} catch (error) {
logger.error(`Failed to start Docker volume plugin server: ${error}`);
}
}
startup(); startup();
logger.info(`Server is running at http://localhost:4096`); logger.info(`Server is running at http://localhost:4096`);

View file

@ -17,11 +17,9 @@ export class BackupExecutionJob extends Job {
logger.info(`Found ${scheduleIds.length} backup schedule(s) to execute`); logger.info(`Found ${scheduleIds.length} backup schedule(s) to execute`);
for (const scheduleId of scheduleIds) { for (const scheduleId of scheduleIds) {
try { backupsService.executeBackup(scheduleId).catch((error) => {
await backupsService.executeBackup(scheduleId);
} catch (error) {
logger.error(`Failed to execute backup for schedule ${scheduleId}: ${toMessage(error)}`); logger.error(`Failed to execute backup for schedule ${scheduleId}: ${toMessage(error)}`);
} });
} }
return { done: true, timestamp: new Date(), executed: scheduleIds.length }; return { done: true, timestamp: new Date(), executed: scheduleIds.length };

View file

@ -3,6 +3,7 @@ import type { Volume } from "../../db/schema";
import { getVolumePath } from "../volumes/helpers"; import { getVolumePath } from "../volumes/helpers";
import { makeDirectoryBackend } from "./directory/directory-backend"; import { makeDirectoryBackend } from "./directory/directory-backend";
import { makeNfsBackend } from "./nfs/nfs-backend"; import { makeNfsBackend } from "./nfs/nfs-backend";
import { makeRcloneBackend } from "./rclone/rclone-backend";
import { makeSmbBackend } from "./smb/smb-backend"; import { makeSmbBackend } from "./smb/smb-backend";
import { makeWebdavBackend } from "./webdav/webdav-backend"; import { makeWebdavBackend } from "./webdav/webdav-backend";
@ -33,5 +34,8 @@ export const createVolumeBackend = (volume: Volume): VolumeBackend => {
case "webdav": { case "webdav": {
return makeWebdavBackend(volume.config, path); return makeWebdavBackend(volume.config, path);
} }
case "rclone": {
return makeRcloneBackend(volume.config, path);
}
} }
}; };

View file

@ -0,0 +1,128 @@
import * as fs from "node:fs/promises";
import * as os from "node:os";
import { $ } from "bun";
import { OPERATION_TIMEOUT } from "../../../core/constants";
import { toMessage } from "../../../utils/errors";
import { logger } from "../../../utils/logger";
import { getMountForPath } from "../../../utils/mountinfo";
import { withTimeout } from "../../../utils/timeout";
import type { VolumeBackend } from "../backend";
import { executeUnmount } from "../utils/backend-utils";
import { BACKEND_STATUS, type BackendConfig } from "~/schemas/volumes";
const mount = async (config: BackendConfig, path: string) => {
logger.debug(`Mounting rclone volume ${path}...`);
if (config.backend !== "rclone") {
logger.error("Provided config is not for rclone backend");
return { status: BACKEND_STATUS.error, error: "Provided config is not for rclone backend" };
}
if (os.platform() !== "linux") {
logger.error("Rclone mounting is only supported on Linux hosts.");
return { status: BACKEND_STATUS.error, error: "Rclone mounting is only supported on Linux hosts." };
}
const { status } = await checkHealth(path);
if (status === "mounted") {
return { status: BACKEND_STATUS.mounted };
}
logger.debug(`Trying to unmount any existing mounts at ${path} before mounting...`);
await unmount(path);
const run = async () => {
await fs.mkdir(path, { recursive: true });
const remotePath = `${config.remote}:${config.path}`;
const args = ["mount", remotePath, path, "--daemon"];
if (config.readOnly) {
args.push("--read-only");
}
args.push("--vfs-cache-mode", "writes");
args.push("--allow-non-empty");
args.push("--allow-other");
logger.debug(`Mounting rclone volume ${path}...`);
logger.info(`Executing rclone: rclone ${args.join(" ")}`);
const result = await $`rclone ${args}`.nothrow();
if (result.exitCode !== 0) {
const errorMsg = result.stderr.toString() || result.stdout.toString() || "Unknown error";
throw new Error(`Failed to mount rclone volume: ${errorMsg}`);
}
logger.info(`Rclone volume at ${path} mounted successfully.`);
return { status: BACKEND_STATUS.mounted };
};
try {
return await withTimeout(run(), OPERATION_TIMEOUT, "Rclone mount");
} catch (error) {
const errorMsg = toMessage(error);
logger.error("Error mounting rclone volume", { error: errorMsg });
return { status: BACKEND_STATUS.error, error: errorMsg };
}
};
const unmount = async (path: string) => {
if (os.platform() !== "linux") {
logger.error("Rclone unmounting is only supported on Linux hosts.");
return { status: BACKEND_STATUS.error, error: "Rclone unmounting is only supported on Linux hosts." };
}
const run = async () => {
try {
await fs.access(path);
} catch (e) {
logger.warn(`Path ${path} does not exist. Skipping unmount.`, e);
return { status: BACKEND_STATUS.unmounted };
}
await executeUnmount(path);
await fs.rmdir(path);
logger.info(`Rclone volume at ${path} unmounted successfully.`);
return { status: BACKEND_STATUS.unmounted };
};
try {
return await withTimeout(run(), OPERATION_TIMEOUT, "Rclone unmount");
} catch (error) {
logger.error("Error unmounting rclone volume", { path, error: toMessage(error) });
return { status: BACKEND_STATUS.error, error: toMessage(error) };
}
};
const checkHealth = async (path: string) => {
const run = async () => {
logger.debug(`Checking health of rclone volume at ${path}...`);
await fs.access(path);
const mount = await getMountForPath(path);
if (!mount || mount.fstype !== "fuse.rclone") {
throw new Error(`Path ${path} is not mounted as rclone.`);
}
logger.debug(`Rclone volume at ${path} is healthy and mounted.`);
return { status: BACKEND_STATUS.mounted };
};
try {
return await withTimeout(run(), OPERATION_TIMEOUT, "Rclone health check");
} catch (error) {
logger.error("Rclone volume health check failed:", toMessage(error));
return { status: BACKEND_STATUS.error, error: toMessage(error) };
}
};
export const makeRcloneBackend = (config: BackendConfig, path: string): VolumeBackend => ({
mount: () => mount(config, path),
unmount: () => unmount(path),
checkHealth: () => checkHealth(path),
});

View file

@ -1,6 +1,7 @@
import * as fs from "node:fs/promises"; import * as fs from "node:fs/promises";
import * as os from "node:os"; import * as os from "node:os";
import { OPERATION_TIMEOUT } from "../../../core/constants"; import { OPERATION_TIMEOUT } from "../../../core/constants";
import { cryptoUtils } from "../../../utils/crypto";
import { toMessage } from "../../../utils/errors"; import { toMessage } from "../../../utils/errors";
import { logger } from "../../../utils/logger"; import { logger } from "../../../utils/logger";
import { getMountForPath } from "../../../utils/mountinfo"; import { getMountForPath } from "../../../utils/mountinfo";
@ -33,10 +34,12 @@ const mount = async (config: BackendConfig, path: string) => {
const run = async () => { const run = async () => {
await fs.mkdir(path, { recursive: true }); await fs.mkdir(path, { recursive: true });
const password = await cryptoUtils.resolveSecret(config.password);
const source = `//${config.server}/${config.share}`; const source = `//${config.server}/${config.share}`;
const options = [ const options = [
`user=${config.username}`, `user=${config.username}`,
`pass=${config.password}`, `pass=${password}`,
`vers=${config.vers}`, `vers=${config.vers}`,
`port=${config.port}`, `port=${config.port}`,
"uid=1000", "uid=1000",

View file

@ -3,6 +3,7 @@ import * as fs from "node:fs/promises";
import * as os from "node:os"; import * as os from "node:os";
import { promisify } from "node:util"; import { promisify } from "node:util";
import { OPERATION_TIMEOUT } from "../../../core/constants"; import { OPERATION_TIMEOUT } from "../../../core/constants";
import { cryptoUtils } from "../../../utils/crypto";
import { toMessage } from "../../../utils/errors"; import { toMessage } from "../../../utils/errors";
import { logger } from "../../../utils/logger"; import { logger } from "../../../utils/logger";
import { getMountForPath } from "../../../utils/mountinfo"; import { getMountForPath } from "../../../utils/mountinfo";
@ -49,8 +50,9 @@ const mount = async (config: BackendConfig, path: string) => {
: ["uid=1000", "gid=1000", "file_mode=0664", "dir_mode=0775"]; : ["uid=1000", "gid=1000", "file_mode=0664", "dir_mode=0775"];
if (config.username && config.password) { if (config.username && config.password) {
const password = await cryptoUtils.resolveSecret(config.password);
const secretsFile = "/etc/davfs2/secrets"; const secretsFile = "/etc/davfs2/secrets";
const secretsContent = `${source} ${config.username} ${config.password}\n`; const secretsContent = `${source} ${config.username} ${password}\n`;
await fs.appendFile(secretsFile, secretsContent, { mode: 0o600 }); await fs.appendFile(secretsFile, secretsContent, { mode: 0o600 });
} }

View file

@ -16,6 +16,8 @@ import {
updateScheduleMirrorsDto, updateScheduleMirrorsDto,
updateScheduleMirrorsBody, updateScheduleMirrorsBody,
getMirrorCompatibilityDto, getMirrorCompatibilityDto,
reorderBackupSchedulesDto,
reorderBackupSchedulesBody,
type CreateBackupScheduleDto, type CreateBackupScheduleDto,
type DeleteBackupScheduleDto, type DeleteBackupScheduleDto,
type GetBackupScheduleDto, type GetBackupScheduleDto,
@ -28,6 +30,7 @@ import {
type GetScheduleMirrorsDto, type GetScheduleMirrorsDto,
type UpdateScheduleMirrorsDto, type UpdateScheduleMirrorsDto,
type GetMirrorCompatibilityDto, type GetMirrorCompatibilityDto,
type ReorderBackupSchedulesDto,
} from "./backups.dto"; } from "./backups.dto";
import { backupsService } from "./backups.service"; import { backupsService } from "./backups.service";
import { import {
@ -139,4 +142,11 @@ export const backupScheduleController = new Hono()
const compatibility = await backupsService.getMirrorCompatibility(scheduleId); const compatibility = await backupsService.getMirrorCompatibility(scheduleId);
return c.json<GetMirrorCompatibilityDto>(compatibility, 200); return c.json<GetMirrorCompatibilityDto>(compatibility, 200);
})
.post("/reorder", reorderBackupSchedulesDto, validator("json", reorderBackupSchedulesBody), async (c) => {
const body = c.req.valid("json");
await backupsService.reorderSchedules(body.scheduleIds);
return c.json<ReorderBackupSchedulesDto>({ success: true }, 200);
}); });

View file

@ -367,3 +367,34 @@ export const getMirrorCompatibilityDto = describeRoute({
}, },
}, },
}); });
/**
* Reorder backup schedules
*/
export const reorderBackupSchedulesBody = type({
scheduleIds: "number[]",
});
export type ReorderBackupSchedulesBody = typeof reorderBackupSchedulesBody.infer;
export const reorderBackupSchedulesResponse = type({
success: "boolean",
});
export type ReorderBackupSchedulesDto = typeof reorderBackupSchedulesResponse.infer;
export const reorderBackupSchedulesDto = describeRoute({
description: "Reorder backup schedules by providing an array of schedule IDs in the desired order",
operationId: "reorderBackupSchedules",
tags: ["Backups"],
responses: {
200: {
description: "Backup schedules reordered successfully",
content: {
"application/json": {
schema: resolver(reorderBackupSchedulesResponse),
},
},
},
},
});

View file

@ -1,4 +1,4 @@
import { and, eq, ne } from "drizzle-orm"; import { and, asc, eq, ne } from "drizzle-orm";
import cron from "node-cron"; import cron from "node-cron";
import { CronExpressionParser } from "cron-parser"; import { CronExpressionParser } from "cron-parser";
import { NotFoundError, BadRequestError, ConflictError } from "http-errors-enhanced"; import { NotFoundError, BadRequestError, ConflictError } from "http-errors-enhanced";
@ -38,6 +38,7 @@ const listSchedules = async () => {
volume: true, volume: true,
repository: true, repository: true,
}, },
orderBy: [asc(backupSchedulesTable.sortOrder), asc(backupSchedulesTable.id)],
}); });
return schedules; return schedules;
}; };
@ -300,10 +301,12 @@ const executeBackup = async (scheduleId: number, manual = false) => {
} }
if (schedule.retentionPolicy) { if (schedule.retentionPolicy) {
void runForget(schedule.id); void runForget(schedule.id).catch((error) => {
logger.error(`Failed to run retention policy for schedule ${scheduleId}: ${toMessage(error)}`);
});
} }
copyToMirrors(scheduleId, repository, schedule.retentionPolicy).catch((error) => { void copyToMirrors(scheduleId, repository, schedule.retentionPolicy).catch((error) => {
logger.error(`Background mirror copy failed for schedule ${scheduleId}: ${toMessage(error)}`); logger.error(`Background mirror copy failed for schedule ${scheduleId}: ${toMessage(error)}`);
}); });
@ -381,7 +384,7 @@ const executeBackup = async (scheduleId: number, manual = false) => {
const getSchedulesToExecute = async () => { const getSchedulesToExecute = async () => {
const now = Date.now(); const now = Date.now();
const schedules = await db.query.backupSchedulesTable.findMany({ const schedules = await db.query.backupSchedulesTable.findMany({
where: eq(backupSchedulesTable.enabled, true), where: and(eq(backupSchedulesTable.enabled, true), ne(backupSchedulesTable.lastBackupStatus, "in_progress")),
}); });
const schedulesToRun: number[] = []; const schedulesToRun: number[] = [];
@ -432,7 +435,7 @@ const stopBackup = async (scheduleId: number) => {
abortController.abort(); abortController.abort();
}; };
const runForget = async (scheduleId: number) => { const runForget = async (scheduleId: number, repositoryId?: string) => {
const schedule = await db.query.backupSchedulesTable.findFirst({ const schedule = await db.query.backupSchedulesTable.findFirst({
where: eq(backupSchedulesTable.id, scheduleId), where: eq(backupSchedulesTable.id, scheduleId),
}); });
@ -446,7 +449,7 @@ const runForget = async (scheduleId: number) => {
} }
const repository = await db.query.repositoriesTable.findFirst({ const repository = await db.query.repositoriesTable.findFirst({
where: eq(repositoriesTable.id, schedule.repositoryId), where: eq(repositoriesTable.id, repositoryId ?? schedule.repositoryId),
}); });
if (!repository) { if (!repository) {
@ -454,7 +457,7 @@ const runForget = async (scheduleId: number) => {
} }
logger.info(`running retention policy (forget) for schedule ${scheduleId}`); logger.info(`running retention policy (forget) for schedule ${scheduleId}`);
const releaseLock = await repoMutex.acquireExclusive(repository.id, `forget:manual:${scheduleId}`); const releaseLock = await repoMutex.acquireExclusive(repository.id, `forget:${scheduleId}`);
try { try {
await restic.forget(repository.config, schedule.retentionPolicy, { tag: schedule.id.toString() }); await restic.forget(repository.config, schedule.retentionPolicy, { tag: schedule.id.toString() });
} finally { } finally {
@ -513,15 +516,32 @@ const updateMirrors = async (scheduleId: number, data: UpdateScheduleMirrorsBody
} }
} }
const existingMirrors = await db.query.backupScheduleMirrorsTable.findMany({
where: eq(backupScheduleMirrorsTable.scheduleId, scheduleId),
});
const existingMirrorsMap = new Map(
existingMirrors.map((m) => [
m.repositoryId,
{ lastCopyAt: m.lastCopyAt, lastCopyStatus: m.lastCopyStatus, lastCopyError: m.lastCopyError },
]),
);
await db.delete(backupScheduleMirrorsTable).where(eq(backupScheduleMirrorsTable.scheduleId, scheduleId)); await db.delete(backupScheduleMirrorsTable).where(eq(backupScheduleMirrorsTable.scheduleId, scheduleId));
if (data.mirrors.length > 0) { if (data.mirrors.length > 0) {
await db.insert(backupScheduleMirrorsTable).values( await db.insert(backupScheduleMirrorsTable).values(
data.mirrors.map((mirror) => ({ data.mirrors.map((mirror) => {
scheduleId, const existing = existingMirrorsMap.get(mirror.repositoryId);
repositoryId: mirror.repositoryId, return {
enabled: mirror.enabled, scheduleId,
})), repositoryId: mirror.repositoryId,
enabled: mirror.enabled,
lastCopyAt: existing?.lastCopyAt ?? null,
lastCopyStatus: existing?.lastCopyStatus ?? null,
lastCopyError: existing?.lastCopyError ?? null,
};
}),
); );
} }
@ -569,14 +589,11 @@ const copyToMirrors = async (
} }
if (retentionPolicy) { if (retentionPolicy) {
const releaseForget = await repoMutex.acquireExclusive(mirror.repository.id, `forget:mirror:${scheduleId}`); void runForget(scheduleId, mirror.repository.id).catch((error) => {
logger.error(
try { `Failed to run retention policy for mirror repository ${mirror.repository.name}: ${toMessage(error)}`,
logger.info(`[Background] Applying retention policy to mirror repository: ${mirror.repository.name}`); );
await restic.forget(mirror.repository.config, retentionPolicy, { tag: scheduleId.toString() }); });
} finally {
releaseForget();
}
} }
await db await db
@ -632,6 +649,36 @@ const getMirrorCompatibility = async (scheduleId: number) => {
return compatibility; return compatibility;
}; };
const reorderSchedules = async (scheduleIds: number[]) => {
const uniqueIds = new Set(scheduleIds);
if (uniqueIds.size !== scheduleIds.length) {
throw new BadRequestError("Duplicate schedule IDs in reorder request");
}
const existingSchedules = await db.query.backupSchedulesTable.findMany({
columns: { id: true },
});
const existingIds = new Set(existingSchedules.map((s) => s.id));
for (const id of scheduleIds) {
if (!existingIds.has(id)) {
throw new NotFoundError(`Backup schedule with ID ${id} not found`);
}
}
await db.transaction(async (tx) => {
const now = Date.now();
await Promise.all(
scheduleIds.map((scheduleId, index) =>
tx
.update(backupSchedulesTable)
.set({ sortOrder: index, updatedAt: now })
.where(eq(backupSchedulesTable.id, scheduleId)),
),
);
});
};
export const backupsService = { export const backupsService = {
listSchedules, listSchedules,
getSchedule, getSchedule,
@ -646,4 +693,5 @@ export const backupsService = {
getMirrors, getMirrors,
updateMirrors, updateMirrors,
getMirrorCompatibility, getMirrorCompatibility,
reorderSchedules,
}; };

View file

@ -1,114 +0,0 @@
import { Hono } from "hono";
import { volumeService } from "../volumes/volume.service";
import { getVolumePath } from "../volumes/helpers";
import { eq } from "drizzle-orm";
import { db } from "../../db/db";
import { volumesTable } from "../../db/schema";
export const driverController = new Hono()
.post("/VolumeDriver.Capabilities", (c) => {
return c.json({
Capabilities: {
Scope: "global",
},
});
})
.post("/Plugin.Activate", (c) => {
return c.json({
Implements: ["VolumeDriver"],
});
})
.post("/VolumeDriver.Create", (_) => {
throw new Error("Volume creation is not supported via the driver");
})
.post("/VolumeDriver.Remove", (c) => {
return c.json({
Err: "",
});
})
.post("/VolumeDriver.Mount", async (c) => {
const body = await c.req.json();
if (!body.Name) {
return c.json({ Err: "Volume name is required" }, 400);
}
const shortId = body.Name.replace(/^zb-/, "");
const volume = await db.query.volumesTable.findFirst({
where: eq(volumesTable.shortId, shortId),
});
if (!volume) {
return c.json({ Err: `Volume with shortId ${shortId} not found` }, 404);
}
return c.json({
Mountpoint: getVolumePath(volume),
});
})
.post("/VolumeDriver.Unmount", (c) => {
return c.json({
Err: "",
});
})
.post("/VolumeDriver.Path", async (c) => {
const body = await c.req.json();
if (!body.Name) {
return c.json({ Err: "Volume name is required" }, 400);
}
const shortId = body.Name.replace(/^zb-/, "");
const volume = await db.query.volumesTable.findFirst({
where: eq(volumesTable.shortId, shortId),
});
if (!volume) {
return c.json({ Err: `Volume with shortId ${shortId} not found` }, 404);
}
return c.json({
Mountpoint: getVolumePath(volume),
});
})
.post("/VolumeDriver.Get", async (c) => {
const body = await c.req.json();
if (!body.Name) {
return c.json({ Err: "Volume name is required" }, 400);
}
const shortId = body.Name.replace(/^zb-/, "");
const volume = await db.query.volumesTable.findFirst({
where: eq(volumesTable.shortId, shortId),
});
if (!volume) {
return c.json({ Err: `Volume with shortId ${shortId} not found` }, 404);
}
return c.json({
Volume: {
Name: `zb-${volume.shortId}`,
Mountpoint: getVolumePath(volume),
Status: {},
},
Err: "",
});
})
.post("/VolumeDriver.List", async (c) => {
const volumes = await volumeService.listVolumes();
const res = volumes.map((volume) => ({
Name: `zb-${volume.shortId}`,
Mountpoint: getVolumePath(volume),
Status: {},
}));
return c.json({
Volumes: res,
});
});

View file

@ -3,18 +3,11 @@ import { eq, or } from "drizzle-orm";
import { db } from "../../db/db"; import { db } from "../../db/db";
import { volumesTable } from "../../db/schema"; import { volumesTable } from "../../db/schema";
import { logger } from "../../utils/logger"; import { logger } from "../../utils/logger";
import { SOCKET_PATH } from "../../core/constants";
import { createVolumeBackend } from "../backends/backend"; import { createVolumeBackend } from "../backends/backend";
export const shutdown = async () => { export const shutdown = async () => {
await Scheduler.stop(); await Scheduler.stop();
await Bun.file(SOCKET_PATH)
.delete()
.catch(() => {
// Ignore errors if the socket file does not exist
});
const volumes = await db.query.volumesTable.findMany({ const volumes = await db.query.volumesTable.findMany({
where: or(eq(volumesTable.status, "mounted")), where: or(eq(volumesTable.status, "mounted")),
}); });

View file

@ -1,10 +1,13 @@
import type { NotificationConfig } from "~/schemas/notifications"; import type { NotificationConfig } from "~/schemas/notifications";
export function buildEmailShoutrrrUrl(config: Extract<NotificationConfig, { type: "email" }>): string { export function buildEmailShoutrrrUrl(config: Extract<NotificationConfig, { type: "email" }>): string {
const auth = `${encodeURIComponent(config.username)}:${encodeURIComponent(config.password)}`; const auth =
config.username && config.password
? `${encodeURIComponent(config.username)}:${encodeURIComponent(config.password)}@`
: "";
const host = `${config.smtpHost}:${config.smtpPort}`; const host = `${config.smtpHost}:${config.smtpPort}`;
const toRecipients = config.to.map((email) => encodeURIComponent(email)).join(","); const toRecipients = config.to.map((email) => encodeURIComponent(email)).join(",");
const useStartTLS = config.useTLS ? "yes" : "no"; const useStartTLS = config.useTLS ? "yes" : "no";
return `smtp://${auth}@${host}/?from=${encodeURIComponent(config.from)}&to=${toRecipients}&starttls=${useStartTLS}`; return `smtp://${auth}${host}/?from=${encodeURIComponent(config.from)}&to=${toRecipients}&starttls=${useStartTLS}`;
} }

View file

@ -190,6 +190,7 @@ export const scheduleNotificationAssignmentSchema = type({
destinationId: "number", destinationId: "number",
notifyOnStart: "boolean", notifyOnStart: "boolean",
notifyOnSuccess: "boolean", notifyOnSuccess: "boolean",
notifyOnWarning: "boolean",
notifyOnFailure: "boolean", notifyOnFailure: "boolean",
createdAt: "number", createdAt: "number",
destination: notificationDestinationSchema, destination: notificationDestinationSchema,
@ -227,6 +228,7 @@ export const updateScheduleNotificationsBody = type({
destinationId: "number", destinationId: "number",
notifyOnStart: "boolean", notifyOnStart: "boolean",
notifyOnSuccess: "boolean", notifyOnSuccess: "boolean",
notifyOnWarning: "boolean",
notifyOnFailure: "boolean", notifyOnFailure: "boolean",
}).array(), }).array(),
}); });

View file

@ -38,42 +38,42 @@ async function encryptSensitiveFields(config: NotificationConfig): Promise<Notif
case "email": case "email":
return { return {
...config, ...config,
password: await cryptoUtils.encrypt(config.password), password: config.password ? await cryptoUtils.sealSecret(config.password) : undefined,
}; };
case "slack": case "slack":
return { return {
...config, ...config,
webhookUrl: await cryptoUtils.encrypt(config.webhookUrl), webhookUrl: await cryptoUtils.sealSecret(config.webhookUrl),
}; };
case "discord": case "discord":
return { return {
...config, ...config,
webhookUrl: await cryptoUtils.encrypt(config.webhookUrl), webhookUrl: await cryptoUtils.sealSecret(config.webhookUrl),
}; };
case "gotify": case "gotify":
return { return {
...config, ...config,
token: await cryptoUtils.encrypt(config.token), token: await cryptoUtils.sealSecret(config.token),
}; };
case "ntfy": case "ntfy":
return { return {
...config, ...config,
password: config.password ? await cryptoUtils.encrypt(config.password) : undefined, password: config.password ? await cryptoUtils.sealSecret(config.password) : undefined,
}; };
case "pushover": case "pushover":
return { return {
...config, ...config,
apiToken: await cryptoUtils.encrypt(config.apiToken), apiToken: await cryptoUtils.sealSecret(config.apiToken),
}; };
case "telegram": case "telegram":
return { return {
...config, ...config,
botToken: await cryptoUtils.encrypt(config.botToken), botToken: await cryptoUtils.sealSecret(config.botToken),
}; };
case "custom": case "custom":
return { return {
...config, ...config,
shoutrrrUrl: await cryptoUtils.encrypt(config.shoutrrrUrl), shoutrrrUrl: await cryptoUtils.sealSecret(config.shoutrrrUrl),
}; };
default: default:
return config; return config;
@ -85,42 +85,42 @@ async function decryptSensitiveFields(config: NotificationConfig): Promise<Notif
case "email": case "email":
return { return {
...config, ...config,
password: await cryptoUtils.decrypt(config.password), password: config.password ? await cryptoUtils.resolveSecret(config.password) : undefined,
}; };
case "slack": case "slack":
return { return {
...config, ...config,
webhookUrl: await cryptoUtils.decrypt(config.webhookUrl), webhookUrl: await cryptoUtils.resolveSecret(config.webhookUrl),
}; };
case "discord": case "discord":
return { return {
...config, ...config,
webhookUrl: await cryptoUtils.decrypt(config.webhookUrl), webhookUrl: await cryptoUtils.resolveSecret(config.webhookUrl),
}; };
case "gotify": case "gotify":
return { return {
...config, ...config,
token: await cryptoUtils.decrypt(config.token), token: await cryptoUtils.resolveSecret(config.token),
}; };
case "ntfy": case "ntfy":
return { return {
...config, ...config,
password: config.password ? await cryptoUtils.decrypt(config.password) : undefined, password: config.password ? await cryptoUtils.resolveSecret(config.password) : undefined,
}; };
case "pushover": case "pushover":
return { return {
...config, ...config,
apiToken: await cryptoUtils.decrypt(config.apiToken), apiToken: await cryptoUtils.resolveSecret(config.apiToken),
}; };
case "telegram": case "telegram":
return { return {
...config, ...config,
botToken: await cryptoUtils.decrypt(config.botToken), botToken: await cryptoUtils.resolveSecret(config.botToken),
}; };
case "custom": case "custom":
return { return {
...config, ...config,
shoutrrrUrl: await cryptoUtils.decrypt(config.shoutrrrUrl), shoutrrrUrl: await cryptoUtils.resolveSecret(config.shoutrrrUrl),
}; };
default: default:
return config; return config;
@ -253,6 +253,7 @@ const updateScheduleNotifications = async (
destinationId: number; destinationId: number;
notifyOnStart: boolean; notifyOnStart: boolean;
notifyOnSuccess: boolean; notifyOnSuccess: boolean;
notifyOnWarning: boolean;
notifyOnFailure: boolean; notifyOnFailure: boolean;
}>, }>,
) => { ) => {
@ -300,8 +301,9 @@ const sendBackupNotification = async (
return assignment.notifyOnStart; return assignment.notifyOnStart;
case "success": case "success":
return assignment.notifyOnSuccess; return assignment.notifyOnSuccess;
case "failure":
case "warning": case "warning":
return assignment.notifyOnWarning;
case "failure":
return assignment.notifyOnFailure; return assignment.notifyOnFailure;
default: default:
return false; return false;

View file

@ -20,31 +20,31 @@ const encryptConfig = async (config: RepositoryConfig): Promise<RepositoryConfig
const encryptedConfig: Record<string, string | boolean | number> = { ...config }; const encryptedConfig: Record<string, string | boolean | number> = { ...config };
if (config.customPassword) { if (config.customPassword) {
encryptedConfig.customPassword = await cryptoUtils.encrypt(config.customPassword); encryptedConfig.customPassword = await cryptoUtils.sealSecret(config.customPassword);
} }
switch (config.backend) { switch (config.backend) {
case "s3": case "s3":
case "r2": case "r2":
encryptedConfig.accessKeyId = await cryptoUtils.encrypt(config.accessKeyId); encryptedConfig.accessKeyId = await cryptoUtils.sealSecret(config.accessKeyId);
encryptedConfig.secretAccessKey = await cryptoUtils.encrypt(config.secretAccessKey); encryptedConfig.secretAccessKey = await cryptoUtils.sealSecret(config.secretAccessKey);
break; break;
case "gcs": case "gcs":
encryptedConfig.credentialsJson = await cryptoUtils.encrypt(config.credentialsJson); encryptedConfig.credentialsJson = await cryptoUtils.sealSecret(config.credentialsJson);
break; break;
case "azure": case "azure":
encryptedConfig.accountKey = await cryptoUtils.encrypt(config.accountKey); encryptedConfig.accountKey = await cryptoUtils.sealSecret(config.accountKey);
break; break;
case "rest": case "rest":
if (config.username) { if (config.username) {
encryptedConfig.username = await cryptoUtils.encrypt(config.username); encryptedConfig.username = await cryptoUtils.sealSecret(config.username);
} }
if (config.password) { if (config.password) {
encryptedConfig.password = await cryptoUtils.encrypt(config.password); encryptedConfig.password = await cryptoUtils.sealSecret(config.password);
} }
break; break;
case "sftp": case "sftp":
encryptedConfig.privateKey = await cryptoUtils.encrypt(config.privateKey); encryptedConfig.privateKey = await cryptoUtils.sealSecret(config.privateKey);
break; break;
} }
@ -385,23 +385,31 @@ const doctorRepository = async (name: string) => {
error: recheckResult.error, error: recheckResult.error,
}); });
} }
} catch (error) {
steps.push({
step: "unexpected_error",
success: false,
output: null,
error: toMessage(error),
});
} finally { } finally {
releaseLock(); releaseLock();
} }
const allSuccessful = steps.every((s) => s.success); const doctorSucceeded = steps.every((step) => step.success);
const doctorError = steps.find((step) => step.error)?.error ?? null;
await db await db
.update(repositoriesTable) .update(repositoriesTable)
.set({ .set({
status: allSuccessful ? "healthy" : "error", status: doctorSucceeded ? "healthy" : "error",
lastChecked: Date.now(), lastChecked: Date.now(),
lastError: allSuccessful ? null : steps.find((s) => !s.success)?.error, lastError: doctorError,
}) })
.where(eq(repositoriesTable.id, repository.id)); .where(eq(repositoriesTable.id, repository.id));
return { return {
success: allSuccessful, success: doctorSucceeded,
steps, steps,
}; };
}; };

View file

@ -2,8 +2,8 @@ import { type } from "arktype";
import { describeRoute, resolver } from "hono-openapi"; import { describeRoute, resolver } from "hono-openapi";
export const capabilitiesSchema = type({ export const capabilitiesSchema = type({
docker: "boolean",
rclone: "boolean", rclone: "boolean",
sysAdmin: "boolean",
}); });
export const systemInfoResponse = type({ export const systemInfoResponse = type({

View file

@ -4,7 +4,6 @@ import {
createVolumeBody, createVolumeBody,
createVolumeDto, createVolumeDto,
deleteVolumeDto, deleteVolumeDto,
getContainersDto,
getVolumeDto, getVolumeDto,
healthCheckDto, healthCheckDto,
type ListVolumesDto, type ListVolumesDto,
@ -18,7 +17,6 @@ import {
updateVolumeDto, updateVolumeDto,
type CreateVolumeDto, type CreateVolumeDto,
type GetVolumeDto, type GetVolumeDto,
type ListContainersDto,
type UpdateVolumeDto, type UpdateVolumeDto,
type ListFilesDto, type ListFilesDto,
browseFilesystemDto, browseFilesystemDto,
@ -74,12 +72,6 @@ export const volumeController = new Hono()
return c.json<GetVolumeDto>(response, 200); return c.json<GetVolumeDto>(response, 200);
}) })
.get("/:name/containers", getContainersDto, async (c) => {
const { name } = c.req.param();
const { containers } = await volumeService.getContainersUsingVolume(name);
return c.json<ListContainersDto>(containers, 200);
})
.put("/:name", updateVolumeDto, validator("json", updateVolumeBody), async (c) => { .put("/:name", updateVolumeDto, validator("json", updateVolumeBody), async (c) => {
const { name } = c.req.param(); const { name } = c.req.param();
const body = c.req.valid("json"); const body = c.req.valid("json");

View file

@ -262,38 +262,6 @@ export const healthCheckDto = describeRoute({
}, },
}); });
/**
* Get containers using a volume
*/
const containerSchema = type({
id: "string",
name: "string",
state: "string",
image: "string",
});
export const listContainersResponse = containerSchema.array();
export type ListContainersDto = typeof listContainersResponse.infer;
export const getContainersDto = describeRoute({
description: "Get containers using a volume by name",
operationId: "getContainersUsingVolume",
tags: ["Volumes"],
responses: {
200: {
description: "List of containers using the volume",
content: {
"application/json": {
schema: resolver(listContainersResponse),
},
},
},
404: {
description: "Volume not found",
},
},
});
/** /**
* List files in a volume * List files in a volume
*/ */

View file

@ -1,13 +1,12 @@
import * as fs from "node:fs/promises"; import * as fs from "node:fs/promises";
import * as os from "node:os"; import * as os from "node:os";
import * as path from "node:path"; import * as path from "node:path";
import Docker from "dockerode";
import { and, eq, ne } from "drizzle-orm"; import { and, eq, ne } from "drizzle-orm";
import { ConflictError, InternalServerError, NotFoundError } from "http-errors-enhanced"; import { ConflictError, InternalServerError, NotFoundError } from "http-errors-enhanced";
import slugify from "slugify"; import slugify from "slugify";
import { getCapabilities, parseDockerHost } from "../../core/capabilities";
import { db } from "../../db/db"; import { db } from "../../db/db";
import { volumesTable } from "../../db/schema"; import { volumesTable } from "../../db/schema";
import { cryptoUtils } from "../../utils/crypto";
import { toMessage } from "../../utils/errors"; import { toMessage } from "../../utils/errors";
import { generateShortId } from "../../utils/id"; import { generateShortId } from "../../utils/id";
import { getStatFs, type StatFs } from "../../utils/mountinfo"; import { getStatFs, type StatFs } from "../../utils/mountinfo";
@ -19,6 +18,23 @@ import { logger } from "../../utils/logger";
import { serverEvents } from "../../core/events"; import { serverEvents } from "../../core/events";
import type { BackendConfig } from "~/schemas/volumes"; import type { BackendConfig } from "~/schemas/volumes";
async function encryptSensitiveFields(config: BackendConfig): Promise<BackendConfig> {
switch (config.backend) {
case "smb":
return {
...config,
password: await cryptoUtils.sealSecret(config.password),
};
case "webdav":
return {
...config,
password: config.password ? await cryptoUtils.sealSecret(config.password) : undefined,
};
default:
return config;
}
}
const listVolumes = async () => { const listVolumes = async () => {
const volumes = await db.query.volumesTable.findMany({}); const volumes = await db.query.volumesTable.findMany({});
@ -37,13 +53,14 @@ const createVolume = async (name: string, backendConfig: BackendConfig) => {
} }
const shortId = generateShortId(); const shortId = generateShortId();
const encryptedConfig = await encryptSensitiveFields(backendConfig);
const [created] = await db const [created] = await db
.insert(volumesTable) .insert(volumesTable)
.values({ .values({
shortId, shortId,
name: slug, name: slug,
config: backendConfig, config: encryptedConfig,
type: backendConfig.backend, type: backendConfig.backend,
}) })
.returning(); .returning();
@ -175,11 +192,13 @@ const updateVolume = async (name: string, volumeData: UpdateVolumeBody) => {
await backend.unmount(); await backend.unmount();
} }
const encryptedConfig = volumeData.config ? await encryptSensitiveFields(volumeData.config) : undefined;
const [updated] = await db const [updated] = await db
.update(volumesTable) .update(volumesTable)
.set({ .set({
name: newName, name: newName,
config: volumeData.config, config: encryptedConfig,
type: volumeData.config?.backend, type: volumeData.config?.backend,
autoRemount: volumeData.autoRemount, autoRemount: volumeData.autoRemount,
updatedAt: Date.now(), updatedAt: Date.now(),
@ -261,49 +280,6 @@ const checkHealth = async (name: string) => {
return { status, error }; return { status, error };
}; };
const getContainersUsingVolume = async (name: string) => {
const volume = await db.query.volumesTable.findFirst({
where: eq(volumesTable.name, name),
});
if (!volume) {
throw new NotFoundError("Volume not found");
}
const { docker } = await getCapabilities();
if (!docker) {
logger.debug("Docker capability not available, returning empty containers list");
return { containers: [] };
}
try {
const docker = new Docker(parseDockerHost(process.env.DOCKER_HOST));
const containers = await docker.listContainers({ all: true });
const usingContainers = [];
for (const info of containers) {
const container = docker.getContainer(info.Id);
const inspect = await container.inspect();
const mounts = inspect.Mounts || [];
const usesVolume = mounts.some((mount) => mount.Type === "volume" && mount.Name === `zb-${volume.shortId}`);
if (usesVolume) {
usingContainers.push({
id: inspect.Id,
name: inspect.Name,
state: inspect.State.Status,
image: inspect.Config.Image,
});
}
}
return { containers: usingContainers };
} catch (error) {
logger.error(`Failed to get containers using volume: ${toMessage(error)}`);
return { containers: [] };
}
};
const listFiles = async (name: string, subPath?: string) => { const listFiles = async (name: string, subPath?: string) => {
const volume = await db.query.volumesTable.findFirst({ const volume = await db.query.volumesTable.findFirst({
where: eq(volumesTable.name, name), where: eq(volumesTable.name, name),
@ -422,7 +398,6 @@ export const volumeService = {
testConnection, testConnection,
unmountVolume, unmountVolume,
checkHealth, checkHealth,
getContainersUsingVolume,
listFiles, listFiles,
browseFilesystem, browseFilesystem,
}; };

View file

@ -41,11 +41,11 @@ export const hasCompatibleCredentials = async (
(config1.backend === "s3" || config1.backend === "r2") && (config1.backend === "s3" || config1.backend === "r2") &&
(config2.backend === "s3" || config2.backend === "r2") (config2.backend === "s3" || config2.backend === "r2")
) { ) {
const accessKey1 = await cryptoUtils.decrypt(config1.accessKeyId); const accessKey1 = await cryptoUtils.resolveSecret(config1.accessKeyId);
const secretKey1 = await cryptoUtils.decrypt(config1.secretAccessKey); const secretKey1 = await cryptoUtils.resolveSecret(config1.secretAccessKey);
const accessKey2 = await cryptoUtils.decrypt(config2.accessKeyId); const accessKey2 = await cryptoUtils.resolveSecret(config2.accessKeyId);
const secretKey2 = await cryptoUtils.decrypt(config2.secretAccessKey); const secretKey2 = await cryptoUtils.resolveSecret(config2.secretAccessKey);
return accessKey1 === accessKey2 && secretKey1 === secretKey2; return accessKey1 === accessKey2 && secretKey1 === secretKey2;
} }
@ -53,8 +53,8 @@ export const hasCompatibleCredentials = async (
} }
case "gcs": { case "gcs": {
if (config1.backend === "gcs" && config2.backend === "gcs") { if (config1.backend === "gcs" && config2.backend === "gcs") {
const credentials1 = await cryptoUtils.decrypt(config1.credentialsJson); const credentials1 = await cryptoUtils.resolveSecret(config1.credentialsJson);
const credentials2 = await cryptoUtils.decrypt(config2.credentialsJson); const credentials2 = await cryptoUtils.resolveSecret(config2.credentialsJson);
return credentials1 === credentials2 && config1.projectId === config2.projectId; return credentials1 === credentials2 && config1.projectId === config2.projectId;
} }
@ -62,8 +62,8 @@ export const hasCompatibleCredentials = async (
} }
case "azure": { case "azure": {
if (config1.backend === "azure" && config2.backend === "azure") { if (config1.backend === "azure" && config2.backend === "azure") {
const config1Accountkey = await cryptoUtils.decrypt(config1.accountKey); const config1Accountkey = await cryptoUtils.resolveSecret(config1.accountKey);
const config2Accountkey = await cryptoUtils.decrypt(config2.accountKey); const config2Accountkey = await cryptoUtils.resolveSecret(config2.accountKey);
return config1.accountName === config2.accountName && config1Accountkey === config2Accountkey; return config1.accountName === config2.accountName && config1Accountkey === config2Accountkey;
} }
@ -75,10 +75,10 @@ export const hasCompatibleCredentials = async (
return true; return true;
} }
const config1Username = await cryptoUtils.decrypt(config1.username || ""); const config1Username = await cryptoUtils.resolveSecret(config1.username || "");
const config1Password = await cryptoUtils.decrypt(config1.password || ""); const config1Password = await cryptoUtils.resolveSecret(config1.password || "");
const config2Username = await cryptoUtils.decrypt(config2.username || ""); const config2Username = await cryptoUtils.resolveSecret(config2.username || "");
const config2Password = await cryptoUtils.decrypt(config2.password || ""); const config2Password = await cryptoUtils.resolveSecret(config2.password || "");
return config1Username === config2Username && config1Password === config2Password; return config1Username === config2Username && config1Password === config2Password;
} }

View file

@ -1,23 +1,99 @@
import crypto from "node:crypto"; import crypto from "node:crypto";
import fs from "node:fs/promises";
import path from "node:path";
import { RESTIC_PASS_FILE } from "../core/constants"; import { RESTIC_PASS_FILE } from "../core/constants";
import { isNodeJSErrnoException } from "./fs";
const algorithm = "aes-256-gcm" as const; const algorithm = "aes-256-gcm" as const;
const keyLength = 32; const keyLength = 32;
const encryptionPrefix = "encv1"; const encryptionPrefix = "encv1:";
const envSecretPrefix = "env://";
const fileSecretPrefix = "file://";
/** /**
* Given a string, encrypts it using a randomly generated salt * Checks if a given string is encrypted by looking for the encryption prefix.
*/
const isEncrypted = (val?: string): boolean => {
return typeof val === "string" && val.startsWith(encryptionPrefix);
};
/**
* Checks if a string looks like a supported secret reference.
* - env://VAR_NAME -> reads process.env.VAR_NAME
* - file://name -> reads a file /run/secrets/name
*/
const isSecretReference = (val?: string): boolean => {
return typeof val === "string" && (val.startsWith(envSecretPrefix) || val.startsWith(fileSecretPrefix));
};
/**
* Resolves an environment variable secret reference.
*/
const resolveEnvSecret = (ref: string): string => {
const name = ref.slice(envSecretPrefix.length);
if (!name) {
throw new Error("env:// reference is missing variable name");
}
const value = process.env[name];
if (value === undefined) {
throw new Error(`Environment variable not set: ${name}`);
}
return value;
};
/**
* Resolves a file-based secret reference.
* Reads the secret from /run/secrets/{name}
*/
const resolveFileSecret = async (ref: string): Promise<string> => {
const secretName = ref.slice(fileSecretPrefix.length);
if (!secretName) {
throw new Error("file:// reference is missing secret name");
}
const normalizedName = secretName.replace(/^\/+/, "");
if (!normalizedName) {
throw new Error("file:// reference is missing secret name");
}
if (normalizedName.includes("\0") || normalizedName.includes("/") || normalizedName.includes("\\")) {
throw new Error("Invalid secret reference: secret name must be a single path segment");
}
const resolvedPath = path.join("/run/secrets", normalizedName);
try {
const content = await fs.readFile(resolvedPath, "utf-8");
return content.trimEnd();
} catch (error) {
if (isNodeJSErrnoException(error)) {
if (error.code === "ENOENT") {
throw new Error(`Secret file not found: ${resolvedPath}`);
}
if (error.code === "EACCES") {
throw new Error(`Permission denied reading secret file: ${resolvedPath}`);
}
}
throw new Error(`Failed to read secret file ${resolvedPath}: ${(error as Error).message}`);
}
};
/**
* Given a string, encrypts it using a randomly generated salt.
* Returns the input unchanged if it's empty or already encrypted.
*/ */
const encrypt = async (data: string) => { const encrypt = async (data: string) => {
if (!data) { if (!data) {
return data; return data;
} }
if (data.startsWith(encryptionPrefix)) { if (isEncrypted(data)) {
return data; return data;
} }
const secret = (await Bun.file(RESTIC_PASS_FILE).text()).trim(); const secret = await Bun.file(RESTIC_PASS_FILE).text();
const salt = crypto.randomBytes(16); const salt = crypto.randomBytes(16);
const key = crypto.pbkdf2Sync(secret, salt, 100000, keyLength, "sha256"); const key = crypto.pbkdf2Sync(secret, salt, 100000, keyLength, "sha256");
@ -27,14 +103,19 @@ const encrypt = async (data: string) => {
const encrypted = Buffer.concat([cipher.update(data), cipher.final()]); const encrypted = Buffer.concat([cipher.update(data), cipher.final()]);
const tag = cipher.getAuthTag(); const tag = cipher.getAuthTag();
return `${encryptionPrefix}:${salt.toString("hex")}:${iv.toString("hex")}:${encrypted.toString("hex")}:${tag.toString("hex")}`; return `${encryptionPrefix}${salt.toString("hex")}:${iv.toString("hex")}:${encrypted.toString("hex")}:${tag.toString("hex")}`;
}; };
/** /**
* Given an encrypted string, decrypts it using the salt stored in the string * Given an encrypted string, decrypts it using the salt stored in the string.
* Returns the input unchanged if it's not encrypted (for backward compatibility).
*/ */
const decrypt = async (encryptedData: string) => { const decrypt = async (encryptedData: string) => {
const secret = await Bun.file(RESTIC_PASS_FILE).text(); if (!isEncrypted(encryptedData)) {
return encryptedData;
}
const secret = (await Bun.file(RESTIC_PASS_FILE).text()).trim();
const parts = encryptedData.split(":").slice(1); // Remove prefix const parts = encryptedData.split(":").slice(1); // Remove prefix
const saltHex = parts.shift() as string; const saltHex = parts.shift() as string;
@ -55,7 +136,54 @@ const decrypt = async (encryptedData: string) => {
return decrypted.toString(); return decrypted.toString();
}; };
export const cryptoUtils = { /**
encrypt, * Resolves secret references and encrypted database values.
decrypt, *
* - encv1:... -> decrypt
* - env://VAR -> read process.env.VAR
* - file://name -> read /run/secrets/name
* - otherwise returns value unchanged
*/
const resolveSecret = async (value: string): Promise<string> => {
if (!value) {
return value;
}
if (isEncrypted(value)) {
return decrypt(value);
}
if (value.startsWith(envSecretPrefix)) {
return resolveEnvSecret(value);
}
if (value.startsWith(fileSecretPrefix)) {
return resolveFileSecret(value);
}
return value;
};
/**
* Prepares a secret value for storage.
*
* - env://... and file://... are stored as-is (references)
* - encv1:... is stored as-is (already encrypted)
* - otherwise encrypt before storing
*/
const sealSecret = async (value: string): Promise<string> => {
if (!value) {
return value;
}
if (isEncrypted(value) || isSecretReference(value)) {
return value;
}
return encrypt(value);
};
export const cryptoUtils = {
resolveSecret,
sealSecret,
}; };

8
app/server/utils/fs.ts Normal file
View file

@ -0,0 +1,8 @@
export const isNodeJSErrnoException = (error: unknown): error is NodeJS.ErrnoException => {
return (
typeof error === "object" &&
error !== null &&
"code" in error &&
typeof (error as NodeJS.ErrnoException).code === "string"
);
};

View file

@ -1,17 +1,20 @@
import { createLogger, format, transports } from "winston"; import { createLogger, format, transports } from "winston";
import { sanitizeSensitiveData } from "./sanitize"; import { sanitizeSensitiveData } from "./sanitize";
import { config } from "../core/config";
const { printf, combine, colorize } = format; const { printf, combine, colorize } = format;
const printConsole = printf((info) => `${info.level} > ${info.message}`); const printConsole = printf((info) => `${info.level} > ${info.message}`);
const consoleFormat = combine(colorize(), printConsole); const consoleFormat = combine(colorize(), printConsole);
const defaultLevel = config.__prod__ ? "info" : "debug"; const getDefaultLevel = () => {
const isProd = process.env.NODE_ENV === "production";
return isProd ? "info" : "debug";
};
const winstonLogger = createLogger({ const winstonLogger = createLogger({
level: process.env.LOG_LEVEL || defaultLevel, level: process.env.LOG_LEVEL || getDefaultLevel(),
format: format.json(), format: format.json(),
transports: [new transports.Console({ level: process.env.LOG_LEVEL || defaultLevel, format: consoleFormat })], transports: [new transports.Console({ level: process.env.LOG_LEVEL || getDefaultLevel(), format: consoleFormat })],
}); });
const log = (level: "info" | "warn" | "error" | "debug", messages: unknown[]) => { const log = (level: "info" | "warn" | "error" | "debug", messages: unknown[]) => {

View file

@ -40,7 +40,7 @@ const snapshotInfoSchema = type({
short_id: "string", short_id: "string",
time: "string", time: "string",
uid: "number?", uid: "number?",
username: "string", username: "string?",
tags: "string[]?", tags: "string[]?",
summary: type({ summary: type({
backup_end: "string", backup_end: "string",
@ -106,7 +106,7 @@ const buildEnv = async (config: RepositoryConfig) => {
}; };
if (config.isExistingRepository && config.customPassword) { if (config.isExistingRepository && config.customPassword) {
const decryptedPassword = await cryptoUtils.decrypt(config.customPassword); const decryptedPassword = await cryptoUtils.resolveSecret(config.customPassword);
const passwordFilePath = path.join("/tmp", `zerobyte-pass-${crypto.randomBytes(8).toString("hex")}.txt`); const passwordFilePath = path.join("/tmp", `zerobyte-pass-${crypto.randomBytes(8).toString("hex")}.txt`);
await fs.writeFile(passwordFilePath, decryptedPassword, { mode: 0o600 }); await fs.writeFile(passwordFilePath, decryptedPassword, { mode: 0o600 });
@ -117,17 +117,17 @@ const buildEnv = async (config: RepositoryConfig) => {
switch (config.backend) { switch (config.backend) {
case "s3": case "s3":
env.AWS_ACCESS_KEY_ID = await cryptoUtils.decrypt(config.accessKeyId); env.AWS_ACCESS_KEY_ID = await cryptoUtils.resolveSecret(config.accessKeyId);
env.AWS_SECRET_ACCESS_KEY = await cryptoUtils.decrypt(config.secretAccessKey); env.AWS_SECRET_ACCESS_KEY = await cryptoUtils.resolveSecret(config.secretAccessKey);
break; break;
case "r2": case "r2":
env.AWS_ACCESS_KEY_ID = await cryptoUtils.decrypt(config.accessKeyId); env.AWS_ACCESS_KEY_ID = await cryptoUtils.resolveSecret(config.accessKeyId);
env.AWS_SECRET_ACCESS_KEY = await cryptoUtils.decrypt(config.secretAccessKey); env.AWS_SECRET_ACCESS_KEY = await cryptoUtils.resolveSecret(config.secretAccessKey);
env.AWS_REGION = "auto"; env.AWS_REGION = "auto";
env.AWS_S3_FORCE_PATH_STYLE = "true"; env.AWS_S3_FORCE_PATH_STYLE = "true";
break; break;
case "gcs": { case "gcs": {
const decryptedCredentials = await cryptoUtils.decrypt(config.credentialsJson); const decryptedCredentials = await cryptoUtils.resolveSecret(config.credentialsJson);
const credentialsPath = path.join("/tmp", `zerobyte-gcs-${crypto.randomBytes(8).toString("hex")}.json`); const credentialsPath = path.join("/tmp", `zerobyte-gcs-${crypto.randomBytes(8).toString("hex")}.json`);
await fs.writeFile(credentialsPath, decryptedCredentials, { mode: 0o600 }); await fs.writeFile(credentialsPath, decryptedCredentials, { mode: 0o600 });
env.GOOGLE_PROJECT_ID = config.projectId; env.GOOGLE_PROJECT_ID = config.projectId;
@ -136,7 +136,7 @@ const buildEnv = async (config: RepositoryConfig) => {
} }
case "azure": { case "azure": {
env.AZURE_ACCOUNT_NAME = config.accountName; env.AZURE_ACCOUNT_NAME = config.accountName;
env.AZURE_ACCOUNT_KEY = await cryptoUtils.decrypt(config.accountKey); env.AZURE_ACCOUNT_KEY = await cryptoUtils.resolveSecret(config.accountKey);
if (config.endpointSuffix) { if (config.endpointSuffix) {
env.AZURE_ENDPOINT_SUFFIX = config.endpointSuffix; env.AZURE_ENDPOINT_SUFFIX = config.endpointSuffix;
} }
@ -144,15 +144,15 @@ const buildEnv = async (config: RepositoryConfig) => {
} }
case "rest": { case "rest": {
if (config.username) { if (config.username) {
env.RESTIC_REST_USERNAME = await cryptoUtils.decrypt(config.username); env.RESTIC_REST_USERNAME = await cryptoUtils.resolveSecret(config.username);
} }
if (config.password) { if (config.password) {
env.RESTIC_REST_PASSWORD = await cryptoUtils.decrypt(config.password); env.RESTIC_REST_PASSWORD = await cryptoUtils.resolveSecret(config.password);
} }
break; break;
} }
case "sftp": { case "sftp": {
const decryptedKey = await cryptoUtils.decrypt(config.privateKey); const decryptedKey = await cryptoUtils.resolveSecret(config.privateKey);
const keyPath = path.join("/tmp", `ironmount-ssh-${crypto.randomBytes(8).toString("hex")}`); const keyPath = path.join("/tmp", `ironmount-ssh-${crypto.randomBytes(8).toString("hex")}`);
let normalizedKey = decryptedKey.replace(/\r\n/g, "\n"); let normalizedKey = decryptedKey.replace(/\r\n/g, "\n");
@ -795,7 +795,7 @@ const cleanupTemporaryKeys = async (config: RepositoryConfig, env: Record<string
}; };
const addCommonArgs = (args: string[], env: Record<string, string>) => { const addCommonArgs = (args: string[], env: Record<string, string>) => {
args.push("--retry-lock", "1m", "--json"); args.push("--json");
if (env._SFTP_SSH_ARGS) { if (env._SFTP_SSH_ARGS) {
args.push("-o", `sftp.args=${env._SFTP_SSH_ARGS}`); args.push("-o", `sftp.args=${env._SFTP_SSH_ARGS}`);

View file

@ -1,8 +1,9 @@
{ {
"$schema": "https://biomejs.dev/schemas/2.3.5/schema.json", "$schema": "./node_modules/@biomejs/biome/configuration_schema.json",
"vcs": { "vcs": {
"enabled": true, "enabled": true,
"clientKind": "git", "clientKind": "git",
"defaultBranch": "origin/main",
"useIgnoreFile": true "useIgnoreFile": true
}, },
"files": { "files": {

137
bun.lock
View file

@ -4,6 +4,9 @@
"workspaces": { "workspaces": {
"": { "": {
"dependencies": { "dependencies": {
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@hono/standard-validator": "^0.2.0", "@hono/standard-validator": "^0.2.0",
"@hookform/resolvers": "^5.2.2", "@hookform/resolvers": "^5.2.2",
"@radix-ui/react-alert-dialog": "^1.1.15", "@radix-ui/react-alert-dialog": "^1.1.15",
@ -28,7 +31,6 @@
"cron-parser": "^5.4.0", "cron-parser": "^5.4.0",
"date-fns": "^4.1.0", "date-fns": "^4.1.0",
"dither-plugin": "^1.1.1", "dither-plugin": "^1.1.1",
"dockerode": "^4.0.9",
"dotenv": "^17.2.3", "dotenv": "^17.2.3",
"drizzle-orm": "^0.44.7", "drizzle-orm": "^0.44.7",
"es-toolkit": "^1.42.0", "es-toolkit": "^1.42.0",
@ -59,7 +61,6 @@
"@tailwindcss/vite": "^4.1.17", "@tailwindcss/vite": "^4.1.17",
"@tanstack/react-query-devtools": "^5.91.1", "@tanstack/react-query-devtools": "^5.91.1",
"@types/bun": "^1.3.3", "@types/bun": "^1.3.3",
"@types/dockerode": "^3.3.47",
"@types/node": "^24.10.1", "@types/node": "^24.10.1",
"@types/react": "^19.2.7", "@types/react": "^19.2.7",
"@types/react-dom": "^19.2.3", "@types/react-dom": "^19.2.3",
@ -136,8 +137,6 @@
"@babel/types": ["@babel/types@7.28.5", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA=="], "@babel/types": ["@babel/types@7.28.5", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA=="],
"@balena/dockerignore": ["@balena/dockerignore@1.0.2", "", {}, "sha512-wMue2Sy4GAVTk6Ic4tJVcnfdau+gx2EnG7S+uAEe+TWJFqE4YoWN4/H8MSLj4eYJKxGg26lZwboEniNiNwZQ6Q=="],
"@biomejs/biome": ["@biomejs/biome@2.3.8", "", { "optionalDependencies": { "@biomejs/cli-darwin-arm64": "2.3.8", "@biomejs/cli-darwin-x64": "2.3.8", "@biomejs/cli-linux-arm64": "2.3.8", "@biomejs/cli-linux-arm64-musl": "2.3.8", "@biomejs/cli-linux-x64": "2.3.8", "@biomejs/cli-linux-x64-musl": "2.3.8", "@biomejs/cli-win32-arm64": "2.3.8", "@biomejs/cli-win32-x64": "2.3.8" }, "bin": { "biome": "bin/biome" } }, "sha512-Qjsgoe6FEBxWAUzwFGFrB+1+M8y/y5kwmg5CHac+GSVOdmOIqsAiXM5QMVGZJ1eCUCLlPZtq4aFAQ0eawEUuUA=="], "@biomejs/biome": ["@biomejs/biome@2.3.8", "", { "optionalDependencies": { "@biomejs/cli-darwin-arm64": "2.3.8", "@biomejs/cli-darwin-x64": "2.3.8", "@biomejs/cli-linux-arm64": "2.3.8", "@biomejs/cli-linux-arm64-musl": "2.3.8", "@biomejs/cli-linux-x64": "2.3.8", "@biomejs/cli-linux-x64-musl": "2.3.8", "@biomejs/cli-win32-arm64": "2.3.8", "@biomejs/cli-win32-x64": "2.3.8" }, "bin": { "biome": "bin/biome" } }, "sha512-Qjsgoe6FEBxWAUzwFGFrB+1+M8y/y5kwmg5CHac+GSVOdmOIqsAiXM5QMVGZJ1eCUCLlPZtq4aFAQ0eawEUuUA=="],
"@biomejs/cli-darwin-arm64": ["@biomejs/cli-darwin-arm64@2.3.8", "", { "os": "darwin", "cpu": "arm64" }, "sha512-HM4Zg9CGQ3txTPflxD19n8MFPrmUAjaC7PQdLkugeeC0cQ+PiVrd7i09gaBS/11QKsTDBJhVg85CEIK9f50Qww=="], "@biomejs/cli-darwin-arm64": ["@biomejs/cli-darwin-arm64@2.3.8", "", { "os": "darwin", "cpu": "arm64" }, "sha512-HM4Zg9CGQ3txTPflxD19n8MFPrmUAjaC7PQdLkugeeC0cQ+PiVrd7i09gaBS/11QKsTDBJhVg85CEIK9f50Qww=="],
@ -160,6 +159,14 @@
"@dabh/diagnostics": ["@dabh/diagnostics@2.0.8", "", { "dependencies": { "@so-ric/colorspace": "^1.1.6", "enabled": "2.0.x", "kuler": "^2.0.0" } }, "sha512-R4MSXTVnuMzGD7bzHdW2ZhhdPC/igELENcq5IjEverBvq5hn1SXCWcsi6eSsdWP0/Ur+SItRRjAktmdoX/8R/Q=="], "@dabh/diagnostics": ["@dabh/diagnostics@2.0.8", "", { "dependencies": { "@so-ric/colorspace": "^1.1.6", "enabled": "2.0.x", "kuler": "^2.0.0" } }, "sha512-R4MSXTVnuMzGD7bzHdW2ZhhdPC/igELENcq5IjEverBvq5hn1SXCWcsi6eSsdWP0/Ur+SItRRjAktmdoX/8R/Q=="],
"@dnd-kit/accessibility": ["@dnd-kit/accessibility@3.1.1", "", { "dependencies": { "tslib": "^2.0.0" }, "peerDependencies": { "react": ">=16.8.0" } }, "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw=="],
"@dnd-kit/core": ["@dnd-kit/core@6.3.1", "", { "dependencies": { "@dnd-kit/accessibility": "^3.1.1", "@dnd-kit/utilities": "^3.2.2", "tslib": "^2.0.0" }, "peerDependencies": { "react": ">=16.8.0", "react-dom": ">=16.8.0" } }, "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ=="],
"@dnd-kit/sortable": ["@dnd-kit/sortable@10.0.0", "", { "dependencies": { "@dnd-kit/utilities": "^3.2.2", "tslib": "^2.0.0" }, "peerDependencies": { "@dnd-kit/core": "^6.3.0", "react": ">=16.8.0" } }, "sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg=="],
"@dnd-kit/utilities": ["@dnd-kit/utilities@3.2.2", "", { "dependencies": { "tslib": "^2.0.0" }, "peerDependencies": { "react": ">=16.8.0" } }, "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg=="],
"@drizzle-team/brocli": ["@drizzle-team/brocli@0.10.2", "", {}, "sha512-z33Il7l5dKjUgGULTqBsQBQwckHh5AbIuxhdsIxDDiZAzBOrZO6q9ogcWC65kU382AfynTfgNumVcNIjuIua6w=="], "@drizzle-team/brocli": ["@drizzle-team/brocli@0.10.2", "", {}, "sha512-z33Il7l5dKjUgGULTqBsQBQwckHh5AbIuxhdsIxDDiZAzBOrZO6q9ogcWC65kU382AfynTfgNumVcNIjuIua6w=="],
"@esbuild-kit/core-utils": ["@esbuild-kit/core-utils@3.3.2", "", { "dependencies": { "esbuild": "~0.18.20", "source-map-support": "^0.5.21" } }, "sha512-sPRAnw9CdSsRmEtnsl2WXWdyquogVpB3yZ3dgwJfe8zrOzTsV7cJvmwrKVa+0ma5BoiGJ+BoqkMvawbayKUsqQ=="], "@esbuild-kit/core-utils": ["@esbuild-kit/core-utils@3.3.2", "", { "dependencies": { "esbuild": "~0.18.20", "source-map-support": "^0.5.21" } }, "sha512-sPRAnw9CdSsRmEtnsl2WXWdyquogVpB3yZ3dgwJfe8zrOzTsV7cJvmwrKVa+0ma5BoiGJ+BoqkMvawbayKUsqQ=="],
@ -226,10 +233,6 @@
"@floating-ui/utils": ["@floating-ui/utils@0.2.10", "", {}, "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ=="], "@floating-ui/utils": ["@floating-ui/utils@0.2.10", "", {}, "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ=="],
"@grpc/grpc-js": ["@grpc/grpc-js@1.14.1", "", { "dependencies": { "@grpc/proto-loader": "^0.8.0", "@js-sdsl/ordered-map": "^4.4.2" } }, "sha512-sPxgEWtPUR3EnRJCEtbGZG2iX8LQDUls2wUS3o27jg07KqJFMq6YDeWvMo1wfpmy3rqRdS0rivpLwhqQtEyCuQ=="],
"@grpc/proto-loader": ["@grpc/proto-loader@0.7.15", "", { "dependencies": { "lodash.camelcase": "^4.3.0", "long": "^5.0.0", "protobufjs": "^7.2.5", "yargs": "^17.7.2" }, "bin": { "proto-loader-gen-types": "build/bin/proto-loader-gen-types.js" } }, "sha512-tMXdRCfYVixjuFK+Hk0Q1s38gV9zDiDJfWL3h1rv4Qc39oILCu1TRTDt7+fGUI8K4G1Fj125Hx/ru3azECWTyQ=="],
"@hey-api/codegen-core": ["@hey-api/codegen-core@0.3.3", "", { "peerDependencies": { "typescript": ">=5.5.3" } }, "sha512-vArVDtrvdzFewu1hnjUm4jX1NBITlSCeO81EdWq676MxQbyxsGcDPAgohaSA+Wvr4HjPSvsg2/1s2zYxUtXebg=="], "@hey-api/codegen-core": ["@hey-api/codegen-core@0.3.3", "", { "peerDependencies": { "typescript": ">=5.5.3" } }, "sha512-vArVDtrvdzFewu1hnjUm4jX1NBITlSCeO81EdWq676MxQbyxsGcDPAgohaSA+Wvr4HjPSvsg2/1s2zYxUtXebg=="],
"@hey-api/json-schema-ref-parser": ["@hey-api/json-schema-ref-parser@1.2.1", "", { "dependencies": { "@jsdevtools/ono": "^7.1.3", "@types/json-schema": "^7.0.15", "js-yaml": "^4.1.0", "lodash": "^4.17.21" } }, "sha512-inPeksRLq+j3ArnuGOzQPQE//YrhezQG0+9Y9yizScBN2qatJ78fIByhEgKdNAbtguDCn4RPxmEhcrePwHxs4A=="], "@hey-api/json-schema-ref-parser": ["@hey-api/json-schema-ref-parser@1.2.1", "", { "dependencies": { "@jsdevtools/ono": "^7.1.3", "@types/json-schema": "^7.0.15", "js-yaml": "^4.1.0", "lodash": "^4.17.21" } }, "sha512-inPeksRLq+j3ArnuGOzQPQE//YrhezQG0+9Y9yizScBN2qatJ78fIByhEgKdNAbtguDCn4RPxmEhcrePwHxs4A=="],
@ -256,32 +259,10 @@
"@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="], "@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="],
"@js-sdsl/ordered-map": ["@js-sdsl/ordered-map@4.4.2", "", {}, "sha512-iUKgm52T8HOE/makSxjqoWhe95ZJA1/G1sYsGev2JDKUSS14KAgg1LHb+Ba+IPow0xflbnSkOsZcO08C7w1gYw=="],
"@jsdevtools/ono": ["@jsdevtools/ono@7.1.3", "", {}, "sha512-4JQNk+3mVzK3xh2rqd6RB4J46qUR19azEHBneZyTZM+c456qOrbbM/5xcR8huNCCcbVt7+UmizG6GuUvPvKUYg=="], "@jsdevtools/ono": ["@jsdevtools/ono@7.1.3", "", {}, "sha512-4JQNk+3mVzK3xh2rqd6RB4J46qUR19azEHBneZyTZM+c456qOrbbM/5xcR8huNCCcbVt7+UmizG6GuUvPvKUYg=="],
"@mjackson/node-fetch-server": ["@mjackson/node-fetch-server@0.2.0", "", {}, "sha512-EMlH1e30yzmTpGLQjlFmaDAjyOeZhng1/XCd7DExR8PNAnG/G1tyruZxEoUe11ClnwGhGrtsdnyyUx1frSzjng=="], "@mjackson/node-fetch-server": ["@mjackson/node-fetch-server@0.2.0", "", {}, "sha512-EMlH1e30yzmTpGLQjlFmaDAjyOeZhng1/XCd7DExR8PNAnG/G1tyruZxEoUe11ClnwGhGrtsdnyyUx1frSzjng=="],
"@protobufjs/aspromise": ["@protobufjs/aspromise@1.1.2", "", {}, "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ=="],
"@protobufjs/base64": ["@protobufjs/base64@1.1.2", "", {}, "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg=="],
"@protobufjs/codegen": ["@protobufjs/codegen@2.0.4", "", {}, "sha512-YyFaikqM5sH0ziFZCN3xDC7zeGaB/d0IUb9CATugHWbd1FRFwWwt4ld4OYMPWu5a3Xe01mGAULCdqhMlPl29Jg=="],
"@protobufjs/eventemitter": ["@protobufjs/eventemitter@1.1.0", "", {}, "sha512-j9ednRT81vYJ9OfVuXG6ERSTdEL1xVsNgqpkxMsbIabzSo3goCjDIveeGv5d03om39ML71RdmrGNjG5SReBP/Q=="],
"@protobufjs/fetch": ["@protobufjs/fetch@1.1.0", "", { "dependencies": { "@protobufjs/aspromise": "^1.1.1", "@protobufjs/inquire": "^1.1.0" } }, "sha512-lljVXpqXebpsijW71PZaCYeIcE5on1w5DlQy5WH6GLbFryLUrBD4932W/E2BSpfRJWseIL4v/KPgBFxDOIdKpQ=="],
"@protobufjs/float": ["@protobufjs/float@1.0.2", "", {}, "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ=="],
"@protobufjs/inquire": ["@protobufjs/inquire@1.1.0", "", {}, "sha512-kdSefcPdruJiFMVSbn801t4vFK7KB/5gd2fYvrxhuJYg8ILrmn9SKSX2tZdV6V+ksulWqS7aXjBcRXl3wHoD9Q=="],
"@protobufjs/path": ["@protobufjs/path@1.1.2", "", {}, "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA=="],
"@protobufjs/pool": ["@protobufjs/pool@1.1.0", "", {}, "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw=="],
"@protobufjs/utf8": ["@protobufjs/utf8@1.1.0", "", {}, "sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw=="],
"@radix-ui/number": ["@radix-ui/number@1.1.1", "", {}, "sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g=="], "@radix-ui/number": ["@radix-ui/number@1.1.1", "", {}, "sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g=="],
"@radix-ui/primitive": ["@radix-ui/primitive@1.1.3", "", {}, "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg=="], "@radix-ui/primitive": ["@radix-ui/primitive@1.1.3", "", {}, "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg=="],
@ -488,10 +469,6 @@
"@types/d3-timer": ["@types/d3-timer@3.0.2", "", {}, "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw=="], "@types/d3-timer": ["@types/d3-timer@3.0.2", "", {}, "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw=="],
"@types/docker-modem": ["@types/docker-modem@3.0.6", "", { "dependencies": { "@types/node": "*", "@types/ssh2": "*" } }, "sha512-yKpAGEuKRSS8wwx0joknWxsmLha78wNMe9R2S3UNsVOkZded8UqOrV8KoeDXoXsjndxwyF3eIhyClGbO1SEhEg=="],
"@types/dockerode": ["@types/dockerode@3.3.47", "", { "dependencies": { "@types/docker-modem": "*", "@types/node": "*", "@types/ssh2": "*" } }, "sha512-ShM1mz7rCjdssXt7Xz0u1/R2BJC7piWa3SJpUBiVjCf2A3XNn4cP6pUVaD8bLanpPVVn4IKzJuw3dOvkJ8IbYw=="],
"@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="], "@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="],
"@types/json-schema": ["@types/json-schema@7.0.15", "", {}, "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="], "@types/json-schema": ["@types/json-schema@7.0.15", "", {}, "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="],
@ -502,8 +479,6 @@
"@types/react-dom": ["@types/react-dom@19.2.3", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ=="], "@types/react-dom": ["@types/react-dom@19.2.3", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ=="],
"@types/ssh2": ["@types/ssh2@1.15.5", "", { "dependencies": { "@types/node": "^18.11.18" } }, "sha512-N1ASjp/nXH3ovBHddRJpli4ozpk6UdDYIX4RJWFa9L1YKnzdhTlVmiGHm4DZnj/jLbqZpes4aeR30EFGQtvhQQ=="],
"@types/triple-beam": ["@types/triple-beam@1.3.5", "", {}, "sha512-6WaYesThRMCl19iryMYP7/x2OVgCtbIVflDGFpWnb9irXI3UjYE4AzmYuiUKY1AJstGijoY+MgUszMgRxIYTYw=="], "@types/triple-beam": ["@types/triple-beam@1.3.5", "", {}, "sha512-6WaYesThRMCl19iryMYP7/x2OVgCtbIVflDGFpWnb9irXI3UjYE4AzmYuiUKY1AJstGijoY+MgUszMgRxIYTYw=="],
"@types/use-sync-external-store": ["@types/use-sync-external-store@0.0.6", "", {}, "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg=="], "@types/use-sync-external-store": ["@types/use-sync-external-store@0.0.6", "", {}, "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg=="],
@ -512,10 +487,6 @@
"ansi-colors": ["ansi-colors@4.1.3", "", {}, "sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw=="], "ansi-colors": ["ansi-colors@4.1.3", "", {}, "sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw=="],
"ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
"ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="],
"arg": ["arg@5.0.2", "", {}, "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg=="], "arg": ["arg@5.0.2", "", {}, "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg=="],
"argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="], "argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="],
@ -528,36 +499,24 @@
"array-flatten": ["array-flatten@1.1.1", "", {}, "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg=="], "array-flatten": ["array-flatten@1.1.1", "", {}, "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg=="],
"asn1": ["asn1@0.2.6", "", { "dependencies": { "safer-buffer": "~2.1.0" } }, "sha512-ix/FxPn0MDjeyJ7i/yoHGFt/EX6LyNbxSEhPPXODPL+KB0VPk86UYfL0lMdy+KCnv+fmvIzySwaK5COwqVbWTQ=="],
"async": ["async@3.2.6", "", {}, "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA=="], "async": ["async@3.2.6", "", {}, "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA=="],
"babel-dead-code-elimination": ["babel-dead-code-elimination@1.0.10", "", { "dependencies": { "@babel/core": "^7.23.7", "@babel/parser": "^7.23.6", "@babel/traverse": "^7.23.7", "@babel/types": "^7.23.6" } }, "sha512-DV5bdJZTzZ0zn0DC24v3jD7Mnidh6xhKa4GfKCbq3sfW8kaWhDdZjP3i81geA8T33tdYqWKw4D3fVv0CwEgKVA=="], "babel-dead-code-elimination": ["babel-dead-code-elimination@1.0.10", "", { "dependencies": { "@babel/core": "^7.23.7", "@babel/parser": "^7.23.6", "@babel/traverse": "^7.23.7", "@babel/types": "^7.23.6" } }, "sha512-DV5bdJZTzZ0zn0DC24v3jD7Mnidh6xhKa4GfKCbq3sfW8kaWhDdZjP3i81geA8T33tdYqWKw4D3fVv0CwEgKVA=="],
"balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], "balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="],
"base64-js": ["base64-js@1.5.1", "", {}, "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="],
"baseline-browser-mapping": ["baseline-browser-mapping@2.8.28", "", { "bin": { "baseline-browser-mapping": "dist/cli.js" } }, "sha512-gYjt7OIqdM0PcttNYP2aVrr2G0bMALkBaoehD4BuRGjAOtipg0b6wHg1yNL+s5zSnLZZrGHOw4IrND8CD+3oIQ=="], "baseline-browser-mapping": ["baseline-browser-mapping@2.8.28", "", { "bin": { "baseline-browser-mapping": "dist/cli.js" } }, "sha512-gYjt7OIqdM0PcttNYP2aVrr2G0bMALkBaoehD4BuRGjAOtipg0b6wHg1yNL+s5zSnLZZrGHOw4IrND8CD+3oIQ=="],
"basic-auth": ["basic-auth@2.0.1", "", { "dependencies": { "safe-buffer": "5.1.2" } }, "sha512-NF+epuEdnUYVlGuhaxbbq+dvJttwLnGY+YixlXlME5KpQ5W3CnXA5cVTneY3SPbPDRkcjMbifrwmFYcClgOZeg=="], "basic-auth": ["basic-auth@2.0.1", "", { "dependencies": { "safe-buffer": "5.1.2" } }, "sha512-NF+epuEdnUYVlGuhaxbbq+dvJttwLnGY+YixlXlME5KpQ5W3CnXA5cVTneY3SPbPDRkcjMbifrwmFYcClgOZeg=="],
"bcrypt-pbkdf": ["bcrypt-pbkdf@1.0.2", "", { "dependencies": { "tweetnacl": "^0.14.3" } }, "sha512-qeFIXtP4MSoi6NLqO12WfqARWWuCKi2Rn/9hJLEmtB5yTNr9DqFWkJRCf2qShWzPeAMRnOgCrq0sg/KLv5ES9w=="],
"bl": ["bl@4.1.0", "", { "dependencies": { "buffer": "^5.5.0", "inherits": "^2.0.4", "readable-stream": "^3.4.0" } }, "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w=="],
"body-parser": ["body-parser@1.20.3", "", { "dependencies": { "bytes": "3.1.2", "content-type": "~1.0.5", "debug": "2.6.9", "depd": "2.0.0", "destroy": "1.2.0", "http-errors": "2.0.0", "iconv-lite": "0.4.24", "on-finished": "2.4.1", "qs": "6.13.0", "raw-body": "2.5.2", "type-is": "~1.6.18", "unpipe": "1.0.0" } }, "sha512-7rAxByjUMqQ3/bHJy7D6OGXvx/MMc4IqBn/X0fcM1QUcAItpZrBEYhWGem+tzXH90c+G01ypMcYJBO9Y30203g=="], "body-parser": ["body-parser@1.20.3", "", { "dependencies": { "bytes": "3.1.2", "content-type": "~1.0.5", "debug": "2.6.9", "depd": "2.0.0", "destroy": "1.2.0", "http-errors": "2.0.0", "iconv-lite": "0.4.24", "on-finished": "2.4.1", "qs": "6.13.0", "raw-body": "2.5.2", "type-is": "~1.6.18", "unpipe": "1.0.0" } }, "sha512-7rAxByjUMqQ3/bHJy7D6OGXvx/MMc4IqBn/X0fcM1QUcAItpZrBEYhWGem+tzXH90c+G01ypMcYJBO9Y30203g=="],
"brace-expansion": ["brace-expansion@2.0.2", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ=="], "brace-expansion": ["brace-expansion@2.0.2", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ=="],
"browserslist": ["browserslist@4.28.0", "", { "dependencies": { "baseline-browser-mapping": "^2.8.25", "caniuse-lite": "^1.0.30001754", "electron-to-chromium": "^1.5.249", "node-releases": "^2.0.27", "update-browserslist-db": "^1.1.4" }, "bin": { "browserslist": "cli.js" } }, "sha512-tbydkR/CxfMwelN0vwdP/pLkDwyAASZ+VfWm4EOwlB6SWhx1sYnWLqo8N5j0rAzPfzfRaxt0mM/4wPU/Su84RQ=="], "browserslist": ["browserslist@4.28.0", "", { "dependencies": { "baseline-browser-mapping": "^2.8.25", "caniuse-lite": "^1.0.30001754", "electron-to-chromium": "^1.5.249", "node-releases": "^2.0.27", "update-browserslist-db": "^1.1.4" }, "bin": { "browserslist": "cli.js" } }, "sha512-tbydkR/CxfMwelN0vwdP/pLkDwyAASZ+VfWm4EOwlB6SWhx1sYnWLqo8N5j0rAzPfzfRaxt0mM/4wPU/Su84RQ=="],
"buffer": ["buffer@5.7.1", "", { "dependencies": { "base64-js": "^1.3.1", "ieee754": "^1.1.13" } }, "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ=="],
"buffer-from": ["buffer-from@1.1.2", "", {}, "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ=="], "buffer-from": ["buffer-from@1.1.2", "", {}, "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ=="],
"buildcheck": ["buildcheck@0.0.6", "", {}, "sha512-8f9ZJCUXyT1M35Jx7MkBgmBMo3oHTTBIPLiY9xyL0pl3T5RwcPEY8cUHr5LBNfu/fk6c2T4DJZuVM/8ZZT2D2A=="],
"bun-types": ["bun-types@1.3.3", "", { "dependencies": { "@types/node": "*" } }, "sha512-z3Xwlg7j2l9JY27x5Qn3Wlyos8YAp0kKRlrePAOjgjMGS5IG6E7Jnlx736vH9UVI4wUICwwhC9anYL++XeOgTQ=="], "bun-types": ["bun-types@1.3.3", "", { "dependencies": { "@types/node": "*" } }, "sha512-z3Xwlg7j2l9JY27x5Qn3Wlyos8YAp0kKRlrePAOjgjMGS5IG6E7Jnlx736vH9UVI4wUICwwhC9anYL++XeOgTQ=="],
"bundle-name": ["bundle-name@4.1.0", "", { "dependencies": { "run-applescript": "^7.0.0" } }, "sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q=="], "bundle-name": ["bundle-name@4.1.0", "", { "dependencies": { "run-applescript": "^7.0.0" } }, "sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q=="],
@ -576,14 +535,10 @@
"chokidar": ["chokidar@4.0.3", "", { "dependencies": { "readdirp": "^4.0.1" } }, "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA=="], "chokidar": ["chokidar@4.0.3", "", { "dependencies": { "readdirp": "^4.0.1" } }, "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA=="],
"chownr": ["chownr@1.1.4", "", {}, "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg=="],
"citty": ["citty@0.1.6", "", { "dependencies": { "consola": "^3.2.3" } }, "sha512-tskPPKEs8D2KPafUypv2gxwJP8h/OaJmC82QQGGDQcHvXX43xF2VDACcJVmZ0EuSxkpO9Kc4MlrA3q0+FG58AQ=="], "citty": ["citty@0.1.6", "", { "dependencies": { "consola": "^3.2.3" } }, "sha512-tskPPKEs8D2KPafUypv2gxwJP8h/OaJmC82QQGGDQcHvXX43xF2VDACcJVmZ0EuSxkpO9Kc4MlrA3q0+FG58AQ=="],
"class-variance-authority": ["class-variance-authority@0.7.1", "", { "dependencies": { "clsx": "^2.1.1" } }, "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg=="], "class-variance-authority": ["class-variance-authority@0.7.1", "", { "dependencies": { "clsx": "^2.1.1" } }, "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg=="],
"cliui": ["cliui@8.0.1", "", { "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.1", "wrap-ansi": "^7.0.0" } }, "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ=="],
"clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="], "clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="],
"color": ["color@5.0.2", "", { "dependencies": { "color-convert": "^3.0.1", "color-string": "^2.0.0" } }, "sha512-e2hz5BzbUPcYlIRHo8ieAhYgoajrJr+hWoceg6E345TPsATMUKqDgzt8fSXZJJbxfpiPzkWyphz8yn8At7q3fA=="], "color": ["color@5.0.2", "", { "dependencies": { "color-convert": "^3.0.1", "color-string": "^2.0.0" } }, "sha512-e2hz5BzbUPcYlIRHo8ieAhYgoajrJr+hWoceg6E345TPsATMUKqDgzt8fSXZJJbxfpiPzkWyphz8yn8At7q3fA=="],
@ -616,8 +571,6 @@
"cookie-signature": ["cookie-signature@1.0.6", "", {}, "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ=="], "cookie-signature": ["cookie-signature@1.0.6", "", {}, "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ=="],
"cpu-features": ["cpu-features@0.0.10", "", { "dependencies": { "buildcheck": "~0.0.6", "nan": "^2.19.0" } }, "sha512-9IkYqtX3YHPCzoVg1Py+o9057a3i0fp7S530UWokCSaFVTc7CwXPRiOjRjBQQ18ZCNafx78YfnG+HALxtVmOGA=="],
"cron-parser": ["cron-parser@5.4.0", "", { "dependencies": { "luxon": "^3.7.1" } }, "sha512-HxYB8vTvnQFx4dLsZpGRa0uHp6X3qIzS3ZJgJ9v6l/5TJMgeWQbLkR5yiJ5hOxGbc9+jCADDnydIe15ReLZnJA=="], "cron-parser": ["cron-parser@5.4.0", "", { "dependencies": { "luxon": "^3.7.1" } }, "sha512-HxYB8vTvnQFx4dLsZpGRa0uHp6X3qIzS3ZJgJ9v6l/5TJMgeWQbLkR5yiJ5hOxGbc9+jCADDnydIe15ReLZnJA=="],
"csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="], "csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="],
@ -672,10 +625,6 @@
"dither-plugin": ["dither-plugin@1.1.1", "", {}, "sha512-PsgAcSoNVKkwh+Q/OopRn/2qb9HW1LRyGqT1bQe8iooYvVY1FIIqePFN9JkEIVK9rkfZdj7nXn9EUB4B7mNh6g=="], "dither-plugin": ["dither-plugin@1.1.1", "", {}, "sha512-PsgAcSoNVKkwh+Q/OopRn/2qb9HW1LRyGqT1bQe8iooYvVY1FIIqePFN9JkEIVK9rkfZdj7nXn9EUB4B7mNh6g=="],
"docker-modem": ["docker-modem@5.0.6", "", { "dependencies": { "debug": "^4.1.1", "readable-stream": "^3.5.0", "split-ca": "^1.0.1", "ssh2": "^1.15.0" } }, "sha512-ens7BiayssQz/uAxGzH8zGXCtiV24rRWXdjNha5V4zSOcxmAZsfGVm/PPFbwQdqEkDnhG+SyR9E3zSHUbOKXBQ=="],
"dockerode": ["dockerode@4.0.9", "", { "dependencies": { "@balena/dockerignore": "^1.0.2", "@grpc/grpc-js": "^1.11.1", "@grpc/proto-loader": "^0.7.13", "docker-modem": "^5.0.6", "protobufjs": "^7.3.2", "tar-fs": "^2.1.4", "uuid": "^10.0.0" } }, "sha512-iND4mcOWhPaCNh54WmK/KoSb35AFqPAUWFMffTQcp52uQt36b5uNwEJTSXntJZBbeGad72Crbi/hvDIv6us/6Q=="],
"dotenv": ["dotenv@17.2.3", "", {}, "sha512-JVUnt+DUIzu87TABbhPmNfVdBDt18BLOWjMUFJMSi/Qqg7NTYtabbvSNJGOJ7afbRuv9D/lngizHtP7QyLQ+9w=="], "dotenv": ["dotenv@17.2.3", "", {}, "sha512-JVUnt+DUIzu87TABbhPmNfVdBDt18BLOWjMUFJMSi/Qqg7NTYtabbvSNJGOJ7afbRuv9D/lngizHtP7QyLQ+9w=="],
"drizzle-kit": ["drizzle-kit@0.31.7", "", { "dependencies": { "@drizzle-team/brocli": "^0.10.2", "@esbuild-kit/esm-loader": "^2.5.5", "esbuild": "^0.25.4", "esbuild-register": "^3.5.0" }, "bin": { "drizzle-kit": "bin.cjs" } }, "sha512-hOzRGSdyKIU4FcTSFYGKdXEjFsncVwHZ43gY3WU5Bz9j5Iadp6Rh6hxLSQ1IWXpKLBKt/d5y1cpSPcV+FcoQ1A=="], "drizzle-kit": ["drizzle-kit@0.31.7", "", { "dependencies": { "@drizzle-team/brocli": "^0.10.2", "@esbuild-kit/esm-loader": "^2.5.5", "esbuild": "^0.25.4", "esbuild-register": "^3.5.0" }, "bin": { "drizzle-kit": "bin.cjs" } }, "sha512-hOzRGSdyKIU4FcTSFYGKdXEjFsncVwHZ43gY3WU5Bz9j5Iadp6Rh6hxLSQ1IWXpKLBKt/d5y1cpSPcV+FcoQ1A=="],
@ -688,14 +637,10 @@
"electron-to-chromium": ["electron-to-chromium@1.5.250", "", {}, "sha512-/5UMj9IiGDMOFBnN4i7/Ry5onJrAGSbOGo3s9FEKmwobGq6xw832ccET0CE3CkkMBZ8GJSlUIesZofpyurqDXw=="], "electron-to-chromium": ["electron-to-chromium@1.5.250", "", {}, "sha512-/5UMj9IiGDMOFBnN4i7/Ry5onJrAGSbOGo3s9FEKmwobGq6xw832ccET0CE3CkkMBZ8GJSlUIesZofpyurqDXw=="],
"emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="],
"enabled": ["enabled@2.0.0", "", {}, "sha512-AKrN98kuwOzMIdAizXGI86UFBoo26CL21UM763y1h/GMSJ4/OHU9k2YlsmBpyScFo/wbLzWQJBMCW4+IO3/+OQ=="], "enabled": ["enabled@2.0.0", "", {}, "sha512-AKrN98kuwOzMIdAizXGI86UFBoo26CL21UM763y1h/GMSJ4/OHU9k2YlsmBpyScFo/wbLzWQJBMCW4+IO3/+OQ=="],
"encodeurl": ["encodeurl@2.0.0", "", {}, "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg=="], "encodeurl": ["encodeurl@2.0.0", "", {}, "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg=="],
"end-of-stream": ["end-of-stream@1.4.5", "", { "dependencies": { "once": "^1.4.0" } }, "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg=="],
"enhanced-resolve": ["enhanced-resolve@5.18.3", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.2.0" } }, "sha512-d4lC8xfavMeBjzGr2vECC3fsGXziXZQyJxD868h2M/mBI3PwAuODxAkLkq5HYuvrPYcUtiLzsTo8U3PgX3Ocww=="], "enhanced-resolve": ["enhanced-resolve@5.18.3", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.2.0" } }, "sha512-d4lC8xfavMeBjzGr2vECC3fsGXziXZQyJxD868h2M/mBI3PwAuODxAkLkq5HYuvrPYcUtiLzsTo8U3PgX3Ocww=="],
"es-define-property": ["es-define-property@1.0.1", "", {}, "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="], "es-define-property": ["es-define-property@1.0.1", "", {}, "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="],
@ -738,16 +683,12 @@
"fresh": ["fresh@0.5.2", "", {}, "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q=="], "fresh": ["fresh@0.5.2", "", {}, "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q=="],
"fs-constants": ["fs-constants@1.0.0", "", {}, "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow=="],
"fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], "fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
"function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="], "function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="],
"gensync": ["gensync@1.0.0-beta.2", "", {}, "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg=="], "gensync": ["gensync@1.0.0-beta.2", "", {}, "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg=="],
"get-caller-file": ["get-caller-file@2.0.5", "", {}, "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg=="],
"get-intrinsic": ["get-intrinsic@1.3.0", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "function-bind": "^1.1.2", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "math-intrinsics": "^1.1.0" } }, "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ=="], "get-intrinsic": ["get-intrinsic@1.3.0", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "function-bind": "^1.1.2", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "math-intrinsics": "^1.1.0" } }, "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ=="],
"get-nonce": ["get-nonce@1.0.1", "", {}, "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q=="], "get-nonce": ["get-nonce@1.0.1", "", {}, "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q=="],
@ -780,8 +721,6 @@
"iconv-lite": ["iconv-lite@0.4.24", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3" } }, "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA=="], "iconv-lite": ["iconv-lite@0.4.24", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3" } }, "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA=="],
"ieee754": ["ieee754@1.2.1", "", {}, "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="],
"immer": ["immer@10.2.0", "", {}, "sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw=="], "immer": ["immer@10.2.0", "", {}, "sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw=="],
"inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="], "inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="],
@ -792,8 +731,6 @@
"is-docker": ["is-docker@3.0.0", "", { "bin": { "is-docker": "cli.js" } }, "sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ=="], "is-docker": ["is-docker@3.0.0", "", { "bin": { "is-docker": "cli.js" } }, "sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ=="],
"is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="],
"is-in-ssh": ["is-in-ssh@1.0.0", "", {}, "sha512-jYa6Q9rH90kR1vKB6NM7qqd1mge3Fx4Dhw5TVlK1MUBqhEOuCagrEHMevNuCcbECmXZ0ThXkRm+Ymr51HwEPAw=="], "is-in-ssh": ["is-in-ssh@1.0.0", "", {}, "sha512-jYa6Q9rH90kR1vKB6NM7qqd1mge3Fx4Dhw5TVlK1MUBqhEOuCagrEHMevNuCcbECmXZ0ThXkRm+Ymr51HwEPAw=="],
"is-inside-container": ["is-inside-container@1.0.0", "", { "dependencies": { "is-docker": "^3.0.0" }, "bin": { "is-inside-container": "cli.js" } }, "sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA=="], "is-inside-container": ["is-inside-container@1.0.0", "", { "dependencies": { "is-docker": "^3.0.0" }, "bin": { "is-inside-container": "cli.js" } }, "sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA=="],
@ -842,12 +779,8 @@
"lodash": ["lodash@4.17.21", "", {}, "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg=="], "lodash": ["lodash@4.17.21", "", {}, "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg=="],
"lodash.camelcase": ["lodash.camelcase@4.3.0", "", {}, "sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA=="],
"logform": ["logform@2.7.0", "", { "dependencies": { "@colors/colors": "1.6.0", "@types/triple-beam": "^1.3.2", "fecha": "^4.2.0", "ms": "^2.1.1", "safe-stable-stringify": "^2.3.1", "triple-beam": "^1.3.0" } }, "sha512-TFYA4jnP7PVbmlBIfhlSe+WKxs9dklXMTEGcBCIvLhE/Tn3H6Gk1norupVW7m5Cnd4bLcr08AytbyV/xj7f/kQ=="], "logform": ["logform@2.7.0", "", { "dependencies": { "@colors/colors": "1.6.0", "@types/triple-beam": "^1.3.2", "fecha": "^4.2.0", "ms": "^2.1.1", "safe-stable-stringify": "^2.3.1", "triple-beam": "^1.3.0" } }, "sha512-TFYA4jnP7PVbmlBIfhlSe+WKxs9dklXMTEGcBCIvLhE/Tn3H6Gk1norupVW7m5Cnd4bLcr08AytbyV/xj7f/kQ=="],
"long": ["long@5.3.2", "", {}, "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA=="],
"lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="], "lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="],
"lucide-react": ["lucide-react@0.555.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-D8FvHUGbxWBRQM90NZeIyhAvkFfsh3u9ekrMvJ30Z6gnpBHS6HC6ldLg7tL45hwiIz/u66eKDtdA23gwwGsAHA=="], "lucide-react": ["lucide-react@0.555.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-D8FvHUGbxWBRQM90NZeIyhAvkFfsh3u9ekrMvJ30Z6gnpBHS6HC6ldLg7tL45hwiIz/u66eKDtdA23gwwGsAHA=="],
@ -872,14 +805,10 @@
"minimatch": ["minimatch@9.0.5", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow=="], "minimatch": ["minimatch@9.0.5", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow=="],
"mkdirp-classic": ["mkdirp-classic@0.5.3", "", {}, "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A=="],
"morgan": ["morgan@1.10.1", "", { "dependencies": { "basic-auth": "~2.0.1", "debug": "2.6.9", "depd": "~2.0.0", "on-finished": "~2.3.0", "on-headers": "~1.1.0" } }, "sha512-223dMRJtI/l25dJKWpgij2cMtywuG/WiUKXdvwfbhGKBhy1puASqXwFzmWZ7+K73vUPoR7SS2Qz2cI/g9MKw0A=="], "morgan": ["morgan@1.10.1", "", { "dependencies": { "basic-auth": "~2.0.1", "debug": "2.6.9", "depd": "~2.0.0", "on-finished": "~2.3.0", "on-headers": "~1.1.0" } }, "sha512-223dMRJtI/l25dJKWpgij2cMtywuG/WiUKXdvwfbhGKBhy1puASqXwFzmWZ7+K73vUPoR7SS2Qz2cI/g9MKw0A=="],
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
"nan": ["nan@2.23.1", "", {}, "sha512-r7bBUGKzlqk8oPBDYxt6Z0aEdF1G1rwlMcLk8LCOMbOzf0mG+JUfUzG4fIMWwHWP0iyaLWEQZJmtB7nOHEm/qw=="],
"nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="], "nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="],
"negotiator": ["negotiator@0.6.4", "", {}, "sha512-myRT3DiWPHqho5PrJaIRyaMv2kgYf0mUVgBNOYMuCH5Ki1yEiQaf/ZJuQ62nvpc44wL5WDbTX7yGJi1Neevw8w=="], "negotiator": ["negotiator@0.6.4", "", {}, "sha512-myRT3DiWPHqho5PrJaIRyaMv2kgYf0mUVgBNOYMuCH5Ki1yEiQaf/ZJuQ62nvpc44wL5WDbTX7yGJi1Neevw8w=="],
@ -902,8 +831,6 @@
"on-headers": ["on-headers@1.1.0", "", {}, "sha512-737ZY3yNnXy37FHkQxPzt4UZ2UWPWiCZWLvFZ4fu5cueciegX0zGPnrlY6bwRg4FdQOe9YU8MkmJwGhoMybl8A=="], "on-headers": ["on-headers@1.1.0", "", {}, "sha512-737ZY3yNnXy37FHkQxPzt4UZ2UWPWiCZWLvFZ4fu5cueciegX0zGPnrlY6bwRg4FdQOe9YU8MkmJwGhoMybl8A=="],
"once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="],
"one-time": ["one-time@1.0.0", "", { "dependencies": { "fn.name": "1.x.x" } }, "sha512-5DXOiRKwuSEcQ/l0kGCF6Q3jcADFv5tSmRaJck/OqkVFcOzutB134KRSfF0xDrL39MNnqxbHBbUUcjZIhTgb2g=="], "one-time": ["one-time@1.0.0", "", { "dependencies": { "fn.name": "1.x.x" } }, "sha512-5DXOiRKwuSEcQ/l0kGCF6Q3jcADFv5tSmRaJck/OqkVFcOzutB134KRSfF0xDrL39MNnqxbHBbUUcjZIhTgb2g=="],
"open": ["open@11.0.0", "", { "dependencies": { "default-browser": "^5.4.0", "define-lazy-prop": "^3.0.0", "is-in-ssh": "^1.0.0", "is-inside-container": "^1.0.0", "powershell-utils": "^0.1.0", "wsl-utils": "^0.3.0" } }, "sha512-smsWv2LzFjP03xmvFoJ331ss6h+jixfA4UUV/Bsiyuu4YJPfN+FIQGOIiv4w9/+MoHkfkJ22UIaQWRVFRfH6Vw=="], "open": ["open@11.0.0", "", { "dependencies": { "default-browser": "^5.4.0", "define-lazy-prop": "^3.0.0", "is-in-ssh": "^1.0.0", "is-inside-container": "^1.0.0", "powershell-utils": "^0.1.0", "wsl-utils": "^0.3.0" } }, "sha512-smsWv2LzFjP03xmvFoJ331ss6h+jixfA4UUV/Bsiyuu4YJPfN+FIQGOIiv4w9/+MoHkfkJ22UIaQWRVFRfH6Vw=="],
@ -932,12 +859,8 @@
"prettier": ["prettier@3.6.2", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ=="], "prettier": ["prettier@3.6.2", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ=="],
"protobufjs": ["protobufjs@7.5.4", "", { "dependencies": { "@protobufjs/aspromise": "^1.1.2", "@protobufjs/base64": "^1.1.2", "@protobufjs/codegen": "^2.0.4", "@protobufjs/eventemitter": "^1.1.0", "@protobufjs/fetch": "^1.1.0", "@protobufjs/float": "^1.0.2", "@protobufjs/inquire": "^1.1.0", "@protobufjs/path": "^1.1.2", "@protobufjs/pool": "^1.1.0", "@protobufjs/utf8": "^1.1.0", "@types/node": ">=13.7.0", "long": "^5.0.0" } }, "sha512-CvexbZtbov6jW2eXAvLukXjXUW1TzFaivC46BpWc/3BpcCysb5Vffu+B3XHMm8lVEuy2Mm4XGex8hBSg1yapPg=="],
"proxy-addr": ["proxy-addr@2.0.7", "", { "dependencies": { "forwarded": "0.2.0", "ipaddr.js": "1.9.1" } }, "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg=="], "proxy-addr": ["proxy-addr@2.0.7", "", { "dependencies": { "forwarded": "0.2.0", "ipaddr.js": "1.9.1" } }, "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg=="],
"pump": ["pump@3.0.3", "", { "dependencies": { "end-of-stream": "^1.1.0", "once": "^1.3.1" } }, "sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA=="],
"qs": ["qs@6.13.0", "", { "dependencies": { "side-channel": "^1.0.6" } }, "sha512-+38qI9SOr8tfZ4QmJNplMUxqjbe7LKvvZgWdExBOmd+egZTtjLB67Gu0HRX3u/XOq7UU2Nx6nsjvS16Z9uwfpg=="], "qs": ["qs@6.13.0", "", { "dependencies": { "side-channel": "^1.0.6" } }, "sha512-+38qI9SOr8tfZ4QmJNplMUxqjbe7LKvvZgWdExBOmd+egZTtjLB67Gu0HRX3u/XOq7UU2Nx6nsjvS16Z9uwfpg=="],
"quansync": ["quansync@0.2.11", "", {}, "sha512-AifT7QEbW9Nri4tAwR5M/uzpBuqfZf+zwaEM/QkzEjj7NBuFD2rBuy0K3dE+8wltbezDV7JMA0WfnCPYRSYbXA=="], "quansync": ["quansync@0.2.11", "", {}, "sha512-AifT7QEbW9Nri4tAwR5M/uzpBuqfZf+zwaEM/QkzEjj7NBuFD2rBuy0K3dE+8wltbezDV7JMA0WfnCPYRSYbXA=="],
@ -980,8 +903,6 @@
"redux-thunk": ["redux-thunk@3.1.0", "", { "peerDependencies": { "redux": "^5.0.0" } }, "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw=="], "redux-thunk": ["redux-thunk@3.1.0", "", { "peerDependencies": { "redux": "^5.0.0" } }, "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw=="],
"require-directory": ["require-directory@2.1.1", "", {}, "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q=="],
"reselect": ["reselect@5.1.1", "", {}, "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w=="], "reselect": ["reselect@5.1.1", "", {}, "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w=="],
"resolve-pkg-maps": ["resolve-pkg-maps@1.0.0", "", {}, "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw=="], "resolve-pkg-maps": ["resolve-pkg-maps@1.0.0", "", {}, "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw=="],
@ -1026,20 +947,12 @@
"source-map-support": ["source-map-support@0.5.21", "", { "dependencies": { "buffer-from": "^1.0.0", "source-map": "^0.6.0" } }, "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w=="], "source-map-support": ["source-map-support@0.5.21", "", { "dependencies": { "buffer-from": "^1.0.0", "source-map": "^0.6.0" } }, "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w=="],
"split-ca": ["split-ca@1.0.1", "", {}, "sha512-Q5thBSxp5t8WPTTJQS59LrGqOZqOsrhDGDVm8azCqIBjSBd7nd9o2PM+mDulQQkh8h//4U6hFZnc/mul8t5pWQ=="],
"ssh2": ["ssh2@1.17.0", "", { "dependencies": { "asn1": "^0.2.6", "bcrypt-pbkdf": "^1.0.2" }, "optionalDependencies": { "cpu-features": "~0.0.10", "nan": "^2.23.0" } }, "sha512-wPldCk3asibAjQ/kziWQQt1Wh3PgDFpC0XpwclzKcdT1vql6KeYxf5LIt4nlFkUeR8WuphYMKqUA56X4rjbfgQ=="],
"stack-trace": ["stack-trace@0.0.10", "", {}, "sha512-KGzahc7puUKkzyMt+IqAep+TVNbKP+k2Lmwhub39m1AsTSkaDutx56aDCo+HLDzf/D26BIHTJWNiTG1KAJiQCg=="], "stack-trace": ["stack-trace@0.0.10", "", {}, "sha512-KGzahc7puUKkzyMt+IqAep+TVNbKP+k2Lmwhub39m1AsTSkaDutx56aDCo+HLDzf/D26BIHTJWNiTG1KAJiQCg=="],
"statuses": ["statuses@2.0.1", "", {}, "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ=="], "statuses": ["statuses@2.0.1", "", {}, "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ=="],
"string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="],
"string_decoder": ["string_decoder@1.3.0", "", { "dependencies": { "safe-buffer": "~5.2.0" } }, "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA=="], "string_decoder": ["string_decoder@1.3.0", "", { "dependencies": { "safe-buffer": "~5.2.0" } }, "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA=="],
"strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
"tagged-tag": ["tagged-tag@1.0.0", "", {}, "sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng=="], "tagged-tag": ["tagged-tag@1.0.0", "", {}, "sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng=="],
"tailwind-merge": ["tailwind-merge@3.4.0", "", {}, "sha512-uSaO4gnW+b3Y2aWoWfFpX62vn2sR3skfhbjsEnaBI81WD1wBLlHZe5sWf0AqjksNdYTbGBEd0UasQMT3SNV15g=="], "tailwind-merge": ["tailwind-merge@3.4.0", "", {}, "sha512-uSaO4gnW+b3Y2aWoWfFpX62vn2sR3skfhbjsEnaBI81WD1wBLlHZe5sWf0AqjksNdYTbGBEd0UasQMT3SNV15g=="],
@ -1048,10 +961,6 @@
"tapable": ["tapable@2.3.0", "", {}, "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg=="], "tapable": ["tapable@2.3.0", "", {}, "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg=="],
"tar-fs": ["tar-fs@2.1.4", "", { "dependencies": { "chownr": "^1.1.1", "mkdirp-classic": "^0.5.2", "pump": "^3.0.0", "tar-stream": "^2.1.4" } }, "sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ=="],
"tar-stream": ["tar-stream@2.2.0", "", { "dependencies": { "bl": "^4.0.3", "end-of-stream": "^1.4.1", "fs-constants": "^1.0.0", "inherits": "^2.0.3", "readable-stream": "^3.1.1" } }, "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ=="],
"text-hex": ["text-hex@1.0.0", "", {}, "sha512-uuVGNWzgJ4yhRaNSiubPY7OjISw4sw4E5Uv0wbjp+OzcbmVU/rsT8ujgcXJhn9ypzsgr5vlzpPqP+MBBKcGvbg=="], "text-hex": ["text-hex@1.0.0", "", {}, "sha512-uuVGNWzgJ4yhRaNSiubPY7OjISw4sw4E5Uv0wbjp+OzcbmVU/rsT8ujgcXJhn9ypzsgr5vlzpPqP+MBBKcGvbg=="],
"tiny-invariant": ["tiny-invariant@1.3.3", "", {}, "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg=="], "tiny-invariant": ["tiny-invariant@1.3.3", "", {}, "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg=="],
@ -1072,8 +981,6 @@
"tw-animate-css": ["tw-animate-css@1.4.0", "", {}, "sha512-7bziOlRqH0hJx80h/3mbicLW7o8qLsH5+RaLR2t+OHM3D0JlWGODQKQ4cxbK7WlvmUxpcj6Kgu6EKqjrGFe3QQ=="], "tw-animate-css": ["tw-animate-css@1.4.0", "", {}, "sha512-7bziOlRqH0hJx80h/3mbicLW7o8qLsH5+RaLR2t+OHM3D0JlWGODQKQ4cxbK7WlvmUxpcj6Kgu6EKqjrGFe3QQ=="],
"tweetnacl": ["tweetnacl@0.14.5", "", {}, "sha512-KXXFFdAbFXY4geFIwoyNK+f5Z1b7swfXABfL7HXCmoIWMKU3dmS26672A4EeQtDzLKy7SXmfBu51JolvEKwtGA=="],
"type-fest": ["type-fest@5.0.0", "", { "dependencies": { "tagged-tag": "^1.0.0" } }, "sha512-GeJop7+u7BYlQ6yQCAY1nBQiRSHR+6OdCEtd8Bwp9a3NK3+fWAVjOaPKJDteB9f6cIJ0wt4IfnScjLG450EpXA=="], "type-fest": ["type-fest@5.0.0", "", { "dependencies": { "tagged-tag": "^1.0.0" } }, "sha512-GeJop7+u7BYlQ6yQCAY1nBQiRSHR+6OdCEtd8Bwp9a3NK3+fWAVjOaPKJDteB9f6cIJ0wt4IfnScjLG450EpXA=="],
"type-is": ["type-is@1.6.18", "", { "dependencies": { "media-typer": "0.3.0", "mime-types": "~2.1.24" } }, "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g=="], "type-is": ["type-is@1.6.18", "", { "dependencies": { "media-typer": "0.3.0", "mime-types": "~2.1.24" } }, "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g=="],
@ -1096,8 +1003,6 @@
"utils-merge": ["utils-merge@1.0.1", "", {}, "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA=="], "utils-merge": ["utils-merge@1.0.1", "", {}, "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA=="],
"uuid": ["uuid@10.0.0", "", { "bin": { "uuid": "dist/bin/uuid" } }, "sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ=="],
"valibot": ["valibot@1.1.0", "", { "peerDependencies": { "typescript": ">=5" }, "optionalPeers": ["typescript"] }, "sha512-Nk8lX30Qhu+9txPYTwM0cFlWLdPFsFr6LblzqIySfbZph9+BFsAHsNvHOymEviUepeIW6KFHzpX8TKhbptBXXw=="], "valibot": ["valibot@1.1.0", "", { "peerDependencies": { "typescript": ">=5" }, "optionalPeers": ["typescript"] }, "sha512-Nk8lX30Qhu+9txPYTwM0cFlWLdPFsFr6LblzqIySfbZph9+BFsAHsNvHOymEviUepeIW6KFHzpX8TKhbptBXXw=="],
"vary": ["vary@1.1.2", "", {}, "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg=="], "vary": ["vary@1.1.2", "", {}, "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg=="],
@ -1116,24 +1021,14 @@
"winston-transport": ["winston-transport@4.9.0", "", { "dependencies": { "logform": "^2.7.0", "readable-stream": "^3.6.2", "triple-beam": "^1.3.0" } }, "sha512-8drMJ4rkgaPo1Me4zD/3WLfI/zPdA9o2IipKODunnGDcuqbHwjsbB79ylv04LCGGzU0xQ6vTznOMpQGaLhhm6A=="], "winston-transport": ["winston-transport@4.9.0", "", { "dependencies": { "logform": "^2.7.0", "readable-stream": "^3.6.2", "triple-beam": "^1.3.0" } }, "sha512-8drMJ4rkgaPo1Me4zD/3WLfI/zPdA9o2IipKODunnGDcuqbHwjsbB79ylv04LCGGzU0xQ6vTznOMpQGaLhhm6A=="],
"wrap-ansi": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="],
"wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="],
"ws": ["ws@8.18.3", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg=="], "ws": ["ws@8.18.3", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg=="],
"wsl-utils": ["wsl-utils@0.3.0", "", { "dependencies": { "is-wsl": "^3.1.0", "powershell-utils": "^0.1.0" } }, "sha512-3sFIGLiaDP7rTO4xh3g+b3AzhYDIUGGywE/WsmqzJWDxus5aJXVnPTNC/6L+r2WzrwXqVOdD262OaO+cEyPMSQ=="], "wsl-utils": ["wsl-utils@0.3.0", "", { "dependencies": { "is-wsl": "^3.1.0", "powershell-utils": "^0.1.0" } }, "sha512-3sFIGLiaDP7rTO4xh3g+b3AzhYDIUGGywE/WsmqzJWDxus5aJXVnPTNC/6L+r2WzrwXqVOdD262OaO+cEyPMSQ=="],
"y18n": ["y18n@5.0.8", "", {}, "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA=="],
"yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="], "yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="],
"yaml": ["yaml@2.8.2", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A=="], "yaml": ["yaml@2.8.2", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A=="],
"yargs": ["yargs@17.7.2", "", { "dependencies": { "cliui": "^8.0.1", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", "require-directory": "^2.1.1", "string-width": "^4.2.3", "y18n": "^5.0.5", "yargs-parser": "^21.1.1" } }, "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w=="],
"yargs-parser": ["yargs-parser@21.1.1", "", {}, "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw=="],
"zod": ["zod@4.1.11", "", {}, "sha512-WPsqwxITS2tzx1bzhIKsEs19ABD5vmCVa4xBo2tq/SrV4RNZtfws1EnCWQXM6yh8bD08a1idvkB5MZSBiZsjwg=="], "zod": ["zod@4.1.11", "", {}, "sha512-WPsqwxITS2tzx1bzhIKsEs19ABD5vmCVa4xBo2tq/SrV4RNZtfws1EnCWQXM6yh8bD08a1idvkB5MZSBiZsjwg=="],
"@babel/core/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], "@babel/core/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
@ -1144,8 +1039,6 @@
"@esbuild-kit/core-utils/esbuild": ["esbuild@0.18.20", "", { "optionalDependencies": { "@esbuild/android-arm": "0.18.20", "@esbuild/android-arm64": "0.18.20", "@esbuild/android-x64": "0.18.20", "@esbuild/darwin-arm64": "0.18.20", "@esbuild/darwin-x64": "0.18.20", "@esbuild/freebsd-arm64": "0.18.20", "@esbuild/freebsd-x64": "0.18.20", "@esbuild/linux-arm": "0.18.20", "@esbuild/linux-arm64": "0.18.20", "@esbuild/linux-ia32": "0.18.20", "@esbuild/linux-loong64": "0.18.20", "@esbuild/linux-mips64el": "0.18.20", "@esbuild/linux-ppc64": "0.18.20", "@esbuild/linux-riscv64": "0.18.20", "@esbuild/linux-s390x": "0.18.20", "@esbuild/linux-x64": "0.18.20", "@esbuild/netbsd-x64": "0.18.20", "@esbuild/openbsd-x64": "0.18.20", "@esbuild/sunos-x64": "0.18.20", "@esbuild/win32-arm64": "0.18.20", "@esbuild/win32-ia32": "0.18.20", "@esbuild/win32-x64": "0.18.20" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA=="], "@esbuild-kit/core-utils/esbuild": ["esbuild@0.18.20", "", { "optionalDependencies": { "@esbuild/android-arm": "0.18.20", "@esbuild/android-arm64": "0.18.20", "@esbuild/android-x64": "0.18.20", "@esbuild/darwin-arm64": "0.18.20", "@esbuild/darwin-x64": "0.18.20", "@esbuild/freebsd-arm64": "0.18.20", "@esbuild/freebsd-x64": "0.18.20", "@esbuild/linux-arm": "0.18.20", "@esbuild/linux-arm64": "0.18.20", "@esbuild/linux-ia32": "0.18.20", "@esbuild/linux-loong64": "0.18.20", "@esbuild/linux-mips64el": "0.18.20", "@esbuild/linux-ppc64": "0.18.20", "@esbuild/linux-riscv64": "0.18.20", "@esbuild/linux-s390x": "0.18.20", "@esbuild/linux-x64": "0.18.20", "@esbuild/netbsd-x64": "0.18.20", "@esbuild/openbsd-x64": "0.18.20", "@esbuild/sunos-x64": "0.18.20", "@esbuild/win32-arm64": "0.18.20", "@esbuild/win32-ia32": "0.18.20", "@esbuild/win32-x64": "0.18.20" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA=="],
"@grpc/grpc-js/@grpc/proto-loader": ["@grpc/proto-loader@0.8.0", "", { "dependencies": { "lodash.camelcase": "^4.3.0", "long": "^5.0.0", "protobufjs": "^7.5.3", "yargs": "^17.7.2" }, "bin": { "proto-loader-gen-types": "build/bin/proto-loader-gen-types.js" } }, "sha512-rc1hOQtjIWGxcxpb9aHAfLpIctjEnsDehj0DAiVfBlmT84uvR0uUtN2hEi/ecvWVjXUGf5qPF4qEgiLOx1YIMQ=="],
"@radix-ui/react-alert-dialog/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], "@radix-ui/react-alert-dialog/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
"@radix-ui/react-collection/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], "@radix-ui/react-collection/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
@ -1182,12 +1075,8 @@
"@tailwindcss/oxide-wasm32-wasi/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], "@tailwindcss/oxide-wasm32-wasi/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
"@types/ssh2/@types/node": ["@types/node@18.19.130", "", { "dependencies": { "undici-types": "~5.26.4" } }, "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg=="],
"accepts/negotiator": ["negotiator@0.6.3", "", {}, "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg=="], "accepts/negotiator": ["negotiator@0.6.3", "", {}, "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg=="],
"ansi-styles/color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="],
"basic-auth/safe-buffer": ["safe-buffer@5.1.2", "", {}, "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g=="], "basic-auth/safe-buffer": ["safe-buffer@5.1.2", "", {}, "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g=="],
"body-parser/debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="], "body-parser/debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="],
@ -1268,10 +1157,6 @@
"@esbuild-kit/core-utils/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.18.20", "", { "os": "win32", "cpu": "x64" }, "sha512-kTdfRcSiDfQca/y9QIkng02avJ+NCaQvrMejlsB3RRv5sE9rRoeBPISaZpKxHELzRxZyLvNts1P27W3wV+8geQ=="], "@esbuild-kit/core-utils/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.18.20", "", { "os": "win32", "cpu": "x64" }, "sha512-kTdfRcSiDfQca/y9QIkng02avJ+NCaQvrMejlsB3RRv5sE9rRoeBPISaZpKxHELzRxZyLvNts1P27W3wV+8geQ=="],
"@types/ssh2/@types/node/undici-types": ["undici-types@5.26.5", "", {}, "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA=="],
"ansi-styles/color-convert/color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="],
"body-parser/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="], "body-parser/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="],
"compression/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="], "compression/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="],

View file

@ -12,8 +12,11 @@ services:
- SYS_ADMIN - SYS_ADMIN
environment: environment:
- NODE_ENV=development - NODE_ENV=development
# - SMB_PASSWORD=secret
ports: ports:
- "4096:4096" - "4096:4096"
# secrets:
# - smb-password
volumes: volumes:
- /etc/localtime:/etc/localtime:ro - /etc/localtime:/etc/localtime:ro
- /var/lib/zerobyte:/var/lib/zerobyte - /var/lib/zerobyte:/var/lib/zerobyte
@ -68,3 +71,7 @@ services:
- /run/docker/plugins:/run/docker/plugins - /run/docker/plugins:/run/docker/plugins
- /var/run/docker.sock:/var/run/docker.sock - /var/run/docker.sock:/var/run/docker.sock
- ~/.config/rclone:/root/.config/rclone - ~/.config/rclone:/root/.config/rclone
# secrets:
# smb-password:
# file: ./smb-password.txt

View file

@ -1,7 +1,8 @@
import { defaultPlugins, defineConfig } from "@hey-api/openapi-ts"; import { defaultPlugins, defineConfig } from "@hey-api/openapi-ts";
import { config } from "./app/server/core/config.js";
export default defineConfig({ export default defineConfig({
input: "http://192.168.2.42:4096/api/v1/openapi.json", input: `http://${config.serverIp}:4096/api/v1/openapi.json`,
output: { output: {
path: "./app/client/api-client", path: "./app/client/api-client",
format: "biome", format: "biome",

View file

@ -9,7 +9,7 @@
"start": "bun ./dist/server/index.js", "start": "bun ./dist/server/index.js",
"tsc": "react-router typegen && tsc", "tsc": "react-router typegen && tsc",
"lint": "biome check .", "lint": "biome check .",
"lint:ci": "biome check . --ci", "lint:ci": "biome ci . --changed --error-on-warnings --no-errors-on-unmatched",
"start:dev": "docker compose down && docker compose up --build zerobyte-dev", "start:dev": "docker compose down && docker compose up --build zerobyte-dev",
"start:prod": "docker compose down && docker compose up --build zerobyte-prod", "start:prod": "docker compose down && docker compose up --build zerobyte-prod",
"gen:api-client": "openapi-ts", "gen:api-client": "openapi-ts",
@ -17,6 +17,9 @@
"studio": "drizzle-kit studio" "studio": "drizzle-kit studio"
}, },
"dependencies": { "dependencies": {
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@hono/standard-validator": "^0.2.0", "@hono/standard-validator": "^0.2.0",
"@hookform/resolvers": "^5.2.2", "@hookform/resolvers": "^5.2.2",
"@radix-ui/react-alert-dialog": "^1.1.15", "@radix-ui/react-alert-dialog": "^1.1.15",
@ -41,7 +44,6 @@
"cron-parser": "^5.4.0", "cron-parser": "^5.4.0",
"date-fns": "^4.1.0", "date-fns": "^4.1.0",
"dither-plugin": "^1.1.1", "dither-plugin": "^1.1.1",
"dockerode": "^4.0.9",
"dotenv": "^17.2.3", "dotenv": "^17.2.3",
"drizzle-orm": "^0.44.7", "drizzle-orm": "^0.44.7",
"es-toolkit": "^1.42.0", "es-toolkit": "^1.42.0",
@ -72,7 +74,6 @@
"@tailwindcss/vite": "^4.1.17", "@tailwindcss/vite": "^4.1.17",
"@tanstack/react-query-devtools": "^5.91.1", "@tanstack/react-query-devtools": "^5.91.1",
"@types/bun": "^1.3.3", "@types/bun": "^1.3.3",
"@types/dockerode": "^3.3.47",
"@types/node": "^24.10.1", "@types/node": "^24.10.1",
"@types/react": "^19.2.7", "@types/react": "^19.2.7",
"@types/react-dom": "^19.2.3", "@types/react-dom": "^19.2.3",