diff --git a/app/client/api-client/@tanstack/react-query.gen.ts b/app/client/api-client/@tanstack/react-query.gen.ts index feb34cf2..a55dc09b 100644 --- a/app/client/api-client/@tanstack/react-query.gen.ts +++ b/app/client/api-client/@tanstack/react-query.gen.ts @@ -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>): UseMutationOptions> => { + const mutationOptions: UseMutationOptions> = { + mutationFn: async (fnOptions) => { + const { data } = await deleteSnapshots({ + ...options, + ...fnOptions, + throwOnError: true + }); + return data; + } + }; + return mutationOptions; +}; + export const listSnapshotsQueryKey = (options: Options) => createQueryKey('listSnapshots', options); /** @@ -630,6 +647,23 @@ export const doctorRepositoryMutation = (options?: Partial>): UseMutationOptions> => { + const mutationOptions: UseMutationOptions> = { + mutationFn: async (fnOptions) => { + const { data } = await tagSnapshots({ + ...options, + ...fnOptions, + throwOnError: true + }); + return data; + } + }; + return mutationOptions; +}; + export const listBackupSchedulesQueryKey = (options?: Options) => createQueryKey('listBackupSchedules', options); /** diff --git a/app/client/api-client/sdk.gen.ts b/app/client/api-client/sdk.gen.ts index 33635680..1ef13d0c 100644 --- a/app/client/api-client/sdk.gen.ts +++ b/app/client/api-client/sdk.gen.ts @@ -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 = Options2 & { /** @@ -235,6 +235,18 @@ export const updateRepository = (options: } }); +/** + * Delete multiple snapshots from a repository + */ +export const deleteSnapshots = (options: Options) => (options.client ?? client).delete({ + 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 = (options: O */ export const doctorRepository = (options: Options) => (options.client ?? client).post({ url: '/api/v1/repositories/{id}/doctor', ...options }); +/** + * Tag multiple snapshots in a repository + */ +export const tagSnapshots = (options: Options) => (options.client ?? client).post({ + url: '/api/v1/repositories/{id}/snapshots/tag', + ...options, + headers: { + 'Content-Type': 'application/json', + ...options.headers + } +}); + /** * List all backup schedules */ diff --git a/app/client/api-client/types.gen.ts b/app/client/api-client/types.gen.ts index 11044047..a6b1d1d0 100644 --- a/app/client/api-client/types.gen.ts +++ b/app/client/api-client/types.gen.ts @@ -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; + }; + 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; + add?: Array; + remove?: Array; + set?: Array; + }; + 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; }; diff --git a/app/client/components/__test__/file-tree.test.tsx b/app/client/components/__test__/file-tree.test.tsx new file mode 100644 index 00000000..7589fe63 --- /dev/null +++ b/app/client/components/__test__/file-tree.test.tsx @@ -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(); + const onSelectionChange = (selection: Set) => { + currentSelection = selection; + }; + + render( + 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(["/root"]); + const onSelectionChange = (selection: Set) => { + currentSelection = selection; + }; + + render( + 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(); + const onSelectionChange = (selection: Set) => { + currentSelection = selection; + }; + + const { rerender } = render( + 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( + 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(); + const onSelectionChange = (selection: Set) => { + currentSelection = selection; + }; + + render( + 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(["/hello", "/hello_prev", "/service/app/data/upload"]); + const onSelectionChange = (selection: Set) => { + currentSelection = selection; + }; + + render( + , + ); + + 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 + }); +}); diff --git a/app/client/components/app-breadcrumb.tsx b/app/client/components/app-breadcrumb.tsx index b6c32b29..6f602f62 100644 --- a/app/client/components/app-breadcrumb.tsx +++ b/app/client/components/app-breadcrumb.tsx @@ -38,7 +38,7 @@ export function AppBreadcrumb() { } return ( - + {breadcrumbs.map((breadcrumb, index) => { const isLast = index === breadcrumbs.length - 1; diff --git a/app/client/components/file-tree.tsx b/app/client/components/file-tree.tsx index 7789c821..fa9559c2 100644 --- a/app/client/components/file-tree.tsx +++ b/app/client/components/file-tree.tsx @@ -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(); - 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(); + 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 ( - + ); }); diff --git a/app/client/components/layout.tsx b/app/client/components/layout.tsx index 8142c252..6030391d 100644 --- a/app/client/components/layout.tsx +++ b/app/client/components/layout.tsx @@ -43,8 +43,8 @@ export default function Layout({ loaderData }: Route.ComponentProps) {
-
-
+
+
@@ -76,7 +76,7 @@ export default function Layout({ loaderData }: Route.ComponentProps) {
-
+
diff --git a/app/client/components/snapshots-table.tsx b/app/client/components/snapshots-table.tsx index dfee2792..0f8574bb 100644 --- a/app/client/components/snapshots-table.tsx +++ b/app/client/components/snapshots-table.tsx @@ -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(null); - const deleteSnapshot = useMutation({ - ...deleteSnapshotMutation(), + const [selectedIds, setSelectedIds] = useState>(new Set()); + const [showBulkDeleteConfirm, setShowBulkDeleteConfirm] = useState(false); + const [showReTagDialog, setShowReTagDialog] = useState(false); + const [targetScheduleId, setTargetScheduleId] = useState(""); + + 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 ( <> -
+
+ + 0} + onCheckedChange={toggleSelectAll} + aria-label="Select all" + /> + Snapshot ID Schedule Date & Time Size Duration - Volume - Actions {snapshots.map((snapshot) => { const backup = backups.find((b) => snapshot.tags.includes(b.shortId)); + const isSelected = selectedIds.has(snapshot.short_id); return ( handleRowClick(snapshot.short_id)} > + e.stopPropagation()}> + { + 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}`} + /> +
@@ -134,32 +202,6 @@ export const SnapshotsTable = ({ snapshots, repositoryId, backups }: Props) => { {formatDuration(snapshot.duration / 1000)}
- -
- - e.stopPropagation()} - className="hover:underline" - > - {backup ? backup.volume.name : "-"} - - -
-
- - -
); })} @@ -167,27 +209,99 @@ export const SnapshotsTable = ({ snapshots, repositoryId, backups }: Props) => {
- + {selectedIds.size > 0 && ( +
+
+
+ + {selectedIds.size} selected +
+
+ + +
+
+
+ )} + + - Delete snapshot? + Delete {selectedIds.size} snapshots? - 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. Cancel - Delete snapshot + Delete {selectedIds.size} snapshots + + + + + Re-tag snapshots + + Select a backup schedule to re-tag the {selectedIds.size} selected snapshots. All {selectedIds.size}{" "} + selected snapshots will be associated with the chosen schedule. + + +
+ +
+ + + + +
+
); }; diff --git a/app/client/components/ui/breadcrumb.tsx b/app/client/components/ui/breadcrumb.tsx index 4d0d3eab..e1fb0843 100644 --- a/app/client/components/ui/breadcrumb.tsx +++ b/app/client/components/ui/breadcrumb.tsx @@ -4,8 +4,8 @@ import { ChevronRight, MoreHorizontal } from "lucide-react"; import { cn } from "~/client/lib/utils"; -function Breadcrumb({ ...props }: React.ComponentProps<"nav">) { - return
diff --git a/app/client/components/ui/collapsible.tsx b/app/client/components/ui/collapsible.tsx new file mode 100644 index 00000000..5e13272d --- /dev/null +++ b/app/client/components/ui/collapsible.tsx @@ -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 { + open?: boolean; + onOpenChange?: (open: boolean) => void; + defaultOpen?: boolean; +} + +const CollapsibleContext = React.createContext<{ + open: boolean; + setOpen: React.Dispatch>; +}>({ + open: false, + setOpen: () => {}, +}); + +const Collapsible = React.forwardRef( + ({ 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) => { + const newValue = typeof value === "function" ? value(open) : value; + if (!isControlled) { + setUncontrolledOpen(newValue); + } + onOpenChange?.(newValue); + }, + [isControlled, open, onOpenChange], + ); + + return ( + +
+ {children} +
+
+ ); + }, +); +Collapsible.displayName = "Collapsible"; + +interface CollapsibleTriggerProps extends React.ButtonHTMLAttributes {} + +const CollapsibleTrigger = React.forwardRef( + ({ className, children, ...props }, ref) => { + const { open, setOpen } = React.useContext(CollapsibleContext); + + return ( + + ); + }, +); +CollapsibleTrigger.displayName = "CollapsibleTrigger"; + +interface CollapsibleContentProps extends React.HTMLAttributes {} + +const CollapsibleContent = React.forwardRef( + ({ className, children, ...props }, ref) => { + const { open } = React.useContext(CollapsibleContext); + + return ( + + ); + }, +); +CollapsibleContent.displayName = "CollapsibleContent"; + +export { Collapsible, CollapsibleTrigger, CollapsibleContent }; diff --git a/app/client/components/volume-file-browser.tsx b/app/client/components/volume-file-browser.tsx index 84ea09e5..6e9cd53e 100644 --- a/app/client/components/volume-file-browser.tsx +++ b/app/client/components/volume-file-browser.tsx @@ -57,7 +57,7 @@ export const VolumeFileBrowser = ({ if (fileBrowser.isLoading) { return ( -
+

Loading files...

); @@ -65,7 +65,7 @@ export const VolumeFileBrowser = ({ if (error) { return ( -
+

Failed to load files: {(error as Error).message}

); @@ -73,7 +73,7 @@ export const VolumeFileBrowser = ({ if (fileBrowser.isEmpty) { return ( -
+

{emptyMessage}

{emptyDescription &&

{emptyDescription}

} diff --git a/app/client/modules/backups/components/create-schedule-form.tsx b/app/client/modules/backups/components/create-schedule-form.tsx index 38437f7f..63d3a8e0 100644 --- a/app/client/modules/backups/components/create-schedule-form.tsx +++ b/app/client/modules/backups/components/create-schedule-form.tsx @@ -195,12 +195,12 @@ export const CreateScheduleForm = ({ initialValues, formId, onSubmit, volume }: Schedule automated backups of {volume.name} to a secure repository. - + ( - + Backup name @@ -215,7 +215,7 @@ export const CreateScheduleForm = ({ initialValues, formId, onSubmit, volume }: control={form.control} name="repositoryId" render={({ field }) => ( - + Backup repository @@ -327,7 +327,7 @@ export const CreateScheduleForm = ({ initialValues, formId, onSubmit, volume }: control={form.control} name="monthlyDays" render={({ field }) => ( - + Days of the month
@@ -512,7 +512,7 @@ export const CreateScheduleForm = ({ initialValues, formId, onSubmit, volume }: Retention policy Define how many snapshots to keep. Leave empty to keep all. - + Mirror Repositories - + Configure secondary repositories where snapshots will be automatically copied after each backup
@@ -270,9 +270,9 @@ export const ScheduleMirrorsConfig = ({ scheduleId, primaryRepositoryId, reposit Repository - Enabled - Last Copy - + Enabled + Last Copy + diff --git a/app/client/modules/backups/components/schedule-notifications-config.tsx b/app/client/modules/backups/components/schedule-notifications-config.tsx index 820e72b7..0a1d4f37 100644 --- a/app/client/modules/backups/components/schedule-notifications-config.tsx +++ b/app/client/modules/backups/components/schedule-notifications-config.tsx @@ -152,7 +152,9 @@ export const ScheduleNotificationsConfig = ({ scheduleId, destinations }: Props) Notifications - Configure which notifications to send for this backup schedule + + Configure which notifications to send for this backup schedule +
{!isAddingNew && availableDestinations.length > 0 && ( ) : ( - )} {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" > - Run cleanup + Run cleanup )} -
- +

Schedule

{summary.scheduleLabel}

@@ -178,19 +178,21 @@ export const ScheduleSummary = (props: Props) => {
{schedule.lastBackupStatus === "warning" && ( -
+

Warning Details

-

+

{schedule.lastBackupError ?? "Last backup completed with warnings. Check your container logs for more details."}

)} - {schedule.lastBackupError && ( -
-

Error Details

-

{schedule.lastBackupError}

+ {schedule.lastBackupError && schedule.lastBackupStatus === "error" && ( +
+

Error details

+

+ {schedule.lastBackupError} +

)} diff --git a/app/client/modules/backups/components/snapshot-timeline.tsx b/app/client/modules/backups/components/snapshot-timeline.tsx index 4bdce96c..7ed68ef4 100644 --- a/app/client/modules/backups/components/snapshot-timeline.tsx +++ b/app/client/modules/backups/components/snapshot-timeline.tsx @@ -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 ( -
-

Error loading snapshots: {error}

-
+ +

{error}

+
); } diff --git a/app/client/modules/backups/routes/backups.tsx b/app/client/modules/backups/routes/backups.tsx index 76f0c714..49b5ca54 100644 --- a/app/client/modules/backups/routes/backups.tsx +++ b/app/client/modules/backups/routes/backups.tsx @@ -117,7 +117,7 @@ export default function Backups({ loaderData }: Route.ComponentProps) {
-
+
{items.map((id) => { const schedule = scheduleMap.get(id); if (!schedule) return null; diff --git a/app/client/modules/repositories/components/create-repository-form.tsx b/app/client/modules/repositories/components/create-repository-form.tsx index 08fa6e0b..becbf94d 100644 --- a/app/client/modules/repositories/components/create-repository-form.tsx +++ b/app/client/modules/repositories/components/create-repository-form.tsx @@ -31,6 +31,7 @@ import { RcloneRepositoryForm, RestRepositoryForm, SftpRepositoryForm, + AdvancedForm, } from "./repository-forms"; export const formSchema = type({ @@ -225,12 +226,13 @@ export const CreateRepositoryForm = ({ - Use Zerobyte's password + Use the existing recovery key Enter password manually - 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. @@ -268,6 +270,8 @@ export const CreateRepositoryForm = ({ {watchedBackend === "rest" && } {watchedBackend === "sftp" && } + + {mode === "update" && (