Merge origin/main into 2fa-totp

This commit is contained in:
Jakub Trávník 2026-01-03 12:07:00 +01:00
commit e9b0db2178
35 changed files with 1331 additions and 240 deletions

View file

@ -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);
/**

View file

@ -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
*/

View file

@ -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;
};

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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"

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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">

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

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

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

View file

@ -13,6 +13,7 @@
"@inquirer/prompts": "^8.0.2",
"@radix-ui/react-alert-dialog": "^1.1.15",
"@radix-ui/react-checkbox": "^1.3.3",
"@radix-ui/react-collapsible": "^1.1.12",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-label": "^2.1.8",
"@radix-ui/react-progress": "^1.1.8",
@ -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=="],

View file

@ -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",