diff --git a/app/client/api-client/@tanstack/react-query.gen.ts b/app/client/api-client/@tanstack/react-query.gen.ts index 910e0fd..7cdc0d7 100644 --- a/app/client/api-client/@tanstack/react-query.gen.ts +++ b/app/client/api-client/@tanstack/react-query.gen.ts @@ -3,8 +3,8 @@ import { type DefaultError, queryOptions, type UseMutationOptions } from '@tanstack/react-query'; import { client } from '../client.gen'; -import { browseFilesystem, changePassword, createBackupSchedule, createRepository, createVolume, deleteBackupSchedule, deleteRepository, deleteVolume, doctorRepository, downloadResticPassword, getBackupSchedule, getBackupScheduleForVolume, getContainersUsingVolume, getMe, getRepository, getSnapshotDetails, getStatus, getSystemInfo, getVolume, healthCheckVolume, listBackupSchedules, listFiles, listRcloneRemotes, listRepositories, listSnapshotFiles, listSnapshots, listVolumes, login, logout, mountVolume, type Options, register, restoreSnapshot, runBackupNow, runForget, stopBackup, testConnection, unmountVolume, updateBackupSchedule, updateVolume } from '../sdk.gen'; -import type { BrowseFilesystemData, BrowseFilesystemResponse, ChangePasswordData, ChangePasswordResponse, CreateBackupScheduleData, CreateBackupScheduleResponse, CreateRepositoryData, CreateRepositoryResponse, CreateVolumeData, CreateVolumeResponse, DeleteBackupScheduleData, DeleteBackupScheduleResponse, DeleteRepositoryData, DeleteRepositoryResponse, DeleteVolumeData, DeleteVolumeResponse, DoctorRepositoryData, DoctorRepositoryResponse, DownloadResticPasswordData, DownloadResticPasswordResponse, GetBackupScheduleData, GetBackupScheduleForVolumeData, GetBackupScheduleForVolumeResponse, GetBackupScheduleResponse, GetContainersUsingVolumeData, GetContainersUsingVolumeResponse, GetMeData, GetMeResponse, GetRepositoryData, GetRepositoryResponse, GetSnapshotDetailsData, GetSnapshotDetailsResponse, GetStatusData, GetStatusResponse, GetSystemInfoData, GetSystemInfoResponse, GetVolumeData, GetVolumeResponse, HealthCheckVolumeData, HealthCheckVolumeResponse, ListBackupSchedulesData, ListBackupSchedulesResponse, ListFilesData, ListFilesResponse, ListRcloneRemotesData, ListRcloneRemotesResponse, ListRepositoriesData, ListRepositoriesResponse, ListSnapshotFilesData, ListSnapshotFilesResponse, ListSnapshotsData, ListSnapshotsResponse, ListVolumesData, ListVolumesResponse, LoginData, LoginResponse, LogoutData, LogoutResponse, MountVolumeData, MountVolumeResponse, RegisterData, RegisterResponse, RestoreSnapshotData, RestoreSnapshotResponse, RunBackupNowData, RunBackupNowResponse, RunForgetData, RunForgetResponse, StopBackupData, StopBackupResponse, TestConnectionData, TestConnectionResponse, UnmountVolumeData, UnmountVolumeResponse, UpdateBackupScheduleData, UpdateBackupScheduleResponse, UpdateVolumeData, UpdateVolumeResponse } from '../types.gen'; +import { browseFilesystem, changePassword, createBackupSchedule, createRepository, createVolume, deleteBackupSchedule, deleteRepository, deleteSnapshot, deleteVolume, doctorRepository, downloadResticPassword, getBackupSchedule, getBackupScheduleForVolume, getContainersUsingVolume, getMe, getRepository, getSnapshotDetails, getStatus, getSystemInfo, getVolume, healthCheckVolume, listBackupSchedules, listFiles, listRcloneRemotes, listRepositories, listSnapshotFiles, listSnapshots, listVolumes, login, logout, mountVolume, type Options, register, restoreSnapshot, runBackupNow, runForget, stopBackup, testConnection, unmountVolume, updateBackupSchedule, updateVolume } from '../sdk.gen'; +import type { BrowseFilesystemData, BrowseFilesystemResponse, ChangePasswordData, ChangePasswordResponse, CreateBackupScheduleData, CreateBackupScheduleResponse, CreateRepositoryData, CreateRepositoryResponse, CreateVolumeData, CreateVolumeResponse, DeleteBackupScheduleData, DeleteBackupScheduleResponse, DeleteRepositoryData, DeleteRepositoryResponse, DeleteSnapshotData, DeleteSnapshotResponse, DeleteVolumeData, DeleteVolumeResponse, DoctorRepositoryData, DoctorRepositoryResponse, DownloadResticPasswordData, DownloadResticPasswordResponse, GetBackupScheduleData, GetBackupScheduleForVolumeData, GetBackupScheduleForVolumeResponse, GetBackupScheduleResponse, GetContainersUsingVolumeData, GetContainersUsingVolumeResponse, GetMeData, GetMeResponse, GetRepositoryData, GetRepositoryResponse, GetSnapshotDetailsData, GetSnapshotDetailsResponse, GetStatusData, GetStatusResponse, GetSystemInfoData, GetSystemInfoResponse, GetVolumeData, GetVolumeResponse, HealthCheckVolumeData, HealthCheckVolumeResponse, ListBackupSchedulesData, ListBackupSchedulesResponse, ListFilesData, ListFilesResponse, ListRcloneRemotesData, ListRcloneRemotesResponse, ListRepositoriesData, ListRepositoriesResponse, ListSnapshotFilesData, ListSnapshotFilesResponse, ListSnapshotsData, ListSnapshotsResponse, ListVolumesData, ListVolumesResponse, LoginData, LoginResponse, LogoutData, LogoutResponse, MountVolumeData, MountVolumeResponse, RegisterData, RegisterResponse, RestoreSnapshotData, RestoreSnapshotResponse, RunBackupNowData, RunBackupNowResponse, RunForgetData, RunForgetResponse, StopBackupData, StopBackupResponse, TestConnectionData, TestConnectionResponse, UnmountVolumeData, UnmountVolumeResponse, UpdateBackupScheduleData, UpdateBackupScheduleResponse, UpdateVolumeData, UpdateVolumeResponse } from '../types.gen'; /** * Register a new user @@ -460,6 +460,23 @@ export const listSnapshotsOptions = (options: Options) => que queryKey: listSnapshotsQueryKey(options) }); +/** + * Delete a specific snapshot from a repository + */ +export const deleteSnapshotMutation = (options?: Partial>): UseMutationOptions> => { + const mutationOptions: UseMutationOptions> = { + mutationFn: async (fnOptions) => { + const { data } = await deleteSnapshot({ + ...options, + ...fnOptions, + throwOnError: true + }); + return data; + } + }; + return mutationOptions; +}; + export const getSnapshotDetailsQueryKey = (options: Options) => createQueryKey("getSnapshotDetails", options); /** diff --git a/app/client/api-client/sdk.gen.ts b/app/client/api-client/sdk.gen.ts index 3e20b8c..c71979b 100644 --- a/app/client/api-client/sdk.gen.ts +++ b/app/client/api-client/sdk.gen.ts @@ -2,7 +2,7 @@ import type { Client, Options as Options2, TDataShape } from './client'; import { client } from './client.gen'; -import type { BrowseFilesystemData, BrowseFilesystemResponses, ChangePasswordData, ChangePasswordResponses, CreateBackupScheduleData, CreateBackupScheduleResponses, CreateRepositoryData, CreateRepositoryResponses, CreateVolumeData, CreateVolumeResponses, DeleteBackupScheduleData, DeleteBackupScheduleResponses, DeleteRepositoryData, DeleteRepositoryResponses, DeleteVolumeData, DeleteVolumeResponses, DoctorRepositoryData, DoctorRepositoryResponses, DownloadResticPasswordData, DownloadResticPasswordResponses, GetBackupScheduleData, GetBackupScheduleForVolumeData, GetBackupScheduleForVolumeResponses, GetBackupScheduleResponses, GetContainersUsingVolumeData, GetContainersUsingVolumeErrors, GetContainersUsingVolumeResponses, GetMeData, GetMeResponses, GetRepositoryData, GetRepositoryResponses, GetSnapshotDetailsData, GetSnapshotDetailsResponses, GetStatusData, GetStatusResponses, GetSystemInfoData, GetSystemInfoResponses, GetVolumeData, GetVolumeErrors, GetVolumeResponses, HealthCheckVolumeData, HealthCheckVolumeErrors, HealthCheckVolumeResponses, ListBackupSchedulesData, ListBackupSchedulesResponses, ListFilesData, ListFilesResponses, ListRcloneRemotesData, ListRcloneRemotesResponses, ListRepositoriesData, ListRepositoriesResponses, ListSnapshotFilesData, ListSnapshotFilesResponses, ListSnapshotsData, ListSnapshotsResponses, ListVolumesData, ListVolumesResponses, LoginData, LoginResponses, LogoutData, LogoutResponses, MountVolumeData, MountVolumeResponses, RegisterData, RegisterResponses, RestoreSnapshotData, RestoreSnapshotResponses, RunBackupNowData, RunBackupNowResponses, RunForgetData, RunForgetErrors, RunForgetResponses, StopBackupData, StopBackupErrors, StopBackupResponses, TestConnectionData, TestConnectionResponses, UnmountVolumeData, UnmountVolumeResponses, UpdateBackupScheduleData, UpdateBackupScheduleResponses, UpdateVolumeData, UpdateVolumeErrors, UpdateVolumeResponses } from './types.gen'; +import type { BrowseFilesystemData, BrowseFilesystemResponses, ChangePasswordData, ChangePasswordResponses, CreateBackupScheduleData, CreateBackupScheduleResponses, CreateRepositoryData, CreateRepositoryResponses, CreateVolumeData, CreateVolumeResponses, DeleteBackupScheduleData, DeleteBackupScheduleResponses, DeleteRepositoryData, DeleteRepositoryResponses, DeleteSnapshotData, DeleteSnapshotResponses, DeleteVolumeData, DeleteVolumeResponses, DoctorRepositoryData, DoctorRepositoryResponses, DownloadResticPasswordData, DownloadResticPasswordResponses, GetBackupScheduleData, GetBackupScheduleForVolumeData, GetBackupScheduleForVolumeResponses, GetBackupScheduleResponses, GetContainersUsingVolumeData, GetContainersUsingVolumeErrors, GetContainersUsingVolumeResponses, GetMeData, GetMeResponses, GetRepositoryData, GetRepositoryResponses, GetSnapshotDetailsData, GetSnapshotDetailsResponses, GetStatusData, GetStatusResponses, GetSystemInfoData, GetSystemInfoResponses, GetVolumeData, GetVolumeErrors, GetVolumeResponses, HealthCheckVolumeData, HealthCheckVolumeErrors, HealthCheckVolumeResponses, ListBackupSchedulesData, ListBackupSchedulesResponses, ListFilesData, ListFilesResponses, ListRcloneRemotesData, ListRcloneRemotesResponses, ListRepositoriesData, ListRepositoriesResponses, ListSnapshotFilesData, ListSnapshotFilesResponses, ListSnapshotsData, ListSnapshotsResponses, ListVolumesData, ListVolumesResponses, LoginData, LoginResponses, LogoutData, LogoutResponses, MountVolumeData, MountVolumeResponses, RegisterData, RegisterResponses, RestoreSnapshotData, RestoreSnapshotResponses, RunBackupNowData, RunBackupNowResponses, RunForgetData, RunForgetResponses, StopBackupData, StopBackupErrors, StopBackupResponses, TestConnectionData, TestConnectionResponses, UnmountVolumeData, UnmountVolumeResponses, UpdateBackupScheduleData, UpdateBackupScheduleResponses, UpdateVolumeData, UpdateVolumeErrors, UpdateVolumeResponses } from './types.gen'; export type Options = Options2 & { /** @@ -286,6 +286,16 @@ export const listSnapshots = (options: Opt }); }; +/** + * Delete a specific snapshot from a repository + */ +export const deleteSnapshot = (options: Options) => { + return (options.client ?? client).delete({ + url: '/api/v1/repositories/{name}/snapshots/{snapshotId}', + ...options + }); +}; + /** * Get details of a specific snapshot */ @@ -422,7 +432,7 @@ export const stopBackup = (options: Option * Manually apply retention policy to clean up old snapshots */ export const runForget = (options: Options) => { - return (options.client ?? client).post({ + return (options.client ?? client).post({ url: '/api/v1/backups/{scheduleId}/forget', ...options }); diff --git a/app/client/api-client/types.gen.ts b/app/client/api-client/types.gen.ts index db35bf8..5a3de70 100644 --- a/app/client/api-client/types.gen.ts +++ b/app/client/api-client/types.gen.ts @@ -711,30 +711,42 @@ export type ListRepositoriesResponses = { bucket: string; endpoint: string; secretAccessKey: string; + customPassword?: string; + isExistingRepository?: boolean; } | { accessKeyId: string; backend: 's3'; bucket: string; endpoint: string; secretAccessKey: string; + customPassword?: string; + isExistingRepository?: boolean; } | { accountKey: string; accountName: string; backend: 'azure'; container: string; + customPassword?: string; endpointSuffix?: string; + isExistingRepository?: boolean; } | { backend: 'gcs'; bucket: string; credentialsJson: string; projectId: string; + customPassword?: string; + isExistingRepository?: boolean; } | { backend: 'local'; name: string; + customPassword?: string; + isExistingRepository?: boolean; } | { backend: 'rclone'; path: string; remote: string; + customPassword?: string; + isExistingRepository?: boolean; }; createdAt: number; id: string; @@ -757,30 +769,42 @@ export type CreateRepositoryData = { bucket: string; endpoint: string; secretAccessKey: string; + customPassword?: string; + isExistingRepository?: boolean; } | { accessKeyId: string; backend: 's3'; bucket: string; endpoint: string; secretAccessKey: string; + customPassword?: string; + isExistingRepository?: boolean; } | { accountKey: string; accountName: string; backend: 'azure'; container: string; + customPassword?: string; endpointSuffix?: string; + isExistingRepository?: boolean; } | { backend: 'gcs'; bucket: string; credentialsJson: string; projectId: string; + customPassword?: string; + isExistingRepository?: boolean; } | { backend: 'local'; name: string; + customPassword?: string; + isExistingRepository?: boolean; } | { backend: 'rclone'; path: string; remote: string; + customPassword?: string; + isExistingRepository?: boolean; }; name: string; compressionMode?: 'auto' | 'better' | 'fastest' | 'max' | 'off'; @@ -865,30 +889,42 @@ export type GetRepositoryResponses = { bucket: string; endpoint: string; secretAccessKey: string; + customPassword?: string; + isExistingRepository?: boolean; } | { accessKeyId: string; backend: 's3'; bucket: string; endpoint: string; secretAccessKey: string; + customPassword?: string; + isExistingRepository?: boolean; } | { accountKey: string; accountName: string; backend: 'azure'; container: string; + customPassword?: string; endpointSuffix?: string; + isExistingRepository?: boolean; } | { backend: 'gcs'; bucket: string; credentialsJson: string; projectId: string; + customPassword?: string; + isExistingRepository?: boolean; } | { backend: 'local'; name: string; + customPassword?: string; + isExistingRepository?: boolean; } | { backend: 'rclone'; path: string; remote: string; + customPassword?: string; + isExistingRepository?: boolean; }; createdAt: number; id: string; @@ -929,6 +965,27 @@ export type ListSnapshotsResponses = { export type ListSnapshotsResponse = ListSnapshotsResponses[keyof ListSnapshotsResponses]; +export type DeleteSnapshotData = { + body?: never; + path: { + name: string; + snapshotId: string; + }; + query?: never; + url: '/api/v1/repositories/{name}/snapshots/{snapshotId}'; +}; + +export type DeleteSnapshotResponses = { + /** + * Snapshot deleted successfully + */ + 200: { + message: string; + }; +}; + +export type DeleteSnapshotResponse = DeleteSnapshotResponses[keyof DeleteSnapshotResponses]; + export type GetSnapshotDetailsData = { body?: never; path: { @@ -1079,30 +1136,42 @@ export type ListBackupSchedulesResponses = { bucket: string; endpoint: string; secretAccessKey: string; + customPassword?: string; + isExistingRepository?: boolean; } | { accessKeyId: string; backend: 's3'; bucket: string; endpoint: string; secretAccessKey: string; + customPassword?: string; + isExistingRepository?: boolean; } | { accountKey: string; accountName: string; backend: 'azure'; container: string; + customPassword?: string; endpointSuffix?: string; + isExistingRepository?: boolean; } | { backend: 'gcs'; bucket: string; credentialsJson: string; projectId: string; + customPassword?: string; + isExistingRepository?: boolean; } | { backend: 'local'; name: string; + customPassword?: string; + isExistingRepository?: boolean; } | { backend: 'rclone'; path: string; remote: string; + customPassword?: string; + isExistingRepository?: boolean; }; createdAt: number; id: string; @@ -1280,30 +1349,42 @@ export type GetBackupScheduleResponses = { bucket: string; endpoint: string; secretAccessKey: string; + customPassword?: string; + isExistingRepository?: boolean; } | { accessKeyId: string; backend: 's3'; bucket: string; endpoint: string; secretAccessKey: string; + customPassword?: string; + isExistingRepository?: boolean; } | { accountKey: string; accountName: string; backend: 'azure'; container: string; + customPassword?: string; endpointSuffix?: string; + isExistingRepository?: boolean; } | { backend: 'gcs'; bucket: string; credentialsJson: string; projectId: string; + customPassword?: string; + isExistingRepository?: boolean; } | { backend: 'local'; name: string; + customPassword?: string; + isExistingRepository?: boolean; } | { backend: 'rclone'; path: string; remote: string; + customPassword?: string; + isExistingRepository?: boolean; }; createdAt: number; id: string; @@ -1462,30 +1543,42 @@ export type GetBackupScheduleForVolumeResponses = { bucket: string; endpoint: string; secretAccessKey: string; + customPassword?: string; + isExistingRepository?: boolean; } | { accessKeyId: string; backend: 's3'; bucket: string; endpoint: string; secretAccessKey: string; + customPassword?: string; + isExistingRepository?: boolean; } | { accountKey: string; accountName: string; backend: 'azure'; container: string; + customPassword?: string; endpointSuffix?: string; + isExistingRepository?: boolean; } | { backend: 'gcs'; bucket: string; credentialsJson: string; projectId: string; + customPassword?: string; + isExistingRepository?: boolean; } | { backend: 'local'; name: string; + customPassword?: string; + isExistingRepository?: boolean; } | { backend: 'rclone'; path: string; remote: string; + customPassword?: string; + isExistingRepository?: boolean; }; createdAt: number; id: string; @@ -1611,13 +1704,6 @@ export type RunForgetData = { url: '/api/v1/backups/{scheduleId}/forget'; }; -export type RunForgetErrors = { - /** - * No retention policy configured for this schedule - */ - 400: unknown; -}; - export type RunForgetResponses = { /** * Retention policy applied successfully diff --git a/app/client/components/snapshots-table.tsx b/app/client/components/snapshots-table.tsx index e185290..c03059d 100644 --- a/app/client/components/snapshots-table.tsx +++ b/app/client/components/snapshots-table.tsx @@ -1,10 +1,26 @@ -import { Calendar, Clock, Database, FolderTree, HardDrive } from "lucide-react"; +import { useState } from "react"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { Calendar, Clock, Database, FolderTree, HardDrive, Trash2 } from "lucide-react"; import { 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 { Tooltip, TooltipContent, TooltipTrigger } from "~/client/components/ui/tooltip"; +import { Button } from "~/client/components/ui/button"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "~/client/components/ui/alert-dialog"; import { formatDuration } from "~/utils/utils"; import type { ListSnapshotsResponse } from "../api-client"; +import { deleteSnapshotMutation } from "~/client/api-client/@tanstack/react-query.gen"; +import { parseError } from "~/client/lib/errors"; type Snapshot = ListSnapshotsResponse[number]; @@ -15,81 +31,149 @@ type Props = { export const SnapshotsTable = ({ snapshots, repositoryName }: Props) => { const navigate = useNavigate(); + const queryClient = useQueryClient(); + const [showDeleteConfirm, setShowDeleteConfirm] = useState(false); + const [snapshotToDelete, setSnapshotToDelete] = useState(null); + + const deleteSnapshot = useMutation({ + ...deleteSnapshotMutation(), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["listSnapshots"] }); + setShowDeleteConfirm(false); + setSnapshotToDelete(null); + }, + }); + + const handleDeleteClick = (e: React.MouseEvent, snapshotId: string) => { + e.stopPropagation(); + setSnapshotToDelete(snapshotId); + setShowDeleteConfirm(true); + }; + + const handleConfirmDelete = () => { + if (snapshotToDelete) { + toast.promise( + deleteSnapshot.mutateAsync({ + path: { name: repositoryName, snapshotId: snapshotToDelete }, + }), + { + loading: "Deleting snapshot...", + success: "Snapshot deleted successfully", + error: (error) => parseError(error)?.message || "Failed to delete snapshot", + }, + ); + } + }; const handleRowClick = (snapshotId: string) => { navigate(`/repositories/${repositoryName}/${snapshotId}`); }; return ( -
- - - - Snapshot ID - Date & Time - Size - Duration - Paths - - - - {snapshots.map((snapshot) => ( - handleRowClick(snapshot.short_id)} - > - -
- - {snapshot.short_id} -
-
- -
- - {new Date(snapshot.time).toLocaleString()} -
-
- -
- - - - -
-
- -
- - {formatDuration(snapshot.duration / 1000)} -
-
- -
- - - - - {snapshot.paths.length} {snapshot.paths.length === 1 ? "path" : "paths"} - - - -
- {snapshot.paths.map((path) => ( -
- {path} -
- ))} -
-
-
-
-
+ <> +
+
+ + + Snapshot ID + Date & Time + Size + Duration + Paths + Actions - ))} - -
-
+ + + {snapshots.map((snapshot) => ( + handleRowClick(snapshot.short_id)} + > + +
+ + {snapshot.short_id} +
+
+ +
+ + {new Date(snapshot.time).toLocaleString()} +
+
+ +
+ + + + +
+
+ +
+ + {formatDuration(snapshot.duration / 1000)} +
+
+ +
+ + + + + {snapshot.paths.length} {snapshot.paths.length === 1 ? "path" : "paths"} + + + +
+ {snapshot.paths.map((path) => ( +
+ {path} +
+ ))} +
+
+
+
+
+ + + +
+ ))} +
+ + + + + + + Delete snapshot? + + This action cannot be undone. This will permanently delete the snapshot and all its data from the + repository. + + + + Cancel + + Delete snapshot + + + + + ); }; diff --git a/app/client/modules/backups/components/create-schedule-form.tsx b/app/client/modules/backups/components/create-schedule-form.tsx index d47d032..ee4d1d8 100644 --- a/app/client/modules/backups/components/create-schedule-form.tsx +++ b/app/client/modules/backups/components/create-schedule-form.tsx @@ -254,8 +254,8 @@ export const CreateScheduleForm = ({ initialValues, formId, onSubmit, volume }: Backup paths - Select which folders or files to include in the backup. If no paths are selected, the entire volume will be - backed up. + Select which folders or files to include in the backup. If no paths are selected, the entire volume will + be backed up. diff --git a/app/client/modules/backups/components/snapshot-file-browser.tsx b/app/client/modules/backups/components/snapshot-file-browser.tsx index 3ccca73..6c40f3f 100644 --- a/app/client/modules/backups/components/snapshot-file-browser.tsx +++ b/app/client/modules/backups/components/snapshot-file-browser.tsx @@ -26,10 +26,12 @@ interface Props { snapshot: Snapshot; repositoryName: string; volume?: Volume; + onDeleteSnapshot?: (snapshotId: string) => void; + isDeletingSnapshot?: boolean; } export const SnapshotFileBrowser = (props: Props) => { - const { snapshot, repositoryName, volume } = props; + const { snapshot, repositoryName, volume, onDeleteSnapshot, isDeletingSnapshot } = props; const isReadOnly = volume?.config && "readOnly" in volume.config && volume.config.readOnly === true; @@ -136,30 +138,43 @@ export const SnapshotFileBrowser = (props: Props) => { File Browser {`Viewing snapshot from ${new Date(snapshot?.time ?? 0).toLocaleString()}`} - {selectedPaths.size > 0 && ( - - - - - - - {isReadOnly && ( - -

Volume is mounted as read-only.

-

Please remount with read-only disabled to restore files.

-
- )} -
- )} +
+ {selectedPaths.size > 0 && ( + + + + + + + {isReadOnly && ( + +

Volume is mounted as read-only.

+

Please remount with read-only disabled to restore files.

+
+ )} +
+ )} + {onDeleteSnapshot && ( + + )} +
diff --git a/app/client/modules/backups/routes/backup-details.tsx b/app/client/modules/backups/routes/backup-details.tsx index a6311a3..7281faf 100644 --- a/app/client/modules/backups/routes/backup-details.tsx +++ b/app/client/modules/backups/routes/backup-details.tsx @@ -3,6 +3,16 @@ import { useQuery, useMutation } from "@tanstack/react-query"; import { redirect, useNavigate } from "react-router"; import { toast } from "sonner"; import { Button } from "~/client/components/ui/button"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "~/client/components/ui/alert-dialog"; import { getBackupScheduleOptions, runBackupNowMutation, @@ -10,6 +20,7 @@ import { listSnapshotsOptions, updateBackupScheduleMutation, stopBackupMutation, + deleteSnapshotMutation, } from "~/client/api-client/@tanstack/react-query.gen"; import { parseError } from "~/client/lib/errors"; import { getCronExpression } from "~/utils/utils"; @@ -50,6 +61,8 @@ export default function ScheduleDetailsPage({ params, loaderData }: Route.Compon const [isEditMode, setIsEditMode] = useState(false); const formId = useId(); const [selectedSnapshotId, setSelectedSnapshotId] = useState(); + const [showDeleteConfirm, setShowDeleteConfirm] = useState(false); + const [snapshotToDelete, setSnapshotToDelete] = useState(null); const { data: schedule } = useQuery({ ...getBackupScheduleOptions({ path: { scheduleId: params.id } }), @@ -110,6 +123,17 @@ export default function ScheduleDetailsPage({ params, loaderData }: Route.Compon }, }); + const deleteSnapshot = useMutation({ + ...deleteSnapshotMutation(), + onSuccess: () => { + setShowDeleteConfirm(false); + setSnapshotToDelete(null); + if (selectedSnapshotId === snapshotToDelete) { + setSelectedSnapshotId(undefined); + } + }, + }); + const handleSubmit = (formValues: BackupScheduleFormValues) => { if (!schedule) return; @@ -150,6 +174,26 @@ export default function ScheduleDetailsPage({ params, loaderData }: Route.Compon }); }; + const handleDeleteSnapshot = (snapshotId: string) => { + setSnapshotToDelete(snapshotId); + setShowDeleteConfirm(true); + }; + + const handleConfirmDelete = () => { + if (snapshotToDelete) { + toast.promise( + deleteSnapshot.mutateAsync({ + path: { name: schedule.repository.name, snapshotId: snapshotToDelete }, + }), + { + loading: "Deleting snapshot...", + success: "Snapshot deleted successfully", + error: (error) => parseError(error)?.message || "Failed to delete snapshot", + }, + ); + } + }; + if (isEditMode) { return (
@@ -191,8 +235,32 @@ export default function ScheduleDetailsPage({ params, loaderData }: Route.Compon snapshot={selectedSnapshot} repositoryName={schedule.repository.name} volume={schedule.volume} + onDeleteSnapshot={handleDeleteSnapshot} + isDeletingSnapshot={deleteSnapshot.isPending} /> )} + + + + + Delete snapshot? + + This action cannot be undone. This will permanently delete the snapshot and all its data from the + repository. + + + + Cancel + + Delete snapshot + + + +
); } diff --git a/app/server/modules/repositories/repositories.controller.ts b/app/server/modules/repositories/repositories.controller.ts index b6a67a4..2cda998 100644 --- a/app/server/modules/repositories/repositories.controller.ts +++ b/app/server/modules/repositories/repositories.controller.ts @@ -4,6 +4,7 @@ import { createRepositoryBody, createRepositoryDto, deleteRepositoryDto, + deleteSnapshotDto, doctorRepositoryDto, getRepositoryDto, getSnapshotDetailsDto, @@ -16,6 +17,7 @@ import { restoreSnapshotBody, restoreSnapshotDto, type DeleteRepositoryDto, + type DeleteSnapshotDto, type DoctorRepositoryDto, type GetRepositoryDto, type GetSnapshotDetailsDto, @@ -142,4 +144,11 @@ export const repositoriesController = new Hono() const result = await repositoriesService.doctorRepository(name); return c.json(result, 200); + }) + .delete("/:name/snapshots/:snapshotId", deleteSnapshotDto, async (c) => { + const { name, snapshotId } = c.req.param(); + + await repositoriesService.deleteSnapshot(name, snapshotId); + + return c.json({ message: "Snapshot deleted" }, 200); }); diff --git a/app/server/modules/repositories/repositories.dto.ts b/app/server/modules/repositories/repositories.dto.ts index da4d35f..88f5262 100644 --- a/app/server/modules/repositories/repositories.dto.ts +++ b/app/server/modules/repositories/repositories.dto.ts @@ -326,3 +326,28 @@ export const listRcloneRemotesDto = describeRoute({ }, }, }); + +/** + * Delete a snapshot + */ +export const deleteSnapshotResponse = type({ + message: "string", +}); + +export type DeleteSnapshotDto = typeof deleteSnapshotResponse.infer; + +export const deleteSnapshotDto = describeRoute({ + description: "Delete a specific snapshot from a repository", + tags: ["Repositories"], + operationId: "deleteSnapshot", + responses: { + 200: { + description: "Snapshot deleted successfully", + content: { + "application/json": { + schema: resolver(deleteSnapshotResponse), + }, + }, + }, + }, +}); diff --git a/app/server/modules/repositories/repositories.service.ts b/app/server/modules/repositories/repositories.service.ts index 80bb8e8..b95380b 100644 --- a/app/server/modules/repositories/repositories.service.ts +++ b/app/server/modules/repositories/repositories.service.ts @@ -327,6 +327,18 @@ const doctorRepository = async (name: string) => { }; }; +const deleteSnapshot = async (name: string, snapshotId: string) => { + const repository = await db.query.repositoriesTable.findFirst({ + where: eq(repositoriesTable.name, name), + }); + + if (!repository) { + throw new NotFoundError("Repository not found"); + } + + await restic.deleteSnapshot(repository.config, snapshotId); +}; + export const repositoriesService = { listRepositories, createRepository, @@ -338,4 +350,5 @@ export const repositoriesService = { getSnapshotDetails, checkHealth, doctorRepository, + deleteSnapshot, }; diff --git a/app/server/utils/restic.ts b/app/server/utils/restic.ts index a85757a..eaadec3 100644 --- a/app/server/utils/restic.ts +++ b/app/server/utils/restic.ts @@ -441,6 +441,22 @@ const forget = async (config: RepositoryConfig, options: RetentionPolicy, extra: return { success: true }; }; +const deleteSnapshot = async (config: RepositoryConfig, snapshotId: string) => { + const repoUrl = buildRepoUrl(config); + const env = await buildEnv(config); + + const args: string[] = ["--repo", repoUrl, "forget", snapshotId, "--prune"]; + + const res = await $`restic ${args}`.env(env).nothrow(); + + if (res.exitCode !== 0) { + logger.error(`Restic snapshot deletion failed: ${res.stderr}`); + throw new Error(`Failed to delete snapshot: ${res.stderr}`); + } + + return { success: true }; +}; + const lsNodeSchema = type({ name: "string", type: "string", @@ -601,6 +617,7 @@ export const restic = { restore, snapshots, forget, + deleteSnapshot, unlock, ls, check,