Merge remote-tracking branch 'upstream/main' into config-export-feature
This commit is contained in:
commit
20c3b8156d
73 changed files with 2997 additions and 832 deletions
|
|
@ -45,6 +45,8 @@ RUN tar -xzf shoutrrr.tar.gz && chmod +x shoutrrr
|
|||
# ------------------------------
|
||||
FROM base AS development
|
||||
|
||||
ARG APP_VERSION=dev
|
||||
ENV APP_VERSION=${APP_VERSION}
|
||||
ENV NODE_ENV="development"
|
||||
|
||||
WORKDIR /app
|
||||
|
|
@ -84,6 +86,8 @@ RUN bun run build
|
|||
|
||||
FROM base AS production
|
||||
|
||||
ARG APP_VERSION=dev
|
||||
ENV APP_VERSION=${APP_VERSION}
|
||||
ENV NODE_ENV="production"
|
||||
|
||||
WORKDIR /app
|
||||
|
|
|
|||
|
|
@ -40,7 +40,7 @@ In order to run Zerobyte, you need to have Docker and Docker Compose installed o
|
|||
```yaml
|
||||
services:
|
||||
zerobyte:
|
||||
image: ghcr.io/nicotsx/zerobyte:v0.19
|
||||
image: ghcr.io/nicotsx/zerobyte:v0.21
|
||||
container_name: zerobyte
|
||||
restart: unless-stopped
|
||||
cap_add:
|
||||
|
|
@ -77,7 +77,7 @@ If you only need to back up locally mounted folders and don't require remote sha
|
|||
```yaml
|
||||
services:
|
||||
zerobyte:
|
||||
image: ghcr.io/nicotsx/zerobyte:v0.19
|
||||
image: ghcr.io/nicotsx/zerobyte:v0.21
|
||||
container_name: zerobyte
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
|
|
@ -113,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.19
|
||||
image: ghcr.io/nicotsx/zerobyte:v0.21
|
||||
container_name: zerobyte
|
||||
restart: unless-stopped
|
||||
cap_add:
|
||||
|
|
@ -186,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.19
|
||||
image: ghcr.io/nicotsx/zerobyte:v0.21
|
||||
container_name: zerobyte
|
||||
restart: unless-stopped
|
||||
cap_add:
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
@import "tailwindcss";
|
||||
@plugin "@tailwindcss/typography";
|
||||
@import "tw-animate-css";
|
||||
@import "dither-plugin";
|
||||
|
||||
|
|
|
|||
|
|
@ -3,8 +3,8 @@
|
|||
import { type DefaultError, queryOptions, type UseMutationOptions } from '@tanstack/react-query';
|
||||
|
||||
import { client } from '../client.gen';
|
||||
import { browseFilesystem, changePassword, createBackupSchedule, createNotificationDestination, createRepository, createVolume, deleteBackupSchedule, deleteNotificationDestination, deleteRepository, deleteSnapshot, deleteVolume, doctorRepository, downloadResticPassword, exportFullConfig, getBackupSchedule, getBackupScheduleForVolume, getMe, getMirrorCompatibility, getNotificationDestination, getRepository, getScheduleMirrors, getScheduleNotifications, getSnapshotDetails, getStatus, getSystemInfo, getVolume, healthCheckVolume, listBackupSchedules, listFiles, listNotificationDestinations, listRcloneRemotes, listRepositories, listSnapshotFiles, listSnapshots, listVolumes, login, logout, mountVolume, type Options, register, reorderBackupSchedules, restoreSnapshot, runBackupNow, runForget, stopBackup, testConnection, testNotificationDestination, unmountVolume, updateBackupSchedule, updateNotificationDestination, updateRepository, updateScheduleMirrors, updateScheduleNotifications, updateVolume } from '../sdk.gen';
|
||||
import type { BrowseFilesystemData, BrowseFilesystemResponse, ChangePasswordData, ChangePasswordResponse, CreateBackupScheduleData, CreateBackupScheduleResponse, CreateNotificationDestinationData, CreateNotificationDestinationResponse, CreateRepositoryData, CreateRepositoryResponse, CreateVolumeData, CreateVolumeResponse, DeleteBackupScheduleData, DeleteBackupScheduleResponse, DeleteNotificationDestinationData, DeleteNotificationDestinationResponse, DeleteRepositoryData, DeleteRepositoryResponse, DeleteSnapshotData, DeleteSnapshotResponse, DeleteVolumeData, DeleteVolumeResponse, DoctorRepositoryData, DoctorRepositoryResponse, DownloadResticPasswordData, DownloadResticPasswordResponse, ExportFullConfigData, ExportFullConfigError, ExportFullConfigResponse, GetBackupScheduleData, GetBackupScheduleForVolumeData, GetBackupScheduleForVolumeResponse, GetBackupScheduleResponse, GetMeData, GetMeResponse, GetMirrorCompatibilityData, GetMirrorCompatibilityResponse, GetNotificationDestinationData, GetNotificationDestinationResponse, GetRepositoryData, GetRepositoryResponse, GetScheduleMirrorsData, GetScheduleMirrorsResponse, GetScheduleNotificationsData, GetScheduleNotificationsResponse, GetSnapshotDetailsData, GetSnapshotDetailsResponse, GetStatusData, GetStatusResponse, GetSystemInfoData, GetSystemInfoResponse, GetVolumeData, GetVolumeResponse, HealthCheckVolumeData, HealthCheckVolumeResponse, ListBackupSchedulesData, ListBackupSchedulesResponse, ListFilesData, ListFilesResponse, ListNotificationDestinationsData, ListNotificationDestinationsResponse, ListRcloneRemotesData, ListRcloneRemotesResponse, ListRepositoriesData, ListRepositoriesResponse, ListSnapshotFilesData, ListSnapshotFilesResponse, ListSnapshotsData, ListSnapshotsResponse, ListVolumesData, ListVolumesResponse, LoginData, LoginResponse, LogoutData, LogoutResponse, MountVolumeData, MountVolumeResponse, RegisterData, RegisterResponse, ReorderBackupSchedulesData, ReorderBackupSchedulesResponse, RestoreSnapshotData, RestoreSnapshotResponse, RunBackupNowData, RunBackupNowResponse, RunForgetData, RunForgetResponse, StopBackupData, StopBackupResponse, TestConnectionData, TestConnectionResponse, TestNotificationDestinationData, TestNotificationDestinationResponse, UnmountVolumeData, UnmountVolumeResponse, UpdateBackupScheduleData, UpdateBackupScheduleResponse, UpdateNotificationDestinationData, UpdateNotificationDestinationResponse, UpdateRepositoryData, UpdateRepositoryResponse, UpdateScheduleMirrorsData, UpdateScheduleMirrorsResponse, UpdateScheduleNotificationsData, UpdateScheduleNotificationsResponse, UpdateVolumeData, UpdateVolumeResponse } from '../types.gen';
|
||||
import { browseFilesystem, changePassword, createBackupSchedule, createNotificationDestination, createRepository, createVolume, deleteBackupSchedule, deleteNotificationDestination, deleteRepository, deleteSnapshot, deleteSnapshots, deleteVolume, doctorRepository, downloadResticPassword, 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, GetBackupScheduleData, GetBackupScheduleForVolumeData, GetBackupScheduleForVolumeResponse, GetBackupScheduleResponse, GetMeData, GetMeResponse, GetMirrorCompatibilityData, GetMirrorCompatibilityResponse, GetNotificationDestinationData, GetNotificationDestinationResponse, GetRepositoryData, GetRepositoryResponse, GetScheduleMirrorsData, GetScheduleMirrorsResponse, GetScheduleNotificationsData, GetScheduleNotificationsResponse, GetSnapshotDetailsData, GetSnapshotDetailsResponse, GetStatusData, GetStatusResponse, GetSystemInfoData, GetSystemInfoResponse, GetUpdatesData, GetUpdatesResponse, GetVolumeData, GetVolumeResponse, HealthCheckVolumeData, HealthCheckVolumeResponse, ListBackupSchedulesData, ListBackupSchedulesResponse, ListFilesData, ListFilesResponse, ListNotificationDestinationsData, ListNotificationDestinationsResponse, ListRcloneRemotesData, ListRcloneRemotesResponse, ListRepositoriesData, ListRepositoriesResponse, ListSnapshotFilesData, ListSnapshotFilesResponse, ListSnapshotsData, ListSnapshotsResponse, ListVolumesData, ListVolumesResponse, LoginData, LoginResponse, LogoutData, LogoutResponse, MountVolumeData, MountVolumeResponse, RegisterData, RegisterResponse, ReorderBackupSchedulesData, ReorderBackupSchedulesResponse, RestoreSnapshotData, RestoreSnapshotResponse, RunBackupNowData, RunBackupNowResponse, RunForgetData, RunForgetResponse, StopBackupData, StopBackupResponse, TagSnapshotsData, TagSnapshotsResponse, TestConnectionData, TestConnectionResponse, TestNotificationDestinationData, TestNotificationDestinationResponse, UnmountVolumeData, UnmountVolumeResponse, UpdateBackupScheduleData, UpdateBackupScheduleResponse, UpdateNotificationDestinationData, UpdateNotificationDestinationResponse, UpdateRepositoryData, UpdateRepositoryResponse, UpdateScheduleMirrorsData, UpdateScheduleMirrorsResponse, UpdateScheduleNotificationsData, UpdateScheduleNotificationsResponse, UpdateVolumeData, UpdateVolumeResponse } from '../types.gen';
|
||||
|
||||
/**
|
||||
* Register a new user
|
||||
|
|
@ -439,6 +439,23 @@ export const updateRepositoryMutation = (options?: Partial<Options<UpdateReposit
|
|||
return mutationOptions;
|
||||
};
|
||||
|
||||
/**
|
||||
* Delete multiple snapshots from a repository
|
||||
*/
|
||||
export const deleteSnapshotsMutation = (options?: Partial<Options<DeleteSnapshotsData>>): UseMutationOptions<DeleteSnapshotsResponse, DefaultError, Options<DeleteSnapshotsData>> => {
|
||||
const mutationOptions: UseMutationOptions<DeleteSnapshotsResponse, DefaultError, Options<DeleteSnapshotsData>> = {
|
||||
mutationFn: async (fnOptions) => {
|
||||
const { data } = await deleteSnapshots({
|
||||
...options,
|
||||
...fnOptions,
|
||||
throwOnError: true
|
||||
});
|
||||
return data;
|
||||
}
|
||||
};
|
||||
return mutationOptions;
|
||||
};
|
||||
|
||||
export const listSnapshotsQueryKey = (options: Options<ListSnapshotsData>) => createQueryKey('listSnapshots', options);
|
||||
|
||||
/**
|
||||
|
|
@ -544,6 +561,23 @@ export const doctorRepositoryMutation = (options?: Partial<Options<DoctorReposit
|
|||
return mutationOptions;
|
||||
};
|
||||
|
||||
/**
|
||||
* Tag multiple snapshots in a repository
|
||||
*/
|
||||
export const tagSnapshotsMutation = (options?: Partial<Options<TagSnapshotsData>>): UseMutationOptions<TagSnapshotsResponse, DefaultError, Options<TagSnapshotsData>> => {
|
||||
const mutationOptions: UseMutationOptions<TagSnapshotsResponse, DefaultError, Options<TagSnapshotsData>> = {
|
||||
mutationFn: async (fnOptions) => {
|
||||
const { data } = await tagSnapshots({
|
||||
...options,
|
||||
...fnOptions,
|
||||
throwOnError: true
|
||||
});
|
||||
return data;
|
||||
}
|
||||
};
|
||||
return mutationOptions;
|
||||
};
|
||||
|
||||
export const listBackupSchedulesQueryKey = (options?: Options<ListBackupSchedulesData>) => createQueryKey('listBackupSchedules', options);
|
||||
|
||||
/**
|
||||
|
|
@ -927,6 +961,24 @@ export const getSystemInfoOptions = (options?: Options<GetSystemInfoData>) => qu
|
|||
queryKey: getSystemInfoQueryKey(options)
|
||||
});
|
||||
|
||||
export const getUpdatesQueryKey = (options?: Options<GetUpdatesData>) => createQueryKey('getUpdates', options);
|
||||
|
||||
/**
|
||||
* Check for application updates from GitHub
|
||||
*/
|
||||
export const getUpdatesOptions = (options?: Options<GetUpdatesData>) => queryOptions<GetUpdatesResponse, DefaultError, GetUpdatesResponse, ReturnType<typeof getUpdatesQueryKey>>({
|
||||
queryFn: async ({ queryKey, signal }) => {
|
||||
const { data } = await getUpdates({
|
||||
...options,
|
||||
...queryKey[0],
|
||||
signal,
|
||||
throwOnError: true
|
||||
});
|
||||
return data;
|
||||
},
|
||||
queryKey: getUpdatesQueryKey(options)
|
||||
});
|
||||
|
||||
/**
|
||||
* Download the Restic password file for backup recovery. Requires password re-authentication.
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
import type { Client, Options as Options2, TDataShape } from './client';
|
||||
import { client } from './client.gen';
|
||||
import type { BrowseFilesystemData, BrowseFilesystemResponses, ChangePasswordData, ChangePasswordResponses, CreateBackupScheduleData, CreateBackupScheduleResponses, CreateNotificationDestinationData, CreateNotificationDestinationResponses, CreateRepositoryData, CreateRepositoryResponses, CreateVolumeData, CreateVolumeResponses, DeleteBackupScheduleData, DeleteBackupScheduleResponses, DeleteNotificationDestinationData, DeleteNotificationDestinationErrors, DeleteNotificationDestinationResponses, DeleteRepositoryData, DeleteRepositoryResponses, DeleteSnapshotData, DeleteSnapshotResponses, DeleteVolumeData, DeleteVolumeResponses, DoctorRepositoryData, DoctorRepositoryResponses, DownloadResticPasswordData, DownloadResticPasswordResponses, ExportFullConfigData, ExportFullConfigErrors, ExportFullConfigResponses, GetBackupScheduleData, GetBackupScheduleForVolumeData, GetBackupScheduleForVolumeResponses, GetBackupScheduleResponses, GetMeData, GetMeResponses, GetMirrorCompatibilityData, GetMirrorCompatibilityResponses, GetNotificationDestinationData, GetNotificationDestinationErrors, GetNotificationDestinationResponses, GetRepositoryData, GetRepositoryResponses, GetScheduleMirrorsData, GetScheduleMirrorsResponses, GetScheduleNotificationsData, GetScheduleNotificationsResponses, GetSnapshotDetailsData, GetSnapshotDetailsResponses, GetStatusData, GetStatusResponses, GetSystemInfoData, GetSystemInfoResponses, GetVolumeData, GetVolumeErrors, GetVolumeResponses, HealthCheckVolumeData, HealthCheckVolumeErrors, HealthCheckVolumeResponses, ListBackupSchedulesData, ListBackupSchedulesResponses, ListFilesData, ListFilesResponses, ListNotificationDestinationsData, ListNotificationDestinationsResponses, ListRcloneRemotesData, ListRcloneRemotesResponses, ListRepositoriesData, ListRepositoriesResponses, ListSnapshotFilesData, ListSnapshotFilesResponses, ListSnapshotsData, ListSnapshotsResponses, ListVolumesData, ListVolumesResponses, LoginData, LoginResponses, LogoutData, LogoutResponses, MountVolumeData, MountVolumeResponses, RegisterData, RegisterResponses, ReorderBackupSchedulesData, ReorderBackupSchedulesResponses, RestoreSnapshotData, RestoreSnapshotResponses, RunBackupNowData, RunBackupNowResponses, RunForgetData, RunForgetResponses, StopBackupData, StopBackupErrors, StopBackupResponses, TestConnectionData, TestConnectionResponses, TestNotificationDestinationData, TestNotificationDestinationErrors, TestNotificationDestinationResponses, UnmountVolumeData, UnmountVolumeResponses, UpdateBackupScheduleData, UpdateBackupScheduleResponses, UpdateNotificationDestinationData, UpdateNotificationDestinationErrors, UpdateNotificationDestinationResponses, UpdateRepositoryData, UpdateRepositoryErrors, UpdateRepositoryResponses, UpdateScheduleMirrorsData, UpdateScheduleMirrorsResponses, UpdateScheduleNotificationsData, UpdateScheduleNotificationsResponses, UpdateVolumeData, UpdateVolumeErrors, UpdateVolumeResponses } from './types.gen';
|
||||
import type { BrowseFilesystemData, BrowseFilesystemResponses, ChangePasswordData, ChangePasswordResponses, CreateBackupScheduleData, CreateBackupScheduleResponses, CreateNotificationDestinationData, CreateNotificationDestinationResponses, CreateRepositoryData, CreateRepositoryResponses, CreateVolumeData, CreateVolumeResponses, DeleteBackupScheduleData, DeleteBackupScheduleResponses, DeleteNotificationDestinationData, DeleteNotificationDestinationErrors, DeleteNotificationDestinationResponses, DeleteRepositoryData, DeleteRepositoryResponses, DeleteSnapshotData, DeleteSnapshotResponses, DeleteSnapshotsData, DeleteSnapshotsResponses, DeleteVolumeData, DeleteVolumeResponses, DoctorRepositoryData, DoctorRepositoryResponses, DownloadResticPasswordData, DownloadResticPasswordResponses, GetBackupScheduleData, GetBackupScheduleForVolumeData, GetBackupScheduleForVolumeResponses, GetBackupScheduleResponses, GetMeData, GetMeResponses, GetMirrorCompatibilityData, GetMirrorCompatibilityResponses, GetNotificationDestinationData, GetNotificationDestinationErrors, GetNotificationDestinationResponses, GetRepositoryData, GetRepositoryResponses, GetScheduleMirrorsData, GetScheduleMirrorsResponses, GetScheduleNotificationsData, GetScheduleNotificationsResponses, GetSnapshotDetailsData, GetSnapshotDetailsResponses, GetStatusData, GetStatusResponses, GetSystemInfoData, GetSystemInfoResponses, GetUpdatesData, GetUpdatesResponses, GetVolumeData, GetVolumeErrors, GetVolumeResponses, HealthCheckVolumeData, HealthCheckVolumeErrors, HealthCheckVolumeResponses, ListBackupSchedulesData, ListBackupSchedulesResponses, ListFilesData, ListFilesResponses, ListNotificationDestinationsData, ListNotificationDestinationsResponses, ListRcloneRemotesData, ListRcloneRemotesResponses, ListRepositoriesData, ListRepositoriesResponses, ListSnapshotFilesData, ListSnapshotFilesResponses, ListSnapshotsData, ListSnapshotsResponses, ListVolumesData, ListVolumesResponses, LoginData, LoginResponses, LogoutData, LogoutResponses, MountVolumeData, MountVolumeResponses, RegisterData, RegisterResponses, ReorderBackupSchedulesData, ReorderBackupSchedulesResponses, RestoreSnapshotData, RestoreSnapshotResponses, RunBackupNowData, RunBackupNowResponses, RunForgetData, RunForgetResponses, StopBackupData, StopBackupErrors, StopBackupResponses, TagSnapshotsData, TagSnapshotsResponses, TestConnectionData, TestConnectionResponses, TestNotificationDestinationData, TestNotificationDestinationErrors, TestNotificationDestinationResponses, UnmountVolumeData, UnmountVolumeResponses, UpdateBackupScheduleData, UpdateBackupScheduleResponses, UpdateNotificationDestinationData, UpdateNotificationDestinationErrors, UpdateNotificationDestinationResponses, UpdateRepositoryData, UpdateRepositoryErrors, UpdateRepositoryResponses, UpdateScheduleMirrorsData, UpdateScheduleMirrorsResponses, UpdateScheduleNotificationsData, UpdateScheduleNotificationsResponses, UpdateVolumeData, UpdateVolumeErrors, UpdateVolumeResponses } from './types.gen';
|
||||
|
||||
export type Options<TData extends TDataShape = TDataShape, ThrowOnError extends boolean = boolean> = Options2<TData, ThrowOnError> & {
|
||||
/**
|
||||
|
|
@ -189,6 +189,18 @@ export const updateRepository = <ThrowOnError extends boolean = false>(options:
|
|||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Delete multiple snapshots from a repository
|
||||
*/
|
||||
export const deleteSnapshots = <ThrowOnError extends boolean = false>(options: Options<DeleteSnapshotsData, ThrowOnError>) => (options.client ?? client).delete<DeleteSnapshotsResponses, unknown, ThrowOnError>({
|
||||
url: '/api/v1/repositories/{id}/snapshots',
|
||||
...options,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...options.headers
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* List all snapshots in a repository
|
||||
*/
|
||||
|
|
@ -226,6 +238,18 @@ export const restoreSnapshot = <ThrowOnError extends boolean = false>(options: O
|
|||
*/
|
||||
export const doctorRepository = <ThrowOnError extends boolean = false>(options: Options<DoctorRepositoryData, ThrowOnError>) => (options.client ?? client).post<DoctorRepositoryResponses, unknown, ThrowOnError>({ url: '/api/v1/repositories/{id}/doctor', ...options });
|
||||
|
||||
/**
|
||||
* Tag multiple snapshots in a repository
|
||||
*/
|
||||
export const tagSnapshots = <ThrowOnError extends boolean = false>(options: Options<TagSnapshotsData, ThrowOnError>) => (options.client ?? client).post<TagSnapshotsResponses, unknown, ThrowOnError>({
|
||||
url: '/api/v1/repositories/{id}/snapshots/tag',
|
||||
...options,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...options.headers
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* List all backup schedules
|
||||
*/
|
||||
|
|
@ -385,6 +409,11 @@ export const testNotificationDestination = <ThrowOnError extends boolean = false
|
|||
*/
|
||||
export const getSystemInfo = <ThrowOnError extends boolean = false>(options?: Options<GetSystemInfoData, ThrowOnError>) => (options?.client ?? client).get<GetSystemInfoResponses, unknown, ThrowOnError>({ url: '/api/v1/system/info', ...options });
|
||||
|
||||
/**
|
||||
* Check for application updates from GitHub
|
||||
*/
|
||||
export const getUpdates = <ThrowOnError extends boolean = false>(options?: Options<GetUpdatesData, ThrowOnError>) => (options?.client ?? client).get<GetUpdatesResponses, unknown, ThrowOnError>({ url: '/api/v1/system/updates', ...options });
|
||||
|
||||
/**
|
||||
* Download the Restic password file for backup recovery. Requires password re-authentication.
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -798,7 +798,9 @@ export type ListRepositoriesResponses = {
|
|||
bucket: string;
|
||||
endpoint: string;
|
||||
secretAccessKey: string;
|
||||
cacert?: string;
|
||||
customPassword?: string;
|
||||
insecureTls?: boolean;
|
||||
isExistingRepository?: boolean;
|
||||
} | {
|
||||
accessKeyId: string;
|
||||
|
|
@ -806,39 +808,51 @@ export type ListRepositoriesResponses = {
|
|||
bucket: string;
|
||||
endpoint: string;
|
||||
secretAccessKey: string;
|
||||
cacert?: string;
|
||||
customPassword?: string;
|
||||
insecureTls?: boolean;
|
||||
isExistingRepository?: boolean;
|
||||
} | {
|
||||
accountKey: string;
|
||||
accountName: string;
|
||||
backend: 'azure';
|
||||
container: string;
|
||||
cacert?: string;
|
||||
customPassword?: string;
|
||||
endpointSuffix?: string;
|
||||
insecureTls?: boolean;
|
||||
isExistingRepository?: boolean;
|
||||
} | {
|
||||
backend: 'gcs';
|
||||
bucket: string;
|
||||
credentialsJson: string;
|
||||
projectId: string;
|
||||
cacert?: string;
|
||||
customPassword?: string;
|
||||
insecureTls?: boolean;
|
||||
isExistingRepository?: boolean;
|
||||
} | {
|
||||
backend: 'local';
|
||||
name: string;
|
||||
cacert?: string;
|
||||
customPassword?: string;
|
||||
insecureTls?: boolean;
|
||||
isExistingRepository?: boolean;
|
||||
path?: string;
|
||||
} | {
|
||||
backend: 'rclone';
|
||||
path: string;
|
||||
remote: string;
|
||||
cacert?: string;
|
||||
customPassword?: string;
|
||||
insecureTls?: boolean;
|
||||
isExistingRepository?: boolean;
|
||||
} | {
|
||||
backend: 'rest';
|
||||
url: string;
|
||||
cacert?: string;
|
||||
customPassword?: string;
|
||||
insecureTls?: boolean;
|
||||
isExistingRepository?: boolean;
|
||||
password?: string;
|
||||
path?: string;
|
||||
|
|
@ -851,7 +865,9 @@ export type ListRepositoriesResponses = {
|
|||
user: string;
|
||||
port?: number;
|
||||
skipHostKeyCheck?: boolean;
|
||||
cacert?: string;
|
||||
customPassword?: string;
|
||||
insecureTls?: boolean;
|
||||
isExistingRepository?: boolean;
|
||||
knownHosts?: string;
|
||||
};
|
||||
|
|
@ -877,7 +893,9 @@ export type CreateRepositoryData = {
|
|||
bucket: string;
|
||||
endpoint: string;
|
||||
secretAccessKey: string;
|
||||
cacert?: string;
|
||||
customPassword?: string;
|
||||
insecureTls?: boolean;
|
||||
isExistingRepository?: boolean;
|
||||
} | {
|
||||
accessKeyId: string;
|
||||
|
|
@ -885,39 +903,51 @@ export type CreateRepositoryData = {
|
|||
bucket: string;
|
||||
endpoint: string;
|
||||
secretAccessKey: string;
|
||||
cacert?: string;
|
||||
customPassword?: string;
|
||||
insecureTls?: boolean;
|
||||
isExistingRepository?: boolean;
|
||||
} | {
|
||||
accountKey: string;
|
||||
accountName: string;
|
||||
backend: 'azure';
|
||||
container: string;
|
||||
cacert?: string;
|
||||
customPassword?: string;
|
||||
endpointSuffix?: string;
|
||||
insecureTls?: boolean;
|
||||
isExistingRepository?: boolean;
|
||||
} | {
|
||||
backend: 'gcs';
|
||||
bucket: string;
|
||||
credentialsJson: string;
|
||||
projectId: string;
|
||||
cacert?: string;
|
||||
customPassword?: string;
|
||||
insecureTls?: boolean;
|
||||
isExistingRepository?: boolean;
|
||||
} | {
|
||||
backend: 'local';
|
||||
name: string;
|
||||
cacert?: string;
|
||||
customPassword?: string;
|
||||
insecureTls?: boolean;
|
||||
isExistingRepository?: boolean;
|
||||
path?: string;
|
||||
} | {
|
||||
backend: 'rclone';
|
||||
path: string;
|
||||
remote: string;
|
||||
cacert?: string;
|
||||
customPassword?: string;
|
||||
insecureTls?: boolean;
|
||||
isExistingRepository?: boolean;
|
||||
} | {
|
||||
backend: 'rest';
|
||||
url: string;
|
||||
cacert?: string;
|
||||
customPassword?: string;
|
||||
insecureTls?: boolean;
|
||||
isExistingRepository?: boolean;
|
||||
password?: string;
|
||||
path?: string;
|
||||
|
|
@ -930,7 +960,9 @@ export type CreateRepositoryData = {
|
|||
user: string;
|
||||
port?: number;
|
||||
skipHostKeyCheck?: boolean;
|
||||
cacert?: string;
|
||||
customPassword?: string;
|
||||
insecureTls?: boolean;
|
||||
isExistingRepository?: boolean;
|
||||
knownHosts?: string;
|
||||
};
|
||||
|
|
@ -1018,7 +1050,9 @@ export type GetRepositoryResponses = {
|
|||
bucket: string;
|
||||
endpoint: string;
|
||||
secretAccessKey: string;
|
||||
cacert?: string;
|
||||
customPassword?: string;
|
||||
insecureTls?: boolean;
|
||||
isExistingRepository?: boolean;
|
||||
} | {
|
||||
accessKeyId: string;
|
||||
|
|
@ -1026,39 +1060,51 @@ export type GetRepositoryResponses = {
|
|||
bucket: string;
|
||||
endpoint: string;
|
||||
secretAccessKey: string;
|
||||
cacert?: string;
|
||||
customPassword?: string;
|
||||
insecureTls?: boolean;
|
||||
isExistingRepository?: boolean;
|
||||
} | {
|
||||
accountKey: string;
|
||||
accountName: string;
|
||||
backend: 'azure';
|
||||
container: string;
|
||||
cacert?: string;
|
||||
customPassword?: string;
|
||||
endpointSuffix?: string;
|
||||
insecureTls?: boolean;
|
||||
isExistingRepository?: boolean;
|
||||
} | {
|
||||
backend: 'gcs';
|
||||
bucket: string;
|
||||
credentialsJson: string;
|
||||
projectId: string;
|
||||
cacert?: string;
|
||||
customPassword?: string;
|
||||
insecureTls?: boolean;
|
||||
isExistingRepository?: boolean;
|
||||
} | {
|
||||
backend: 'local';
|
||||
name: string;
|
||||
cacert?: string;
|
||||
customPassword?: string;
|
||||
insecureTls?: boolean;
|
||||
isExistingRepository?: boolean;
|
||||
path?: string;
|
||||
} | {
|
||||
backend: 'rclone';
|
||||
path: string;
|
||||
remote: string;
|
||||
cacert?: string;
|
||||
customPassword?: string;
|
||||
insecureTls?: boolean;
|
||||
isExistingRepository?: boolean;
|
||||
} | {
|
||||
backend: 'rest';
|
||||
url: string;
|
||||
cacert?: string;
|
||||
customPassword?: string;
|
||||
insecureTls?: boolean;
|
||||
isExistingRepository?: boolean;
|
||||
password?: string;
|
||||
path?: string;
|
||||
|
|
@ -1071,7 +1117,9 @@ export type GetRepositoryResponses = {
|
|||
user: string;
|
||||
port?: number;
|
||||
skipHostKeyCheck?: boolean;
|
||||
cacert?: string;
|
||||
customPassword?: string;
|
||||
insecureTls?: boolean;
|
||||
isExistingRepository?: boolean;
|
||||
knownHosts?: string;
|
||||
};
|
||||
|
|
@ -1124,7 +1172,9 @@ export type UpdateRepositoryResponses = {
|
|||
bucket: string;
|
||||
endpoint: string;
|
||||
secretAccessKey: string;
|
||||
cacert?: string;
|
||||
customPassword?: string;
|
||||
insecureTls?: boolean;
|
||||
isExistingRepository?: boolean;
|
||||
} | {
|
||||
accessKeyId: string;
|
||||
|
|
@ -1132,39 +1182,51 @@ export type UpdateRepositoryResponses = {
|
|||
bucket: string;
|
||||
endpoint: string;
|
||||
secretAccessKey: string;
|
||||
cacert?: string;
|
||||
customPassword?: string;
|
||||
insecureTls?: boolean;
|
||||
isExistingRepository?: boolean;
|
||||
} | {
|
||||
accountKey: string;
|
||||
accountName: string;
|
||||
backend: 'azure';
|
||||
container: string;
|
||||
cacert?: string;
|
||||
customPassword?: string;
|
||||
endpointSuffix?: string;
|
||||
insecureTls?: boolean;
|
||||
isExistingRepository?: boolean;
|
||||
} | {
|
||||
backend: 'gcs';
|
||||
bucket: string;
|
||||
credentialsJson: string;
|
||||
projectId: string;
|
||||
cacert?: string;
|
||||
customPassword?: string;
|
||||
insecureTls?: boolean;
|
||||
isExistingRepository?: boolean;
|
||||
} | {
|
||||
backend: 'local';
|
||||
name: string;
|
||||
cacert?: string;
|
||||
customPassword?: string;
|
||||
insecureTls?: boolean;
|
||||
isExistingRepository?: boolean;
|
||||
path?: string;
|
||||
} | {
|
||||
backend: 'rclone';
|
||||
path: string;
|
||||
remote: string;
|
||||
cacert?: string;
|
||||
customPassword?: string;
|
||||
insecureTls?: boolean;
|
||||
isExistingRepository?: boolean;
|
||||
} | {
|
||||
backend: 'rest';
|
||||
url: string;
|
||||
cacert?: string;
|
||||
customPassword?: string;
|
||||
insecureTls?: boolean;
|
||||
isExistingRepository?: boolean;
|
||||
password?: string;
|
||||
path?: string;
|
||||
|
|
@ -1177,7 +1239,9 @@ export type UpdateRepositoryResponses = {
|
|||
user: string;
|
||||
port?: number;
|
||||
skipHostKeyCheck?: boolean;
|
||||
cacert?: string;
|
||||
customPassword?: string;
|
||||
insecureTls?: boolean;
|
||||
isExistingRepository?: boolean;
|
||||
knownHosts?: string;
|
||||
};
|
||||
|
|
@ -1195,6 +1259,28 @@ export type UpdateRepositoryResponses = {
|
|||
|
||||
export type UpdateRepositoryResponse = UpdateRepositoryResponses[keyof UpdateRepositoryResponses];
|
||||
|
||||
export type DeleteSnapshotsData = {
|
||||
body?: {
|
||||
snapshotIds: Array<string>;
|
||||
};
|
||||
path: {
|
||||
id: string;
|
||||
};
|
||||
query?: never;
|
||||
url: '/api/v1/repositories/{id}/snapshots';
|
||||
};
|
||||
|
||||
export type DeleteSnapshotsResponses = {
|
||||
/**
|
||||
* Snapshots deleted successfully
|
||||
*/
|
||||
200: {
|
||||
message: string;
|
||||
};
|
||||
};
|
||||
|
||||
export type DeleteSnapshotsResponse = DeleteSnapshotsResponses[keyof DeleteSnapshotsResponses];
|
||||
|
||||
export type ListSnapshotsData = {
|
||||
body?: never;
|
||||
path: {
|
||||
|
|
@ -1367,6 +1453,31 @@ export type DoctorRepositoryResponses = {
|
|||
|
||||
export type DoctorRepositoryResponse = DoctorRepositoryResponses[keyof DoctorRepositoryResponses];
|
||||
|
||||
export type TagSnapshotsData = {
|
||||
body?: {
|
||||
snapshotIds: Array<string>;
|
||||
add?: Array<string>;
|
||||
remove?: Array<string>;
|
||||
set?: Array<string>;
|
||||
};
|
||||
path: {
|
||||
id: string;
|
||||
};
|
||||
query?: never;
|
||||
url: '/api/v1/repositories/{id}/snapshots/tag';
|
||||
};
|
||||
|
||||
export type TagSnapshotsResponses = {
|
||||
/**
|
||||
* Snapshots tagged successfully
|
||||
*/
|
||||
200: {
|
||||
message: string;
|
||||
};
|
||||
};
|
||||
|
||||
export type TagSnapshotsResponse = TagSnapshotsResponses[keyof TagSnapshotsResponses];
|
||||
|
||||
export type ListBackupSchedulesData = {
|
||||
body?: never;
|
||||
path?: never;
|
||||
|
|
@ -1400,7 +1511,9 @@ export type ListBackupSchedulesResponses = {
|
|||
bucket: string;
|
||||
endpoint: string;
|
||||
secretAccessKey: string;
|
||||
cacert?: string;
|
||||
customPassword?: string;
|
||||
insecureTls?: boolean;
|
||||
isExistingRepository?: boolean;
|
||||
} | {
|
||||
accessKeyId: string;
|
||||
|
|
@ -1408,39 +1521,51 @@ export type ListBackupSchedulesResponses = {
|
|||
bucket: string;
|
||||
endpoint: string;
|
||||
secretAccessKey: string;
|
||||
cacert?: string;
|
||||
customPassword?: string;
|
||||
insecureTls?: boolean;
|
||||
isExistingRepository?: boolean;
|
||||
} | {
|
||||
accountKey: string;
|
||||
accountName: string;
|
||||
backend: 'azure';
|
||||
container: string;
|
||||
cacert?: string;
|
||||
customPassword?: string;
|
||||
endpointSuffix?: string;
|
||||
insecureTls?: boolean;
|
||||
isExistingRepository?: boolean;
|
||||
} | {
|
||||
backend: 'gcs';
|
||||
bucket: string;
|
||||
credentialsJson: string;
|
||||
projectId: string;
|
||||
cacert?: string;
|
||||
customPassword?: string;
|
||||
insecureTls?: boolean;
|
||||
isExistingRepository?: boolean;
|
||||
} | {
|
||||
backend: 'local';
|
||||
name: string;
|
||||
cacert?: string;
|
||||
customPassword?: string;
|
||||
insecureTls?: boolean;
|
||||
isExistingRepository?: boolean;
|
||||
path?: string;
|
||||
} | {
|
||||
backend: 'rclone';
|
||||
path: string;
|
||||
remote: string;
|
||||
cacert?: string;
|
||||
customPassword?: string;
|
||||
insecureTls?: boolean;
|
||||
isExistingRepository?: boolean;
|
||||
} | {
|
||||
backend: 'rest';
|
||||
url: string;
|
||||
cacert?: string;
|
||||
customPassword?: string;
|
||||
insecureTls?: boolean;
|
||||
isExistingRepository?: boolean;
|
||||
password?: string;
|
||||
path?: string;
|
||||
|
|
@ -1453,7 +1578,9 @@ export type ListBackupSchedulesResponses = {
|
|||
user: string;
|
||||
port?: number;
|
||||
skipHostKeyCheck?: boolean;
|
||||
cacert?: string;
|
||||
customPassword?: string;
|
||||
insecureTls?: boolean;
|
||||
isExistingRepository?: boolean;
|
||||
knownHosts?: string;
|
||||
};
|
||||
|
|
@ -1662,7 +1789,9 @@ export type GetBackupScheduleResponses = {
|
|||
bucket: string;
|
||||
endpoint: string;
|
||||
secretAccessKey: string;
|
||||
cacert?: string;
|
||||
customPassword?: string;
|
||||
insecureTls?: boolean;
|
||||
isExistingRepository?: boolean;
|
||||
} | {
|
||||
accessKeyId: string;
|
||||
|
|
@ -1670,39 +1799,51 @@ export type GetBackupScheduleResponses = {
|
|||
bucket: string;
|
||||
endpoint: string;
|
||||
secretAccessKey: string;
|
||||
cacert?: string;
|
||||
customPassword?: string;
|
||||
insecureTls?: boolean;
|
||||
isExistingRepository?: boolean;
|
||||
} | {
|
||||
accountKey: string;
|
||||
accountName: string;
|
||||
backend: 'azure';
|
||||
container: string;
|
||||
cacert?: string;
|
||||
customPassword?: string;
|
||||
endpointSuffix?: string;
|
||||
insecureTls?: boolean;
|
||||
isExistingRepository?: boolean;
|
||||
} | {
|
||||
backend: 'gcs';
|
||||
bucket: string;
|
||||
credentialsJson: string;
|
||||
projectId: string;
|
||||
cacert?: string;
|
||||
customPassword?: string;
|
||||
insecureTls?: boolean;
|
||||
isExistingRepository?: boolean;
|
||||
} | {
|
||||
backend: 'local';
|
||||
name: string;
|
||||
cacert?: string;
|
||||
customPassword?: string;
|
||||
insecureTls?: boolean;
|
||||
isExistingRepository?: boolean;
|
||||
path?: string;
|
||||
} | {
|
||||
backend: 'rclone';
|
||||
path: string;
|
||||
remote: string;
|
||||
cacert?: string;
|
||||
customPassword?: string;
|
||||
insecureTls?: boolean;
|
||||
isExistingRepository?: boolean;
|
||||
} | {
|
||||
backend: 'rest';
|
||||
url: string;
|
||||
cacert?: string;
|
||||
customPassword?: string;
|
||||
insecureTls?: boolean;
|
||||
isExistingRepository?: boolean;
|
||||
password?: string;
|
||||
path?: string;
|
||||
|
|
@ -1715,7 +1856,9 @@ export type GetBackupScheduleResponses = {
|
|||
user: string;
|
||||
port?: number;
|
||||
skipHostKeyCheck?: boolean;
|
||||
cacert?: string;
|
||||
customPassword?: string;
|
||||
insecureTls?: boolean;
|
||||
isExistingRepository?: boolean;
|
||||
knownHosts?: string;
|
||||
};
|
||||
|
|
@ -1905,7 +2048,9 @@ export type GetBackupScheduleForVolumeResponses = {
|
|||
bucket: string;
|
||||
endpoint: string;
|
||||
secretAccessKey: string;
|
||||
cacert?: string;
|
||||
customPassword?: string;
|
||||
insecureTls?: boolean;
|
||||
isExistingRepository?: boolean;
|
||||
} | {
|
||||
accessKeyId: string;
|
||||
|
|
@ -1913,39 +2058,51 @@ export type GetBackupScheduleForVolumeResponses = {
|
|||
bucket: string;
|
||||
endpoint: string;
|
||||
secretAccessKey: string;
|
||||
cacert?: string;
|
||||
customPassword?: string;
|
||||
insecureTls?: boolean;
|
||||
isExistingRepository?: boolean;
|
||||
} | {
|
||||
accountKey: string;
|
||||
accountName: string;
|
||||
backend: 'azure';
|
||||
container: string;
|
||||
cacert?: string;
|
||||
customPassword?: string;
|
||||
endpointSuffix?: string;
|
||||
insecureTls?: boolean;
|
||||
isExistingRepository?: boolean;
|
||||
} | {
|
||||
backend: 'gcs';
|
||||
bucket: string;
|
||||
credentialsJson: string;
|
||||
projectId: string;
|
||||
cacert?: string;
|
||||
customPassword?: string;
|
||||
insecureTls?: boolean;
|
||||
isExistingRepository?: boolean;
|
||||
} | {
|
||||
backend: 'local';
|
||||
name: string;
|
||||
cacert?: string;
|
||||
customPassword?: string;
|
||||
insecureTls?: boolean;
|
||||
isExistingRepository?: boolean;
|
||||
path?: string;
|
||||
} | {
|
||||
backend: 'rclone';
|
||||
path: string;
|
||||
remote: string;
|
||||
cacert?: string;
|
||||
customPassword?: string;
|
||||
insecureTls?: boolean;
|
||||
isExistingRepository?: boolean;
|
||||
} | {
|
||||
backend: 'rest';
|
||||
url: string;
|
||||
cacert?: string;
|
||||
customPassword?: string;
|
||||
insecureTls?: boolean;
|
||||
isExistingRepository?: boolean;
|
||||
password?: string;
|
||||
path?: string;
|
||||
|
|
@ -1958,7 +2115,9 @@ export type GetBackupScheduleForVolumeResponses = {
|
|||
user: string;
|
||||
port?: number;
|
||||
skipHostKeyCheck?: boolean;
|
||||
cacert?: string;
|
||||
customPassword?: string;
|
||||
insecureTls?: boolean;
|
||||
isExistingRepository?: boolean;
|
||||
knownHosts?: string;
|
||||
};
|
||||
|
|
@ -2151,10 +2310,20 @@ export type GetScheduleNotificationsResponses = {
|
|||
useTLS: boolean;
|
||||
password?: string;
|
||||
username?: string;
|
||||
} | {
|
||||
method: 'GET' | 'POST';
|
||||
type: 'generic';
|
||||
url: string;
|
||||
contentType?: string;
|
||||
headers?: Array<string>;
|
||||
messageKey?: string;
|
||||
titleKey?: string;
|
||||
useJson?: boolean;
|
||||
} | {
|
||||
priority: 'default' | 'high' | 'low' | 'max' | 'min';
|
||||
topic: string;
|
||||
type: 'ntfy';
|
||||
accessToken?: string;
|
||||
password?: string;
|
||||
serverUrl?: string;
|
||||
username?: string;
|
||||
|
|
@ -2184,7 +2353,7 @@ export type GetScheduleNotificationsResponses = {
|
|||
enabled: boolean;
|
||||
id: number;
|
||||
name: string;
|
||||
type: 'custom' | 'discord' | 'email' | 'gotify' | 'ntfy' | 'pushover' | 'slack' | 'telegram';
|
||||
type: 'custom' | 'discord' | 'email' | 'generic' | 'gotify' | 'ntfy' | 'pushover' | 'slack' | 'telegram';
|
||||
updatedAt: number;
|
||||
};
|
||||
destinationId: number;
|
||||
|
|
@ -2241,10 +2410,20 @@ export type UpdateScheduleNotificationsResponses = {
|
|||
useTLS: boolean;
|
||||
password?: string;
|
||||
username?: string;
|
||||
} | {
|
||||
method: 'GET' | 'POST';
|
||||
type: 'generic';
|
||||
url: string;
|
||||
contentType?: string;
|
||||
headers?: Array<string>;
|
||||
messageKey?: string;
|
||||
titleKey?: string;
|
||||
useJson?: boolean;
|
||||
} | {
|
||||
priority: 'default' | 'high' | 'low' | 'max' | 'min';
|
||||
topic: string;
|
||||
type: 'ntfy';
|
||||
accessToken?: string;
|
||||
password?: string;
|
||||
serverUrl?: string;
|
||||
username?: string;
|
||||
|
|
@ -2274,7 +2453,7 @@ export type UpdateScheduleNotificationsResponses = {
|
|||
enabled: boolean;
|
||||
id: number;
|
||||
name: string;
|
||||
type: 'custom' | 'discord' | 'email' | 'gotify' | 'ntfy' | 'pushover' | 'slack' | 'telegram';
|
||||
type: 'custom' | 'discord' | 'email' | 'generic' | 'gotify' | 'ntfy' | 'pushover' | 'slack' | 'telegram';
|
||||
updatedAt: number;
|
||||
};
|
||||
destinationId: number;
|
||||
|
|
@ -2315,7 +2494,9 @@ export type GetScheduleMirrorsResponses = {
|
|||
bucket: string;
|
||||
endpoint: string;
|
||||
secretAccessKey: string;
|
||||
cacert?: string;
|
||||
customPassword?: string;
|
||||
insecureTls?: boolean;
|
||||
isExistingRepository?: boolean;
|
||||
} | {
|
||||
accessKeyId: string;
|
||||
|
|
@ -2323,39 +2504,51 @@ export type GetScheduleMirrorsResponses = {
|
|||
bucket: string;
|
||||
endpoint: string;
|
||||
secretAccessKey: string;
|
||||
cacert?: string;
|
||||
customPassword?: string;
|
||||
insecureTls?: boolean;
|
||||
isExistingRepository?: boolean;
|
||||
} | {
|
||||
accountKey: string;
|
||||
accountName: string;
|
||||
backend: 'azure';
|
||||
container: string;
|
||||
cacert?: string;
|
||||
customPassword?: string;
|
||||
endpointSuffix?: string;
|
||||
insecureTls?: boolean;
|
||||
isExistingRepository?: boolean;
|
||||
} | {
|
||||
backend: 'gcs';
|
||||
bucket: string;
|
||||
credentialsJson: string;
|
||||
projectId: string;
|
||||
cacert?: string;
|
||||
customPassword?: string;
|
||||
insecureTls?: boolean;
|
||||
isExistingRepository?: boolean;
|
||||
} | {
|
||||
backend: 'local';
|
||||
name: string;
|
||||
cacert?: string;
|
||||
customPassword?: string;
|
||||
insecureTls?: boolean;
|
||||
isExistingRepository?: boolean;
|
||||
path?: string;
|
||||
} | {
|
||||
backend: 'rclone';
|
||||
path: string;
|
||||
remote: string;
|
||||
cacert?: string;
|
||||
customPassword?: string;
|
||||
insecureTls?: boolean;
|
||||
isExistingRepository?: boolean;
|
||||
} | {
|
||||
backend: 'rest';
|
||||
url: string;
|
||||
cacert?: string;
|
||||
customPassword?: string;
|
||||
insecureTls?: boolean;
|
||||
isExistingRepository?: boolean;
|
||||
password?: string;
|
||||
path?: string;
|
||||
|
|
@ -2368,7 +2561,9 @@ export type GetScheduleMirrorsResponses = {
|
|||
user: string;
|
||||
port?: number;
|
||||
skipHostKeyCheck?: boolean;
|
||||
cacert?: string;
|
||||
customPassword?: string;
|
||||
insecureTls?: boolean;
|
||||
isExistingRepository?: boolean;
|
||||
knownHosts?: string;
|
||||
};
|
||||
|
|
@ -2421,7 +2616,9 @@ export type UpdateScheduleMirrorsResponses = {
|
|||
bucket: string;
|
||||
endpoint: string;
|
||||
secretAccessKey: string;
|
||||
cacert?: string;
|
||||
customPassword?: string;
|
||||
insecureTls?: boolean;
|
||||
isExistingRepository?: boolean;
|
||||
} | {
|
||||
accessKeyId: string;
|
||||
|
|
@ -2429,39 +2626,51 @@ export type UpdateScheduleMirrorsResponses = {
|
|||
bucket: string;
|
||||
endpoint: string;
|
||||
secretAccessKey: string;
|
||||
cacert?: string;
|
||||
customPassword?: string;
|
||||
insecureTls?: boolean;
|
||||
isExistingRepository?: boolean;
|
||||
} | {
|
||||
accountKey: string;
|
||||
accountName: string;
|
||||
backend: 'azure';
|
||||
container: string;
|
||||
cacert?: string;
|
||||
customPassword?: string;
|
||||
endpointSuffix?: string;
|
||||
insecureTls?: boolean;
|
||||
isExistingRepository?: boolean;
|
||||
} | {
|
||||
backend: 'gcs';
|
||||
bucket: string;
|
||||
credentialsJson: string;
|
||||
projectId: string;
|
||||
cacert?: string;
|
||||
customPassword?: string;
|
||||
insecureTls?: boolean;
|
||||
isExistingRepository?: boolean;
|
||||
} | {
|
||||
backend: 'local';
|
||||
name: string;
|
||||
cacert?: string;
|
||||
customPassword?: string;
|
||||
insecureTls?: boolean;
|
||||
isExistingRepository?: boolean;
|
||||
path?: string;
|
||||
} | {
|
||||
backend: 'rclone';
|
||||
path: string;
|
||||
remote: string;
|
||||
cacert?: string;
|
||||
customPassword?: string;
|
||||
insecureTls?: boolean;
|
||||
isExistingRepository?: boolean;
|
||||
} | {
|
||||
backend: 'rest';
|
||||
url: string;
|
||||
cacert?: string;
|
||||
customPassword?: string;
|
||||
insecureTls?: boolean;
|
||||
isExistingRepository?: boolean;
|
||||
password?: string;
|
||||
path?: string;
|
||||
|
|
@ -2474,7 +2683,9 @@ export type UpdateScheduleMirrorsResponses = {
|
|||
user: string;
|
||||
port?: number;
|
||||
skipHostKeyCheck?: boolean;
|
||||
cacert?: string;
|
||||
customPassword?: string;
|
||||
insecureTls?: boolean;
|
||||
isExistingRepository?: boolean;
|
||||
knownHosts?: string;
|
||||
};
|
||||
|
|
@ -2568,10 +2779,20 @@ export type ListNotificationDestinationsResponses = {
|
|||
useTLS: boolean;
|
||||
password?: string;
|
||||
username?: string;
|
||||
} | {
|
||||
method: 'GET' | 'POST';
|
||||
type: 'generic';
|
||||
url: string;
|
||||
contentType?: string;
|
||||
headers?: Array<string>;
|
||||
messageKey?: string;
|
||||
titleKey?: string;
|
||||
useJson?: boolean;
|
||||
} | {
|
||||
priority: 'default' | 'high' | 'low' | 'max' | 'min';
|
||||
topic: string;
|
||||
type: 'ntfy';
|
||||
accessToken?: string;
|
||||
password?: string;
|
||||
serverUrl?: string;
|
||||
username?: string;
|
||||
|
|
@ -2601,7 +2822,7 @@ export type ListNotificationDestinationsResponses = {
|
|||
enabled: boolean;
|
||||
id: number;
|
||||
name: string;
|
||||
type: 'custom' | 'discord' | 'email' | 'gotify' | 'ntfy' | 'pushover' | 'slack' | 'telegram';
|
||||
type: 'custom' | 'discord' | 'email' | 'generic' | 'gotify' | 'ntfy' | 'pushover' | 'slack' | 'telegram';
|
||||
updatedAt: number;
|
||||
}>;
|
||||
};
|
||||
|
|
@ -2629,10 +2850,20 @@ export type CreateNotificationDestinationData = {
|
|||
useTLS: boolean;
|
||||
password?: string;
|
||||
username?: string;
|
||||
} | {
|
||||
method: 'GET' | 'POST';
|
||||
type: 'generic';
|
||||
url: string;
|
||||
contentType?: string;
|
||||
headers?: Array<string>;
|
||||
messageKey?: string;
|
||||
titleKey?: string;
|
||||
useJson?: boolean;
|
||||
} | {
|
||||
priority: 'default' | 'high' | 'low' | 'max' | 'min';
|
||||
topic: string;
|
||||
type: 'ntfy';
|
||||
accessToken?: string;
|
||||
password?: string;
|
||||
serverUrl?: string;
|
||||
username?: string;
|
||||
|
|
@ -2689,10 +2920,20 @@ export type CreateNotificationDestinationResponses = {
|
|||
useTLS: boolean;
|
||||
password?: string;
|
||||
username?: string;
|
||||
} | {
|
||||
method: 'GET' | 'POST';
|
||||
type: 'generic';
|
||||
url: string;
|
||||
contentType?: string;
|
||||
headers?: Array<string>;
|
||||
messageKey?: string;
|
||||
titleKey?: string;
|
||||
useJson?: boolean;
|
||||
} | {
|
||||
priority: 'default' | 'high' | 'low' | 'max' | 'min';
|
||||
topic: string;
|
||||
type: 'ntfy';
|
||||
accessToken?: string;
|
||||
password?: string;
|
||||
serverUrl?: string;
|
||||
username?: string;
|
||||
|
|
@ -2722,7 +2963,7 @@ export type CreateNotificationDestinationResponses = {
|
|||
enabled: boolean;
|
||||
id: number;
|
||||
name: string;
|
||||
type: 'custom' | 'discord' | 'email' | 'gotify' | 'ntfy' | 'pushover' | 'slack' | 'telegram';
|
||||
type: 'custom' | 'discord' | 'email' | 'generic' | 'gotify' | 'ntfy' | 'pushover' | 'slack' | 'telegram';
|
||||
updatedAt: number;
|
||||
};
|
||||
};
|
||||
|
|
@ -2796,10 +3037,20 @@ export type GetNotificationDestinationResponses = {
|
|||
useTLS: boolean;
|
||||
password?: string;
|
||||
username?: string;
|
||||
} | {
|
||||
method: 'GET' | 'POST';
|
||||
type: 'generic';
|
||||
url: string;
|
||||
contentType?: string;
|
||||
headers?: Array<string>;
|
||||
messageKey?: string;
|
||||
titleKey?: string;
|
||||
useJson?: boolean;
|
||||
} | {
|
||||
priority: 'default' | 'high' | 'low' | 'max' | 'min';
|
||||
topic: string;
|
||||
type: 'ntfy';
|
||||
accessToken?: string;
|
||||
password?: string;
|
||||
serverUrl?: string;
|
||||
username?: string;
|
||||
|
|
@ -2829,7 +3080,7 @@ export type GetNotificationDestinationResponses = {
|
|||
enabled: boolean;
|
||||
id: number;
|
||||
name: string;
|
||||
type: 'custom' | 'discord' | 'email' | 'gotify' | 'ntfy' | 'pushover' | 'slack' | 'telegram';
|
||||
type: 'custom' | 'discord' | 'email' | 'generic' | 'gotify' | 'ntfy' | 'pushover' | 'slack' | 'telegram';
|
||||
updatedAt: number;
|
||||
};
|
||||
};
|
||||
|
|
@ -2857,10 +3108,20 @@ export type UpdateNotificationDestinationData = {
|
|||
useTLS: boolean;
|
||||
password?: string;
|
||||
username?: string;
|
||||
} | {
|
||||
method: 'GET' | 'POST';
|
||||
type: 'generic';
|
||||
url: string;
|
||||
contentType?: string;
|
||||
headers?: Array<string>;
|
||||
messageKey?: string;
|
||||
titleKey?: string;
|
||||
useJson?: boolean;
|
||||
} | {
|
||||
priority: 'default' | 'high' | 'low' | 'max' | 'min';
|
||||
topic: string;
|
||||
type: 'ntfy';
|
||||
accessToken?: string;
|
||||
password?: string;
|
||||
serverUrl?: string;
|
||||
username?: string;
|
||||
|
|
@ -2927,10 +3188,20 @@ export type UpdateNotificationDestinationResponses = {
|
|||
useTLS: boolean;
|
||||
password?: string;
|
||||
username?: string;
|
||||
} | {
|
||||
method: 'GET' | 'POST';
|
||||
type: 'generic';
|
||||
url: string;
|
||||
contentType?: string;
|
||||
headers?: Array<string>;
|
||||
messageKey?: string;
|
||||
titleKey?: string;
|
||||
useJson?: boolean;
|
||||
} | {
|
||||
priority: 'default' | 'high' | 'low' | 'max' | 'min';
|
||||
topic: string;
|
||||
type: 'ntfy';
|
||||
accessToken?: string;
|
||||
password?: string;
|
||||
serverUrl?: string;
|
||||
username?: string;
|
||||
|
|
@ -2960,7 +3231,7 @@ export type UpdateNotificationDestinationResponses = {
|
|||
enabled: boolean;
|
||||
id: number;
|
||||
name: string;
|
||||
type: 'custom' | 'discord' | 'email' | 'gotify' | 'ntfy' | 'pushover' | 'slack' | 'telegram';
|
||||
type: 'custom' | 'discord' | 'email' | 'generic' | 'gotify' | 'ntfy' | 'pushover' | 'slack' | 'telegram';
|
||||
updatedAt: number;
|
||||
};
|
||||
};
|
||||
|
|
@ -3023,6 +3294,32 @@ export type GetSystemInfoResponses = {
|
|||
|
||||
export type GetSystemInfoResponse = GetSystemInfoResponses[keyof GetSystemInfoResponses];
|
||||
|
||||
export type GetUpdatesData = {
|
||||
body?: never;
|
||||
path?: never;
|
||||
query?: never;
|
||||
url: '/api/v1/system/updates';
|
||||
};
|
||||
|
||||
export type GetUpdatesResponses = {
|
||||
/**
|
||||
* Update information and missed releases
|
||||
*/
|
||||
200: {
|
||||
currentVersion: string;
|
||||
hasUpdate: boolean;
|
||||
latestVersion: string;
|
||||
missedReleases: Array<{
|
||||
body: string;
|
||||
publishedAt: string;
|
||||
url: string;
|
||||
version: string;
|
||||
}>;
|
||||
};
|
||||
};
|
||||
|
||||
export type GetUpdatesResponse = GetUpdatesResponses[keyof GetUpdatesResponses];
|
||||
|
||||
export type DownloadResticPasswordData = {
|
||||
body?: {
|
||||
password: string;
|
||||
|
|
|
|||
166
app/client/components/__test__/file-tree.test.tsx
Normal file
166
app/client/components/__test__/file-tree.test.tsx
Normal file
|
|
@ -0,0 +1,166 @@
|
|||
/** biome-ignore-all lint/style/noNonNullAssertion: Testing file - non-null assertions are acceptable here */
|
||||
import { expect, test, describe } from "bun:test";
|
||||
import { render, screen, fireEvent } from "@testing-library/react";
|
||||
import { FileTree, type FileEntry } from "../file-tree";
|
||||
|
||||
describe("FileTree Selection Logic", () => {
|
||||
const testFiles: FileEntry[] = [
|
||||
{ name: "root", path: "/root", type: "folder" },
|
||||
{ name: "photos", path: "/root/photos", type: "folder" },
|
||||
{ name: "backups", path: "/root/photos/backups", type: "folder" },
|
||||
{ name: "library", path: "/root/photos/library", type: "folder" },
|
||||
{ name: "profile", path: "/root/photos/profile", type: "folder" },
|
||||
{ name: "upload", path: "/root/photos/upload", type: "folder" },
|
||||
];
|
||||
|
||||
test("selecting a folder simplifies to parent if it's the only child", async () => {
|
||||
let currentSelection = new Set<string>();
|
||||
const onSelectionChange = (selection: Set<string>) => {
|
||||
currentSelection = selection;
|
||||
};
|
||||
|
||||
render(
|
||||
<FileTree
|
||||
files={testFiles}
|
||||
withCheckboxes={true}
|
||||
selectedPaths={currentSelection}
|
||||
onSelectionChange={onSelectionChange}
|
||||
expandedFolders={new Set(testFiles.map((f) => f.path))}
|
||||
/>,
|
||||
);
|
||||
|
||||
const photosCheckbox = screen.getByText("photos").parentElement?.querySelector('button[role="checkbox"]');
|
||||
expect(photosCheckbox).toBeTruthy();
|
||||
|
||||
fireEvent.click(photosCheckbox!);
|
||||
|
||||
expect(currentSelection.has("/root")).toBe(true);
|
||||
expect(currentSelection.size).toBe(1);
|
||||
});
|
||||
|
||||
test("unselecting a child removes the parent from selection", async () => {
|
||||
let currentSelection = new Set<string>(["/root"]);
|
||||
const onSelectionChange = (selection: Set<string>) => {
|
||||
currentSelection = selection;
|
||||
};
|
||||
|
||||
render(
|
||||
<FileTree
|
||||
files={testFiles}
|
||||
withCheckboxes={true}
|
||||
selectedPaths={currentSelection}
|
||||
onSelectionChange={onSelectionChange}
|
||||
expandedFolders={new Set(testFiles.map((f) => f.path))}
|
||||
/>,
|
||||
);
|
||||
|
||||
const libraryCheckbox = screen.getByText("library").parentElement?.querySelector('button[role="checkbox"]');
|
||||
fireEvent.click(libraryCheckbox!);
|
||||
|
||||
expect(currentSelection.has("/root")).toBe(false);
|
||||
expect(currentSelection.has("/root/photos")).toBe(false);
|
||||
|
||||
expect(currentSelection.has("/root/photos/backups")).toBe(true);
|
||||
expect(currentSelection.has("/root/photos/profile")).toBe(true);
|
||||
expect(currentSelection.has("/root/photos/upload")).toBe(true);
|
||||
expect(currentSelection.size).toBe(3);
|
||||
});
|
||||
|
||||
test("recursive simplification when all children are selected", async () => {
|
||||
let currentSelection = new Set<string>();
|
||||
const onSelectionChange = (selection: Set<string>) => {
|
||||
currentSelection = selection;
|
||||
};
|
||||
|
||||
const { rerender } = render(
|
||||
<FileTree
|
||||
files={testFiles}
|
||||
withCheckboxes={true}
|
||||
selectedPaths={currentSelection}
|
||||
onSelectionChange={onSelectionChange}
|
||||
expandedFolders={new Set(testFiles.map((f) => f.path))}
|
||||
/>,
|
||||
);
|
||||
|
||||
const children = ["backups", "library", "profile", "upload"];
|
||||
|
||||
for (const name of children) {
|
||||
const checkbox = screen.getByText(name).parentElement?.querySelector('button[role="checkbox"]');
|
||||
fireEvent.click(checkbox!);
|
||||
|
||||
rerender(
|
||||
<FileTree
|
||||
files={testFiles}
|
||||
withCheckboxes={true}
|
||||
selectedPaths={currentSelection}
|
||||
onSelectionChange={onSelectionChange}
|
||||
expandedFolders={new Set(testFiles.map((f) => f.path))}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
|
||||
expect(currentSelection.has("/root")).toBe(true);
|
||||
expect(currentSelection.size).toBe(1);
|
||||
});
|
||||
|
||||
test("does not simplify to parent if not all children are selected", async () => {
|
||||
const multipleFiles: FileEntry[] = [
|
||||
{ name: "root", path: "/root", type: "folder" },
|
||||
{ name: "child1", path: "/root/child1", type: "folder" },
|
||||
{ name: "child2", path: "/root/child2", type: "folder" },
|
||||
];
|
||||
|
||||
let currentSelection = new Set<string>();
|
||||
const onSelectionChange = (selection: Set<string>) => {
|
||||
currentSelection = selection;
|
||||
};
|
||||
|
||||
render(
|
||||
<FileTree
|
||||
files={multipleFiles}
|
||||
withCheckboxes={true}
|
||||
selectedPaths={currentSelection}
|
||||
onSelectionChange={onSelectionChange}
|
||||
expandedFolders={new Set(multipleFiles.map((f) => f.path))}
|
||||
/>,
|
||||
);
|
||||
|
||||
const child1Checkbox = screen.getByText("child1").parentElement?.querySelector('button[role="checkbox"]');
|
||||
fireEvent.click(child1Checkbox!);
|
||||
|
||||
expect(currentSelection.has("/root/child1")).toBe(true);
|
||||
expect(currentSelection.has("/root")).toBe(false);
|
||||
expect(currentSelection.size).toBe(1);
|
||||
});
|
||||
|
||||
test("simplifies existing deep paths when parent is selected", async () => {
|
||||
const files: FileEntry[] = [
|
||||
{ name: "hello", path: "/hello", type: "folder" },
|
||||
{ name: "hello_prev", path: "/hello_prev", type: "folder" },
|
||||
{ name: "service", path: "/service", type: "folder" },
|
||||
];
|
||||
|
||||
let currentSelection = new Set<string>(["/hello", "/hello_prev", "/service/app/data/upload"]);
|
||||
const onSelectionChange = (selection: Set<string>) => {
|
||||
currentSelection = selection;
|
||||
};
|
||||
|
||||
render(
|
||||
<FileTree
|
||||
files={files}
|
||||
withCheckboxes={true}
|
||||
selectedPaths={currentSelection}
|
||||
onSelectionChange={onSelectionChange}
|
||||
/>,
|
||||
);
|
||||
|
||||
const serviceCheckbox = screen.getByText("service").parentElement?.querySelector('button[role="checkbox"]');
|
||||
expect(serviceCheckbox).toBeTruthy();
|
||||
|
||||
fireEvent.click(serviceCheckbox!);
|
||||
|
||||
expect(currentSelection.has("/service")).toBe(true);
|
||||
expect(currentSelection.has("/service/app/data/upload")).toBe(false);
|
||||
expect(currentSelection.size).toBe(3); // /hello, /hello_prev, /service
|
||||
});
|
||||
});
|
||||
|
|
@ -38,7 +38,7 @@ export function AppBreadcrumb() {
|
|||
}
|
||||
|
||||
return (
|
||||
<Breadcrumb>
|
||||
<Breadcrumb className="min-w-0">
|
||||
<BreadcrumbList>
|
||||
{breadcrumbs.map((breadcrumb, index) => {
|
||||
const isLast = index === breadcrumbs.length - 1;
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { Bell, CalendarClock, Database, HardDrive, Settings } from "lucide-react";
|
||||
import { Link, NavLink } from "react-router";
|
||||
import { useState } from "react";
|
||||
import {
|
||||
Sidebar,
|
||||
SidebarContent,
|
||||
|
|
@ -15,6 +16,8 @@ import {
|
|||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "~/client/components/ui/tooltip";
|
||||
import { cn } from "~/client/lib/utils";
|
||||
import { APP_VERSION } from "~/client/lib/version";
|
||||
import { useUpdates } from "~/client/hooks/use-updates";
|
||||
import { ReleaseNotesDialog } from "./release-notes-dialog";
|
||||
|
||||
const items = [
|
||||
{
|
||||
|
|
@ -46,6 +49,9 @@ const items = [
|
|||
|
||||
export function AppSidebar() {
|
||||
const { state } = useSidebar();
|
||||
const { updates, hasUpdate } = useUpdates();
|
||||
const [showReleaseNotes, setShowReleaseNotes] = useState(false);
|
||||
|
||||
const displayVersion = APP_VERSION.startsWith("v") || APP_VERSION === "dev" ? APP_VERSION : `v${APP_VERSION}`;
|
||||
const releaseUrl =
|
||||
APP_VERSION === "dev"
|
||||
|
|
@ -98,16 +104,28 @@ export function AppSidebar() {
|
|||
</SidebarGroup>
|
||||
</SidebarContent>
|
||||
<SidebarFooter className="p-4 border-r border-t border-border/50">
|
||||
<a
|
||||
href={releaseUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className={cn("text-xs text-muted-foreground transition-all duration-200 hover:text-foreground", {
|
||||
"opacity-0 w-0 overflow-hidden": state === "collapsed",
|
||||
})}
|
||||
>
|
||||
{displayVersion}
|
||||
</a>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<a
|
||||
href={releaseUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={cn("text-xs text-muted-foreground hover:text-foreground", {
|
||||
"opacity-0 w-0 overflow-hidden": state === "collapsed",
|
||||
})}
|
||||
>
|
||||
{displayVersion}
|
||||
</a>
|
||||
{hasUpdate && state !== "collapsed" && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowReleaseNotes(true)}
|
||||
className="text-[10px] font-medium text-destructive hover:underline cursor-pointer"
|
||||
>
|
||||
Update available
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<ReleaseNotesDialog open={showReleaseNotes} onOpenChange={setShowReleaseNotes} updates={updates} />
|
||||
</SidebarFooter>
|
||||
</Sidebar>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -116,13 +116,15 @@ export const FileTree = memo((props: Props) => {
|
|||
// Add new folders to collapsed set when file list changes
|
||||
useEffect(() => {
|
||||
setCollapsedFolders((prevSet) => {
|
||||
let hasChanges = false;
|
||||
const newSet = new Set(prevSet);
|
||||
for (const item of fileList) {
|
||||
if (item.kind === "folder" && !newSet.has(item.fullPath) && !expandedFolders.has(item.fullPath)) {
|
||||
newSet.add(item.fullPath);
|
||||
hasChanges = true;
|
||||
}
|
||||
}
|
||||
return newSet;
|
||||
return hasChanges ? newSet : prevSet;
|
||||
});
|
||||
}, [fileList, expandedFolders]);
|
||||
|
||||
|
|
@ -149,9 +151,9 @@ export const FileTree = memo((props: Props) => {
|
|||
newSelection.add(path);
|
||||
|
||||
// Remove any descendants from selection since parent now covers them
|
||||
for (const item of fileList) {
|
||||
if (item.fullPath.startsWith(`${path}/`)) {
|
||||
newSelection.delete(item.fullPath);
|
||||
for (const selectedPath of newSelection) {
|
||||
if (selectedPath.startsWith(`${path}/`)) {
|
||||
newSelection.delete(selectedPath);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
|
|
@ -182,7 +184,8 @@ export const FileTree = memo((props: Props) => {
|
|||
if (
|
||||
item.fullPath.startsWith(`${selectedParentPath}/`) &&
|
||||
!item.fullPath.startsWith(`${path}/`) &&
|
||||
item.fullPath !== path
|
||||
item.fullPath !== path &&
|
||||
!path.startsWith(`${item.fullPath}/`)
|
||||
) {
|
||||
newSelection.add(item.fullPath);
|
||||
}
|
||||
|
|
@ -190,39 +193,45 @@ export const FileTree = memo((props: Props) => {
|
|||
}
|
||||
}
|
||||
|
||||
const childrenByParent = new Map<string, string[]>();
|
||||
for (const selectedPath of newSelection) {
|
||||
const lastSlashIndex = selectedPath.lastIndexOf("/");
|
||||
if (lastSlashIndex > 0) {
|
||||
const parentPath = selectedPath.slice(0, lastSlashIndex);
|
||||
if (!childrenByParent.has(parentPath)) {
|
||||
childrenByParent.set(parentPath, []);
|
||||
}
|
||||
childrenByParent.get(parentPath)?.push(selectedPath);
|
||||
}
|
||||
}
|
||||
|
||||
// For each parent, check if all its children are selected
|
||||
for (const [parentPath, selectedChildren] of childrenByParent.entries()) {
|
||||
// Get all children of this parent from the file list
|
||||
const allChildren = fileList.filter((item) => {
|
||||
const itemParentPath = item.fullPath.slice(0, item.fullPath.lastIndexOf("/"));
|
||||
return itemParentPath === parentPath;
|
||||
});
|
||||
|
||||
// If all children are selected, replace them with the parent
|
||||
if (allChildren.length > 0 && selectedChildren.length === allChildren.length) {
|
||||
// Check that we have every child
|
||||
const allChildrenPaths = new Set(allChildren.map((c) => c.fullPath));
|
||||
const allChildrenSelected = selectedChildren.every((c) => allChildrenPaths.has(c));
|
||||
|
||||
if (allChildrenSelected) {
|
||||
// Remove all children
|
||||
for (const childPath of selectedChildren) {
|
||||
newSelection.delete(childPath);
|
||||
let changed = true;
|
||||
while (changed) {
|
||||
changed = false;
|
||||
const childrenByParent = new Map<string, string[]>();
|
||||
for (const selectedPath of newSelection) {
|
||||
const lastSlashIndex = selectedPath.lastIndexOf("/");
|
||||
if (lastSlashIndex > 0) {
|
||||
const parentPath = selectedPath.slice(0, lastSlashIndex);
|
||||
if (!childrenByParent.has(parentPath)) {
|
||||
childrenByParent.set(parentPath, []);
|
||||
}
|
||||
childrenByParent.get(parentPath)?.push(selectedPath);
|
||||
}
|
||||
}
|
||||
|
||||
// For each parent, check if all its children are selected
|
||||
for (const [parentPath, selectedChildren] of childrenByParent.entries()) {
|
||||
// Get all children of this parent from the file list
|
||||
const allChildren = fileList.filter((item) => {
|
||||
const itemParentPath = item.fullPath.slice(0, item.fullPath.lastIndexOf("/"));
|
||||
return itemParentPath === parentPath;
|
||||
});
|
||||
|
||||
// If all children are selected, replace them with the parent
|
||||
if (allChildren.length > 0 && selectedChildren.length === allChildren.length) {
|
||||
// Check that we have every child
|
||||
const allChildrenPaths = new Set(allChildren.map((c) => c.fullPath));
|
||||
const allChildrenSelected = selectedChildren.every((c) => allChildrenPaths.has(c));
|
||||
|
||||
if (allChildrenSelected) {
|
||||
// Remove all children
|
||||
for (const childPath of selectedChildren) {
|
||||
newSelection.delete(childPath);
|
||||
}
|
||||
// Add the parent
|
||||
newSelection.add(parentPath);
|
||||
changed = true;
|
||||
break;
|
||||
}
|
||||
// Add the parent
|
||||
newSelection.add(parentPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -481,8 +490,9 @@ const NodeButton = memo(({ depth, icon, onClick, onMouseEnter, className, childr
|
|||
const paddingLeft = useMemo(() => `${8 + depth * NODE_PADDING_LEFT}px`, [depth]);
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
// biome-ignore lint/a11y/noStaticElementInteractions: we handle click and hover manually
|
||||
// biome-ignore lint/a11y/useKeyWithClickEvents: we handle click and hover manually
|
||||
<div
|
||||
className={cn("flex items-center gap-2 w-full pr-2 text-sm py-1.5 text-left", className)}
|
||||
style={{ paddingLeft }}
|
||||
onClick={onClick}
|
||||
|
|
@ -490,7 +500,7 @@ const NodeButton = memo(({ depth, icon, onClick, onMouseEnter, className, childr
|
|||
>
|
||||
{icon}
|
||||
<div className="truncate w-full flex items-center gap-2">{children}</div>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -43,8 +43,8 @@ export default function Layout({ loaderData }: Route.ComponentProps) {
|
|||
<AppSidebar />
|
||||
<div className="w-full relative flex flex-col h-screen overflow-hidden">
|
||||
<header className="z-50 bg-card-header border-b border-border/50 shrink-0">
|
||||
<div className="flex items-center justify-between py-3 sm:py-4 px-2 sm:px-8 mx-auto container">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex items-center justify-between py-3 sm:py-4 px-2 sm:px-8 mx-auto container gap-4">
|
||||
<div className="flex items-center gap-4 min-w-0">
|
||||
<SidebarTrigger />
|
||||
<AppBreadcrumb />
|
||||
</div>
|
||||
|
|
@ -76,7 +76,7 @@ export default function Layout({ loaderData }: Route.ComponentProps) {
|
|||
</header>
|
||||
<div className="main-content flex-1 overflow-y-auto">
|
||||
<GridBackground>
|
||||
<main className="flex flex-col p-2 pb-6 pt-2 sm:p-8 sm:pt-6 mx-auto">
|
||||
<main className="flex flex-col p-2 pb-6 pt-2 sm:p-8 sm:pt-6 mx-auto @container">
|
||||
<Outlet />
|
||||
</main>
|
||||
</GridBackground>
|
||||
|
|
|
|||
44
app/client/components/release-notes-dialog.tsx
Normal file
44
app/client/components/release-notes-dialog.tsx
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
import { format } from "date-fns";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import remarkGfm from "remark-gfm";
|
||||
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "~/client/components/ui/dialog";
|
||||
import { ScrollArea } from "~/client/components/ui/scroll-area";
|
||||
import type { UpdateInfoDto } from "~/server/modules/system/system.dto";
|
||||
|
||||
interface ReleaseNotesDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
updates: UpdateInfoDto | undefined;
|
||||
}
|
||||
|
||||
export function ReleaseNotesDialog({ open, onOpenChange, updates }: ReleaseNotesDialogProps) {
|
||||
if (!updates) return null;
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-h-[80vh] flex flex-col p-0">
|
||||
<DialogHeader className="p-6 pb-2">
|
||||
<DialogTitle className="flex items-center gap-2">Release Notes</DialogTitle>
|
||||
<DialogDescription>
|
||||
Current version: {updates.currentVersion} • Latest version: {updates.latestVersion}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<ScrollArea className="p-6 pt-2 h-[50vh] min-w-0">
|
||||
<div className="space-y-8">
|
||||
{updates.missedReleases.map((release) => (
|
||||
<div key={release.version} className="space-y-4">
|
||||
<div className="flex items-center justify-between border-b pb-2">
|
||||
<h3 className="text-lg font-bold text-foreground">{release.version}</h3>
|
||||
<span className="text-sm text-muted-foreground">{format(new Date(release.publishedAt), "PPP")}</span>
|
||||
</div>
|
||||
<div className="prose prose-sm dark:prose-invert max-w-none prose-pre:bg-muted prose-pre:text-muted-foreground prose-a:text-primary hover:prose-a:underline wrap-anywhere text-wrap prose-pre:whitespace-pre-wrap prose-pre:wrap-anywhere">
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]}>{release.body}</ReactMarkdown>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,11 +1,12 @@
|
|||
import { useState } from "react";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { Calendar, Clock, Database, HardDrive, Server, Trash2 } from "lucide-react";
|
||||
import { Calendar, Clock, Database, HardDrive, Tag, Trash2, X } from "lucide-react";
|
||||
import { Link, useNavigate } from "react-router";
|
||||
import { toast } from "sonner";
|
||||
import { ByteSize } from "~/client/components/bytes-size";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "~/client/components/ui/table";
|
||||
import { Button } from "~/client/components/ui/button";
|
||||
import { Checkbox } from "~/client/components/ui/checkbox";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
|
|
@ -16,8 +17,17 @@ import {
|
|||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "~/client/components/ui/alert-dialog";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "~/client/components/ui/dialog";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "~/client/components/ui/select";
|
||||
import { formatDuration } from "~/utils/utils";
|
||||
import { deleteSnapshotMutation } from "~/client/api-client/@tanstack/react-query.gen";
|
||||
import { deleteSnapshotsMutation, tagSnapshotsMutation } from "~/client/api-client/@tanstack/react-query.gen";
|
||||
import { parseError } from "~/client/lib/errors";
|
||||
import type { BackupSchedule, Snapshot } from "../lib/types";
|
||||
import { cn } from "../lib/utils";
|
||||
|
|
@ -31,68 +41,126 @@ type Props = {
|
|||
export const SnapshotsTable = ({ snapshots, repositoryId, backups }: Props) => {
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
|
||||
const [snapshotToDelete, setSnapshotToDelete] = useState<string | null>(null);
|
||||
|
||||
const deleteSnapshot = useMutation({
|
||||
...deleteSnapshotMutation(),
|
||||
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
|
||||
const [showBulkDeleteConfirm, setShowBulkDeleteConfirm] = useState(false);
|
||||
const [showReTagDialog, setShowReTagDialog] = useState(false);
|
||||
const [targetScheduleId, setTargetScheduleId] = useState<string>("");
|
||||
|
||||
const deleteSnapshots = useMutation({
|
||||
...deleteSnapshotsMutation(),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["listSnapshots"] });
|
||||
setShowDeleteConfirm(false);
|
||||
setSnapshotToDelete(null);
|
||||
setShowBulkDeleteConfirm(false);
|
||||
setSelectedIds(new Set());
|
||||
},
|
||||
});
|
||||
|
||||
const handleDeleteClick = (e: React.MouseEvent, snapshotId: string) => {
|
||||
e.stopPropagation();
|
||||
setSnapshotToDelete(snapshotId);
|
||||
setShowDeleteConfirm(true);
|
||||
};
|
||||
|
||||
const handleConfirmDelete = () => {
|
||||
if (snapshotToDelete) {
|
||||
toast.promise(
|
||||
deleteSnapshot.mutateAsync({
|
||||
path: { id: repositoryId, snapshotId: snapshotToDelete },
|
||||
}),
|
||||
{
|
||||
loading: "Deleting snapshot...",
|
||||
success: "Snapshot deleted successfully",
|
||||
error: (error) => parseError(error)?.message || "Failed to delete snapshot",
|
||||
},
|
||||
);
|
||||
}
|
||||
};
|
||||
const tagSnapshots = useMutation({
|
||||
...tagSnapshotsMutation(),
|
||||
onMutate: () => {
|
||||
setShowReTagDialog(false);
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["listSnapshots"] });
|
||||
setShowReTagDialog(false);
|
||||
setSelectedIds(new Set());
|
||||
setTargetScheduleId("");
|
||||
},
|
||||
});
|
||||
|
||||
const handleRowClick = (snapshotId: string) => {
|
||||
navigate(`/repositories/${repositoryId}/${snapshotId}`);
|
||||
};
|
||||
|
||||
const toggleSelectAll = () => {
|
||||
if (selectedIds.size === snapshots.length) {
|
||||
setSelectedIds(new Set());
|
||||
} else {
|
||||
setSelectedIds(new Set(snapshots.map((s) => s.short_id)));
|
||||
}
|
||||
};
|
||||
|
||||
const handleBulkDelete = () => {
|
||||
toast.promise(
|
||||
deleteSnapshots.mutateAsync({
|
||||
path: { id: repositoryId },
|
||||
body: { snapshotIds: Array.from(selectedIds) },
|
||||
}),
|
||||
{
|
||||
loading: `Deleting ${selectedIds.size} snapshots...`,
|
||||
success: "Snapshots deleted successfully",
|
||||
error: (error) => parseError(error)?.message || "Failed to delete snapshots",
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
const handleBulkReTag = () => {
|
||||
const schedule = backups.find((b) => String(b.id) === targetScheduleId);
|
||||
if (!schedule) return;
|
||||
|
||||
toast.promise(
|
||||
tagSnapshots.mutateAsync({
|
||||
path: { id: repositoryId },
|
||||
body: {
|
||||
snapshotIds: Array.from(selectedIds),
|
||||
set: [schedule.shortId],
|
||||
},
|
||||
}),
|
||||
{
|
||||
loading: `Re-tagging ${selectedIds.size} snapshots...`,
|
||||
success: `Snapshots re-tagged to ${schedule.name}`,
|
||||
error: (error) => parseError(error)?.message || "Failed to re-tag snapshots",
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="overflow-x-auto">
|
||||
<div className="overflow-x-auto relative">
|
||||
<Table className="border-t">
|
||||
<TableHeader className="bg-card-header">
|
||||
<TableRow>
|
||||
<TableHead className="w-10">
|
||||
<Checkbox
|
||||
checked={selectedIds.size === snapshots.length && snapshots.length > 0}
|
||||
onCheckedChange={toggleSelectAll}
|
||||
aria-label="Select all"
|
||||
/>
|
||||
</TableHead>
|
||||
<TableHead className="uppercase">Snapshot ID</TableHead>
|
||||
<TableHead className="uppercase">Schedule</TableHead>
|
||||
<TableHead className="uppercase">Date & Time</TableHead>
|
||||
<TableHead className="uppercase">Size</TableHead>
|
||||
<TableHead className="uppercase hidden md:table-cell text-right">Duration</TableHead>
|
||||
<TableHead className="uppercase hidden text-right lg:table-cell">Volume</TableHead>
|
||||
<TableHead className="uppercase text-right">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{snapshots.map((snapshot) => {
|
||||
const backup = backups.find((b) => snapshot.tags.includes(b.shortId));
|
||||
const isSelected = selectedIds.has(snapshot.short_id);
|
||||
|
||||
return (
|
||||
<TableRow
|
||||
key={snapshot.short_id}
|
||||
className="hover:bg-accent/50 cursor-pointer"
|
||||
className={cn("hover:bg-accent/50 cursor-pointer", isSelected && "bg-accent/30")}
|
||||
onClick={() => handleRowClick(snapshot.short_id)}
|
||||
>
|
||||
<TableCell onClick={(e) => e.stopPropagation()}>
|
||||
<Checkbox
|
||||
checked={isSelected}
|
||||
onCheckedChange={() => {
|
||||
const newSelected = new Set(selectedIds);
|
||||
if (newSelected.has(snapshot.short_id)) {
|
||||
newSelected.delete(snapshot.short_id);
|
||||
} else {
|
||||
newSelected.add(snapshot.short_id);
|
||||
}
|
||||
setSelectedIds(newSelected);
|
||||
}}
|
||||
aria-label={`Select snapshot ${snapshot.short_id}`}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className="font-mono text-sm">
|
||||
<div className="flex items-center gap-2">
|
||||
<HardDrive className="h-4 w-4 text-muted-foreground" />
|
||||
|
|
@ -134,32 +202,6 @@ export const SnapshotsTable = ({ snapshots, repositoryId, backups }: Props) => {
|
|||
<span className="text-sm text-muted-foreground">{formatDuration(snapshot.duration / 1000)}</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="hidden lg:table-cell">
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<Server className={cn("h-4 w-4 text-muted-foreground", { hidden: !backup })} />
|
||||
<Link
|
||||
hidden={!backup}
|
||||
to={backup ? `/volumes/${backup.volume.name}` : "#"}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="hover:underline"
|
||||
>
|
||||
<span className="text-sm">{backup ? backup.volume.name : "-"}</span>
|
||||
</Link>
|
||||
<span hidden={!!backup} className="text-sm text-muted-foreground">
|
||||
-
|
||||
</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={(e) => handleDeleteClick(e, snapshot.short_id)}
|
||||
disabled={deleteSnapshot.isPending}
|
||||
>
|
||||
<Trash2 className="h-4 w-4 text-destructive" />
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
|
|
@ -167,27 +209,99 @@ export const SnapshotsTable = ({ snapshots, repositoryId, backups }: Props) => {
|
|||
</Table>
|
||||
</div>
|
||||
|
||||
<AlertDialog open={showDeleteConfirm} onOpenChange={setShowDeleteConfirm}>
|
||||
{selectedIds.size > 0 && (
|
||||
<div className="fixed bottom-6 left-1/2 -translate-x-1/2 z-50 animate-in fade-in slide-in-from-bottom-4 duration-300">
|
||||
<div className="bg-card border shadow-2xl rounded-full px-4 py-2 flex items-center gap-4 min-w-75 justify-between">
|
||||
<div className="flex items-center gap-3 border-r pr-4">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 rounded-full"
|
||||
onClick={() => setSelectedIds(new Set())}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
<span className="text-sm font-medium">{selectedIds.size} selected</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="rounded-full gap-2"
|
||||
onClick={() => setShowReTagDialog(true)}
|
||||
>
|
||||
<Tag className="h-4 w-4 mr-2" />
|
||||
Re-tag
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
className="rounded-full gap-2"
|
||||
onClick={() => setShowBulkDeleteConfirm(true)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4 mr-2" />
|
||||
Delete
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<AlertDialog open={showBulkDeleteConfirm} onOpenChange={setShowBulkDeleteConfirm}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Delete snapshot?</AlertDialogTitle>
|
||||
<AlertDialogTitle>Delete {selectedIds.size} snapshots?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
This action cannot be undone. This will permanently delete the snapshot and all its data from the
|
||||
repository.
|
||||
This action cannot be undone. This will permanently delete the selected snapshots and all their data from
|
||||
the repository.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={handleConfirmDelete}
|
||||
disabled={deleteSnapshot.isPending}
|
||||
onClick={handleBulkDelete}
|
||||
disabled={deleteSnapshots.isPending}
|
||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
>
|
||||
Delete snapshot
|
||||
Delete {selectedIds.size} snapshots
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
|
||||
<Dialog open={showReTagDialog} onOpenChange={setShowReTagDialog}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Re-tag snapshots</DialogTitle>
|
||||
<DialogDescription>
|
||||
Select a backup schedule to re-tag the {selectedIds.size} selected snapshots. All {selectedIds.size}{" "}
|
||||
selected snapshots will be associated with the chosen schedule.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="py-4">
|
||||
<Select value={targetScheduleId} onValueChange={setTargetScheduleId}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select a schedule" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{backups.map((backup) => (
|
||||
<SelectItem key={backup.id} value={String(backup.id)}>
|
||||
{backup.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setShowReTagDialog(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleBulkReTag} disabled={!targetScheduleId}>
|
||||
Apply tags
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -4,8 +4,8 @@ import { ChevronRight, MoreHorizontal } from "lucide-react";
|
|||
|
||||
import { cn } from "~/client/lib/utils";
|
||||
|
||||
function Breadcrumb({ ...props }: React.ComponentProps<"nav">) {
|
||||
return <nav aria-label="breadcrumb" data-slot="breadcrumb" {...props} />;
|
||||
function Breadcrumb({ className, ...props }: React.ComponentProps<"nav">) {
|
||||
return <nav aria-label="breadcrumb" data-slot="breadcrumb" className={cn("min-w-0", className)} {...props} />;
|
||||
}
|
||||
|
||||
function BreadcrumbList({ className, ...props }: React.ComponentProps<"ol">) {
|
||||
|
|
@ -13,7 +13,7 @@ function BreadcrumbList({ className, ...props }: React.ComponentProps<"ol">) {
|
|||
<ol
|
||||
data-slot="breadcrumb-list"
|
||||
className={cn(
|
||||
"text-muted-foreground flex flex-wrap items-center gap-1.5 text-sm wrap-break-words sm:gap-2.5",
|
||||
"text-muted-foreground flex items-center gap-1.5 text-sm sm:gap-2.5 min-w-0",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
|
|
@ -22,7 +22,7 @@ function BreadcrumbList({ className, ...props }: React.ComponentProps<"ol">) {
|
|||
}
|
||||
|
||||
function BreadcrumbItem({ className, ...props }: React.ComponentProps<"li">) {
|
||||
return <li data-slot="breadcrumb-item" className={cn("inline-flex items-center gap-1.5", className)} {...props} />;
|
||||
return <li data-slot="breadcrumb-item" className={cn("inline-flex items-center gap-1.5 min-w-0", className)} {...props} />;
|
||||
}
|
||||
|
||||
function BreadcrumbLink({
|
||||
|
|
@ -35,7 +35,11 @@ function BreadcrumbLink({
|
|||
const Comp = asChild ? Slot : "a";
|
||||
|
||||
return (
|
||||
<Comp data-slot="breadcrumb-link" className={cn("hover:text-foreground transition-colors", className)} {...props} />
|
||||
<Comp
|
||||
data-slot="breadcrumb-link"
|
||||
className={cn("hover:text-foreground transition-colors truncate", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -46,7 +50,7 @@ function BreadcrumbPage({ className, ...props }: React.ComponentProps<"span">) {
|
|||
role="link"
|
||||
aria-disabled="true"
|
||||
aria-current="page"
|
||||
className={cn("text-foreground font-normal", className)}
|
||||
className={cn("text-foreground font-normal truncate", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -10,14 +10,14 @@ function Card({ className, children, ...props }: React.ComponentProps<"div">) {
|
|||
{...props}
|
||||
>
|
||||
<span aria-hidden="true" className="pointer-events-none absolute inset-0 z-10 select-none">
|
||||
<span className="absolute left-[-2px] top-[-2px] h-0.5 w-4 bg-white/80" />
|
||||
<span className="absolute left-[-2px] top-[-2px] h-4 w-0.5 bg-white/80" />
|
||||
<span className="absolute right-[-2px] top-[-2px] h-0.5 w-4 bg-white/80" />
|
||||
<span className="absolute right-[-2px] top-[-2px] h-4 w-0.5 bg-white/80" />
|
||||
<span className="absolute left-[-2px] bottom-[-2px] h-0.5 w-4 bg-white/80" />
|
||||
<span className="absolute left-[-2px] bottom-[-2px] h-4 w-0.5 bg-white/80" />
|
||||
<span className="absolute right-[-2px] bottom-[-2px] h-0.5 w-4 bg-white/80" />
|
||||
<span className="absolute right-[-2px] bottom-[-2px] h-4 w-0.5 bg-white/80" />
|
||||
<span className="absolute -left-0.5 -top-0.5 h-0.5 w-4 bg-white/80" />
|
||||
<span className="absolute -left-0.5 -top-0.5 h-4 w-0.5 bg-white/80" />
|
||||
<span className="absolute -right-0.5 -top-0.5 h-0.5 w-4 bg-white/80" />
|
||||
<span className="absolute -right-0.5 -top-0.5 h-4 w-0.5 bg-white/80" />
|
||||
<span className="absolute -left-0.5 -bottom-0.5 h-0.5 w-4 bg-white/80" />
|
||||
<span className="absolute -left-0.5 -bottom-0.5 h-4 w-0.5 bg-white/80" />
|
||||
<span className="absolute -right-0.5 -bottom-0.5 h-0.5 w-4 bg-white/80" />
|
||||
<span className="absolute -right-0.5 -bottom-0.5 h-4 w-0.5 bg-white/80" />
|
||||
</span>
|
||||
{children}
|
||||
</div>
|
||||
|
|
|
|||
98
app/client/components/ui/collapsible.tsx
Normal file
98
app/client/components/ui/collapsible.tsx
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
import * as React from "react";
|
||||
import { ChevronDown } from "lucide-react";
|
||||
import { cn } from "~/client/lib/utils";
|
||||
|
||||
interface CollapsibleProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
open?: boolean;
|
||||
onOpenChange?: (open: boolean) => void;
|
||||
defaultOpen?: boolean;
|
||||
}
|
||||
|
||||
const CollapsibleContext = React.createContext<{
|
||||
open: boolean;
|
||||
setOpen: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
}>({
|
||||
open: false,
|
||||
setOpen: () => {},
|
||||
});
|
||||
|
||||
const Collapsible = React.forwardRef<HTMLDivElement, CollapsibleProps>(
|
||||
({ className, open: controlledOpen, onOpenChange, defaultOpen = false, children, ...props }, ref) => {
|
||||
const [uncontrolledOpen, setUncontrolledOpen] = React.useState(defaultOpen);
|
||||
|
||||
const isControlled = controlledOpen !== undefined;
|
||||
const open = isControlled ? controlledOpen : uncontrolledOpen;
|
||||
|
||||
const setOpen = React.useCallback(
|
||||
(value: React.SetStateAction<boolean>) => {
|
||||
const newValue = typeof value === "function" ? value(open) : value;
|
||||
if (!isControlled) {
|
||||
setUncontrolledOpen(newValue);
|
||||
}
|
||||
onOpenChange?.(newValue);
|
||||
},
|
||||
[isControlled, open, onOpenChange],
|
||||
);
|
||||
|
||||
return (
|
||||
<CollapsibleContext.Provider value={{ open, setOpen }}>
|
||||
<div ref={ref} className={cn(className)} {...props}>
|
||||
{children}
|
||||
</div>
|
||||
</CollapsibleContext.Provider>
|
||||
);
|
||||
},
|
||||
);
|
||||
Collapsible.displayName = "Collapsible";
|
||||
|
||||
interface CollapsibleTriggerProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {}
|
||||
|
||||
const CollapsibleTrigger = React.forwardRef<HTMLButtonElement, CollapsibleTriggerProps>(
|
||||
({ className, children, ...props }, ref) => {
|
||||
const { open, setOpen } = React.useContext(CollapsibleContext);
|
||||
|
||||
return (
|
||||
<button
|
||||
ref={ref}
|
||||
type="button"
|
||||
className={cn(
|
||||
"flex w-full items-center justify-between py-2 text-sm font-medium transition-all hover:underline [&[data-state=open]>svg]:rotate-180",
|
||||
className,
|
||||
)}
|
||||
data-state={open ? "open" : "closed"}
|
||||
onClick={() => setOpen(!open)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronDown className="h-4 w-4 shrink-0 transition-transform duration-200" />
|
||||
</button>
|
||||
);
|
||||
},
|
||||
);
|
||||
CollapsibleTrigger.displayName = "CollapsibleTrigger";
|
||||
|
||||
interface CollapsibleContentProps extends React.HTMLAttributes<HTMLDivElement> {}
|
||||
|
||||
const CollapsibleContent = React.forwardRef<HTMLDivElement, CollapsibleContentProps>(
|
||||
({ className, children, ...props }, ref) => {
|
||||
const { open } = React.useContext(CollapsibleContext);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"overflow-hidden transition-all data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down",
|
||||
className,
|
||||
)}
|
||||
data-state={open ? "open" : "closed"}
|
||||
hidden={!open}
|
||||
{...props}
|
||||
>
|
||||
{open && children}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
);
|
||||
CollapsibleContent.displayName = "CollapsibleContent";
|
||||
|
||||
export { Collapsible, CollapsibleTrigger, CollapsibleContent };
|
||||
|
|
@ -57,7 +57,7 @@ export const VolumeFileBrowser = ({
|
|||
|
||||
if (fileBrowser.isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full min-h-[200px]">
|
||||
<div className="flex items-center justify-center h-full min-h-50">
|
||||
<p className="text-muted-foreground">Loading files...</p>
|
||||
</div>
|
||||
);
|
||||
|
|
@ -65,7 +65,7 @@ export const VolumeFileBrowser = ({
|
|||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full min-h-[200px]">
|
||||
<div className="flex items-center justify-center h-full min-h-50">
|
||||
<p className="text-destructive">Failed to load files: {(error as Error).message}</p>
|
||||
</div>
|
||||
);
|
||||
|
|
@ -73,7 +73,7 @@ export const VolumeFileBrowser = ({
|
|||
|
||||
if (fileBrowser.isEmpty) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center h-full text-center p-8 min-h-[200px]">
|
||||
<div className="flex flex-col items-center justify-center h-full text-center p-8 min-h-50">
|
||||
<FolderOpen className="mb-4 h-12 w-12 text-muted-foreground" />
|
||||
<p className="text-muted-foreground">{emptyMessage}</p>
|
||||
{emptyDescription && <p className="text-sm text-muted-foreground mt-2">{emptyDescription}</p>}
|
||||
|
|
|
|||
18
app/client/hooks/use-updates.ts
Normal file
18
app/client/hooks/use-updates.ts
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
import { useQuery } from "@tanstack/react-query";
|
||||
import { getUpdatesOptions } from "../api-client/@tanstack/react-query.gen";
|
||||
|
||||
export function useUpdates() {
|
||||
const { data, isLoading, error } = useQuery({
|
||||
...getUpdatesOptions(),
|
||||
staleTime: 60 * 60 * 1000,
|
||||
gcTime: 24 * 60 * 60 * 1000,
|
||||
refetchOnWindowFocus: false,
|
||||
});
|
||||
|
||||
return {
|
||||
updates: data,
|
||||
hasUpdate: data?.hasUpdate ?? false,
|
||||
isLoading,
|
||||
error,
|
||||
};
|
||||
}
|
||||
|
|
@ -2,6 +2,7 @@ import { arktypeResolver } from "@hookform/resolvers/arktype";
|
|||
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { type } from "arktype";
|
||||
import { X } from "lucide-react";
|
||||
import { useCallback, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { listRepositoriesOptions } from "~/client/api-client/@tanstack/react-query.gen";
|
||||
|
|
@ -169,6 +170,16 @@ export const CreateScheduleForm = ({ initialValues, formId, onSubmit, volume }:
|
|||
[form],
|
||||
);
|
||||
|
||||
const handleRemovePath = useCallback(
|
||||
(pathToRemove: string) => {
|
||||
const newPaths = new Set(selectedPaths);
|
||||
newPaths.delete(pathToRemove);
|
||||
setSelectedPaths(newPaths);
|
||||
form.setValue("includePatterns", Array.from(newPaths));
|
||||
},
|
||||
[selectedPaths, form],
|
||||
);
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form
|
||||
|
|
@ -184,12 +195,12 @@ export const CreateScheduleForm = ({ initialValues, formId, onSubmit, volume }:
|
|||
Schedule automated backups of <strong>{volume.name}</strong> to a secure repository.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="grid gap-6 md:grid-cols-2">
|
||||
<CardContent className="grid gap-6 @md:grid-cols-2">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem className="md:col-span-2">
|
||||
<FormItem className="@md:col-span-2">
|
||||
<FormLabel>Backup name</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="My backup" {...field} />
|
||||
|
|
@ -204,7 +215,7 @@ export const CreateScheduleForm = ({ initialValues, formId, onSubmit, volume }:
|
|||
control={form.control}
|
||||
name="repositoryId"
|
||||
render={({ field }) => (
|
||||
<FormItem className="md:col-span-2">
|
||||
<FormItem className="@md:col-span-2">
|
||||
<FormLabel>Backup repository</FormLabel>
|
||||
<FormControl>
|
||||
<Select {...field} onValueChange={field.onChange}>
|
||||
|
|
@ -289,7 +300,7 @@ export const CreateScheduleForm = ({ initialValues, formId, onSubmit, volume }:
|
|||
control={form.control}
|
||||
name="weeklyDay"
|
||||
render={({ field }) => (
|
||||
<FormItem className="md:col-span-2">
|
||||
<FormItem className="@md:col-span-2">
|
||||
<FormLabel>Execution day</FormLabel>
|
||||
<FormControl>
|
||||
<Select {...field} onValueChange={field.onChange}>
|
||||
|
|
@ -316,7 +327,7 @@ export const CreateScheduleForm = ({ initialValues, formId, onSubmit, volume }:
|
|||
control={form.control}
|
||||
name="monthlyDays"
|
||||
render={({ field }) => (
|
||||
<FormItem className="md:col-span-2">
|
||||
<FormItem className="@md:col-span-2">
|
||||
<FormLabel>Days of the month</FormLabel>
|
||||
<FormControl>
|
||||
<div className="grid grid-cols-7 gap-4 w-max">
|
||||
|
|
@ -373,8 +384,19 @@ export const CreateScheduleForm = ({ initialValues, formId, onSubmit, volume }:
|
|||
<p className="text-xs text-muted-foreground mb-2">Selected paths:</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{Array.from(selectedPaths).map((path) => (
|
||||
<span key={path} className="text-xs bg-accent px-2 py-1 rounded-md font-mono">
|
||||
<span
|
||||
key={path}
|
||||
className="text-xs bg-accent px-2 py-1 rounded-md font-mono inline-flex items-center gap-1"
|
||||
>
|
||||
{path}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleRemovePath(path)}
|
||||
className="ml-1 hover:bg-destructive/20 rounded p-0.5 transition-colors"
|
||||
aria-label={`Remove ${path}`}
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
|
|
@ -490,7 +512,7 @@ export const CreateScheduleForm = ({ initialValues, formId, onSubmit, volume }:
|
|||
<CardTitle>Retention policy</CardTitle>
|
||||
<CardDescription>Define how many snapshots to keep. Leave empty to keep all.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="grid gap-4 md:grid-cols-2">
|
||||
<CardContent className="grid gap-4 @md:grid-cols-2">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="keepLast"
|
||||
|
|
|
|||
|
|
@ -196,7 +196,7 @@ export const ScheduleMirrorsConfig = ({ scheduleId, primaryRepositoryId, reposit
|
|||
<Copy className="h-5 w-5" />
|
||||
Mirror Repositories
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
<CardDescription className="hidden @md:block mt-1">
|
||||
Configure secondary repositories where snapshots will be automatically copied after each backup
|
||||
</CardDescription>
|
||||
</div>
|
||||
|
|
@ -270,9 +270,9 @@ export const ScheduleMirrorsConfig = ({ scheduleId, primaryRepositoryId, reposit
|
|||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Repository</TableHead>
|
||||
<TableHead className="text-center w-[100px]">Enabled</TableHead>
|
||||
<TableHead className="w-[180px]">Last Copy</TableHead>
|
||||
<TableHead className="w-[50px]"></TableHead>
|
||||
<TableHead className="text-center w-25">Enabled</TableHead>
|
||||
<TableHead className="w-45">Last Copy</TableHead>
|
||||
<TableHead className="w-12.5"></TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
|
|
|
|||
|
|
@ -152,7 +152,9 @@ export const ScheduleNotificationsConfig = ({ scheduleId, destinations }: Props)
|
|||
<Bell className="h-5 w-5" />
|
||||
Notifications
|
||||
</CardTitle>
|
||||
<CardDescription>Configure which notifications to send for this backup schedule</CardDescription>
|
||||
<CardDescription className="hidden @md:block mt-1">
|
||||
Configure which notifications to send for this backup schedule
|
||||
</CardDescription>
|
||||
</div>
|
||||
{!isAddingNew && availableDestinations.length > 0 && (
|
||||
<Button variant="outline" size="sm" onClick={() => setIsAddingNew(true)}>
|
||||
|
|
@ -198,11 +200,11 @@ export const ScheduleNotificationsConfig = ({ scheduleId, destinations }: Props)
|
|||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Destination</TableHead>
|
||||
<TableHead className="text-center w-[100px]">Start</TableHead>
|
||||
<TableHead className="text-center w-[100px]">Success</TableHead>
|
||||
<TableHead className="text-center w-[100px]">Warnings</TableHead>
|
||||
<TableHead className="text-center w-[100px]">Failures</TableHead>
|
||||
<TableHead className="w-[50px]"></TableHead>
|
||||
<TableHead className="text-center w-25">Start</TableHead>
|
||||
<TableHead className="text-center w-25">Success</TableHead>
|
||||
<TableHead className="text-center w-25">Warnings</TableHead>
|
||||
<TableHead className="text-center w-25">Failures</TableHead>
|
||||
<TableHead className="w-12.5"></TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
|
|
|
|||
|
|
@ -81,7 +81,7 @@ export const ScheduleSummary = (props: Props) => {
|
|||
<div className="space-y-4">
|
||||
<Card>
|
||||
<CardHeader className="space-y-4">
|
||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
||||
<div className="flex flex-col @sm:flex-row @sm:items-center @sm:justify-between gap-4">
|
||||
<div>
|
||||
<CardTitle>{schedule.name}</CardTitle>
|
||||
<CardDescription className="mt-1">
|
||||
|
|
@ -96,7 +96,7 @@ export const ScheduleSummary = (props: Props) => {
|
|||
</Link>
|
||||
</CardDescription>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 justify-between sm:justify-start">
|
||||
<div className="flex items-center gap-2 justify-between @sm:justify-start">
|
||||
<OnOff
|
||||
isOn={schedule.enabled}
|
||||
toggle={handleToggleEnabled}
|
||||
|
|
@ -105,16 +105,16 @@ export const ScheduleSummary = (props: Props) => {
|
|||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col sm:flex-row gap-2">
|
||||
<div className="flex flex-col @lg:flex-row gap-2">
|
||||
{schedule.lastBackupStatus === "in_progress" ? (
|
||||
<Button variant="destructive" size="sm" onClick={handleStopBackup} className="w-full sm:w-auto">
|
||||
<Button variant="destructive" size="sm" onClick={handleStopBackup} className="w-full @md:w-auto">
|
||||
<Square className="h-4 w-4 mr-2" />
|
||||
<span className="sm:inline">Stop backup</span>
|
||||
<span>Stop backup</span>
|
||||
</Button>
|
||||
) : (
|
||||
<Button variant="default" size="sm" onClick={handleRunBackupNow} className="w-full sm:w-auto">
|
||||
<Button variant="default" size="sm" onClick={handleRunBackupNow} className="w-full @md:w-auto">
|
||||
<Play className="h-4 w-4 mr-2" />
|
||||
<span className="sm:inline">Backup now</span>
|
||||
<span>Backup now</span>
|
||||
</Button>
|
||||
)}
|
||||
{schedule.retentionPolicy && (
|
||||
|
|
@ -123,28 +123,28 @@ export const ScheduleSummary = (props: Props) => {
|
|||
size="sm"
|
||||
loading={runForget.isPending}
|
||||
onClick={() => setShowForgetConfirm(true)}
|
||||
className="w-full sm:w-auto"
|
||||
className="w-full @md:w-auto"
|
||||
>
|
||||
<Eraser className="h-4 w-4 mr-2" />
|
||||
<span className="sm:inline">Run cleanup</span>
|
||||
<span>Run cleanup</span>
|
||||
</Button>
|
||||
)}
|
||||
<Button variant="outline" size="sm" onClick={() => setIsEditMode(true)} className="w-full sm:w-auto">
|
||||
<Button variant="outline" size="sm" onClick={() => setIsEditMode(true)} className="w-full @md:w-auto">
|
||||
<Pencil className="h-4 w-4 mr-2" />
|
||||
<span className="sm:inline">Edit schedule</span>
|
||||
<span>Edit schedule</span>
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setShowDeleteConfirm(true)}
|
||||
className="text-destructive hover:text-destructive w-full sm:w-auto"
|
||||
className="text-destructive hover:text-destructive w-full @md:w-auto"
|
||||
>
|
||||
<Trash2 className="h-4 w-4 mr-2" />
|
||||
<span className="sm:inline">Delete</span>
|
||||
<span>Delete</span>
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
|
||||
<CardContent className="grid gap-4 grid-cols-1 @md:grid-cols-2 @lg:grid-cols-4">
|
||||
<div>
|
||||
<p className="text-xs uppercase text-muted-foreground">Schedule</p>
|
||||
<p className="font-medium">{summary.scheduleLabel}</p>
|
||||
|
|
@ -178,19 +178,21 @@ export const ScheduleSummary = (props: Props) => {
|
|||
</div>
|
||||
|
||||
{schedule.lastBackupStatus === "warning" && (
|
||||
<div className="md:col-span-2 lg:col-span-4">
|
||||
<div className="@md:col-span-2 @lg:col-span-4">
|
||||
<p className="text-xs uppercase text-muted-foreground">Warning Details</p>
|
||||
<p className="font-mono text-sm text-yellow-600 whitespace-pre-wrap break-all">
|
||||
<p className="font-mono text-sm text-yellow-600 whitespace-pre-wrap wrap-break-word">
|
||||
{schedule.lastBackupError ??
|
||||
"Last backup completed with warnings. Check your container logs for more details."}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{schedule.lastBackupError && (
|
||||
<div className="md:col-span-2 lg:col-span-4">
|
||||
<p className="text-xs uppercase text-muted-foreground">Error Details</p>
|
||||
<p className="font-mono text-sm text-red-600 whitespace-pre-wrap break-all">{schedule.lastBackupError}</p>
|
||||
{schedule.lastBackupError && schedule.lastBackupStatus === "error" && (
|
||||
<div className="@md:col-span-2 @lg:col-span-4">
|
||||
<p className="text-xs uppercase text-muted-foreground">Error details</p>
|
||||
<p className="font-mono text-sm text-red-600 whitespace-pre-wrap wrap-break-word">
|
||||
{schedule.lastBackupError}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { cn } from "~/client/lib/utils";
|
||||
import { Card } from "~/client/components/ui/card";
|
||||
import { Card, CardContent } from "~/client/components/ui/card";
|
||||
import { ByteSize } from "~/client/components/bytes-size";
|
||||
import { useEffect } from "react";
|
||||
import type { ListSnapshotsResponse } from "~/client/api-client";
|
||||
|
|
@ -24,9 +24,9 @@ export const SnapshotTimeline = (props: Props) => {
|
|||
if (error) {
|
||||
return (
|
||||
<Card>
|
||||
<div className="flex items-center justify-center h-24 p-4 text-center">
|
||||
<p className="text-destructive">Error loading snapshots: {error}</p>
|
||||
</div>
|
||||
<CardContent className="flex items-center justify-center text-center">
|
||||
<p className="text-destructive text-sm">{error}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -117,7 +117,7 @@ export default function Backups({ loaderData }: Route.ComponentProps) {
|
|||
<div className="container mx-auto space-y-6">
|
||||
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
|
||||
<SortableContext items={items} strategy={rectSortingStrategy}>
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3 auto-rows-fr">
|
||||
<div className="grid gap-4 @md:grid-cols-1 @lg:grid-cols-2 @2xl:grid-cols-3 auto-rows-fr">
|
||||
{items.map((id) => {
|
||||
const schedule = scheduleMap.get(id);
|
||||
if (!schedule) return null;
|
||||
|
|
|
|||
|
|
@ -14,10 +14,19 @@ import {
|
|||
FormMessage,
|
||||
} from "~/client/components/ui/form";
|
||||
import { Input } from "~/client/components/ui/input";
|
||||
import { SecretInput } from "~/client/components/ui/secret-input";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "~/client/components/ui/select";
|
||||
import { Checkbox } from "~/client/components/ui/checkbox";
|
||||
import { notificationConfigSchemaBase } from "~/schemas/notifications";
|
||||
import {
|
||||
CustomForm,
|
||||
DiscordForm,
|
||||
EmailForm,
|
||||
GenericForm,
|
||||
GotifyForm,
|
||||
NtfyForm,
|
||||
PushoverForm,
|
||||
SlackForm,
|
||||
TelegramForm,
|
||||
} from "./notification-forms";
|
||||
|
||||
export const formSchema = type({
|
||||
name: "2<=string<=32",
|
||||
|
|
@ -48,6 +57,9 @@ const defaultValuesForType = {
|
|||
slack: {
|
||||
type: "slack" as const,
|
||||
webhookUrl: "",
|
||||
channel: "",
|
||||
username: "",
|
||||
iconEmoji: "",
|
||||
},
|
||||
discord: {
|
||||
type: "discord" as const,
|
||||
|
|
@ -75,6 +87,16 @@ const defaultValuesForType = {
|
|||
botToken: "",
|
||||
chatId: "",
|
||||
},
|
||||
generic: {
|
||||
type: "generic" as const,
|
||||
url: "",
|
||||
method: "POST" as const,
|
||||
contentType: "application/json",
|
||||
headers: [],
|
||||
useJson: true,
|
||||
titleKey: "title",
|
||||
messageKey: "message",
|
||||
},
|
||||
custom: {
|
||||
type: "custom" as const,
|
||||
shoutrrrUrl: "",
|
||||
|
|
@ -84,7 +106,9 @@ const defaultValuesForType = {
|
|||
export const CreateNotificationForm = ({ onSubmit, mode = "create", initialValues, formId, className }: Props) => {
|
||||
const form = useForm<NotificationFormValues>({
|
||||
resolver: arktypeResolver(cleanSchema as unknown as typeof formSchema),
|
||||
defaultValues: initialValues,
|
||||
defaultValues: initialValues || {
|
||||
name: "",
|
||||
},
|
||||
resetOptions: {
|
||||
keepDefaultValues: true,
|
||||
keepDirtyValues: false,
|
||||
|
|
@ -97,7 +121,7 @@ export const CreateNotificationForm = ({ onSubmit, mode = "create", initialValue
|
|||
useEffect(() => {
|
||||
if (!initialValues) {
|
||||
form.reset({
|
||||
name: form.getValues().name,
|
||||
name: form.getValues().name || "",
|
||||
...defaultValuesForType[watchedType as keyof typeof defaultValuesForType],
|
||||
});
|
||||
}
|
||||
|
|
@ -152,6 +176,7 @@ export const CreateNotificationForm = ({ onSubmit, mode = "create", initialValue
|
|||
<SelectItem value="ntfy">Ntfy</SelectItem>
|
||||
<SelectItem value="pushover">Pushover</SelectItem>
|
||||
<SelectItem value="telegram">Telegram</SelectItem>
|
||||
<SelectItem value="generic">Generic Webhook</SelectItem>
|
||||
<SelectItem value="custom">Custom (Shoutrrr URL)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
|
@ -161,545 +186,15 @@ export const CreateNotificationForm = ({ onSubmit, mode = "create", initialValue
|
|||
)}
|
||||
/>
|
||||
|
||||
{watchedType === "email" && (
|
||||
<>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="smtpHost"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>SMTP Host</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} placeholder="smtp.example.com" />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="smtpPort"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>SMTP Port</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
type="number"
|
||||
placeholder="587"
|
||||
onChange={(e) => field.onChange(Number(e.target.value))}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="username"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Username (Optional)</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} placeholder="user@example.com" />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="password"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Password (Optional)</FormLabel>
|
||||
<FormControl>
|
||||
<SecretInput {...field} placeholder="••••••••" />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="from"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>From Address</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} placeholder="noreply@example.com" />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="to"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>To Addresses</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
placeholder="user@example.com, admin@example.com"
|
||||
value={Array.isArray(field.value) ? field.value.join(", ") : ""}
|
||||
onChange={(e) => field.onChange(e.target.value.split(",").map((email) => email.trim()))}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>Comma-separated list of recipient email addresses.</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="useTLS"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex flex-row items-center space-x-3">
|
||||
<FormControl>
|
||||
<Checkbox checked={field.value} onCheckedChange={field.onChange} />
|
||||
</FormControl>
|
||||
<div className="space-y-1 leading-none">
|
||||
<FormLabel>Use TLS</FormLabel>
|
||||
<FormDescription>Enable TLS encryption for SMTP connection.</FormDescription>
|
||||
</div>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{watchedType === "slack" && (
|
||||
<>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="webhookUrl"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Webhook URL</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
placeholder="https://hooks.slack.com/services/T00000000/B00000000/XXXXXXXXXXXXXXXXXXXX"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>Get this from your Slack app's Incoming Webhooks settings.</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="channel"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Channel (Optional)</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} placeholder="#backups" />
|
||||
</FormControl>
|
||||
<FormDescription>Override the default channel (use # for channels, @ for users).</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="username"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Bot Username (Optional)</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} placeholder="Zerobyte" />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="iconEmoji"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Icon Emoji (Optional)</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} placeholder=":floppy_disk:" />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{watchedType === "discord" && (
|
||||
<>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="webhookUrl"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Webhook URL</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} placeholder="https://discord.com/api/webhooks/WEBHOOK_ID/WEBHOOK_TOKEN" />
|
||||
</FormControl>
|
||||
<FormDescription>Get this from your Discord server's Integrations settings.</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="username"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Bot Username (Optional)</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} placeholder="Zerobyte" />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="avatarUrl"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Avatar URL (Optional)</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} placeholder="https://example.com/avatar.png" />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="threadId"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Thread ID (Optional)</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
ID of the thread to post messages in. Leave empty to post in the main channel.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{watchedType === "gotify" && (
|
||||
<>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="serverUrl"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Server URL</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} placeholder="https://gotify.example.com" />
|
||||
</FormControl>
|
||||
<FormDescription>Your self-hosted Gotify server URL.</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="token"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>App Token</FormLabel>
|
||||
<FormControl>
|
||||
<SecretInput {...field} placeholder="••••••••" />
|
||||
</FormControl>
|
||||
<FormDescription>Application token from Gotify.</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="priority"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Priority</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
type="number"
|
||||
min={0}
|
||||
max={10}
|
||||
onChange={(e) => field.onChange(Number(e.target.value))}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>Priority level (0-10, where 10 is highest).</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="path"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Path (Optional)</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} placeholder="/custom/path" />
|
||||
</FormControl>
|
||||
<FormDescription>Custom path on the Gotify server, if applicable.</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{watchedType === "ntfy" && (
|
||||
<>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="serverUrl"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Server URL (Optional)</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} placeholder="https://ntfy.example.com" />
|
||||
</FormControl>
|
||||
<FormDescription>Leave empty to use ntfy.sh public service.</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="topic"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Topic</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} placeholder="ironmount-backups" />
|
||||
</FormControl>
|
||||
<FormDescription>The ntfy topic name to publish to.</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="username"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Username (Optional)</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} placeholder="username" />
|
||||
</FormControl>
|
||||
<FormDescription>Username for server authentication, if required.</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="password"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Password (Optional)</FormLabel>
|
||||
<FormControl>
|
||||
<SecretInput {...field} placeholder="••••••••" />
|
||||
</FormControl>
|
||||
<FormDescription>Password for server authentication, if required.</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="accessToken"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Access token (Optional)</FormLabel>
|
||||
<FormControl>
|
||||
<SecretInput {...field} placeholder="••••••••" />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Access token for server authentication. Will take precedence over username/password if set.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="priority"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Priority</FormLabel>
|
||||
<Select onValueChange={field.onChange} defaultValue={String(field.value)} value={String(field.value)}>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select priority" />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
<SelectItem value="max">Max (5)</SelectItem>
|
||||
<SelectItem value="high">High (4)</SelectItem>
|
||||
<SelectItem value="default">Default (3)</SelectItem>
|
||||
<SelectItem value="low">Low (2)</SelectItem>
|
||||
<SelectItem value="min">Min (1)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{watchedType === "pushover" && (
|
||||
<>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="userKey"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>User Key</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} placeholder="uQiRzpo4DXghDmr9QzzfQu27cmVRsG" />
|
||||
</FormControl>
|
||||
<FormDescription>Your Pushover user key from the dashboard.</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="apiToken"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>API Token</FormLabel>
|
||||
<FormControl>
|
||||
<SecretInput {...field} placeholder="••••••••" />
|
||||
</FormControl>
|
||||
<FormDescription>Application API token from your Pushover application.</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="devices"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Devices (Optional)</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} placeholder="iphone,android" />
|
||||
</FormControl>
|
||||
<FormDescription>Comma-separated list of device names. Leave empty for all devices.</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="priority"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Priority</FormLabel>
|
||||
<Select
|
||||
onValueChange={(value) => field.onChange(Number(value))}
|
||||
defaultValue={String(field.value)}
|
||||
value={String(field.value)}
|
||||
>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select priority" />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
<SelectItem value="-1">Low (-1)</SelectItem>
|
||||
<SelectItem value="0">Normal (0)</SelectItem>
|
||||
<SelectItem value="1">High (1)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormDescription>Message priority level.</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{watchedType === "telegram" && (
|
||||
<>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="botToken"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Bot Token</FormLabel>
|
||||
<FormControl>
|
||||
<SecretInput {...field} placeholder="123456789:ABC-DEF1234ghIkl-zyx57W2v1u123ew11" />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Telegram bot token. Get this from BotFather when you create your bot.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="chatId"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Chat ID</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} placeholder="-1231234567890" />
|
||||
</FormControl>
|
||||
<FormDescription>Telegram chat ID to send notifications to.</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{watchedType === "custom" && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="shoutrrrUrl"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Shoutrrr URL</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
placeholder="smtp://user:pass@smtp.gmail.com:587/?from=you@gmail.com&to=recipient@example.com"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Direct Shoutrrr URL for power users. See
|
||||
<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>
|
||||
for supported services and URL formats.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
{watchedType === "email" && <EmailForm form={form} />}
|
||||
{watchedType === "slack" && <SlackForm form={form} />}
|
||||
{watchedType === "discord" && <DiscordForm form={form} />}
|
||||
{watchedType === "gotify" && <GotifyForm form={form} />}
|
||||
{watchedType === "ntfy" && <NtfyForm form={form} />}
|
||||
{watchedType === "pushover" && <PushoverForm form={form} />}
|
||||
{watchedType === "telegram" && <TelegramForm form={form} />}
|
||||
{watchedType === "generic" && <GenericForm form={form} />}
|
||||
{watchedType === "custom" && <CustomForm form={form} />}
|
||||
</form>
|
||||
</Form>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,41 @@
|
|||
import type { UseFormReturn } from "react-hook-form";
|
||||
import { FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from "~/client/components/ui/form";
|
||||
import { Input } from "~/client/components/ui/input";
|
||||
import type { NotificationFormValues } from "../create-notification-form";
|
||||
|
||||
type Props = {
|
||||
form: UseFormReturn<NotificationFormValues>;
|
||||
};
|
||||
|
||||
export const CustomForm = ({ form }: Props) => {
|
||||
return (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="shoutrrrUrl"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Shoutrrr URL</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
placeholder="smtp://user:pass@smtp.gmail.com:587/?from=you@gmail.com&to=recipient@example.com"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Direct Shoutrrr URL for power users. See
|
||||
<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>
|
||||
for supported services and URL formats.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1,71 @@
|
|||
import type { UseFormReturn } from "react-hook-form";
|
||||
import { FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from "~/client/components/ui/form";
|
||||
import { Input } from "~/client/components/ui/input";
|
||||
import type { NotificationFormValues } from "../create-notification-form";
|
||||
|
||||
type Props = {
|
||||
form: UseFormReturn<NotificationFormValues>;
|
||||
};
|
||||
|
||||
export const DiscordForm = ({ form }: Props) => {
|
||||
return (
|
||||
<>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="webhookUrl"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Webhook URL</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} placeholder="https://discord.com/api/webhooks/WEBHOOK_ID/WEBHOOK_TOKEN" />
|
||||
</FormControl>
|
||||
<FormDescription>Get this from your Discord server's Integrations settings.</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="username"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Bot Username (Optional)</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} placeholder="Zerobyte" />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="avatarUrl"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Avatar URL (Optional)</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} placeholder="https://example.com/avatar.png" />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="threadId"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Thread ID (Optional)</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
ID of the thread to post messages in. Leave empty to post in the main channel.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1,128 @@
|
|||
import type { UseFormReturn } from "react-hook-form";
|
||||
import { FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from "~/client/components/ui/form";
|
||||
import { Input } from "~/client/components/ui/input";
|
||||
import { SecretInput } from "~/client/components/ui/secret-input";
|
||||
import { Checkbox } from "~/client/components/ui/checkbox";
|
||||
import type { NotificationFormValues } from "../create-notification-form";
|
||||
|
||||
type Props = {
|
||||
form: UseFormReturn<NotificationFormValues>;
|
||||
};
|
||||
|
||||
export const EmailForm = ({ form }: Props) => {
|
||||
return (
|
||||
<>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="smtpHost"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>SMTP Host</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} placeholder="smtp.example.com" />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="smtpPort"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>SMTP Port</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
type="number"
|
||||
placeholder="587"
|
||||
onChange={(e) => field.onChange(Number(e.target.value))}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="username"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Username (Optional)</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} placeholder="user@example.com" />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="password"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Password (Optional)</FormLabel>
|
||||
<FormControl>
|
||||
<SecretInput {...field} placeholder="••••••••" />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="from"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>From Address</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} placeholder="noreply@example.com" />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="to"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>To Addresses</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
placeholder="user@example.com, admin@example.com"
|
||||
value={Array.isArray(field.value) ? field.value.join(", ") : ""}
|
||||
onChange={(e) =>
|
||||
field.onChange(
|
||||
e.target.value
|
||||
.split(",")
|
||||
.map((email) => email.trim())
|
||||
.filter(Boolean),
|
||||
)
|
||||
}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>Comma-separated list of recipient email addresses.</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="useTLS"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex flex-row items-center space-x-3">
|
||||
<FormControl>
|
||||
<Checkbox checked={field.value} onCheckedChange={field.onChange} />
|
||||
</FormControl>
|
||||
<div className="space-y-1 leading-none">
|
||||
<FormLabel>Use TLS</FormLabel>
|
||||
<FormDescription>Enable TLS encryption for SMTP connection.</FormDescription>
|
||||
</div>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1,173 @@
|
|||
import type { UseFormReturn } from "react-hook-form";
|
||||
import { FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from "~/client/components/ui/form";
|
||||
import { Input } from "~/client/components/ui/input";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "~/client/components/ui/select";
|
||||
import { Checkbox } from "~/client/components/ui/checkbox";
|
||||
import { Textarea } from "~/client/components/ui/textarea";
|
||||
import { CodeBlock } from "~/client/components/ui/code-block";
|
||||
import { Label } from "~/client/components/ui/label";
|
||||
import type { NotificationFormValues } from "../create-notification-form";
|
||||
|
||||
type Props = {
|
||||
form: UseFormReturn<NotificationFormValues>;
|
||||
};
|
||||
|
||||
const WebhookPreview = ({ values }: { values: Partial<NotificationFormValues> }) => {
|
||||
if (values.type !== "generic") return null;
|
||||
|
||||
const contentType = values.contentType || "application/json";
|
||||
const headers = values.headers || [];
|
||||
const useJson = values.useJson;
|
||||
const titleKey = values.titleKey || "title";
|
||||
const messageKey = values.messageKey || "message";
|
||||
|
||||
let body = "";
|
||||
if (useJson) {
|
||||
body = JSON.stringify(
|
||||
{
|
||||
[titleKey]: "Notification title",
|
||||
[messageKey]: "Notification message",
|
||||
},
|
||||
null,
|
||||
2,
|
||||
);
|
||||
} else {
|
||||
body = "Notification message";
|
||||
}
|
||||
|
||||
const previewCode = `${values.method} ${values.url}\nContent-Type: ${contentType}${headers.length > 0 ? `\n${headers.join("\n")}` : ""}
|
||||
|
||||
${body}`;
|
||||
|
||||
return (
|
||||
<div className="space-y-2 pt-4 border-t">
|
||||
<Label>Request Preview</Label>
|
||||
<CodeBlock code={previewCode} filename="HTTP Request" />
|
||||
<p className="text-[0.8rem] text-muted-foreground">This is a preview of the HTTP request that will be sent.</p>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const GenericForm = ({ form }: Props) => {
|
||||
const watchedValues = form.watch();
|
||||
|
||||
return (
|
||||
<>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="url"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Webhook URL</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} placeholder="https://api.example.com/webhook" />
|
||||
</FormControl>
|
||||
<FormDescription>The target URL for the webhook.</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="method"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Method</FormLabel>
|
||||
<Select onValueChange={field.onChange} value={field.value}>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select method" />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
<SelectItem value="GET">GET</SelectItem>
|
||||
<SelectItem value="POST">POST</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="contentType"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Content Type</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} placeholder="application/json" />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="headers"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Headers</FormLabel>
|
||||
<FormControl>
|
||||
<Textarea
|
||||
{...field}
|
||||
placeholder="Authorization: Bearer token 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} />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1,78 @@
|
|||
import type { UseFormReturn } from "react-hook-form";
|
||||
import { FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from "~/client/components/ui/form";
|
||||
import { Input } from "~/client/components/ui/input";
|
||||
import { SecretInput } from "~/client/components/ui/secret-input";
|
||||
import type { NotificationFormValues } from "../create-notification-form";
|
||||
|
||||
type Props = {
|
||||
form: UseFormReturn<NotificationFormValues>;
|
||||
};
|
||||
|
||||
export const GotifyForm = ({ form }: Props) => {
|
||||
return (
|
||||
<>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="serverUrl"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Server URL</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} placeholder="https://gotify.example.com" />
|
||||
</FormControl>
|
||||
<FormDescription>Your self-hosted Gotify server URL.</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="token"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>App Token</FormLabel>
|
||||
<FormControl>
|
||||
<SecretInput {...field} placeholder="••••••••" />
|
||||
</FormControl>
|
||||
<FormDescription>Application token from Gotify.</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="priority"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Priority</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
type="number"
|
||||
min={0}
|
||||
max={10}
|
||||
onChange={(e) => field.onChange(Number(e.target.value))}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>Priority level (0-10, where 10 is highest).</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="path"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Path (Optional)</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} placeholder="/custom/path" />
|
||||
</FormControl>
|
||||
<FormDescription>Custom path on the Gotify server, if applicable.</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
export * from "./email-form";
|
||||
export * from "./slack-form";
|
||||
export * from "./discord-form";
|
||||
export * from "./gotify-form";
|
||||
export * from "./ntfy-form";
|
||||
export * from "./pushover-form";
|
||||
export * from "./telegram-form";
|
||||
export * from "./generic-form";
|
||||
export * from "./custom-form";
|
||||
|
|
@ -0,0 +1,113 @@
|
|||
import type { UseFormReturn } from "react-hook-form";
|
||||
import { FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from "~/client/components/ui/form";
|
||||
import { Input } from "~/client/components/ui/input";
|
||||
import { SecretInput } from "~/client/components/ui/secret-input";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "~/client/components/ui/select";
|
||||
import type { NotificationFormValues } from "../create-notification-form";
|
||||
|
||||
type Props = {
|
||||
form: UseFormReturn<NotificationFormValues>;
|
||||
};
|
||||
|
||||
export const NtfyForm = ({ form }: Props) => {
|
||||
return (
|
||||
<>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="serverUrl"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Server URL (Optional)</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} placeholder="https://ntfy.example.com" />
|
||||
</FormControl>
|
||||
<FormDescription>Leave empty to use ntfy.sh public service.</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="topic"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Topic</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} placeholder="zerobyte-backups" />
|
||||
</FormControl>
|
||||
<FormDescription>The ntfy topic name to publish to.</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="username"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Username (Optional)</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} placeholder="username" />
|
||||
</FormControl>
|
||||
<FormDescription>Username for server authentication, if required.</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="password"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Password (Optional)</FormLabel>
|
||||
<FormControl>
|
||||
<SecretInput {...field} placeholder="••••••••" />
|
||||
</FormControl>
|
||||
<FormDescription>Password for server authentication, if required.</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="accessToken"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Access token (Optional)</FormLabel>
|
||||
<FormControl>
|
||||
<SecretInput {...field} placeholder="••••••••" />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Access token for server authentication. Will take precedence over username/password if set.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="priority"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Priority</FormLabel>
|
||||
<Select onValueChange={field.onChange} defaultValue={String(field.value)} value={String(field.value)}>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select priority" />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
<SelectItem value="max">Max (5)</SelectItem>
|
||||
<SelectItem value="high">High (4)</SelectItem>
|
||||
<SelectItem value="default">Default (3)</SelectItem>
|
||||
<SelectItem value="low">Low (2)</SelectItem>
|
||||
<SelectItem value="min">Min (1)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1,86 @@
|
|||
import type { UseFormReturn } from "react-hook-form";
|
||||
import { FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from "~/client/components/ui/form";
|
||||
import { Input } from "~/client/components/ui/input";
|
||||
import { SecretInput } from "~/client/components/ui/secret-input";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "~/client/components/ui/select";
|
||||
import type { NotificationFormValues } from "../create-notification-form";
|
||||
|
||||
type Props = {
|
||||
form: UseFormReturn<NotificationFormValues>;
|
||||
};
|
||||
|
||||
export const PushoverForm = ({ form }: Props) => {
|
||||
return (
|
||||
<>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="userKey"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>User Key</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} placeholder="uQiRzpo4DXghDmr9QzzfQu27cmVRsG" />
|
||||
</FormControl>
|
||||
<FormDescription>Your Pushover user key from the dashboard.</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="apiToken"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>API Token</FormLabel>
|
||||
<FormControl>
|
||||
<SecretInput {...field} placeholder="••••••••" />
|
||||
</FormControl>
|
||||
<FormDescription>Application API token from your Pushover application.</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="devices"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Devices (Optional)</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} placeholder="iphone,android" />
|
||||
</FormControl>
|
||||
<FormDescription>Comma-separated list of device names. Leave empty for all devices.</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="priority"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Priority</FormLabel>
|
||||
<Select
|
||||
onValueChange={(value) => field.onChange(Number(value))}
|
||||
defaultValue={String(field.value)}
|
||||
value={String(field.value)}
|
||||
>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select priority" />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
<SelectItem value="-1">Low (-1)</SelectItem>
|
||||
<SelectItem value="0">Normal (0)</SelectItem>
|
||||
<SelectItem value="1">High (1)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormDescription>Message priority level.</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1,72 @@
|
|||
import type { UseFormReturn } from "react-hook-form";
|
||||
import { FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from "~/client/components/ui/form";
|
||||
import { Input } from "~/client/components/ui/input";
|
||||
import type { NotificationFormValues } from "../create-notification-form";
|
||||
|
||||
type Props = {
|
||||
form: UseFormReturn<NotificationFormValues>;
|
||||
};
|
||||
|
||||
export const SlackForm = ({ form }: Props) => {
|
||||
return (
|
||||
<>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="webhookUrl"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Webhook URL</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
placeholder="https://hooks.slack.com/services/T00000000/B00000000/XXXXXXXXXXXXXXXXXXXX"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>Get this from your Slack app's Incoming Webhooks settings.</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="channel"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Channel (Optional)</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} placeholder="#backups" />
|
||||
</FormControl>
|
||||
<FormDescription>Override the default channel (use # for channels, @ for users).</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="username"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Bot Username (Optional)</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} placeholder="Zerobyte" />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="iconEmoji"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Icon Emoji (Optional)</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} placeholder=":floppy_disk:" />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1,44 @@
|
|||
import type { UseFormReturn } from "react-hook-form";
|
||||
import { FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from "~/client/components/ui/form";
|
||||
import { Input } from "~/client/components/ui/input";
|
||||
import { SecretInput } from "~/client/components/ui/secret-input";
|
||||
import type { NotificationFormValues } from "../create-notification-form";
|
||||
|
||||
type Props = {
|
||||
form: UseFormReturn<NotificationFormValues>;
|
||||
};
|
||||
|
||||
export const TelegramForm = ({ form }: Props) => {
|
||||
return (
|
||||
<>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="botToken"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Bot Token</FormLabel>
|
||||
<FormControl>
|
||||
<SecretInput {...field} placeholder="123456789:ABC-DEF1234ghIkl-zyx57W2v1u123ew11" />
|
||||
</FormControl>
|
||||
<FormDescription>Telegram bot token. Get this from BotFather when you create your bot.</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="chatId"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Chat ID</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} placeholder="-1231234567890" />
|
||||
</FormControl>
|
||||
<FormDescription>Telegram chat ID to send notifications to.</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
@ -200,10 +200,7 @@ export default function NotificationDetailsPage({ loaderData }: Route.ComponentP
|
|||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>
|
||||
<X className="h-4 w-4 mr-2" />
|
||||
Cancel
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={handleConfirmDelete}>
|
||||
<Trash2 className="h-4 w-4 mr-2" />
|
||||
Delete
|
||||
|
|
|
|||
|
|
@ -31,6 +31,7 @@ import {
|
|||
RcloneRepositoryForm,
|
||||
RestRepositoryForm,
|
||||
SftpRepositoryForm,
|
||||
AdvancedForm,
|
||||
} from "./repository-forms";
|
||||
|
||||
export const formSchema = type({
|
||||
|
|
@ -225,12 +226,13 @@ export const CreateRepositoryForm = ({
|
|||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
<SelectItem value="default">Use Zerobyte's password</SelectItem>
|
||||
<SelectItem value="default">Use the existing recovery key</SelectItem>
|
||||
<SelectItem value="custom">Enter password manually</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormDescription>
|
||||
Choose whether to use Zerobyte's master password or enter a custom password for the existing repository.
|
||||
Choose whether to use Zerobyte's recovery key (which you downloaded when creating your account) or enter
|
||||
a custom password for the existing repository.
|
||||
</FormDescription>
|
||||
</FormItem>
|
||||
|
||||
|
|
@ -268,6 +270,8 @@ export const CreateRepositoryForm = ({
|
|||
{watchedBackend === "rest" && <RestRepositoryForm form={form} />}
|
||||
{watchedBackend === "sftp" && <SftpRepositoryForm form={form} />}
|
||||
|
||||
<AdvancedForm form={form} />
|
||||
|
||||
{mode === "update" && (
|
||||
<Button type="submit" className="w-full" loading={loading}>
|
||||
<Save className="h-4 w-4 mr-2" />
|
||||
|
|
|
|||
|
|
@ -0,0 +1,112 @@
|
|||
import type { UseFormReturn } from "react-hook-form";
|
||||
import {
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "../../../../components/ui/form";
|
||||
import { Textarea } from "../../../../components/ui/textarea";
|
||||
import { Checkbox } from "../../../../components/ui/checkbox";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "../../../../components/ui/tooltip";
|
||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "../../../../components/ui/collapsible";
|
||||
import type { RepositoryFormValues } from "../create-repository-form";
|
||||
import { cn } from "~/client/lib/utils";
|
||||
|
||||
type Props = {
|
||||
form: UseFormReturn<RepositoryFormValues>;
|
||||
};
|
||||
|
||||
export const AdvancedForm = ({ form }: Props) => {
|
||||
const insecureTls = form.watch("insecureTls");
|
||||
const cacert = form.watch("cacert");
|
||||
|
||||
return (
|
||||
<Collapsible>
|
||||
<CollapsibleTrigger className="w-full text-muted-foreground hover:no-underline">
|
||||
Advanced Settings
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent className="pb-4 space-y-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="insecureTls"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex flex-row items-start space-x-3 space-y-0 rounded-md border p-4">
|
||||
<FormControl>
|
||||
<Tooltip delayDuration={500}>
|
||||
<TooltipTrigger asChild>
|
||||
<div>
|
||||
<Checkbox
|
||||
checked={field.value ?? false}
|
||||
disabled={!!cacert}
|
||||
onCheckedChange={(checked) => {
|
||||
field.onChange(checked);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className={cn({ hidden: !cacert })}>
|
||||
<p className="max-w-xs">
|
||||
This option is disabled because a CA certificate is provided. Remove the CA certificate to skip
|
||||
TLS validation instead.
|
||||
</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</FormControl>
|
||||
<div className="space-y-1 leading-none">
|
||||
<FormLabel>Skip TLS certificate verification</FormLabel>
|
||||
<FormDescription>
|
||||
Disable TLS certificate verification for HTTPS connections with self-signed certificates. This is
|
||||
insecure and should only be used for testing.
|
||||
</FormDescription>
|
||||
</div>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="cacert"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>CA Certificate (Optional)</FormLabel>
|
||||
<FormControl>
|
||||
<Tooltip delayDuration={500}>
|
||||
<TooltipTrigger asChild>
|
||||
<div>
|
||||
<Textarea
|
||||
placeholder={"-----BEGIN CERTIFICATE-----\n...\n-----END CERTIFICATE-----"}
|
||||
rows={6}
|
||||
disabled={insecureTls}
|
||||
{...field}
|
||||
/>
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className={cn({ hidden: !insecureTls })}>
|
||||
<p className="max-w-xs">
|
||||
CA certificate is disabled because TLS validation is being skipped. Uncheck "Skip TLS Certificate
|
||||
Verification" to provide a custom CA certificate.
|
||||
</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Custom CA certificate for self-signed certificates (PEM format). This applies to HTTPS
|
||||
connections.
|
||||
<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>
|
||||
);
|
||||
};
|
||||
|
|
@ -6,3 +6,4 @@ export { AzureRepositoryForm } from "./azure-repository-form";
|
|||
export { RcloneRepositoryForm } from "./rclone-repository-form";
|
||||
export { RestRepositoryForm } from "./rest-repository-form";
|
||||
export { SftpRepositoryForm } from "./sftp-repository-form";
|
||||
export { AdvancedForm } from "./advanced-tls-form";
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@ import {
|
|||
} from "~/client/components/ui/alert-dialog";
|
||||
import type { Repository } from "~/client/lib/types";
|
||||
import { updateRepositoryMutation } from "~/client/api-client/@tanstack/react-query.gen";
|
||||
import type { CompressionMode } from "~/schemas/restic";
|
||||
import type { CompressionMode, RepositoryConfig } from "~/schemas/restic";
|
||||
|
||||
type Props = {
|
||||
repository: Repository;
|
||||
|
|
@ -59,6 +59,8 @@ export const RepositoryInfoTabContent = ({ repository }: Props) => {
|
|||
const hasChanges =
|
||||
name !== repository.name || compressionMode !== ((repository.compressionMode as CompressionMode) || "off");
|
||||
|
||||
const config = repository.config as RepositoryConfig;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Card className="p-6">
|
||||
|
|
@ -116,6 +118,26 @@ export const RepositoryInfoTabContent = ({ repository }: Props) => {
|
|||
{repository.lastChecked ? new Date(repository.lastChecked).toLocaleString() : "Never"}
|
||||
</p>
|
||||
</div>
|
||||
{config.cacert && (
|
||||
<div>
|
||||
<div className="text-sm font-medium text-muted-foreground">CA Certificate</div>
|
||||
<p className="mt-1 text-sm">
|
||||
<span className="text-green-500">configured</span>
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{"insecureTls" in config && (
|
||||
<div>
|
||||
<div className="text-sm font-medium text-muted-foreground">TLS Certificate Validation</div>
|
||||
<p className="mt-1 text-sm">
|
||||
{config.insecureTls ? (
|
||||
<span className="text-red-500">disabled</span>
|
||||
) : (
|
||||
<span className="text-green-500">enabled</span>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -125,7 +147,7 @@ export const RepositoryInfoTabContent = ({ repository }: Props) => {
|
|||
<h3 className="text-lg font-semibold text-red-500">Last Error</h3>
|
||||
</div>
|
||||
<div className="bg-red-500/10 border border-red-500/20 rounded-md p-4">
|
||||
<p className="text-sm text-red-500">{repository.lastError}</p>
|
||||
<p className="text-sm text-red-500 wrap-break-word">{repository.lastError}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -46,13 +46,13 @@ export const RepositorySnapshotsTabContent = ({ repository }: Props) => {
|
|||
<Card>
|
||||
<CardContent className="flex flex-col items-center justify-center text-center py-12">
|
||||
<Database className="mb-4 h-12 w-12 text-destructive" />
|
||||
<p className="text-destructive font-semibold">Repository Error</p>
|
||||
<p className="text-destructive font-semibold">Repository error</p>
|
||||
<p className="text-sm text-muted-foreground mt-2">
|
||||
This repository is in an error state and cannot be accessed.
|
||||
</p>
|
||||
{repository.lastError && (
|
||||
<div className="mt-4 max-w-md bg-destructive/10 border border-destructive/20 rounded-md p-3">
|
||||
<p className="text-sm text-destructive">{repository.lastError}</p>
|
||||
<div className="mt-4 w-full max-w-md bg-destructive/10 border border-destructive/20 rounded-md p-3">
|
||||
<p className="text-sm text-destructive wrap-break-word">{repository.lastError}</p>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
|
|
|
|||
|
|
@ -207,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 Restic password file for disaster recovery</CardDescription>
|
||||
<CardDescription className="mt-1.5">Download your recovery key for Restic backups</CardDescription>
|
||||
</div>
|
||||
<CardContent className="p-6 space-y-4">
|
||||
<p className="text-sm text-muted-foreground max-w-2xl">
|
||||
|
|
@ -220,15 +220,15 @@ export default function Settings({ loaderData }: Route.ComponentProps) {
|
|||
<DialogTrigger asChild>
|
||||
<Button variant="outline">
|
||||
<Download size={16} className="mr-2" />
|
||||
Download Restic Password
|
||||
Download recovery key
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<form onSubmit={handleDownloadResticPassword}>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Download Restic Password</DialogTitle>
|
||||
<DialogTitle>Download Recovery Key</DialogTitle>
|
||||
<DialogDescription>
|
||||
For security reasons, please enter your account password to download the Restic password file.
|
||||
For security reasons, please enter your account password to download the recovery key file.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 py-4">
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ export const NOTIFICATION_TYPES = {
|
|||
ntfy: "ntfy",
|
||||
pushover: "pushover",
|
||||
telegram: "telegram",
|
||||
generic: "generic",
|
||||
custom: "custom",
|
||||
} as const;
|
||||
|
||||
|
|
@ -72,6 +73,17 @@ export const telegramNotificationConfigSchema = type({
|
|||
chatId: "string",
|
||||
});
|
||||
|
||||
export const genericNotificationConfigSchema = type({
|
||||
type: "'generic'",
|
||||
url: "string",
|
||||
method: "'GET' | 'POST'",
|
||||
contentType: "string?",
|
||||
headers: "string[]?",
|
||||
useJson: "boolean?",
|
||||
titleKey: "string?",
|
||||
messageKey: "string?",
|
||||
});
|
||||
|
||||
export const customNotificationConfigSchema = type({
|
||||
type: "'custom'",
|
||||
shoutrrrUrl: "string",
|
||||
|
|
@ -84,6 +96,7 @@ export const notificationConfigSchemaBase = emailNotificationConfigSchema
|
|||
.or(ntfyNotificationConfigSchema)
|
||||
.or(pushoverNotificationConfigSchema)
|
||||
.or(telegramNotificationConfigSchema)
|
||||
.or(genericNotificationConfigSchema)
|
||||
.or(customNotificationConfigSchema);
|
||||
|
||||
export const notificationConfigSchema = notificationConfigSchemaBase.onUndeclaredKey("delete");
|
||||
|
|
|
|||
|
|
@ -17,6 +17,8 @@ export type RepositoryBackend = keyof typeof REPOSITORY_BACKENDS;
|
|||
const baseRepositoryConfigSchema = type({
|
||||
isExistingRepository: "boolean?",
|
||||
customPassword: "string?",
|
||||
cacert: "string?",
|
||||
insecureTls: "boolean?",
|
||||
});
|
||||
|
||||
export const s3RepositoryConfigSchema = type({
|
||||
|
|
|
|||
|
|
@ -8,13 +8,29 @@ program.addCommand(resetPasswordCommand);
|
|||
|
||||
export async function runCLI(argv: string[]): Promise<boolean> {
|
||||
const args = argv.slice(2);
|
||||
const hasCommand = args.length > 0 && !args[0].startsWith("-");
|
||||
const isCLIMode = process.env.ZEROBYTE_CLI === "1";
|
||||
|
||||
if (!hasCommand) {
|
||||
if (args.length === 0) {
|
||||
if (isCLIMode) {
|
||||
program.help();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
await program.parseAsync(argv);
|
||||
if (!isCLIMode && args[0].startsWith("-")) {
|
||||
return false;
|
||||
}
|
||||
|
||||
await program.parseAsync(argv).catch((err) => {
|
||||
if (err.message.includes("SIGINT")) {
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
console.error(err.message);
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ const envSchema = type({
|
|||
RESTIC_HOSTNAME: "string = 'zerobyte'",
|
||||
PORT: 'string.integer.parse = "4096"',
|
||||
MIGRATIONS_PATH: "string?",
|
||||
APP_VERSION: "string = 'dev'",
|
||||
}).pipe((s) => ({
|
||||
__prod__: s.NODE_ENV === "production",
|
||||
environment: s.NODE_ENV,
|
||||
|
|
@ -16,6 +17,7 @@ const envSchema = type({
|
|||
resticHostname: s.RESTIC_HOSTNAME,
|
||||
port: s.PORT,
|
||||
migrationsPath: s.MIGRATIONS_PATH,
|
||||
appVersion: s.APP_VERSION,
|
||||
}));
|
||||
|
||||
const parseConfig = (env: unknown) => {
|
||||
|
|
|
|||
|
|
@ -6,4 +6,4 @@ export const RESTIC_PASS_FILE = "/var/lib/zerobyte/data/restic.pass";
|
|||
|
||||
export const DEFAULT_EXCLUDES = [DATABASE_URL, RESTIC_PASS_FILE, REPOSITORY_BASE];
|
||||
|
||||
export const REQUIRED_MIGRATIONS = ["v0.21.0"];
|
||||
export const REQUIRED_MIGRATIONS = []; // ["v0.21.1"] add this once re-tagging migration is removed
|
||||
|
|
|
|||
|
|
@ -8,6 +8,12 @@ import { REQUIRED_MIGRATIONS } from "./core/constants";
|
|||
import { validateRequiredMigrations } from "./modules/lifecycle/checkpoint";
|
||||
import { createApp } from "./app";
|
||||
import { config } from "./core/config";
|
||||
import { runCLI } from "./cli";
|
||||
|
||||
const cliRun = await runCLI(Bun.argv);
|
||||
if (cliRun) {
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const app = createApp();
|
||||
|
||||
|
|
|
|||
|
|
@ -1,13 +1,13 @@
|
|||
import { eq } from "drizzle-orm";
|
||||
import { db } from "../../db/db";
|
||||
import { repositoriesTable } from "../../db/schema";
|
||||
import { backupScheduleMirrorsTable, repositoriesTable, type Repository } from "../../db/schema";
|
||||
import { logger } from "../../utils/logger";
|
||||
import { hasMigrationCheckpoint, recordMigrationCheckpoint } from "./checkpoint";
|
||||
import { toMessage } from "~/server/utils/errors";
|
||||
import { safeSpawn } from "~/server/utils/spawn";
|
||||
import { addCommonArgs, buildEnv, buildRepoUrl, cleanupTemporaryKeys } from "~/server/utils/restic";
|
||||
|
||||
const MIGRATION_VERSION = "v0.21.0";
|
||||
const MIGRATION_VERSION = "v0.21.1";
|
||||
|
||||
interface MigrationResult {
|
||||
success: boolean;
|
||||
|
|
@ -40,52 +40,96 @@ export const retagSnapshots = async () => {
|
|||
const allErrors = [...result.errors];
|
||||
|
||||
if (allErrors.length > 0) {
|
||||
logger.error(`Migration ${MIGRATION_VERSION} completed with errors: ${allErrors.length} items failed.`);
|
||||
logger.error(
|
||||
`Some snapshots could not be retagged. Please check the logs for details. Fix any repository in error state and re-start zerobyte to retry the migration for failed items.`,
|
||||
);
|
||||
for (const err of allErrors) {
|
||||
logger.error(`Migration failure - ${err.name}: ${err.error}`);
|
||||
}
|
||||
throw new MigrationError(MIGRATION_VERSION, allErrors);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
await recordMigrationCheckpoint(MIGRATION_VERSION);
|
||||
|
||||
logger.info(`Snapshots retagging migration (${MIGRATION_VERSION}) complete.`);
|
||||
};
|
||||
|
||||
const migrateTag = async (
|
||||
oldTag: string,
|
||||
newTag: string,
|
||||
repository: Repository,
|
||||
scheduleName: string,
|
||||
): Promise<string | null> => {
|
||||
const repoUrl = buildRepoUrl(repository.config);
|
||||
const env = await buildEnv(repository.config);
|
||||
|
||||
const args = ["--repo", repoUrl, "tag", "--tag", oldTag, "--add", newTag, "--remove", oldTag];
|
||||
|
||||
addCommonArgs(args, env);
|
||||
|
||||
logger.info(`Migrating snapshots for schedule '${scheduleName}' from tag '${oldTag}' to '${newTag}'`);
|
||||
const res = await safeSpawn({ command: "restic", args, env });
|
||||
await cleanupTemporaryKeys(env);
|
||||
|
||||
if (res.exitCode !== 0) {
|
||||
logger.error(`Restic tag failed: ${res.stderr}`);
|
||||
return toMessage(res.stderr);
|
||||
}
|
||||
|
||||
logger.info(`Migrated snapshots for schedule '${scheduleName}' from tag '${oldTag}' to '${newTag}'`);
|
||||
return null;
|
||||
};
|
||||
|
||||
const migrateSnapshotsToShortIdTag = async (): Promise<MigrationResult> => {
|
||||
const errors: Array<{ name: string; error: string }> = [];
|
||||
const backupSchedules = await db.query.backupSchedulesTable.findMany({});
|
||||
|
||||
for (const schedule of backupSchedules) {
|
||||
const oldTag = schedule.id.toString();
|
||||
const newTag = schedule.shortId;
|
||||
try {
|
||||
const oldTag = schedule.id.toString();
|
||||
const newTag = schedule.shortId;
|
||||
|
||||
const repository = await db.query.repositoriesTable.findFirst({
|
||||
where: eq(repositoriesTable.id, schedule.repositoryId),
|
||||
});
|
||||
const repository = await db.query.repositoriesTable.findFirst({
|
||||
where: eq(repositoriesTable.id, schedule.repositoryId),
|
||||
});
|
||||
|
||||
if (!repository) {
|
||||
errors.push({ name: `schedule:${schedule.name}`, error: `Associated repository not found` });
|
||||
continue;
|
||||
if (!repository) {
|
||||
errors.push({ name: `schedule:${schedule.name}`, error: `Associated repository not found` });
|
||||
continue;
|
||||
}
|
||||
|
||||
const error = await migrateTag(oldTag, newTag, repository, schedule.name);
|
||||
if (error) {
|
||||
errors.push({ name: `schedule:${schedule.name}`, error });
|
||||
continue;
|
||||
}
|
||||
|
||||
const mirrors = await db
|
||||
.select()
|
||||
.from(backupScheduleMirrorsTable)
|
||||
.where(eq(backupScheduleMirrorsTable.scheduleId, schedule.id));
|
||||
|
||||
for (const mirror of mirrors) {
|
||||
const mirrorRepo = await db.query.repositoriesTable.findFirst({
|
||||
where: eq(repositoriesTable.id, mirror.repositoryId),
|
||||
});
|
||||
|
||||
if (!mirrorRepo) {
|
||||
errors.push({ name: `schedule-mirror:${schedule.name}`, error: `Associated mirror repository not found` });
|
||||
continue;
|
||||
}
|
||||
|
||||
const mirrorError = await migrateTag(oldTag, newTag, mirrorRepo, `${schedule.name} (mirror)`);
|
||||
if (mirrorError) {
|
||||
errors.push({ name: `schedule-mirror:${schedule.name}`, error: mirrorError });
|
||||
}
|
||||
}
|
||||
|
||||
logger.info(`Migrated snapshots for schedule '${schedule.name}' from tag '${oldTag}' to '${newTag}'`);
|
||||
} catch (err) {
|
||||
errors.push({ name: `schedule:${schedule.name}`, error: toMessage(err) });
|
||||
}
|
||||
|
||||
const repoUrl = buildRepoUrl(repository.config);
|
||||
const env = await buildEnv(repository.config);
|
||||
|
||||
const args = ["--repo", repoUrl, "tag", "--tag", oldTag, "--add", newTag, "--remove", oldTag];
|
||||
|
||||
addCommonArgs(args, env);
|
||||
|
||||
logger.info(`Migrating snapshots for schedule '${schedule.name}' from tag '${oldTag}' to '${newTag}'`);
|
||||
const res = await safeSpawn({ command: "restic", args, env });
|
||||
await cleanupTemporaryKeys(repository.config, env);
|
||||
|
||||
if (res.exitCode !== 0) {
|
||||
logger.error(`Restic tag failed: ${res.stderr}`);
|
||||
errors.push({ name: `schedule:${schedule.name}`, error: `Restic tag command failed: ${toMessage(res.stderr)}` });
|
||||
continue;
|
||||
}
|
||||
|
||||
logger.info(`Migrated snapshots for schedule '${schedule.name}' from tag '${oldTag}' to '${newTag}'`);
|
||||
}
|
||||
|
||||
return { success: errors.length === 0, errors };
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ import { CleanupSessionsJob } from "../../jobs/cleanup-sessions";
|
|||
import { repositoriesService } from "../repositories/repositories.service";
|
||||
import { notificationsService } from "../notifications/notifications.service";
|
||||
import { VolumeAutoRemountJob } from "~/server/jobs/auto-remount";
|
||||
import { cache } from "~/server/utils/cache";
|
||||
|
||||
const ensureLatestConfigurationSchema = async () => {
|
||||
const volumes = await db.query.volumesTable.findMany({});
|
||||
|
|
@ -41,6 +42,8 @@ const ensureLatestConfigurationSchema = async () => {
|
|||
};
|
||||
|
||||
export const startup = async () => {
|
||||
cache.clear();
|
||||
|
||||
await Scheduler.start();
|
||||
await Scheduler.clear();
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import type { NotificationConfig } from "~/schemas/notifications";
|
||||
|
||||
export function buildCustomShoutrrrUrl(config: Extract<NotificationConfig, { type: "custom" }>): string {
|
||||
export const buildCustomShoutrrrUrl = (config: Extract<NotificationConfig, { type: "custom" }>) => {
|
||||
return config.shoutrrrUrl;
|
||||
}
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import type { NotificationConfig } from "~/schemas/notifications";
|
||||
|
||||
export function buildDiscordShoutrrrUrl(config: Extract<NotificationConfig, { type: "discord" }>): string {
|
||||
export const buildDiscordShoutrrrUrl = (config: Extract<NotificationConfig, { type: "discord" }>) => {
|
||||
const url = new URL(config.webhookUrl);
|
||||
const pathParts = url.pathname.split("/").filter(Boolean);
|
||||
|
||||
|
|
@ -28,4 +28,4 @@ export function buildDiscordShoutrrrUrl(config: Extract<NotificationConfig, { ty
|
|||
}
|
||||
|
||||
return shoutrrrUrl;
|
||||
}
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import type { NotificationConfig } from "~/schemas/notifications";
|
||||
|
||||
export function buildEmailShoutrrrUrl(config: Extract<NotificationConfig, { type: "email" }>): string {
|
||||
export const buildEmailShoutrrrUrl = (config: Extract<NotificationConfig, { type: "email" }>) => {
|
||||
const auth =
|
||||
config.username && config.password
|
||||
? `${encodeURIComponent(config.username)}:${encodeURIComponent(config.password)}@`
|
||||
|
|
@ -10,4 +10,4 @@ export function buildEmailShoutrrrUrl(config: Extract<NotificationConfig, { type
|
|||
const useStartTLS = config.useTLS ? "yes" : "no";
|
||||
|
||||
return `smtp://${auth}${host}/?from=${encodeURIComponent(config.from)}&to=${toRecipients}&starttls=${useStartTLS}`;
|
||||
}
|
||||
};
|
||||
|
|
|
|||
50
app/server/modules/notifications/builders/generic.ts
Normal file
50
app/server/modules/notifications/builders/generic.ts
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
import type { NotificationConfig } from "~/schemas/notifications";
|
||||
|
||||
export const buildGenericShoutrrrUrl = (config: Extract<NotificationConfig, { type: "generic" }>) => {
|
||||
const targetUrl = new URL(config.url);
|
||||
const shoutrrrUrl = new URL(`generic://${targetUrl.host}${targetUrl.pathname}`);
|
||||
|
||||
for (const [key, value] of targetUrl.searchParams.entries()) {
|
||||
const reservedKeys = ["contenttype", "disabletls", "messagekey", "method", "template", "title", "titlekey"];
|
||||
if (reservedKeys.includes(key.toLowerCase())) {
|
||||
shoutrrrUrl.searchParams.append(`_${key}`, value);
|
||||
} else {
|
||||
shoutrrrUrl.searchParams.append(key, value);
|
||||
}
|
||||
}
|
||||
|
||||
if (targetUrl.protocol === "http:") {
|
||||
shoutrrrUrl.searchParams.append("disabletls", "yes");
|
||||
}
|
||||
|
||||
if (config.method) {
|
||||
shoutrrrUrl.searchParams.append("method", config.method);
|
||||
}
|
||||
|
||||
if (config.contentType) {
|
||||
shoutrrrUrl.searchParams.append("contenttype", config.contentType);
|
||||
}
|
||||
|
||||
if (config.useJson) {
|
||||
shoutrrrUrl.searchParams.append("template", "json");
|
||||
}
|
||||
|
||||
if (config.titleKey) {
|
||||
shoutrrrUrl.searchParams.append("titlekey", config.titleKey);
|
||||
}
|
||||
|
||||
if (config.messageKey) {
|
||||
shoutrrrUrl.searchParams.append("messagekey", config.messageKey);
|
||||
}
|
||||
|
||||
if (config.headers) {
|
||||
for (const header of config.headers) {
|
||||
const [key, ...valueParts] = header.split(":");
|
||||
if (key && valueParts.length > 0) {
|
||||
shoutrrrUrl.searchParams.append(`@${key.trim()}`, valueParts.join(":").trim());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return shoutrrrUrl.toString();
|
||||
};
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
import type { NotificationConfig } from "~/schemas/notifications";
|
||||
|
||||
export function buildGotifyShoutrrrUrl(config: Extract<NotificationConfig, { type: "gotify" }>): string {
|
||||
export const buildGotifyShoutrrrUrl = (config: Extract<NotificationConfig, { type: "gotify" }>) => {
|
||||
const url = new URL(config.serverUrl);
|
||||
const hostname = url.hostname;
|
||||
const port = url.port ? `:${url.port}` : "";
|
||||
|
|
@ -13,4 +13,4 @@ export function buildGotifyShoutrrrUrl(config: Extract<NotificationConfig, { typ
|
|||
}
|
||||
|
||||
return shoutrrrUrl;
|
||||
}
|
||||
};
|
||||
|
|
|
|||
|
|
@ -6,9 +6,10 @@ import { buildGotifyShoutrrrUrl } from "./gotify";
|
|||
import { buildNtfyShoutrrrUrl } from "./ntfy";
|
||||
import { buildPushoverShoutrrrUrl } from "./pushover";
|
||||
import { buildTelegramShoutrrrUrl } from "./telegram";
|
||||
import { buildGenericShoutrrrUrl } from "./generic";
|
||||
import { buildCustomShoutrrrUrl } from "./custom";
|
||||
|
||||
export function buildShoutrrrUrl(config: NotificationConfig): string {
|
||||
export const buildShoutrrrUrl = (config: NotificationConfig) => {
|
||||
switch (config.type) {
|
||||
case "email":
|
||||
return buildEmailShoutrrrUrl(config);
|
||||
|
|
@ -24,12 +25,13 @@ export function buildShoutrrrUrl(config: NotificationConfig): string {
|
|||
return buildPushoverShoutrrrUrl(config);
|
||||
case "telegram":
|
||||
return buildTelegramShoutrrrUrl(config);
|
||||
case "generic":
|
||||
return buildGenericShoutrrrUrl(config);
|
||||
case "custom":
|
||||
return buildCustomShoutrrrUrl(config);
|
||||
default: {
|
||||
// TypeScript exhaustiveness check
|
||||
const _exhaustive: never = config;
|
||||
throw new Error(`Unsupported notification type: ${(_exhaustive as NotificationConfig).type}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import type { NotificationConfig } from "~/schemas/notifications";
|
||||
|
||||
export function buildNtfyShoutrrrUrl(config: Extract<NotificationConfig, { type: "ntfy" }>): string {
|
||||
export const buildNtfyShoutrrrUrl = (config: Extract<NotificationConfig, { type: "ntfy" }>) => {
|
||||
let shoutrrrUrl: string;
|
||||
|
||||
const params = new URLSearchParams();
|
||||
|
|
@ -38,4 +38,4 @@ export function buildNtfyShoutrrrUrl(config: Extract<NotificationConfig, { type:
|
|||
}
|
||||
|
||||
return shoutrrrUrl;
|
||||
}
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import type { NotificationConfig } from "~/schemas/notifications";
|
||||
|
||||
export function buildPushoverShoutrrrUrl(config: Extract<NotificationConfig, { type: "pushover" }>): string {
|
||||
export const buildPushoverShoutrrrUrl = (config: Extract<NotificationConfig, { type: "pushover" }>) => {
|
||||
const params = new URLSearchParams();
|
||||
|
||||
if (config.devices) {
|
||||
|
|
@ -19,4 +19,4 @@ export function buildPushoverShoutrrrUrl(config: Extract<NotificationConfig, { t
|
|||
}
|
||||
|
||||
return shoutrrrUrl;
|
||||
}
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import type { NotificationConfig } from "~/schemas/notifications";
|
||||
|
||||
export function buildSlackShoutrrrUrl(config: Extract<NotificationConfig, { type: "slack" }>): string {
|
||||
export const buildSlackShoutrrrUrl = (config: Extract<NotificationConfig, { type: "slack" }>) => {
|
||||
const url = new URL(config.webhookUrl);
|
||||
const pathParts = url.pathname.split("/").filter(Boolean);
|
||||
|
||||
|
|
@ -28,4 +28,4 @@ export function buildSlackShoutrrrUrl(config: Extract<NotificationConfig, { type
|
|||
}
|
||||
|
||||
return shoutrrrUrl;
|
||||
}
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import type { NotificationConfig } from "~/schemas/notifications";
|
||||
|
||||
export function buildTelegramShoutrrrUrl(config: Extract<NotificationConfig, { type: "telegram" }>): string {
|
||||
export const buildTelegramShoutrrrUrl = (config: Extract<NotificationConfig, { type: "telegram" }>) => {
|
||||
return `telegram://${config.botToken}@telegram?channels=${config.chatId}`;
|
||||
}
|
||||
};
|
||||
|
|
|
|||
|
|
@ -71,6 +71,8 @@ async function encryptSensitiveFields(config: NotificationConfig): Promise<Notif
|
|||
...config,
|
||||
botToken: await cryptoUtils.sealSecret(config.botToken),
|
||||
};
|
||||
case "generic":
|
||||
return config;
|
||||
case "custom":
|
||||
return {
|
||||
...config,
|
||||
|
|
@ -118,6 +120,8 @@ async function decryptSensitiveFields(config: NotificationConfig): Promise<Notif
|
|||
...config,
|
||||
botToken: await cryptoUtils.resolveSecret(config.botToken),
|
||||
};
|
||||
case "generic":
|
||||
return config;
|
||||
case "custom":
|
||||
return {
|
||||
...config,
|
||||
|
|
|
|||
|
|
@ -50,6 +50,7 @@ describe("repositories security", () => {
|
|||
{ method: "POST", path: "/api/v1/repositories/test-repo/restore" },
|
||||
{ method: "POST", path: "/api/v1/repositories/test-repo/doctor" },
|
||||
{ method: "DELETE", path: "/api/v1/repositories/test-repo/snapshots/test-snapshot" },
|
||||
{ method: "DELETE", path: "/api/v1/repositories/test-repo/snapshots" },
|
||||
{ method: "PATCH", path: "/api/v1/repositories/test-repo" },
|
||||
];
|
||||
|
||||
|
|
|
|||
|
|
@ -5,6 +5,8 @@ import {
|
|||
createRepositoryDto,
|
||||
deleteRepositoryDto,
|
||||
deleteSnapshotDto,
|
||||
deleteSnapshotsBody,
|
||||
deleteSnapshotsDto,
|
||||
doctorRepositoryDto,
|
||||
getRepositoryDto,
|
||||
getSnapshotDetailsDto,
|
||||
|
|
@ -16,10 +18,13 @@ import {
|
|||
listSnapshotsFilters,
|
||||
restoreSnapshotBody,
|
||||
restoreSnapshotDto,
|
||||
tagSnapshotsBody,
|
||||
tagSnapshotsDto,
|
||||
updateRepositoryBody,
|
||||
updateRepositoryDto,
|
||||
type DeleteRepositoryDto,
|
||||
type DeleteSnapshotDto,
|
||||
type DeleteSnapshotsResponseDto,
|
||||
type DoctorRepositoryDto,
|
||||
type GetRepositoryDto,
|
||||
type GetSnapshotDetailsDto,
|
||||
|
|
@ -27,6 +32,7 @@ import {
|
|||
type ListSnapshotFilesDto,
|
||||
type ListSnapshotsDto,
|
||||
type RestoreSnapshotDto,
|
||||
type TagSnapshotsResponseDto,
|
||||
type UpdateRepositoryDto,
|
||||
} from "./repositories.dto";
|
||||
import { repositoriesService } from "./repositories.service";
|
||||
|
|
@ -160,6 +166,22 @@ export const repositoriesController = new Hono()
|
|||
|
||||
return c.json<DeleteSnapshotDto>({ message: "Snapshot deleted" }, 200);
|
||||
})
|
||||
.delete("/:id/snapshots", deleteSnapshotsDto, validator("json", deleteSnapshotsBody), async (c) => {
|
||||
const { id } = c.req.param();
|
||||
const { snapshotIds } = c.req.valid("json");
|
||||
|
||||
await repositoriesService.deleteSnapshots(id, snapshotIds);
|
||||
|
||||
return c.json<DeleteSnapshotsResponseDto>({ message: "Snapshots deleted" }, 200);
|
||||
})
|
||||
.post("/:id/snapshots/tag", tagSnapshotsDto, validator("json", tagSnapshotsBody), async (c) => {
|
||||
const { id } = c.req.param();
|
||||
const { snapshotIds, ...tags } = c.req.valid("json");
|
||||
|
||||
await repositoriesService.tagSnapshots(id, snapshotIds, tags);
|
||||
|
||||
return c.json<TagSnapshotsResponseDto>({ message: "Snapshots tagged" }, 200);
|
||||
})
|
||||
.patch("/:id", updateRepositoryDto, validator("json", updateRepositoryBody), async (c) => {
|
||||
const { id } = c.req.param();
|
||||
const body = c.req.valid("json");
|
||||
|
|
|
|||
|
|
@ -400,3 +400,64 @@ export const deleteSnapshotDto = describeRoute({
|
|||
},
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Delete multiple snapshots
|
||||
*/
|
||||
export const deleteSnapshotsBody = type({
|
||||
snapshotIds: "string[]>=1",
|
||||
});
|
||||
|
||||
export const deleteSnapshotsResponse = type({
|
||||
message: "string",
|
||||
});
|
||||
|
||||
export type DeleteSnapshotsResponseDto = typeof deleteSnapshotsResponse.infer;
|
||||
|
||||
export const deleteSnapshotsDto = describeRoute({
|
||||
description: "Delete multiple snapshots from a repository",
|
||||
tags: ["Repositories"],
|
||||
operationId: "deleteSnapshots",
|
||||
responses: {
|
||||
200: {
|
||||
description: "Snapshots deleted successfully",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(deleteSnapshotsResponse),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Tag multiple snapshots
|
||||
*/
|
||||
export const tagSnapshotsBody = type({
|
||||
snapshotIds: "string[]>=1",
|
||||
add: "string[]?",
|
||||
remove: "string[]?",
|
||||
set: "string[]?",
|
||||
});
|
||||
|
||||
export const tagSnapshotsResponse = type({
|
||||
message: "string",
|
||||
});
|
||||
|
||||
export type TagSnapshotsResponseDto = typeof tagSnapshotsResponse.infer;
|
||||
|
||||
export const tagSnapshotsDto = describeRoute({
|
||||
description: "Tag multiple snapshots in a repository",
|
||||
tags: ["Repositories"],
|
||||
operationId: "tagSnapshots",
|
||||
responses: {
|
||||
200: {
|
||||
description: "Snapshots tagged successfully",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(tagSnapshotsResponse),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -34,6 +34,10 @@ const encryptConfig = async (config: RepositoryConfig): Promise<RepositoryConfig
|
|||
encryptedConfig.customPassword = await cryptoUtils.sealSecret(config.customPassword);
|
||||
}
|
||||
|
||||
if (config.cacert) {
|
||||
encryptedConfig.cacert = await cryptoUtils.sealSecret(config.cacert);
|
||||
}
|
||||
|
||||
switch (config.backend) {
|
||||
case "s3":
|
||||
case "r2":
|
||||
|
|
@ -389,6 +393,40 @@ const deleteSnapshot = async (id: string, snapshotId: string) => {
|
|||
}
|
||||
};
|
||||
|
||||
const deleteSnapshots = async (id: string, snapshotIds: string[]) => {
|
||||
const repository = await findRepository(id);
|
||||
|
||||
if (!repository) {
|
||||
throw new NotFoundError("Repository not found");
|
||||
}
|
||||
|
||||
const releaseLock = await repoMutex.acquireExclusive(repository.id, `delete:bulk`);
|
||||
try {
|
||||
await restic.deleteSnapshots(repository.config, snapshotIds);
|
||||
} finally {
|
||||
releaseLock();
|
||||
}
|
||||
};
|
||||
|
||||
const tagSnapshots = async (
|
||||
id: string,
|
||||
snapshotIds: string[],
|
||||
tags: { add?: string[]; remove?: string[]; set?: string[] },
|
||||
) => {
|
||||
const repository = await findRepository(id);
|
||||
|
||||
if (!repository) {
|
||||
throw new NotFoundError("Repository not found");
|
||||
}
|
||||
|
||||
const releaseLock = await repoMutex.acquireExclusive(repository.id, `tag:bulk`);
|
||||
try {
|
||||
await restic.tagSnapshots(repository.config, snapshotIds, tags);
|
||||
} finally {
|
||||
releaseLock();
|
||||
}
|
||||
};
|
||||
|
||||
const updateRepository = async (id: string, updates: { name?: string; compressionMode?: CompressionMode }) => {
|
||||
const existing = await findRepository(id);
|
||||
|
||||
|
|
@ -439,4 +477,6 @@ export const repositoriesService = {
|
|||
checkHealth,
|
||||
doctorRepository,
|
||||
deleteSnapshot,
|
||||
deleteSnapshots,
|
||||
tagSnapshots,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -3,8 +3,10 @@ import { validator } from "hono-openapi";
|
|||
import {
|
||||
downloadResticPasswordBodySchema,
|
||||
downloadResticPasswordDto,
|
||||
getUpdatesDto,
|
||||
systemInfoDto,
|
||||
type SystemInfoDto,
|
||||
type UpdateInfoDto,
|
||||
} from "./system.dto";
|
||||
import { systemService } from "./system.service";
|
||||
import { requireAuth } from "../auth/auth.middleware";
|
||||
|
|
@ -20,6 +22,11 @@ export const systemController = new Hono()
|
|||
|
||||
return c.json<SystemInfoDto>(info, 200);
|
||||
})
|
||||
.get("/updates", getUpdatesDto, async (c) => {
|
||||
const updates = await systemService.getUpdates();
|
||||
|
||||
return c.json<UpdateInfoDto>(updates, 200);
|
||||
})
|
||||
.post(
|
||||
"/restic-password",
|
||||
downloadResticPasswordDto,
|
||||
|
|
|
|||
|
|
@ -12,6 +12,22 @@ export const systemInfoResponse = type({
|
|||
|
||||
export type SystemInfoDto = typeof systemInfoResponse.infer;
|
||||
|
||||
export const releaseInfoSchema = type({
|
||||
version: "string",
|
||||
url: "string",
|
||||
publishedAt: "string",
|
||||
body: "string",
|
||||
});
|
||||
|
||||
export const updateInfoResponse = type({
|
||||
currentVersion: "string",
|
||||
latestVersion: "string",
|
||||
hasUpdate: "boolean",
|
||||
missedReleases: releaseInfoSchema.array(),
|
||||
});
|
||||
|
||||
export type UpdateInfoDto = typeof updateInfoResponse.infer;
|
||||
|
||||
export const systemInfoDto = describeRoute({
|
||||
description: "Get system information including available capabilities",
|
||||
tags: ["System"],
|
||||
|
|
@ -28,6 +44,22 @@ export const systemInfoDto = describeRoute({
|
|||
},
|
||||
});
|
||||
|
||||
export const getUpdatesDto = describeRoute({
|
||||
description: "Check for application updates from GitHub",
|
||||
tags: ["System"],
|
||||
operationId: "getUpdates",
|
||||
responses: {
|
||||
200: {
|
||||
description: "Update information and missed releases",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(updateInfoResponse),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const downloadResticPasswordBodySchema = type({
|
||||
password: "string",
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,4 +1,11 @@
|
|||
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";
|
||||
|
||||
const CACHE_TTL = 60 * 60;
|
||||
|
||||
const getSystemInfo = async () => {
|
||||
return {
|
||||
|
|
@ -6,6 +13,84 @@ 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: [],
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
export const systemService = {
|
||||
getSystemInfo,
|
||||
getUpdates,
|
||||
};
|
||||
|
|
|
|||
96
app/server/utils/cache.ts
Normal file
96
app/server/utils/cache.ts
Normal file
|
|
@ -0,0 +1,96 @@
|
|||
import { Database } from "bun:sqlite";
|
||||
import path from "node:path";
|
||||
import fs from "node:fs";
|
||||
import { DATABASE_URL } from "../core/constants";
|
||||
|
||||
export const ONE_DAY_IN_SECONDS = 60 * 60 * 24;
|
||||
|
||||
export interface CacheOptions {
|
||||
dbPath?: string;
|
||||
}
|
||||
|
||||
export const createCache = (options: CacheOptions = {}) => {
|
||||
const defaultPath = path.join(path.dirname(DATABASE_URL), "cache.db");
|
||||
const dbPath = options.dbPath || defaultPath;
|
||||
|
||||
const dir = path.dirname(dbPath);
|
||||
if (!fs.existsSync(dir)) {
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
}
|
||||
|
||||
const db = new Database(dbPath);
|
||||
|
||||
db.run("CREATE TABLE IF NOT EXISTS cache (key TEXT PRIMARY KEY, value TEXT, expiration INTEGER)");
|
||||
|
||||
const set = (key: string, value: unknown, expirationSeconds = ONE_DAY_IN_SECONDS) => {
|
||||
const expiration = Date.now() + expirationSeconds * 1000;
|
||||
const stmt = db.prepare("INSERT OR REPLACE INTO cache (key, value, expiration) VALUES (?, ?, ?)");
|
||||
stmt.run(key, JSON.stringify(value), expiration);
|
||||
};
|
||||
|
||||
const get = <T>(key: string): T | undefined => {
|
||||
const stmt = db.prepare("SELECT value, expiration FROM cache WHERE key = ?");
|
||||
const row = stmt.get(key) as { value: string; expiration: number } | undefined;
|
||||
|
||||
if (!row) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (row.expiration < Date.now()) {
|
||||
const delStmt = db.prepare("DELETE FROM cache WHERE key = ?");
|
||||
delStmt.run(key);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
try {
|
||||
return JSON.parse(row.value) as T;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
|
||||
const del = (key: string) => {
|
||||
const stmt = db.prepare("DELETE FROM cache WHERE key = ?");
|
||||
stmt.run(key);
|
||||
};
|
||||
|
||||
const getByPrefix = <T>(prefix: string): { key: string; value: T }[] => {
|
||||
const stmt = db.prepare("SELECT key, value, expiration FROM cache WHERE key LIKE ?");
|
||||
const rows = stmt.all(`${prefix}%`) as { key: string; value: string; expiration: number }[];
|
||||
|
||||
const now = Date.now();
|
||||
const results: { key: string; value: T }[] = [];
|
||||
|
||||
for (const row of rows) {
|
||||
if (row.expiration < now) {
|
||||
const delStmt = db.prepare("DELETE FROM cache WHERE key = ?");
|
||||
delStmt.run(row.key);
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
results.push({
|
||||
key: row.key,
|
||||
value: JSON.parse(row.value) as T,
|
||||
});
|
||||
} catch {
|
||||
// Ignore malformed entries
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
};
|
||||
|
||||
const clear = () => {
|
||||
db.run("DELETE FROM cache");
|
||||
};
|
||||
|
||||
return {
|
||||
set,
|
||||
get,
|
||||
del,
|
||||
getByPrefix,
|
||||
clear,
|
||||
};
|
||||
};
|
||||
|
||||
export const cache = createCache();
|
||||
|
|
@ -205,6 +205,17 @@ export const buildEnv = async (config: RepositoryConfig) => {
|
|||
}
|
||||
}
|
||||
|
||||
if (config.cacert) {
|
||||
const decryptedCert = await cryptoUtils.resolveSecret(config.cacert);
|
||||
const certPath = path.join("/tmp", `zerobyte-cacert-${crypto.randomBytes(8).toString("hex")}.pem`);
|
||||
await fs.writeFile(certPath, decryptedCert, { mode: 0o600 });
|
||||
env.RESTIC_CACERT = certPath;
|
||||
}
|
||||
|
||||
if (config.insecureTls) {
|
||||
env._INSECURE_TLS = "true";
|
||||
}
|
||||
|
||||
return env;
|
||||
};
|
||||
|
||||
|
|
@ -221,7 +232,7 @@ const init = async (config: RepositoryConfig) => {
|
|||
addCommonArgs(args, env);
|
||||
|
||||
const res = await safeSpawn({ command: "restic", args, env });
|
||||
await cleanupTemporaryKeys(config, env);
|
||||
await cleanupTemporaryKeys(env);
|
||||
|
||||
if (res.exitCode !== 0) {
|
||||
logger.error(`Restic init failed: ${res.stderr}`);
|
||||
|
|
@ -349,7 +360,7 @@ const backup = async (
|
|||
finally: async () => {
|
||||
includeFile && (await fs.unlink(includeFile).catch(() => {}));
|
||||
excludeFile && (await fs.unlink(excludeFile).catch(() => {}));
|
||||
await cleanupTemporaryKeys(config, env);
|
||||
await cleanupTemporaryKeys(env);
|
||||
},
|
||||
});
|
||||
|
||||
|
|
@ -447,7 +458,7 @@ const restore = async (
|
|||
logger.debug(`Executing: restic ${args.join(" ")}`);
|
||||
const res = await safeSpawn({ command: "restic", args, env });
|
||||
|
||||
await cleanupTemporaryKeys(config, env);
|
||||
await cleanupTemporaryKeys(env);
|
||||
|
||||
if (res.exitCode !== 0) {
|
||||
logger.error(`Restic restore failed: ${res.stderr}`);
|
||||
|
|
@ -508,7 +519,7 @@ const snapshots = async (config: RepositoryConfig, options: { tags?: string[] }
|
|||
addCommonArgs(args, env);
|
||||
|
||||
const res = await safeSpawn({ command: "restic", args, env });
|
||||
await cleanupTemporaryKeys(config, env);
|
||||
await cleanupTemporaryKeys(env);
|
||||
|
||||
if (res.exitCode !== 0) {
|
||||
logger.error(`Restic snapshots retrieval failed: ${res.stderr}`);
|
||||
|
|
@ -557,7 +568,7 @@ const forget = async (config: RepositoryConfig, options: RetentionPolicy, extra:
|
|||
addCommonArgs(args, env);
|
||||
|
||||
const res = await safeSpawn({ command: "restic", args, env });
|
||||
await cleanupTemporaryKeys(config, env);
|
||||
await cleanupTemporaryKeys(env);
|
||||
|
||||
if (res.exitCode !== 0) {
|
||||
logger.error(`Restic forget failed: ${res.stderr}`);
|
||||
|
|
@ -567,15 +578,19 @@ const forget = async (config: RepositoryConfig, options: RetentionPolicy, extra:
|
|||
return { success: true };
|
||||
};
|
||||
|
||||
const deleteSnapshot = async (config: RepositoryConfig, snapshotId: string) => {
|
||||
const deleteSnapshots = async (config: RepositoryConfig, snapshotIds: string[]) => {
|
||||
const repoUrl = buildRepoUrl(config);
|
||||
const env = await buildEnv(config);
|
||||
|
||||
const args: string[] = ["--repo", repoUrl, "forget", snapshotId, "--prune"];
|
||||
if (snapshotIds.length === 0) {
|
||||
throw new Error("No snapshot IDs provided for deletion.");
|
||||
}
|
||||
|
||||
const args: string[] = ["--repo", repoUrl, "forget", ...snapshotIds, "--prune"];
|
||||
addCommonArgs(args, env);
|
||||
|
||||
const res = await safeSpawn({ command: "restic", args, env });
|
||||
await cleanupTemporaryKeys(config, env);
|
||||
await cleanupTemporaryKeys(env);
|
||||
|
||||
if (res.exitCode !== 0) {
|
||||
logger.error(`Restic snapshot deletion failed: ${res.stderr}`);
|
||||
|
|
@ -585,6 +600,55 @@ const deleteSnapshot = async (config: RepositoryConfig, snapshotId: string) => {
|
|||
return { success: true };
|
||||
};
|
||||
|
||||
const deleteSnapshot = async (config: RepositoryConfig, snapshotId: string) => {
|
||||
return deleteSnapshots(config, [snapshotId]);
|
||||
};
|
||||
|
||||
const tagSnapshots = async (
|
||||
config: RepositoryConfig,
|
||||
snapshotIds: string[],
|
||||
tags: { add?: string[]; remove?: string[]; set?: string[] },
|
||||
) => {
|
||||
const repoUrl = buildRepoUrl(config);
|
||||
const env = await buildEnv(config);
|
||||
|
||||
if (snapshotIds.length === 0) {
|
||||
throw new Error("No snapshot IDs provided for tagging.");
|
||||
}
|
||||
|
||||
const args: string[] = ["--repo", repoUrl, "tag", ...snapshotIds];
|
||||
|
||||
if (tags.add) {
|
||||
for (const tag of tags.add) {
|
||||
args.push("--add", tag);
|
||||
}
|
||||
}
|
||||
|
||||
if (tags.remove) {
|
||||
for (const tag of tags.remove) {
|
||||
args.push("--remove", tag);
|
||||
}
|
||||
}
|
||||
|
||||
if (tags.set) {
|
||||
for (const tag of tags.set) {
|
||||
args.push("--set", tag);
|
||||
}
|
||||
}
|
||||
|
||||
addCommonArgs(args, env);
|
||||
|
||||
const res = await safeSpawn({ command: "restic", args, env });
|
||||
await cleanupTemporaryKeys(env);
|
||||
|
||||
if (res.exitCode !== 0) {
|
||||
logger.error(`Restic snapshot tagging failed: ${res.stderr}`);
|
||||
throw new ResticError(res.exitCode, res.stderr);
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
};
|
||||
|
||||
const lsNodeSchema = type({
|
||||
name: "string",
|
||||
type: "string",
|
||||
|
|
@ -625,7 +689,7 @@ const ls = async (config: RepositoryConfig, snapshotId: string, path?: string) =
|
|||
addCommonArgs(args, env);
|
||||
|
||||
const res = await safeSpawn({ command: "restic", args, env });
|
||||
await cleanupTemporaryKeys(config, env);
|
||||
await cleanupTemporaryKeys(env);
|
||||
|
||||
if (res.exitCode !== 0) {
|
||||
logger.error(`Restic ls failed: ${res.stderr}`);
|
||||
|
|
@ -676,7 +740,7 @@ const unlock = async (config: RepositoryConfig) => {
|
|||
addCommonArgs(args, env);
|
||||
|
||||
const res = await safeSpawn({ command: "restic", args, env });
|
||||
await cleanupTemporaryKeys(config, env);
|
||||
await cleanupTemporaryKeys(env);
|
||||
|
||||
if (res.exitCode !== 0) {
|
||||
logger.error(`Restic unlock failed: ${res.stderr}`);
|
||||
|
|
@ -700,7 +764,7 @@ const check = async (config: RepositoryConfig, options?: { readData?: boolean })
|
|||
addCommonArgs(args, env);
|
||||
|
||||
const res = await safeSpawn({ command: "restic", args, env });
|
||||
await cleanupTemporaryKeys(config, env);
|
||||
await cleanupTemporaryKeys(env);
|
||||
|
||||
const { stdout, stderr } = res;
|
||||
|
||||
|
|
@ -733,7 +797,7 @@ const repairIndex = async (config: RepositoryConfig) => {
|
|||
addCommonArgs(args, env);
|
||||
|
||||
const res = await safeSpawn({ command: "restic", args, env });
|
||||
await cleanupTemporaryKeys(config, env);
|
||||
await cleanupTemporaryKeys(env);
|
||||
|
||||
const { stdout, stderr } = res;
|
||||
|
||||
|
|
@ -793,8 +857,8 @@ const copy = async (
|
|||
|
||||
const res = await safeSpawn({ command: "restic", args, env });
|
||||
|
||||
await cleanupTemporaryKeys(sourceConfig, sourceEnv);
|
||||
await cleanupTemporaryKeys(destConfig, destEnv);
|
||||
await cleanupTemporaryKeys(sourceEnv);
|
||||
await cleanupTemporaryKeys(destEnv);
|
||||
|
||||
const { stdout, stderr } = res;
|
||||
|
||||
|
|
@ -810,19 +874,26 @@ const copy = async (
|
|||
};
|
||||
};
|
||||
|
||||
export const cleanupTemporaryKeys = async (config: RepositoryConfig, env: Record<string, string>) => {
|
||||
if (config.backend === "sftp") {
|
||||
if (env._SFTP_KEY_PATH) {
|
||||
await fs.unlink(env._SFTP_KEY_PATH).catch(() => {});
|
||||
}
|
||||
if (env._SFTP_KNOWN_HOSTS_PATH) {
|
||||
await fs.unlink(env._SFTP_KNOWN_HOSTS_PATH).catch(() => {});
|
||||
}
|
||||
} else if (config.isExistingRepository && config.customPassword && env.RESTIC_PASSWORD_FILE) {
|
||||
export const cleanupTemporaryKeys = async (env: Record<string, string>) => {
|
||||
if (env._SFTP_KEY_PATH) {
|
||||
await fs.unlink(env._SFTP_KEY_PATH).catch(() => {});
|
||||
}
|
||||
|
||||
if (env._SFTP_KNOWN_HOSTS_PATH) {
|
||||
await fs.unlink(env._SFTP_KNOWN_HOSTS_PATH).catch(() => {});
|
||||
}
|
||||
|
||||
if (env.RESTIC_PASSWORD_FILE && env.RESTIC_PASSWORD_FILE !== RESTIC_PASS_FILE) {
|
||||
await fs.unlink(env.RESTIC_PASSWORD_FILE).catch(() => {});
|
||||
} else if (config.backend === "gcs" && env.GOOGLE_APPLICATION_CREDENTIALS) {
|
||||
}
|
||||
|
||||
if (env.GOOGLE_APPLICATION_CREDENTIALS) {
|
||||
await fs.unlink(env.GOOGLE_APPLICATION_CREDENTIALS).catch(() => {});
|
||||
}
|
||||
|
||||
if (env.RESTIC_CACERT) {
|
||||
await fs.unlink(env.RESTIC_CACERT).catch(() => {});
|
||||
}
|
||||
};
|
||||
|
||||
export const addCommonArgs = (args: string[], env: Record<string, string>) => {
|
||||
|
|
@ -831,6 +902,14 @@ export const addCommonArgs = (args: string[], env: Record<string, string>) => {
|
|||
if (env._SFTP_SSH_ARGS) {
|
||||
args.push("-o", `sftp.args=${env._SFTP_SSH_ARGS}`);
|
||||
}
|
||||
|
||||
if (env._INSECURE_TLS === "true") {
|
||||
args.push("--insecure-tls");
|
||||
}
|
||||
|
||||
if (env.RESTIC_CACERT) {
|
||||
args.push("--cacert", env.RESTIC_CACERT);
|
||||
}
|
||||
};
|
||||
|
||||
export const restic = {
|
||||
|
|
@ -841,6 +920,8 @@ export const restic = {
|
|||
snapshots,
|
||||
forget,
|
||||
deleteSnapshot,
|
||||
deleteSnapshots,
|
||||
tagSnapshots,
|
||||
unlock,
|
||||
ls,
|
||||
check,
|
||||
|
|
|
|||
4
app/test/setup-client.ts
Normal file
4
app/test/setup-client.ts
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
import "./setup.ts";
|
||||
import { GlobalRegistrator } from "@happy-dom/global-registrator";
|
||||
|
||||
GlobalRegistrator.register();
|
||||
261
bun.lock
261
bun.lock
|
|
@ -13,6 +13,7 @@
|
|||
"@inquirer/prompts": "^8.0.2",
|
||||
"@radix-ui/react-alert-dialog": "^1.1.15",
|
||||
"@radix-ui/react-checkbox": "^1.3.3",
|
||||
"@radix-ui/react-collapsible": "^1.1.12",
|
||||
"@radix-ui/react-dialog": "^1.1.15",
|
||||
"@radix-ui/react-label": "^2.1.8",
|
||||
"@radix-ui/react-progress": "^1.1.8",
|
||||
|
|
@ -48,9 +49,12 @@
|
|||
"react": "^19.2.1",
|
||||
"react-dom": "^19.2.1",
|
||||
"react-hook-form": "^7.68.0",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-router": "^7.10.0",
|
||||
"react-router-hono-server": "^2.22.0",
|
||||
"recharts": "3.5.1",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"semver": "^7.7.3",
|
||||
"slugify": "^1.6.6",
|
||||
"sonner": "^2.0.7",
|
||||
"tailwind-merge": "^3.4.0",
|
||||
|
|
@ -61,14 +65,19 @@
|
|||
"devDependencies": {
|
||||
"@biomejs/biome": "^2.3.8",
|
||||
"@faker-js/faker": "^10.1.0",
|
||||
"@happy-dom/global-registrator": "^20.0.11",
|
||||
"@hey-api/openapi-ts": "^0.88.0",
|
||||
"@react-router/dev": "^7.10.0",
|
||||
"@tailwindcss/typography": "^0.5.19",
|
||||
"@tailwindcss/vite": "^4.1.17",
|
||||
"@tanstack/react-query-devtools": "^5.91.1",
|
||||
"@testing-library/dom": "^10.4.1",
|
||||
"@testing-library/react": "^16.3.1",
|
||||
"@types/bun": "^1.3.4",
|
||||
"@types/node": "^25.0.3",
|
||||
"@types/react": "^19.2.7",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@types/semver": "^7.7.1",
|
||||
"dotenv-cli": "^11.0.0",
|
||||
"drizzle-kit": "^0.31.7",
|
||||
"lightningcss": "^1.30.2",
|
||||
|
|
@ -140,6 +149,8 @@
|
|||
|
||||
"@babel/preset-typescript": ["@babel/preset-typescript@7.28.5", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1", "@babel/helper-validator-option": "^7.27.1", "@babel/plugin-syntax-jsx": "^7.27.1", "@babel/plugin-transform-modules-commonjs": "^7.27.1", "@babel/plugin-transform-typescript": "^7.28.5" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-+bQy5WOI2V6LJZpPVxY+yp66XdZ2yifu0Mc1aP5CQKgjn4QM5IN2i5fAZ4xKop47pr8rpVhiAeu+nDQa12C8+g=="],
|
||||
|
||||
"@babel/runtime": ["@babel/runtime@7.28.4", "", {}, "sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ=="],
|
||||
|
||||
"@babel/template": ["@babel/template@7.27.2", "", { "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/parser": "^7.27.2", "@babel/types": "^7.27.1" } }, "sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw=="],
|
||||
|
||||
"@babel/traverse": ["@babel/traverse@7.28.5", "", { "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.5", "@babel/helper-globals": "^7.28.0", "@babel/parser": "^7.28.5", "@babel/template": "^7.27.2", "@babel/types": "^7.28.5", "debug": "^4.3.1" } }, "sha512-TCCj4t55U90khlYkVV/0TfkJkAkUg3jZFA3Neb7unZT8CPok7iiRfaX0F+WnqWqt7OxhOn0uBKXCw4lbL8W0aQ=="],
|
||||
|
|
@ -244,6 +255,8 @@
|
|||
|
||||
"@floating-ui/utils": ["@floating-ui/utils@0.2.10", "", {}, "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ=="],
|
||||
|
||||
"@happy-dom/global-registrator": ["@happy-dom/global-registrator@20.0.11", "", { "dependencies": { "@types/node": "^20.0.0", "happy-dom": "^20.0.11" } }, "sha512-GqNqiShBT/lzkHTMC/slKBrvN0DsD4Di8ssBk4aDaVgEn+2WMzE6DXxq701ndSXj7/0cJ8mNT71pM7Bnrr6JRw=="],
|
||||
|
||||
"@hey-api/codegen-core": ["@hey-api/codegen-core@0.3.3", "", { "peerDependencies": { "typescript": ">=5.5.3" } }, "sha512-vArVDtrvdzFewu1hnjUm4jX1NBITlSCeO81EdWq676MxQbyxsGcDPAgohaSA+Wvr4HjPSvsg2/1s2zYxUtXebg=="],
|
||||
|
||||
"@hey-api/json-schema-ref-parser": ["@hey-api/json-schema-ref-parser@1.2.2", "", { "dependencies": { "@jsdevtools/ono": "^7.1.3", "@types/json-schema": "^7.0.15", "js-yaml": "^4.1.1", "lodash": "^4.17.21" } }, "sha512-oS+5yAdwnK20lSeFO1d53Ku+yaGCsY8PcrmSq2GtSs3bsBfRnHAbpPKSVzQcaxAOrzj5NB+f34WhZglVrNayBA=="],
|
||||
|
|
@ -316,6 +329,8 @@
|
|||
|
||||
"@radix-ui/react-checkbox": ["@radix-ui/react-checkbox@1.3.3", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-previous": "1.1.1", "@radix-ui/react-use-size": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-wBbpv+NQftHDdG86Qc0pIyXk5IR3tM8Vd0nWLKDcX8nNn4nXFOFwsKuqw2okA/1D/mpaAkmuyndrPJTYDNZtFw=="],
|
||||
|
||||
"@radix-ui/react-collapsible": ["@radix-ui/react-collapsible@1.1.12", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-Uu+mSh4agx2ib1uIGPP4/CKNULyajb3p92LsVXmH2EHVMTfZWpll88XJ0j4W0z3f8NK1eYl1+Mf/szHPmcHzyA=="],
|
||||
|
||||
"@radix-ui/react-collection": ["@radix-ui/react-collection@1.1.7", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-Fh9rGN0MoI4ZFUNyfFVNU4y9LUz93u9/0K+yLgA2bwRojxM8JU1DyvvMBabnZPBgMWREAJvU2jjVzq+LrFUglw=="],
|
||||
|
||||
"@radix-ui/react-compose-refs": ["@radix-ui/react-compose-refs@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg=="],
|
||||
|
|
@ -484,6 +499,8 @@
|
|||
|
||||
"@tailwindcss/oxide-win32-x64-msvc": ["@tailwindcss/oxide-win32-x64-msvc@4.1.18", "", { "os": "win32", "cpu": "x64" }, "sha512-bJWbyYpUlqamC8dpR7pfjA0I7vdF6t5VpUGMWRkXVE3AXgIZjYUYAK7II1GNaxR8J1SSrSrppRar8G++JekE3Q=="],
|
||||
|
||||
"@tailwindcss/typography": ["@tailwindcss/typography@0.5.19", "", { "dependencies": { "postcss-selector-parser": "6.0.10" }, "peerDependencies": { "tailwindcss": ">=3.0.0 || insiders || >=4.0.0-alpha.20 || >=4.0.0-beta.1" } }, "sha512-w31dd8HOx3k9vPtcQh5QHP9GwKcgbMp87j58qi6xgiBnFFtKEAgCWnDw4qUT8aHwkCp8bKvb/KGKWWHedP0AAg=="],
|
||||
|
||||
"@tailwindcss/vite": ["@tailwindcss/vite@4.1.18", "", { "dependencies": { "@tailwindcss/node": "4.1.18", "@tailwindcss/oxide": "4.1.18", "tailwindcss": "4.1.18" }, "peerDependencies": { "vite": "^5.2.0 || ^6 || ^7" } }, "sha512-jVA+/UpKL1vRLg6Hkao5jldawNmRo7mQYrZtNHMIVpLfLhDml5nMRUo/8MwoX2vNXvnaXNNMedrMfMugAVX1nA=="],
|
||||
|
||||
"@tanstack/query-core": ["@tanstack/query-core@5.90.12", "", {}, "sha512-T1/8t5DhV/SisWjDnaiU2drl6ySvsHj1bHBCWNXd+/T+Hh1cf6JodyEYMd5sgwm+b/mETT4EV3H+zCVczCU5hg=="],
|
||||
|
|
@ -494,6 +511,12 @@
|
|||
|
||||
"@tanstack/react-query-devtools": ["@tanstack/react-query-devtools@5.91.1", "", { "dependencies": { "@tanstack/query-devtools": "5.91.1" }, "peerDependencies": { "@tanstack/react-query": "^5.90.10", "react": "^18 || ^19" } }, "sha512-tRnJYwEbH0kAOuToy8Ew7bJw1lX3AjkkgSlf/vzb+NpnqmHPdWM+lA2DSdGQSLi1SU0PDRrrCI1vnZnci96CsQ=="],
|
||||
|
||||
"@testing-library/dom": ["@testing-library/dom@10.4.1", "", { "dependencies": { "@babel/code-frame": "^7.10.4", "@babel/runtime": "^7.12.5", "@types/aria-query": "^5.0.1", "aria-query": "5.3.0", "dom-accessibility-api": "^0.5.9", "lz-string": "^1.5.0", "picocolors": "1.1.1", "pretty-format": "^27.0.2" } }, "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg=="],
|
||||
|
||||
"@testing-library/react": ["@testing-library/react@16.3.1", "", { "dependencies": { "@babel/runtime": "^7.12.5" }, "peerDependencies": { "@testing-library/dom": "^10.0.0", "@types/react": "^18.0.0 || ^19.0.0", "@types/react-dom": "^18.0.0 || ^19.0.0", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-gr4KtAWqIOQoucWYD/f6ki+j5chXfcPc74Col/6poTyqTmn7zRmodWahWRCp8tYd+GMqBonw6hstNzqjbs6gjw=="],
|
||||
|
||||
"@types/aria-query": ["@types/aria-query@5.0.4", "", {}, "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw=="],
|
||||
|
||||
"@types/bun": ["@types/bun@1.3.5", "", { "dependencies": { "bun-types": "1.3.5" } }, "sha512-RnygCqNrd3srIPEWBd5LFeUYG7plCoH2Yw9WaZGyNmdTEei+gWaHqydbaIRkIkcbXwhBT94q78QljxN0Sk838w=="],
|
||||
|
||||
"@types/d3-array": ["@types/d3-array@3.2.2", "", {}, "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw=="],
|
||||
|
|
@ -514,27 +537,45 @@
|
|||
|
||||
"@types/d3-timer": ["@types/d3-timer@3.0.2", "", {}, "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw=="],
|
||||
|
||||
"@types/debug": ["@types/debug@4.1.12", "", { "dependencies": { "@types/ms": "*" } }, "sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ=="],
|
||||
|
||||
"@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="],
|
||||
|
||||
"@types/estree-jsx": ["@types/estree-jsx@1.0.5", "", { "dependencies": { "@types/estree": "*" } }, "sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg=="],
|
||||
|
||||
"@types/hast": ["@types/hast@3.0.4", "", { "dependencies": { "@types/unist": "*" } }, "sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ=="],
|
||||
|
||||
"@types/json-schema": ["@types/json-schema@7.0.15", "", {}, "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="],
|
||||
|
||||
"@types/mdast": ["@types/mdast@4.0.4", "", { "dependencies": { "@types/unist": "*" } }, "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA=="],
|
||||
|
||||
"@types/ms": ["@types/ms@2.1.0", "", {}, "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA=="],
|
||||
|
||||
"@types/node": ["@types/node@25.0.3", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-W609buLVRVmeW693xKfzHeIV6nJGGz98uCPfeXI1ELMLXVeKYZ9m15fAMSaUPBHYLGFsVRcMmSCksQOrZV9BYA=="],
|
||||
|
||||
"@types/react": ["@types/react@19.2.7", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg=="],
|
||||
|
||||
"@types/react-dom": ["@types/react-dom@19.2.3", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ=="],
|
||||
|
||||
"@types/semver": ["@types/semver@7.7.1", "", {}, "sha512-FmgJfu+MOcQ370SD0ev7EI8TlCAfKYU+B4m5T3yXc1CiRN94g/SZPtsCkk506aUDtlMnFZvasDwHHUcZUEaYuA=="],
|
||||
|
||||
"@types/triple-beam": ["@types/triple-beam@1.3.5", "", {}, "sha512-6WaYesThRMCl19iryMYP7/x2OVgCtbIVflDGFpWnb9irXI3UjYE4AzmYuiUKY1AJstGijoY+MgUszMgRxIYTYw=="],
|
||||
|
||||
"@types/unist": ["@types/unist@3.0.3", "", {}, "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q=="],
|
||||
|
||||
"@types/use-sync-external-store": ["@types/use-sync-external-store@0.0.6", "", {}, "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg=="],
|
||||
|
||||
"@types/whatwg-mimetype": ["@types/whatwg-mimetype@3.0.2", "", {}, "sha512-c2AKvDT8ToxLIOUlN51gTiHXflsfIFisS4pO7pDPoKouJCESkhZnEy623gwP9laCy5lnLDAw1vAzu2vM2YLOrA=="],
|
||||
|
||||
"@ungap/structured-clone": ["@ungap/structured-clone@1.3.0", "", {}, "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g=="],
|
||||
|
||||
"accepts": ["accepts@1.3.8", "", { "dependencies": { "mime-types": "~2.1.34", "negotiator": "0.6.3" } }, "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw=="],
|
||||
|
||||
"ansi-colors": ["ansi-colors@4.1.3", "", {}, "sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw=="],
|
||||
|
||||
"ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="],
|
||||
"ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
|
||||
|
||||
"ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="],
|
||||
"ansi-styles": ["ansi-styles@5.2.0", "", {}, "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA=="],
|
||||
|
||||
"anymatch": ["anymatch@3.1.3", "", { "dependencies": { "normalize-path": "^3.0.0", "picomatch": "^2.0.4" } }, "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw=="],
|
||||
|
||||
|
|
@ -544,6 +585,8 @@
|
|||
|
||||
"aria-hidden": ["aria-hidden@1.2.6", "", { "dependencies": { "tslib": "^2.0.0" } }, "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA=="],
|
||||
|
||||
"aria-query": ["aria-query@5.3.0", "", { "dependencies": { "dequal": "^2.0.3" } }, "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A=="],
|
||||
|
||||
"arkregex": ["arkregex@0.0.5", "", { "dependencies": { "@ark/util": "0.56.0" } }, "sha512-ncYjBdLlh5/QnVsAA8De16Tc9EqmYM7y/WU9j+236KcyYNUXogpz3sC4ATIZYzzLxwI+0sEOaQLEmLmRleaEXw=="],
|
||||
|
||||
"arktype": ["arktype@2.1.29", "", { "dependencies": { "@ark/schema": "0.56.0", "@ark/util": "0.56.0", "arkregex": "0.0.5" } }, "sha512-jyfKk4xIOzvYNayqnD8ZJQqOwcrTOUbIU4293yrzAjA3O1dWh61j71ArMQ6tS/u4pD7vabSPe7nG3RCyoXW6RQ=="],
|
||||
|
|
@ -554,6 +597,8 @@
|
|||
|
||||
"babel-dead-code-elimination": ["babel-dead-code-elimination@1.0.11", "", { "dependencies": { "@babel/core": "^7.23.7", "@babel/parser": "^7.23.6", "@babel/traverse": "^7.23.7", "@babel/types": "^7.23.6" } }, "sha512-mwq3W3e/pKSI6TG8lXMiDWvEi1VXYlSBlJlB3l+I0bAb5u1RNUl88udos85eOPNK3m5EXK9uO7d2g08pesTySQ=="],
|
||||
|
||||
"bail": ["bail@2.0.2", "", {}, "sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw=="],
|
||||
|
||||
"balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="],
|
||||
|
||||
"baseline-browser-mapping": ["baseline-browser-mapping@2.9.11", "", { "bin": { "baseline-browser-mapping": "dist/cli.js" } }, "sha512-Sg0xJUNDU1sJNGdfGWhVHX0kkZ+HWcvmVymJbj6NSgZZmW/8S9Y2HQ5euytnIgakgxN6papOAWiwDo1ctFDcoQ=="],
|
||||
|
|
@ -584,6 +629,16 @@
|
|||
|
||||
"caniuse-lite": ["caniuse-lite@1.0.30001761", "", {}, "sha512-JF9ptu1vP2coz98+5051jZ4PwQgd2ni8A+gYSN7EA7dPKIMf0pDlSUxhdmVOaV3/fYK5uWBkgSXJaRLr4+3A6g=="],
|
||||
|
||||
"ccount": ["ccount@2.0.1", "", {}, "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg=="],
|
||||
|
||||
"character-entities": ["character-entities@2.0.2", "", {}, "sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ=="],
|
||||
|
||||
"character-entities-html4": ["character-entities-html4@2.1.0", "", {}, "sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA=="],
|
||||
|
||||
"character-entities-legacy": ["character-entities-legacy@3.0.0", "", {}, "sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ=="],
|
||||
|
||||
"character-reference-invalid": ["character-reference-invalid@2.0.1", "", {}, "sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw=="],
|
||||
|
||||
"chardet": ["chardet@2.1.1", "", {}, "sha512-PsezH1rqdV9VvyNhxxOW32/d75r01NY7TQCmOqomRo15ZSOKbpTFVsfjghxo6JloQUCGnH4k1LGu0R4yCLlWQQ=="],
|
||||
|
||||
"chokidar": ["chokidar@4.0.3", "", { "dependencies": { "readdirp": "^4.0.1" } }, "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA=="],
|
||||
|
|
@ -606,6 +661,8 @@
|
|||
|
||||
"color-support": ["color-support@1.1.3", "", { "bin": { "color-support": "bin.js" } }, "sha512-qiBjkpbMLO/HL68y+lh4q0/O1MZFj2RX6X/KmMa3+gJD3z+WwI1ZzDHysvqHGS3mP6mznPckpXmw1nI9cJjyRg=="],
|
||||
|
||||
"comma-separated-tokens": ["comma-separated-tokens@2.0.3", "", {}, "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg=="],
|
||||
|
||||
"commander": ["commander@14.0.2", "", {}, "sha512-TywoWNNRbhoD0BXs1P3ZEScW8W5iKrnbithIl0YH+uCmBd0QpPOA8yc82DS3BIE5Ma6FnBVUsJ7wVUDz4dvOWQ=="],
|
||||
|
||||
"compressible": ["compressible@2.0.18", "", { "dependencies": { "mime-db": ">= 1.43.0 < 2" } }, "sha512-AF3r7P5dWxL8MxyITRMlORQNaOA2IkAFaTr4k7BUumjPtRpGDTZpl0Pb1XCO6JeDCBdp126Cgs9sMxqSjgYyRg=="],
|
||||
|
|
@ -634,6 +691,8 @@
|
|||
|
||||
"crossws": ["crossws@0.3.5", "", { "dependencies": { "uncrypto": "^0.1.3" } }, "sha512-ojKiDvcmByhwa8YYqbQI/hg7MEU0NC03+pSdEq4ZUnZR9xXpwk7E43SMNGkn+JxJGPFtNvQ48+vV2p+P1ml5PA=="],
|
||||
|
||||
"cssesc": ["cssesc@3.0.0", "", { "bin": { "cssesc": "bin/cssesc" } }, "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg=="],
|
||||
|
||||
"csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="],
|
||||
|
||||
"d3-array": ["d3-array@3.2.4", "", { "dependencies": { "internmap": "1 - 2" } }, "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg=="],
|
||||
|
|
@ -664,6 +723,8 @@
|
|||
|
||||
"decimal.js-light": ["decimal.js-light@2.5.1", "", {}, "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg=="],
|
||||
|
||||
"decode-named-character-reference": ["decode-named-character-reference@1.2.0", "", { "dependencies": { "character-entities": "^2.0.0" } }, "sha512-c6fcElNV6ShtZXmsgNgFFV5tVX2PaV4g+MOAkb8eXHvn6sryJBrZa9r0zV6+dtTyoCKxtDy5tyQ5ZwQuidtd+Q=="],
|
||||
|
||||
"dedent": ["dedent@1.7.1", "", { "peerDependencies": { "babel-plugin-macros": "^3.1.0" }, "optionalPeers": ["babel-plugin-macros"] }, "sha512-9JmrhGZpOlEgOLdQgSm0zxFaYoQon408V1v49aqTWuXENVlnCuY9JBZcXZiCsZQWDjTm5Qf/nIvAy77mXDAjEg=="],
|
||||
|
||||
"default-browser": ["default-browser@5.4.0", "", { "dependencies": { "bundle-name": "^4.1.0", "default-browser-id": "^5.0.0" } }, "sha512-XDuvSq38Hr1MdN47EDvYtx3U0MTqpCEn+F6ft8z2vYDzMrvQhVp0ui9oQdqW3MvK3vqUETglt1tVGgjLuJ5izg=="],
|
||||
|
|
@ -676,6 +737,8 @@
|
|||
|
||||
"depd": ["depd@2.0.0", "", {}, "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw=="],
|
||||
|
||||
"dequal": ["dequal@2.0.3", "", {}, "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA=="],
|
||||
|
||||
"destr": ["destr@2.0.5", "", {}, "sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA=="],
|
||||
|
||||
"destroy": ["destroy@1.2.0", "", {}, "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg=="],
|
||||
|
|
@ -684,8 +747,12 @@
|
|||
|
||||
"detect-node-es": ["detect-node-es@1.1.0", "", {}, "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ=="],
|
||||
|
||||
"devlop": ["devlop@1.1.0", "", { "dependencies": { "dequal": "^2.0.0" } }, "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA=="],
|
||||
|
||||
"dither-plugin": ["dither-plugin@1.1.1", "", {}, "sha512-PsgAcSoNVKkwh+Q/OopRn/2qb9HW1LRyGqT1bQe8iooYvVY1FIIqePFN9JkEIVK9rkfZdj7nXn9EUB4B7mNh6g=="],
|
||||
|
||||
"dom-accessibility-api": ["dom-accessibility-api@0.5.16", "", {}, "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg=="],
|
||||
|
||||
"dotenv": ["dotenv@17.2.3", "", {}, "sha512-JVUnt+DUIzu87TABbhPmNfVdBDt18BLOWjMUFJMSi/Qqg7NTYtabbvSNJGOJ7afbRuv9D/lngizHtP7QyLQ+9w=="],
|
||||
|
||||
"dotenv-cli": ["dotenv-cli@11.0.0", "", { "dependencies": { "cross-spawn": "^7.0.6", "dotenv": "^17.1.0", "dotenv-expand": "^12.0.0", "minimist": "^1.2.6" }, "bin": { "dotenv": "cli.js" } }, "sha512-r5pA8idbk7GFWuHEU7trSTflWcdBpQEK+Aw17UrSHjS6CReuhrrPcyC3zcQBPQvhArRHnBo/h6eLH1fkCvNlww=="],
|
||||
|
|
@ -728,6 +795,10 @@
|
|||
|
||||
"escape-html": ["escape-html@1.0.3", "", {}, "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow=="],
|
||||
|
||||
"escape-string-regexp": ["escape-string-regexp@5.0.0", "", {}, "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw=="],
|
||||
|
||||
"estree-util-is-identifier-name": ["estree-util-is-identifier-name@3.0.0", "", {}, "sha512-hFtqIDZTIUZ9BXLb8y4pYGyk6+wekIivNVTcmvk8NoOh+VeRn5y6cEHzbURrWbfp1fIqdVipilzj+lfaadNZmg=="],
|
||||
|
||||
"etag": ["etag@1.8.1", "", {}, "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg=="],
|
||||
|
||||
"eventemitter3": ["eventemitter3@5.0.1", "", {}, "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA=="],
|
||||
|
|
@ -738,6 +809,8 @@
|
|||
|
||||
"exsolve": ["exsolve@1.0.8", "", {}, "sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA=="],
|
||||
|
||||
"extend": ["extend@3.0.2", "", {}, "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g=="],
|
||||
|
||||
"fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],
|
||||
|
||||
"fecha": ["fecha@4.2.3", "", {}, "sha512-OP2IUU6HeYKJi3i0z4A19kHMQoLVs4Hc+DPqqxI2h/DPZHTm/vjsfC6P0b4jCMy14XizLBqvndQ+UilD7707Jw=="],
|
||||
|
|
@ -778,16 +851,24 @@
|
|||
|
||||
"h3": ["h3@1.15.4", "", { "dependencies": { "cookie-es": "^1.2.2", "crossws": "^0.3.5", "defu": "^6.1.4", "destr": "^2.0.5", "iron-webcrypto": "^1.2.1", "node-mock-http": "^1.0.2", "radix3": "^1.1.2", "ufo": "^1.6.1", "uncrypto": "^0.1.3" } }, "sha512-z5cFQWDffyOe4vQ9xIqNfCZdV4p//vy6fBnr8Q1AWnVZ0teurKMG66rLj++TKwKPUP3u7iMUvrvKaEUiQw2QWQ=="],
|
||||
|
||||
"happy-dom": ["happy-dom@20.0.11", "", { "dependencies": { "@types/node": "^20.0.0", "@types/whatwg-mimetype": "^3.0.2", "whatwg-mimetype": "^3.0.0" } }, "sha512-QsCdAUHAmiDeKeaNojb1OHOPF7NjcWPBR7obdu3NwH2a/oyQaLg5d0aaCy/9My6CdPChYF07dvz5chaXBGaD4g=="],
|
||||
|
||||
"has-symbols": ["has-symbols@1.1.0", "", {}, "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ=="],
|
||||
|
||||
"hasown": ["hasown@2.0.2", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="],
|
||||
|
||||
"hast-util-to-jsx-runtime": ["hast-util-to-jsx-runtime@2.3.6", "", { "dependencies": { "@types/estree": "^1.0.0", "@types/hast": "^3.0.0", "@types/unist": "^3.0.0", "comma-separated-tokens": "^2.0.0", "devlop": "^1.0.0", "estree-util-is-identifier-name": "^3.0.0", "hast-util-whitespace": "^3.0.0", "mdast-util-mdx-expression": "^2.0.0", "mdast-util-mdx-jsx": "^3.0.0", "mdast-util-mdxjs-esm": "^2.0.0", "property-information": "^7.0.0", "space-separated-tokens": "^2.0.0", "style-to-js": "^1.0.0", "unist-util-position": "^5.0.0", "vfile-message": "^4.0.0" } }, "sha512-zl6s8LwNyo1P9uw+XJGvZtdFF1GdAkOg8ujOw+4Pyb76874fLps4ueHXDhXWdk6YHQ6OgUtinliG7RsYvCbbBg=="],
|
||||
|
||||
"hast-util-whitespace": ["hast-util-whitespace@3.0.0", "", { "dependencies": { "@types/hast": "^3.0.0" } }, "sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw=="],
|
||||
|
||||
"hono": ["hono@4.10.5", "", {}, "sha512-h/MXuTkoAK8NG1EfDp0jI1YLf6yGdDnfkebRO2pwEh5+hE3RAJFXkCsnD0vamSiARK4ZrB6MY+o3E/hCnOyHrQ=="],
|
||||
|
||||
"hono-openapi": ["hono-openapi@1.1.2", "", { "peerDependencies": { "@hono/standard-validator": "^0.2.0", "@standard-community/standard-json": "^0.3.5", "@standard-community/standard-openapi": "^0.2.9", "@types/json-schema": "^7.0.15", "hono": "^4.8.3", "openapi-types": "^12.1.3" }, "optionalPeers": ["@hono/standard-validator", "hono"] }, "sha512-toUcO60MftRBxqcVyxsHNYs2m4vf4xkQaiARAucQx3TiBPDtMNNkoh+C4I1vAretQZiGyaLOZNWn1YxfSyUA5g=="],
|
||||
|
||||
"hono-rate-limiter": ["hono-rate-limiter@0.5.1", "", { "peerDependencies": { "hono": "^4.10.8", "unstorage": "^1.17.3" } }, "sha512-c3bUn6IRgFKjlouvRNBy+ZIPZ2CTyTt3fc0uat2bv3GiHmLM4jI0QJ6fHd3Tf4R6dO2sX2Uvl9Gtp+kny4KdXg=="],
|
||||
|
||||
"html-url-attributes": ["html-url-attributes@3.0.1", "", {}, "sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ=="],
|
||||
|
||||
"http-errors": ["http-errors@2.0.1", "", { "dependencies": { "depd": "~2.0.0", "inherits": "~2.0.4", "setprototypeof": "~1.2.0", "statuses": "~2.0.2", "toidentifier": "~1.0.1" } }, "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ=="],
|
||||
|
||||
"http-errors-enhanced": ["http-errors-enhanced@4.0.2", "", {}, "sha512-5EXN1gmhJVvuWpNfz+RclWvLnnENEXNMPfww3gm30H9mQzPF4QSBj/MD5FRkVDxGIUhO/cR2GSLCd/6C6xpBcw=="],
|
||||
|
|
@ -798,18 +879,30 @@
|
|||
|
||||
"inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="],
|
||||
|
||||
"inline-style-parser": ["inline-style-parser@0.2.7", "", {}, "sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA=="],
|
||||
|
||||
"internmap": ["internmap@2.0.3", "", {}, "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg=="],
|
||||
|
||||
"ipaddr.js": ["ipaddr.js@1.9.1", "", {}, "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g=="],
|
||||
|
||||
"iron-webcrypto": ["iron-webcrypto@1.2.1", "", {}, "sha512-feOM6FaSr6rEABp/eDfVseKyTMDt+KGpeB35SkVn9Tyn0CqvVsY3EwI0v5i8nMHyJnzCIQf7nsy3p41TPkJZhg=="],
|
||||
|
||||
"is-alphabetical": ["is-alphabetical@2.0.1", "", {}, "sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ=="],
|
||||
|
||||
"is-alphanumerical": ["is-alphanumerical@2.0.1", "", { "dependencies": { "is-alphabetical": "^2.0.0", "is-decimal": "^2.0.0" } }, "sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw=="],
|
||||
|
||||
"is-decimal": ["is-decimal@2.0.1", "", {}, "sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A=="],
|
||||
|
||||
"is-docker": ["is-docker@3.0.0", "", { "bin": { "is-docker": "cli.js" } }, "sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ=="],
|
||||
|
||||
"is-hexadecimal": ["is-hexadecimal@2.0.1", "", {}, "sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg=="],
|
||||
|
||||
"is-in-ssh": ["is-in-ssh@1.0.0", "", {}, "sha512-jYa6Q9rH90kR1vKB6NM7qqd1mge3Fx4Dhw5TVlK1MUBqhEOuCagrEHMevNuCcbECmXZ0ThXkRm+Ymr51HwEPAw=="],
|
||||
|
||||
"is-inside-container": ["is-inside-container@1.0.0", "", { "dependencies": { "is-docker": "^3.0.0" }, "bin": { "is-inside-container": "cli.js" } }, "sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA=="],
|
||||
|
||||
"is-plain-obj": ["is-plain-obj@4.1.0", "", {}, "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg=="],
|
||||
|
||||
"is-stream": ["is-stream@2.0.1", "", {}, "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg=="],
|
||||
|
||||
"is-wsl": ["is-wsl@3.1.0", "", { "dependencies": { "is-inside-container": "^1.0.0" } }, "sha512-UcVfVfaK4Sc4m7X3dUSoHoozQGBEFeDC+zVo06t98xe8CzHSZZBekNXH+tu0NalHolcJ/QAGqS46Hef7QXBIMw=="],
|
||||
|
|
@ -858,22 +951,114 @@
|
|||
|
||||
"logform": ["logform@2.7.0", "", { "dependencies": { "@colors/colors": "1.6.0", "@types/triple-beam": "^1.3.2", "fecha": "^4.2.0", "ms": "^2.1.1", "safe-stable-stringify": "^2.3.1", "triple-beam": "^1.3.0" } }, "sha512-TFYA4jnP7PVbmlBIfhlSe+WKxs9dklXMTEGcBCIvLhE/Tn3H6Gk1norupVW7m5Cnd4bLcr08AytbyV/xj7f/kQ=="],
|
||||
|
||||
"longest-streak": ["longest-streak@3.1.0", "", {}, "sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g=="],
|
||||
|
||||
"lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="],
|
||||
|
||||
"lucide-react": ["lucide-react@0.555.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-D8FvHUGbxWBRQM90NZeIyhAvkFfsh3u9ekrMvJ30Z6gnpBHS6HC6ldLg7tL45hwiIz/u66eKDtdA23gwwGsAHA=="],
|
||||
|
||||
"luxon": ["luxon@3.7.2", "", {}, "sha512-vtEhXh/gNjI9Yg1u4jX/0YVPMvxzHuGgCm6tC5kZyb08yjGWGnqAjGJvcXbqQR2P3MyMEFnRbpcdFS6PBcLqew=="],
|
||||
|
||||
"lz-string": ["lz-string@1.5.0", "", { "bin": { "lz-string": "bin/bin.js" } }, "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ=="],
|
||||
|
||||
"magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="],
|
||||
|
||||
"markdown-table": ["markdown-table@3.0.4", "", {}, "sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw=="],
|
||||
|
||||
"math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="],
|
||||
|
||||
"mdast-util-find-and-replace": ["mdast-util-find-and-replace@3.0.2", "", { "dependencies": { "@types/mdast": "^4.0.0", "escape-string-regexp": "^5.0.0", "unist-util-is": "^6.0.0", "unist-util-visit-parents": "^6.0.0" } }, "sha512-Tmd1Vg/m3Xz43afeNxDIhWRtFZgM2VLyaf4vSTYwudTyeuTneoL3qtWMA5jeLyz/O1vDJmmV4QuScFCA2tBPwg=="],
|
||||
|
||||
"mdast-util-from-markdown": ["mdast-util-from-markdown@2.0.2", "", { "dependencies": { "@types/mdast": "^4.0.0", "@types/unist": "^3.0.0", "decode-named-character-reference": "^1.0.0", "devlop": "^1.0.0", "mdast-util-to-string": "^4.0.0", "micromark": "^4.0.0", "micromark-util-decode-numeric-character-reference": "^2.0.0", "micromark-util-decode-string": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0", "unist-util-stringify-position": "^4.0.0" } }, "sha512-uZhTV/8NBuw0WHkPTrCqDOl0zVe1BIng5ZtHoDk49ME1qqcjYmmLmOf0gELgcRMxN4w2iuIeVso5/6QymSrgmA=="],
|
||||
|
||||
"mdast-util-gfm": ["mdast-util-gfm@3.1.0", "", { "dependencies": { "mdast-util-from-markdown": "^2.0.0", "mdast-util-gfm-autolink-literal": "^2.0.0", "mdast-util-gfm-footnote": "^2.0.0", "mdast-util-gfm-strikethrough": "^2.0.0", "mdast-util-gfm-table": "^2.0.0", "mdast-util-gfm-task-list-item": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-0ulfdQOM3ysHhCJ1p06l0b0VKlhU0wuQs3thxZQagjcjPrlFRqY215uZGHHJan9GEAXd9MbfPjFJz+qMkVR6zQ=="],
|
||||
|
||||
"mdast-util-gfm-autolink-literal": ["mdast-util-gfm-autolink-literal@2.0.1", "", { "dependencies": { "@types/mdast": "^4.0.0", "ccount": "^2.0.0", "devlop": "^1.0.0", "mdast-util-find-and-replace": "^3.0.0", "micromark-util-character": "^2.0.0" } }, "sha512-5HVP2MKaP6L+G6YaxPNjuL0BPrq9orG3TsrZ9YXbA3vDw/ACI4MEsnoDpn6ZNm7GnZgtAcONJyPhOP8tNJQavQ=="],
|
||||
|
||||
"mdast-util-gfm-footnote": ["mdast-util-gfm-footnote@2.1.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "devlop": "^1.1.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0" } }, "sha512-sqpDWlsHn7Ac9GNZQMeUzPQSMzR6Wv0WKRNvQRg0KqHh02fpTz69Qc1QSseNX29bhz1ROIyNyxExfawVKTm1GQ=="],
|
||||
|
||||
"mdast-util-gfm-strikethrough": ["mdast-util-gfm-strikethrough@2.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-mKKb915TF+OC5ptj5bJ7WFRPdYtuHv0yTRxK2tJvi+BDqbkiG7h7u/9SI89nRAYcmap2xHQL9D+QG/6wSrTtXg=="],
|
||||
|
||||
"mdast-util-gfm-table": ["mdast-util-gfm-table@2.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "markdown-table": "^3.0.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-78UEvebzz/rJIxLvE7ZtDd/vIQ0RHv+3Mh5DR96p7cS7HsBhYIICDBCu8csTNWNO6tBWfqXPWekRuj2FNOGOZg=="],
|
||||
|
||||
"mdast-util-gfm-task-list-item": ["mdast-util-gfm-task-list-item@2.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-IrtvNvjxC1o06taBAVJznEnkiHxLFTzgonUdy8hzFVeDun0uTjxxrRGVaNFqkU1wJR3RBPEfsxmU6jDWPofrTQ=="],
|
||||
|
||||
"mdast-util-mdx-expression": ["mdast-util-mdx-expression@2.0.1", "", { "dependencies": { "@types/estree-jsx": "^1.0.0", "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-J6f+9hUp+ldTZqKRSg7Vw5V6MqjATc+3E4gf3CFNcuZNWD8XdyI6zQ8GqH7f8169MM6P7hMBRDVGnn7oHB9kXQ=="],
|
||||
|
||||
"mdast-util-mdx-jsx": ["mdast-util-mdx-jsx@3.2.0", "", { "dependencies": { "@types/estree-jsx": "^1.0.0", "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "@types/unist": "^3.0.0", "ccount": "^2.0.0", "devlop": "^1.1.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0", "parse-entities": "^4.0.0", "stringify-entities": "^4.0.0", "unist-util-stringify-position": "^4.0.0", "vfile-message": "^4.0.0" } }, "sha512-lj/z8v0r6ZtsN/cGNNtemmmfoLAFZnjMbNyLzBafjzikOM+glrjNHPlf6lQDOTccj9n5b0PPihEBbhneMyGs1Q=="],
|
||||
|
||||
"mdast-util-mdxjs-esm": ["mdast-util-mdxjs-esm@2.0.1", "", { "dependencies": { "@types/estree-jsx": "^1.0.0", "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-EcmOpxsZ96CvlP03NghtH1EsLtr0n9Tm4lPUJUBccV9RwUOneqSycg19n5HGzCf+10LozMRSObtVr3ee1WoHtg=="],
|
||||
|
||||
"mdast-util-phrasing": ["mdast-util-phrasing@4.1.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "unist-util-is": "^6.0.0" } }, "sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w=="],
|
||||
|
||||
"mdast-util-to-hast": ["mdast-util-to-hast@13.2.1", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "@ungap/structured-clone": "^1.0.0", "devlop": "^1.0.0", "micromark-util-sanitize-uri": "^2.0.0", "trim-lines": "^3.0.0", "unist-util-position": "^5.0.0", "unist-util-visit": "^5.0.0", "vfile": "^6.0.0" } }, "sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA=="],
|
||||
|
||||
"mdast-util-to-markdown": ["mdast-util-to-markdown@2.1.2", "", { "dependencies": { "@types/mdast": "^4.0.0", "@types/unist": "^3.0.0", "longest-streak": "^3.0.0", "mdast-util-phrasing": "^4.0.0", "mdast-util-to-string": "^4.0.0", "micromark-util-classify-character": "^2.0.0", "micromark-util-decode-string": "^2.0.0", "unist-util-visit": "^5.0.0", "zwitch": "^2.0.0" } }, "sha512-xj68wMTvGXVOKonmog6LwyJKrYXZPvlwabaryTjLh9LuvovB/KAH+kvi8Gjj+7rJjsFi23nkUxRQv1KqSroMqA=="],
|
||||
|
||||
"mdast-util-to-string": ["mdast-util-to-string@4.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0" } }, "sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg=="],
|
||||
|
||||
"media-typer": ["media-typer@0.3.0", "", {}, "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ=="],
|
||||
|
||||
"merge-descriptors": ["merge-descriptors@1.0.3", "", {}, "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ=="],
|
||||
|
||||
"methods": ["methods@1.1.2", "", {}, "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w=="],
|
||||
|
||||
"micromark": ["micromark@4.0.2", "", { "dependencies": { "@types/debug": "^4.0.0", "debug": "^4.0.0", "decode-named-character-reference": "^1.0.0", "devlop": "^1.0.0", "micromark-core-commonmark": "^2.0.0", "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-combine-extensions": "^2.0.0", "micromark-util-decode-numeric-character-reference": "^2.0.0", "micromark-util-encode": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-resolve-all": "^2.0.0", "micromark-util-sanitize-uri": "^2.0.0", "micromark-util-subtokenize": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA=="],
|
||||
|
||||
"micromark-core-commonmark": ["micromark-core-commonmark@2.0.3", "", { "dependencies": { "decode-named-character-reference": "^1.0.0", "devlop": "^1.0.0", "micromark-factory-destination": "^2.0.0", "micromark-factory-label": "^2.0.0", "micromark-factory-space": "^2.0.0", "micromark-factory-title": "^2.0.0", "micromark-factory-whitespace": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-classify-character": "^2.0.0", "micromark-util-html-tag-name": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-resolve-all": "^2.0.0", "micromark-util-subtokenize": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg=="],
|
||||
|
||||
"micromark-extension-gfm": ["micromark-extension-gfm@3.0.0", "", { "dependencies": { "micromark-extension-gfm-autolink-literal": "^2.0.0", "micromark-extension-gfm-footnote": "^2.0.0", "micromark-extension-gfm-strikethrough": "^2.0.0", "micromark-extension-gfm-table": "^2.0.0", "micromark-extension-gfm-tagfilter": "^2.0.0", "micromark-extension-gfm-task-list-item": "^2.0.0", "micromark-util-combine-extensions": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-vsKArQsicm7t0z2GugkCKtZehqUm31oeGBV/KVSorWSy8ZlNAv7ytjFhvaryUiCUJYqs+NoE6AFhpQvBTM6Q4w=="],
|
||||
|
||||
"micromark-extension-gfm-autolink-literal": ["micromark-extension-gfm-autolink-literal@2.1.0", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-sanitize-uri": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-oOg7knzhicgQ3t4QCjCWgTmfNhvQbDDnJeVu9v81r7NltNCVmhPy1fJRX27pISafdjL+SVc4d3l48Gb6pbRypw=="],
|
||||
|
||||
"micromark-extension-gfm-footnote": ["micromark-extension-gfm-footnote@2.1.0", "", { "dependencies": { "devlop": "^1.0.0", "micromark-core-commonmark": "^2.0.0", "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-sanitize-uri": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-/yPhxI1ntnDNsiHtzLKYnE3vf9JZ6cAisqVDauhp4CEHxlb4uoOTxOCJ+9s51bIB8U1N1FJ1RXOKTIlD5B/gqw=="],
|
||||
|
||||
"micromark-extension-gfm-strikethrough": ["micromark-extension-gfm-strikethrough@2.1.0", "", { "dependencies": { "devlop": "^1.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-classify-character": "^2.0.0", "micromark-util-resolve-all": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-ADVjpOOkjz1hhkZLlBiYA9cR2Anf8F4HqZUO6e5eDcPQd0Txw5fxLzzxnEkSkfnD0wziSGiv7sYhk/ktvbf1uw=="],
|
||||
|
||||
"micromark-extension-gfm-table": ["micromark-extension-gfm-table@2.1.1", "", { "dependencies": { "devlop": "^1.0.0", "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-t2OU/dXXioARrC6yWfJ4hqB7rct14e8f7m0cbI5hUmDyyIlwv5vEtooptH8INkbLzOatzKuVbQmAYcbWoyz6Dg=="],
|
||||
|
||||
"micromark-extension-gfm-tagfilter": ["micromark-extension-gfm-tagfilter@2.0.0", "", { "dependencies": { "micromark-util-types": "^2.0.0" } }, "sha512-xHlTOmuCSotIA8TW1mDIM6X2O1SiX5P9IuDtqGonFhEK0qgRI4yeC6vMxEV2dgyr2TiD+2PQ10o+cOhdVAcwfg=="],
|
||||
|
||||
"micromark-extension-gfm-task-list-item": ["micromark-extension-gfm-task-list-item@2.1.0", "", { "dependencies": { "devlop": "^1.0.0", "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-qIBZhqxqI6fjLDYFTBIa4eivDMnP+OZqsNwmQ3xNLE4Cxwc+zfQEfbs6tzAo2Hjq+bh6q5F+Z8/cksrLFYWQQw=="],
|
||||
|
||||
"micromark-factory-destination": ["micromark-factory-destination@2.0.1", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA=="],
|
||||
|
||||
"micromark-factory-label": ["micromark-factory-label@2.0.1", "", { "dependencies": { "devlop": "^1.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-VFMekyQExqIW7xIChcXn4ok29YE3rnuyveW3wZQWWqF4Nv9Wk5rgJ99KzPvHjkmPXF93FXIbBp6YdW3t71/7Vg=="],
|
||||
|
||||
"micromark-factory-space": ["micromark-factory-space@2.0.1", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-zRkxjtBxxLd2Sc0d+fbnEunsTj46SWXgXciZmHq0kDYGnck/ZSGj9/wULTV95uoeYiK5hRXP2mJ98Uo4cq/LQg=="],
|
||||
|
||||
"micromark-factory-title": ["micromark-factory-title@2.0.1", "", { "dependencies": { "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-5bZ+3CjhAd9eChYTHsjy6TGxpOFSKgKKJPJxr293jTbfry2KDoWkhBb6TcPVB4NmzaPhMs1Frm9AZH7OD4Cjzw=="],
|
||||
|
||||
"micromark-factory-whitespace": ["micromark-factory-whitespace@2.0.1", "", { "dependencies": { "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-Ob0nuZ3PKt/n0hORHyvoD9uZhr+Za8sFoP+OnMcnWK5lngSzALgQYKMr9RJVOWLqQYuyn6ulqGWSXdwf6F80lQ=="],
|
||||
|
||||
"micromark-util-character": ["micromark-util-character@2.1.1", "", { "dependencies": { "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q=="],
|
||||
|
||||
"micromark-util-chunked": ["micromark-util-chunked@2.0.1", "", { "dependencies": { "micromark-util-symbol": "^2.0.0" } }, "sha512-QUNFEOPELfmvv+4xiNg2sRYeS/P84pTW0TCgP5zc9FpXetHY0ab7SxKyAQCNCc1eK0459uoLI1y5oO5Vc1dbhA=="],
|
||||
|
||||
"micromark-util-classify-character": ["micromark-util-classify-character@2.0.1", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-K0kHzM6afW/MbeWYWLjoHQv1sgg2Q9EccHEDzSkxiP/EaagNzCm7T/WMKZ3rjMbvIpvBiZgwR3dKMygtA4mG1Q=="],
|
||||
|
||||
"micromark-util-combine-extensions": ["micromark-util-combine-extensions@2.0.1", "", { "dependencies": { "micromark-util-chunked": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-OnAnH8Ujmy59JcyZw8JSbK9cGpdVY44NKgSM7E9Eh7DiLS2E9RNQf0dONaGDzEG9yjEl5hcqeIsj4hfRkLH/Bg=="],
|
||||
|
||||
"micromark-util-decode-numeric-character-reference": ["micromark-util-decode-numeric-character-reference@2.0.2", "", { "dependencies": { "micromark-util-symbol": "^2.0.0" } }, "sha512-ccUbYk6CwVdkmCQMyr64dXz42EfHGkPQlBj5p7YVGzq8I7CtjXZJrubAYezf7Rp+bjPseiROqe7G6foFd+lEuw=="],
|
||||
|
||||
"micromark-util-decode-string": ["micromark-util-decode-string@2.0.1", "", { "dependencies": { "decode-named-character-reference": "^1.0.0", "micromark-util-character": "^2.0.0", "micromark-util-decode-numeric-character-reference": "^2.0.0", "micromark-util-symbol": "^2.0.0" } }, "sha512-nDV/77Fj6eH1ynwscYTOsbK7rR//Uj0bZXBwJZRfaLEJ1iGBR6kIfNmlNqaqJf649EP0F3NWNdeJi03elllNUQ=="],
|
||||
|
||||
"micromark-util-encode": ["micromark-util-encode@2.0.1", "", {}, "sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw=="],
|
||||
|
||||
"micromark-util-html-tag-name": ["micromark-util-html-tag-name@2.0.1", "", {}, "sha512-2cNEiYDhCWKI+Gs9T0Tiysk136SnR13hhO8yW6BGNyhOC4qYFnwF1nKfD3HFAIXA5c45RrIG1ub11GiXeYd1xA=="],
|
||||
|
||||
"micromark-util-normalize-identifier": ["micromark-util-normalize-identifier@2.0.1", "", { "dependencies": { "micromark-util-symbol": "^2.0.0" } }, "sha512-sxPqmo70LyARJs0w2UclACPUUEqltCkJ6PhKdMIDuJ3gSf/Q+/GIe3WKl0Ijb/GyH9lOpUkRAO2wp0GVkLvS9Q=="],
|
||||
|
||||
"micromark-util-resolve-all": ["micromark-util-resolve-all@2.0.1", "", { "dependencies": { "micromark-util-types": "^2.0.0" } }, "sha512-VdQyxFWFT2/FGJgwQnJYbe1jjQoNTS4RjglmSjTUlpUMa95Htx9NHeYW4rGDJzbjvCsl9eLjMQwGeElsqmzcHg=="],
|
||||
|
||||
"micromark-util-sanitize-uri": ["micromark-util-sanitize-uri@2.0.1", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-encode": "^2.0.0", "micromark-util-symbol": "^2.0.0" } }, "sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ=="],
|
||||
|
||||
"micromark-util-subtokenize": ["micromark-util-subtokenize@2.1.0", "", { "dependencies": { "devlop": "^1.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-XQLu552iSctvnEcgXw6+Sx75GflAPNED1qx7eBJ+wydBb2KCbRZe+NwvIEEMM83uml1+2WSXpBAcp9IUCgCYWA=="],
|
||||
|
||||
"micromark-util-symbol": ["micromark-util-symbol@2.0.1", "", {}, "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q=="],
|
||||
|
||||
"micromark-util-types": ["micromark-util-types@2.0.2", "", {}, "sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA=="],
|
||||
|
||||
"mime": ["mime@1.6.0", "", { "bin": { "mime": "cli.js" } }, "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg=="],
|
||||
|
||||
"mime-db": ["mime-db@1.54.0", "", {}, "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ=="],
|
||||
|
|
@ -926,6 +1111,8 @@
|
|||
|
||||
"p-map": ["p-map@7.0.4", "", {}, "sha512-tkAQEw8ysMzmkhgw8k+1U/iPhWNhykKnSk4Rd5zLoPJCuJaGRPo6YposrZgaxHKzDHdDWWZvE/Sk7hsL2X/CpQ=="],
|
||||
|
||||
"parse-entities": ["parse-entities@4.0.2", "", { "dependencies": { "@types/unist": "^2.0.0", "character-entities-legacy": "^3.0.0", "character-reference-invalid": "^2.0.0", "decode-named-character-reference": "^1.0.0", "is-alphanumerical": "^2.0.0", "is-decimal": "^2.0.0", "is-hexadecimal": "^2.0.0" } }, "sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw=="],
|
||||
|
||||
"parseurl": ["parseurl@1.3.3", "", {}, "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ=="],
|
||||
|
||||
"path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="],
|
||||
|
|
@ -944,10 +1131,16 @@
|
|||
|
||||
"postcss": ["postcss@8.5.6", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg=="],
|
||||
|
||||
"postcss-selector-parser": ["postcss-selector-parser@6.0.10", "", { "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" } }, "sha512-IQ7TZdoaqbT+LCpShg46jnZVlhWD2w6iQYAcYXfHARZ7X1t/UGhhceQDs5X0cGqKvYlHNOuv7Oa1xmb0oQuA3w=="],
|
||||
|
||||
"powershell-utils": ["powershell-utils@0.1.0", "", {}, "sha512-dM0jVuXJPsDN6DvRpea484tCUaMiXWjuCn++HGTqUWzGDjv5tZkEZldAJ/UMlqRYGFrD/etByo4/xOuC/snX2A=="],
|
||||
|
||||
"prettier": ["prettier@3.7.4", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-v6UNi1+3hSlVvv8fSaoUbggEM5VErKmmpGA7Pl3HF8V6uKY7rvClBOJlH6yNwQtfTueNkGVpOv/mtWL9L4bgRA=="],
|
||||
|
||||
"pretty-format": ["pretty-format@27.5.1", "", { "dependencies": { "ansi-regex": "^5.0.1", "ansi-styles": "^5.0.0", "react-is": "^17.0.1" } }, "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ=="],
|
||||
|
||||
"property-information": ["property-information@7.1.0", "", {}, "sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ=="],
|
||||
|
||||
"proxy-addr": ["proxy-addr@2.0.7", "", { "dependencies": { "forwarded": "0.2.0", "ipaddr.js": "1.9.1" } }, "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg=="],
|
||||
|
||||
"qs": ["qs@6.14.0", "", { "dependencies": { "side-channel": "^1.1.0" } }, "sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w=="],
|
||||
|
|
@ -970,6 +1163,8 @@
|
|||
|
||||
"react-is": ["react-is@19.2.3", "", {}, "sha512-qJNJfu81ByyabuG7hPFEbXqNcWSU3+eVus+KJs+0ncpGfMyYdvSmxiJxbWR65lYi1I+/0HBcliO029gc4F+PnA=="],
|
||||
|
||||
"react-markdown": ["react-markdown@10.1.0", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "hast-util-to-jsx-runtime": "^2.0.0", "html-url-attributes": "^3.0.0", "mdast-util-to-hast": "^13.0.0", "remark-parse": "^11.0.0", "remark-rehype": "^11.0.0", "unified": "^11.0.0", "unist-util-visit": "^5.0.0", "vfile": "^6.0.0" }, "peerDependencies": { "@types/react": ">=18", "react": ">=18" } }, "sha512-qKxVopLT/TyA6BX3Ue5NwabOsAzm0Q7kAPwq6L+wWDwisYs7R8vZ0nRXqq6rkueboxpkjvLGU9fWifiX/ZZFxQ=="],
|
||||
|
||||
"react-redux": ["react-redux@9.2.0", "", { "dependencies": { "@types/use-sync-external-store": "^0.0.6", "use-sync-external-store": "^1.4.0" }, "peerDependencies": { "@types/react": "^18.2.25 || ^19", "react": "^18.0 || ^19", "redux": "^5.0.0" }, "optionalPeers": ["@types/react", "redux"] }, "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g=="],
|
||||
|
||||
"react-refresh": ["react-refresh@0.14.2", "", {}, "sha512-jCvmsr+1IUSMUyzOkRcvnVbX3ZYC6g9TDrDbFuFmRDq7PD4yaGbLKNQL6k2jnArV8hjYxh7hVhAZB6s9HDGpZA=="],
|
||||
|
|
@ -994,6 +1189,14 @@
|
|||
|
||||
"redux-thunk": ["redux-thunk@3.1.0", "", { "peerDependencies": { "redux": "^5.0.0" } }, "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw=="],
|
||||
|
||||
"remark-gfm": ["remark-gfm@4.0.1", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-gfm": "^3.0.0", "micromark-extension-gfm": "^3.0.0", "remark-parse": "^11.0.0", "remark-stringify": "^11.0.0", "unified": "^11.0.0" } }, "sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg=="],
|
||||
|
||||
"remark-parse": ["remark-parse@11.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-from-markdown": "^2.0.0", "micromark-util-types": "^2.0.0", "unified": "^11.0.0" } }, "sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA=="],
|
||||
|
||||
"remark-rehype": ["remark-rehype@11.1.2", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "mdast-util-to-hast": "^13.0.0", "unified": "^11.0.0", "vfile": "^6.0.0" } }, "sha512-Dh7l57ianaEoIpzbp0PC9UKAdCSVklD8E5Rpw7ETfbTl3FqcOOgq5q2LVDhgGCkaBv7p24JXikPdvhhmHvKMsw=="],
|
||||
|
||||
"remark-stringify": ["remark-stringify@11.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-to-markdown": "^2.0.0", "unified": "^11.0.0" } }, "sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw=="],
|
||||
|
||||
"reselect": ["reselect@5.1.1", "", {}, "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w=="],
|
||||
|
||||
"resolve-pkg-maps": ["resolve-pkg-maps@1.0.0", "", {}, "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw=="],
|
||||
|
|
@ -1010,7 +1213,7 @@
|
|||
|
||||
"scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="],
|
||||
|
||||
"semver": ["semver@7.7.2", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA=="],
|
||||
"semver": ["semver@7.7.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q=="],
|
||||
|
||||
"send": ["send@0.19.2", "", { "dependencies": { "debug": "2.6.9", "depd": "2.0.0", "destroy": "1.2.0", "encodeurl": "~2.0.0", "escape-html": "~1.0.3", "etag": "~1.8.1", "fresh": "~0.5.2", "http-errors": "~2.0.1", "mime": "1.6.0", "ms": "2.1.3", "on-finished": "~2.4.1", "range-parser": "~1.2.1", "statuses": "~2.0.2" } }, "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg=="],
|
||||
|
||||
|
|
@ -1044,6 +1247,8 @@
|
|||
|
||||
"source-map-support": ["source-map-support@0.5.21", "", { "dependencies": { "buffer-from": "^1.0.0", "source-map": "^0.6.0" } }, "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w=="],
|
||||
|
||||
"space-separated-tokens": ["space-separated-tokens@2.0.2", "", {}, "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q=="],
|
||||
|
||||
"stack-trace": ["stack-trace@0.0.10", "", {}, "sha512-KGzahc7puUKkzyMt+IqAep+TVNbKP+k2Lmwhub39m1AsTSkaDutx56aDCo+HLDzf/D26BIHTJWNiTG1KAJiQCg=="],
|
||||
|
||||
"statuses": ["statuses@2.0.2", "", {}, "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw=="],
|
||||
|
|
@ -1052,8 +1257,14 @@
|
|||
|
||||
"string_decoder": ["string_decoder@1.3.0", "", { "dependencies": { "safe-buffer": "~5.2.0" } }, "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA=="],
|
||||
|
||||
"stringify-entities": ["stringify-entities@4.0.4", "", { "dependencies": { "character-entities-html4": "^2.0.0", "character-entities-legacy": "^3.0.0" } }, "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg=="],
|
||||
|
||||
"strip-ansi": ["strip-ansi@7.1.2", "", { "dependencies": { "ansi-regex": "^6.0.1" } }, "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA=="],
|
||||
|
||||
"style-to-js": ["style-to-js@1.1.21", "", { "dependencies": { "style-to-object": "1.0.14" } }, "sha512-RjQetxJrrUJLQPHbLku6U/ocGtzyjbJMP9lCNK7Ag0CNh690nSH8woqWH9u16nMjYBAok+i7JO1NP2pOy8IsPQ=="],
|
||||
|
||||
"style-to-object": ["style-to-object@1.0.14", "", { "dependencies": { "inline-style-parser": "0.2.7" } }, "sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw=="],
|
||||
|
||||
"tagged-tag": ["tagged-tag@1.0.0", "", {}, "sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng=="],
|
||||
|
||||
"tailwind-merge": ["tailwind-merge@3.4.0", "", {}, "sha512-uSaO4gnW+b3Y2aWoWfFpX62vn2sR3skfhbjsEnaBI81WD1wBLlHZe5sWf0AqjksNdYTbGBEd0UasQMT3SNV15g=="],
|
||||
|
|
@ -1074,8 +1285,12 @@
|
|||
|
||||
"toidentifier": ["toidentifier@1.0.1", "", {}, "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA=="],
|
||||
|
||||
"trim-lines": ["trim-lines@3.0.1", "", {}, "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg=="],
|
||||
|
||||
"triple-beam": ["triple-beam@1.4.1", "", {}, "sha512-aZbgViZrg1QNcG+LULa7nhZpJTZSLm/mXnHXnbAbjmN5aSa0y7V+wvv6+4WaBtpISJzThKy+PIPxc1Nq1EJ9mg=="],
|
||||
|
||||
"trough": ["trough@2.2.0", "", {}, "sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw=="],
|
||||
|
||||
"tsconfck": ["tsconfck@3.1.6", "", { "peerDependencies": { "typescript": "^5.0.0" }, "optionalPeers": ["typescript"], "bin": { "tsconfck": "bin/tsconfck.js" } }, "sha512-ks6Vjr/jEw0P1gmOVwutM3B7fWxoWBL2KRDb1JfqGVawBmO5UsvmWOQFGHBPl5yxYz4eERr19E6L7NMv+Fej4w=="],
|
||||
|
||||
"tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
|
||||
|
|
@ -1094,6 +1309,18 @@
|
|||
|
||||
"undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="],
|
||||
|
||||
"unified": ["unified@11.0.5", "", { "dependencies": { "@types/unist": "^3.0.0", "bail": "^2.0.0", "devlop": "^1.0.0", "extend": "^3.0.0", "is-plain-obj": "^4.0.0", "trough": "^2.0.0", "vfile": "^6.0.0" } }, "sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA=="],
|
||||
|
||||
"unist-util-is": ["unist-util-is@6.0.1", "", { "dependencies": { "@types/unist": "^3.0.0" } }, "sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g=="],
|
||||
|
||||
"unist-util-position": ["unist-util-position@5.0.0", "", { "dependencies": { "@types/unist": "^3.0.0" } }, "sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA=="],
|
||||
|
||||
"unist-util-stringify-position": ["unist-util-stringify-position@4.0.0", "", { "dependencies": { "@types/unist": "^3.0.0" } }, "sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ=="],
|
||||
|
||||
"unist-util-visit": ["unist-util-visit@5.0.0", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-is": "^6.0.0", "unist-util-visit-parents": "^6.0.0" } }, "sha512-MR04uvD+07cwl/yhVuVWAtw+3GOR/knlL55Nd/wAdblk27GCVt3lqpTivy/tkJcZoNPzTwS1Y+KMojlLDhoTzg=="],
|
||||
|
||||
"unist-util-visit-parents": ["unist-util-visit-parents@6.0.2", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-is": "^6.0.0" } }, "sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ=="],
|
||||
|
||||
"unpipe": ["unpipe@1.0.0", "", {}, "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ=="],
|
||||
|
||||
"unstorage": ["unstorage@1.17.3", "", { "dependencies": { "anymatch": "^3.1.3", "chokidar": "^4.0.3", "destr": "^2.0.5", "h3": "^1.15.4", "lru-cache": "^10.4.3", "node-fetch-native": "^1.6.7", "ofetch": "^1.5.1", "ufo": "^1.6.1" }, "peerDependencies": { "@azure/app-configuration": "^1.8.0", "@azure/cosmos": "^4.2.0", "@azure/data-tables": "^13.3.0", "@azure/identity": "^4.6.0", "@azure/keyvault-secrets": "^4.9.0", "@azure/storage-blob": "^12.26.0", "@capacitor/preferences": "^6.0.3 || ^7.0.0", "@deno/kv": ">=0.9.0", "@netlify/blobs": "^6.5.0 || ^7.0.0 || ^8.1.0 || ^9.0.0 || ^10.0.0", "@planetscale/database": "^1.19.0", "@upstash/redis": "^1.34.3", "@vercel/blob": ">=0.27.1", "@vercel/functions": "^2.2.12 || ^3.0.0", "@vercel/kv": "^1.0.1", "aws4fetch": "^1.0.20", "db0": ">=0.2.1", "idb-keyval": "^6.2.1", "ioredis": "^5.4.2", "uploadthing": "^7.4.4" }, "optionalPeers": ["@azure/app-configuration", "@azure/cosmos", "@azure/data-tables", "@azure/identity", "@azure/keyvault-secrets", "@azure/storage-blob", "@capacitor/preferences", "@deno/kv", "@netlify/blobs", "@planetscale/database", "@upstash/redis", "@vercel/blob", "@vercel/functions", "@vercel/kv", "aws4fetch", "db0", "idb-keyval", "ioredis", "uploadthing"] }, "sha512-i+JYyy0DoKmQ3FximTHbGadmIYb8JEpq7lxUjnjeB702bCPum0vzo6oy5Mfu0lpqISw7hCyMW2yj4nWC8bqJ3Q=="],
|
||||
|
|
@ -1114,6 +1341,10 @@
|
|||
|
||||
"vary": ["vary@1.1.2", "", {}, "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg=="],
|
||||
|
||||
"vfile": ["vfile@6.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "vfile-message": "^4.0.0" } }, "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q=="],
|
||||
|
||||
"vfile-message": ["vfile-message@4.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-stringify-position": "^4.0.0" } }, "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw=="],
|
||||
|
||||
"victory-vendor": ["victory-vendor@37.3.6", "", { "dependencies": { "@types/d3-array": "^3.0.3", "@types/d3-ease": "^3.0.0", "@types/d3-interpolate": "^3.0.1", "@types/d3-scale": "^4.0.2", "@types/d3-shape": "^3.1.0", "@types/d3-time": "^3.0.0", "@types/d3-timer": "^3.0.0", "d3-array": "^3.1.6", "d3-ease": "^3.0.1", "d3-interpolate": "^3.0.1", "d3-scale": "^4.0.2", "d3-shape": "^3.1.0", "d3-time": "^3.0.0", "d3-timer": "^3.0.1" } }, "sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ=="],
|
||||
|
||||
"vite": ["vite@7.3.0", "", { "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-dZwN5L1VlUBewiP6H9s2+B3e3Jg96D0vzN+Ry73sOefebhYr9f94wwkMNN/9ouoU8pV1BqA1d1zGk8928cx0rg=="],
|
||||
|
|
@ -1124,6 +1355,8 @@
|
|||
|
||||
"vite-tsconfig-paths": ["vite-tsconfig-paths@6.0.3", "", { "dependencies": { "debug": "^4.1.1", "globrex": "^0.1.2", "tsconfck": "^3.0.3" }, "peerDependencies": { "vite": "*" }, "optionalPeers": ["vite"] }, "sha512-7bL7FPX/DSviaZGYUKowWF1AiDVWjMjxNbE8lyaVGDezkedWqfGhlnQ4BZXre0ZN5P4kAgIJfAlgFDVyjrCIyg=="],
|
||||
|
||||
"whatwg-mimetype": ["whatwg-mimetype@3.0.0", "", {}, "sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q=="],
|
||||
|
||||
"which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="],
|
||||
|
||||
"winston": ["winston@3.19.0", "", { "dependencies": { "@colors/colors": "^1.6.0", "@dabh/diagnostics": "^2.0.8", "async": "^3.2.3", "is-stream": "^2.0.0", "logform": "^2.7.0", "one-time": "^1.0.0", "readable-stream": "^3.4.0", "safe-stable-stringify": "^2.3.1", "stack-trace": "0.0.x", "triple-beam": "^1.3.0", "winston-transport": "^4.9.0" } }, "sha512-LZNJgPzfKR+/J3cHkxcpHKpKKvGfDZVPS4hfJCc4cCG0CgYzvlD6yE/S3CIL/Yt91ak327YCpiF/0MyeZHEHKA=="],
|
||||
|
|
@ -1142,6 +1375,8 @@
|
|||
|
||||
"zod": ["zod@4.2.1", "", {}, "sha512-0wZ1IRqGGhMP76gLqz8EyfBXKk0J2qo2+H3fi4mcUP/KtTocoX08nmIAHl1Z2kJIZbZee8KOpBCSNPRgauucjw=="],
|
||||
|
||||
"zwitch": ["zwitch@2.0.4", "", {}, "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A=="],
|
||||
|
||||
"@babel/core/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
|
||||
|
||||
"@babel/helper-compilation-targets/lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="],
|
||||
|
|
@ -1150,6 +1385,10 @@
|
|||
|
||||
"@babel/helper-create-class-features-plugin/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
|
||||
|
||||
"@happy-dom/global-registrator/@types/node": ["@types/node@20.19.27", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-N2clP5pJhB2YnZJ3PIHFk5RkygRX5WO/5f0WC08tp0wd+sv0rsJk3MqWn3CbNmT2J505a5336jaQj4ph1AdMug=="],
|
||||
|
||||
"@hey-api/openapi-ts/semver": ["semver@7.7.2", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA=="],
|
||||
|
||||
"@radix-ui/react-alert-dialog/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
|
||||
|
||||
"@radix-ui/react-collection/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
|
||||
|
|
@ -1170,6 +1409,8 @@
|
|||
|
||||
"@radix-ui/react-tooltip/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
|
||||
|
||||
"@react-router/dev/semver": ["semver@7.7.2", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA=="],
|
||||
|
||||
"@reduxjs/toolkit/immer": ["immer@11.1.0", "", {}, "sha512-dlzb07f5LDY+tzs+iLCSXV2yuhaYfezqyZQc+n6baLECWkOMEWxkECAOnXL0ba7lsA25fM9b2jtzpu/uxo1a7g=="],
|
||||
|
||||
"@scalar/types/nanoid": ["nanoid@5.1.5", "", { "bin": { "nanoid": "bin/nanoid.js" } }, "sha512-Ir/+ZpE9fDsNH0hQ3C68uyThDXzYcim2EqcZ8zn8Chtt1iylPT9xXJB0kPCnqzgcEGikO9RxSrh63MsmVCU7Fw=="],
|
||||
|
|
@ -1210,6 +1451,8 @@
|
|||
|
||||
"giget/pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="],
|
||||
|
||||
"happy-dom/@types/node": ["@types/node@20.19.27", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-N2clP5pJhB2YnZJ3PIHFk5RkygRX5WO/5f0WC08tp0wd+sv0rsJk3MqWn3CbNmT2J505a5336jaQj4ph1AdMug=="],
|
||||
|
||||
"mime-types/mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="],
|
||||
|
||||
"morgan/debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="],
|
||||
|
|
@ -1218,16 +1461,26 @@
|
|||
|
||||
"nypm/pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="],
|
||||
|
||||
"parse-entities/@types/unist": ["@types/unist@2.0.11", "", {}, "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA=="],
|
||||
|
||||
"pkg-types/pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="],
|
||||
|
||||
"pretty-format/react-is": ["react-is@17.0.2", "", {}, "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w=="],
|
||||
|
||||
"raw-body/iconv-lite": ["iconv-lite@0.4.24", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3" } }, "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA=="],
|
||||
|
||||
"react-router-hono-server/@drizzle-team/brocli": ["@drizzle-team/brocli@0.11.0", "", {}, "sha512-hD3pekGiPg0WPCCGAZmusBBJsDqGUR66Y452YgQsZOnkdQ7ViEPKuyP4huUGEZQefp8g34RRodXYmJ2TbCH+tg=="],
|
||||
|
||||
"send/debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="],
|
||||
|
||||
"strip-ansi/ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="],
|
||||
|
||||
"vite-node/pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="],
|
||||
|
||||
"wrap-ansi/ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="],
|
||||
|
||||
"@happy-dom/global-registrator/@types/node/undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="],
|
||||
|
||||
"body-parser/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="],
|
||||
|
||||
"compression/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="],
|
||||
|
|
@ -1236,6 +1489,8 @@
|
|||
|
||||
"finalhandler/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="],
|
||||
|
||||
"happy-dom/@types/node/undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="],
|
||||
|
||||
"morgan/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="],
|
||||
|
||||
"send/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="],
|
||||
|
|
|
|||
16
package.json
16
package.json
|
|
@ -7,7 +7,8 @@
|
|||
"build": "react-router build",
|
||||
"dev": "bunx --bun vite",
|
||||
"start": "bun ./dist/server/index.js",
|
||||
"cli": "bun run app/server/cli/main.ts",
|
||||
"cli:dev": "bun run app/server/cli/main.ts",
|
||||
"cli": "ZEROBYTE_CLI=1 bun ./dist/server/index.js",
|
||||
"tsc": "react-router typegen && tsc",
|
||||
"lint": "biome check .",
|
||||
"lint:ci": "biome ci . --changed --error-on-warnings --no-errors-on-unmatched",
|
||||
|
|
@ -16,7 +17,9 @@
|
|||
"gen:api-client": "openapi-ts",
|
||||
"gen:migrations": "drizzle-kit generate",
|
||||
"studio": "drizzle-kit studio",
|
||||
"test": "dotenv -e .env.test -- bun test --preload ./app/test/setup.ts"
|
||||
"test:server": "dotenv -e .env.test -- bun test app/server --preload ./app/test/setup.ts",
|
||||
"test:client": "dotenv -e .env.test -- bun test app/client --preload ./app/test/setup-client.ts",
|
||||
"test": "bun run test:server && bun run test:client"
|
||||
},
|
||||
"overrides": {
|
||||
"esbuild": "^0.27.2"
|
||||
|
|
@ -30,6 +33,7 @@
|
|||
"@inquirer/prompts": "^8.0.2",
|
||||
"@radix-ui/react-alert-dialog": "^1.1.15",
|
||||
"@radix-ui/react-checkbox": "^1.3.3",
|
||||
"@radix-ui/react-collapsible": "^1.1.12",
|
||||
"@radix-ui/react-dialog": "^1.1.15",
|
||||
"@radix-ui/react-label": "^2.1.8",
|
||||
"@radix-ui/react-progress": "^1.1.8",
|
||||
|
|
@ -65,9 +69,12 @@
|
|||
"react": "^19.2.1",
|
||||
"react-dom": "^19.2.1",
|
||||
"react-hook-form": "^7.68.0",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-router": "^7.10.0",
|
||||
"react-router-hono-server": "^2.22.0",
|
||||
"recharts": "3.5.1",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"semver": "^7.7.3",
|
||||
"slugify": "^1.6.6",
|
||||
"sonner": "^2.0.7",
|
||||
"tailwind-merge": "^3.4.0",
|
||||
|
|
@ -78,14 +85,19 @@
|
|||
"devDependencies": {
|
||||
"@biomejs/biome": "^2.3.8",
|
||||
"@faker-js/faker": "^10.1.0",
|
||||
"@happy-dom/global-registrator": "^20.0.11",
|
||||
"@hey-api/openapi-ts": "^0.88.0",
|
||||
"@react-router/dev": "^7.10.0",
|
||||
"@tailwindcss/typography": "^0.5.19",
|
||||
"@tailwindcss/vite": "^4.1.17",
|
||||
"@tanstack/react-query-devtools": "^5.91.1",
|
||||
"@testing-library/dom": "^10.4.1",
|
||||
"@testing-library/react": "^16.3.1",
|
||||
"@types/bun": "^1.3.4",
|
||||
"@types/node": "^25.0.3",
|
||||
"@types/react": "^19.2.7",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@types/semver": "^7.7.1",
|
||||
"dotenv-cli": "^11.0.0",
|
||||
"drizzle-kit": "^0.31.7",
|
||||
"lightningcss": "^1.30.2",
|
||||
|
|
|
|||
Loading…
Reference in a new issue