Compare commits

...

27 commits

Author SHA1 Message Date
Nicolas Meienberger
bb6bbcfd74 feat(export): add user password env placeholder 2026-01-04 18:36:01 +01:00
Nicolas Meienberger
1d49f59ebc refactor(config-export): do not include user's password hashes 2026-01-04 17:14:28 +01:00
Nicolas Meienberger
99ccfa4305 chore(openapi): remove error responses from schema 2026-01-04 16:29:53 +01:00
Nicolas Meienberger
64b7b93831 chore: fix linting issue 2026-01-04 16:24:18 +01:00
Nicolas Meienberger
3da653d5ad fix(export-config): missing call to service 2026-01-04 15:42:44 +01:00
Nicolas Meienberger
3f550678db refactor: put export endpoint in system controller 2026-01-04 15:02:47 +01:00
Nicolas Meienberger
081a10afeb refactor: simplify export process to minimal working concept 2026-01-04 14:18:10 +01:00
Nicolas Meienberger
20c3b8156d Merge remote-tracking branch 'upstream/main' into config-export-feature 2026-01-04 12:54:19 +01:00
Nicolas Meienberger
f67ca4eed2 style(delete notification): remove icon in delete button 2026-01-04 10:30:36 +01:00
Nico
d7bcf4c9e2
feat: display updates and release notes (#290)
* feat: display notification when new release is available

* refactor: standalone re-usable cache util

* refactor: clear cache on server startup

* refactor: add timeout to gh release fetch

* fix: run with app version env variable
2026-01-04 10:19:23 +01:00
Nicolas Meienberger
80acbbabe1 docs: update version in readme 2026-01-04 07:31:40 +01:00
Nicolas Meienberger
1fff5f53c5 chore: fix migration version
Some checks failed
Release Workflow / checks (push) Has been cancelled
Release Workflow / determine-release-type (push) Has been cancelled
Release Workflow / build-images (push) Has been cancelled
Release Workflow / publish-release (push) Has been cancelled
2026-01-03 00:26:57 +01:00
Nicolas Meienberger
3eb07ef924 refactor: allow the server to start, even in case of migration failure
Some checks failed
Release Workflow / checks (push) Has been cancelled
Release Workflow / determine-release-type (push) Has been cancelled
Release Workflow / build-images (push) Has been cancelled
Release Workflow / publish-release (push) Has been cancelled
2026-01-03 00:16:42 +01:00
Nico
b0a1e4b3d7
fix: also migrate tag for schedule mirrors (#283)
Some checks failed
Release Workflow / determine-release-type (push) Has been cancelled
Release Workflow / checks (push) Has been cancelled
Release Workflow / build-images (push) Has been cancelled
Release Workflow / publish-release (push) Has been cancelled
2026-01-02 23:07:14 +01:00
Nico
1b88fa6bd6
style: responsive fixes (#282)
Some checks failed
Release Workflow / determine-release-type (push) Has been cancelled
Release Workflow / checks (push) Has been cancelled
Release Workflow / build-images (push) Has been cancelled
Release Workflow / publish-release (push) Has been cancelled
* style(backups): use container queries to avoid overflow when menu is open

* style: fix breadcrumbs breaking the layout on smaller devices

* style: improve wording and error displays

* chore: wording
2026-01-02 22:10:27 +01:00
Nico
b8fe2a985b
feat: insecure tls & cacert for self-hosted repos (#277)
* feat: insecure tls & cacert for self-hosted repos

* fix: extra arg
2026-01-02 18:13:19 +01:00
Nicolas Meienberger
08e43e1f07 Merge branch 'tvories-feat/rest-tls-support' 2026-01-02 17:37:56 +01:00
Nicolas Meienberger
e0027b7668 refactor: code style and api client 2026-01-02 17:37:38 +01:00
tvories
08a19ed38d fix: PR suggestions 2026-01-02 17:05:43 +01:00
tvories
1a12436195 fix: add property existence checks before accessing repository config fields 2026-01-02 17:05:43 +01:00
tvories
e78af76beb feat: added additional validation, moved field location, made skip tls and ca certificate mutually exclusive with tooltips 2026-01-02 17:05:43 +01:00
tvories
07e68b9fe7 feat: add support for a cacert configuration as well as allowing the user to skip tls validation 2026-01-02 17:05:43 +01:00
Nico
2c786dfb35
feat: snapshots bulk actions (#256)
* feat: delete snapshots in bulk

* feat: re-tag snapshots in bulk

* chore: pr feedbacks
2026-01-02 17:04:06 +01:00
Nico
013fe5a521
Fix/file tree selection parents (#276)
* fix(file-tree): propagate path simplification to parents

When all childs of a folder are selected in the tree, the selected path
was only simplifiyng one level. But if the reduction was also making its
own parent "fully selected" it was not correctly reporting only the
parent as selected

* test(file-tree): ensure path selection correctness

* fix(file-tree): cleanup based on given path not what's visible
2026-01-02 16:56:13 +01:00
Jakub Trávník
f836e2f9df
fix(backups): allow removing selected paths that no longer exist on filesystem (#272)
Add X button to selected paths list in backup schedule form. When files/folders
are selected for backup but later deleted from the volume, restic fails because
`--files-from` references non-existent paths. Previously, users couldn't remove
these stale paths since they don't appear in the file browser to uncheck.

The X button enables direct removal of any selected path, fixing stuck backup
configurations that would always fail with "file not found" errors.
2026-01-02 15:33:25 +01:00
Nicolas Meienberger
5d7c6bd543 fix: cli not running with prod image 2026-01-02 15:22:44 +01:00
Nico
658c42a53b
feat: add generic webhook form & refactor each type in its own form (#239)
* feat: add generic webhook form & refactor each type in its own form

* chore: pr feedbacks

* fix(email-form): filter out empty emails
2026-01-02 14:56:35 +01:00
80 changed files with 3298 additions and 1406 deletions

View file

@ -45,6 +45,8 @@ RUN tar -xzf shoutrrr.tar.gz && chmod +x shoutrrr
# ------------------------------
FROM base AS development
ARG APP_VERSION=dev
ENV APP_VERSION=${APP_VERSION}
ENV NODE_ENV="development"
WORKDIR /app
@ -84,6 +86,8 @@ RUN bun run build
FROM base AS production
ARG APP_VERSION=dev
ENV APP_VERSION=${APP_VERSION}
ENV NODE_ENV="production"
WORKDIR /app

View file

@ -40,7 +40,7 @@ In order to run Zerobyte, you need to have Docker and Docker Compose installed o
```yaml
services:
zerobyte:
image: ghcr.io/nicotsx/zerobyte:v0.19
image: ghcr.io/nicotsx/zerobyte:v0.21
container_name: zerobyte
restart: unless-stopped
cap_add:
@ -50,7 +50,7 @@ services:
devices:
- /dev/fuse:/dev/fuse
environment:
- TZ=Europe/Paris # Set your timezone here
- TZ=Europe/Paris # Set your timezone here
volumes:
- /etc/localtime:/etc/localtime:ro
- /var/lib/zerobyte:/var/lib/zerobyte
@ -77,13 +77,13 @@ If you only need to back up locally mounted folders and don't require remote sha
```yaml
services:
zerobyte:
image: ghcr.io/nicotsx/zerobyte:v0.19
image: ghcr.io/nicotsx/zerobyte:v0.21
container_name: zerobyte
restart: unless-stopped
ports:
- "4096:4096"
environment:
- TZ=Europe/Paris # Set your timezone here
- TZ=Europe/Paris # Set your timezone here
volumes:
- /etc/localtime:/etc/localtime:ro
- /var/lib/zerobyte:/var/lib/zerobyte
@ -91,6 +91,7 @@ services:
```
**Trade-offs:**
- ✅ Improved security by reducing container capabilities
- ✅ Support for local directories
- ✅ Keep support all repository types (local, S3, GCS, Azure, rclone)
@ -113,7 +114,7 @@ If you want to track a local directory on the same server where Zerobyte is runn
```diff
services:
zerobyte:
image: ghcr.io/nicotsx/zerobyte:v0.19
image: ghcr.io/nicotsx/zerobyte:v0.21
container_name: zerobyte
restart: unless-stopped
cap_add:
@ -186,7 +187,7 @@ Zerobyte can use [rclone](https://rclone.org/) to support 40+ cloud storage prov
```diff
services:
zerobyte:
image: ghcr.io/nicotsx/zerobyte:v0.19
image: ghcr.io/nicotsx/zerobyte:v0.21
container_name: zerobyte
restart: unless-stopped
cap_add:
@ -238,26 +239,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)
## Exporting configuration
Zerobyte allows you to export your configuration for backup, migration, or documentation purposes.
To export, click the "Export" button in Settings. A dialog will appear with options to:
- **Include metadata** - Include IDs, timestamps, and runtime state of entities
- **Secrets handling**:
- **Exclude** - Remove sensitive fields like passwords and API keys
- **Keep encrypted** - Export secrets in encrypted form (requires the same recovery key to decrypt on import)
- **Decrypt** - Export secrets as plaintext (use with caution)
- **Include recovery key** - Include the master encryption key for all repositories
- **Include password hash** - Include the hashed user passwords (enables future import workflows)
Export requires password verification for security. You must enter your password to confirm your identity before any configuration can be exported.
Export is downloaded as JSON file that can be used for reference or future import functionality.
> **Sensitive data handling**: Some sensitive data from earlier versions may not be encrypted in the database. Additionally, nested configuration objects within config fields are exported as-is and not processed separately. Review exported data carefully before sharing, especially when using the "Decrypt" secrets option.
## Third-Party Software
This project includes the following third-party software components:

View file

@ -1,4 +1,5 @@
@import "tailwindcss";
@plugin "@tailwindcss/typography";
@import "tw-animate-css";
@import "dither-plugin";

View file

@ -3,8 +3,8 @@
import { type DefaultError, queryOptions, type UseMutationOptions } from '@tanstack/react-query';
import { client } from '../client.gen';
import { browseFilesystem, changePassword, createBackupSchedule, createNotificationDestination, createRepository, createVolume, deleteBackupSchedule, deleteNotificationDestination, deleteRepository, deleteSnapshot, deleteVolume, doctorRepository, downloadResticPassword, exportFullConfig, 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, ExportFullConfigData, ExportFullConfigError, ExportFullConfigResponse, 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';
import { browseFilesystem, changePassword, createBackupSchedule, createNotificationDestination, createRepository, createVolume, deleteBackupSchedule, deleteNotificationDestination, deleteRepository, deleteSnapshot, deleteSnapshots, deleteVolume, doctorRepository, downloadResticPassword, exportFullConfig, getBackupSchedule, getBackupScheduleForVolume, getMe, getMirrorCompatibility, getNotificationDestination, getRepository, getScheduleMirrors, getScheduleNotifications, getSnapshotDetails, getStatus, getSystemInfo, getUpdates, getVolume, healthCheckVolume, listBackupSchedules, listFiles, listNotificationDestinations, listRcloneRemotes, listRepositories, listSnapshotFiles, listSnapshots, listVolumes, login, logout, mountVolume, type Options, register, reorderBackupSchedules, restoreSnapshot, runBackupNow, runForget, stopBackup, tagSnapshots, 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, DeleteSnapshotsData, DeleteSnapshotsResponse, DeleteVolumeData, DeleteVolumeResponse, DoctorRepositoryData, DoctorRepositoryResponse, DownloadResticPasswordData, DownloadResticPasswordResponse, ExportFullConfigData, ExportFullConfigResponse, GetBackupScheduleData, GetBackupScheduleForVolumeData, GetBackupScheduleForVolumeResponse, GetBackupScheduleResponse, GetMeData, GetMeResponse, GetMirrorCompatibilityData, GetMirrorCompatibilityResponse, GetNotificationDestinationData, GetNotificationDestinationResponse, GetRepositoryData, GetRepositoryResponse, GetScheduleMirrorsData, GetScheduleMirrorsResponse, GetScheduleNotificationsData, GetScheduleNotificationsResponse, GetSnapshotDetailsData, GetSnapshotDetailsResponse, GetStatusData, GetStatusResponse, GetSystemInfoData, GetSystemInfoResponse, GetUpdatesData, GetUpdatesResponse, 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, TagSnapshotsData, TagSnapshotsResponse, 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
@ -439,6 +439,23 @@ export const updateRepositoryMutation = (options?: Partial<Options<UpdateReposit
return mutationOptions;
};
/**
* Delete multiple snapshots from a repository
*/
export const deleteSnapshotsMutation = (options?: Partial<Options<DeleteSnapshotsData>>): UseMutationOptions<DeleteSnapshotsResponse, DefaultError, Options<DeleteSnapshotsData>> => {
const mutationOptions: UseMutationOptions<DeleteSnapshotsResponse, DefaultError, Options<DeleteSnapshotsData>> = {
mutationFn: async (fnOptions) => {
const { data } = await deleteSnapshots({
...options,
...fnOptions,
throwOnError: true
});
return data;
}
};
return mutationOptions;
};
export const listSnapshotsQueryKey = (options: Options<ListSnapshotsData>) => createQueryKey('listSnapshots', options);
/**
@ -544,6 +561,23 @@ export const doctorRepositoryMutation = (options?: Partial<Options<DoctorReposit
return mutationOptions;
};
/**
* Tag multiple snapshots in a repository
*/
export const tagSnapshotsMutation = (options?: Partial<Options<TagSnapshotsData>>): UseMutationOptions<TagSnapshotsResponse, DefaultError, Options<TagSnapshotsData>> => {
const mutationOptions: UseMutationOptions<TagSnapshotsResponse, DefaultError, Options<TagSnapshotsData>> = {
mutationFn: async (fnOptions) => {
const { data } = await tagSnapshots({
...options,
...fnOptions,
throwOnError: true
});
return data;
}
};
return mutationOptions;
};
export const listBackupSchedulesQueryKey = (options?: Options<ListBackupSchedulesData>) => createQueryKey('listBackupSchedules', options);
/**
@ -927,6 +961,24 @@ export const getSystemInfoOptions = (options?: Options<GetSystemInfoData>) => qu
queryKey: getSystemInfoQueryKey(options)
});
export const getUpdatesQueryKey = (options?: Options<GetUpdatesData>) => createQueryKey('getUpdates', options);
/**
* Check for application updates from GitHub
*/
export const getUpdatesOptions = (options?: Options<GetUpdatesData>) => queryOptions<GetUpdatesResponse, DefaultError, GetUpdatesResponse, ReturnType<typeof getUpdatesQueryKey>>({
queryFn: async ({ queryKey, signal }) => {
const { data } = await getUpdates({
...options,
...queryKey[0],
signal,
throwOnError: true
});
return data;
},
queryKey: getUpdatesQueryKey(options)
});
/**
* Download the Restic password file for backup recovery. Requires password re-authentication.
*/
@ -947,8 +999,8 @@ export const downloadResticPasswordMutation = (options?: Partial<Options<Downloa
/**
* Export full configuration including all volumes, repositories, backup schedules, and notifications
*/
export const exportFullConfigMutation = (options?: Partial<Options<ExportFullConfigData>>): UseMutationOptions<ExportFullConfigResponse, ExportFullConfigError, Options<ExportFullConfigData>> => {
const mutationOptions: UseMutationOptions<ExportFullConfigResponse, ExportFullConfigError, Options<ExportFullConfigData>> = {
export const exportFullConfigMutation = (options?: Partial<Options<ExportFullConfigData>>): UseMutationOptions<ExportFullConfigResponse, DefaultError, Options<ExportFullConfigData>> => {
const mutationOptions: UseMutationOptions<ExportFullConfigResponse, DefaultError, Options<ExportFullConfigData>> = {
mutationFn: async (fnOptions) => {
const { data } = await exportFullConfig({
...options,

View file

@ -2,7 +2,7 @@
import type { Client, Options as Options2, TDataShape } from './client';
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, ExportFullConfigData, ExportFullConfigErrors, ExportFullConfigResponses, 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';
import type { BrowseFilesystemData, BrowseFilesystemResponses, ChangePasswordData, ChangePasswordResponses, CreateBackupScheduleData, CreateBackupScheduleResponses, CreateNotificationDestinationData, CreateNotificationDestinationResponses, CreateRepositoryData, CreateRepositoryResponses, CreateVolumeData, CreateVolumeResponses, DeleteBackupScheduleData, DeleteBackupScheduleResponses, DeleteNotificationDestinationData, DeleteNotificationDestinationErrors, DeleteNotificationDestinationResponses, DeleteRepositoryData, DeleteRepositoryResponses, DeleteSnapshotData, DeleteSnapshotResponses, DeleteSnapshotsData, DeleteSnapshotsResponses, DeleteVolumeData, DeleteVolumeResponses, DoctorRepositoryData, DoctorRepositoryResponses, DownloadResticPasswordData, DownloadResticPasswordResponses, ExportFullConfigData, ExportFullConfigResponses, GetBackupScheduleData, GetBackupScheduleForVolumeData, GetBackupScheduleForVolumeResponses, GetBackupScheduleResponses, GetMeData, GetMeResponses, GetMirrorCompatibilityData, GetMirrorCompatibilityResponses, GetNotificationDestinationData, GetNotificationDestinationErrors, GetNotificationDestinationResponses, GetRepositoryData, GetRepositoryResponses, GetScheduleMirrorsData, GetScheduleMirrorsResponses, GetScheduleNotificationsData, GetScheduleNotificationsResponses, GetSnapshotDetailsData, GetSnapshotDetailsResponses, GetStatusData, GetStatusResponses, GetSystemInfoData, GetSystemInfoResponses, GetUpdatesData, GetUpdatesResponses, 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, TagSnapshotsData, TagSnapshotsResponses, 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> & {
/**
@ -189,6 +189,18 @@ export const updateRepository = <ThrowOnError extends boolean = false>(options:
}
});
/**
* Delete multiple snapshots from a repository
*/
export const deleteSnapshots = <ThrowOnError extends boolean = false>(options: Options<DeleteSnapshotsData, ThrowOnError>) => (options.client ?? client).delete<DeleteSnapshotsResponses, unknown, ThrowOnError>({
url: '/api/v1/repositories/{id}/snapshots',
...options,
headers: {
'Content-Type': 'application/json',
...options.headers
}
});
/**
* List all snapshots in a repository
*/
@ -226,6 +238,18 @@ export const restoreSnapshot = <ThrowOnError extends boolean = false>(options: O
*/
export const doctorRepository = <ThrowOnError extends boolean = false>(options: Options<DoctorRepositoryData, ThrowOnError>) => (options.client ?? client).post<DoctorRepositoryResponses, unknown, ThrowOnError>({ url: '/api/v1/repositories/{id}/doctor', ...options });
/**
* Tag multiple snapshots in a repository
*/
export const tagSnapshots = <ThrowOnError extends boolean = false>(options: Options<TagSnapshotsData, ThrowOnError>) => (options.client ?? client).post<TagSnapshotsResponses, unknown, ThrowOnError>({
url: '/api/v1/repositories/{id}/snapshots/tag',
...options,
headers: {
'Content-Type': 'application/json',
...options.headers
}
});
/**
* List all backup schedules
*/
@ -385,6 +409,11 @@ export const testNotificationDestination = <ThrowOnError extends boolean = false
*/
export const getSystemInfo = <ThrowOnError extends boolean = false>(options?: Options<GetSystemInfoData, ThrowOnError>) => (options?.client ?? client).get<GetSystemInfoResponses, unknown, ThrowOnError>({ url: '/api/v1/system/info', ...options });
/**
* Check for application updates from GitHub
*/
export const getUpdates = <ThrowOnError extends boolean = false>(options?: Options<GetUpdatesData, ThrowOnError>) => (options?.client ?? client).get<GetUpdatesResponses, unknown, ThrowOnError>({ url: '/api/v1/system/updates', ...options });
/**
* Download the Restic password file for backup recovery. Requires password re-authentication.
*/
@ -400,8 +429,8 @@ export const downloadResticPassword = <ThrowOnError extends boolean = false>(opt
/**
* Export full configuration including all volumes, repositories, backup schedules, and notifications
*/
export const exportFullConfig = <ThrowOnError extends boolean = false>(options?: Options<ExportFullConfigData, ThrowOnError>) => (options?.client ?? client).post<ExportFullConfigResponses, ExportFullConfigErrors, ThrowOnError>({
url: '/api/v1/config/export',
export const exportFullConfig = <ThrowOnError extends boolean = false>(options?: Options<ExportFullConfigData, ThrowOnError>) => (options?.client ?? client).post<ExportFullConfigResponses, unknown, ThrowOnError>({
url: '/api/v1/system/export',
...options,
headers: {
'Content-Type': 'application/json',

View file

@ -798,7 +798,9 @@ export type ListRepositoriesResponses = {
bucket: string;
endpoint: string;
secretAccessKey: string;
cacert?: string;
customPassword?: string;
insecureTls?: boolean;
isExistingRepository?: boolean;
} | {
accessKeyId: string;
@ -806,39 +808,51 @@ export type ListRepositoriesResponses = {
bucket: string;
endpoint: string;
secretAccessKey: string;
cacert?: string;
customPassword?: string;
insecureTls?: boolean;
isExistingRepository?: boolean;
} | {
accountKey: string;
accountName: string;
backend: 'azure';
container: string;
cacert?: string;
customPassword?: string;
endpointSuffix?: string;
insecureTls?: boolean;
isExistingRepository?: boolean;
} | {
backend: 'gcs';
bucket: string;
credentialsJson: string;
projectId: string;
cacert?: string;
customPassword?: string;
insecureTls?: boolean;
isExistingRepository?: boolean;
} | {
backend: 'local';
name: string;
cacert?: string;
customPassword?: string;
insecureTls?: boolean;
isExistingRepository?: boolean;
path?: string;
} | {
backend: 'rclone';
path: string;
remote: string;
cacert?: string;
customPassword?: string;
insecureTls?: boolean;
isExistingRepository?: boolean;
} | {
backend: 'rest';
url: string;
cacert?: string;
customPassword?: string;
insecureTls?: boolean;
isExistingRepository?: boolean;
password?: string;
path?: string;
@ -851,7 +865,9 @@ export type ListRepositoriesResponses = {
user: string;
port?: number;
skipHostKeyCheck?: boolean;
cacert?: string;
customPassword?: string;
insecureTls?: boolean;
isExistingRepository?: boolean;
knownHosts?: string;
};
@ -877,7 +893,9 @@ export type CreateRepositoryData = {
bucket: string;
endpoint: string;
secretAccessKey: string;
cacert?: string;
customPassword?: string;
insecureTls?: boolean;
isExistingRepository?: boolean;
} | {
accessKeyId: string;
@ -885,39 +903,51 @@ export type CreateRepositoryData = {
bucket: string;
endpoint: string;
secretAccessKey: string;
cacert?: string;
customPassword?: string;
insecureTls?: boolean;
isExistingRepository?: boolean;
} | {
accountKey: string;
accountName: string;
backend: 'azure';
container: string;
cacert?: string;
customPassword?: string;
endpointSuffix?: string;
insecureTls?: boolean;
isExistingRepository?: boolean;
} | {
backend: 'gcs';
bucket: string;
credentialsJson: string;
projectId: string;
cacert?: string;
customPassword?: string;
insecureTls?: boolean;
isExistingRepository?: boolean;
} | {
backend: 'local';
name: string;
cacert?: string;
customPassword?: string;
insecureTls?: boolean;
isExistingRepository?: boolean;
path?: string;
} | {
backend: 'rclone';
path: string;
remote: string;
cacert?: string;
customPassword?: string;
insecureTls?: boolean;
isExistingRepository?: boolean;
} | {
backend: 'rest';
url: string;
cacert?: string;
customPassword?: string;
insecureTls?: boolean;
isExistingRepository?: boolean;
password?: string;
path?: string;
@ -930,7 +960,9 @@ export type CreateRepositoryData = {
user: string;
port?: number;
skipHostKeyCheck?: boolean;
cacert?: string;
customPassword?: string;
insecureTls?: boolean;
isExistingRepository?: boolean;
knownHosts?: string;
};
@ -1018,7 +1050,9 @@ export type GetRepositoryResponses = {
bucket: string;
endpoint: string;
secretAccessKey: string;
cacert?: string;
customPassword?: string;
insecureTls?: boolean;
isExistingRepository?: boolean;
} | {
accessKeyId: string;
@ -1026,39 +1060,51 @@ export type GetRepositoryResponses = {
bucket: string;
endpoint: string;
secretAccessKey: string;
cacert?: string;
customPassword?: string;
insecureTls?: boolean;
isExistingRepository?: boolean;
} | {
accountKey: string;
accountName: string;
backend: 'azure';
container: string;
cacert?: string;
customPassword?: string;
endpointSuffix?: string;
insecureTls?: boolean;
isExistingRepository?: boolean;
} | {
backend: 'gcs';
bucket: string;
credentialsJson: string;
projectId: string;
cacert?: string;
customPassword?: string;
insecureTls?: boolean;
isExistingRepository?: boolean;
} | {
backend: 'local';
name: string;
cacert?: string;
customPassword?: string;
insecureTls?: boolean;
isExistingRepository?: boolean;
path?: string;
} | {
backend: 'rclone';
path: string;
remote: string;
cacert?: string;
customPassword?: string;
insecureTls?: boolean;
isExistingRepository?: boolean;
} | {
backend: 'rest';
url: string;
cacert?: string;
customPassword?: string;
insecureTls?: boolean;
isExistingRepository?: boolean;
password?: string;
path?: string;
@ -1071,7 +1117,9 @@ export type GetRepositoryResponses = {
user: string;
port?: number;
skipHostKeyCheck?: boolean;
cacert?: string;
customPassword?: string;
insecureTls?: boolean;
isExistingRepository?: boolean;
knownHosts?: string;
};
@ -1124,7 +1172,9 @@ export type UpdateRepositoryResponses = {
bucket: string;
endpoint: string;
secretAccessKey: string;
cacert?: string;
customPassword?: string;
insecureTls?: boolean;
isExistingRepository?: boolean;
} | {
accessKeyId: string;
@ -1132,39 +1182,51 @@ export type UpdateRepositoryResponses = {
bucket: string;
endpoint: string;
secretAccessKey: string;
cacert?: string;
customPassword?: string;
insecureTls?: boolean;
isExistingRepository?: boolean;
} | {
accountKey: string;
accountName: string;
backend: 'azure';
container: string;
cacert?: string;
customPassword?: string;
endpointSuffix?: string;
insecureTls?: boolean;
isExistingRepository?: boolean;
} | {
backend: 'gcs';
bucket: string;
credentialsJson: string;
projectId: string;
cacert?: string;
customPassword?: string;
insecureTls?: boolean;
isExistingRepository?: boolean;
} | {
backend: 'local';
name: string;
cacert?: string;
customPassword?: string;
insecureTls?: boolean;
isExistingRepository?: boolean;
path?: string;
} | {
backend: 'rclone';
path: string;
remote: string;
cacert?: string;
customPassword?: string;
insecureTls?: boolean;
isExistingRepository?: boolean;
} | {
backend: 'rest';
url: string;
cacert?: string;
customPassword?: string;
insecureTls?: boolean;
isExistingRepository?: boolean;
password?: string;
path?: string;
@ -1177,7 +1239,9 @@ export type UpdateRepositoryResponses = {
user: string;
port?: number;
skipHostKeyCheck?: boolean;
cacert?: string;
customPassword?: string;
insecureTls?: boolean;
isExistingRepository?: boolean;
knownHosts?: string;
};
@ -1195,6 +1259,28 @@ export type UpdateRepositoryResponses = {
export type UpdateRepositoryResponse = UpdateRepositoryResponses[keyof UpdateRepositoryResponses];
export type DeleteSnapshotsData = {
body?: {
snapshotIds: Array<string>;
};
path: {
id: string;
};
query?: never;
url: '/api/v1/repositories/{id}/snapshots';
};
export type DeleteSnapshotsResponses = {
/**
* Snapshots deleted successfully
*/
200: {
message: string;
};
};
export type DeleteSnapshotsResponse = DeleteSnapshotsResponses[keyof DeleteSnapshotsResponses];
export type ListSnapshotsData = {
body?: never;
path: {
@ -1367,6 +1453,31 @@ export type DoctorRepositoryResponses = {
export type DoctorRepositoryResponse = DoctorRepositoryResponses[keyof DoctorRepositoryResponses];
export type TagSnapshotsData = {
body?: {
snapshotIds: Array<string>;
add?: Array<string>;
remove?: Array<string>;
set?: Array<string>;
};
path: {
id: string;
};
query?: never;
url: '/api/v1/repositories/{id}/snapshots/tag';
};
export type TagSnapshotsResponses = {
/**
* Snapshots tagged successfully
*/
200: {
message: string;
};
};
export type TagSnapshotsResponse = TagSnapshotsResponses[keyof TagSnapshotsResponses];
export type ListBackupSchedulesData = {
body?: never;
path?: never;
@ -1400,7 +1511,9 @@ export type ListBackupSchedulesResponses = {
bucket: string;
endpoint: string;
secretAccessKey: string;
cacert?: string;
customPassword?: string;
insecureTls?: boolean;
isExistingRepository?: boolean;
} | {
accessKeyId: string;
@ -1408,39 +1521,51 @@ export type ListBackupSchedulesResponses = {
bucket: string;
endpoint: string;
secretAccessKey: string;
cacert?: string;
customPassword?: string;
insecureTls?: boolean;
isExistingRepository?: boolean;
} | {
accountKey: string;
accountName: string;
backend: 'azure';
container: string;
cacert?: string;
customPassword?: string;
endpointSuffix?: string;
insecureTls?: boolean;
isExistingRepository?: boolean;
} | {
backend: 'gcs';
bucket: string;
credentialsJson: string;
projectId: string;
cacert?: string;
customPassword?: string;
insecureTls?: boolean;
isExistingRepository?: boolean;
} | {
backend: 'local';
name: string;
cacert?: string;
customPassword?: string;
insecureTls?: boolean;
isExistingRepository?: boolean;
path?: string;
} | {
backend: 'rclone';
path: string;
remote: string;
cacert?: string;
customPassword?: string;
insecureTls?: boolean;
isExistingRepository?: boolean;
} | {
backend: 'rest';
url: string;
cacert?: string;
customPassword?: string;
insecureTls?: boolean;
isExistingRepository?: boolean;
password?: string;
path?: string;
@ -1453,7 +1578,9 @@ export type ListBackupSchedulesResponses = {
user: string;
port?: number;
skipHostKeyCheck?: boolean;
cacert?: string;
customPassword?: string;
insecureTls?: boolean;
isExistingRepository?: boolean;
knownHosts?: string;
};
@ -1662,7 +1789,9 @@ export type GetBackupScheduleResponses = {
bucket: string;
endpoint: string;
secretAccessKey: string;
cacert?: string;
customPassword?: string;
insecureTls?: boolean;
isExistingRepository?: boolean;
} | {
accessKeyId: string;
@ -1670,39 +1799,51 @@ export type GetBackupScheduleResponses = {
bucket: string;
endpoint: string;
secretAccessKey: string;
cacert?: string;
customPassword?: string;
insecureTls?: boolean;
isExistingRepository?: boolean;
} | {
accountKey: string;
accountName: string;
backend: 'azure';
container: string;
cacert?: string;
customPassword?: string;
endpointSuffix?: string;
insecureTls?: boolean;
isExistingRepository?: boolean;
} | {
backend: 'gcs';
bucket: string;
credentialsJson: string;
projectId: string;
cacert?: string;
customPassword?: string;
insecureTls?: boolean;
isExistingRepository?: boolean;
} | {
backend: 'local';
name: string;
cacert?: string;
customPassword?: string;
insecureTls?: boolean;
isExistingRepository?: boolean;
path?: string;
} | {
backend: 'rclone';
path: string;
remote: string;
cacert?: string;
customPassword?: string;
insecureTls?: boolean;
isExistingRepository?: boolean;
} | {
backend: 'rest';
url: string;
cacert?: string;
customPassword?: string;
insecureTls?: boolean;
isExistingRepository?: boolean;
password?: string;
path?: string;
@ -1715,7 +1856,9 @@ export type GetBackupScheduleResponses = {
user: string;
port?: number;
skipHostKeyCheck?: boolean;
cacert?: string;
customPassword?: string;
insecureTls?: boolean;
isExistingRepository?: boolean;
knownHosts?: string;
};
@ -1905,7 +2048,9 @@ export type GetBackupScheduleForVolumeResponses = {
bucket: string;
endpoint: string;
secretAccessKey: string;
cacert?: string;
customPassword?: string;
insecureTls?: boolean;
isExistingRepository?: boolean;
} | {
accessKeyId: string;
@ -1913,39 +2058,51 @@ export type GetBackupScheduleForVolumeResponses = {
bucket: string;
endpoint: string;
secretAccessKey: string;
cacert?: string;
customPassword?: string;
insecureTls?: boolean;
isExistingRepository?: boolean;
} | {
accountKey: string;
accountName: string;
backend: 'azure';
container: string;
cacert?: string;
customPassword?: string;
endpointSuffix?: string;
insecureTls?: boolean;
isExistingRepository?: boolean;
} | {
backend: 'gcs';
bucket: string;
credentialsJson: string;
projectId: string;
cacert?: string;
customPassword?: string;
insecureTls?: boolean;
isExistingRepository?: boolean;
} | {
backend: 'local';
name: string;
cacert?: string;
customPassword?: string;
insecureTls?: boolean;
isExistingRepository?: boolean;
path?: string;
} | {
backend: 'rclone';
path: string;
remote: string;
cacert?: string;
customPassword?: string;
insecureTls?: boolean;
isExistingRepository?: boolean;
} | {
backend: 'rest';
url: string;
cacert?: string;
customPassword?: string;
insecureTls?: boolean;
isExistingRepository?: boolean;
password?: string;
path?: string;
@ -1958,7 +2115,9 @@ export type GetBackupScheduleForVolumeResponses = {
user: string;
port?: number;
skipHostKeyCheck?: boolean;
cacert?: string;
customPassword?: string;
insecureTls?: boolean;
isExistingRepository?: boolean;
knownHosts?: string;
};
@ -2151,10 +2310,20 @@ export type GetScheduleNotificationsResponses = {
useTLS: boolean;
password?: string;
username?: string;
} | {
method: 'GET' | 'POST';
type: 'generic';
url: string;
contentType?: string;
headers?: Array<string>;
messageKey?: string;
titleKey?: string;
useJson?: boolean;
} | {
priority: 'default' | 'high' | 'low' | 'max' | 'min';
topic: string;
type: 'ntfy';
accessToken?: string;
password?: string;
serverUrl?: string;
username?: string;
@ -2184,7 +2353,7 @@ export type GetScheduleNotificationsResponses = {
enabled: boolean;
id: number;
name: string;
type: 'custom' | 'discord' | 'email' | 'gotify' | 'ntfy' | 'pushover' | 'slack' | 'telegram';
type: 'custom' | 'discord' | 'email' | 'generic' | 'gotify' | 'ntfy' | 'pushover' | 'slack' | 'telegram';
updatedAt: number;
};
destinationId: number;
@ -2241,10 +2410,20 @@ export type UpdateScheduleNotificationsResponses = {
useTLS: boolean;
password?: string;
username?: string;
} | {
method: 'GET' | 'POST';
type: 'generic';
url: string;
contentType?: string;
headers?: Array<string>;
messageKey?: string;
titleKey?: string;
useJson?: boolean;
} | {
priority: 'default' | 'high' | 'low' | 'max' | 'min';
topic: string;
type: 'ntfy';
accessToken?: string;
password?: string;
serverUrl?: string;
username?: string;
@ -2274,7 +2453,7 @@ export type UpdateScheduleNotificationsResponses = {
enabled: boolean;
id: number;
name: string;
type: 'custom' | 'discord' | 'email' | 'gotify' | 'ntfy' | 'pushover' | 'slack' | 'telegram';
type: 'custom' | 'discord' | 'email' | 'generic' | 'gotify' | 'ntfy' | 'pushover' | 'slack' | 'telegram';
updatedAt: number;
};
destinationId: number;
@ -2315,7 +2494,9 @@ export type GetScheduleMirrorsResponses = {
bucket: string;
endpoint: string;
secretAccessKey: string;
cacert?: string;
customPassword?: string;
insecureTls?: boolean;
isExistingRepository?: boolean;
} | {
accessKeyId: string;
@ -2323,39 +2504,51 @@ export type GetScheduleMirrorsResponses = {
bucket: string;
endpoint: string;
secretAccessKey: string;
cacert?: string;
customPassword?: string;
insecureTls?: boolean;
isExistingRepository?: boolean;
} | {
accountKey: string;
accountName: string;
backend: 'azure';
container: string;
cacert?: string;
customPassword?: string;
endpointSuffix?: string;
insecureTls?: boolean;
isExistingRepository?: boolean;
} | {
backend: 'gcs';
bucket: string;
credentialsJson: string;
projectId: string;
cacert?: string;
customPassword?: string;
insecureTls?: boolean;
isExistingRepository?: boolean;
} | {
backend: 'local';
name: string;
cacert?: string;
customPassword?: string;
insecureTls?: boolean;
isExistingRepository?: boolean;
path?: string;
} | {
backend: 'rclone';
path: string;
remote: string;
cacert?: string;
customPassword?: string;
insecureTls?: boolean;
isExistingRepository?: boolean;
} | {
backend: 'rest';
url: string;
cacert?: string;
customPassword?: string;
insecureTls?: boolean;
isExistingRepository?: boolean;
password?: string;
path?: string;
@ -2368,7 +2561,9 @@ export type GetScheduleMirrorsResponses = {
user: string;
port?: number;
skipHostKeyCheck?: boolean;
cacert?: string;
customPassword?: string;
insecureTls?: boolean;
isExistingRepository?: boolean;
knownHosts?: string;
};
@ -2421,7 +2616,9 @@ export type UpdateScheduleMirrorsResponses = {
bucket: string;
endpoint: string;
secretAccessKey: string;
cacert?: string;
customPassword?: string;
insecureTls?: boolean;
isExistingRepository?: boolean;
} | {
accessKeyId: string;
@ -2429,39 +2626,51 @@ export type UpdateScheduleMirrorsResponses = {
bucket: string;
endpoint: string;
secretAccessKey: string;
cacert?: string;
customPassword?: string;
insecureTls?: boolean;
isExistingRepository?: boolean;
} | {
accountKey: string;
accountName: string;
backend: 'azure';
container: string;
cacert?: string;
customPassword?: string;
endpointSuffix?: string;
insecureTls?: boolean;
isExistingRepository?: boolean;
} | {
backend: 'gcs';
bucket: string;
credentialsJson: string;
projectId: string;
cacert?: string;
customPassword?: string;
insecureTls?: boolean;
isExistingRepository?: boolean;
} | {
backend: 'local';
name: string;
cacert?: string;
customPassword?: string;
insecureTls?: boolean;
isExistingRepository?: boolean;
path?: string;
} | {
backend: 'rclone';
path: string;
remote: string;
cacert?: string;
customPassword?: string;
insecureTls?: boolean;
isExistingRepository?: boolean;
} | {
backend: 'rest';
url: string;
cacert?: string;
customPassword?: string;
insecureTls?: boolean;
isExistingRepository?: boolean;
password?: string;
path?: string;
@ -2474,7 +2683,9 @@ export type UpdateScheduleMirrorsResponses = {
user: string;
port?: number;
skipHostKeyCheck?: boolean;
cacert?: string;
customPassword?: string;
insecureTls?: boolean;
isExistingRepository?: boolean;
knownHosts?: string;
};
@ -2568,10 +2779,20 @@ export type ListNotificationDestinationsResponses = {
useTLS: boolean;
password?: string;
username?: string;
} | {
method: 'GET' | 'POST';
type: 'generic';
url: string;
contentType?: string;
headers?: Array<string>;
messageKey?: string;
titleKey?: string;
useJson?: boolean;
} | {
priority: 'default' | 'high' | 'low' | 'max' | 'min';
topic: string;
type: 'ntfy';
accessToken?: string;
password?: string;
serverUrl?: string;
username?: string;
@ -2601,7 +2822,7 @@ export type ListNotificationDestinationsResponses = {
enabled: boolean;
id: number;
name: string;
type: 'custom' | 'discord' | 'email' | 'gotify' | 'ntfy' | 'pushover' | 'slack' | 'telegram';
type: 'custom' | 'discord' | 'email' | 'generic' | 'gotify' | 'ntfy' | 'pushover' | 'slack' | 'telegram';
updatedAt: number;
}>;
};
@ -2629,10 +2850,20 @@ export type CreateNotificationDestinationData = {
useTLS: boolean;
password?: string;
username?: string;
} | {
method: 'GET' | 'POST';
type: 'generic';
url: string;
contentType?: string;
headers?: Array<string>;
messageKey?: string;
titleKey?: string;
useJson?: boolean;
} | {
priority: 'default' | 'high' | 'low' | 'max' | 'min';
topic: string;
type: 'ntfy';
accessToken?: string;
password?: string;
serverUrl?: string;
username?: string;
@ -2689,10 +2920,20 @@ export type CreateNotificationDestinationResponses = {
useTLS: boolean;
password?: string;
username?: string;
} | {
method: 'GET' | 'POST';
type: 'generic';
url: string;
contentType?: string;
headers?: Array<string>;
messageKey?: string;
titleKey?: string;
useJson?: boolean;
} | {
priority: 'default' | 'high' | 'low' | 'max' | 'min';
topic: string;
type: 'ntfy';
accessToken?: string;
password?: string;
serverUrl?: string;
username?: string;
@ -2722,7 +2963,7 @@ export type CreateNotificationDestinationResponses = {
enabled: boolean;
id: number;
name: string;
type: 'custom' | 'discord' | 'email' | 'gotify' | 'ntfy' | 'pushover' | 'slack' | 'telegram';
type: 'custom' | 'discord' | 'email' | 'generic' | 'gotify' | 'ntfy' | 'pushover' | 'slack' | 'telegram';
updatedAt: number;
};
};
@ -2796,10 +3037,20 @@ export type GetNotificationDestinationResponses = {
useTLS: boolean;
password?: string;
username?: string;
} | {
method: 'GET' | 'POST';
type: 'generic';
url: string;
contentType?: string;
headers?: Array<string>;
messageKey?: string;
titleKey?: string;
useJson?: boolean;
} | {
priority: 'default' | 'high' | 'low' | 'max' | 'min';
topic: string;
type: 'ntfy';
accessToken?: string;
password?: string;
serverUrl?: string;
username?: string;
@ -2829,7 +3080,7 @@ export type GetNotificationDestinationResponses = {
enabled: boolean;
id: number;
name: string;
type: 'custom' | 'discord' | 'email' | 'gotify' | 'ntfy' | 'pushover' | 'slack' | 'telegram';
type: 'custom' | 'discord' | 'email' | 'generic' | 'gotify' | 'ntfy' | 'pushover' | 'slack' | 'telegram';
updatedAt: number;
};
};
@ -2857,10 +3108,20 @@ export type UpdateNotificationDestinationData = {
useTLS: boolean;
password?: string;
username?: string;
} | {
method: 'GET' | 'POST';
type: 'generic';
url: string;
contentType?: string;
headers?: Array<string>;
messageKey?: string;
titleKey?: string;
useJson?: boolean;
} | {
priority: 'default' | 'high' | 'low' | 'max' | 'min';
topic: string;
type: 'ntfy';
accessToken?: string;
password?: string;
serverUrl?: string;
username?: string;
@ -2927,10 +3188,20 @@ export type UpdateNotificationDestinationResponses = {
useTLS: boolean;
password?: string;
username?: string;
} | {
method: 'GET' | 'POST';
type: 'generic';
url: string;
contentType?: string;
headers?: Array<string>;
messageKey?: string;
titleKey?: string;
useJson?: boolean;
} | {
priority: 'default' | 'high' | 'low' | 'max' | 'min';
topic: string;
type: 'ntfy';
accessToken?: string;
password?: string;
serverUrl?: string;
username?: string;
@ -2960,7 +3231,7 @@ export type UpdateNotificationDestinationResponses = {
enabled: boolean;
id: number;
name: string;
type: 'custom' | 'discord' | 'email' | 'gotify' | 'ntfy' | 'pushover' | 'slack' | 'telegram';
type: 'custom' | 'discord' | 'email' | 'generic' | 'gotify' | 'ntfy' | 'pushover' | 'slack' | 'telegram';
updatedAt: number;
};
};
@ -3023,6 +3294,32 @@ export type GetSystemInfoResponses = {
export type GetSystemInfoResponse = GetSystemInfoResponses[keyof GetSystemInfoResponses];
export type GetUpdatesData = {
body?: never;
path?: never;
query?: never;
url: '/api/v1/system/updates';
};
export type GetUpdatesResponses = {
/**
* Update information and missed releases
*/
200: {
currentVersion: string;
hasUpdate: boolean;
latestVersion: string;
missedReleases: Array<{
body: string;
publishedAt: string;
url: string;
version: string;
}>;
};
};
export type GetUpdatesResponse = GetUpdatesResponses[keyof GetUpdatesResponses];
export type DownloadResticPasswordData = {
body?: {
password: string;
@ -3045,32 +3342,12 @@ export type ExportFullConfigData = {
body?: {
password: string;
includeMetadata?: boolean;
includePasswordHash?: boolean;
includeRecoveryKey?: boolean;
secretsMode?: 'cleartext' | 'encrypted' | 'exclude';
};
path?: never;
query?: never;
url: '/api/v1/config/export';
url: '/api/v1/system/export';
};
export type ExportFullConfigErrors = {
/**
* Password required for export or authentication failed
*/
401: {
error: string;
};
/**
* Export failed
*/
500: {
error: string;
};
};
export type ExportFullConfigError = ExportFullConfigErrors[keyof ExportFullConfigErrors];
export type ExportFullConfigResponses = {
/**
* Full configuration export
@ -3087,7 +3364,6 @@ export type ExportFullConfigResponses = {
createdAt?: number;
hasDownloadedResticPassword?: boolean;
id?: number;
passwordHash?: string;
updatedAt?: number;
}>;
volumes?: Array<unknown>;

View file

@ -0,0 +1,166 @@
/** biome-ignore-all lint/style/noNonNullAssertion: Testing file - non-null assertions are acceptable here */
import { expect, test, describe } from "bun:test";
import { render, screen, fireEvent } from "@testing-library/react";
import { FileTree, type FileEntry } from "../file-tree";
describe("FileTree Selection Logic", () => {
const testFiles: FileEntry[] = [
{ name: "root", path: "/root", type: "folder" },
{ name: "photos", path: "/root/photos", type: "folder" },
{ name: "backups", path: "/root/photos/backups", type: "folder" },
{ name: "library", path: "/root/photos/library", type: "folder" },
{ name: "profile", path: "/root/photos/profile", type: "folder" },
{ name: "upload", path: "/root/photos/upload", type: "folder" },
];
test("selecting a folder simplifies to parent if it's the only child", async () => {
let currentSelection = new Set<string>();
const onSelectionChange = (selection: Set<string>) => {
currentSelection = selection;
};
render(
<FileTree
files={testFiles}
withCheckboxes={true}
selectedPaths={currentSelection}
onSelectionChange={onSelectionChange}
expandedFolders={new Set(testFiles.map((f) => f.path))}
/>,
);
const photosCheckbox = screen.getByText("photos").parentElement?.querySelector('button[role="checkbox"]');
expect(photosCheckbox).toBeTruthy();
fireEvent.click(photosCheckbox!);
expect(currentSelection.has("/root")).toBe(true);
expect(currentSelection.size).toBe(1);
});
test("unselecting a child removes the parent from selection", async () => {
let currentSelection = new Set<string>(["/root"]);
const onSelectionChange = (selection: Set<string>) => {
currentSelection = selection;
};
render(
<FileTree
files={testFiles}
withCheckboxes={true}
selectedPaths={currentSelection}
onSelectionChange={onSelectionChange}
expandedFolders={new Set(testFiles.map((f) => f.path))}
/>,
);
const libraryCheckbox = screen.getByText("library").parentElement?.querySelector('button[role="checkbox"]');
fireEvent.click(libraryCheckbox!);
expect(currentSelection.has("/root")).toBe(false);
expect(currentSelection.has("/root/photos")).toBe(false);
expect(currentSelection.has("/root/photos/backups")).toBe(true);
expect(currentSelection.has("/root/photos/profile")).toBe(true);
expect(currentSelection.has("/root/photos/upload")).toBe(true);
expect(currentSelection.size).toBe(3);
});
test("recursive simplification when all children are selected", async () => {
let currentSelection = new Set<string>();
const onSelectionChange = (selection: Set<string>) => {
currentSelection = selection;
};
const { rerender } = render(
<FileTree
files={testFiles}
withCheckboxes={true}
selectedPaths={currentSelection}
onSelectionChange={onSelectionChange}
expandedFolders={new Set(testFiles.map((f) => f.path))}
/>,
);
const children = ["backups", "library", "profile", "upload"];
for (const name of children) {
const checkbox = screen.getByText(name).parentElement?.querySelector('button[role="checkbox"]');
fireEvent.click(checkbox!);
rerender(
<FileTree
files={testFiles}
withCheckboxes={true}
selectedPaths={currentSelection}
onSelectionChange={onSelectionChange}
expandedFolders={new Set(testFiles.map((f) => f.path))}
/>,
);
}
expect(currentSelection.has("/root")).toBe(true);
expect(currentSelection.size).toBe(1);
});
test("does not simplify to parent if not all children are selected", async () => {
const multipleFiles: FileEntry[] = [
{ name: "root", path: "/root", type: "folder" },
{ name: "child1", path: "/root/child1", type: "folder" },
{ name: "child2", path: "/root/child2", type: "folder" },
];
let currentSelection = new Set<string>();
const onSelectionChange = (selection: Set<string>) => {
currentSelection = selection;
};
render(
<FileTree
files={multipleFiles}
withCheckboxes={true}
selectedPaths={currentSelection}
onSelectionChange={onSelectionChange}
expandedFolders={new Set(multipleFiles.map((f) => f.path))}
/>,
);
const child1Checkbox = screen.getByText("child1").parentElement?.querySelector('button[role="checkbox"]');
fireEvent.click(child1Checkbox!);
expect(currentSelection.has("/root/child1")).toBe(true);
expect(currentSelection.has("/root")).toBe(false);
expect(currentSelection.size).toBe(1);
});
test("simplifies existing deep paths when parent is selected", async () => {
const files: FileEntry[] = [
{ name: "hello", path: "/hello", type: "folder" },
{ name: "hello_prev", path: "/hello_prev", type: "folder" },
{ name: "service", path: "/service", type: "folder" },
];
let currentSelection = new Set<string>(["/hello", "/hello_prev", "/service/app/data/upload"]);
const onSelectionChange = (selection: Set<string>) => {
currentSelection = selection;
};
render(
<FileTree
files={files}
withCheckboxes={true}
selectedPaths={currentSelection}
onSelectionChange={onSelectionChange}
/>,
);
const serviceCheckbox = screen.getByText("service").parentElement?.querySelector('button[role="checkbox"]');
expect(serviceCheckbox).toBeTruthy();
fireEvent.click(serviceCheckbox!);
expect(currentSelection.has("/service")).toBe(true);
expect(currentSelection.has("/service/app/data/upload")).toBe(false);
expect(currentSelection.size).toBe(3); // /hello, /hello_prev, /service
});
});

View file

@ -38,7 +38,7 @@ export function AppBreadcrumb() {
}
return (
<Breadcrumb>
<Breadcrumb className="min-w-0">
<BreadcrumbList>
{breadcrumbs.map((breadcrumb, index) => {
const isLast = index === breadcrumbs.length - 1;

View file

@ -1,5 +1,6 @@
import { Bell, CalendarClock, Database, HardDrive, Settings } from "lucide-react";
import { Link, NavLink } from "react-router";
import { useState } from "react";
import {
Sidebar,
SidebarContent,
@ -15,6 +16,8 @@ import {
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "~/client/components/ui/tooltip";
import { cn } from "~/client/lib/utils";
import { APP_VERSION } from "~/client/lib/version";
import { useUpdates } from "~/client/hooks/use-updates";
import { ReleaseNotesDialog } from "./release-notes-dialog";
const items = [
{
@ -46,6 +49,9 @@ const items = [
export function AppSidebar() {
const { state } = useSidebar();
const { updates, hasUpdate } = useUpdates();
const [showReleaseNotes, setShowReleaseNotes] = useState(false);
const displayVersion = APP_VERSION.startsWith("v") || APP_VERSION === "dev" ? APP_VERSION : `v${APP_VERSION}`;
const releaseUrl =
APP_VERSION === "dev"
@ -98,16 +104,28 @@ export function AppSidebar() {
</SidebarGroup>
</SidebarContent>
<SidebarFooter className="p-4 border-r border-t border-border/50">
<a
href={releaseUrl}
target="_blank"
rel="noreferrer"
className={cn("text-xs text-muted-foreground transition-all duration-200 hover:text-foreground", {
"opacity-0 w-0 overflow-hidden": state === "collapsed",
})}
>
{displayVersion}
</a>
<div className="flex items-center justify-between gap-2">
<a
href={releaseUrl}
target="_blank"
rel="noopener noreferrer"
className={cn("text-xs text-muted-foreground hover:text-foreground", {
"opacity-0 w-0 overflow-hidden": state === "collapsed",
})}
>
{displayVersion}
</a>
{hasUpdate && state !== "collapsed" && (
<button
type="button"
onClick={() => setShowReleaseNotes(true)}
className="text-[10px] font-medium text-destructive hover:underline cursor-pointer"
>
Update available
</button>
)}
</div>
<ReleaseNotesDialog open={showReleaseNotes} onOpenChange={setShowReleaseNotes} updates={updates} />
</SidebarFooter>
</Sidebar>
);

View file

@ -1,6 +1,6 @@
import { useMutation } from "@tanstack/react-query";
import { Download } from "lucide-react";
import { type ReactNode, useState } from "react";
import { useState } from "react";
import { toast } from "sonner";
import { exportFullConfigMutation } from "~/client/api-client/@tanstack/react-query.gen";
import { Button } from "~/client/components/ui/button";
@ -16,82 +16,29 @@ import {
} from "~/client/components/ui/dialog";
import { Input } from "~/client/components/ui/input";
import { Label } from "~/client/components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "~/client/components/ui/select";
type SecretsMode = "exclude" | "encrypted" | "cleartext";
import { parseError } from "../lib/errors";
import { downloadFile } from "../lib/utils";
const DEFAULT_EXPORT_FILENAME = "zerobyte-full-config";
function downloadAsJson(data: unknown, filename: string): void {
const blob = new Blob([JSON.stringify(data, null, 2)], { type: "application/json" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `${filename}.json`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}
type BaseExportDialogProps = {
/** Optional custom filename (without extension). Defaults to `zerobyte-full-config`. */
filename?: string;
};
type ExportDialogWithTrigger = BaseExportDialogProps & {
/** Custom trigger element. When provided, variant/size/triggerLabel/showIcon are ignored. */
trigger: ReactNode;
variant?: never;
size?: never;
triggerLabel?: never;
showIcon?: never;
};
type ExportDialogWithDefaultTrigger = BaseExportDialogProps & {
trigger?: never;
variant?: "default" | "outline" | "ghost" | "card";
size?: "default" | "sm" | "lg" | "icon";
triggerLabel?: string;
showIcon?: boolean;
};
type ExportDialogProps = ExportDialogWithTrigger | ExportDialogWithDefaultTrigger;
export function ExportDialog({
filename = DEFAULT_EXPORT_FILENAME,
trigger,
variant = "outline",
size = "default",
triggerLabel,
showIcon = true,
}: ExportDialogProps) {
export const ExportDialog = () => {
const [open, setOpen] = useState(false);
const [includeMetadata, setIncludeMetadata] = useState(false);
const [includeRecoveryKey, setIncludeRecoveryKey] = useState(false);
const [includePasswordHash, setIncludePasswordHash] = useState(false);
const [secretsMode, setSecretsMode] = useState<SecretsMode>("exclude");
const [password, setPassword] = useState("");
const handleExportSuccess = (data: unknown) => {
downloadAsJson(data, filename);
toast.success("Configuration exported successfully");
setOpen(false);
setPassword("");
};
const handleExportError = (error: unknown) => {
const message =
error && typeof error === "object" && "error" in error ? (error as { error: string }).error : "Unknown error";
toast.error("Export failed", {
description: message,
});
};
const exportMutation = useMutation({
...exportFullConfigMutation(),
onSuccess: handleExportSuccess,
onError: handleExportError,
onSuccess: (data) => {
downloadFile(data, `${DEFAULT_EXPORT_FILENAME}.json`, "application/json");
toast.success("Configuration exported successfully");
setOpen(false);
setPassword("");
},
onError: (e) => {
toast.error("Export failed", {
description: parseError(e)?.message,
});
},
});
const handleExport = (e: React.FormEvent) => {
@ -105,9 +52,6 @@ export function ExportDialog({
body: {
password,
includeMetadata,
secretsMode,
includeRecoveryKey,
includePasswordHash,
},
});
};
@ -119,25 +63,14 @@ export function ExportDialog({
}
};
const defaultTrigger =
variant === "card" ? (
<button
type="button"
className="flex flex-col items-center justify-center gap-2 cursor-pointer h-full w-full border-0 bg-transparent p-0 hover:opacity-80 transition-opacity"
>
<Download className="h-8 w-8 text-muted-foreground" />
<span className="text-sm font-medium text-muted-foreground">{triggerLabel ?? "Export Config"}</span>
</button>
) : (
<Button variant={variant} size={size}>
{showIcon && <Download className="h-4 w-4 mr-2" />}
{triggerLabel ?? "Export Config"}
</Button>
);
return (
<Dialog open={open} onOpenChange={handleDialogChange}>
<DialogTrigger asChild>{trigger ?? defaultTrigger}</DialogTrigger>
<DialogTrigger asChild>
<Button>
<Download className="h-4 w-4 mr-2" />
Export configuration
</Button>
</DialogTrigger>
<DialogContent>
<form onSubmit={handleExport}>
<DialogHeader>
@ -157,62 +90,7 @@ export function ExportDialog({
</Label>
</div>
<p className="text-xs text-muted-foreground ml-7">
Include database IDs, timestamps, and runtime state (status, health checks, last backup info).
</p>
<div className="flex items-center justify-between pt-2 border-t">
<Label className="cursor-pointer">Secrets handling</Label>
<Select value={secretsMode} onValueChange={(v) => setSecretsMode(v as SecretsMode)}>
<SelectTrigger className="w-40">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="exclude">Exclude</SelectItem>
<SelectItem value="encrypted">Keep encrypted</SelectItem>
<SelectItem value="cleartext">Decrypt</SelectItem>
</SelectContent>
</Select>
</div>
<p className="text-xs text-muted-foreground">
{secretsMode === "exclude" &&
"Sensitive fields (passwords, API keys, webhooks) will be removed from the export."}
{secretsMode === "encrypted" &&
"Secrets will be exported in encrypted form. Requires the same recovery key to decrypt on import."}
{secretsMode === "cleartext" && (
<span className="text-yellow-600">
Secrets will be decrypted and exported as plaintext. Keep this export secure!
</span>
)}
</p>
<div className="flex items-center space-x-3 pt-2 border-t">
<Checkbox
id="includeRecoveryKey"
checked={includeRecoveryKey}
onCheckedChange={(checked) => setIncludeRecoveryKey(checked === true)}
/>
<Label htmlFor="includeRecoveryKey" className="cursor-pointer">
Include recovery key
</Label>
</div>
<p className="text-xs text-muted-foreground ml-7">
<span className="text-yellow-600 font-medium"> Security sensitive:</span> The recovery key is the master
encryption key for all repositories. Keep this export secure and never share it.
</p>
<div className="flex items-center space-x-3">
<Checkbox
id="includePasswordHash"
checked={includePasswordHash}
onCheckedChange={(checked) => setIncludePasswordHash(checked === true)}
/>
<Label htmlFor="includePasswordHash" className="cursor-pointer">
Include password hash
</Label>
</div>
<p className="text-xs text-muted-foreground ml-7">
Include the hashed user passwords for seamless migration. The passwords are already securely hashed
(argon2).
Include timestamps and runtime state (status, health checks, last backup info).
</p>
<div className="space-y-2 pt-2 border-t">
@ -244,4 +122,4 @@ export function ExportDialog({
</DialogContent>
</Dialog>
);
}
};

View file

@ -116,13 +116,15 @@ export const FileTree = memo((props: Props) => {
// Add new folders to collapsed set when file list changes
useEffect(() => {
setCollapsedFolders((prevSet) => {
let hasChanges = false;
const newSet = new Set(prevSet);
for (const item of fileList) {
if (item.kind === "folder" && !newSet.has(item.fullPath) && !expandedFolders.has(item.fullPath)) {
newSet.add(item.fullPath);
hasChanges = true;
}
}
return newSet;
return hasChanges ? newSet : prevSet;
});
}, [fileList, expandedFolders]);
@ -149,9 +151,9 @@ export const FileTree = memo((props: Props) => {
newSelection.add(path);
// Remove any descendants from selection since parent now covers them
for (const item of fileList) {
if (item.fullPath.startsWith(`${path}/`)) {
newSelection.delete(item.fullPath);
for (const selectedPath of newSelection) {
if (selectedPath.startsWith(`${path}/`)) {
newSelection.delete(selectedPath);
}
}
} else {
@ -182,7 +184,8 @@ export const FileTree = memo((props: Props) => {
if (
item.fullPath.startsWith(`${selectedParentPath}/`) &&
!item.fullPath.startsWith(`${path}/`) &&
item.fullPath !== path
item.fullPath !== path &&
!path.startsWith(`${item.fullPath}/`)
) {
newSelection.add(item.fullPath);
}
@ -190,39 +193,45 @@ export const FileTree = memo((props: Props) => {
}
}
const childrenByParent = new Map<string, string[]>();
for (const selectedPath of newSelection) {
const lastSlashIndex = selectedPath.lastIndexOf("/");
if (lastSlashIndex > 0) {
const parentPath = selectedPath.slice(0, lastSlashIndex);
if (!childrenByParent.has(parentPath)) {
childrenByParent.set(parentPath, []);
}
childrenByParent.get(parentPath)?.push(selectedPath);
}
}
// For each parent, check if all its children are selected
for (const [parentPath, selectedChildren] of childrenByParent.entries()) {
// Get all children of this parent from the file list
const allChildren = fileList.filter((item) => {
const itemParentPath = item.fullPath.slice(0, item.fullPath.lastIndexOf("/"));
return itemParentPath === parentPath;
});
// If all children are selected, replace them with the parent
if (allChildren.length > 0 && selectedChildren.length === allChildren.length) {
// Check that we have every child
const allChildrenPaths = new Set(allChildren.map((c) => c.fullPath));
const allChildrenSelected = selectedChildren.every((c) => allChildrenPaths.has(c));
if (allChildrenSelected) {
// Remove all children
for (const childPath of selectedChildren) {
newSelection.delete(childPath);
let changed = true;
while (changed) {
changed = false;
const childrenByParent = new Map<string, string[]>();
for (const selectedPath of newSelection) {
const lastSlashIndex = selectedPath.lastIndexOf("/");
if (lastSlashIndex > 0) {
const parentPath = selectedPath.slice(0, lastSlashIndex);
if (!childrenByParent.has(parentPath)) {
childrenByParent.set(parentPath, []);
}
childrenByParent.get(parentPath)?.push(selectedPath);
}
}
// For each parent, check if all its children are selected
for (const [parentPath, selectedChildren] of childrenByParent.entries()) {
// Get all children of this parent from the file list
const allChildren = fileList.filter((item) => {
const itemParentPath = item.fullPath.slice(0, item.fullPath.lastIndexOf("/"));
return itemParentPath === parentPath;
});
// If all children are selected, replace them with the parent
if (allChildren.length > 0 && selectedChildren.length === allChildren.length) {
// Check that we have every child
const allChildrenPaths = new Set(allChildren.map((c) => c.fullPath));
const allChildrenSelected = selectedChildren.every((c) => allChildrenPaths.has(c));
if (allChildrenSelected) {
// Remove all children
for (const childPath of selectedChildren) {
newSelection.delete(childPath);
}
// Add the parent
newSelection.add(parentPath);
changed = true;
break;
}
// Add the parent
newSelection.add(parentPath);
}
}
}
@ -481,8 +490,9 @@ const NodeButton = memo(({ depth, icon, onClick, onMouseEnter, className, childr
const paddingLeft = useMemo(() => `${8 + depth * NODE_PADDING_LEFT}px`, [depth]);
return (
<button
type="button"
// biome-ignore lint/a11y/noStaticElementInteractions: we handle click and hover manually
// biome-ignore lint/a11y/useKeyWithClickEvents: we handle click and hover manually
<div
className={cn("flex items-center gap-2 w-full pr-2 text-sm py-1.5 text-left", className)}
style={{ paddingLeft }}
onClick={onClick}
@ -490,7 +500,7 @@ const NodeButton = memo(({ depth, icon, onClick, onMouseEnter, className, childr
>
{icon}
<div className="truncate w-full flex items-center gap-2">{children}</div>
</button>
</div>
);
});

View file

@ -43,8 +43,8 @@ export default function Layout({ loaderData }: Route.ComponentProps) {
<AppSidebar />
<div className="w-full relative flex flex-col h-screen overflow-hidden">
<header className="z-50 bg-card-header border-b border-border/50 shrink-0">
<div className="flex items-center justify-between py-3 sm:py-4 px-2 sm:px-8 mx-auto container">
<div className="flex items-center gap-4">
<div className="flex items-center justify-between py-3 sm:py-4 px-2 sm:px-8 mx-auto container gap-4">
<div className="flex items-center gap-4 min-w-0">
<SidebarTrigger />
<AppBreadcrumb />
</div>
@ -76,7 +76,7 @@ export default function Layout({ loaderData }: Route.ComponentProps) {
</header>
<div className="main-content flex-1 overflow-y-auto">
<GridBackground>
<main className="flex flex-col p-2 pb-6 pt-2 sm:p-8 sm:pt-6 mx-auto">
<main className="flex flex-col p-2 pb-6 pt-2 sm:p-8 sm:pt-6 mx-auto @container">
<Outlet />
</main>
</GridBackground>

View file

@ -0,0 +1,44 @@
import { format } from "date-fns";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "~/client/components/ui/dialog";
import { ScrollArea } from "~/client/components/ui/scroll-area";
import type { UpdateInfoDto } from "~/server/modules/system/system.dto";
interface ReleaseNotesDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
updates: UpdateInfoDto | undefined;
}
export function ReleaseNotesDialog({ open, onOpenChange, updates }: ReleaseNotesDialogProps) {
if (!updates) return null;
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-h-[80vh] flex flex-col p-0">
<DialogHeader className="p-6 pb-2">
<DialogTitle className="flex items-center gap-2">Release Notes</DialogTitle>
<DialogDescription>
Current version: {updates.currentVersion} Latest version: {updates.latestVersion}
</DialogDescription>
</DialogHeader>
<ScrollArea className="p-6 pt-2 h-[50vh] min-w-0">
<div className="space-y-8">
{updates.missedReleases.map((release) => (
<div key={release.version} className="space-y-4">
<div className="flex items-center justify-between border-b pb-2">
<h3 className="text-lg font-bold text-foreground">{release.version}</h3>
<span className="text-sm text-muted-foreground">{format(new Date(release.publishedAt), "PPP")}</span>
</div>
<div className="prose prose-sm dark:prose-invert max-w-none prose-pre:bg-muted prose-pre:text-muted-foreground prose-a:text-primary hover:prose-a:underline wrap-anywhere text-wrap prose-pre:whitespace-pre-wrap prose-pre:wrap-anywhere">
<ReactMarkdown remarkPlugins={[remarkGfm]}>{release.body}</ReactMarkdown>
</div>
</div>
))}
</div>
</ScrollArea>
</DialogContent>
</Dialog>
);
}

View file

@ -1,11 +1,12 @@
import { useState } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { Calendar, Clock, Database, HardDrive, Server, Trash2 } from "lucide-react";
import { Calendar, Clock, Database, HardDrive, Tag, Trash2, X } from "lucide-react";
import { Link, useNavigate } from "react-router";
import { toast } from "sonner";
import { ByteSize } from "~/client/components/bytes-size";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "~/client/components/ui/table";
import { Button } from "~/client/components/ui/button";
import { Checkbox } from "~/client/components/ui/checkbox";
import {
AlertDialog,
AlertDialogAction,
@ -16,8 +17,17 @@ import {
AlertDialogHeader,
AlertDialogTitle,
} from "~/client/components/ui/alert-dialog";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "~/client/components/ui/dialog";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "~/client/components/ui/select";
import { formatDuration } from "~/utils/utils";
import { deleteSnapshotMutation } from "~/client/api-client/@tanstack/react-query.gen";
import { deleteSnapshotsMutation, tagSnapshotsMutation } from "~/client/api-client/@tanstack/react-query.gen";
import { parseError } from "~/client/lib/errors";
import type { BackupSchedule, Snapshot } from "../lib/types";
import { cn } from "../lib/utils";
@ -31,68 +41,126 @@ type Props = {
export const SnapshotsTable = ({ snapshots, repositoryId, backups }: Props) => {
const navigate = useNavigate();
const queryClient = useQueryClient();
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
const [snapshotToDelete, setSnapshotToDelete] = useState<string | null>(null);
const deleteSnapshot = useMutation({
...deleteSnapshotMutation(),
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
const [showBulkDeleteConfirm, setShowBulkDeleteConfirm] = useState(false);
const [showReTagDialog, setShowReTagDialog] = useState(false);
const [targetScheduleId, setTargetScheduleId] = useState<string>("");
const deleteSnapshots = useMutation({
...deleteSnapshotsMutation(),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["listSnapshots"] });
setShowDeleteConfirm(false);
setSnapshotToDelete(null);
setShowBulkDeleteConfirm(false);
setSelectedIds(new Set());
},
});
const handleDeleteClick = (e: React.MouseEvent, snapshotId: string) => {
e.stopPropagation();
setSnapshotToDelete(snapshotId);
setShowDeleteConfirm(true);
};
const handleConfirmDelete = () => {
if (snapshotToDelete) {
toast.promise(
deleteSnapshot.mutateAsync({
path: { id: repositoryId, snapshotId: snapshotToDelete },
}),
{
loading: "Deleting snapshot...",
success: "Snapshot deleted successfully",
error: (error) => parseError(error)?.message || "Failed to delete snapshot",
},
);
}
};
const tagSnapshots = useMutation({
...tagSnapshotsMutation(),
onMutate: () => {
setShowReTagDialog(false);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["listSnapshots"] });
setShowReTagDialog(false);
setSelectedIds(new Set());
setTargetScheduleId("");
},
});
const handleRowClick = (snapshotId: string) => {
navigate(`/repositories/${repositoryId}/${snapshotId}`);
};
const toggleSelectAll = () => {
if (selectedIds.size === snapshots.length) {
setSelectedIds(new Set());
} else {
setSelectedIds(new Set(snapshots.map((s) => s.short_id)));
}
};
const handleBulkDelete = () => {
toast.promise(
deleteSnapshots.mutateAsync({
path: { id: repositoryId },
body: { snapshotIds: Array.from(selectedIds) },
}),
{
loading: `Deleting ${selectedIds.size} snapshots...`,
success: "Snapshots deleted successfully",
error: (error) => parseError(error)?.message || "Failed to delete snapshots",
},
);
};
const handleBulkReTag = () => {
const schedule = backups.find((b) => String(b.id) === targetScheduleId);
if (!schedule) return;
toast.promise(
tagSnapshots.mutateAsync({
path: { id: repositoryId },
body: {
snapshotIds: Array.from(selectedIds),
set: [schedule.shortId],
},
}),
{
loading: `Re-tagging ${selectedIds.size} snapshots...`,
success: `Snapshots re-tagged to ${schedule.name}`,
error: (error) => parseError(error)?.message || "Failed to re-tag snapshots",
},
);
};
return (
<>
<div className="overflow-x-auto">
<div className="overflow-x-auto relative">
<Table className="border-t">
<TableHeader className="bg-card-header">
<TableRow>
<TableHead className="w-10">
<Checkbox
checked={selectedIds.size === snapshots.length && snapshots.length > 0}
onCheckedChange={toggleSelectAll}
aria-label="Select all"
/>
</TableHead>
<TableHead className="uppercase">Snapshot ID</TableHead>
<TableHead className="uppercase">Schedule</TableHead>
<TableHead className="uppercase">Date & Time</TableHead>
<TableHead className="uppercase">Size</TableHead>
<TableHead className="uppercase hidden md:table-cell text-right">Duration</TableHead>
<TableHead className="uppercase hidden text-right lg:table-cell">Volume</TableHead>
<TableHead className="uppercase text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{snapshots.map((snapshot) => {
const backup = backups.find((b) => snapshot.tags.includes(b.shortId));
const isSelected = selectedIds.has(snapshot.short_id);
return (
<TableRow
key={snapshot.short_id}
className="hover:bg-accent/50 cursor-pointer"
className={cn("hover:bg-accent/50 cursor-pointer", isSelected && "bg-accent/30")}
onClick={() => handleRowClick(snapshot.short_id)}
>
<TableCell onClick={(e) => e.stopPropagation()}>
<Checkbox
checked={isSelected}
onCheckedChange={() => {
const newSelected = new Set(selectedIds);
if (newSelected.has(snapshot.short_id)) {
newSelected.delete(snapshot.short_id);
} else {
newSelected.add(snapshot.short_id);
}
setSelectedIds(newSelected);
}}
aria-label={`Select snapshot ${snapshot.short_id}`}
/>
</TableCell>
<TableCell className="font-mono text-sm">
<div className="flex items-center gap-2">
<HardDrive className="h-4 w-4 text-muted-foreground" />
@ -134,32 +202,6 @@ export const SnapshotsTable = ({ snapshots, repositoryId, backups }: Props) => {
<span className="text-sm text-muted-foreground">{formatDuration(snapshot.duration / 1000)}</span>
</div>
</TableCell>
<TableCell className="hidden lg:table-cell">
<div className="flex items-center justify-end gap-2">
<Server className={cn("h-4 w-4 text-muted-foreground", { hidden: !backup })} />
<Link
hidden={!backup}
to={backup ? `/volumes/${backup.volume.name}` : "#"}
onClick={(e) => e.stopPropagation()}
className="hover:underline"
>
<span className="text-sm">{backup ? backup.volume.name : "-"}</span>
</Link>
<span hidden={!!backup} className="text-sm text-muted-foreground">
-
</span>
</div>
</TableCell>
<TableCell className="text-right">
<Button
variant="ghost"
size="sm"
onClick={(e) => handleDeleteClick(e, snapshot.short_id)}
disabled={deleteSnapshot.isPending}
>
<Trash2 className="h-4 w-4 text-destructive" />
</Button>
</TableCell>
</TableRow>
);
})}
@ -167,27 +209,99 @@ export const SnapshotsTable = ({ snapshots, repositoryId, backups }: Props) => {
</Table>
</div>
<AlertDialog open={showDeleteConfirm} onOpenChange={setShowDeleteConfirm}>
{selectedIds.size > 0 && (
<div className="fixed bottom-6 left-1/2 -translate-x-1/2 z-50 animate-in fade-in slide-in-from-bottom-4 duration-300">
<div className="bg-card border shadow-2xl rounded-full px-4 py-2 flex items-center gap-4 min-w-75 justify-between">
<div className="flex items-center gap-3 border-r pr-4">
<Button
variant="ghost"
size="icon"
className="h-8 w-8 rounded-full"
onClick={() => setSelectedIds(new Set())}
>
<X className="h-4 w-4" />
</Button>
<span className="text-sm font-medium">{selectedIds.size} selected</span>
</div>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
className="rounded-full gap-2"
onClick={() => setShowReTagDialog(true)}
>
<Tag className="h-4 w-4 mr-2" />
Re-tag
</Button>
<Button
variant="destructive"
size="sm"
className="rounded-full gap-2"
onClick={() => setShowBulkDeleteConfirm(true)}
>
<Trash2 className="h-4 w-4 mr-2" />
Delete
</Button>
</div>
</div>
</div>
)}
<AlertDialog open={showBulkDeleteConfirm} onOpenChange={setShowBulkDeleteConfirm}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete snapshot?</AlertDialogTitle>
<AlertDialogTitle>Delete {selectedIds.size} snapshots?</AlertDialogTitle>
<AlertDialogDescription>
This action cannot be undone. This will permanently delete the snapshot and all its data from the
repository.
This action cannot be undone. This will permanently delete the selected snapshots and all their data from
the repository.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={handleConfirmDelete}
disabled={deleteSnapshot.isPending}
onClick={handleBulkDelete}
disabled={deleteSnapshots.isPending}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
Delete snapshot
Delete {selectedIds.size} snapshots
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<Dialog open={showReTagDialog} onOpenChange={setShowReTagDialog}>
<DialogContent>
<DialogHeader>
<DialogTitle>Re-tag snapshots</DialogTitle>
<DialogDescription>
Select a backup schedule to re-tag the {selectedIds.size} selected snapshots. All {selectedIds.size}{" "}
selected snapshots will be associated with the chosen schedule.
</DialogDescription>
</DialogHeader>
<div className="py-4">
<Select value={targetScheduleId} onValueChange={setTargetScheduleId}>
<SelectTrigger>
<SelectValue placeholder="Select a schedule" />
</SelectTrigger>
<SelectContent>
{backups.map((backup) => (
<SelectItem key={backup.id} value={String(backup.id)}>
{backup.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setShowReTagDialog(false)}>
Cancel
</Button>
<Button onClick={handleBulkReTag} disabled={!targetScheduleId}>
Apply tags
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
};

View file

@ -4,8 +4,8 @@ import { ChevronRight, MoreHorizontal } from "lucide-react";
import { cn } from "~/client/lib/utils";
function Breadcrumb({ ...props }: React.ComponentProps<"nav">) {
return <nav aria-label="breadcrumb" data-slot="breadcrumb" {...props} />;
function Breadcrumb({ className, ...props }: React.ComponentProps<"nav">) {
return <nav aria-label="breadcrumb" data-slot="breadcrumb" className={cn("min-w-0", className)} {...props} />;
}
function BreadcrumbList({ className, ...props }: React.ComponentProps<"ol">) {
@ -13,7 +13,7 @@ function BreadcrumbList({ className, ...props }: React.ComponentProps<"ol">) {
<ol
data-slot="breadcrumb-list"
className={cn(
"text-muted-foreground flex flex-wrap items-center gap-1.5 text-sm wrap-break-words sm:gap-2.5",
"text-muted-foreground flex items-center gap-1.5 text-sm sm:gap-2.5 min-w-0",
className,
)}
{...props}
@ -22,7 +22,7 @@ function BreadcrumbList({ className, ...props }: React.ComponentProps<"ol">) {
}
function BreadcrumbItem({ className, ...props }: React.ComponentProps<"li">) {
return <li data-slot="breadcrumb-item" className={cn("inline-flex items-center gap-1.5", className)} {...props} />;
return <li data-slot="breadcrumb-item" className={cn("inline-flex items-center gap-1.5 min-w-0", className)} {...props} />;
}
function BreadcrumbLink({
@ -35,7 +35,11 @@ function BreadcrumbLink({
const Comp = asChild ? Slot : "a";
return (
<Comp data-slot="breadcrumb-link" className={cn("hover:text-foreground transition-colors", className)} {...props} />
<Comp
data-slot="breadcrumb-link"
className={cn("hover:text-foreground transition-colors truncate", className)}
{...props}
/>
);
}
@ -46,7 +50,7 @@ function BreadcrumbPage({ className, ...props }: React.ComponentProps<"span">) {
role="link"
aria-disabled="true"
aria-current="page"
className={cn("text-foreground font-normal", className)}
className={cn("text-foreground font-normal truncate", className)}
{...props}
/>
);

View file

@ -10,14 +10,14 @@ function Card({ className, children, ...props }: React.ComponentProps<"div">) {
{...props}
>
<span aria-hidden="true" className="pointer-events-none absolute inset-0 z-10 select-none">
<span className="absolute left-[-2px] top-[-2px] h-0.5 w-4 bg-white/80" />
<span className="absolute left-[-2px] top-[-2px] h-4 w-0.5 bg-white/80" />
<span className="absolute right-[-2px] top-[-2px] h-0.5 w-4 bg-white/80" />
<span className="absolute right-[-2px] top-[-2px] h-4 w-0.5 bg-white/80" />
<span className="absolute left-[-2px] bottom-[-2px] h-0.5 w-4 bg-white/80" />
<span className="absolute left-[-2px] bottom-[-2px] h-4 w-0.5 bg-white/80" />
<span className="absolute right-[-2px] bottom-[-2px] h-0.5 w-4 bg-white/80" />
<span className="absolute right-[-2px] bottom-[-2px] h-4 w-0.5 bg-white/80" />
<span className="absolute -left-0.5 -top-0.5 h-0.5 w-4 bg-white/80" />
<span className="absolute -left-0.5 -top-0.5 h-4 w-0.5 bg-white/80" />
<span className="absolute -right-0.5 -top-0.5 h-0.5 w-4 bg-white/80" />
<span className="absolute -right-0.5 -top-0.5 h-4 w-0.5 bg-white/80" />
<span className="absolute -left-0.5 -bottom-0.5 h-0.5 w-4 bg-white/80" />
<span className="absolute -left-0.5 -bottom-0.5 h-4 w-0.5 bg-white/80" />
<span className="absolute -right-0.5 -bottom-0.5 h-0.5 w-4 bg-white/80" />
<span className="absolute -right-0.5 -bottom-0.5 h-4 w-0.5 bg-white/80" />
</span>
{children}
</div>

View file

@ -0,0 +1,98 @@
import * as React from "react";
import { ChevronDown } from "lucide-react";
import { cn } from "~/client/lib/utils";
interface CollapsibleProps extends React.HTMLAttributes<HTMLDivElement> {
open?: boolean;
onOpenChange?: (open: boolean) => void;
defaultOpen?: boolean;
}
const CollapsibleContext = React.createContext<{
open: boolean;
setOpen: React.Dispatch<React.SetStateAction<boolean>>;
}>({
open: false,
setOpen: () => {},
});
const Collapsible = React.forwardRef<HTMLDivElement, CollapsibleProps>(
({ className, open: controlledOpen, onOpenChange, defaultOpen = false, children, ...props }, ref) => {
const [uncontrolledOpen, setUncontrolledOpen] = React.useState(defaultOpen);
const isControlled = controlledOpen !== undefined;
const open = isControlled ? controlledOpen : uncontrolledOpen;
const setOpen = React.useCallback(
(value: React.SetStateAction<boolean>) => {
const newValue = typeof value === "function" ? value(open) : value;
if (!isControlled) {
setUncontrolledOpen(newValue);
}
onOpenChange?.(newValue);
},
[isControlled, open, onOpenChange],
);
return (
<CollapsibleContext.Provider value={{ open, setOpen }}>
<div ref={ref} className={cn(className)} {...props}>
{children}
</div>
</CollapsibleContext.Provider>
);
},
);
Collapsible.displayName = "Collapsible";
interface CollapsibleTriggerProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {}
const CollapsibleTrigger = React.forwardRef<HTMLButtonElement, CollapsibleTriggerProps>(
({ className, children, ...props }, ref) => {
const { open, setOpen } = React.useContext(CollapsibleContext);
return (
<button
ref={ref}
type="button"
className={cn(
"flex w-full items-center justify-between py-2 text-sm font-medium transition-all hover:underline [&[data-state=open]>svg]:rotate-180",
className,
)}
data-state={open ? "open" : "closed"}
onClick={() => setOpen(!open)}
{...props}
>
{children}
<ChevronDown className="h-4 w-4 shrink-0 transition-transform duration-200" />
</button>
);
},
);
CollapsibleTrigger.displayName = "CollapsibleTrigger";
interface CollapsibleContentProps extends React.HTMLAttributes<HTMLDivElement> {}
const CollapsibleContent = React.forwardRef<HTMLDivElement, CollapsibleContentProps>(
({ className, children, ...props }, ref) => {
const { open } = React.useContext(CollapsibleContext);
return (
<div
ref={ref}
className={cn(
"overflow-hidden transition-all data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down",
className,
)}
data-state={open ? "open" : "closed"}
hidden={!open}
{...props}
>
{open && children}
</div>
);
},
);
CollapsibleContent.displayName = "CollapsibleContent";
export { Collapsible, CollapsibleTrigger, CollapsibleContent };

View file

@ -57,7 +57,7 @@ export const VolumeFileBrowser = ({
if (fileBrowser.isLoading) {
return (
<div className="flex items-center justify-center h-full min-h-[200px]">
<div className="flex items-center justify-center h-full min-h-50">
<p className="text-muted-foreground">Loading files...</p>
</div>
);
@ -65,7 +65,7 @@ export const VolumeFileBrowser = ({
if (error) {
return (
<div className="flex items-center justify-center h-full min-h-[200px]">
<div className="flex items-center justify-center h-full min-h-50">
<p className="text-destructive">Failed to load files: {(error as Error).message}</p>
</div>
);
@ -73,7 +73,7 @@ export const VolumeFileBrowser = ({
if (fileBrowser.isEmpty) {
return (
<div className="flex flex-col items-center justify-center h-full text-center p-8 min-h-[200px]">
<div className="flex flex-col items-center justify-center h-full text-center p-8 min-h-50">
<FolderOpen className="mb-4 h-12 w-12 text-muted-foreground" />
<p className="text-muted-foreground">{emptyMessage}</p>
{emptyDescription && <p className="text-sm text-muted-foreground mt-2">{emptyDescription}</p>}

View file

@ -0,0 +1,18 @@
import { useQuery } from "@tanstack/react-query";
import { getUpdatesOptions } from "../api-client/@tanstack/react-query.gen";
export function useUpdates() {
const { data, isLoading, error } = useQuery({
...getUpdatesOptions(),
staleTime: 60 * 60 * 1000,
gcTime: 24 * 60 * 60 * 1000,
refetchOnWindowFocus: false,
});
return {
updates: data,
hasUpdate: data?.hasUpdate ?? false,
isLoading,
error,
};
}

View file

@ -28,3 +28,20 @@ export function slugify(input: string): string {
.replace(/[_]{2,}/g, "_")
.trim();
}
type DownloadFileMimeType = "text/plain" | "application/json";
export const downloadFile = (data: unknown, filename: string, mimeType: DownloadFileMimeType = "text/plain") => {
const content = mimeType === "application/json" && typeof data !== "string" ? JSON.stringify(data, null, 2) : data;
const blob = new Blob([content as BlobPart], { type: mimeType });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
};

View file

@ -11,6 +11,7 @@ import { Label } from "~/client/components/ui/label";
import { authMiddleware } from "~/middleware/auth";
import type { Route } from "./+types/download-recovery-key";
import { downloadResticPasswordMutation } from "~/client/api-client/@tanstack/react-query.gen";
import { downloadFile } from "~/client/lib/utils";
export const clientMiddleware = [authMiddleware];
@ -31,16 +32,7 @@ export default function DownloadRecoveryKeyPage() {
const downloadResticPassword = useMutation({
...downloadResticPasswordMutation(),
onSuccess: (data) => {
const blob = new Blob([data], { type: "text/plain" });
const url = window.URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = "restic.pass";
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
window.URL.revokeObjectURL(url);
downloadFile(data, "restic.pass");
toast.success("Recovery key downloaded successfully!");
navigate("/volumes", { replace: true });
},

View file

@ -2,6 +2,7 @@ import { arktypeResolver } from "@hookform/resolvers/arktype";
import { useQuery } from "@tanstack/react-query";
import { type } from "arktype";
import { X } from "lucide-react";
import { useCallback, useState } from "react";
import { useForm } from "react-hook-form";
import { listRepositoriesOptions } from "~/client/api-client/@tanstack/react-query.gen";
@ -169,6 +170,16 @@ export const CreateScheduleForm = ({ initialValues, formId, onSubmit, volume }:
[form],
);
const handleRemovePath = useCallback(
(pathToRemove: string) => {
const newPaths = new Set(selectedPaths);
newPaths.delete(pathToRemove);
setSelectedPaths(newPaths);
form.setValue("includePatterns", Array.from(newPaths));
},
[selectedPaths, form],
);
return (
<Form {...form}>
<form
@ -184,12 +195,12 @@ export const CreateScheduleForm = ({ initialValues, formId, onSubmit, volume }:
Schedule automated backups of <strong>{volume.name}</strong> to a secure repository.
</CardDescription>
</CardHeader>
<CardContent className="grid gap-6 md:grid-cols-2">
<CardContent className="grid gap-6 @md:grid-cols-2">
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem className="md:col-span-2">
<FormItem className="@md:col-span-2">
<FormLabel>Backup name</FormLabel>
<FormControl>
<Input placeholder="My backup" {...field} />
@ -204,7 +215,7 @@ export const CreateScheduleForm = ({ initialValues, formId, onSubmit, volume }:
control={form.control}
name="repositoryId"
render={({ field }) => (
<FormItem className="md:col-span-2">
<FormItem className="@md:col-span-2">
<FormLabel>Backup repository</FormLabel>
<FormControl>
<Select {...field} onValueChange={field.onChange}>
@ -289,7 +300,7 @@ export const CreateScheduleForm = ({ initialValues, formId, onSubmit, volume }:
control={form.control}
name="weeklyDay"
render={({ field }) => (
<FormItem className="md:col-span-2">
<FormItem className="@md:col-span-2">
<FormLabel>Execution day</FormLabel>
<FormControl>
<Select {...field} onValueChange={field.onChange}>
@ -316,7 +327,7 @@ export const CreateScheduleForm = ({ initialValues, formId, onSubmit, volume }:
control={form.control}
name="monthlyDays"
render={({ field }) => (
<FormItem className="md:col-span-2">
<FormItem className="@md:col-span-2">
<FormLabel>Days of the month</FormLabel>
<FormControl>
<div className="grid grid-cols-7 gap-4 w-max">
@ -373,8 +384,19 @@ export const CreateScheduleForm = ({ initialValues, formId, onSubmit, volume }:
<p className="text-xs text-muted-foreground mb-2">Selected paths:</p>
<div className="flex flex-wrap gap-2">
{Array.from(selectedPaths).map((path) => (
<span key={path} className="text-xs bg-accent px-2 py-1 rounded-md font-mono">
<span
key={path}
className="text-xs bg-accent px-2 py-1 rounded-md font-mono inline-flex items-center gap-1"
>
{path}
<button
type="button"
onClick={() => handleRemovePath(path)}
className="ml-1 hover:bg-destructive/20 rounded p-0.5 transition-colors"
aria-label={`Remove ${path}`}
>
<X className="h-3 w-3" />
</button>
</span>
))}
</div>
@ -490,7 +512,7 @@ export const CreateScheduleForm = ({ initialValues, formId, onSubmit, volume }:
<CardTitle>Retention policy</CardTitle>
<CardDescription>Define how many snapshots to keep. Leave empty to keep all.</CardDescription>
</CardHeader>
<CardContent className="grid gap-4 md:grid-cols-2">
<CardContent className="grid gap-4 @md:grid-cols-2">
<FormField
control={form.control}
name="keepLast"

View file

@ -196,7 +196,7 @@ export const ScheduleMirrorsConfig = ({ scheduleId, primaryRepositoryId, reposit
<Copy className="h-5 w-5" />
Mirror Repositories
</CardTitle>
<CardDescription>
<CardDescription className="hidden @md:block mt-1">
Configure secondary repositories where snapshots will be automatically copied after each backup
</CardDescription>
</div>
@ -270,9 +270,9 @@ export const ScheduleMirrorsConfig = ({ scheduleId, primaryRepositoryId, reposit
<TableHeader>
<TableRow>
<TableHead>Repository</TableHead>
<TableHead className="text-center w-[100px]">Enabled</TableHead>
<TableHead className="w-[180px]">Last Copy</TableHead>
<TableHead className="w-[50px]"></TableHead>
<TableHead className="text-center w-25">Enabled</TableHead>
<TableHead className="w-45">Last Copy</TableHead>
<TableHead className="w-12.5"></TableHead>
</TableRow>
</TableHeader>
<TableBody>

View file

@ -152,7 +152,9 @@ export const ScheduleNotificationsConfig = ({ scheduleId, destinations }: Props)
<Bell className="h-5 w-5" />
Notifications
</CardTitle>
<CardDescription>Configure which notifications to send for this backup schedule</CardDescription>
<CardDescription className="hidden @md:block mt-1">
Configure which notifications to send for this backup schedule
</CardDescription>
</div>
{!isAddingNew && availableDestinations.length > 0 && (
<Button variant="outline" size="sm" onClick={() => setIsAddingNew(true)}>
@ -198,11 +200,11 @@ export const ScheduleNotificationsConfig = ({ scheduleId, destinations }: Props)
<TableHeader>
<TableRow>
<TableHead>Destination</TableHead>
<TableHead className="text-center w-[100px]">Start</TableHead>
<TableHead className="text-center w-[100px]">Success</TableHead>
<TableHead className="text-center w-[100px]">Warnings</TableHead>
<TableHead className="text-center w-[100px]">Failures</TableHead>
<TableHead className="w-[50px]"></TableHead>
<TableHead className="text-center w-25">Start</TableHead>
<TableHead className="text-center w-25">Success</TableHead>
<TableHead className="text-center w-25">Warnings</TableHead>
<TableHead className="text-center w-25">Failures</TableHead>
<TableHead className="w-12.5"></TableHead>
</TableRow>
</TableHeader>
<TableBody>

View file

@ -81,7 +81,7 @@ export const ScheduleSummary = (props: Props) => {
<div className="space-y-4">
<Card>
<CardHeader className="space-y-4">
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<div className="flex flex-col @sm:flex-row @sm:items-center @sm:justify-between gap-4">
<div>
<CardTitle>{schedule.name}</CardTitle>
<CardDescription className="mt-1">
@ -96,7 +96,7 @@ export const ScheduleSummary = (props: Props) => {
</Link>
</CardDescription>
</div>
<div className="flex items-center gap-2 justify-between sm:justify-start">
<div className="flex items-center gap-2 justify-between @sm:justify-start">
<OnOff
isOn={schedule.enabled}
toggle={handleToggleEnabled}
@ -105,16 +105,16 @@ export const ScheduleSummary = (props: Props) => {
/>
</div>
</div>
<div className="flex flex-col sm:flex-row gap-2">
<div className="flex flex-col @lg:flex-row gap-2">
{schedule.lastBackupStatus === "in_progress" ? (
<Button variant="destructive" size="sm" onClick={handleStopBackup} className="w-full sm:w-auto">
<Button variant="destructive" size="sm" onClick={handleStopBackup} className="w-full @md:w-auto">
<Square className="h-4 w-4 mr-2" />
<span className="sm:inline">Stop backup</span>
<span>Stop backup</span>
</Button>
) : (
<Button variant="default" size="sm" onClick={handleRunBackupNow} className="w-full sm:w-auto">
<Button variant="default" size="sm" onClick={handleRunBackupNow} className="w-full @md:w-auto">
<Play className="h-4 w-4 mr-2" />
<span className="sm:inline">Backup now</span>
<span>Backup now</span>
</Button>
)}
{schedule.retentionPolicy && (
@ -123,28 +123,28 @@ export const ScheduleSummary = (props: Props) => {
size="sm"
loading={runForget.isPending}
onClick={() => setShowForgetConfirm(true)}
className="w-full sm:w-auto"
className="w-full @md:w-auto"
>
<Eraser className="h-4 w-4 mr-2" />
<span className="sm:inline">Run cleanup</span>
<span>Run cleanup</span>
</Button>
)}
<Button variant="outline" size="sm" onClick={() => setIsEditMode(true)} className="w-full sm:w-auto">
<Button variant="outline" size="sm" onClick={() => setIsEditMode(true)} className="w-full @md:w-auto">
<Pencil className="h-4 w-4 mr-2" />
<span className="sm:inline">Edit schedule</span>
<span>Edit schedule</span>
</Button>
<Button
variant="outline"
size="sm"
onClick={() => setShowDeleteConfirm(true)}
className="text-destructive hover:text-destructive w-full sm:w-auto"
className="text-destructive hover:text-destructive w-full @md:w-auto"
>
<Trash2 className="h-4 w-4 mr-2" />
<span className="sm:inline">Delete</span>
<span>Delete</span>
</Button>
</div>
</CardHeader>
<CardContent className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
<CardContent className="grid gap-4 grid-cols-1 @md:grid-cols-2 @lg:grid-cols-4">
<div>
<p className="text-xs uppercase text-muted-foreground">Schedule</p>
<p className="font-medium">{summary.scheduleLabel}</p>
@ -178,19 +178,21 @@ export const ScheduleSummary = (props: Props) => {
</div>
{schedule.lastBackupStatus === "warning" && (
<div className="md:col-span-2 lg:col-span-4">
<div className="@md:col-span-2 @lg:col-span-4">
<p className="text-xs uppercase text-muted-foreground">Warning Details</p>
<p className="font-mono text-sm text-yellow-600 whitespace-pre-wrap break-all">
<p className="font-mono text-sm text-yellow-600 whitespace-pre-wrap wrap-break-word">
{schedule.lastBackupError ??
"Last backup completed with warnings. Check your container logs for more details."}
</p>
</div>
)}
{schedule.lastBackupError && (
<div className="md:col-span-2 lg:col-span-4">
<p className="text-xs uppercase text-muted-foreground">Error Details</p>
<p className="font-mono text-sm text-red-600 whitespace-pre-wrap break-all">{schedule.lastBackupError}</p>
{schedule.lastBackupError && schedule.lastBackupStatus === "error" && (
<div className="@md:col-span-2 @lg:col-span-4">
<p className="text-xs uppercase text-muted-foreground">Error details</p>
<p className="font-mono text-sm text-red-600 whitespace-pre-wrap wrap-break-word">
{schedule.lastBackupError}
</p>
</div>
)}
</CardContent>

View file

@ -1,5 +1,5 @@
import { cn } from "~/client/lib/utils";
import { Card } from "~/client/components/ui/card";
import { Card, CardContent } from "~/client/components/ui/card";
import { ByteSize } from "~/client/components/bytes-size";
import { useEffect } from "react";
import type { ListSnapshotsResponse } from "~/client/api-client";
@ -24,9 +24,9 @@ export const SnapshotTimeline = (props: Props) => {
if (error) {
return (
<Card>
<div className="flex items-center justify-center h-24 p-4 text-center">
<p className="text-destructive">Error loading snapshots: {error}</p>
</div>
<CardContent className="flex items-center justify-center text-center">
<p className="text-destructive text-sm">{error}</p>
</CardContent>
</Card>
);
}

View file

@ -117,7 +117,7 @@ export default function Backups({ loaderData }: Route.ComponentProps) {
<div className="container mx-auto space-y-6">
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
<SortableContext items={items} strategy={rectSortingStrategy}>
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3 auto-rows-fr">
<div className="grid gap-4 @md:grid-cols-1 @lg:grid-cols-2 @2xl:grid-cols-3 auto-rows-fr">
{items.map((id) => {
const schedule = scheduleMap.get(id);
if (!schedule) return null;

View file

@ -14,10 +14,19 @@ import {
FormMessage,
} from "~/client/components/ui/form";
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 { Checkbox } from "~/client/components/ui/checkbox";
import { notificationConfigSchemaBase } from "~/schemas/notifications";
import {
CustomForm,
DiscordForm,
EmailForm,
GenericForm,
GotifyForm,
NtfyForm,
PushoverForm,
SlackForm,
TelegramForm,
} from "./notification-forms";
export const formSchema = type({
name: "2<=string<=32",
@ -48,6 +57,9 @@ const defaultValuesForType = {
slack: {
type: "slack" as const,
webhookUrl: "",
channel: "",
username: "",
iconEmoji: "",
},
discord: {
type: "discord" as const,
@ -75,6 +87,16 @@ const defaultValuesForType = {
botToken: "",
chatId: "",
},
generic: {
type: "generic" as const,
url: "",
method: "POST" as const,
contentType: "application/json",
headers: [],
useJson: true,
titleKey: "title",
messageKey: "message",
},
custom: {
type: "custom" as const,
shoutrrrUrl: "",
@ -84,7 +106,9 @@ const defaultValuesForType = {
export const CreateNotificationForm = ({ onSubmit, mode = "create", initialValues, formId, className }: Props) => {
const form = useForm<NotificationFormValues>({
resolver: arktypeResolver(cleanSchema as unknown as typeof formSchema),
defaultValues: initialValues,
defaultValues: initialValues || {
name: "",
},
resetOptions: {
keepDefaultValues: true,
keepDirtyValues: false,
@ -97,7 +121,7 @@ export const CreateNotificationForm = ({ onSubmit, mode = "create", initialValue
useEffect(() => {
if (!initialValues) {
form.reset({
name: form.getValues().name,
name: form.getValues().name || "",
...defaultValuesForType[watchedType as keyof typeof defaultValuesForType],
});
}
@ -152,6 +176,7 @@ export const CreateNotificationForm = ({ onSubmit, mode = "create", initialValue
<SelectItem value="ntfy">Ntfy</SelectItem>
<SelectItem value="pushover">Pushover</SelectItem>
<SelectItem value="telegram">Telegram</SelectItem>
<SelectItem value="generic">Generic Webhook</SelectItem>
<SelectItem value="custom">Custom (Shoutrrr URL)</SelectItem>
</SelectContent>
</Select>
@ -161,545 +186,15 @@ export const CreateNotificationForm = ({ onSubmit, mode = "create", initialValue
)}
/>
{watchedType === "email" && (
<>
<FormField
control={form.control}
name="smtpHost"
render={({ field }) => (
<FormItem>
<FormLabel>SMTP Host</FormLabel>
<FormControl>
<Input {...field} placeholder="smtp.example.com" />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="smtpPort"
render={({ field }) => (
<FormItem>
<FormLabel>SMTP Port</FormLabel>
<FormControl>
<Input
{...field}
type="number"
placeholder="587"
onChange={(e) => field.onChange(Number(e.target.value))}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="username"
render={({ field }) => (
<FormItem>
<FormLabel>Username (Optional)</FormLabel>
<FormControl>
<Input {...field} placeholder="user@example.com" />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="password"
render={({ field }) => (
<FormItem>
<FormLabel>Password (Optional)</FormLabel>
<FormControl>
<SecretInput {...field} placeholder="••••••••" />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="from"
render={({ field }) => (
<FormItem>
<FormLabel>From Address</FormLabel>
<FormControl>
<Input {...field} placeholder="noreply@example.com" />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="to"
render={({ field }) => (
<FormItem>
<FormLabel>To Addresses</FormLabel>
<FormControl>
<Input
{...field}
placeholder="user@example.com, admin@example.com"
value={Array.isArray(field.value) ? field.value.join(", ") : ""}
onChange={(e) => field.onChange(e.target.value.split(",").map((email) => email.trim()))}
/>
</FormControl>
<FormDescription>Comma-separated list of recipient email addresses.</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="useTLS"
render={({ field }) => (
<FormItem className="flex flex-row items-center space-x-3">
<FormControl>
<Checkbox checked={field.value} onCheckedChange={field.onChange} />
</FormControl>
<div className="space-y-1 leading-none">
<FormLabel>Use TLS</FormLabel>
<FormDescription>Enable TLS encryption for SMTP connection.</FormDescription>
</div>
</FormItem>
)}
/>
</>
)}
{watchedType === "slack" && (
<>
<FormField
control={form.control}
name="webhookUrl"
render={({ field }) => (
<FormItem>
<FormLabel>Webhook URL</FormLabel>
<FormControl>
<Input
{...field}
placeholder="https://hooks.slack.com/services/T00000000/B00000000/XXXXXXXXXXXXXXXXXXXX"
/>
</FormControl>
<FormDescription>Get this from your Slack app's Incoming Webhooks settings.</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="channel"
render={({ field }) => (
<FormItem>
<FormLabel>Channel (Optional)</FormLabel>
<FormControl>
<Input {...field} placeholder="#backups" />
</FormControl>
<FormDescription>Override the default channel (use # for channels, @ for users).</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="username"
render={({ field }) => (
<FormItem>
<FormLabel>Bot Username (Optional)</FormLabel>
<FormControl>
<Input {...field} placeholder="Zerobyte" />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="iconEmoji"
render={({ field }) => (
<FormItem>
<FormLabel>Icon Emoji (Optional)</FormLabel>
<FormControl>
<Input {...field} placeholder=":floppy_disk:" />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</>
)}
{watchedType === "discord" && (
<>
<FormField
control={form.control}
name="webhookUrl"
render={({ field }) => (
<FormItem>
<FormLabel>Webhook URL</FormLabel>
<FormControl>
<Input {...field} placeholder="https://discord.com/api/webhooks/WEBHOOK_ID/WEBHOOK_TOKEN" />
</FormControl>
<FormDescription>Get this from your Discord server's Integrations settings.</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="username"
render={({ field }) => (
<FormItem>
<FormLabel>Bot Username (Optional)</FormLabel>
<FormControl>
<Input {...field} placeholder="Zerobyte" />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="avatarUrl"
render={({ field }) => (
<FormItem>
<FormLabel>Avatar URL (Optional)</FormLabel>
<FormControl>
<Input {...field} placeholder="https://example.com/avatar.png" />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="threadId"
render={({ field }) => (
<FormItem>
<FormLabel>Thread ID (Optional)</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormDescription>
ID of the thread to post messages in. Leave empty to post in the main channel.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</>
)}
{watchedType === "gotify" && (
<>
<FormField
control={form.control}
name="serverUrl"
render={({ field }) => (
<FormItem>
<FormLabel>Server URL</FormLabel>
<FormControl>
<Input {...field} placeholder="https://gotify.example.com" />
</FormControl>
<FormDescription>Your self-hosted Gotify server URL.</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="token"
render={({ field }) => (
<FormItem>
<FormLabel>App Token</FormLabel>
<FormControl>
<SecretInput {...field} placeholder="••••••••" />
</FormControl>
<FormDescription>Application token from Gotify.</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="priority"
render={({ field }) => (
<FormItem>
<FormLabel>Priority</FormLabel>
<FormControl>
<Input
{...field}
type="number"
min={0}
max={10}
onChange={(e) => field.onChange(Number(e.target.value))}
/>
</FormControl>
<FormDescription>Priority level (0-10, where 10 is highest).</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="path"
render={({ field }) => (
<FormItem>
<FormLabel>Path (Optional)</FormLabel>
<FormControl>
<Input {...field} placeholder="/custom/path" />
</FormControl>
<FormDescription>Custom path on the Gotify server, if applicable.</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</>
)}
{watchedType === "ntfy" && (
<>
<FormField
control={form.control}
name="serverUrl"
render={({ field }) => (
<FormItem>
<FormLabel>Server URL (Optional)</FormLabel>
<FormControl>
<Input {...field} placeholder="https://ntfy.example.com" />
</FormControl>
<FormDescription>Leave empty to use ntfy.sh public service.</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="topic"
render={({ field }) => (
<FormItem>
<FormLabel>Topic</FormLabel>
<FormControl>
<Input {...field} placeholder="ironmount-backups" />
</FormControl>
<FormDescription>The ntfy topic name to publish to.</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="username"
render={({ field }) => (
<FormItem>
<FormLabel>Username (Optional)</FormLabel>
<FormControl>
<Input {...field} placeholder="username" />
</FormControl>
<FormDescription>Username for server authentication, if required.</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="password"
render={({ field }) => (
<FormItem>
<FormLabel>Password (Optional)</FormLabel>
<FormControl>
<SecretInput {...field} placeholder="••••••••" />
</FormControl>
<FormDescription>Password for server authentication, if required.</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="accessToken"
render={({ field }) => (
<FormItem>
<FormLabel>Access token (Optional)</FormLabel>
<FormControl>
<SecretInput {...field} placeholder="••••••••" />
</FormControl>
<FormDescription>
Access token for server authentication. Will take precedence over username/password if set.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="priority"
render={({ field }) => (
<FormItem>
<FormLabel>Priority</FormLabel>
<Select onValueChange={field.onChange} defaultValue={String(field.value)} value={String(field.value)}>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select priority" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="max">Max (5)</SelectItem>
<SelectItem value="high">High (4)</SelectItem>
<SelectItem value="default">Default (3)</SelectItem>
<SelectItem value="low">Low (2)</SelectItem>
<SelectItem value="min">Min (1)</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
</>
)}
{watchedType === "pushover" && (
<>
<FormField
control={form.control}
name="userKey"
render={({ field }) => (
<FormItem>
<FormLabel>User Key</FormLabel>
<FormControl>
<Input {...field} placeholder="uQiRzpo4DXghDmr9QzzfQu27cmVRsG" />
</FormControl>
<FormDescription>Your Pushover user key from the dashboard.</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="apiToken"
render={({ field }) => (
<FormItem>
<FormLabel>API Token</FormLabel>
<FormControl>
<SecretInput {...field} placeholder="••••••••" />
</FormControl>
<FormDescription>Application API token from your Pushover application.</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="devices"
render={({ field }) => (
<FormItem>
<FormLabel>Devices (Optional)</FormLabel>
<FormControl>
<Input {...field} placeholder="iphone,android" />
</FormControl>
<FormDescription>Comma-separated list of device names. Leave empty for all devices.</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="priority"
render={({ field }) => (
<FormItem>
<FormLabel>Priority</FormLabel>
<Select
onValueChange={(value) => field.onChange(Number(value))}
defaultValue={String(field.value)}
value={String(field.value)}
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select priority" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="-1">Low (-1)</SelectItem>
<SelectItem value="0">Normal (0)</SelectItem>
<SelectItem value="1">High (1)</SelectItem>
</SelectContent>
</Select>
<FormDescription>Message priority level.</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</>
)}
{watchedType === "telegram" && (
<>
<FormField
control={form.control}
name="botToken"
render={({ field }) => (
<FormItem>
<FormLabel>Bot Token</FormLabel>
<FormControl>
<SecretInput {...field} placeholder="123456789:ABC-DEF1234ghIkl-zyx57W2v1u123ew11" />
</FormControl>
<FormDescription>
Telegram bot token. Get this from BotFather when you create your bot.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="chatId"
render={({ field }) => (
<FormItem>
<FormLabel>Chat ID</FormLabel>
<FormControl>
<Input {...field} placeholder="-1231234567890" />
</FormControl>
<FormDescription>Telegram chat ID to send notifications to.</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</>
)}
{watchedType === "custom" && (
<FormField
control={form.control}
name="shoutrrrUrl"
render={({ field }) => (
<FormItem>
<FormLabel>Shoutrrr URL</FormLabel>
<FormControl>
<Input
{...field}
placeholder="smtp://user:pass@smtp.gmail.com:587/?from=you@gmail.com&to=recipient@example.com"
/>
</FormControl>
<FormDescription>
Direct Shoutrrr URL for power users. See&nbsp;
<a
href="https://shoutrrr.nickfedor.com/v0.12.0/services/overview/"
target="_blank"
rel="noopener noreferrer"
className="text-strong-accent hover:underline"
>
Shoutrrr documentation
</a>
&nbsp;for supported services and URL formats.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
)}
{watchedType === "email" && <EmailForm form={form} />}
{watchedType === "slack" && <SlackForm form={form} />}
{watchedType === "discord" && <DiscordForm form={form} />}
{watchedType === "gotify" && <GotifyForm form={form} />}
{watchedType === "ntfy" && <NtfyForm form={form} />}
{watchedType === "pushover" && <PushoverForm form={form} />}
{watchedType === "telegram" && <TelegramForm form={form} />}
{watchedType === "generic" && <GenericForm form={form} />}
{watchedType === "custom" && <CustomForm form={form} />}
</form>
</Form>
);

View file

@ -0,0 +1,41 @@
import type { UseFormReturn } from "react-hook-form";
import { FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from "~/client/components/ui/form";
import { Input } from "~/client/components/ui/input";
import type { NotificationFormValues } from "../create-notification-form";
type Props = {
form: UseFormReturn<NotificationFormValues>;
};
export const CustomForm = ({ form }: Props) => {
return (
<FormField
control={form.control}
name="shoutrrrUrl"
render={({ field }) => (
<FormItem>
<FormLabel>Shoutrrr URL</FormLabel>
<FormControl>
<Input
{...field}
placeholder="smtp://user:pass@smtp.gmail.com:587/?from=you@gmail.com&to=recipient@example.com"
/>
</FormControl>
<FormDescription>
Direct Shoutrrr URL for power users. See&nbsp;
<a
href="https://shoutrrr.nickfedor.com/v0.12.0/services/overview/"
target="_blank"
rel="noopener noreferrer"
className="text-strong-accent hover:underline"
>
Shoutrrr documentation
</a>
&nbsp;for supported services and URL formats.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
);
};

View file

@ -0,0 +1,71 @@
import type { UseFormReturn } from "react-hook-form";
import { FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from "~/client/components/ui/form";
import { Input } from "~/client/components/ui/input";
import type { NotificationFormValues } from "../create-notification-form";
type Props = {
form: UseFormReturn<NotificationFormValues>;
};
export const DiscordForm = ({ form }: Props) => {
return (
<>
<FormField
control={form.control}
name="webhookUrl"
render={({ field }) => (
<FormItem>
<FormLabel>Webhook URL</FormLabel>
<FormControl>
<Input {...field} placeholder="https://discord.com/api/webhooks/WEBHOOK_ID/WEBHOOK_TOKEN" />
</FormControl>
<FormDescription>Get this from your Discord server's Integrations settings.</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="username"
render={({ field }) => (
<FormItem>
<FormLabel>Bot Username (Optional)</FormLabel>
<FormControl>
<Input {...field} placeholder="Zerobyte" />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="avatarUrl"
render={({ field }) => (
<FormItem>
<FormLabel>Avatar URL (Optional)</FormLabel>
<FormControl>
<Input {...field} placeholder="https://example.com/avatar.png" />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="threadId"
render={({ field }) => (
<FormItem>
<FormLabel>Thread ID (Optional)</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormDescription>
ID of the thread to post messages in. Leave empty to post in the main channel.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</>
);
};

View file

@ -0,0 +1,128 @@
import type { UseFormReturn } from "react-hook-form";
import { FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from "~/client/components/ui/form";
import { Input } from "~/client/components/ui/input";
import { SecretInput } from "~/client/components/ui/secret-input";
import { Checkbox } from "~/client/components/ui/checkbox";
import type { NotificationFormValues } from "../create-notification-form";
type Props = {
form: UseFormReturn<NotificationFormValues>;
};
export const EmailForm = ({ form }: Props) => {
return (
<>
<FormField
control={form.control}
name="smtpHost"
render={({ field }) => (
<FormItem>
<FormLabel>SMTP Host</FormLabel>
<FormControl>
<Input {...field} placeholder="smtp.example.com" />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="smtpPort"
render={({ field }) => (
<FormItem>
<FormLabel>SMTP Port</FormLabel>
<FormControl>
<Input
{...field}
type="number"
placeholder="587"
onChange={(e) => field.onChange(Number(e.target.value))}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="username"
render={({ field }) => (
<FormItem>
<FormLabel>Username (Optional)</FormLabel>
<FormControl>
<Input {...field} placeholder="user@example.com" />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="password"
render={({ field }) => (
<FormItem>
<FormLabel>Password (Optional)</FormLabel>
<FormControl>
<SecretInput {...field} placeholder="••••••••" />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="from"
render={({ field }) => (
<FormItem>
<FormLabel>From Address</FormLabel>
<FormControl>
<Input {...field} placeholder="noreply@example.com" />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="to"
render={({ field }) => (
<FormItem>
<FormLabel>To Addresses</FormLabel>
<FormControl>
<Input
{...field}
placeholder="user@example.com, admin@example.com"
value={Array.isArray(field.value) ? field.value.join(", ") : ""}
onChange={(e) =>
field.onChange(
e.target.value
.split(",")
.map((email) => email.trim())
.filter(Boolean),
)
}
/>
</FormControl>
<FormDescription>Comma-separated list of recipient email addresses.</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="useTLS"
render={({ field }) => (
<FormItem className="flex flex-row items-center space-x-3">
<FormControl>
<Checkbox checked={field.value} onCheckedChange={field.onChange} />
</FormControl>
<div className="space-y-1 leading-none">
<FormLabel>Use TLS</FormLabel>
<FormDescription>Enable TLS encryption for SMTP connection.</FormDescription>
</div>
</FormItem>
)}
/>
</>
);
};

View file

@ -0,0 +1,173 @@
import type { UseFormReturn } from "react-hook-form";
import { FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from "~/client/components/ui/form";
import { Input } from "~/client/components/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "~/client/components/ui/select";
import { Checkbox } from "~/client/components/ui/checkbox";
import { Textarea } from "~/client/components/ui/textarea";
import { CodeBlock } from "~/client/components/ui/code-block";
import { Label } from "~/client/components/ui/label";
import type { NotificationFormValues } from "../create-notification-form";
type Props = {
form: UseFormReturn<NotificationFormValues>;
};
const WebhookPreview = ({ values }: { values: Partial<NotificationFormValues> }) => {
if (values.type !== "generic") return null;
const contentType = values.contentType || "application/json";
const headers = values.headers || [];
const useJson = values.useJson;
const titleKey = values.titleKey || "title";
const messageKey = values.messageKey || "message";
let body = "";
if (useJson) {
body = JSON.stringify(
{
[titleKey]: "Notification title",
[messageKey]: "Notification message",
},
null,
2,
);
} else {
body = "Notification message";
}
const previewCode = `${values.method} ${values.url}\nContent-Type: ${contentType}${headers.length > 0 ? `\n${headers.join("\n")}` : ""}
${body}`;
return (
<div className="space-y-2 pt-4 border-t">
<Label>Request Preview</Label>
<CodeBlock code={previewCode} filename="HTTP Request" />
<p className="text-[0.8rem] text-muted-foreground">This is a preview of the HTTP request that will be sent.</p>
</div>
);
};
export const GenericForm = ({ form }: Props) => {
const watchedValues = form.watch();
return (
<>
<FormField
control={form.control}
name="url"
render={({ field }) => (
<FormItem>
<FormLabel>Webhook URL</FormLabel>
<FormControl>
<Input {...field} placeholder="https://api.example.com/webhook" />
</FormControl>
<FormDescription>The target URL for the webhook.</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="method"
render={({ field }) => (
<FormItem>
<FormLabel>Method</FormLabel>
<Select onValueChange={field.onChange} value={field.value}>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select method" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="GET">GET</SelectItem>
<SelectItem value="POST">POST</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="contentType"
render={({ field }) => (
<FormItem>
<FormLabel>Content Type</FormLabel>
<FormControl>
<Input {...field} placeholder="application/json" />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="headers"
render={({ field }) => (
<FormItem>
<FormLabel>Headers</FormLabel>
<FormControl>
<Textarea
{...field}
placeholder="Authorization: Bearer token&#10;X-Custom-Header: value"
value={Array.isArray(field.value) ? field.value.join("\n") : ""}
onChange={(e) => field.onChange(e.target.value.split("\n"))}
/>
</FormControl>
<FormDescription>One header per line in Key: Value format.</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="useJson"
render={({ field }) => (
<FormItem className="flex flex-row items-center space-x-3">
<FormControl>
<Checkbox checked={field.value} onCheckedChange={field.onChange} />
</FormControl>
<div className="space-y-1 leading-none">
<FormLabel>Use JSON Template</FormLabel>
<FormDescription>Send the message as a JSON object.</FormDescription>
</div>
</FormItem>
)}
/>
{form.watch("useJson") && (
<>
<FormField
control={form.control}
name="titleKey"
render={({ field }) => (
<FormItem>
<FormLabel>Title Key</FormLabel>
<FormControl>
<Input {...field} placeholder="title" />
</FormControl>
<FormDescription>The JSON key for the notification title.</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="messageKey"
render={({ field }) => (
<FormItem>
<FormLabel>Message Key</FormLabel>
<FormControl>
<Input {...field} placeholder="message" />
</FormControl>
<FormDescription>The JSON key for the notification message.</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</>
)}
<WebhookPreview values={watchedValues} />
</>
);
};

View file

@ -0,0 +1,78 @@
import type { UseFormReturn } from "react-hook-form";
import { FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from "~/client/components/ui/form";
import { Input } from "~/client/components/ui/input";
import { SecretInput } from "~/client/components/ui/secret-input";
import type { NotificationFormValues } from "../create-notification-form";
type Props = {
form: UseFormReturn<NotificationFormValues>;
};
export const GotifyForm = ({ form }: Props) => {
return (
<>
<FormField
control={form.control}
name="serverUrl"
render={({ field }) => (
<FormItem>
<FormLabel>Server URL</FormLabel>
<FormControl>
<Input {...field} placeholder="https://gotify.example.com" />
</FormControl>
<FormDescription>Your self-hosted Gotify server URL.</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="token"
render={({ field }) => (
<FormItem>
<FormLabel>App Token</FormLabel>
<FormControl>
<SecretInput {...field} placeholder="••••••••" />
</FormControl>
<FormDescription>Application token from Gotify.</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="priority"
render={({ field }) => (
<FormItem>
<FormLabel>Priority</FormLabel>
<FormControl>
<Input
{...field}
type="number"
min={0}
max={10}
onChange={(e) => field.onChange(Number(e.target.value))}
/>
</FormControl>
<FormDescription>Priority level (0-10, where 10 is highest).</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="path"
render={({ field }) => (
<FormItem>
<FormLabel>Path (Optional)</FormLabel>
<FormControl>
<Input {...field} placeholder="/custom/path" />
</FormControl>
<FormDescription>Custom path on the Gotify server, if applicable.</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</>
);
};

View file

@ -0,0 +1,9 @@
export * from "./email-form";
export * from "./slack-form";
export * from "./discord-form";
export * from "./gotify-form";
export * from "./ntfy-form";
export * from "./pushover-form";
export * from "./telegram-form";
export * from "./generic-form";
export * from "./custom-form";

View file

@ -0,0 +1,113 @@
import type { UseFormReturn } from "react-hook-form";
import { FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from "~/client/components/ui/form";
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 type { NotificationFormValues } from "../create-notification-form";
type Props = {
form: UseFormReturn<NotificationFormValues>;
};
export const NtfyForm = ({ form }: Props) => {
return (
<>
<FormField
control={form.control}
name="serverUrl"
render={({ field }) => (
<FormItem>
<FormLabel>Server URL (Optional)</FormLabel>
<FormControl>
<Input {...field} placeholder="https://ntfy.example.com" />
</FormControl>
<FormDescription>Leave empty to use ntfy.sh public service.</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="topic"
render={({ field }) => (
<FormItem>
<FormLabel>Topic</FormLabel>
<FormControl>
<Input {...field} placeholder="zerobyte-backups" />
</FormControl>
<FormDescription>The ntfy topic name to publish to.</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="username"
render={({ field }) => (
<FormItem>
<FormLabel>Username (Optional)</FormLabel>
<FormControl>
<Input {...field} placeholder="username" />
</FormControl>
<FormDescription>Username for server authentication, if required.</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="password"
render={({ field }) => (
<FormItem>
<FormLabel>Password (Optional)</FormLabel>
<FormControl>
<SecretInput {...field} placeholder="••••••••" />
</FormControl>
<FormDescription>Password for server authentication, if required.</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="accessToken"
render={({ field }) => (
<FormItem>
<FormLabel>Access token (Optional)</FormLabel>
<FormControl>
<SecretInput {...field} placeholder="••••••••" />
</FormControl>
<FormDescription>
Access token for server authentication. Will take precedence over username/password if set.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="priority"
render={({ field }) => (
<FormItem>
<FormLabel>Priority</FormLabel>
<Select onValueChange={field.onChange} defaultValue={String(field.value)} value={String(field.value)}>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select priority" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="max">Max (5)</SelectItem>
<SelectItem value="high">High (4)</SelectItem>
<SelectItem value="default">Default (3)</SelectItem>
<SelectItem value="low">Low (2)</SelectItem>
<SelectItem value="min">Min (1)</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
</>
);
};

View file

@ -0,0 +1,86 @@
import type { UseFormReturn } from "react-hook-form";
import { FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from "~/client/components/ui/form";
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 type { NotificationFormValues } from "../create-notification-form";
type Props = {
form: UseFormReturn<NotificationFormValues>;
};
export const PushoverForm = ({ form }: Props) => {
return (
<>
<FormField
control={form.control}
name="userKey"
render={({ field }) => (
<FormItem>
<FormLabel>User Key</FormLabel>
<FormControl>
<Input {...field} placeholder="uQiRzpo4DXghDmr9QzzfQu27cmVRsG" />
</FormControl>
<FormDescription>Your Pushover user key from the dashboard.</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="apiToken"
render={({ field }) => (
<FormItem>
<FormLabel>API Token</FormLabel>
<FormControl>
<SecretInput {...field} placeholder="••••••••" />
</FormControl>
<FormDescription>Application API token from your Pushover application.</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="devices"
render={({ field }) => (
<FormItem>
<FormLabel>Devices (Optional)</FormLabel>
<FormControl>
<Input {...field} placeholder="iphone,android" />
</FormControl>
<FormDescription>Comma-separated list of device names. Leave empty for all devices.</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="priority"
render={({ field }) => (
<FormItem>
<FormLabel>Priority</FormLabel>
<Select
onValueChange={(value) => field.onChange(Number(value))}
defaultValue={String(field.value)}
value={String(field.value)}
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select priority" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="-1">Low (-1)</SelectItem>
<SelectItem value="0">Normal (0)</SelectItem>
<SelectItem value="1">High (1)</SelectItem>
</SelectContent>
</Select>
<FormDescription>Message priority level.</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</>
);
};

View file

@ -0,0 +1,72 @@
import type { UseFormReturn } from "react-hook-form";
import { FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from "~/client/components/ui/form";
import { Input } from "~/client/components/ui/input";
import type { NotificationFormValues } from "../create-notification-form";
type Props = {
form: UseFormReturn<NotificationFormValues>;
};
export const SlackForm = ({ form }: Props) => {
return (
<>
<FormField
control={form.control}
name="webhookUrl"
render={({ field }) => (
<FormItem>
<FormLabel>Webhook URL</FormLabel>
<FormControl>
<Input
{...field}
placeholder="https://hooks.slack.com/services/T00000000/B00000000/XXXXXXXXXXXXXXXXXXXX"
/>
</FormControl>
<FormDescription>Get this from your Slack app's Incoming Webhooks settings.</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="channel"
render={({ field }) => (
<FormItem>
<FormLabel>Channel (Optional)</FormLabel>
<FormControl>
<Input {...field} placeholder="#backups" />
</FormControl>
<FormDescription>Override the default channel (use # for channels, @ for users).</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="username"
render={({ field }) => (
<FormItem>
<FormLabel>Bot Username (Optional)</FormLabel>
<FormControl>
<Input {...field} placeholder="Zerobyte" />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="iconEmoji"
render={({ field }) => (
<FormItem>
<FormLabel>Icon Emoji (Optional)</FormLabel>
<FormControl>
<Input {...field} placeholder=":floppy_disk:" />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</>
);
};

View file

@ -0,0 +1,44 @@
import type { UseFormReturn } from "react-hook-form";
import { FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from "~/client/components/ui/form";
import { Input } from "~/client/components/ui/input";
import { SecretInput } from "~/client/components/ui/secret-input";
import type { NotificationFormValues } from "../create-notification-form";
type Props = {
form: UseFormReturn<NotificationFormValues>;
};
export const TelegramForm = ({ form }: Props) => {
return (
<>
<FormField
control={form.control}
name="botToken"
render={({ field }) => (
<FormItem>
<FormLabel>Bot Token</FormLabel>
<FormControl>
<SecretInput {...field} placeholder="123456789:ABC-DEF1234ghIkl-zyx57W2v1u123ew11" />
</FormControl>
<FormDescription>Telegram bot token. Get this from BotFather when you create your bot.</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="chatId"
render={({ field }) => (
<FormItem>
<FormLabel>Chat ID</FormLabel>
<FormControl>
<Input {...field} placeholder="-1231234567890" />
</FormControl>
<FormDescription>Telegram chat ID to send notifications to.</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</>
);
};

View file

@ -200,10 +200,7 @@ export default function NotificationDetailsPage({ loaderData }: Route.ComponentP
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>
<X className="h-4 w-4 mr-2" />
Cancel
</AlertDialogCancel>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction onClick={handleConfirmDelete}>
<Trash2 className="h-4 w-4 mr-2" />
Delete

View file

@ -31,6 +31,7 @@ import {
RcloneRepositoryForm,
RestRepositoryForm,
SftpRepositoryForm,
AdvancedForm,
} from "./repository-forms";
export const formSchema = type({
@ -225,12 +226,13 @@ export const CreateRepositoryForm = ({
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="default">Use Zerobyte's password</SelectItem>
<SelectItem value="default">Use the existing recovery key</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.
Choose whether to use Zerobyte's recovery key (which you downloaded when creating your account) or enter
a custom password for the existing repository.
</FormDescription>
</FormItem>
@ -268,6 +270,8 @@ export const CreateRepositoryForm = ({
{watchedBackend === "rest" && <RestRepositoryForm form={form} />}
{watchedBackend === "sftp" && <SftpRepositoryForm form={form} />}
<AdvancedForm form={form} />
{mode === "update" && (
<Button type="submit" className="w-full" loading={loading}>
<Save className="h-4 w-4 mr-2" />

View file

@ -0,0 +1,112 @@
import type { UseFormReturn } from "react-hook-form";
import {
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "../../../../components/ui/form";
import { Textarea } from "../../../../components/ui/textarea";
import { Checkbox } from "../../../../components/ui/checkbox";
import { Tooltip, TooltipContent, TooltipTrigger } from "../../../../components/ui/tooltip";
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "../../../../components/ui/collapsible";
import type { RepositoryFormValues } from "../create-repository-form";
import { cn } from "~/client/lib/utils";
type Props = {
form: UseFormReturn<RepositoryFormValues>;
};
export const AdvancedForm = ({ form }: Props) => {
const insecureTls = form.watch("insecureTls");
const cacert = form.watch("cacert");
return (
<Collapsible>
<CollapsibleTrigger className="w-full text-muted-foreground hover:no-underline">
Advanced Settings
</CollapsibleTrigger>
<CollapsibleContent className="pb-4 space-y-4">
<FormField
control={form.control}
name="insecureTls"
render={({ field }) => (
<FormItem className="flex flex-row items-start space-x-3 space-y-0 rounded-md border p-4">
<FormControl>
<Tooltip delayDuration={500}>
<TooltipTrigger asChild>
<div>
<Checkbox
checked={field.value ?? false}
disabled={!!cacert}
onCheckedChange={(checked) => {
field.onChange(checked);
}}
/>
</div>
</TooltipTrigger>
<TooltipContent className={cn({ hidden: !cacert })}>
<p className="max-w-xs">
This option is disabled because a CA certificate is provided. Remove the CA certificate to skip
TLS validation instead.
</p>
</TooltipContent>
</Tooltip>
</FormControl>
<div className="space-y-1 leading-none">
<FormLabel>Skip TLS certificate verification</FormLabel>
<FormDescription>
Disable TLS certificate verification for HTTPS connections with self-signed certificates. This is
insecure and should only be used for testing.
</FormDescription>
</div>
</FormItem>
)}
/>
<FormField
control={form.control}
name="cacert"
render={({ field }) => (
<FormItem>
<FormLabel>CA Certificate (Optional)</FormLabel>
<FormControl>
<Tooltip delayDuration={500}>
<TooltipTrigger asChild>
<div>
<Textarea
placeholder={"-----BEGIN CERTIFICATE-----\n...\n-----END CERTIFICATE-----"}
rows={6}
disabled={insecureTls}
{...field}
/>
</div>
</TooltipTrigger>
<TooltipContent className={cn({ hidden: !insecureTls })}>
<p className="max-w-xs">
CA certificate is disabled because TLS validation is being skipped. Uncheck "Skip TLS Certificate
Verification" to provide a custom CA certificate.
</p>
</TooltipContent>
</Tooltip>
</FormControl>
<FormDescription>
Custom CA certificate for self-signed certificates (PEM format). This applies to HTTPS
connections.&nbsp;
<a
href="https://restic.readthedocs.io/en/stable/030_preparing_a_new_repo.html#rest-server"
target="_blank"
rel="noopener noreferrer"
className="text-primary hover:underline"
>
Learn more
</a>
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</CollapsibleContent>
</Collapsible>
);
};

View file

@ -6,3 +6,4 @@ export { AzureRepositoryForm } from "./azure-repository-form";
export { RcloneRepositoryForm } from "./rclone-repository-form";
export { RestRepositoryForm } from "./rest-repository-form";
export { SftpRepositoryForm } from "./sftp-repository-form";
export { AdvancedForm } from "./advanced-tls-form";

View file

@ -19,7 +19,7 @@ import {
} from "~/client/components/ui/alert-dialog";
import type { Repository } from "~/client/lib/types";
import { updateRepositoryMutation } from "~/client/api-client/@tanstack/react-query.gen";
import type { CompressionMode } from "~/schemas/restic";
import type { CompressionMode, RepositoryConfig } from "~/schemas/restic";
type Props = {
repository: Repository;
@ -59,6 +59,8 @@ export const RepositoryInfoTabContent = ({ repository }: Props) => {
const hasChanges =
name !== repository.name || compressionMode !== ((repository.compressionMode as CompressionMode) || "off");
const config = repository.config as RepositoryConfig;
return (
<>
<Card className="p-6">
@ -116,6 +118,26 @@ export const RepositoryInfoTabContent = ({ repository }: Props) => {
{repository.lastChecked ? new Date(repository.lastChecked).toLocaleString() : "Never"}
</p>
</div>
{config.cacert && (
<div>
<div className="text-sm font-medium text-muted-foreground">CA Certificate</div>
<p className="mt-1 text-sm">
<span className="text-green-500">configured</span>
</p>
</div>
)}
{"insecureTls" in config && (
<div>
<div className="text-sm font-medium text-muted-foreground">TLS Certificate Validation</div>
<p className="mt-1 text-sm">
{config.insecureTls ? (
<span className="text-red-500">disabled</span>
) : (
<span className="text-green-500">enabled</span>
)}
</p>
</div>
)}
</div>
</div>
@ -125,7 +147,7 @@ export const RepositoryInfoTabContent = ({ repository }: Props) => {
<h3 className="text-lg font-semibold text-red-500">Last Error</h3>
</div>
<div className="bg-red-500/10 border border-red-500/20 rounded-md p-4">
<p className="text-sm text-red-500">{repository.lastError}</p>
<p className="text-sm text-red-500 wrap-break-word">{repository.lastError}</p>
</div>
</div>
)}

View file

@ -46,13 +46,13 @@ export const RepositorySnapshotsTabContent = ({ repository }: Props) => {
<Card>
<CardContent className="flex flex-col items-center justify-center text-center py-12">
<Database className="mb-4 h-12 w-12 text-destructive" />
<p className="text-destructive font-semibold">Repository Error</p>
<p className="text-destructive font-semibold">Repository error</p>
<p className="text-sm text-muted-foreground mt-2">
This repository is in an error state and cannot be accessed.
</p>
{repository.lastError && (
<div className="mt-4 max-w-md bg-destructive/10 border border-destructive/20 rounded-md p-3">
<p className="text-sm text-destructive">{repository.lastError}</p>
<div className="mt-4 w-full max-w-md bg-destructive/10 border border-destructive/20 rounded-md p-3">
<p className="text-sm text-destructive wrap-break-word">{repository.lastError}</p>
</div>
)}
</CardContent>

View file

@ -24,6 +24,7 @@ import {
downloadResticPasswordMutation,
logoutMutation,
} from "~/client/api-client/@tanstack/react-query.gen";
import { downloadFile } from "~/client/lib/utils";
export const handle = {
breadcrumb: () => [{ label: "Settings" }],
@ -79,16 +80,7 @@ export default function Settings({ loaderData }: Route.ComponentProps) {
const downloadResticPassword = useMutation({
...downloadResticPasswordMutation(),
onSuccess: (data) => {
const blob = new Blob([data], { type: "text/plain" });
const url = window.URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = "restic.pass";
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
window.URL.revokeObjectURL(url);
downloadFile(data, "restic.pass");
toast.success("Restic password file downloaded successfully");
setDownloadDialogOpen(false);
setDownloadPassword("");
@ -207,7 +199,7 @@ export default function Settings({ loaderData }: Route.ComponentProps) {
<Download className="size-5" />
Backup Recovery Key
</CardTitle>
<CardDescription className="mt-1.5">Download your Restic password file for disaster recovery</CardDescription>
<CardDescription className="mt-1.5">Download your recovery key for Restic backups</CardDescription>
</div>
<CardContent className="p-6 space-y-4">
<p className="text-sm text-muted-foreground max-w-2xl">
@ -220,15 +212,15 @@ export default function Settings({ loaderData }: Route.ComponentProps) {
<DialogTrigger asChild>
<Button variant="outline">
<Download size={16} className="mr-2" />
Download Restic Password
Download recovery key
</Button>
</DialogTrigger>
<DialogContent>
<form onSubmit={handleDownloadResticPassword}>
<DialogHeader>
<DialogTitle>Download Restic Password</DialogTitle>
<DialogTitle>Download Recovery Key</DialogTitle>
<DialogDescription>
For security reasons, please enter your account password to download the Restic password file.
For security reasons, please enter your account password to download the recovery key file.
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
@ -279,7 +271,7 @@ export default function Settings({ loaderData }: Route.ComponentProps) {
Export all your volumes, repositories, backup schedules, and notification settings to a JSON file. This can be
used to restore your configuration on a new instance or as a backup of your settings.
</p>
<ExportDialog triggerLabel="Export Configuration" />
<ExportDialog />
</CardContent>
</Card>
);

View file

@ -8,6 +8,7 @@ export const NOTIFICATION_TYPES = {
ntfy: "ntfy",
pushover: "pushover",
telegram: "telegram",
generic: "generic",
custom: "custom",
} as const;
@ -72,6 +73,17 @@ export const telegramNotificationConfigSchema = type({
chatId: "string",
});
export const genericNotificationConfigSchema = type({
type: "'generic'",
url: "string",
method: "'GET' | 'POST'",
contentType: "string?",
headers: "string[]?",
useJson: "boolean?",
titleKey: "string?",
messageKey: "string?",
});
export const customNotificationConfigSchema = type({
type: "'custom'",
shoutrrrUrl: "string",
@ -84,6 +96,7 @@ export const notificationConfigSchemaBase = emailNotificationConfigSchema
.or(ntfyNotificationConfigSchema)
.or(pushoverNotificationConfigSchema)
.or(telegramNotificationConfigSchema)
.or(genericNotificationConfigSchema)
.or(customNotificationConfigSchema);
export const notificationConfigSchema = notificationConfigSchemaBase.onUndeclaredKey("delete");

View file

@ -17,6 +17,8 @@ export type RepositoryBackend = keyof typeof REPOSITORY_BACKENDS;
const baseRepositoryConfigSchema = type({
isExistingRepository: "boolean?",
customPassword: "string?",
cacert: "string?",
insecureTls: "boolean?",
});
export const s3RepositoryConfigSchema = type({

View file

@ -12,7 +12,6 @@ import { volumeController } from "./modules/volumes/volume.controller";
import { backupScheduleController } from "./modules/backups/backups.controller";
import { eventsController } from "./modules/events/events.controller";
import { notificationsController } from "./modules/notifications/notifications.controller";
import { configExportController } from "./modules/lifecycle/config-export.controller";
import { handleServiceError } from "./utils/errors";
import { logger } from "./utils/logger";
import { config } from "./core/config";
@ -61,8 +60,7 @@ export const createApp = () => {
.route("/api/v1/backups", backupScheduleController)
.route("/api/v1/notifications", notificationsController)
.route("/api/v1/system", systemController)
.route("/api/v1/events", eventsController)
.route("/api/v1/config", configExportController);
.route("/api/v1/events", eventsController);
app.get("/api/v1/openapi.json", generalDescriptor(app));
app.get("/api/v1/docs", requireAuth, scalarDescriptor);

View file

@ -8,13 +8,29 @@ program.addCommand(resetPasswordCommand);
export async function runCLI(argv: string[]): Promise<boolean> {
const args = argv.slice(2);
const hasCommand = args.length > 0 && !args[0].startsWith("-");
const isCLIMode = process.env.ZEROBYTE_CLI === "1";
if (!hasCommand) {
if (args.length === 0) {
if (isCLIMode) {
program.help();
return true;
}
return false;
}
await program.parseAsync(argv);
if (!isCLIMode && args[0].startsWith("-")) {
return false;
}
await program.parseAsync(argv).catch((err) => {
if (err.message.includes("SIGINT")) {
process.exit(0);
}
console.error(err.message);
process.exit(1);
});
return true;
}

View file

@ -8,6 +8,7 @@ const envSchema = type({
RESTIC_HOSTNAME: "string = 'zerobyte'",
PORT: 'string.integer.parse = "4096"',
MIGRATIONS_PATH: "string?",
APP_VERSION: "string = 'dev'",
}).pipe((s) => ({
__prod__: s.NODE_ENV === "production",
environment: s.NODE_ENV,
@ -16,6 +17,7 @@ const envSchema = type({
resticHostname: s.RESTIC_HOSTNAME,
port: s.PORT,
migrationsPath: s.MIGRATIONS_PATH,
appVersion: s.APP_VERSION,
}));
const parseConfig = (env: unknown) => {

View file

@ -6,4 +6,4 @@ export const RESTIC_PASS_FILE = "/var/lib/zerobyte/data/restic.pass";
export const DEFAULT_EXCLUDES = [DATABASE_URL, RESTIC_PASS_FILE, REPOSITORY_BASE];
export const REQUIRED_MIGRATIONS = ["v0.21.0"];
export const REQUIRED_MIGRATIONS = []; // ["v0.21.1"] add this once re-tagging migration is removed

View file

@ -8,6 +8,12 @@ import { REQUIRED_MIGRATIONS } from "./core/constants";
import { validateRequiredMigrations } from "./modules/lifecycle/checkpoint";
import { createApp } from "./app";
import { config } from "./core/config";
import { runCLI } from "./cli";
const cliRun = await runCLI(Bun.argv);
if (cliRun) {
process.exit(0);
}
const app = createApp();

View file

@ -1,282 +0,0 @@
import { validator } from "hono-openapi";
import { Hono } from "hono";
import type { Context } from "hono";
import {
type backupSchedulesTable,
backupScheduleNotificationsTable,
backupScheduleMirrorsTable,
usersTable,
} from "../../db/schema";
import { db } from "../../db/db";
import { logger } from "../../utils/logger";
import { RESTIC_PASS_FILE, REPOSITORY_BASE } from "../../core/constants";
import { cryptoUtils } from "../../utils/crypto";
import { authService } from "../auth/auth.service";
import { volumeService } from "../volumes/volume.service";
import { repositoriesService } from "../repositories/repositories.service";
import { notificationsService } from "../notifications/notifications.service";
import { backupsService } from "../backups/backups.service";
import { fullExportBodySchema, fullExportDto, type SecretsMode, type FullExportBody } from "./config-export.dto";
import { requireAuth } from "../auth/auth.middleware";
type ExportParams = {
includeMetadata: boolean;
secretsMode: SecretsMode;
};
// Keys to exclude when metadata is not included
const METADATA_KEYS = {
ids: ["id", "volumeId", "repositoryId", "scheduleId", "destinationId"],
timestamps: [
"createdAt",
"updatedAt",
"lastBackupAt",
"nextBackupAt",
"lastHealthCheck",
"lastChecked",
"lastCopyAt",
],
runtimeState: [
"status",
"lastError",
"lastBackupStatus",
"lastBackupError",
"hasDownloadedResticPassword",
"lastCopyStatus",
"lastCopyError",
"sortOrder",
],
};
const ALL_METADATA_KEYS = [...METADATA_KEYS.ids, ...METADATA_KEYS.timestamps, ...METADATA_KEYS.runtimeState];
/** Filter out metadata keys from an object when includeMetadata is false */
function filterMetadataOut<T extends Record<string, unknown>>(obj: T, includeMetadata: boolean): Partial<T> {
if (includeMetadata) {
return obj;
}
const result = { ...obj };
for (const key of ALL_METADATA_KEYS) {
delete result[key as keyof T];
}
return result;
}
/** Parse export params from request body */
function parseExportParamsFromBody(body: { includeMetadata?: boolean; secretsMode?: SecretsMode }): ExportParams {
const includeMetadata = body.includeMetadata === true;
const secretsMode: SecretsMode = body.secretsMode ?? "exclude";
return { includeMetadata, secretsMode };
}
/**
* Verify password for export operation.
* Requires requireAuth middleware to have already validated the session.
*/
async function verifyExportPassword(
c: Context,
password: string,
): Promise<{ valid: true; userId: number } | { valid: false; error: string }> {
// requireAuth middleware ensures c.get('user') exists
const user = c.get("user");
if (!user) {
return { valid: false, error: "Not authenticated" };
}
const isValid = await authService.verifyPassword(user.id, password);
if (!isValid) {
return { valid: false, error: "Incorrect password" };
}
return { valid: true, userId: user.id };
}
/**
* Process secrets in an object based on the secrets mode.
* Automatically detects encrypted fields using cryptoUtils.isEncrypted.
*/
async function processSecrets(
obj: Record<string, unknown>,
secretsMode: SecretsMode,
): Promise<Record<string, unknown>> {
if (secretsMode === "encrypted") {
return obj;
}
const result = { ...obj };
for (const [key, value] of Object.entries(result)) {
if (typeof value === "string" && cryptoUtils.isEncrypted(value)) {
if (secretsMode === "exclude") {
delete result[key];
} else if (secretsMode === "cleartext") {
try {
result[key] = await cryptoUtils.decrypt(value);
} catch (err) {
logger.warn(`Failed to decrypt field "${key}": ${err instanceof Error ? err.message : String(err)}`);
delete result[key];
}
}
} else if (Array.isArray(value)) {
result[key] = await Promise.all(
value.map(async (item) =>
item && typeof item === "object" && !Array.isArray(item)
? processSecrets(item as Record<string, unknown>, secretsMode)
: item,
),
);
} else if (value && typeof value === "object") {
result[key] = await processSecrets(value as Record<string, unknown>, secretsMode);
}
}
return result;
}
/** Clean and process an entity for export */
async function exportEntity(entity: Record<string, unknown>, params: ExportParams): Promise<Record<string, unknown>> {
const cleaned = filterMetadataOut(entity, params.includeMetadata);
return processSecrets(cleaned, params.secretsMode);
}
/** Export multiple entities */
async function exportEntities<T extends Record<string, unknown>>(
entities: T[],
params: ExportParams,
): Promise<Record<string, unknown>[]> {
return Promise.all(entities.map((e) => exportEntity(e as Record<string, unknown>, params)));
}
/** Transform backup schedules with resolved names, notifications, and mirrors */
function transformBackupSchedules(
schedules: (typeof backupSchedulesTable.$inferSelect)[],
scheduleNotifications: (typeof backupScheduleNotificationsTable.$inferSelect)[],
scheduleMirrors: (typeof backupScheduleMirrorsTable.$inferSelect)[],
volumeMap: Map<number, string>,
repoMap: Map<string, string>,
notificationMap: Map<number, string>,
params: ExportParams,
) {
return schedules.map((schedule) => {
const assignments = scheduleNotifications
.filter((sn) => sn.scheduleId === schedule.id)
.map((sn) => ({
...filterMetadataOut(sn as unknown as Record<string, unknown>, params.includeMetadata),
name: notificationMap.get(sn.destinationId) ?? null,
}));
const mirrors = scheduleMirrors
.filter((sm) => sm.scheduleId === schedule.id)
.map((sm) => ({
...filterMetadataOut(sm as unknown as Record<string, unknown>, params.includeMetadata),
repository: repoMap.get(sm.repositoryId) ?? null,
}));
return {
...filterMetadataOut(schedule as Record<string, unknown>, params.includeMetadata),
volume: volumeMap.get(schedule.volumeId) ?? null,
repository: repoMap.get(schedule.repositoryId) ?? null,
notifications: assignments,
mirrors,
};
});
}
export const configExportController = new Hono()
.use(requireAuth)
.post("/export", fullExportDto, validator("json", fullExportBodySchema), async (c) => {
try {
const body = c.req.valid("json") as FullExportBody;
// Verify password - required for all exports
const verification = await verifyExportPassword(c, body.password);
if (!verification.valid) {
return c.json({ error: verification.error }, 401);
}
const params = parseExportParamsFromBody(body);
const includeRecoveryKey = body.includeRecoveryKey === true;
const includePasswordHash = body.includePasswordHash === true;
// Use services to fetch data
const [volumes, repositories, backupSchedulesRaw, notifications, scheduleNotifications, scheduleMirrors, users] =
await Promise.all([
volumeService.listVolumes(),
repositoriesService.listRepositories(),
backupsService.listSchedules(),
notificationsService.listDestinations(),
db.select().from(backupScheduleNotificationsTable),
db.select().from(backupScheduleMirrorsTable),
db.select().from(usersTable),
]);
const volumeMap = new Map<number, string>(volumes.map((v) => [v.id, v.name]));
const repoMap = new Map<string, string>(repositories.map((r) => [r.id, r.name]));
const notificationMap = new Map<number, string>(notifications.map((n) => [n.id, n.name]));
const backupSchedules = transformBackupSchedules(
backupSchedulesRaw,
scheduleNotifications,
scheduleMirrors,
volumeMap,
repoMap,
notificationMap,
params,
);
const [exportVolumes, exportRepositoriesRaw, exportNotifications] = await Promise.all([
exportEntities(volumes, params),
exportEntities(repositories, params),
exportEntities(notifications, params),
]);
// Add isExistingRepository flag and path to all repository configs for import compatibility
const exportRepositories = exportRepositoriesRaw.map((repo) => {
if (!repo.config || typeof repo.config !== "object") {
return repo;
}
const config = repo.config as Record<string, unknown>;
const updatedConfig: Record<string, unknown> = { ...config, isExistingRepository: true };
// For local repos, compute and add the full path if not already present
if (config.backend === "local" && !config.path && typeof config.name === "string") {
updatedConfig.path = `${REPOSITORY_BASE}/${config.name}`;
}
return { ...repo, config: updatedConfig };
});
let recoveryKey: string | undefined;
if (includeRecoveryKey) {
try {
recoveryKey = await Bun.file(RESTIC_PASS_FILE).text();
logger.warn("Recovery key exported - this is a security-sensitive operation");
} catch {
logger.warn("Could not read recovery key file");
}
}
// Users need special handling for passwordHash (controlled by separate flag)
const exportUsers = (await exportEntities(users, params)).map((user) => {
if (!includePasswordHash) {
delete user.passwordHash;
}
return user;
});
return c.json({
version: 1,
...(params.includeMetadata ? { exportedAt: new Date().toISOString() } : {}),
...(recoveryKey ? { recoveryKey } : {}),
volumes: exportVolumes,
repositories: exportRepositories,
backupSchedules,
notificationDestinations: exportNotifications,
users: exportUsers,
});
} catch (err) {
logger.error(`Config export failed: ${err instanceof Error ? err.message : String(err)}`);
return c.json({ error: err instanceof Error ? err.message : "Failed to export config" }, 500);
}
});

View file

@ -1,74 +0,0 @@
import { type } from "arktype";
import { describeRoute, resolver } from "hono-openapi";
const secretsModeSchema = type("'exclude' | 'encrypted' | 'cleartext'");
export const fullExportBodySchema = type({
/** Include metadata (IDs, timestamps, runtime state) in export (default: false) */
"includeMetadata?": "boolean",
/** How to handle secrets: exclude, encrypted, or cleartext (default: exclude) */
"secretsMode?": secretsModeSchema,
/** Password required for authentication */
password: "string",
/** Include the recovery key */
"includeRecoveryKey?": "boolean",
/** Include the user password hash */
"includePasswordHash?": "boolean",
});
export type FullExportBody = typeof fullExportBodySchema.infer;
export type SecretsMode = typeof secretsModeSchema.infer;
const exportResponseSchema = type({
version: "number",
"exportedAt?": "string",
"recoveryKey?": "string",
"volumes?": "unknown[]",
"repositories?": "unknown[]",
"backupSchedules?": "unknown[]",
"notificationDestinations?": "unknown[]",
"users?": type({
"id?": "number",
username: "string",
"passwordHash?": "string",
"createdAt?": "number",
"updatedAt?": "number",
"hasDownloadedResticPassword?": "boolean",
}).array(),
});
const errorResponseSchema = type({
error: "string",
});
export const fullExportDto = describeRoute({
description: "Export full configuration including all volumes, repositories, backup schedules, and notifications",
operationId: "exportFullConfig",
tags: ["Config Export"],
responses: {
200: {
description: "Full configuration export",
content: {
"application/json": {
schema: resolver(exportResponseSchema),
},
},
},
401: {
description: "Password required for export or authentication failed",
content: {
"application/json": {
schema: resolver(errorResponseSchema),
},
},
},
500: {
description: "Export failed",
content: {
"application/json": {
schema: resolver(errorResponseSchema),
},
},
},
},
});

View file

@ -1,13 +1,13 @@
import { eq } from "drizzle-orm";
import { db } from "../../db/db";
import { repositoriesTable } from "../../db/schema";
import { backupScheduleMirrorsTable, repositoriesTable, type Repository } from "../../db/schema";
import { logger } from "../../utils/logger";
import { hasMigrationCheckpoint, recordMigrationCheckpoint } from "./checkpoint";
import { toMessage } from "~/server/utils/errors";
import { safeSpawn } from "~/server/utils/spawn";
import { addCommonArgs, buildEnv, buildRepoUrl, cleanupTemporaryKeys } from "~/server/utils/restic";
const MIGRATION_VERSION = "v0.21.0";
const MIGRATION_VERSION = "v0.21.1";
interface MigrationResult {
success: boolean;
@ -40,52 +40,96 @@ export const retagSnapshots = async () => {
const allErrors = [...result.errors];
if (allErrors.length > 0) {
logger.error(`Migration ${MIGRATION_VERSION} completed with errors: ${allErrors.length} items failed.`);
logger.error(
`Some snapshots could not be retagged. Please check the logs for details. Fix any repository in error state and re-start zerobyte to retry the migration for failed items.`,
);
for (const err of allErrors) {
logger.error(`Migration failure - ${err.name}: ${err.error}`);
}
throw new MigrationError(MIGRATION_VERSION, allErrors);
return;
}
await recordMigrationCheckpoint(MIGRATION_VERSION);
logger.info(`Snapshots retagging migration (${MIGRATION_VERSION}) complete.`);
};
const migrateTag = async (
oldTag: string,
newTag: string,
repository: Repository,
scheduleName: string,
): Promise<string | null> => {
const repoUrl = buildRepoUrl(repository.config);
const env = await buildEnv(repository.config);
const args = ["--repo", repoUrl, "tag", "--tag", oldTag, "--add", newTag, "--remove", oldTag];
addCommonArgs(args, env);
logger.info(`Migrating snapshots for schedule '${scheduleName}' from tag '${oldTag}' to '${newTag}'`);
const res = await safeSpawn({ command: "restic", args, env });
await cleanupTemporaryKeys(env);
if (res.exitCode !== 0) {
logger.error(`Restic tag failed: ${res.stderr}`);
return toMessage(res.stderr);
}
logger.info(`Migrated snapshots for schedule '${scheduleName}' from tag '${oldTag}' to '${newTag}'`);
return null;
};
const migrateSnapshotsToShortIdTag = async (): Promise<MigrationResult> => {
const errors: Array<{ name: string; error: string }> = [];
const backupSchedules = await db.query.backupSchedulesTable.findMany({});
for (const schedule of backupSchedules) {
const oldTag = schedule.id.toString();
const newTag = schedule.shortId;
try {
const oldTag = schedule.id.toString();
const newTag = schedule.shortId;
const repository = await db.query.repositoriesTable.findFirst({
where: eq(repositoriesTable.id, schedule.repositoryId),
});
const repository = await db.query.repositoriesTable.findFirst({
where: eq(repositoriesTable.id, schedule.repositoryId),
});
if (!repository) {
errors.push({ name: `schedule:${schedule.name}`, error: `Associated repository not found` });
continue;
if (!repository) {
errors.push({ name: `schedule:${schedule.name}`, error: `Associated repository not found` });
continue;
}
const error = await migrateTag(oldTag, newTag, repository, schedule.name);
if (error) {
errors.push({ name: `schedule:${schedule.name}`, error });
continue;
}
const mirrors = await db
.select()
.from(backupScheduleMirrorsTable)
.where(eq(backupScheduleMirrorsTable.scheduleId, schedule.id));
for (const mirror of mirrors) {
const mirrorRepo = await db.query.repositoriesTable.findFirst({
where: eq(repositoriesTable.id, mirror.repositoryId),
});
if (!mirrorRepo) {
errors.push({ name: `schedule-mirror:${schedule.name}`, error: `Associated mirror repository not found` });
continue;
}
const mirrorError = await migrateTag(oldTag, newTag, mirrorRepo, `${schedule.name} (mirror)`);
if (mirrorError) {
errors.push({ name: `schedule-mirror:${schedule.name}`, error: mirrorError });
}
}
logger.info(`Migrated snapshots for schedule '${schedule.name}' from tag '${oldTag}' to '${newTag}'`);
} catch (err) {
errors.push({ name: `schedule:${schedule.name}`, error: toMessage(err) });
}
const repoUrl = buildRepoUrl(repository.config);
const env = await buildEnv(repository.config);
const args = ["--repo", repoUrl, "tag", "--tag", oldTag, "--add", newTag, "--remove", oldTag];
addCommonArgs(args, env);
logger.info(`Migrating snapshots for schedule '${schedule.name}' from tag '${oldTag}' to '${newTag}'`);
const res = await safeSpawn({ command: "restic", args, env });
await cleanupTemporaryKeys(repository.config, env);
if (res.exitCode !== 0) {
logger.error(`Restic tag failed: ${res.stderr}`);
errors.push({ name: `schedule:${schedule.name}`, error: `Restic tag command failed: ${toMessage(res.stderr)}` });
continue;
}
logger.info(`Migrated snapshots for schedule '${schedule.name}' from tag '${oldTag}' to '${newTag}'`);
}
return { success: errors.length === 0, errors };

View file

@ -13,6 +13,7 @@ import { CleanupSessionsJob } from "../../jobs/cleanup-sessions";
import { repositoriesService } from "../repositories/repositories.service";
import { notificationsService } from "../notifications/notifications.service";
import { VolumeAutoRemountJob } from "~/server/jobs/auto-remount";
import { cache } from "~/server/utils/cache";
const ensureLatestConfigurationSchema = async () => {
const volumes = await db.query.volumesTable.findMany({});
@ -41,6 +42,8 @@ const ensureLatestConfigurationSchema = async () => {
};
export const startup = async () => {
cache.clear();
await Scheduler.start();
await Scheduler.clear();

View file

@ -1,5 +1,5 @@
import type { NotificationConfig } from "~/schemas/notifications";
export function buildCustomShoutrrrUrl(config: Extract<NotificationConfig, { type: "custom" }>): string {
export const buildCustomShoutrrrUrl = (config: Extract<NotificationConfig, { type: "custom" }>) => {
return config.shoutrrrUrl;
}
};

View file

@ -1,6 +1,6 @@
import type { NotificationConfig } from "~/schemas/notifications";
export function buildDiscordShoutrrrUrl(config: Extract<NotificationConfig, { type: "discord" }>): string {
export const buildDiscordShoutrrrUrl = (config: Extract<NotificationConfig, { type: "discord" }>) => {
const url = new URL(config.webhookUrl);
const pathParts = url.pathname.split("/").filter(Boolean);
@ -28,4 +28,4 @@ export function buildDiscordShoutrrrUrl(config: Extract<NotificationConfig, { ty
}
return shoutrrrUrl;
}
};

View file

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

View file

@ -0,0 +1,50 @@
import type { NotificationConfig } from "~/schemas/notifications";
export const buildGenericShoutrrrUrl = (config: Extract<NotificationConfig, { type: "generic" }>) => {
const targetUrl = new URL(config.url);
const shoutrrrUrl = new URL(`generic://${targetUrl.host}${targetUrl.pathname}`);
for (const [key, value] of targetUrl.searchParams.entries()) {
const reservedKeys = ["contenttype", "disabletls", "messagekey", "method", "template", "title", "titlekey"];
if (reservedKeys.includes(key.toLowerCase())) {
shoutrrrUrl.searchParams.append(`_${key}`, value);
} else {
shoutrrrUrl.searchParams.append(key, value);
}
}
if (targetUrl.protocol === "http:") {
shoutrrrUrl.searchParams.append("disabletls", "yes");
}
if (config.method) {
shoutrrrUrl.searchParams.append("method", config.method);
}
if (config.contentType) {
shoutrrrUrl.searchParams.append("contenttype", config.contentType);
}
if (config.useJson) {
shoutrrrUrl.searchParams.append("template", "json");
}
if (config.titleKey) {
shoutrrrUrl.searchParams.append("titlekey", config.titleKey);
}
if (config.messageKey) {
shoutrrrUrl.searchParams.append("messagekey", config.messageKey);
}
if (config.headers) {
for (const header of config.headers) {
const [key, ...valueParts] = header.split(":");
if (key && valueParts.length > 0) {
shoutrrrUrl.searchParams.append(`@${key.trim()}`, valueParts.join(":").trim());
}
}
}
return shoutrrrUrl.toString();
};

View file

@ -1,6 +1,6 @@
import type { NotificationConfig } from "~/schemas/notifications";
export function buildGotifyShoutrrrUrl(config: Extract<NotificationConfig, { type: "gotify" }>): string {
export const buildGotifyShoutrrrUrl = (config: Extract<NotificationConfig, { type: "gotify" }>) => {
const url = new URL(config.serverUrl);
const hostname = url.hostname;
const port = url.port ? `:${url.port}` : "";
@ -13,4 +13,4 @@ export function buildGotifyShoutrrrUrl(config: Extract<NotificationConfig, { typ
}
return shoutrrrUrl;
}
};

View file

@ -6,9 +6,10 @@ import { buildGotifyShoutrrrUrl } from "./gotify";
import { buildNtfyShoutrrrUrl } from "./ntfy";
import { buildPushoverShoutrrrUrl } from "./pushover";
import { buildTelegramShoutrrrUrl } from "./telegram";
import { buildGenericShoutrrrUrl } from "./generic";
import { buildCustomShoutrrrUrl } from "./custom";
export function buildShoutrrrUrl(config: NotificationConfig): string {
export const buildShoutrrrUrl = (config: NotificationConfig) => {
switch (config.type) {
case "email":
return buildEmailShoutrrrUrl(config);
@ -24,12 +25,13 @@ export function buildShoutrrrUrl(config: NotificationConfig): string {
return buildPushoverShoutrrrUrl(config);
case "telegram":
return buildTelegramShoutrrrUrl(config);
case "generic":
return buildGenericShoutrrrUrl(config);
case "custom":
return buildCustomShoutrrrUrl(config);
default: {
// TypeScript exhaustiveness check
const _exhaustive: never = config;
throw new Error(`Unsupported notification type: ${(_exhaustive as NotificationConfig).type}`);
}
}
}
};

View file

@ -1,6 +1,6 @@
import type { NotificationConfig } from "~/schemas/notifications";
export function buildNtfyShoutrrrUrl(config: Extract<NotificationConfig, { type: "ntfy" }>): string {
export const buildNtfyShoutrrrUrl = (config: Extract<NotificationConfig, { type: "ntfy" }>) => {
let shoutrrrUrl: string;
const params = new URLSearchParams();
@ -38,4 +38,4 @@ export function buildNtfyShoutrrrUrl(config: Extract<NotificationConfig, { type:
}
return shoutrrrUrl;
}
};

View file

@ -1,6 +1,6 @@
import type { NotificationConfig } from "~/schemas/notifications";
export function buildPushoverShoutrrrUrl(config: Extract<NotificationConfig, { type: "pushover" }>): string {
export const buildPushoverShoutrrrUrl = (config: Extract<NotificationConfig, { type: "pushover" }>) => {
const params = new URLSearchParams();
if (config.devices) {
@ -19,4 +19,4 @@ export function buildPushoverShoutrrrUrl(config: Extract<NotificationConfig, { t
}
return shoutrrrUrl;
}
};

View file

@ -1,6 +1,6 @@
import type { NotificationConfig } from "~/schemas/notifications";
export function buildSlackShoutrrrUrl(config: Extract<NotificationConfig, { type: "slack" }>): string {
export const buildSlackShoutrrrUrl = (config: Extract<NotificationConfig, { type: "slack" }>) => {
const url = new URL(config.webhookUrl);
const pathParts = url.pathname.split("/").filter(Boolean);
@ -28,4 +28,4 @@ export function buildSlackShoutrrrUrl(config: Extract<NotificationConfig, { type
}
return shoutrrrUrl;
}
};

View file

@ -1,5 +1,5 @@
import type { NotificationConfig } from "~/schemas/notifications";
export function buildTelegramShoutrrrUrl(config: Extract<NotificationConfig, { type: "telegram" }>): string {
export const buildTelegramShoutrrrUrl = (config: Extract<NotificationConfig, { type: "telegram" }>) => {
return `telegram://${config.botToken}@telegram?channels=${config.chatId}`;
}
};

View file

@ -71,6 +71,8 @@ async function encryptSensitiveFields(config: NotificationConfig): Promise<Notif
...config,
botToken: await cryptoUtils.sealSecret(config.botToken),
};
case "generic":
return config;
case "custom":
return {
...config,
@ -118,6 +120,8 @@ async function decryptSensitiveFields(config: NotificationConfig): Promise<Notif
...config,
botToken: await cryptoUtils.resolveSecret(config.botToken),
};
case "generic":
return config;
case "custom":
return {
...config,

View file

@ -50,6 +50,7 @@ describe("repositories security", () => {
{ method: "POST", path: "/api/v1/repositories/test-repo/restore" },
{ method: "POST", path: "/api/v1/repositories/test-repo/doctor" },
{ method: "DELETE", path: "/api/v1/repositories/test-repo/snapshots/test-snapshot" },
{ method: "DELETE", path: "/api/v1/repositories/test-repo/snapshots" },
{ method: "PATCH", path: "/api/v1/repositories/test-repo" },
];

View file

@ -5,6 +5,8 @@ import {
createRepositoryDto,
deleteRepositoryDto,
deleteSnapshotDto,
deleteSnapshotsBody,
deleteSnapshotsDto,
doctorRepositoryDto,
getRepositoryDto,
getSnapshotDetailsDto,
@ -16,10 +18,13 @@ import {
listSnapshotsFilters,
restoreSnapshotBody,
restoreSnapshotDto,
tagSnapshotsBody,
tagSnapshotsDto,
updateRepositoryBody,
updateRepositoryDto,
type DeleteRepositoryDto,
type DeleteSnapshotDto,
type DeleteSnapshotsResponseDto,
type DoctorRepositoryDto,
type GetRepositoryDto,
type GetSnapshotDetailsDto,
@ -27,6 +32,7 @@ import {
type ListSnapshotFilesDto,
type ListSnapshotsDto,
type RestoreSnapshotDto,
type TagSnapshotsResponseDto,
type UpdateRepositoryDto,
} from "./repositories.dto";
import { repositoriesService } from "./repositories.service";
@ -160,6 +166,22 @@ export const repositoriesController = new Hono()
return c.json<DeleteSnapshotDto>({ message: "Snapshot deleted" }, 200);
})
.delete("/:id/snapshots", deleteSnapshotsDto, validator("json", deleteSnapshotsBody), async (c) => {
const { id } = c.req.param();
const { snapshotIds } = c.req.valid("json");
await repositoriesService.deleteSnapshots(id, snapshotIds);
return c.json<DeleteSnapshotsResponseDto>({ message: "Snapshots deleted" }, 200);
})
.post("/:id/snapshots/tag", tagSnapshotsDto, validator("json", tagSnapshotsBody), async (c) => {
const { id } = c.req.param();
const { snapshotIds, ...tags } = c.req.valid("json");
await repositoriesService.tagSnapshots(id, snapshotIds, tags);
return c.json<TagSnapshotsResponseDto>({ message: "Snapshots tagged" }, 200);
})
.patch("/:id", updateRepositoryDto, validator("json", updateRepositoryBody), async (c) => {
const { id } = c.req.param();
const body = c.req.valid("json");

View file

@ -400,3 +400,64 @@ export const deleteSnapshotDto = describeRoute({
},
},
});
/**
* Delete multiple snapshots
*/
export const deleteSnapshotsBody = type({
snapshotIds: "string[]>=1",
});
export const deleteSnapshotsResponse = type({
message: "string",
});
export type DeleteSnapshotsResponseDto = typeof deleteSnapshotsResponse.infer;
export const deleteSnapshotsDto = describeRoute({
description: "Delete multiple snapshots from a repository",
tags: ["Repositories"],
operationId: "deleteSnapshots",
responses: {
200: {
description: "Snapshots deleted successfully",
content: {
"application/json": {
schema: resolver(deleteSnapshotsResponse),
},
},
},
},
});
/**
* Tag multiple snapshots
*/
export const tagSnapshotsBody = type({
snapshotIds: "string[]>=1",
add: "string[]?",
remove: "string[]?",
set: "string[]?",
});
export const tagSnapshotsResponse = type({
message: "string",
});
export type TagSnapshotsResponseDto = typeof tagSnapshotsResponse.infer;
export const tagSnapshotsDto = describeRoute({
description: "Tag multiple snapshots in a repository",
tags: ["Repositories"],
operationId: "tagSnapshots",
responses: {
200: {
description: "Snapshots tagged successfully",
content: {
"application/json": {
schema: resolver(tagSnapshotsResponse),
},
},
},
},
});

View file

@ -34,6 +34,10 @@ const encryptConfig = async (config: RepositoryConfig): Promise<RepositoryConfig
encryptedConfig.customPassword = await cryptoUtils.sealSecret(config.customPassword);
}
if (config.cacert) {
encryptedConfig.cacert = await cryptoUtils.sealSecret(config.cacert);
}
switch (config.backend) {
case "s3":
case "r2":
@ -389,6 +393,40 @@ const deleteSnapshot = async (id: string, snapshotId: string) => {
}
};
const deleteSnapshots = async (id: string, snapshotIds: string[]) => {
const repository = await findRepository(id);
if (!repository) {
throw new NotFoundError("Repository not found");
}
const releaseLock = await repoMutex.acquireExclusive(repository.id, `delete:bulk`);
try {
await restic.deleteSnapshots(repository.config, snapshotIds);
} finally {
releaseLock();
}
};
const tagSnapshots = async (
id: string,
snapshotIds: string[],
tags: { add?: string[]; remove?: string[]; set?: string[] },
) => {
const repository = await findRepository(id);
if (!repository) {
throw new NotFoundError("Repository not found");
}
const releaseLock = await repoMutex.acquireExclusive(repository.id, `tag:bulk`);
try {
await restic.tagSnapshots(repository.config, snapshotIds, tags);
} finally {
releaseLock();
}
};
const updateRepository = async (id: string, updates: { name?: string; compressionMode?: CompressionMode }) => {
const existing = await findRepository(id);
@ -439,4 +477,6 @@ export const repositoriesService = {
checkHealth,
doctorRepository,
deleteSnapshot,
deleteSnapshots,
tagSnapshots,
};

View file

@ -41,6 +41,7 @@ describe("system security", () => {
const endpoints: { method: string; path: string }[] = [
{ method: "GET", path: "/api/v1/system/info" },
{ method: "POST", path: "/api/v1/system/restic-password" },
{ method: "POST", path: "/api/v1/system/export" },
];
for (const { method, path } of endpoints) {
@ -86,4 +87,60 @@ describe("system security", () => {
expect(body.message).toBe("Incorrect password");
});
});
test("should return 400 for invalid payload on full export", async () => {
const { sessionId } = await createTestSession();
const res = await app.request("/api/v1/system/export", {
method: "POST",
headers: {
Cookie: `session_id=${sessionId}`,
"Content-Type": "application/json",
},
body: JSON.stringify({}),
});
expect(res.status).toBe(400);
});
test("should return 401 for incorrect password on full export", async () => {
const { sessionId } = await createTestSession();
const res = await app.request("/api/v1/system/export", {
method: "POST",
headers: {
Cookie: `session_id=${sessionId}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
includeMetadata: true,
password: "wrong-password",
}),
});
expect(res.status).toBe(401);
const body = await res.json();
expect(body.message).toBe("Incorrect password");
});
test("full export never exposes password hashes", async () => {
const { sessionId } = await createTestSession();
const res = await app.request("/api/v1/system/export", {
method: "POST",
headers: {
Cookie: `session_id=${sessionId}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
includeMetadata: true,
password: "testpassword",
}),
});
expect(res.status).toBe(200);
const body = (await res.json()) as { users?: Array<Record<string, unknown>> };
expect(Array.isArray(body.users)).toBe(true);
for (const user of body.users ?? []) {
expect(user.passwordHash).toBeUndefined();
}
});
});

View file

@ -3,8 +3,12 @@ import { validator } from "hono-openapi";
import {
downloadResticPasswordBodySchema,
downloadResticPasswordDto,
fullExportBodySchema,
fullExportDto,
getUpdatesDto,
systemInfoDto,
type SystemInfoDto,
type UpdateInfoDto,
} from "./system.dto";
import { systemService } from "./system.service";
import { requireAuth } from "../auth/auth.middleware";
@ -20,6 +24,11 @@ export const systemController = new Hono()
return c.json<SystemInfoDto>(info, 200);
})
.get("/updates", getUpdatesDto, async (c) => {
const updates = await systemService.getUpdates();
return c.json<UpdateInfoDto>(updates, 200);
})
.post(
"/restic-password",
downloadResticPasswordDto,
@ -54,4 +63,24 @@ export const systemController = new Hono()
return c.json({ message: "Failed to read Restic password file" }, 500);
}
},
);
)
.post("/export", fullExportDto, validator("json", fullExportBodySchema), async (c) => {
const user = c.get("user");
const { password, ...body } = c.req.valid("json");
const [dbUser] = await db.select().from(usersTable).where(eq(usersTable.id, user.id));
if (!dbUser) {
return c.json({ message: "User not found" }, 401);
}
const isValid = await Bun.password.verify(password, dbUser.passwordHash);
if (!isValid) {
return c.json({ message: "Incorrect password" }, 401);
}
const res = await systemService.exportConfig(body);
return c.json(res);
});

View file

@ -12,6 +12,22 @@ export const systemInfoResponse = type({
export type SystemInfoDto = typeof systemInfoResponse.infer;
export const releaseInfoSchema = type({
version: "string",
url: "string",
publishedAt: "string",
body: "string",
});
export const updateInfoResponse = type({
currentVersion: "string",
latestVersion: "string",
hasUpdate: "boolean",
missedReleases: releaseInfoSchema.array(),
});
export type UpdateInfoDto = typeof updateInfoResponse.infer;
export const systemInfoDto = describeRoute({
description: "Get system information including available capabilities",
tags: ["System"],
@ -28,6 +44,22 @@ export const systemInfoDto = describeRoute({
},
});
export const getUpdatesDto = describeRoute({
description: "Check for application updates from GitHub",
tags: ["System"],
operationId: "getUpdates",
responses: {
200: {
description: "Update information and missed releases",
content: {
"application/json": {
schema: resolver(updateInfoResponse),
},
},
},
},
});
export const downloadResticPasswordBodySchema = type({
password: "string",
});
@ -47,3 +79,47 @@ export const downloadResticPasswordDto = describeRoute({
},
},
});
export const fullExportBodySchema = type({
includeMetadata: "boolean = false",
password: "string",
});
export type FullExportBody = typeof fullExportBodySchema.infer;
const exportResponseSchema = type({
version: "number",
exportedAt: "string?",
recoveryKey: "string?",
volumes: "unknown[]?",
repositories: "unknown[]?",
backupSchedules: "unknown[]?",
notificationDestinations: "unknown[]?",
users: type({
id: "number?",
username: "string",
createdAt: "number?",
updatedAt: "number?",
hasDownloadedResticPassword: "boolean?",
})
.array()
.optional(),
});
export type ExportFullConfigResponse = typeof exportResponseSchema.infer;
export const fullExportDto = describeRoute({
description: "Export full configuration including all volumes, repositories, backup schedules, and notifications",
operationId: "exportFullConfig",
tags: ["Config Export"],
responses: {
200: {
description: "Full configuration export",
content: {
"application/json": {
schema: resolver(exportResponseSchema),
},
},
},
},
});

View file

@ -1,4 +1,28 @@
import { getCapabilities } from "../../core/capabilities";
import { config } from "../../core/config";
import type { UpdateInfoDto } from "./system.dto";
import semver from "semver";
import { cache } from "../../utils/cache";
import { logger } from "~/server/utils/logger";
import { db } from "~/server/db/db";
import {
backupScheduleMirrorsTable,
backupScheduleNotificationsTable,
backupSchedulesTable,
notificationDestinationsTable,
repositoriesTable,
usersTable,
volumesTable,
type BackupScheduleMirror,
type BackupScheduleNotification,
type BackupSchedule,
} from "~/server/db/schema";
type ExportParams = {
includeMetadata: boolean;
};
const CACHE_TTL = 60 * 60;
const getSystemInfo = async () => {
return {
@ -6,6 +30,190 @@ const getSystemInfo = async () => {
};
};
interface GitHubRelease {
tag_name: string;
html_url: string;
published_at: string;
body: string;
}
const getUpdates = async (): Promise<UpdateInfoDto> => {
const CACHE_KEY = `system:updates:${config.appVersion}`;
const cached = cache.get<UpdateInfoDto>(CACHE_KEY);
if (cached) {
return cached;
}
try {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 5000);
const response = await fetch("https://api.github.com/repos/nicotsx/zerobyte/releases", {
signal: controller.signal,
headers: {
"User-Agent": "zerobyte-app",
},
});
clearTimeout(timeoutId);
if (!response.ok) {
throw new Error(`GitHub API returned ${response.status}`);
}
const releases = (await response.json()) as GitHubRelease[];
const currentVersion = config.appVersion;
const formattedReleases = releases.map((r) => ({
version: r.tag_name,
url: r.html_url,
publishedAt: r.published_at,
body: r.body,
}));
const latestRelease = formattedReleases[0];
const latestVersion = latestRelease?.version ?? currentVersion;
const hasUpdate = !!(
currentVersion !== "dev" &&
semver.valid(currentVersion) &&
semver.valid(latestVersion) &&
semver.gt(latestVersion, currentVersion)
);
const missedReleases =
currentVersion === "dev" || !semver.valid(currentVersion)
? []
: formattedReleases.filter((r) => !!(semver.valid(r.version) && semver.gt(r.version, currentVersion)));
const data: UpdateInfoDto = {
currentVersion,
latestVersion,
hasUpdate,
missedReleases,
};
cache.set(CACHE_KEY, data, CACHE_TTL);
return data;
} catch (error) {
logger.error("Failed to fetch updates from GitHub:", error);
return {
currentVersion: config.appVersion,
latestVersion: config.appVersion,
hasUpdate: false,
missedReleases: [],
};
}
};
const METADATA_KEYS = {
timestamps: [
"createdAt",
"updatedAt",
"lastBackupAt",
"nextBackupAt",
"lastHealthCheck",
"lastChecked",
"lastCopyAt",
],
runtimeState: [
"status",
"lastError",
"lastBackupStatus",
"lastBackupError",
"hasDownloadedResticPassword",
"lastCopyStatus",
"lastCopyError",
"sortOrder",
],
};
const ALL_METADATA_KEYS = [...METADATA_KEYS.timestamps, ...METADATA_KEYS.runtimeState];
function filterMetadataOut<T extends Record<string, unknown>>(obj: T, includeMetadata: boolean): Partial<T> {
if (includeMetadata) {
return obj;
}
const result = { ...obj };
for (const key of ALL_METADATA_KEYS) {
delete result[key as keyof T];
}
return result;
}
async function exportEntity(entity: Record<string, unknown>, params: ExportParams) {
return filterMetadataOut(entity, params.includeMetadata);
}
async function exportEntities<T extends Record<string, unknown>>(entities: T[], params: ExportParams) {
return Promise.all(entities.map((e) => exportEntity(e, params)));
}
const transformBackupSchedules = (
schedules: BackupSchedule[],
scheduleNotifications: BackupScheduleNotification[],
scheduleMirrors: BackupScheduleMirror[],
params: ExportParams,
) => {
return schedules.map((schedule) => {
const assignments = scheduleNotifications
.filter((sn) => sn.scheduleId === schedule.id)
.map((sn) => filterMetadataOut(sn, params.includeMetadata));
const mirrors = scheduleMirrors
.filter((sm) => sm.scheduleId === schedule.id)
.map((sm) => filterMetadataOut(sm, params.includeMetadata));
return {
...filterMetadataOut(schedule, params.includeMetadata),
notifications: assignments,
mirrors,
};
});
};
const exportConfig = async (params: ExportParams) => {
const [volumes, repositories, backupSchedulesRaw, notifications, scheduleNotifications, scheduleMirrors, users] =
await Promise.all([
db.select().from(volumesTable),
db.select().from(repositoriesTable),
db.select().from(backupSchedulesTable),
db.select().from(notificationDestinationsTable),
db.select().from(backupScheduleNotificationsTable),
db.select().from(backupScheduleMirrorsTable),
db.select().from(usersTable),
]);
const backupSchedules = transformBackupSchedules(backupSchedulesRaw, scheduleNotifications, scheduleMirrors, params);
const [exportVolumes, exportRepositories, exportNotifications, exportedUsersWithHash] = await Promise.all([
exportEntities(volumes, params) as Promise<typeof volumes>,
exportEntities(repositories, params) as Promise<typeof repositories>,
exportEntities(notifications, params) as Promise<typeof notifications>,
exportEntities(users, params) as Promise<typeof users>,
]);
const exportUsers = exportedUsersWithHash.map((user) => {
const sanitizedUser = { ...user } as Record<string, unknown>;
delete sanitizedUser.passwordHash;
sanitizedUser.password = `\${USER_${user.username.toUpperCase()}_PASSWORD}`;
return sanitizedUser;
});
return {
version: 1,
exportedAt: new Date().toISOString(),
volumes: exportVolumes,
repositories: exportRepositories,
backupSchedules,
notificationDestinations: exportNotifications,
users: exportUsers,
};
};
export const systemService = {
getSystemInfo,
getUpdates,
exportConfig,
};

96
app/server/utils/cache.ts Normal file
View file

@ -0,0 +1,96 @@
import { Database } from "bun:sqlite";
import path from "node:path";
import fs from "node:fs";
import { DATABASE_URL } from "../core/constants";
export const ONE_DAY_IN_SECONDS = 60 * 60 * 24;
export interface CacheOptions {
dbPath?: string;
}
export const createCache = (options: CacheOptions = {}) => {
const defaultPath = path.join(path.dirname(DATABASE_URL), "cache.db");
const dbPath = options.dbPath || defaultPath;
const dir = path.dirname(dbPath);
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
}
const db = new Database(dbPath);
db.run("CREATE TABLE IF NOT EXISTS cache (key TEXT PRIMARY KEY, value TEXT, expiration INTEGER)");
const set = (key: string, value: unknown, expirationSeconds = ONE_DAY_IN_SECONDS) => {
const expiration = Date.now() + expirationSeconds * 1000;
const stmt = db.prepare("INSERT OR REPLACE INTO cache (key, value, expiration) VALUES (?, ?, ?)");
stmt.run(key, JSON.stringify(value), expiration);
};
const get = <T>(key: string): T | undefined => {
const stmt = db.prepare("SELECT value, expiration FROM cache WHERE key = ?");
const row = stmt.get(key) as { value: string; expiration: number } | undefined;
if (!row) {
return undefined;
}
if (row.expiration < Date.now()) {
const delStmt = db.prepare("DELETE FROM cache WHERE key = ?");
delStmt.run(key);
return undefined;
}
try {
return JSON.parse(row.value) as T;
} catch {
return undefined;
}
};
const del = (key: string) => {
const stmt = db.prepare("DELETE FROM cache WHERE key = ?");
stmt.run(key);
};
const getByPrefix = <T>(prefix: string): { key: string; value: T }[] => {
const stmt = db.prepare("SELECT key, value, expiration FROM cache WHERE key LIKE ?");
const rows = stmt.all(`${prefix}%`) as { key: string; value: string; expiration: number }[];
const now = Date.now();
const results: { key: string; value: T }[] = [];
for (const row of rows) {
if (row.expiration < now) {
const delStmt = db.prepare("DELETE FROM cache WHERE key = ?");
delStmt.run(row.key);
continue;
}
try {
results.push({
key: row.key,
value: JSON.parse(row.value) as T,
});
} catch {
// Ignore malformed entries
}
}
return results;
};
const clear = () => {
db.run("DELETE FROM cache");
};
return {
set,
get,
del,
getByPrefix,
clear,
};
};
export const cache = createCache();

View file

@ -205,6 +205,17 @@ export const buildEnv = async (config: RepositoryConfig) => {
}
}
if (config.cacert) {
const decryptedCert = await cryptoUtils.resolveSecret(config.cacert);
const certPath = path.join("/tmp", `zerobyte-cacert-${crypto.randomBytes(8).toString("hex")}.pem`);
await fs.writeFile(certPath, decryptedCert, { mode: 0o600 });
env.RESTIC_CACERT = certPath;
}
if (config.insecureTls) {
env._INSECURE_TLS = "true";
}
return env;
};
@ -221,7 +232,7 @@ const init = async (config: RepositoryConfig) => {
addCommonArgs(args, env);
const res = await safeSpawn({ command: "restic", args, env });
await cleanupTemporaryKeys(config, env);
await cleanupTemporaryKeys(env);
if (res.exitCode !== 0) {
logger.error(`Restic init failed: ${res.stderr}`);
@ -349,7 +360,7 @@ const backup = async (
finally: async () => {
includeFile && (await fs.unlink(includeFile).catch(() => {}));
excludeFile && (await fs.unlink(excludeFile).catch(() => {}));
await cleanupTemporaryKeys(config, env);
await cleanupTemporaryKeys(env);
},
});
@ -447,7 +458,7 @@ const restore = async (
logger.debug(`Executing: restic ${args.join(" ")}`);
const res = await safeSpawn({ command: "restic", args, env });
await cleanupTemporaryKeys(config, env);
await cleanupTemporaryKeys(env);
if (res.exitCode !== 0) {
logger.error(`Restic restore failed: ${res.stderr}`);
@ -508,7 +519,7 @@ const snapshots = async (config: RepositoryConfig, options: { tags?: string[] }
addCommonArgs(args, env);
const res = await safeSpawn({ command: "restic", args, env });
await cleanupTemporaryKeys(config, env);
await cleanupTemporaryKeys(env);
if (res.exitCode !== 0) {
logger.error(`Restic snapshots retrieval failed: ${res.stderr}`);
@ -557,7 +568,7 @@ const forget = async (config: RepositoryConfig, options: RetentionPolicy, extra:
addCommonArgs(args, env);
const res = await safeSpawn({ command: "restic", args, env });
await cleanupTemporaryKeys(config, env);
await cleanupTemporaryKeys(env);
if (res.exitCode !== 0) {
logger.error(`Restic forget failed: ${res.stderr}`);
@ -567,15 +578,19 @@ const forget = async (config: RepositoryConfig, options: RetentionPolicy, extra:
return { success: true };
};
const deleteSnapshot = async (config: RepositoryConfig, snapshotId: string) => {
const deleteSnapshots = async (config: RepositoryConfig, snapshotIds: string[]) => {
const repoUrl = buildRepoUrl(config);
const env = await buildEnv(config);
const args: string[] = ["--repo", repoUrl, "forget", snapshotId, "--prune"];
if (snapshotIds.length === 0) {
throw new Error("No snapshot IDs provided for deletion.");
}
const args: string[] = ["--repo", repoUrl, "forget", ...snapshotIds, "--prune"];
addCommonArgs(args, env);
const res = await safeSpawn({ command: "restic", args, env });
await cleanupTemporaryKeys(config, env);
await cleanupTemporaryKeys(env);
if (res.exitCode !== 0) {
logger.error(`Restic snapshot deletion failed: ${res.stderr}`);
@ -585,6 +600,55 @@ const deleteSnapshot = async (config: RepositoryConfig, snapshotId: string) => {
return { success: true };
};
const deleteSnapshot = async (config: RepositoryConfig, snapshotId: string) => {
return deleteSnapshots(config, [snapshotId]);
};
const tagSnapshots = async (
config: RepositoryConfig,
snapshotIds: string[],
tags: { add?: string[]; remove?: string[]; set?: string[] },
) => {
const repoUrl = buildRepoUrl(config);
const env = await buildEnv(config);
if (snapshotIds.length === 0) {
throw new Error("No snapshot IDs provided for tagging.");
}
const args: string[] = ["--repo", repoUrl, "tag", ...snapshotIds];
if (tags.add) {
for (const tag of tags.add) {
args.push("--add", tag);
}
}
if (tags.remove) {
for (const tag of tags.remove) {
args.push("--remove", tag);
}
}
if (tags.set) {
for (const tag of tags.set) {
args.push("--set", tag);
}
}
addCommonArgs(args, env);
const res = await safeSpawn({ command: "restic", args, env });
await cleanupTemporaryKeys(env);
if (res.exitCode !== 0) {
logger.error(`Restic snapshot tagging failed: ${res.stderr}`);
throw new ResticError(res.exitCode, res.stderr);
}
return { success: true };
};
const lsNodeSchema = type({
name: "string",
type: "string",
@ -625,7 +689,7 @@ const ls = async (config: RepositoryConfig, snapshotId: string, path?: string) =
addCommonArgs(args, env);
const res = await safeSpawn({ command: "restic", args, env });
await cleanupTemporaryKeys(config, env);
await cleanupTemporaryKeys(env);
if (res.exitCode !== 0) {
logger.error(`Restic ls failed: ${res.stderr}`);
@ -676,7 +740,7 @@ const unlock = async (config: RepositoryConfig) => {
addCommonArgs(args, env);
const res = await safeSpawn({ command: "restic", args, env });
await cleanupTemporaryKeys(config, env);
await cleanupTemporaryKeys(env);
if (res.exitCode !== 0) {
logger.error(`Restic unlock failed: ${res.stderr}`);
@ -700,7 +764,7 @@ const check = async (config: RepositoryConfig, options?: { readData?: boolean })
addCommonArgs(args, env);
const res = await safeSpawn({ command: "restic", args, env });
await cleanupTemporaryKeys(config, env);
await cleanupTemporaryKeys(env);
const { stdout, stderr } = res;
@ -733,7 +797,7 @@ const repairIndex = async (config: RepositoryConfig) => {
addCommonArgs(args, env);
const res = await safeSpawn({ command: "restic", args, env });
await cleanupTemporaryKeys(config, env);
await cleanupTemporaryKeys(env);
const { stdout, stderr } = res;
@ -793,8 +857,8 @@ const copy = async (
const res = await safeSpawn({ command: "restic", args, env });
await cleanupTemporaryKeys(sourceConfig, sourceEnv);
await cleanupTemporaryKeys(destConfig, destEnv);
await cleanupTemporaryKeys(sourceEnv);
await cleanupTemporaryKeys(destEnv);
const { stdout, stderr } = res;
@ -810,19 +874,26 @@ const copy = async (
};
};
export const cleanupTemporaryKeys = async (config: RepositoryConfig, env: Record<string, string>) => {
if (config.backend === "sftp") {
if (env._SFTP_KEY_PATH) {
await fs.unlink(env._SFTP_KEY_PATH).catch(() => {});
}
if (env._SFTP_KNOWN_HOSTS_PATH) {
await fs.unlink(env._SFTP_KNOWN_HOSTS_PATH).catch(() => {});
}
} else if (config.isExistingRepository && config.customPassword && env.RESTIC_PASSWORD_FILE) {
export const cleanupTemporaryKeys = async (env: Record<string, string>) => {
if (env._SFTP_KEY_PATH) {
await fs.unlink(env._SFTP_KEY_PATH).catch(() => {});
}
if (env._SFTP_KNOWN_HOSTS_PATH) {
await fs.unlink(env._SFTP_KNOWN_HOSTS_PATH).catch(() => {});
}
if (env.RESTIC_PASSWORD_FILE && env.RESTIC_PASSWORD_FILE !== RESTIC_PASS_FILE) {
await fs.unlink(env.RESTIC_PASSWORD_FILE).catch(() => {});
} else if (config.backend === "gcs" && env.GOOGLE_APPLICATION_CREDENTIALS) {
}
if (env.GOOGLE_APPLICATION_CREDENTIALS) {
await fs.unlink(env.GOOGLE_APPLICATION_CREDENTIALS).catch(() => {});
}
if (env.RESTIC_CACERT) {
await fs.unlink(env.RESTIC_CACERT).catch(() => {});
}
};
export const addCommonArgs = (args: string[], env: Record<string, string>) => {
@ -831,6 +902,14 @@ export const addCommonArgs = (args: string[], env: Record<string, string>) => {
if (env._SFTP_SSH_ARGS) {
args.push("-o", `sftp.args=${env._SFTP_SSH_ARGS}`);
}
if (env._INSECURE_TLS === "true") {
args.push("--insecure-tls");
}
if (env.RESTIC_CACERT) {
args.push("--cacert", env.RESTIC_CACERT);
}
};
export const restic = {
@ -841,6 +920,8 @@ export const restic = {
snapshots,
forget,
deleteSnapshot,
deleteSnapshots,
tagSnapshots,
unlock,
ls,
check,

4
app/test/setup-client.ts Normal file
View file

@ -0,0 +1,4 @@
import "./setup.ts";
import { GlobalRegistrator } from "@happy-dom/global-registrator";
GlobalRegistrator.register();

261
bun.lock
View file

@ -13,6 +13,7 @@
"@inquirer/prompts": "^8.0.2",
"@radix-ui/react-alert-dialog": "^1.1.15",
"@radix-ui/react-checkbox": "^1.3.3",
"@radix-ui/react-collapsible": "^1.1.12",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-label": "^2.1.8",
"@radix-ui/react-progress": "^1.1.8",
@ -48,9 +49,12 @@
"react": "^19.2.1",
"react-dom": "^19.2.1",
"react-hook-form": "^7.68.0",
"react-markdown": "^10.1.0",
"react-router": "^7.10.0",
"react-router-hono-server": "^2.22.0",
"recharts": "3.5.1",
"remark-gfm": "^4.0.1",
"semver": "^7.7.3",
"slugify": "^1.6.6",
"sonner": "^2.0.7",
"tailwind-merge": "^3.4.0",
@ -61,14 +65,19 @@
"devDependencies": {
"@biomejs/biome": "^2.3.8",
"@faker-js/faker": "^10.1.0",
"@happy-dom/global-registrator": "^20.0.11",
"@hey-api/openapi-ts": "^0.88.0",
"@react-router/dev": "^7.10.0",
"@tailwindcss/typography": "^0.5.19",
"@tailwindcss/vite": "^4.1.17",
"@tanstack/react-query-devtools": "^5.91.1",
"@testing-library/dom": "^10.4.1",
"@testing-library/react": "^16.3.1",
"@types/bun": "^1.3.4",
"@types/node": "^25.0.3",
"@types/react": "^19.2.7",
"@types/react-dom": "^19.2.3",
"@types/semver": "^7.7.1",
"dotenv-cli": "^11.0.0",
"drizzle-kit": "^0.31.7",
"lightningcss": "^1.30.2",
@ -140,6 +149,8 @@
"@babel/preset-typescript": ["@babel/preset-typescript@7.28.5", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1", "@babel/helper-validator-option": "^7.27.1", "@babel/plugin-syntax-jsx": "^7.27.1", "@babel/plugin-transform-modules-commonjs": "^7.27.1", "@babel/plugin-transform-typescript": "^7.28.5" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-+bQy5WOI2V6LJZpPVxY+yp66XdZ2yifu0Mc1aP5CQKgjn4QM5IN2i5fAZ4xKop47pr8rpVhiAeu+nDQa12C8+g=="],
"@babel/runtime": ["@babel/runtime@7.28.4", "", {}, "sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ=="],
"@babel/template": ["@babel/template@7.27.2", "", { "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/parser": "^7.27.2", "@babel/types": "^7.27.1" } }, "sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw=="],
"@babel/traverse": ["@babel/traverse@7.28.5", "", { "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.5", "@babel/helper-globals": "^7.28.0", "@babel/parser": "^7.28.5", "@babel/template": "^7.27.2", "@babel/types": "^7.28.5", "debug": "^4.3.1" } }, "sha512-TCCj4t55U90khlYkVV/0TfkJkAkUg3jZFA3Neb7unZT8CPok7iiRfaX0F+WnqWqt7OxhOn0uBKXCw4lbL8W0aQ=="],
@ -244,6 +255,8 @@
"@floating-ui/utils": ["@floating-ui/utils@0.2.10", "", {}, "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ=="],
"@happy-dom/global-registrator": ["@happy-dom/global-registrator@20.0.11", "", { "dependencies": { "@types/node": "^20.0.0", "happy-dom": "^20.0.11" } }, "sha512-GqNqiShBT/lzkHTMC/slKBrvN0DsD4Di8ssBk4aDaVgEn+2WMzE6DXxq701ndSXj7/0cJ8mNT71pM7Bnrr6JRw=="],
"@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.2", "", { "dependencies": { "@jsdevtools/ono": "^7.1.3", "@types/json-schema": "^7.0.15", "js-yaml": "^4.1.1", "lodash": "^4.17.21" } }, "sha512-oS+5yAdwnK20lSeFO1d53Ku+yaGCsY8PcrmSq2GtSs3bsBfRnHAbpPKSVzQcaxAOrzj5NB+f34WhZglVrNayBA=="],
@ -316,6 +329,8 @@
"@radix-ui/react-checkbox": ["@radix-ui/react-checkbox@1.3.3", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-previous": "1.1.1", "@radix-ui/react-use-size": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-wBbpv+NQftHDdG86Qc0pIyXk5IR3tM8Vd0nWLKDcX8nNn4nXFOFwsKuqw2okA/1D/mpaAkmuyndrPJTYDNZtFw=="],
"@radix-ui/react-collapsible": ["@radix-ui/react-collapsible@1.1.12", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-Uu+mSh4agx2ib1uIGPP4/CKNULyajb3p92LsVXmH2EHVMTfZWpll88XJ0j4W0z3f8NK1eYl1+Mf/szHPmcHzyA=="],
"@radix-ui/react-collection": ["@radix-ui/react-collection@1.1.7", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-Fh9rGN0MoI4ZFUNyfFVNU4y9LUz93u9/0K+yLgA2bwRojxM8JU1DyvvMBabnZPBgMWREAJvU2jjVzq+LrFUglw=="],
"@radix-ui/react-compose-refs": ["@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-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg=="],
@ -484,6 +499,8 @@
"@tailwindcss/oxide-win32-x64-msvc": ["@tailwindcss/oxide-win32-x64-msvc@4.1.18", "", { "os": "win32", "cpu": "x64" }, "sha512-bJWbyYpUlqamC8dpR7pfjA0I7vdF6t5VpUGMWRkXVE3AXgIZjYUYAK7II1GNaxR8J1SSrSrppRar8G++JekE3Q=="],
"@tailwindcss/typography": ["@tailwindcss/typography@0.5.19", "", { "dependencies": { "postcss-selector-parser": "6.0.10" }, "peerDependencies": { "tailwindcss": ">=3.0.0 || insiders || >=4.0.0-alpha.20 || >=4.0.0-beta.1" } }, "sha512-w31dd8HOx3k9vPtcQh5QHP9GwKcgbMp87j58qi6xgiBnFFtKEAgCWnDw4qUT8aHwkCp8bKvb/KGKWWHedP0AAg=="],
"@tailwindcss/vite": ["@tailwindcss/vite@4.1.18", "", { "dependencies": { "@tailwindcss/node": "4.1.18", "@tailwindcss/oxide": "4.1.18", "tailwindcss": "4.1.18" }, "peerDependencies": { "vite": "^5.2.0 || ^6 || ^7" } }, "sha512-jVA+/UpKL1vRLg6Hkao5jldawNmRo7mQYrZtNHMIVpLfLhDml5nMRUo/8MwoX2vNXvnaXNNMedrMfMugAVX1nA=="],
"@tanstack/query-core": ["@tanstack/query-core@5.90.12", "", {}, "sha512-T1/8t5DhV/SisWjDnaiU2drl6ySvsHj1bHBCWNXd+/T+Hh1cf6JodyEYMd5sgwm+b/mETT4EV3H+zCVczCU5hg=="],
@ -494,6 +511,12 @@
"@tanstack/react-query-devtools": ["@tanstack/react-query-devtools@5.91.1", "", { "dependencies": { "@tanstack/query-devtools": "5.91.1" }, "peerDependencies": { "@tanstack/react-query": "^5.90.10", "react": "^18 || ^19" } }, "sha512-tRnJYwEbH0kAOuToy8Ew7bJw1lX3AjkkgSlf/vzb+NpnqmHPdWM+lA2DSdGQSLi1SU0PDRrrCI1vnZnci96CsQ=="],
"@testing-library/dom": ["@testing-library/dom@10.4.1", "", { "dependencies": { "@babel/code-frame": "^7.10.4", "@babel/runtime": "^7.12.5", "@types/aria-query": "^5.0.1", "aria-query": "5.3.0", "dom-accessibility-api": "^0.5.9", "lz-string": "^1.5.0", "picocolors": "1.1.1", "pretty-format": "^27.0.2" } }, "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg=="],
"@testing-library/react": ["@testing-library/react@16.3.1", "", { "dependencies": { "@babel/runtime": "^7.12.5" }, "peerDependencies": { "@testing-library/dom": "^10.0.0", "@types/react": "^18.0.0 || ^19.0.0", "@types/react-dom": "^18.0.0 || ^19.0.0", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-gr4KtAWqIOQoucWYD/f6ki+j5chXfcPc74Col/6poTyqTmn7zRmodWahWRCp8tYd+GMqBonw6hstNzqjbs6gjw=="],
"@types/aria-query": ["@types/aria-query@5.0.4", "", {}, "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw=="],
"@types/bun": ["@types/bun@1.3.5", "", { "dependencies": { "bun-types": "1.3.5" } }, "sha512-RnygCqNrd3srIPEWBd5LFeUYG7plCoH2Yw9WaZGyNmdTEei+gWaHqydbaIRkIkcbXwhBT94q78QljxN0Sk838w=="],
"@types/d3-array": ["@types/d3-array@3.2.2", "", {}, "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw=="],
@ -514,27 +537,45 @@
"@types/d3-timer": ["@types/d3-timer@3.0.2", "", {}, "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw=="],
"@types/debug": ["@types/debug@4.1.12", "", { "dependencies": { "@types/ms": "*" } }, "sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ=="],
"@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="],
"@types/estree-jsx": ["@types/estree-jsx@1.0.5", "", { "dependencies": { "@types/estree": "*" } }, "sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg=="],
"@types/hast": ["@types/hast@3.0.4", "", { "dependencies": { "@types/unist": "*" } }, "sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ=="],
"@types/json-schema": ["@types/json-schema@7.0.15", "", {}, "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="],
"@types/mdast": ["@types/mdast@4.0.4", "", { "dependencies": { "@types/unist": "*" } }, "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA=="],
"@types/ms": ["@types/ms@2.1.0", "", {}, "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA=="],
"@types/node": ["@types/node@25.0.3", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-W609buLVRVmeW693xKfzHeIV6nJGGz98uCPfeXI1ELMLXVeKYZ9m15fAMSaUPBHYLGFsVRcMmSCksQOrZV9BYA=="],
"@types/react": ["@types/react@19.2.7", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg=="],
"@types/react-dom": ["@types/react-dom@19.2.3", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ=="],
"@types/semver": ["@types/semver@7.7.1", "", {}, "sha512-FmgJfu+MOcQ370SD0ev7EI8TlCAfKYU+B4m5T3yXc1CiRN94g/SZPtsCkk506aUDtlMnFZvasDwHHUcZUEaYuA=="],
"@types/triple-beam": ["@types/triple-beam@1.3.5", "", {}, "sha512-6WaYesThRMCl19iryMYP7/x2OVgCtbIVflDGFpWnb9irXI3UjYE4AzmYuiUKY1AJstGijoY+MgUszMgRxIYTYw=="],
"@types/unist": ["@types/unist@3.0.3", "", {}, "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q=="],
"@types/use-sync-external-store": ["@types/use-sync-external-store@0.0.6", "", {}, "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg=="],
"@types/whatwg-mimetype": ["@types/whatwg-mimetype@3.0.2", "", {}, "sha512-c2AKvDT8ToxLIOUlN51gTiHXflsfIFisS4pO7pDPoKouJCESkhZnEy623gwP9laCy5lnLDAw1vAzu2vM2YLOrA=="],
"@ungap/structured-clone": ["@ungap/structured-clone@1.3.0", "", {}, "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g=="],
"accepts": ["accepts@1.3.8", "", { "dependencies": { "mime-types": "~2.1.34", "negotiator": "0.6.3" } }, "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw=="],
"ansi-colors": ["ansi-colors@4.1.3", "", {}, "sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw=="],
"ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="],
"ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
"ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="],
"ansi-styles": ["ansi-styles@5.2.0", "", {}, "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA=="],
"anymatch": ["anymatch@3.1.3", "", { "dependencies": { "normalize-path": "^3.0.0", "picomatch": "^2.0.4" } }, "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw=="],
@ -544,6 +585,8 @@
"aria-hidden": ["aria-hidden@1.2.6", "", { "dependencies": { "tslib": "^2.0.0" } }, "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA=="],
"aria-query": ["aria-query@5.3.0", "", { "dependencies": { "dequal": "^2.0.3" } }, "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A=="],
"arkregex": ["arkregex@0.0.5", "", { "dependencies": { "@ark/util": "0.56.0" } }, "sha512-ncYjBdLlh5/QnVsAA8De16Tc9EqmYM7y/WU9j+236KcyYNUXogpz3sC4ATIZYzzLxwI+0sEOaQLEmLmRleaEXw=="],
"arktype": ["arktype@2.1.29", "", { "dependencies": { "@ark/schema": "0.56.0", "@ark/util": "0.56.0", "arkregex": "0.0.5" } }, "sha512-jyfKk4xIOzvYNayqnD8ZJQqOwcrTOUbIU4293yrzAjA3O1dWh61j71ArMQ6tS/u4pD7vabSPe7nG3RCyoXW6RQ=="],
@ -554,6 +597,8 @@
"babel-dead-code-elimination": ["babel-dead-code-elimination@1.0.11", "", { "dependencies": { "@babel/core": "^7.23.7", "@babel/parser": "^7.23.6", "@babel/traverse": "^7.23.7", "@babel/types": "^7.23.6" } }, "sha512-mwq3W3e/pKSI6TG8lXMiDWvEi1VXYlSBlJlB3l+I0bAb5u1RNUl88udos85eOPNK3m5EXK9uO7d2g08pesTySQ=="],
"bail": ["bail@2.0.2", "", {}, "sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw=="],
"balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="],
"baseline-browser-mapping": ["baseline-browser-mapping@2.9.11", "", { "bin": { "baseline-browser-mapping": "dist/cli.js" } }, "sha512-Sg0xJUNDU1sJNGdfGWhVHX0kkZ+HWcvmVymJbj6NSgZZmW/8S9Y2HQ5euytnIgakgxN6papOAWiwDo1ctFDcoQ=="],
@ -584,6 +629,16 @@
"caniuse-lite": ["caniuse-lite@1.0.30001761", "", {}, "sha512-JF9ptu1vP2coz98+5051jZ4PwQgd2ni8A+gYSN7EA7dPKIMf0pDlSUxhdmVOaV3/fYK5uWBkgSXJaRLr4+3A6g=="],
"ccount": ["ccount@2.0.1", "", {}, "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg=="],
"character-entities": ["character-entities@2.0.2", "", {}, "sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ=="],
"character-entities-html4": ["character-entities-html4@2.1.0", "", {}, "sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA=="],
"character-entities-legacy": ["character-entities-legacy@3.0.0", "", {}, "sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ=="],
"character-reference-invalid": ["character-reference-invalid@2.0.1", "", {}, "sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw=="],
"chardet": ["chardet@2.1.1", "", {}, "sha512-PsezH1rqdV9VvyNhxxOW32/d75r01NY7TQCmOqomRo15ZSOKbpTFVsfjghxo6JloQUCGnH4k1LGu0R4yCLlWQQ=="],
"chokidar": ["chokidar@4.0.3", "", { "dependencies": { "readdirp": "^4.0.1" } }, "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA=="],
@ -606,6 +661,8 @@
"color-support": ["color-support@1.1.3", "", { "bin": { "color-support": "bin.js" } }, "sha512-qiBjkpbMLO/HL68y+lh4q0/O1MZFj2RX6X/KmMa3+gJD3z+WwI1ZzDHysvqHGS3mP6mznPckpXmw1nI9cJjyRg=="],
"comma-separated-tokens": ["comma-separated-tokens@2.0.3", "", {}, "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg=="],
"commander": ["commander@14.0.2", "", {}, "sha512-TywoWNNRbhoD0BXs1P3ZEScW8W5iKrnbithIl0YH+uCmBd0QpPOA8yc82DS3BIE5Ma6FnBVUsJ7wVUDz4dvOWQ=="],
"compressible": ["compressible@2.0.18", "", { "dependencies": { "mime-db": ">= 1.43.0 < 2" } }, "sha512-AF3r7P5dWxL8MxyITRMlORQNaOA2IkAFaTr4k7BUumjPtRpGDTZpl0Pb1XCO6JeDCBdp126Cgs9sMxqSjgYyRg=="],
@ -634,6 +691,8 @@
"crossws": ["crossws@0.3.5", "", { "dependencies": { "uncrypto": "^0.1.3" } }, "sha512-ojKiDvcmByhwa8YYqbQI/hg7MEU0NC03+pSdEq4ZUnZR9xXpwk7E43SMNGkn+JxJGPFtNvQ48+vV2p+P1ml5PA=="],
"cssesc": ["cssesc@3.0.0", "", { "bin": { "cssesc": "bin/cssesc" } }, "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg=="],
"csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="],
"d3-array": ["d3-array@3.2.4", "", { "dependencies": { "internmap": "1 - 2" } }, "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg=="],
@ -664,6 +723,8 @@
"decimal.js-light": ["decimal.js-light@2.5.1", "", {}, "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg=="],
"decode-named-character-reference": ["decode-named-character-reference@1.2.0", "", { "dependencies": { "character-entities": "^2.0.0" } }, "sha512-c6fcElNV6ShtZXmsgNgFFV5tVX2PaV4g+MOAkb8eXHvn6sryJBrZa9r0zV6+dtTyoCKxtDy5tyQ5ZwQuidtd+Q=="],
"dedent": ["dedent@1.7.1", "", { "peerDependencies": { "babel-plugin-macros": "^3.1.0" }, "optionalPeers": ["babel-plugin-macros"] }, "sha512-9JmrhGZpOlEgOLdQgSm0zxFaYoQon408V1v49aqTWuXENVlnCuY9JBZcXZiCsZQWDjTm5Qf/nIvAy77mXDAjEg=="],
"default-browser": ["default-browser@5.4.0", "", { "dependencies": { "bundle-name": "^4.1.0", "default-browser-id": "^5.0.0" } }, "sha512-XDuvSq38Hr1MdN47EDvYtx3U0MTqpCEn+F6ft8z2vYDzMrvQhVp0ui9oQdqW3MvK3vqUETglt1tVGgjLuJ5izg=="],
@ -676,6 +737,8 @@
"depd": ["depd@2.0.0", "", {}, "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw=="],
"dequal": ["dequal@2.0.3", "", {}, "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA=="],
"destr": ["destr@2.0.5", "", {}, "sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA=="],
"destroy": ["destroy@1.2.0", "", {}, "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg=="],
@ -684,8 +747,12 @@
"detect-node-es": ["detect-node-es@1.1.0", "", {}, "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ=="],
"devlop": ["devlop@1.1.0", "", { "dependencies": { "dequal": "^2.0.0" } }, "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA=="],
"dither-plugin": ["dither-plugin@1.1.1", "", {}, "sha512-PsgAcSoNVKkwh+Q/OopRn/2qb9HW1LRyGqT1bQe8iooYvVY1FIIqePFN9JkEIVK9rkfZdj7nXn9EUB4B7mNh6g=="],
"dom-accessibility-api": ["dom-accessibility-api@0.5.16", "", {}, "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg=="],
"dotenv": ["dotenv@17.2.3", "", {}, "sha512-JVUnt+DUIzu87TABbhPmNfVdBDt18BLOWjMUFJMSi/Qqg7NTYtabbvSNJGOJ7afbRuv9D/lngizHtP7QyLQ+9w=="],
"dotenv-cli": ["dotenv-cli@11.0.0", "", { "dependencies": { "cross-spawn": "^7.0.6", "dotenv": "^17.1.0", "dotenv-expand": "^12.0.0", "minimist": "^1.2.6" }, "bin": { "dotenv": "cli.js" } }, "sha512-r5pA8idbk7GFWuHEU7trSTflWcdBpQEK+Aw17UrSHjS6CReuhrrPcyC3zcQBPQvhArRHnBo/h6eLH1fkCvNlww=="],
@ -728,6 +795,10 @@
"escape-html": ["escape-html@1.0.3", "", {}, "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow=="],
"escape-string-regexp": ["escape-string-regexp@5.0.0", "", {}, "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw=="],
"estree-util-is-identifier-name": ["estree-util-is-identifier-name@3.0.0", "", {}, "sha512-hFtqIDZTIUZ9BXLb8y4pYGyk6+wekIivNVTcmvk8NoOh+VeRn5y6cEHzbURrWbfp1fIqdVipilzj+lfaadNZmg=="],
"etag": ["etag@1.8.1", "", {}, "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg=="],
"eventemitter3": ["eventemitter3@5.0.1", "", {}, "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA=="],
@ -738,6 +809,8 @@
"exsolve": ["exsolve@1.0.8", "", {}, "sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA=="],
"extend": ["extend@3.0.2", "", {}, "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g=="],
"fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],
"fecha": ["fecha@4.2.3", "", {}, "sha512-OP2IUU6HeYKJi3i0z4A19kHMQoLVs4Hc+DPqqxI2h/DPZHTm/vjsfC6P0b4jCMy14XizLBqvndQ+UilD7707Jw=="],
@ -778,16 +851,24 @@
"h3": ["h3@1.15.4", "", { "dependencies": { "cookie-es": "^1.2.2", "crossws": "^0.3.5", "defu": "^6.1.4", "destr": "^2.0.5", "iron-webcrypto": "^1.2.1", "node-mock-http": "^1.0.2", "radix3": "^1.1.2", "ufo": "^1.6.1", "uncrypto": "^0.1.3" } }, "sha512-z5cFQWDffyOe4vQ9xIqNfCZdV4p//vy6fBnr8Q1AWnVZ0teurKMG66rLj++TKwKPUP3u7iMUvrvKaEUiQw2QWQ=="],
"happy-dom": ["happy-dom@20.0.11", "", { "dependencies": { "@types/node": "^20.0.0", "@types/whatwg-mimetype": "^3.0.2", "whatwg-mimetype": "^3.0.0" } }, "sha512-QsCdAUHAmiDeKeaNojb1OHOPF7NjcWPBR7obdu3NwH2a/oyQaLg5d0aaCy/9My6CdPChYF07dvz5chaXBGaD4g=="],
"has-symbols": ["has-symbols@1.1.0", "", {}, "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ=="],
"hasown": ["hasown@2.0.2", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="],
"hast-util-to-jsx-runtime": ["hast-util-to-jsx-runtime@2.3.6", "", { "dependencies": { "@types/estree": "^1.0.0", "@types/hast": "^3.0.0", "@types/unist": "^3.0.0", "comma-separated-tokens": "^2.0.0", "devlop": "^1.0.0", "estree-util-is-identifier-name": "^3.0.0", "hast-util-whitespace": "^3.0.0", "mdast-util-mdx-expression": "^2.0.0", "mdast-util-mdx-jsx": "^3.0.0", "mdast-util-mdxjs-esm": "^2.0.0", "property-information": "^7.0.0", "space-separated-tokens": "^2.0.0", "style-to-js": "^1.0.0", "unist-util-position": "^5.0.0", "vfile-message": "^4.0.0" } }, "sha512-zl6s8LwNyo1P9uw+XJGvZtdFF1GdAkOg8ujOw+4Pyb76874fLps4ueHXDhXWdk6YHQ6OgUtinliG7RsYvCbbBg=="],
"hast-util-whitespace": ["hast-util-whitespace@3.0.0", "", { "dependencies": { "@types/hast": "^3.0.0" } }, "sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw=="],
"hono": ["hono@4.10.5", "", {}, "sha512-h/MXuTkoAK8NG1EfDp0jI1YLf6yGdDnfkebRO2pwEh5+hE3RAJFXkCsnD0vamSiARK4ZrB6MY+o3E/hCnOyHrQ=="],
"hono-openapi": ["hono-openapi@1.1.2", "", { "peerDependencies": { "@hono/standard-validator": "^0.2.0", "@standard-community/standard-json": "^0.3.5", "@standard-community/standard-openapi": "^0.2.9", "@types/json-schema": "^7.0.15", "hono": "^4.8.3", "openapi-types": "^12.1.3" }, "optionalPeers": ["@hono/standard-validator", "hono"] }, "sha512-toUcO60MftRBxqcVyxsHNYs2m4vf4xkQaiARAucQx3TiBPDtMNNkoh+C4I1vAretQZiGyaLOZNWn1YxfSyUA5g=="],
"hono-rate-limiter": ["hono-rate-limiter@0.5.1", "", { "peerDependencies": { "hono": "^4.10.8", "unstorage": "^1.17.3" } }, "sha512-c3bUn6IRgFKjlouvRNBy+ZIPZ2CTyTt3fc0uat2bv3GiHmLM4jI0QJ6fHd3Tf4R6dO2sX2Uvl9Gtp+kny4KdXg=="],
"html-url-attributes": ["html-url-attributes@3.0.1", "", {}, "sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ=="],
"http-errors": ["http-errors@2.0.1", "", { "dependencies": { "depd": "~2.0.0", "inherits": "~2.0.4", "setprototypeof": "~1.2.0", "statuses": "~2.0.2", "toidentifier": "~1.0.1" } }, "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ=="],
"http-errors-enhanced": ["http-errors-enhanced@4.0.2", "", {}, "sha512-5EXN1gmhJVvuWpNfz+RclWvLnnENEXNMPfww3gm30H9mQzPF4QSBj/MD5FRkVDxGIUhO/cR2GSLCd/6C6xpBcw=="],
@ -798,18 +879,30 @@
"inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="],
"inline-style-parser": ["inline-style-parser@0.2.7", "", {}, "sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA=="],
"internmap": ["internmap@2.0.3", "", {}, "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg=="],
"ipaddr.js": ["ipaddr.js@1.9.1", "", {}, "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g=="],
"iron-webcrypto": ["iron-webcrypto@1.2.1", "", {}, "sha512-feOM6FaSr6rEABp/eDfVseKyTMDt+KGpeB35SkVn9Tyn0CqvVsY3EwI0v5i8nMHyJnzCIQf7nsy3p41TPkJZhg=="],
"is-alphabetical": ["is-alphabetical@2.0.1", "", {}, "sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ=="],
"is-alphanumerical": ["is-alphanumerical@2.0.1", "", { "dependencies": { "is-alphabetical": "^2.0.0", "is-decimal": "^2.0.0" } }, "sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw=="],
"is-decimal": ["is-decimal@2.0.1", "", {}, "sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A=="],
"is-docker": ["is-docker@3.0.0", "", { "bin": { "is-docker": "cli.js" } }, "sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ=="],
"is-hexadecimal": ["is-hexadecimal@2.0.1", "", {}, "sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg=="],
"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-plain-obj": ["is-plain-obj@4.1.0", "", {}, "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg=="],
"is-stream": ["is-stream@2.0.1", "", {}, "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg=="],
"is-wsl": ["is-wsl@3.1.0", "", { "dependencies": { "is-inside-container": "^1.0.0" } }, "sha512-UcVfVfaK4Sc4m7X3dUSoHoozQGBEFeDC+zVo06t98xe8CzHSZZBekNXH+tu0NalHolcJ/QAGqS46Hef7QXBIMw=="],
@ -858,22 +951,114 @@
"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=="],
"longest-streak": ["longest-streak@3.1.0", "", {}, "sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g=="],
"lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="],
"lucide-react": ["lucide-react@0.555.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-D8FvHUGbxWBRQM90NZeIyhAvkFfsh3u9ekrMvJ30Z6gnpBHS6HC6ldLg7tL45hwiIz/u66eKDtdA23gwwGsAHA=="],
"luxon": ["luxon@3.7.2", "", {}, "sha512-vtEhXh/gNjI9Yg1u4jX/0YVPMvxzHuGgCm6tC5kZyb08yjGWGnqAjGJvcXbqQR2P3MyMEFnRbpcdFS6PBcLqew=="],
"lz-string": ["lz-string@1.5.0", "", { "bin": { "lz-string": "bin/bin.js" } }, "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ=="],
"magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="],
"markdown-table": ["markdown-table@3.0.4", "", {}, "sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw=="],
"math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="],
"mdast-util-find-and-replace": ["mdast-util-find-and-replace@3.0.2", "", { "dependencies": { "@types/mdast": "^4.0.0", "escape-string-regexp": "^5.0.0", "unist-util-is": "^6.0.0", "unist-util-visit-parents": "^6.0.0" } }, "sha512-Tmd1Vg/m3Xz43afeNxDIhWRtFZgM2VLyaf4vSTYwudTyeuTneoL3qtWMA5jeLyz/O1vDJmmV4QuScFCA2tBPwg=="],
"mdast-util-from-markdown": ["mdast-util-from-markdown@2.0.2", "", { "dependencies": { "@types/mdast": "^4.0.0", "@types/unist": "^3.0.0", "decode-named-character-reference": "^1.0.0", "devlop": "^1.0.0", "mdast-util-to-string": "^4.0.0", "micromark": "^4.0.0", "micromark-util-decode-numeric-character-reference": "^2.0.0", "micromark-util-decode-string": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0", "unist-util-stringify-position": "^4.0.0" } }, "sha512-uZhTV/8NBuw0WHkPTrCqDOl0zVe1BIng5ZtHoDk49ME1qqcjYmmLmOf0gELgcRMxN4w2iuIeVso5/6QymSrgmA=="],
"mdast-util-gfm": ["mdast-util-gfm@3.1.0", "", { "dependencies": { "mdast-util-from-markdown": "^2.0.0", "mdast-util-gfm-autolink-literal": "^2.0.0", "mdast-util-gfm-footnote": "^2.0.0", "mdast-util-gfm-strikethrough": "^2.0.0", "mdast-util-gfm-table": "^2.0.0", "mdast-util-gfm-task-list-item": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-0ulfdQOM3ysHhCJ1p06l0b0VKlhU0wuQs3thxZQagjcjPrlFRqY215uZGHHJan9GEAXd9MbfPjFJz+qMkVR6zQ=="],
"mdast-util-gfm-autolink-literal": ["mdast-util-gfm-autolink-literal@2.0.1", "", { "dependencies": { "@types/mdast": "^4.0.0", "ccount": "^2.0.0", "devlop": "^1.0.0", "mdast-util-find-and-replace": "^3.0.0", "micromark-util-character": "^2.0.0" } }, "sha512-5HVP2MKaP6L+G6YaxPNjuL0BPrq9orG3TsrZ9YXbA3vDw/ACI4MEsnoDpn6ZNm7GnZgtAcONJyPhOP8tNJQavQ=="],
"mdast-util-gfm-footnote": ["mdast-util-gfm-footnote@2.1.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "devlop": "^1.1.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0" } }, "sha512-sqpDWlsHn7Ac9GNZQMeUzPQSMzR6Wv0WKRNvQRg0KqHh02fpTz69Qc1QSseNX29bhz1ROIyNyxExfawVKTm1GQ=="],
"mdast-util-gfm-strikethrough": ["mdast-util-gfm-strikethrough@2.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-mKKb915TF+OC5ptj5bJ7WFRPdYtuHv0yTRxK2tJvi+BDqbkiG7h7u/9SI89nRAYcmap2xHQL9D+QG/6wSrTtXg=="],
"mdast-util-gfm-table": ["mdast-util-gfm-table@2.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "markdown-table": "^3.0.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-78UEvebzz/rJIxLvE7ZtDd/vIQ0RHv+3Mh5DR96p7cS7HsBhYIICDBCu8csTNWNO6tBWfqXPWekRuj2FNOGOZg=="],
"mdast-util-gfm-task-list-item": ["mdast-util-gfm-task-list-item@2.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-IrtvNvjxC1o06taBAVJznEnkiHxLFTzgonUdy8hzFVeDun0uTjxxrRGVaNFqkU1wJR3RBPEfsxmU6jDWPofrTQ=="],
"mdast-util-mdx-expression": ["mdast-util-mdx-expression@2.0.1", "", { "dependencies": { "@types/estree-jsx": "^1.0.0", "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-J6f+9hUp+ldTZqKRSg7Vw5V6MqjATc+3E4gf3CFNcuZNWD8XdyI6zQ8GqH7f8169MM6P7hMBRDVGnn7oHB9kXQ=="],
"mdast-util-mdx-jsx": ["mdast-util-mdx-jsx@3.2.0", "", { "dependencies": { "@types/estree-jsx": "^1.0.0", "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "@types/unist": "^3.0.0", "ccount": "^2.0.0", "devlop": "^1.1.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0", "parse-entities": "^4.0.0", "stringify-entities": "^4.0.0", "unist-util-stringify-position": "^4.0.0", "vfile-message": "^4.0.0" } }, "sha512-lj/z8v0r6ZtsN/cGNNtemmmfoLAFZnjMbNyLzBafjzikOM+glrjNHPlf6lQDOTccj9n5b0PPihEBbhneMyGs1Q=="],
"mdast-util-mdxjs-esm": ["mdast-util-mdxjs-esm@2.0.1", "", { "dependencies": { "@types/estree-jsx": "^1.0.0", "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-EcmOpxsZ96CvlP03NghtH1EsLtr0n9Tm4lPUJUBccV9RwUOneqSycg19n5HGzCf+10LozMRSObtVr3ee1WoHtg=="],
"mdast-util-phrasing": ["mdast-util-phrasing@4.1.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "unist-util-is": "^6.0.0" } }, "sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w=="],
"mdast-util-to-hast": ["mdast-util-to-hast@13.2.1", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "@ungap/structured-clone": "^1.0.0", "devlop": "^1.0.0", "micromark-util-sanitize-uri": "^2.0.0", "trim-lines": "^3.0.0", "unist-util-position": "^5.0.0", "unist-util-visit": "^5.0.0", "vfile": "^6.0.0" } }, "sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA=="],
"mdast-util-to-markdown": ["mdast-util-to-markdown@2.1.2", "", { "dependencies": { "@types/mdast": "^4.0.0", "@types/unist": "^3.0.0", "longest-streak": "^3.0.0", "mdast-util-phrasing": "^4.0.0", "mdast-util-to-string": "^4.0.0", "micromark-util-classify-character": "^2.0.0", "micromark-util-decode-string": "^2.0.0", "unist-util-visit": "^5.0.0", "zwitch": "^2.0.0" } }, "sha512-xj68wMTvGXVOKonmog6LwyJKrYXZPvlwabaryTjLh9LuvovB/KAH+kvi8Gjj+7rJjsFi23nkUxRQv1KqSroMqA=="],
"mdast-util-to-string": ["mdast-util-to-string@4.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0" } }, "sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg=="],
"media-typer": ["media-typer@0.3.0", "", {}, "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ=="],
"merge-descriptors": ["merge-descriptors@1.0.3", "", {}, "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ=="],
"methods": ["methods@1.1.2", "", {}, "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w=="],
"micromark": ["micromark@4.0.2", "", { "dependencies": { "@types/debug": "^4.0.0", "debug": "^4.0.0", "decode-named-character-reference": "^1.0.0", "devlop": "^1.0.0", "micromark-core-commonmark": "^2.0.0", "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-combine-extensions": "^2.0.0", "micromark-util-decode-numeric-character-reference": "^2.0.0", "micromark-util-encode": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-resolve-all": "^2.0.0", "micromark-util-sanitize-uri": "^2.0.0", "micromark-util-subtokenize": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA=="],
"micromark-core-commonmark": ["micromark-core-commonmark@2.0.3", "", { "dependencies": { "decode-named-character-reference": "^1.0.0", "devlop": "^1.0.0", "micromark-factory-destination": "^2.0.0", "micromark-factory-label": "^2.0.0", "micromark-factory-space": "^2.0.0", "micromark-factory-title": "^2.0.0", "micromark-factory-whitespace": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-classify-character": "^2.0.0", "micromark-util-html-tag-name": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-resolve-all": "^2.0.0", "micromark-util-subtokenize": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg=="],
"micromark-extension-gfm": ["micromark-extension-gfm@3.0.0", "", { "dependencies": { "micromark-extension-gfm-autolink-literal": "^2.0.0", "micromark-extension-gfm-footnote": "^2.0.0", "micromark-extension-gfm-strikethrough": "^2.0.0", "micromark-extension-gfm-table": "^2.0.0", "micromark-extension-gfm-tagfilter": "^2.0.0", "micromark-extension-gfm-task-list-item": "^2.0.0", "micromark-util-combine-extensions": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-vsKArQsicm7t0z2GugkCKtZehqUm31oeGBV/KVSorWSy8ZlNAv7ytjFhvaryUiCUJYqs+NoE6AFhpQvBTM6Q4w=="],
"micromark-extension-gfm-autolink-literal": ["micromark-extension-gfm-autolink-literal@2.1.0", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-sanitize-uri": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-oOg7knzhicgQ3t4QCjCWgTmfNhvQbDDnJeVu9v81r7NltNCVmhPy1fJRX27pISafdjL+SVc4d3l48Gb6pbRypw=="],
"micromark-extension-gfm-footnote": ["micromark-extension-gfm-footnote@2.1.0", "", { "dependencies": { "devlop": "^1.0.0", "micromark-core-commonmark": "^2.0.0", "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-sanitize-uri": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-/yPhxI1ntnDNsiHtzLKYnE3vf9JZ6cAisqVDauhp4CEHxlb4uoOTxOCJ+9s51bIB8U1N1FJ1RXOKTIlD5B/gqw=="],
"micromark-extension-gfm-strikethrough": ["micromark-extension-gfm-strikethrough@2.1.0", "", { "dependencies": { "devlop": "^1.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-classify-character": "^2.0.0", "micromark-util-resolve-all": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-ADVjpOOkjz1hhkZLlBiYA9cR2Anf8F4HqZUO6e5eDcPQd0Txw5fxLzzxnEkSkfnD0wziSGiv7sYhk/ktvbf1uw=="],
"micromark-extension-gfm-table": ["micromark-extension-gfm-table@2.1.1", "", { "dependencies": { "devlop": "^1.0.0", "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-t2OU/dXXioARrC6yWfJ4hqB7rct14e8f7m0cbI5hUmDyyIlwv5vEtooptH8INkbLzOatzKuVbQmAYcbWoyz6Dg=="],
"micromark-extension-gfm-tagfilter": ["micromark-extension-gfm-tagfilter@2.0.0", "", { "dependencies": { "micromark-util-types": "^2.0.0" } }, "sha512-xHlTOmuCSotIA8TW1mDIM6X2O1SiX5P9IuDtqGonFhEK0qgRI4yeC6vMxEV2dgyr2TiD+2PQ10o+cOhdVAcwfg=="],
"micromark-extension-gfm-task-list-item": ["micromark-extension-gfm-task-list-item@2.1.0", "", { "dependencies": { "devlop": "^1.0.0", "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-qIBZhqxqI6fjLDYFTBIa4eivDMnP+OZqsNwmQ3xNLE4Cxwc+zfQEfbs6tzAo2Hjq+bh6q5F+Z8/cksrLFYWQQw=="],
"micromark-factory-destination": ["micromark-factory-destination@2.0.1", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA=="],
"micromark-factory-label": ["micromark-factory-label@2.0.1", "", { "dependencies": { "devlop": "^1.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-VFMekyQExqIW7xIChcXn4ok29YE3rnuyveW3wZQWWqF4Nv9Wk5rgJ99KzPvHjkmPXF93FXIbBp6YdW3t71/7Vg=="],
"micromark-factory-space": ["micromark-factory-space@2.0.1", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-zRkxjtBxxLd2Sc0d+fbnEunsTj46SWXgXciZmHq0kDYGnck/ZSGj9/wULTV95uoeYiK5hRXP2mJ98Uo4cq/LQg=="],
"micromark-factory-title": ["micromark-factory-title@2.0.1", "", { "dependencies": { "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-5bZ+3CjhAd9eChYTHsjy6TGxpOFSKgKKJPJxr293jTbfry2KDoWkhBb6TcPVB4NmzaPhMs1Frm9AZH7OD4Cjzw=="],
"micromark-factory-whitespace": ["micromark-factory-whitespace@2.0.1", "", { "dependencies": { "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-Ob0nuZ3PKt/n0hORHyvoD9uZhr+Za8sFoP+OnMcnWK5lngSzALgQYKMr9RJVOWLqQYuyn6ulqGWSXdwf6F80lQ=="],
"micromark-util-character": ["micromark-util-character@2.1.1", "", { "dependencies": { "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q=="],
"micromark-util-chunked": ["micromark-util-chunked@2.0.1", "", { "dependencies": { "micromark-util-symbol": "^2.0.0" } }, "sha512-QUNFEOPELfmvv+4xiNg2sRYeS/P84pTW0TCgP5zc9FpXetHY0ab7SxKyAQCNCc1eK0459uoLI1y5oO5Vc1dbhA=="],
"micromark-util-classify-character": ["micromark-util-classify-character@2.0.1", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-K0kHzM6afW/MbeWYWLjoHQv1sgg2Q9EccHEDzSkxiP/EaagNzCm7T/WMKZ3rjMbvIpvBiZgwR3dKMygtA4mG1Q=="],
"micromark-util-combine-extensions": ["micromark-util-combine-extensions@2.0.1", "", { "dependencies": { "micromark-util-chunked": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-OnAnH8Ujmy59JcyZw8JSbK9cGpdVY44NKgSM7E9Eh7DiLS2E9RNQf0dONaGDzEG9yjEl5hcqeIsj4hfRkLH/Bg=="],
"micromark-util-decode-numeric-character-reference": ["micromark-util-decode-numeric-character-reference@2.0.2", "", { "dependencies": { "micromark-util-symbol": "^2.0.0" } }, "sha512-ccUbYk6CwVdkmCQMyr64dXz42EfHGkPQlBj5p7YVGzq8I7CtjXZJrubAYezf7Rp+bjPseiROqe7G6foFd+lEuw=="],
"micromark-util-decode-string": ["micromark-util-decode-string@2.0.1", "", { "dependencies": { "decode-named-character-reference": "^1.0.0", "micromark-util-character": "^2.0.0", "micromark-util-decode-numeric-character-reference": "^2.0.0", "micromark-util-symbol": "^2.0.0" } }, "sha512-nDV/77Fj6eH1ynwscYTOsbK7rR//Uj0bZXBwJZRfaLEJ1iGBR6kIfNmlNqaqJf649EP0F3NWNdeJi03elllNUQ=="],
"micromark-util-encode": ["micromark-util-encode@2.0.1", "", {}, "sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw=="],
"micromark-util-html-tag-name": ["micromark-util-html-tag-name@2.0.1", "", {}, "sha512-2cNEiYDhCWKI+Gs9T0Tiysk136SnR13hhO8yW6BGNyhOC4qYFnwF1nKfD3HFAIXA5c45RrIG1ub11GiXeYd1xA=="],
"micromark-util-normalize-identifier": ["micromark-util-normalize-identifier@2.0.1", "", { "dependencies": { "micromark-util-symbol": "^2.0.0" } }, "sha512-sxPqmo70LyARJs0w2UclACPUUEqltCkJ6PhKdMIDuJ3gSf/Q+/GIe3WKl0Ijb/GyH9lOpUkRAO2wp0GVkLvS9Q=="],
"micromark-util-resolve-all": ["micromark-util-resolve-all@2.0.1", "", { "dependencies": { "micromark-util-types": "^2.0.0" } }, "sha512-VdQyxFWFT2/FGJgwQnJYbe1jjQoNTS4RjglmSjTUlpUMa95Htx9NHeYW4rGDJzbjvCsl9eLjMQwGeElsqmzcHg=="],
"micromark-util-sanitize-uri": ["micromark-util-sanitize-uri@2.0.1", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-encode": "^2.0.0", "micromark-util-symbol": "^2.0.0" } }, "sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ=="],
"micromark-util-subtokenize": ["micromark-util-subtokenize@2.1.0", "", { "dependencies": { "devlop": "^1.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-XQLu552iSctvnEcgXw6+Sx75GflAPNED1qx7eBJ+wydBb2KCbRZe+NwvIEEMM83uml1+2WSXpBAcp9IUCgCYWA=="],
"micromark-util-symbol": ["micromark-util-symbol@2.0.1", "", {}, "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q=="],
"micromark-util-types": ["micromark-util-types@2.0.2", "", {}, "sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA=="],
"mime": ["mime@1.6.0", "", { "bin": { "mime": "cli.js" } }, "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg=="],
"mime-db": ["mime-db@1.54.0", "", {}, "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ=="],
@ -926,6 +1111,8 @@
"p-map": ["p-map@7.0.4", "", {}, "sha512-tkAQEw8ysMzmkhgw8k+1U/iPhWNhykKnSk4Rd5zLoPJCuJaGRPo6YposrZgaxHKzDHdDWWZvE/Sk7hsL2X/CpQ=="],
"parse-entities": ["parse-entities@4.0.2", "", { "dependencies": { "@types/unist": "^2.0.0", "character-entities-legacy": "^3.0.0", "character-reference-invalid": "^2.0.0", "decode-named-character-reference": "^1.0.0", "is-alphanumerical": "^2.0.0", "is-decimal": "^2.0.0", "is-hexadecimal": "^2.0.0" } }, "sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw=="],
"parseurl": ["parseurl@1.3.3", "", {}, "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ=="],
"path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="],
@ -944,10 +1131,16 @@
"postcss": ["postcss@8.5.6", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg=="],
"postcss-selector-parser": ["postcss-selector-parser@6.0.10", "", { "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" } }, "sha512-IQ7TZdoaqbT+LCpShg46jnZVlhWD2w6iQYAcYXfHARZ7X1t/UGhhceQDs5X0cGqKvYlHNOuv7Oa1xmb0oQuA3w=="],
"powershell-utils": ["powershell-utils@0.1.0", "", {}, "sha512-dM0jVuXJPsDN6DvRpea484tCUaMiXWjuCn++HGTqUWzGDjv5tZkEZldAJ/UMlqRYGFrD/etByo4/xOuC/snX2A=="],
"prettier": ["prettier@3.7.4", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-v6UNi1+3hSlVvv8fSaoUbggEM5VErKmmpGA7Pl3HF8V6uKY7rvClBOJlH6yNwQtfTueNkGVpOv/mtWL9L4bgRA=="],
"pretty-format": ["pretty-format@27.5.1", "", { "dependencies": { "ansi-regex": "^5.0.1", "ansi-styles": "^5.0.0", "react-is": "^17.0.1" } }, "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ=="],
"property-information": ["property-information@7.1.0", "", {}, "sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ=="],
"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=="],
"qs": ["qs@6.14.0", "", { "dependencies": { "side-channel": "^1.1.0" } }, "sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w=="],
@ -970,6 +1163,8 @@
"react-is": ["react-is@19.2.3", "", {}, "sha512-qJNJfu81ByyabuG7hPFEbXqNcWSU3+eVus+KJs+0ncpGfMyYdvSmxiJxbWR65lYi1I+/0HBcliO029gc4F+PnA=="],
"react-markdown": ["react-markdown@10.1.0", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "hast-util-to-jsx-runtime": "^2.0.0", "html-url-attributes": "^3.0.0", "mdast-util-to-hast": "^13.0.0", "remark-parse": "^11.0.0", "remark-rehype": "^11.0.0", "unified": "^11.0.0", "unist-util-visit": "^5.0.0", "vfile": "^6.0.0" }, "peerDependencies": { "@types/react": ">=18", "react": ">=18" } }, "sha512-qKxVopLT/TyA6BX3Ue5NwabOsAzm0Q7kAPwq6L+wWDwisYs7R8vZ0nRXqq6rkueboxpkjvLGU9fWifiX/ZZFxQ=="],
"react-redux": ["react-redux@9.2.0", "", { "dependencies": { "@types/use-sync-external-store": "^0.0.6", "use-sync-external-store": "^1.4.0" }, "peerDependencies": { "@types/react": "^18.2.25 || ^19", "react": "^18.0 || ^19", "redux": "^5.0.0" }, "optionalPeers": ["@types/react", "redux"] }, "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g=="],
"react-refresh": ["react-refresh@0.14.2", "", {}, "sha512-jCvmsr+1IUSMUyzOkRcvnVbX3ZYC6g9TDrDbFuFmRDq7PD4yaGbLKNQL6k2jnArV8hjYxh7hVhAZB6s9HDGpZA=="],
@ -994,6 +1189,14 @@
"redux-thunk": ["redux-thunk@3.1.0", "", { "peerDependencies": { "redux": "^5.0.0" } }, "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw=="],
"remark-gfm": ["remark-gfm@4.0.1", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-gfm": "^3.0.0", "micromark-extension-gfm": "^3.0.0", "remark-parse": "^11.0.0", "remark-stringify": "^11.0.0", "unified": "^11.0.0" } }, "sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg=="],
"remark-parse": ["remark-parse@11.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-from-markdown": "^2.0.0", "micromark-util-types": "^2.0.0", "unified": "^11.0.0" } }, "sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA=="],
"remark-rehype": ["remark-rehype@11.1.2", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "mdast-util-to-hast": "^13.0.0", "unified": "^11.0.0", "vfile": "^6.0.0" } }, "sha512-Dh7l57ianaEoIpzbp0PC9UKAdCSVklD8E5Rpw7ETfbTl3FqcOOgq5q2LVDhgGCkaBv7p24JXikPdvhhmHvKMsw=="],
"remark-stringify": ["remark-stringify@11.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-to-markdown": "^2.0.0", "unified": "^11.0.0" } }, "sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw=="],
"reselect": ["reselect@5.1.1", "", {}, "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w=="],
"resolve-pkg-maps": ["resolve-pkg-maps@1.0.0", "", {}, "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw=="],
@ -1010,7 +1213,7 @@
"scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="],
"semver": ["semver@7.7.2", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA=="],
"semver": ["semver@7.7.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q=="],
"send": ["send@0.19.2", "", { "dependencies": { "debug": "2.6.9", "depd": "2.0.0", "destroy": "1.2.0", "encodeurl": "~2.0.0", "escape-html": "~1.0.3", "etag": "~1.8.1", "fresh": "~0.5.2", "http-errors": "~2.0.1", "mime": "1.6.0", "ms": "2.1.3", "on-finished": "~2.4.1", "range-parser": "~1.2.1", "statuses": "~2.0.2" } }, "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg=="],
@ -1044,6 +1247,8 @@
"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=="],
"space-separated-tokens": ["space-separated-tokens@2.0.2", "", {}, "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q=="],
"stack-trace": ["stack-trace@0.0.10", "", {}, "sha512-KGzahc7puUKkzyMt+IqAep+TVNbKP+k2Lmwhub39m1AsTSkaDutx56aDCo+HLDzf/D26BIHTJWNiTG1KAJiQCg=="],
"statuses": ["statuses@2.0.2", "", {}, "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw=="],
@ -1052,8 +1257,14 @@
"string_decoder": ["string_decoder@1.3.0", "", { "dependencies": { "safe-buffer": "~5.2.0" } }, "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA=="],
"stringify-entities": ["stringify-entities@4.0.4", "", { "dependencies": { "character-entities-html4": "^2.0.0", "character-entities-legacy": "^3.0.0" } }, "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg=="],
"strip-ansi": ["strip-ansi@7.1.2", "", { "dependencies": { "ansi-regex": "^6.0.1" } }, "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA=="],
"style-to-js": ["style-to-js@1.1.21", "", { "dependencies": { "style-to-object": "1.0.14" } }, "sha512-RjQetxJrrUJLQPHbLku6U/ocGtzyjbJMP9lCNK7Ag0CNh690nSH8woqWH9u16nMjYBAok+i7JO1NP2pOy8IsPQ=="],
"style-to-object": ["style-to-object@1.0.14", "", { "dependencies": { "inline-style-parser": "0.2.7" } }, "sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw=="],
"tagged-tag": ["tagged-tag@1.0.0", "", {}, "sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng=="],
"tailwind-merge": ["tailwind-merge@3.4.0", "", {}, "sha512-uSaO4gnW+b3Y2aWoWfFpX62vn2sR3skfhbjsEnaBI81WD1wBLlHZe5sWf0AqjksNdYTbGBEd0UasQMT3SNV15g=="],
@ -1074,8 +1285,12 @@
"toidentifier": ["toidentifier@1.0.1", "", {}, "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA=="],
"trim-lines": ["trim-lines@3.0.1", "", {}, "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg=="],
"triple-beam": ["triple-beam@1.4.1", "", {}, "sha512-aZbgViZrg1QNcG+LULa7nhZpJTZSLm/mXnHXnbAbjmN5aSa0y7V+wvv6+4WaBtpISJzThKy+PIPxc1Nq1EJ9mg=="],
"trough": ["trough@2.2.0", "", {}, "sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw=="],
"tsconfck": ["tsconfck@3.1.6", "", { "peerDependencies": { "typescript": "^5.0.0" }, "optionalPeers": ["typescript"], "bin": { "tsconfck": "bin/tsconfck.js" } }, "sha512-ks6Vjr/jEw0P1gmOVwutM3B7fWxoWBL2KRDb1JfqGVawBmO5UsvmWOQFGHBPl5yxYz4eERr19E6L7NMv+Fej4w=="],
"tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
@ -1094,6 +1309,18 @@
"undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="],
"unified": ["unified@11.0.5", "", { "dependencies": { "@types/unist": "^3.0.0", "bail": "^2.0.0", "devlop": "^1.0.0", "extend": "^3.0.0", "is-plain-obj": "^4.0.0", "trough": "^2.0.0", "vfile": "^6.0.0" } }, "sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA=="],
"unist-util-is": ["unist-util-is@6.0.1", "", { "dependencies": { "@types/unist": "^3.0.0" } }, "sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g=="],
"unist-util-position": ["unist-util-position@5.0.0", "", { "dependencies": { "@types/unist": "^3.0.0" } }, "sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA=="],
"unist-util-stringify-position": ["unist-util-stringify-position@4.0.0", "", { "dependencies": { "@types/unist": "^3.0.0" } }, "sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ=="],
"unist-util-visit": ["unist-util-visit@5.0.0", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-is": "^6.0.0", "unist-util-visit-parents": "^6.0.0" } }, "sha512-MR04uvD+07cwl/yhVuVWAtw+3GOR/knlL55Nd/wAdblk27GCVt3lqpTivy/tkJcZoNPzTwS1Y+KMojlLDhoTzg=="],
"unist-util-visit-parents": ["unist-util-visit-parents@6.0.2", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-is": "^6.0.0" } }, "sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ=="],
"unpipe": ["unpipe@1.0.0", "", {}, "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ=="],
"unstorage": ["unstorage@1.17.3", "", { "dependencies": { "anymatch": "^3.1.3", "chokidar": "^4.0.3", "destr": "^2.0.5", "h3": "^1.15.4", "lru-cache": "^10.4.3", "node-fetch-native": "^1.6.7", "ofetch": "^1.5.1", "ufo": "^1.6.1" }, "peerDependencies": { "@azure/app-configuration": "^1.8.0", "@azure/cosmos": "^4.2.0", "@azure/data-tables": "^13.3.0", "@azure/identity": "^4.6.0", "@azure/keyvault-secrets": "^4.9.0", "@azure/storage-blob": "^12.26.0", "@capacitor/preferences": "^6.0.3 || ^7.0.0", "@deno/kv": ">=0.9.0", "@netlify/blobs": "^6.5.0 || ^7.0.0 || ^8.1.0 || ^9.0.0 || ^10.0.0", "@planetscale/database": "^1.19.0", "@upstash/redis": "^1.34.3", "@vercel/blob": ">=0.27.1", "@vercel/functions": "^2.2.12 || ^3.0.0", "@vercel/kv": "^1.0.1", "aws4fetch": "^1.0.20", "db0": ">=0.2.1", "idb-keyval": "^6.2.1", "ioredis": "^5.4.2", "uploadthing": "^7.4.4" }, "optionalPeers": ["@azure/app-configuration", "@azure/cosmos", "@azure/data-tables", "@azure/identity", "@azure/keyvault-secrets", "@azure/storage-blob", "@capacitor/preferences", "@deno/kv", "@netlify/blobs", "@planetscale/database", "@upstash/redis", "@vercel/blob", "@vercel/functions", "@vercel/kv", "aws4fetch", "db0", "idb-keyval", "ioredis", "uploadthing"] }, "sha512-i+JYyy0DoKmQ3FximTHbGadmIYb8JEpq7lxUjnjeB702bCPum0vzo6oy5Mfu0lpqISw7hCyMW2yj4nWC8bqJ3Q=="],
@ -1114,6 +1341,10 @@
"vary": ["vary@1.1.2", "", {}, "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg=="],
"vfile": ["vfile@6.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "vfile-message": "^4.0.0" } }, "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q=="],
"vfile-message": ["vfile-message@4.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-stringify-position": "^4.0.0" } }, "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw=="],
"victory-vendor": ["victory-vendor@37.3.6", "", { "dependencies": { "@types/d3-array": "^3.0.3", "@types/d3-ease": "^3.0.0", "@types/d3-interpolate": "^3.0.1", "@types/d3-scale": "^4.0.2", "@types/d3-shape": "^3.1.0", "@types/d3-time": "^3.0.0", "@types/d3-timer": "^3.0.0", "d3-array": "^3.1.6", "d3-ease": "^3.0.1", "d3-interpolate": "^3.0.1", "d3-scale": "^4.0.2", "d3-shape": "^3.1.0", "d3-time": "^3.0.0", "d3-timer": "^3.0.1" } }, "sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ=="],
"vite": ["vite@7.3.0", "", { "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-dZwN5L1VlUBewiP6H9s2+B3e3Jg96D0vzN+Ry73sOefebhYr9f94wwkMNN/9ouoU8pV1BqA1d1zGk8928cx0rg=="],
@ -1124,6 +1355,8 @@
"vite-tsconfig-paths": ["vite-tsconfig-paths@6.0.3", "", { "dependencies": { "debug": "^4.1.1", "globrex": "^0.1.2", "tsconfck": "^3.0.3" }, "peerDependencies": { "vite": "*" }, "optionalPeers": ["vite"] }, "sha512-7bL7FPX/DSviaZGYUKowWF1AiDVWjMjxNbE8lyaVGDezkedWqfGhlnQ4BZXre0ZN5P4kAgIJfAlgFDVyjrCIyg=="],
"whatwg-mimetype": ["whatwg-mimetype@3.0.0", "", {}, "sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q=="],
"which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="],
"winston": ["winston@3.19.0", "", { "dependencies": { "@colors/colors": "^1.6.0", "@dabh/diagnostics": "^2.0.8", "async": "^3.2.3", "is-stream": "^2.0.0", "logform": "^2.7.0", "one-time": "^1.0.0", "readable-stream": "^3.4.0", "safe-stable-stringify": "^2.3.1", "stack-trace": "0.0.x", "triple-beam": "^1.3.0", "winston-transport": "^4.9.0" } }, "sha512-LZNJgPzfKR+/J3cHkxcpHKpKKvGfDZVPS4hfJCc4cCG0CgYzvlD6yE/S3CIL/Yt91ak327YCpiF/0MyeZHEHKA=="],
@ -1142,6 +1375,8 @@
"zod": ["zod@4.2.1", "", {}, "sha512-0wZ1IRqGGhMP76gLqz8EyfBXKk0J2qo2+H3fi4mcUP/KtTocoX08nmIAHl1Z2kJIZbZee8KOpBCSNPRgauucjw=="],
"zwitch": ["zwitch@2.0.4", "", {}, "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A=="],
"@babel/core/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
"@babel/helper-compilation-targets/lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="],
@ -1150,6 +1385,10 @@
"@babel/helper-create-class-features-plugin/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
"@happy-dom/global-registrator/@types/node": ["@types/node@20.19.27", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-N2clP5pJhB2YnZJ3PIHFk5RkygRX5WO/5f0WC08tp0wd+sv0rsJk3MqWn3CbNmT2J505a5336jaQj4ph1AdMug=="],
"@hey-api/openapi-ts/semver": ["semver@7.7.2", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA=="],
"@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=="],
@ -1170,6 +1409,8 @@
"@radix-ui/react-tooltip/@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=="],
"@react-router/dev/semver": ["semver@7.7.2", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA=="],
"@reduxjs/toolkit/immer": ["immer@11.1.0", "", {}, "sha512-dlzb07f5LDY+tzs+iLCSXV2yuhaYfezqyZQc+n6baLECWkOMEWxkECAOnXL0ba7lsA25fM9b2jtzpu/uxo1a7g=="],
"@scalar/types/nanoid": ["nanoid@5.1.5", "", { "bin": { "nanoid": "bin/nanoid.js" } }, "sha512-Ir/+ZpE9fDsNH0hQ3C68uyThDXzYcim2EqcZ8zn8Chtt1iylPT9xXJB0kPCnqzgcEGikO9RxSrh63MsmVCU7Fw=="],
@ -1210,6 +1451,8 @@
"giget/pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="],
"happy-dom/@types/node": ["@types/node@20.19.27", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-N2clP5pJhB2YnZJ3PIHFk5RkygRX5WO/5f0WC08tp0wd+sv0rsJk3MqWn3CbNmT2J505a5336jaQj4ph1AdMug=="],
"mime-types/mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="],
"morgan/debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="],
@ -1218,16 +1461,26 @@
"nypm/pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="],
"parse-entities/@types/unist": ["@types/unist@2.0.11", "", {}, "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA=="],
"pkg-types/pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="],
"pretty-format/react-is": ["react-is@17.0.2", "", {}, "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w=="],
"raw-body/iconv-lite": ["iconv-lite@0.4.24", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3" } }, "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA=="],
"react-router-hono-server/@drizzle-team/brocli": ["@drizzle-team/brocli@0.11.0", "", {}, "sha512-hD3pekGiPg0WPCCGAZmusBBJsDqGUR66Y452YgQsZOnkdQ7ViEPKuyP4huUGEZQefp8g34RRodXYmJ2TbCH+tg=="],
"send/debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="],
"strip-ansi/ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="],
"vite-node/pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="],
"wrap-ansi/ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="],
"@happy-dom/global-registrator/@types/node/undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="],
"body-parser/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="],
"compression/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="],
@ -1236,6 +1489,8 @@
"finalhandler/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="],
"happy-dom/@types/node/undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="],
"morgan/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="],
"send/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="],

View file

@ -7,7 +7,8 @@
"build": "react-router build",
"dev": "bunx --bun vite",
"start": "bun ./dist/server/index.js",
"cli": "bun run app/server/cli/main.ts",
"cli:dev": "bun run app/server/cli/main.ts",
"cli": "ZEROBYTE_CLI=1 bun ./dist/server/index.js",
"tsc": "react-router typegen && tsc",
"lint": "biome check .",
"lint:ci": "biome ci . --changed --error-on-warnings --no-errors-on-unmatched",
@ -16,7 +17,9 @@
"gen:api-client": "openapi-ts",
"gen:migrations": "drizzle-kit generate",
"studio": "drizzle-kit studio",
"test": "dotenv -e .env.test -- bun test --preload ./app/test/setup.ts"
"test:server": "dotenv -e .env.test -- bun test app/server --preload ./app/test/setup.ts",
"test:client": "dotenv -e .env.test -- bun test app/client --preload ./app/test/setup-client.ts",
"test": "bun run test:server && bun run test:client"
},
"overrides": {
"esbuild": "^0.27.2"
@ -30,6 +33,7 @@
"@inquirer/prompts": "^8.0.2",
"@radix-ui/react-alert-dialog": "^1.1.15",
"@radix-ui/react-checkbox": "^1.3.3",
"@radix-ui/react-collapsible": "^1.1.12",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-label": "^2.1.8",
"@radix-ui/react-progress": "^1.1.8",
@ -65,9 +69,12 @@
"react": "^19.2.1",
"react-dom": "^19.2.1",
"react-hook-form": "^7.68.0",
"react-markdown": "^10.1.0",
"react-router": "^7.10.0",
"react-router-hono-server": "^2.22.0",
"recharts": "3.5.1",
"remark-gfm": "^4.0.1",
"semver": "^7.7.3",
"slugify": "^1.6.6",
"sonner": "^2.0.7",
"tailwind-merge": "^3.4.0",
@ -78,14 +85,19 @@
"devDependencies": {
"@biomejs/biome": "^2.3.8",
"@faker-js/faker": "^10.1.0",
"@happy-dom/global-registrator": "^20.0.11",
"@hey-api/openapi-ts": "^0.88.0",
"@react-router/dev": "^7.10.0",
"@tailwindcss/typography": "^0.5.19",
"@tailwindcss/vite": "^4.1.17",
"@tanstack/react-query-devtools": "^5.91.1",
"@testing-library/dom": "^10.4.1",
"@testing-library/react": "^16.3.1",
"@types/bun": "^1.3.4",
"@types/node": "^25.0.3",
"@types/react": "^19.2.7",
"@types/react-dom": "^19.2.3",
"@types/semver": "^7.7.1",
"dotenv-cli": "^11.0.0",
"drizzle-kit": "^0.31.7",
"lightningcss": "^1.30.2",