Merge origin/main into 2fa-totp
This commit is contained in:
commit
e9b0db2178
35 changed files with 1331 additions and 240 deletions
|
|
@ -3,8 +3,8 @@
|
|||
import { type DefaultError, queryOptions, type UseMutationOptions } from '@tanstack/react-query';
|
||||
|
||||
import { client } from '../client.gen';
|
||||
import { browseFilesystem, createBackupSchedule, createNotificationDestination, createRepository, createVolume, deleteBackupSchedule, deleteNotificationDestination, deleteRepository, deleteSnapshot, deleteVolume, disable2Fa, doctorRepository, downloadResticPassword, enable2Fa, getBackupSchedule, getBackupScheduleForVolume, getMe, getMirrorCompatibility, getNotificationDestination, getRepository, getScheduleMirrors, getScheduleNotifications, getSnapshotDetails, getStatus, getSystemInfo, getTwoFactorStatus, getVolume, healthCheckVolume, changePassword, listBackupSchedules, listFiles, listNotificationDestinations, listRcloneRemotes, listRepositories, listSnapshotFiles, listSnapshots, listVolumes, login, logout, mountVolume, type Options, register, reorderBackupSchedules, restoreSnapshot, runBackupNow, runForget, setup2Fa, stopBackup, testConnection, testNotificationDestination, unmountVolume, updateBackupSchedule, updateNotificationDestination, updateRepository, updateScheduleMirrors, updateScheduleNotifications, updateVolume, verify2Fa } from '../sdk.gen';
|
||||
import type { BrowseFilesystemData, BrowseFilesystemResponse, CreateBackupScheduleData, CreateBackupScheduleResponse, CreateNotificationDestinationData, CreateNotificationDestinationResponse, CreateRepositoryData, CreateRepositoryResponse, CreateVolumeData, CreateVolumeResponse, DeleteBackupScheduleData, DeleteBackupScheduleResponse, DeleteNotificationDestinationData, DeleteNotificationDestinationResponse, DeleteRepositoryData, DeleteRepositoryResponse, DeleteSnapshotData, DeleteSnapshotResponse, DeleteVolumeData, DeleteVolumeResponse, Disable2FaData, Disable2FaResponse, DoctorRepositoryData, DoctorRepositoryResponse, DownloadResticPasswordData, DownloadResticPasswordResponse, Enable2FaData, Enable2FaResponse, GetBackupScheduleData, GetBackupScheduleForVolumeData, GetBackupScheduleForVolumeResponse, GetBackupScheduleResponse, GetMeData, GetMeResponse, GetMirrorCompatibilityData, GetMirrorCompatibilityResponse, GetNotificationDestinationData, GetNotificationDestinationResponse, GetRepositoryData, GetRepositoryResponse, GetScheduleMirrorsData, GetScheduleMirrorsResponse, GetScheduleNotificationsData, GetScheduleNotificationsResponse, GetSnapshotDetailsData, GetSnapshotDetailsResponse, GetStatusData, GetStatusResponse, GetSystemInfoData, GetSystemInfoResponse, GetTwoFactorStatusData, GetTwoFactorStatusResponse, GetVolumeData, GetVolumeResponse, HealthCheckVolumeData, HealthCheckVolumeResponse, ChangePasswordData, ChangePasswordResponse, 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, Setup2FaData, Setup2FaResponse, StopBackupData, StopBackupResponse, TestConnectionData, TestConnectionResponse, TestNotificationDestinationData, TestNotificationDestinationResponse, UnmountVolumeData, UnmountVolumeResponse, UpdateBackupScheduleData, UpdateBackupScheduleResponse, UpdateNotificationDestinationData, UpdateNotificationDestinationResponse, UpdateRepositoryData, UpdateRepositoryResponse, UpdateScheduleMirrorsData, UpdateScheduleMirrorsResponse, UpdateScheduleNotificationsData, UpdateScheduleNotificationsResponse, UpdateVolumeData, UpdateVolumeResponse, Verify2FaData, Verify2FaResponse } from '../types.gen';
|
||||
import { browseFilesystem, createBackupSchedule, createNotificationDestination, createRepository, createVolume, deleteBackupSchedule, deleteNotificationDestination, deleteRepository, deleteSnapshot, deleteSnapshots, deleteVolume, disable2Fa, doctorRepository, downloadResticPassword, enable2Fa, getBackupSchedule, getBackupScheduleForVolume, getMe, getMirrorCompatibility, getNotificationDestination, getRepository, getScheduleMirrors, getScheduleNotifications, getSnapshotDetails, getStatus, getSystemInfo, getTwoFactorStatus, getVolume, healthCheckVolume, changePassword, listBackupSchedules, listFiles, listNotificationDestinations, listRcloneRemotes, listRepositories, listSnapshotFiles, listSnapshots, listVolumes, login, logout, mountVolume, type Options, register, reorderBackupSchedules, restoreSnapshot, runBackupNow, runForget, setup2Fa, stopBackup, tagSnapshots, testConnection, testNotificationDestination, unmountVolume, updateBackupSchedule, updateNotificationDestination, updateRepository, updateScheduleMirrors, updateScheduleNotifications, updateVolume, verify2Fa } from '../sdk.gen';
|
||||
import type { BrowseFilesystemData, BrowseFilesystemResponse, CreateBackupScheduleData, CreateBackupScheduleResponse, CreateNotificationDestinationData, CreateNotificationDestinationResponse, CreateRepositoryData, CreateRepositoryResponse, CreateVolumeData, CreateVolumeResponse, DeleteBackupScheduleData, DeleteBackupScheduleResponse, DeleteNotificationDestinationData, DeleteNotificationDestinationResponse, DeleteRepositoryData, DeleteRepositoryResponse, DeleteSnapshotData, DeleteSnapshotResponse, DeleteSnapshotsData, DeleteSnapshotsResponse, DeleteVolumeData, DeleteVolumeResponse, Disable2FaData, Disable2FaResponse, DoctorRepositoryData, DoctorRepositoryResponse, DownloadResticPasswordData, DownloadResticPasswordResponse, Enable2FaData, Enable2FaResponse, GetBackupScheduleData, GetBackupScheduleForVolumeData, GetBackupScheduleForVolumeResponse, GetBackupScheduleResponse, GetMeData, GetMeResponse, GetMirrorCompatibilityData, GetMirrorCompatibilityResponse, GetNotificationDestinationData, GetNotificationDestinationResponse, GetRepositoryData, GetRepositoryResponse, GetScheduleMirrorsData, GetScheduleMirrorsResponse, GetScheduleNotificationsData, GetScheduleNotificationsResponse, GetSnapshotDetailsData, GetSnapshotDetailsResponse, GetStatusData, GetStatusResponse, GetSystemInfoData, GetSystemInfoResponse, GetTwoFactorStatusData, GetTwoFactorStatusResponse, GetVolumeData, GetVolumeResponse, HealthCheckVolumeData, HealthCheckVolumeResponse, ChangePasswordData, ChangePasswordResponse, 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, Setup2FaData, Setup2FaResponse, StopBackupData, StopBackupResponse, TagSnapshotsData, TagSnapshotsResponse, TestConnectionData, TestConnectionResponse, TestNotificationDestinationData, TestNotificationDestinationResponse, UnmountVolumeData, UnmountVolumeResponse, UpdateBackupScheduleData, UpdateBackupScheduleResponse, UpdateNotificationDestinationData, UpdateNotificationDestinationResponse, UpdateRepositoryData, UpdateRepositoryResponse, UpdateScheduleMirrorsData, UpdateScheduleMirrorsResponse, UpdateScheduleNotificationsData, UpdateScheduleNotificationsResponse, UpdateVolumeData, UpdateVolumeResponse, Verify2FaData, Verify2FaResponse } from '../types.gen';
|
||||
|
||||
/**
|
||||
* Register a new user
|
||||
|
|
@ -525,6 +525,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);
|
||||
|
||||
/**
|
||||
|
|
@ -630,6 +647,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);
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
import type { Client, Options as Options2, TDataShape } from './client';
|
||||
import { client } from './client.gen';
|
||||
import type { BrowseFilesystemData, BrowseFilesystemResponses, CreateBackupScheduleData, CreateBackupScheduleResponses, CreateNotificationDestinationData, CreateNotificationDestinationResponses, CreateRepositoryData, CreateRepositoryResponses, CreateVolumeData, CreateVolumeResponses, DeleteBackupScheduleData, DeleteBackupScheduleResponses, DeleteNotificationDestinationData, DeleteNotificationDestinationErrors, DeleteNotificationDestinationResponses, DeleteRepositoryData, DeleteRepositoryResponses, DeleteSnapshotData, DeleteSnapshotResponses, DeleteVolumeData, DeleteVolumeResponses, Disable2FaData, Disable2FaErrors, Disable2FaResponses, DoctorRepositoryData, DoctorRepositoryResponses, DownloadResticPasswordData, DownloadResticPasswordResponses, Enable2FaData, Enable2FaErrors, Enable2FaResponses, GetBackupScheduleData, GetBackupScheduleForVolumeData, GetBackupScheduleForVolumeResponses, GetBackupScheduleResponses, GetMeData, GetMeResponses, GetMirrorCompatibilityData, GetMirrorCompatibilityResponses, GetNotificationDestinationData, GetNotificationDestinationErrors, GetNotificationDestinationResponses, GetRepositoryData, GetRepositoryResponses, GetScheduleMirrorsData, GetScheduleMirrorsResponses, GetScheduleNotificationsData, GetScheduleNotificationsResponses, GetSnapshotDetailsData, GetSnapshotDetailsResponses, GetStatusData, GetStatusResponses, GetSystemInfoData, GetSystemInfoResponses, GetTwoFactorStatusData, GetTwoFactorStatusErrors, GetTwoFactorStatusResponses, GetVolumeData, GetVolumeErrors, GetVolumeResponses, HealthCheckVolumeData, HealthCheckVolumeErrors, HealthCheckVolumeResponses, ChangePasswordData, ChangePasswordResponses, 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, Setup2FaData, Setup2FaErrors, Setup2FaResponses, 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, Verify2FaData, Verify2FaErrors, Verify2FaResponses } from './types.gen';
|
||||
import type { BrowseFilesystemData, BrowseFilesystemResponses, CreateBackupScheduleData, CreateBackupScheduleResponses, CreateNotificationDestinationData, CreateNotificationDestinationResponses, CreateRepositoryData, CreateRepositoryResponses, CreateVolumeData, CreateVolumeResponses, DeleteBackupScheduleData, DeleteBackupScheduleResponses, DeleteNotificationDestinationData, DeleteNotificationDestinationErrors, DeleteNotificationDestinationResponses, DeleteRepositoryData, DeleteRepositoryResponses, DeleteSnapshotData, DeleteSnapshotResponses, DeleteSnapshotsData, DeleteSnapshotsResponses, DeleteVolumeData, DeleteVolumeResponses, Disable2FaData, Disable2FaErrors, Disable2FaResponses, DoctorRepositoryData, DoctorRepositoryResponses, DownloadResticPasswordData, DownloadResticPasswordResponses, Enable2FaData, Enable2FaErrors, Enable2FaResponses, GetBackupScheduleData, GetBackupScheduleForVolumeData, GetBackupScheduleForVolumeResponses, GetBackupScheduleResponses, GetMeData, GetMeResponses, GetMirrorCompatibilityData, GetMirrorCompatibilityResponses, GetNotificationDestinationData, GetNotificationDestinationErrors, GetNotificationDestinationResponses, GetRepositoryData, GetRepositoryResponses, GetScheduleMirrorsData, GetScheduleMirrorsResponses, GetScheduleNotificationsData, GetScheduleNotificationsResponses, GetSnapshotDetailsData, GetSnapshotDetailsResponses, GetStatusData, GetStatusResponses, GetSystemInfoData, GetSystemInfoResponses, GetTwoFactorStatusData, GetTwoFactorStatusErrors, GetTwoFactorStatusResponses, GetVolumeData, GetVolumeErrors, GetVolumeResponses, HealthCheckVolumeData, HealthCheckVolumeErrors, HealthCheckVolumeResponses, ChangePasswordData, ChangePasswordResponses, 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, Setup2FaData, Setup2FaErrors, Setup2FaResponses, 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, Verify2FaData, Verify2FaErrors, Verify2FaResponses } from './types.gen';
|
||||
|
||||
export type Options<TData extends TDataShape = TDataShape, ThrowOnError extends boolean = boolean> = Options2<TData, ThrowOnError> & {
|
||||
/**
|
||||
|
|
@ -235,6 +235,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
|
||||
*/
|
||||
|
|
@ -272,6 +284,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
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -962,7 +962,9 @@ export type ListRepositoriesResponses = {
|
|||
bucket: string;
|
||||
endpoint: string;
|
||||
secretAccessKey: string;
|
||||
cacert?: string;
|
||||
customPassword?: string;
|
||||
insecureTls?: boolean;
|
||||
isExistingRepository?: boolean;
|
||||
} | {
|
||||
accessKeyId: string;
|
||||
|
|
@ -970,39 +972,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;
|
||||
|
|
@ -1015,7 +1029,9 @@ export type ListRepositoriesResponses = {
|
|||
user: string;
|
||||
port?: number;
|
||||
skipHostKeyCheck?: boolean;
|
||||
cacert?: string;
|
||||
customPassword?: string;
|
||||
insecureTls?: boolean;
|
||||
isExistingRepository?: boolean;
|
||||
knownHosts?: string;
|
||||
};
|
||||
|
|
@ -1041,7 +1057,9 @@ export type CreateRepositoryData = {
|
|||
bucket: string;
|
||||
endpoint: string;
|
||||
secretAccessKey: string;
|
||||
cacert?: string;
|
||||
customPassword?: string;
|
||||
insecureTls?: boolean;
|
||||
isExistingRepository?: boolean;
|
||||
} | {
|
||||
accessKeyId: string;
|
||||
|
|
@ -1049,39 +1067,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;
|
||||
|
|
@ -1094,7 +1124,9 @@ export type CreateRepositoryData = {
|
|||
user: string;
|
||||
port?: number;
|
||||
skipHostKeyCheck?: boolean;
|
||||
cacert?: string;
|
||||
customPassword?: string;
|
||||
insecureTls?: boolean;
|
||||
isExistingRepository?: boolean;
|
||||
knownHosts?: string;
|
||||
};
|
||||
|
|
@ -1182,7 +1214,9 @@ export type GetRepositoryResponses = {
|
|||
bucket: string;
|
||||
endpoint: string;
|
||||
secretAccessKey: string;
|
||||
cacert?: string;
|
||||
customPassword?: string;
|
||||
insecureTls?: boolean;
|
||||
isExistingRepository?: boolean;
|
||||
} | {
|
||||
accessKeyId: string;
|
||||
|
|
@ -1190,39 +1224,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;
|
||||
|
|
@ -1235,7 +1281,9 @@ export type GetRepositoryResponses = {
|
|||
user: string;
|
||||
port?: number;
|
||||
skipHostKeyCheck?: boolean;
|
||||
cacert?: string;
|
||||
customPassword?: string;
|
||||
insecureTls?: boolean;
|
||||
isExistingRepository?: boolean;
|
||||
knownHosts?: string;
|
||||
};
|
||||
|
|
@ -1288,7 +1336,9 @@ export type UpdateRepositoryResponses = {
|
|||
bucket: string;
|
||||
endpoint: string;
|
||||
secretAccessKey: string;
|
||||
cacert?: string;
|
||||
customPassword?: string;
|
||||
insecureTls?: boolean;
|
||||
isExistingRepository?: boolean;
|
||||
} | {
|
||||
accessKeyId: string;
|
||||
|
|
@ -1296,39 +1346,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;
|
||||
|
|
@ -1341,7 +1403,9 @@ export type UpdateRepositoryResponses = {
|
|||
user: string;
|
||||
port?: number;
|
||||
skipHostKeyCheck?: boolean;
|
||||
cacert?: string;
|
||||
customPassword?: string;
|
||||
insecureTls?: boolean;
|
||||
isExistingRepository?: boolean;
|
||||
knownHosts?: string;
|
||||
};
|
||||
|
|
@ -1359,6 +1423,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: {
|
||||
|
|
@ -1531,6 +1617,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;
|
||||
|
|
@ -1564,7 +1675,9 @@ export type ListBackupSchedulesResponses = {
|
|||
bucket: string;
|
||||
endpoint: string;
|
||||
secretAccessKey: string;
|
||||
cacert?: string;
|
||||
customPassword?: string;
|
||||
insecureTls?: boolean;
|
||||
isExistingRepository?: boolean;
|
||||
} | {
|
||||
accessKeyId: string;
|
||||
|
|
@ -1572,39 +1685,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;
|
||||
|
|
@ -1617,7 +1742,9 @@ export type ListBackupSchedulesResponses = {
|
|||
user: string;
|
||||
port?: number;
|
||||
skipHostKeyCheck?: boolean;
|
||||
cacert?: string;
|
||||
customPassword?: string;
|
||||
insecureTls?: boolean;
|
||||
isExistingRepository?: boolean;
|
||||
knownHosts?: string;
|
||||
};
|
||||
|
|
@ -1826,7 +1953,9 @@ export type GetBackupScheduleResponses = {
|
|||
bucket: string;
|
||||
endpoint: string;
|
||||
secretAccessKey: string;
|
||||
cacert?: string;
|
||||
customPassword?: string;
|
||||
insecureTls?: boolean;
|
||||
isExistingRepository?: boolean;
|
||||
} | {
|
||||
accessKeyId: string;
|
||||
|
|
@ -1834,39 +1963,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;
|
||||
|
|
@ -1879,7 +2020,9 @@ export type GetBackupScheduleResponses = {
|
|||
user: string;
|
||||
port?: number;
|
||||
skipHostKeyCheck?: boolean;
|
||||
cacert?: string;
|
||||
customPassword?: string;
|
||||
insecureTls?: boolean;
|
||||
isExistingRepository?: boolean;
|
||||
knownHosts?: string;
|
||||
};
|
||||
|
|
@ -2069,7 +2212,9 @@ export type GetBackupScheduleForVolumeResponses = {
|
|||
bucket: string;
|
||||
endpoint: string;
|
||||
secretAccessKey: string;
|
||||
cacert?: string;
|
||||
customPassword?: string;
|
||||
insecureTls?: boolean;
|
||||
isExistingRepository?: boolean;
|
||||
} | {
|
||||
accessKeyId: string;
|
||||
|
|
@ -2077,39 +2222,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;
|
||||
|
|
@ -2122,7 +2279,9 @@ export type GetBackupScheduleForVolumeResponses = {
|
|||
user: string;
|
||||
port?: number;
|
||||
skipHostKeyCheck?: boolean;
|
||||
cacert?: string;
|
||||
customPassword?: string;
|
||||
insecureTls?: boolean;
|
||||
isExistingRepository?: boolean;
|
||||
knownHosts?: string;
|
||||
};
|
||||
|
|
@ -2499,7 +2658,9 @@ export type GetScheduleMirrorsResponses = {
|
|||
bucket: string;
|
||||
endpoint: string;
|
||||
secretAccessKey: string;
|
||||
cacert?: string;
|
||||
customPassword?: string;
|
||||
insecureTls?: boolean;
|
||||
isExistingRepository?: boolean;
|
||||
} | {
|
||||
accessKeyId: string;
|
||||
|
|
@ -2507,39 +2668,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;
|
||||
|
|
@ -2552,7 +2725,9 @@ export type GetScheduleMirrorsResponses = {
|
|||
user: string;
|
||||
port?: number;
|
||||
skipHostKeyCheck?: boolean;
|
||||
cacert?: string;
|
||||
customPassword?: string;
|
||||
insecureTls?: boolean;
|
||||
isExistingRepository?: boolean;
|
||||
knownHosts?: string;
|
||||
};
|
||||
|
|
@ -2605,7 +2780,9 @@ export type UpdateScheduleMirrorsResponses = {
|
|||
bucket: string;
|
||||
endpoint: string;
|
||||
secretAccessKey: string;
|
||||
cacert?: string;
|
||||
customPassword?: string;
|
||||
insecureTls?: boolean;
|
||||
isExistingRepository?: boolean;
|
||||
} | {
|
||||
accessKeyId: string;
|
||||
|
|
@ -2613,39 +2790,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;
|
||||
|
|
@ -2658,7 +2847,9 @@ export type UpdateScheduleMirrorsResponses = {
|
|||
user: string;
|
||||
port?: number;
|
||||
skipHostKeyCheck?: boolean;
|
||||
cacert?: string;
|
||||
customPassword?: string;
|
||||
insecureTls?: boolean;
|
||||
isExistingRepository?: boolean;
|
||||
knownHosts?: 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;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>}
|
||||
|
|
|
|||
|
|
@ -195,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} />
|
||||
|
|
@ -215,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}>
|
||||
|
|
@ -300,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}>
|
||||
|
|
@ -327,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">
|
||||
|
|
@ -512,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;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -566,7 +566,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">
|
||||
|
|
@ -579,15 +579,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">
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
50
bun.lock
50
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",
|
||||
|
|
@ -63,10 +64,13 @@
|
|||
"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/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",
|
||||
|
|
@ -142,6 +146,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=="],
|
||||
|
|
@ -246,6 +252,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=="],
|
||||
|
|
@ -320,6 +328,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=="],
|
||||
|
|
@ -498,6 +508,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=="],
|
||||
|
|
@ -532,13 +548,15 @@
|
|||
|
||||
"@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=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
|
|
@ -548,6 +566,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=="],
|
||||
|
|
@ -680,6 +700,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=="],
|
||||
|
|
@ -690,6 +712,8 @@
|
|||
|
||||
"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=="],
|
||||
|
|
@ -782,6 +806,8 @@
|
|||
|
||||
"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=="],
|
||||
|
|
@ -868,6 +894,8 @@
|
|||
|
||||
"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=="],
|
||||
|
||||
"math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="],
|
||||
|
|
@ -954,6 +982,8 @@
|
|||
|
||||
"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=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
"qrcode.react": ["qrcode.react@4.2.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-QpgqWi8rD9DsS9EP3z7BT+5lY5SFhsqGjpgW5DY/i3mK4M9DTBNz3ErMi8BWYEfI3L0d8GIbGmcdFAS1uIRGjA=="],
|
||||
|
|
@ -1132,6 +1162,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=="],
|
||||
|
|
@ -1158,6 +1190,8 @@
|
|||
|
||||
"@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=="],
|
||||
|
||||
"@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=="],
|
||||
|
|
@ -1218,6 +1252,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=="],
|
||||
|
|
@ -1228,14 +1264,22 @@
|
|||
|
||||
"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=="],
|
||||
|
|
@ -1244,6 +1288,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=="],
|
||||
|
|
|
|||
|
|
@ -17,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"
|
||||
|
|
@ -31,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",
|
||||
|
|
@ -81,10 +84,13 @@
|
|||
"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/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",
|
||||
|
|
|
|||
Loading…
Reference in a new issue