Compare commits

..

No commits in common. "bb6bbcfd744b06085009e7bc5f4040a389a81a17" and "b84ae02ec6b9210bdd56f10d370574fbe01c3d7a" have entirely different histories.

80 changed files with 1401 additions and 3293 deletions

View file

@ -45,8 +45,6 @@ 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
@ -86,8 +84,6 @@ 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.21
image: ghcr.io/nicotsx/zerobyte:v0.19
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.21
image: ghcr.io/nicotsx/zerobyte:v0.19
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,7 +91,6 @@ services:
```
**Trade-offs:**
- ✅ Improved security by reducing container capabilities
- ✅ Support for local directories
- ✅ Keep support all repository types (local, S3, GCS, Azure, rclone)
@ -114,7 +113,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.21
image: ghcr.io/nicotsx/zerobyte:v0.19
container_name: zerobyte
restart: unless-stopped
cap_add:
@ -187,7 +186,7 @@ Zerobyte can use [rclone](https://rclone.org/) to support 40+ cloud storage prov
```diff
services:
zerobyte:
image: ghcr.io/nicotsx/zerobyte:v0.21
image: ghcr.io/nicotsx/zerobyte:v0.19
container_name: zerobyte
restart: unless-stopped
cap_add:
@ -239,6 +238,26 @@ 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,5 +1,4 @@
@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, 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';
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';
/**
* Register a new user
@ -439,23 +439,6 @@ 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);
/**
@ -561,23 +544,6 @@ 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);
/**
@ -961,24 +927,6 @@ 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.
*/
@ -999,8 +947,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, DefaultError, Options<ExportFullConfigData>> => {
const mutationOptions: UseMutationOptions<ExportFullConfigResponse, DefaultError, Options<ExportFullConfigData>> = {
export const exportFullConfigMutation = (options?: Partial<Options<ExportFullConfigData>>): UseMutationOptions<ExportFullConfigResponse, ExportFullConfigError, Options<ExportFullConfigData>> => {
const mutationOptions: UseMutationOptions<ExportFullConfigResponse, ExportFullConfigError, 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, 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';
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';
export type Options<TData extends TDataShape = TDataShape, ThrowOnError extends boolean = boolean> = Options2<TData, ThrowOnError> & {
/**
@ -189,18 +189,6 @@ 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
*/
@ -238,18 +226,6 @@ 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
*/
@ -409,11 +385,6 @@ 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.
*/
@ -429,8 +400,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, unknown, ThrowOnError>({
url: '/api/v1/system/export',
export const exportFullConfig = <ThrowOnError extends boolean = false>(options?: Options<ExportFullConfigData, ThrowOnError>) => (options?.client ?? client).post<ExportFullConfigResponses, ExportFullConfigErrors, ThrowOnError>({
url: '/api/v1/config/export',
...options,
headers: {
'Content-Type': 'application/json',

View file

@ -798,9 +798,7 @@ export type ListRepositoriesResponses = {
bucket: string;
endpoint: string;
secretAccessKey: string;
cacert?: string;
customPassword?: string;
insecureTls?: boolean;
isExistingRepository?: boolean;
} | {
accessKeyId: string;
@ -808,51 +806,39 @@ 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;
@ -865,9 +851,7 @@ export type ListRepositoriesResponses = {
user: string;
port?: number;
skipHostKeyCheck?: boolean;
cacert?: string;
customPassword?: string;
insecureTls?: boolean;
isExistingRepository?: boolean;
knownHosts?: string;
};
@ -893,9 +877,7 @@ export type CreateRepositoryData = {
bucket: string;
endpoint: string;
secretAccessKey: string;
cacert?: string;
customPassword?: string;
insecureTls?: boolean;
isExistingRepository?: boolean;
} | {
accessKeyId: string;
@ -903,51 +885,39 @@ 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;
@ -960,9 +930,7 @@ export type CreateRepositoryData = {
user: string;
port?: number;
skipHostKeyCheck?: boolean;
cacert?: string;
customPassword?: string;
insecureTls?: boolean;
isExistingRepository?: boolean;
knownHosts?: string;
};
@ -1050,9 +1018,7 @@ export type GetRepositoryResponses = {
bucket: string;
endpoint: string;
secretAccessKey: string;
cacert?: string;
customPassword?: string;
insecureTls?: boolean;
isExistingRepository?: boolean;
} | {
accessKeyId: string;
@ -1060,51 +1026,39 @@ 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;
@ -1117,9 +1071,7 @@ export type GetRepositoryResponses = {
user: string;
port?: number;
skipHostKeyCheck?: boolean;
cacert?: string;
customPassword?: string;
insecureTls?: boolean;
isExistingRepository?: boolean;
knownHosts?: string;
};
@ -1172,9 +1124,7 @@ export type UpdateRepositoryResponses = {
bucket: string;
endpoint: string;
secretAccessKey: string;
cacert?: string;
customPassword?: string;
insecureTls?: boolean;
isExistingRepository?: boolean;
} | {
accessKeyId: string;
@ -1182,51 +1132,39 @@ 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;
@ -1239,9 +1177,7 @@ export type UpdateRepositoryResponses = {
user: string;
port?: number;
skipHostKeyCheck?: boolean;
cacert?: string;
customPassword?: string;
insecureTls?: boolean;
isExistingRepository?: boolean;
knownHosts?: string;
};
@ -1259,28 +1195,6 @@ 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: {
@ -1453,31 +1367,6 @@ 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;
@ -1511,9 +1400,7 @@ export type ListBackupSchedulesResponses = {
bucket: string;
endpoint: string;
secretAccessKey: string;
cacert?: string;
customPassword?: string;
insecureTls?: boolean;
isExistingRepository?: boolean;
} | {
accessKeyId: string;
@ -1521,51 +1408,39 @@ 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;
@ -1578,9 +1453,7 @@ export type ListBackupSchedulesResponses = {
user: string;
port?: number;
skipHostKeyCheck?: boolean;
cacert?: string;
customPassword?: string;
insecureTls?: boolean;
isExistingRepository?: boolean;
knownHosts?: string;
};
@ -1789,9 +1662,7 @@ export type GetBackupScheduleResponses = {
bucket: string;
endpoint: string;
secretAccessKey: string;
cacert?: string;
customPassword?: string;
insecureTls?: boolean;
isExistingRepository?: boolean;
} | {
accessKeyId: string;
@ -1799,51 +1670,39 @@ 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;
@ -1856,9 +1715,7 @@ export type GetBackupScheduleResponses = {
user: string;
port?: number;
skipHostKeyCheck?: boolean;
cacert?: string;
customPassword?: string;
insecureTls?: boolean;
isExistingRepository?: boolean;
knownHosts?: string;
};
@ -2048,9 +1905,7 @@ export type GetBackupScheduleForVolumeResponses = {
bucket: string;
endpoint: string;
secretAccessKey: string;
cacert?: string;
customPassword?: string;
insecureTls?: boolean;
isExistingRepository?: boolean;
} | {
accessKeyId: string;
@ -2058,51 +1913,39 @@ 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;
@ -2115,9 +1958,7 @@ export type GetBackupScheduleForVolumeResponses = {
user: string;
port?: number;
skipHostKeyCheck?: boolean;
cacert?: string;
customPassword?: string;
insecureTls?: boolean;
isExistingRepository?: boolean;
knownHosts?: string;
};
@ -2310,20 +2151,10 @@ 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;
@ -2353,7 +2184,7 @@ export type GetScheduleNotificationsResponses = {
enabled: boolean;
id: number;
name: string;
type: 'custom' | 'discord' | 'email' | 'generic' | 'gotify' | 'ntfy' | 'pushover' | 'slack' | 'telegram';
type: 'custom' | 'discord' | 'email' | 'gotify' | 'ntfy' | 'pushover' | 'slack' | 'telegram';
updatedAt: number;
};
destinationId: number;
@ -2410,20 +2241,10 @@ 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;
@ -2453,7 +2274,7 @@ export type UpdateScheduleNotificationsResponses = {
enabled: boolean;
id: number;
name: string;
type: 'custom' | 'discord' | 'email' | 'generic' | 'gotify' | 'ntfy' | 'pushover' | 'slack' | 'telegram';
type: 'custom' | 'discord' | 'email' | 'gotify' | 'ntfy' | 'pushover' | 'slack' | 'telegram';
updatedAt: number;
};
destinationId: number;
@ -2494,9 +2315,7 @@ export type GetScheduleMirrorsResponses = {
bucket: string;
endpoint: string;
secretAccessKey: string;
cacert?: string;
customPassword?: string;
insecureTls?: boolean;
isExistingRepository?: boolean;
} | {
accessKeyId: string;
@ -2504,51 +2323,39 @@ 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;
@ -2561,9 +2368,7 @@ export type GetScheduleMirrorsResponses = {
user: string;
port?: number;
skipHostKeyCheck?: boolean;
cacert?: string;
customPassword?: string;
insecureTls?: boolean;
isExistingRepository?: boolean;
knownHosts?: string;
};
@ -2616,9 +2421,7 @@ export type UpdateScheduleMirrorsResponses = {
bucket: string;
endpoint: string;
secretAccessKey: string;
cacert?: string;
customPassword?: string;
insecureTls?: boolean;
isExistingRepository?: boolean;
} | {
accessKeyId: string;
@ -2626,51 +2429,39 @@ 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;
@ -2683,9 +2474,7 @@ export type UpdateScheduleMirrorsResponses = {
user: string;
port?: number;
skipHostKeyCheck?: boolean;
cacert?: string;
customPassword?: string;
insecureTls?: boolean;
isExistingRepository?: boolean;
knownHosts?: string;
};
@ -2779,20 +2568,10 @@ 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;
@ -2822,7 +2601,7 @@ export type ListNotificationDestinationsResponses = {
enabled: boolean;
id: number;
name: string;
type: 'custom' | 'discord' | 'email' | 'generic' | 'gotify' | 'ntfy' | 'pushover' | 'slack' | 'telegram';
type: 'custom' | 'discord' | 'email' | 'gotify' | 'ntfy' | 'pushover' | 'slack' | 'telegram';
updatedAt: number;
}>;
};
@ -2850,20 +2629,10 @@ 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;
@ -2920,20 +2689,10 @@ 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;
@ -2963,7 +2722,7 @@ export type CreateNotificationDestinationResponses = {
enabled: boolean;
id: number;
name: string;
type: 'custom' | 'discord' | 'email' | 'generic' | 'gotify' | 'ntfy' | 'pushover' | 'slack' | 'telegram';
type: 'custom' | 'discord' | 'email' | 'gotify' | 'ntfy' | 'pushover' | 'slack' | 'telegram';
updatedAt: number;
};
};
@ -3037,20 +2796,10 @@ 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;
@ -3080,7 +2829,7 @@ export type GetNotificationDestinationResponses = {
enabled: boolean;
id: number;
name: string;
type: 'custom' | 'discord' | 'email' | 'generic' | 'gotify' | 'ntfy' | 'pushover' | 'slack' | 'telegram';
type: 'custom' | 'discord' | 'email' | 'gotify' | 'ntfy' | 'pushover' | 'slack' | 'telegram';
updatedAt: number;
};
};
@ -3108,20 +2857,10 @@ 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;
@ -3188,20 +2927,10 @@ 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;
@ -3231,7 +2960,7 @@ export type UpdateNotificationDestinationResponses = {
enabled: boolean;
id: number;
name: string;
type: 'custom' | 'discord' | 'email' | 'generic' | 'gotify' | 'ntfy' | 'pushover' | 'slack' | 'telegram';
type: 'custom' | 'discord' | 'email' | 'gotify' | 'ntfy' | 'pushover' | 'slack' | 'telegram';
updatedAt: number;
};
};
@ -3294,32 +3023,6 @@ 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;
@ -3342,12 +3045,32 @@ export type ExportFullConfigData = {
body?: {
password: string;
includeMetadata?: boolean;
includePasswordHash?: boolean;
includeRecoveryKey?: boolean;
secretsMode?: 'cleartext' | 'encrypted' | 'exclude';
};
path?: never;
query?: never;
url: '/api/v1/system/export';
url: '/api/v1/config/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
@ -3364,6 +3087,7 @@ export type ExportFullConfigResponses = {
createdAt?: number;
hasDownloadedResticPassword?: boolean;
id?: number;
passwordHash?: string;
updatedAt?: number;
}>;
volumes?: Array<unknown>;

View file

@ -1,166 +0,0 @@
/** 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 className="min-w-0">
<Breadcrumb>
<BreadcrumbList>
{breadcrumbs.map((breadcrumb, index) => {
const isLast = index === breadcrumbs.length - 1;

View file

@ -1,6 +1,5 @@
import { Bell, CalendarClock, Database, HardDrive, Settings } from "lucide-react";
import { Link, NavLink } from "react-router";
import { useState } from "react";
import {
Sidebar,
SidebarContent,
@ -16,8 +15,6 @@ 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 = [
{
@ -49,9 +46,6 @@ 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"
@ -104,28 +98,16 @@ export function AppSidebar() {
</SidebarGroup>
</SidebarContent>
<SidebarFooter className="p-4 border-r border-t border-border/50">
<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} />
<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>
</SidebarFooter>
</Sidebar>
);

View file

@ -1,6 +1,6 @@
import { useMutation } from "@tanstack/react-query";
import { Download } from "lucide-react";
import { useState } from "react";
import { type ReactNode, 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,29 +16,82 @@ import {
} from "~/client/components/ui/dialog";
import { Input } from "~/client/components/ui/input";
import { Label } from "~/client/components/ui/label";
import { parseError } from "../lib/errors";
import { downloadFile } from "../lib/utils";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "~/client/components/ui/select";
type SecretsMode = "exclude" | "encrypted" | "cleartext";
const DEFAULT_EXPORT_FILENAME = "zerobyte-full-config";
export const ExportDialog = () => {
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) {
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: (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,
});
},
onSuccess: handleExportSuccess,
onError: handleExportError,
});
const handleExport = (e: React.FormEvent) => {
@ -52,6 +105,9 @@ export const ExportDialog = () => {
body: {
password,
includeMetadata,
secretsMode,
includeRecoveryKey,
includePasswordHash,
},
});
};
@ -63,14 +119,25 @@ export const 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>
<Button>
<Download className="h-4 w-4 mr-2" />
Export configuration
</Button>
</DialogTrigger>
<DialogTrigger asChild>{trigger ?? defaultTrigger}</DialogTrigger>
<DialogContent>
<form onSubmit={handleExport}>
<DialogHeader>
@ -90,7 +157,62 @@ export const ExportDialog = () => {
</Label>
</div>
<p className="text-xs text-muted-foreground ml-7">
Include timestamps and runtime state (status, health checks, last backup info).
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).
</p>
<div className="space-y-2 pt-2 border-t">
@ -122,4 +244,4 @@ export const ExportDialog = () => {
</DialogContent>
</Dialog>
);
};
}

View file

@ -116,15 +116,13 @@ 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 hasChanges ? newSet : prevSet;
return newSet;
});
}, [fileList, expandedFolders]);
@ -151,9 +149,9 @@ export const FileTree = memo((props: Props) => {
newSelection.add(path);
// Remove any descendants from selection since parent now covers them
for (const selectedPath of newSelection) {
if (selectedPath.startsWith(`${path}/`)) {
newSelection.delete(selectedPath);
for (const item of fileList) {
if (item.fullPath.startsWith(`${path}/`)) {
newSelection.delete(item.fullPath);
}
}
} else {
@ -184,8 +182,7 @@ export const FileTree = memo((props: Props) => {
if (
item.fullPath.startsWith(`${selectedParentPath}/`) &&
!item.fullPath.startsWith(`${path}/`) &&
item.fullPath !== path &&
!path.startsWith(`${item.fullPath}/`)
item.fullPath !== path
) {
newSelection.add(item.fullPath);
}
@ -193,45 +190,39 @@ export const FileTree = memo((props: Props) => {
}
}
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);
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;
});
// 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 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;
if (allChildrenSelected) {
// Remove all children
for (const childPath of selectedChildren) {
newSelection.delete(childPath);
}
// Add the parent
newSelection.add(parentPath);
}
}
}
@ -490,9 +481,8 @@ const NodeButton = memo(({ depth, icon, onClick, onMouseEnter, className, childr
const paddingLeft = useMemo(() => `${8 + depth * NODE_PADDING_LEFT}px`, [depth]);
return (
// biome-ignore lint/a11y/noStaticElementInteractions: we handle click and hover manually
// biome-ignore lint/a11y/useKeyWithClickEvents: we handle click and hover manually
<div
<button
type="button"
className={cn("flex items-center gap-2 w-full pr-2 text-sm py-1.5 text-left", className)}
style={{ paddingLeft }}
onClick={onClick}
@ -500,7 +490,7 @@ const NodeButton = memo(({ depth, icon, onClick, onMouseEnter, className, childr
>
{icon}
<div className="truncate w-full flex items-center gap-2">{children}</div>
</div>
</button>
);
});

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 gap-4">
<div className="flex items-center gap-4 min-w-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">
<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 @container">
<main className="flex flex-col p-2 pb-6 pt-2 sm:p-8 sm:pt-6 mx-auto">
<Outlet />
</main>
</GridBackground>

View file

@ -1,44 +0,0 @@
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,12 +1,11 @@
import { useState } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { Calendar, Clock, Database, HardDrive, Tag, Trash2, X } from "lucide-react";
import { Calendar, Clock, Database, HardDrive, Server, Trash2 } 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,
@ -17,17 +16,8 @@ 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 { deleteSnapshotsMutation, tagSnapshotsMutation } from "~/client/api-client/@tanstack/react-query.gen";
import { deleteSnapshotMutation } 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";
@ -41,126 +31,68 @@ 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 [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(),
const deleteSnapshot = useMutation({
...deleteSnapshotMutation(),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["listSnapshots"] });
setShowBulkDeleteConfirm(false);
setSelectedIds(new Set());
setShowDeleteConfirm(false);
setSnapshotToDelete(null);
},
});
const tagSnapshots = useMutation({
...tagSnapshotsMutation(),
onMutate: () => {
setShowReTagDialog(false);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["listSnapshots"] });
setShowReTagDialog(false);
setSelectedIds(new Set());
setTargetScheduleId("");
},
});
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 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 relative">
<div className="overflow-x-auto">
<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={cn("hover:bg-accent/50 cursor-pointer", isSelected && "bg-accent/30")}
className="hover:bg-accent/50 cursor-pointer"
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" />
@ -202,6 +134,32 @@ 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>
);
})}
@ -209,99 +167,27 @@ export const SnapshotsTable = ({ snapshots, repositoryId, backups }: Props) => {
</Table>
</div>
{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}>
<AlertDialog open={showDeleteConfirm} onOpenChange={setShowDeleteConfirm}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete {selectedIds.size} snapshots?</AlertDialogTitle>
<AlertDialogTitle>Delete snapshot?</AlertDialogTitle>
<AlertDialogDescription>
This action cannot be undone. This will permanently delete the selected snapshots and all their data from
the repository.
This action cannot be undone. This will permanently delete the snapshot and all its data from the
repository.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={handleBulkDelete}
disabled={deleteSnapshots.isPending}
onClick={handleConfirmDelete}
disabled={deleteSnapshot.isPending}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
Delete {selectedIds.size} snapshots
Delete snapshot
</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({ className, ...props }: React.ComponentProps<"nav">) {
return <nav aria-label="breadcrumb" data-slot="breadcrumb" className={cn("min-w-0", className)} {...props} />;
function Breadcrumb({ ...props }: React.ComponentProps<"nav">) {
return <nav aria-label="breadcrumb" data-slot="breadcrumb" {...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 items-center gap-1.5 text-sm sm:gap-2.5 min-w-0",
"text-muted-foreground flex flex-wrap items-center gap-1.5 text-sm wrap-break-words sm:gap-2.5",
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 min-w-0", className)} {...props} />;
return <li data-slot="breadcrumb-item" className={cn("inline-flex items-center gap-1.5", className)} {...props} />;
}
function BreadcrumbLink({
@ -35,11 +35,7 @@ function BreadcrumbLink({
const Comp = asChild ? Slot : "a";
return (
<Comp
data-slot="breadcrumb-link"
className={cn("hover:text-foreground transition-colors truncate", className)}
{...props}
/>
<Comp data-slot="breadcrumb-link" className={cn("hover:text-foreground transition-colors", className)} {...props} />
);
}
@ -50,7 +46,7 @@ function BreadcrumbPage({ className, ...props }: React.ComponentProps<"span">) {
role="link"
aria-disabled="true"
aria-current="page"
className={cn("text-foreground font-normal truncate", className)}
className={cn("text-foreground font-normal", 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-0.5 -top-0.5 h-0.5 w-4 bg-white/80" />
<span className="absolute -left-0.5 -top-0.5 h-4 w-0.5 bg-white/80" />
<span className="absolute -right-0.5 -top-0.5 h-0.5 w-4 bg-white/80" />
<span className="absolute -right-0.5 -top-0.5 h-4 w-0.5 bg-white/80" />
<span className="absolute -left-0.5 -bottom-0.5 h-0.5 w-4 bg-white/80" />
<span className="absolute -left-0.5 -bottom-0.5 h-4 w-0.5 bg-white/80" />
<span className="absolute -right-0.5 -bottom-0.5 h-0.5 w-4 bg-white/80" />
<span className="absolute -right-0.5 -bottom-0.5 h-4 w-0.5 bg-white/80" />
<span className="absolute left-[-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>
{children}
</div>

View file

@ -1,98 +0,0 @@
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-50">
<div className="flex items-center justify-center h-full min-h-[200px]">
<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-50">
<div className="flex items-center justify-center h-full min-h-[200px]">
<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-50">
<div className="flex flex-col items-center justify-center h-full text-center p-8 min-h-[200px]">
<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

@ -1,18 +0,0 @@
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,20 +28,3 @@ 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,7 +11,6 @@ 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];
@ -32,7 +31,16 @@ export default function DownloadRecoveryKeyPage() {
const downloadResticPassword = useMutation({
...downloadResticPasswordMutation(),
onSuccess: (data) => {
downloadFile(data, "restic.pass");
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);
toast.success("Recovery key downloaded successfully!");
navigate("/volumes", { replace: true });
},

View file

@ -2,7 +2,6 @@ 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";
@ -170,16 +169,6 @@ 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
@ -195,12 +184,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} />
@ -215,7 +204,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}>
@ -300,7 +289,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}>
@ -327,7 +316,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">
@ -384,19 +373,8 @@ 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 inline-flex items-center gap-1"
>
<span key={path} className="text-xs bg-accent px-2 py-1 rounded-md font-mono">
{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>
@ -512,7 +490,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 className="hidden @md:block mt-1">
<CardDescription>
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-25">Enabled</TableHead>
<TableHead className="w-45">Last Copy</TableHead>
<TableHead className="w-12.5"></TableHead>
<TableHead className="text-center w-[100px]">Enabled</TableHead>
<TableHead className="w-[180px]">Last Copy</TableHead>
<TableHead className="w-[50px]"></TableHead>
</TableRow>
</TableHeader>
<TableBody>

View file

@ -152,9 +152,7 @@ export const ScheduleNotificationsConfig = ({ scheduleId, destinations }: Props)
<Bell className="h-5 w-5" />
Notifications
</CardTitle>
<CardDescription className="hidden @md:block mt-1">
Configure which notifications to send for this backup schedule
</CardDescription>
<CardDescription>Configure which notifications to send for this backup schedule</CardDescription>
</div>
{!isAddingNew && availableDestinations.length > 0 && (
<Button variant="outline" size="sm" onClick={() => setIsAddingNew(true)}>
@ -200,11 +198,11 @@ export const ScheduleNotificationsConfig = ({ scheduleId, destinations }: Props)
<TableHeader>
<TableRow>
<TableHead>Destination</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>
<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>
</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 @lg:flex-row gap-2">
<div className="flex flex-col sm:flex-row gap-2">
{schedule.lastBackupStatus === "in_progress" ? (
<Button variant="destructive" size="sm" onClick={handleStopBackup} className="w-full @md:w-auto">
<Button variant="destructive" size="sm" onClick={handleStopBackup} className="w-full sm:w-auto">
<Square className="h-4 w-4 mr-2" />
<span>Stop backup</span>
<span className="sm:inline">Stop backup</span>
</Button>
) : (
<Button variant="default" size="sm" onClick={handleRunBackupNow} className="w-full @md:w-auto">
<Button variant="default" size="sm" onClick={handleRunBackupNow} className="w-full sm:w-auto">
<Play className="h-4 w-4 mr-2" />
<span>Backup now</span>
<span className="sm:inline">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 @md:w-auto"
className="w-full sm:w-auto"
>
<Eraser className="h-4 w-4 mr-2" />
<span>Run cleanup</span>
<span className="sm:inline">Run cleanup</span>
</Button>
)}
<Button variant="outline" size="sm" onClick={() => setIsEditMode(true)} className="w-full @md:w-auto">
<Button variant="outline" size="sm" onClick={() => setIsEditMode(true)} className="w-full sm:w-auto">
<Pencil className="h-4 w-4 mr-2" />
<span>Edit schedule</span>
<span className="sm:inline">Edit schedule</span>
</Button>
<Button
variant="outline"
size="sm"
onClick={() => setShowDeleteConfirm(true)}
className="text-destructive hover:text-destructive w-full @md:w-auto"
className="text-destructive hover:text-destructive w-full sm:w-auto"
>
<Trash2 className="h-4 w-4 mr-2" />
<span>Delete</span>
<span className="sm:inline">Delete</span>
</Button>
</div>
</CardHeader>
<CardContent className="grid gap-4 grid-cols-1 @md:grid-cols-2 @lg:grid-cols-4">
<CardContent className="grid gap-4 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,21 +178,19 @@ 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 wrap-break-word">
<p className="font-mono text-sm text-yellow-600 whitespace-pre-wrap break-all">
{schedule.lastBackupError ??
"Last backup completed with warnings. Check your container logs for more details."}
</p>
</div>
)}
{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>
{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>
</div>
)}
</CardContent>

View file

@ -1,5 +1,5 @@
import { cn } from "~/client/lib/utils";
import { Card, CardContent } from "~/client/components/ui/card";
import { Card } 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>
<CardContent className="flex items-center justify-center text-center">
<p className="text-destructive text-sm">{error}</p>
</CardContent>
<div className="flex items-center justify-center h-24 p-4 text-center">
<p className="text-destructive">Error loading snapshots: {error}</p>
</div>
</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-1 @lg:grid-cols-2 @2xl:grid-cols-3 auto-rows-fr">
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3 auto-rows-fr">
{items.map((id) => {
const schedule = scheduleMap.get(id);
if (!schedule) return null;

View file

@ -14,19 +14,10 @@ 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",
@ -57,9 +48,6 @@ const defaultValuesForType = {
slack: {
type: "slack" as const,
webhookUrl: "",
channel: "",
username: "",
iconEmoji: "",
},
discord: {
type: "discord" as const,
@ -87,16 +75,6 @@ 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: "",
@ -106,9 +84,7 @@ const defaultValuesForType = {
export const CreateNotificationForm = ({ onSubmit, mode = "create", initialValues, formId, className }: Props) => {
const form = useForm<NotificationFormValues>({
resolver: arktypeResolver(cleanSchema as unknown as typeof formSchema),
defaultValues: initialValues || {
name: "",
},
defaultValues: initialValues,
resetOptions: {
keepDefaultValues: true,
keepDirtyValues: false,
@ -121,7 +97,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],
});
}
@ -176,7 +152,6 @@ 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>
@ -186,15 +161,545 @@ export const CreateNotificationForm = ({ onSubmit, mode = "create", initialValue
)}
/>
{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} />}
{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>
)}
/>
)}
</form>
</Form>
);

View file

@ -1,41 +0,0 @@
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

@ -1,71 +0,0 @@
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

@ -1,128 +0,0 @@
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

@ -1,173 +0,0 @@
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

@ -1,78 +0,0 @@
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

@ -1,9 +0,0 @@
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

@ -1,113 +0,0 @@
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

@ -1,86 +0,0 @@
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

@ -1,72 +0,0 @@
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

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

View file

@ -31,7 +31,6 @@ import {
RcloneRepositoryForm,
RestRepositoryForm,
SftpRepositoryForm,
AdvancedForm,
} from "./repository-forms";
export const formSchema = type({
@ -226,13 +225,12 @@ export const CreateRepositoryForm = ({
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="default">Use the existing recovery key</SelectItem>
<SelectItem value="default">Use Zerobyte's password</SelectItem>
<SelectItem value="custom">Enter password manually</SelectItem>
</SelectContent>
</Select>
<FormDescription>
Choose whether to use Zerobyte's recovery key (which you downloaded when creating your account) or enter
a custom password for the existing repository.
Choose whether to use Zerobyte's master password or enter a custom password for the existing repository.
</FormDescription>
</FormItem>
@ -270,8 +268,6 @@ 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

@ -1,112 +0,0 @@
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,4 +6,3 @@ 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, RepositoryConfig } from "~/schemas/restic";
import type { CompressionMode } from "~/schemas/restic";
type Props = {
repository: Repository;
@ -59,8 +59,6 @@ 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">
@ -118,26 +116,6 @@ 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>
@ -147,7 +125,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 wrap-break-word">{repository.lastError}</p>
<p className="text-sm text-red-500">{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 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 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>
)}
</CardContent>

View file

@ -24,7 +24,6 @@ import {
downloadResticPasswordMutation,
logoutMutation,
} from "~/client/api-client/@tanstack/react-query.gen";
import { downloadFile } from "~/client/lib/utils";
export const handle = {
breadcrumb: () => [{ label: "Settings" }],
@ -80,7 +79,16 @@ export default function Settings({ loaderData }: Route.ComponentProps) {
const downloadResticPassword = useMutation({
...downloadResticPasswordMutation(),
onSuccess: (data) => {
downloadFile(data, "restic.pass");
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);
toast.success("Restic password file downloaded successfully");
setDownloadDialogOpen(false);
setDownloadPassword("");
@ -199,7 +207,7 @@ export default function Settings({ loaderData }: Route.ComponentProps) {
<Download className="size-5" />
Backup Recovery Key
</CardTitle>
<CardDescription className="mt-1.5">Download your recovery key for Restic backups</CardDescription>
<CardDescription className="mt-1.5">Download your Restic password file for disaster recovery</CardDescription>
</div>
<CardContent className="p-6 space-y-4">
<p className="text-sm text-muted-foreground max-w-2xl">
@ -212,15 +220,15 @@ export default function Settings({ loaderData }: Route.ComponentProps) {
<DialogTrigger asChild>
<Button variant="outline">
<Download size={16} className="mr-2" />
Download recovery key
Download Restic Password
</Button>
</DialogTrigger>
<DialogContent>
<form onSubmit={handleDownloadResticPassword}>
<DialogHeader>
<DialogTitle>Download Recovery Key</DialogTitle>
<DialogTitle>Download Restic Password</DialogTitle>
<DialogDescription>
For security reasons, please enter your account password to download the recovery key file.
For security reasons, please enter your account password to download the Restic password file.
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
@ -271,7 +279,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 />
<ExportDialog triggerLabel="Export Configuration" />
</CardContent>
</Card>
);

View file

@ -8,7 +8,6 @@ export const NOTIFICATION_TYPES = {
ntfy: "ntfy",
pushover: "pushover",
telegram: "telegram",
generic: "generic",
custom: "custom",
} as const;
@ -73,17 +72,6 @@ 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",
@ -96,7 +84,6 @@ export const notificationConfigSchemaBase = emailNotificationConfigSchema
.or(ntfyNotificationConfigSchema)
.or(pushoverNotificationConfigSchema)
.or(telegramNotificationConfigSchema)
.or(genericNotificationConfigSchema)
.or(customNotificationConfigSchema);
export const notificationConfigSchema = notificationConfigSchemaBase.onUndeclaredKey("delete");

View file

@ -17,8 +17,6 @@ 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,6 +12,7 @@ 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";
@ -60,7 +61,8 @@ 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/events", eventsController)
.route("/api/v1/config", configExportController);
app.get("/api/v1/openapi.json", generalDescriptor(app));
app.get("/api/v1/docs", requireAuth, scalarDescriptor);

View file

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

View file

@ -8,7 +8,6 @@ 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,
@ -17,7 +16,6 @@ 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.1"] add this once re-tagging migration is removed
export const REQUIRED_MIGRATIONS = ["v0.21.0"];

View file

@ -8,12 +8,6 @@ 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

@ -0,0 +1,282 @@
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

@ -0,0 +1,74 @@
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 { backupScheduleMirrorsTable, repositoriesTable, type Repository } from "../../db/schema";
import { repositoriesTable } 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.1";
const MIGRATION_VERSION = "v0.21.0";
interface MigrationResult {
success: boolean;
@ -40,96 +40,52 @@ 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}`);
}
return;
throw new MigrationError(MIGRATION_VERSION, allErrors);
}
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) {
try {
const oldTag = schedule.id.toString();
const newTag = schedule.shortId;
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;
}
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) });
if (!repository) {
errors.push({ name: `schedule:${schedule.name}`, error: `Associated repository not found` });
continue;
}
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,7 +13,6 @@ 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({});
@ -42,8 +41,6 @@ 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 const buildCustomShoutrrrUrl = (config: Extract<NotificationConfig, { type: "custom" }>) => {
export function buildCustomShoutrrrUrl(config: Extract<NotificationConfig, { type: "custom" }>): string {
return config.shoutrrrUrl;
};
}

View file

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

View file

@ -1,6 +1,6 @@
import type { NotificationConfig } from "~/schemas/notifications";
export const buildEmailShoutrrrUrl = (config: Extract<NotificationConfig, { type: "email" }>) => {
export function buildEmailShoutrrrUrl(config: Extract<NotificationConfig, { type: "email" }>): string {
const auth =
config.username && config.password
? `${encodeURIComponent(config.username)}:${encodeURIComponent(config.password)}@`
@ -10,4 +10,4 @@ export const 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

@ -1,50 +0,0 @@
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 const buildGotifyShoutrrrUrl = (config: Extract<NotificationConfig, { type: "gotify" }>) => {
export function buildGotifyShoutrrrUrl(config: Extract<NotificationConfig, { type: "gotify" }>): string {
const url = new URL(config.serverUrl);
const hostname = url.hostname;
const port = url.port ? `:${url.port}` : "";
@ -13,4 +13,4 @@ export const buildGotifyShoutrrrUrl = (config: Extract<NotificationConfig, { typ
}
return shoutrrrUrl;
};
}

View file

@ -6,10 +6,9 @@ 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 const buildShoutrrrUrl = (config: NotificationConfig) => {
export function buildShoutrrrUrl(config: NotificationConfig): string {
switch (config.type) {
case "email":
return buildEmailShoutrrrUrl(config);
@ -25,13 +24,12 @@ export const buildShoutrrrUrl = (config: NotificationConfig) => {
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 const buildNtfyShoutrrrUrl = (config: Extract<NotificationConfig, { type: "ntfy" }>) => {
export function buildNtfyShoutrrrUrl(config: Extract<NotificationConfig, { type: "ntfy" }>): string {
let shoutrrrUrl: string;
const params = new URLSearchParams();
@ -38,4 +38,4 @@ export const buildNtfyShoutrrrUrl = (config: Extract<NotificationConfig, { type:
}
return shoutrrrUrl;
};
}

View file

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

View file

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

View file

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

View file

@ -71,8 +71,6 @@ async function encryptSensitiveFields(config: NotificationConfig): Promise<Notif
...config,
botToken: await cryptoUtils.sealSecret(config.botToken),
};
case "generic":
return config;
case "custom":
return {
...config,
@ -120,8 +118,6 @@ 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,7 +50,6 @@ 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,8 +5,6 @@ import {
createRepositoryDto,
deleteRepositoryDto,
deleteSnapshotDto,
deleteSnapshotsBody,
deleteSnapshotsDto,
doctorRepositoryDto,
getRepositoryDto,
getSnapshotDetailsDto,
@ -18,13 +16,10 @@ import {
listSnapshotsFilters,
restoreSnapshotBody,
restoreSnapshotDto,
tagSnapshotsBody,
tagSnapshotsDto,
updateRepositoryBody,
updateRepositoryDto,
type DeleteRepositoryDto,
type DeleteSnapshotDto,
type DeleteSnapshotsResponseDto,
type DoctorRepositoryDto,
type GetRepositoryDto,
type GetSnapshotDetailsDto,
@ -32,7 +27,6 @@ import {
type ListSnapshotFilesDto,
type ListSnapshotsDto,
type RestoreSnapshotDto,
type TagSnapshotsResponseDto,
type UpdateRepositoryDto,
} from "./repositories.dto";
import { repositoriesService } from "./repositories.service";
@ -166,22 +160,6 @@ 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,64 +400,3 @@ 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,10 +34,6 @@ 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":
@ -393,40 +389,6 @@ 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);
@ -477,6 +439,4 @@ export const repositoriesService = {
checkHealth,
doctorRepository,
deleteSnapshot,
deleteSnapshots,
tagSnapshots,
};

View file

@ -41,7 +41,6 @@ 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) {
@ -87,60 +86,4 @@ 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,12 +3,8 @@ 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";
@ -24,11 +20,6 @@ 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,
@ -63,24 +54,4 @@ 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,22 +12,6 @@ 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"],
@ -44,22 +28,6 @@ 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",
});
@ -79,47 +47,3 @@ 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,28 +1,4 @@
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 {
@ -30,190 +6,6 @@ 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,
};

View file

@ -1,96 +0,0 @@
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,17 +205,6 @@ 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;
};
@ -232,7 +221,7 @@ const init = async (config: RepositoryConfig) => {
addCommonArgs(args, env);
const res = await safeSpawn({ command: "restic", args, env });
await cleanupTemporaryKeys(env);
await cleanupTemporaryKeys(config, env);
if (res.exitCode !== 0) {
logger.error(`Restic init failed: ${res.stderr}`);
@ -360,7 +349,7 @@ const backup = async (
finally: async () => {
includeFile && (await fs.unlink(includeFile).catch(() => {}));
excludeFile && (await fs.unlink(excludeFile).catch(() => {}));
await cleanupTemporaryKeys(env);
await cleanupTemporaryKeys(config, env);
},
});
@ -458,7 +447,7 @@ const restore = async (
logger.debug(`Executing: restic ${args.join(" ")}`);
const res = await safeSpawn({ command: "restic", args, env });
await cleanupTemporaryKeys(env);
await cleanupTemporaryKeys(config, env);
if (res.exitCode !== 0) {
logger.error(`Restic restore failed: ${res.stderr}`);
@ -519,7 +508,7 @@ const snapshots = async (config: RepositoryConfig, options: { tags?: string[] }
addCommonArgs(args, env);
const res = await safeSpawn({ command: "restic", args, env });
await cleanupTemporaryKeys(env);
await cleanupTemporaryKeys(config, env);
if (res.exitCode !== 0) {
logger.error(`Restic snapshots retrieval failed: ${res.stderr}`);
@ -568,7 +557,7 @@ const forget = async (config: RepositoryConfig, options: RetentionPolicy, extra:
addCommonArgs(args, env);
const res = await safeSpawn({ command: "restic", args, env });
await cleanupTemporaryKeys(env);
await cleanupTemporaryKeys(config, env);
if (res.exitCode !== 0) {
logger.error(`Restic forget failed: ${res.stderr}`);
@ -578,19 +567,15 @@ const forget = async (config: RepositoryConfig, options: RetentionPolicy, extra:
return { success: true };
};
const deleteSnapshots = async (config: RepositoryConfig, snapshotIds: string[]) => {
const deleteSnapshot = async (config: RepositoryConfig, snapshotId: string) => {
const repoUrl = buildRepoUrl(config);
const env = await buildEnv(config);
if (snapshotIds.length === 0) {
throw new Error("No snapshot IDs provided for deletion.");
}
const args: string[] = ["--repo", repoUrl, "forget", ...snapshotIds, "--prune"];
const args: string[] = ["--repo", repoUrl, "forget", snapshotId, "--prune"];
addCommonArgs(args, env);
const res = await safeSpawn({ command: "restic", args, env });
await cleanupTemporaryKeys(env);
await cleanupTemporaryKeys(config, env);
if (res.exitCode !== 0) {
logger.error(`Restic snapshot deletion failed: ${res.stderr}`);
@ -600,55 +585,6 @@ const deleteSnapshots = async (config: RepositoryConfig, snapshotIds: 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",
@ -689,7 +625,7 @@ const ls = async (config: RepositoryConfig, snapshotId: string, path?: string) =
addCommonArgs(args, env);
const res = await safeSpawn({ command: "restic", args, env });
await cleanupTemporaryKeys(env);
await cleanupTemporaryKeys(config, env);
if (res.exitCode !== 0) {
logger.error(`Restic ls failed: ${res.stderr}`);
@ -740,7 +676,7 @@ const unlock = async (config: RepositoryConfig) => {
addCommonArgs(args, env);
const res = await safeSpawn({ command: "restic", args, env });
await cleanupTemporaryKeys(env);
await cleanupTemporaryKeys(config, env);
if (res.exitCode !== 0) {
logger.error(`Restic unlock failed: ${res.stderr}`);
@ -764,7 +700,7 @@ const check = async (config: RepositoryConfig, options?: { readData?: boolean })
addCommonArgs(args, env);
const res = await safeSpawn({ command: "restic", args, env });
await cleanupTemporaryKeys(env);
await cleanupTemporaryKeys(config, env);
const { stdout, stderr } = res;
@ -797,7 +733,7 @@ const repairIndex = async (config: RepositoryConfig) => {
addCommonArgs(args, env);
const res = await safeSpawn({ command: "restic", args, env });
await cleanupTemporaryKeys(env);
await cleanupTemporaryKeys(config, env);
const { stdout, stderr } = res;
@ -857,8 +793,8 @@ const copy = async (
const res = await safeSpawn({ command: "restic", args, env });
await cleanupTemporaryKeys(sourceEnv);
await cleanupTemporaryKeys(destEnv);
await cleanupTemporaryKeys(sourceConfig, sourceEnv);
await cleanupTemporaryKeys(destConfig, destEnv);
const { stdout, stderr } = res;
@ -874,26 +810,19 @@ const copy = async (
};
};
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) {
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) {
await fs.unlink(env.RESTIC_PASSWORD_FILE).catch(() => {});
}
if (env.GOOGLE_APPLICATION_CREDENTIALS) {
} else if (config.backend === "gcs" && 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>) => {
@ -902,14 +831,6 @@ 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 = {
@ -920,8 +841,6 @@ export const restic = {
snapshots,
forget,
deleteSnapshot,
deleteSnapshots,
tagSnapshots,
unlock,
ls,
check,

View file

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

261
bun.lock
View file

@ -13,7 +13,6 @@
"@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",
@ -49,12 +48,9 @@
"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",
@ -65,19 +61,14 @@
"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",
@ -149,8 +140,6 @@
"@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=="],
@ -255,8 +244,6 @@
"@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=="],
@ -329,8 +316,6 @@
"@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=="],
@ -499,8 +484,6 @@
"@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=="],
@ -511,12 +494,6 @@
"@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=="],
@ -537,45 +514,27 @@
"@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@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
"ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="],
"ansi-styles": ["ansi-styles@5.2.0", "", {}, "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA=="],
"ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="],
"anymatch": ["anymatch@3.1.3", "", { "dependencies": { "normalize-path": "^3.0.0", "picomatch": "^2.0.4" } }, "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw=="],
@ -585,8 +544,6 @@
"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=="],
@ -597,8 +554,6 @@
"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=="],
@ -629,16 +584,6 @@
"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=="],
@ -661,8 +606,6 @@
"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=="],
@ -691,8 +634,6 @@
"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=="],
@ -723,8 +664,6 @@
"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=="],
@ -737,8 +676,6 @@
"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=="],
@ -747,12 +684,8 @@
"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=="],
@ -795,10 +728,6 @@
"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=="],
@ -809,8 +738,6 @@
"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=="],
@ -851,24 +778,16 @@
"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=="],
@ -879,30 +798,18 @@
"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=="],
@ -951,114 +858,22 @@
"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=="],
@ -1111,8 +926,6 @@
"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=="],
@ -1131,16 +944,10 @@
"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=="],
@ -1163,8 +970,6 @@
"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=="],
@ -1189,14 +994,6 @@
"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=="],
@ -1213,7 +1010,7 @@
"scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="],
"semver": ["semver@7.7.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q=="],
"semver": ["semver@7.7.2", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA=="],
"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=="],
@ -1247,8 +1044,6 @@
"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=="],
@ -1257,14 +1052,8 @@
"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=="],
@ -1285,12 +1074,8 @@
"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=="],
@ -1309,18 +1094,6 @@
"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=="],
@ -1341,10 +1114,6 @@
"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=="],
@ -1355,8 +1124,6 @@
"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=="],
@ -1375,8 +1142,6 @@
"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=="],
@ -1385,10 +1150,6 @@
"@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=="],
@ -1409,8 +1170,6 @@
"@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=="],
@ -1451,8 +1210,6 @@
"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=="],
@ -1461,26 +1218,16 @@
"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=="],
@ -1489,8 +1236,6 @@
"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,8 +7,7 @@
"build": "react-router build",
"dev": "bunx --bun vite",
"start": "bun ./dist/server/index.js",
"cli:dev": "bun run app/server/cli/main.ts",
"cli": "ZEROBYTE_CLI=1 bun ./dist/server/index.js",
"cli": "bun run app/server/cli/main.ts",
"tsc": "react-router typegen && tsc",
"lint": "biome check .",
"lint:ci": "biome ci . --changed --error-on-warnings --no-errors-on-unmatched",
@ -17,9 +16,7 @@
"gen:api-client": "openapi-ts",
"gen:migrations": "drizzle-kit generate",
"studio": "drizzle-kit studio",
"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"
"test": "dotenv -e .env.test -- bun test --preload ./app/test/setup.ts"
},
"overrides": {
"esbuild": "^0.27.2"
@ -33,7 +30,6 @@
"@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",
@ -69,12 +65,9 @@
"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",
@ -85,19 +78,14 @@
"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",