From 2c786dfb351fa34fd3e91b3b54a71da00dc5b257 Mon Sep 17 00:00:00 2001 From: Nico <47644445+nicotsx@users.noreply.github.com> Date: Fri, 2 Jan 2026 17:04:06 +0100 Subject: [PATCH] feat: snapshots bulk actions (#256) * feat: delete snapshots in bulk * feat: re-tag snapshots in bulk * chore: pr feedbacks --- .../api-client/@tanstack/react-query.gen.ts | 38 ++- app/client/api-client/sdk.gen.ts | 26 +- app/client/api-client/types.gen.ts | 47 ++++ app/client/components/snapshots-table.tsx | 244 +++++++++++++----- .../__tests__/repositories.controller.test.ts | 1 + .../repositories/repositories.controller.ts | 22 ++ .../modules/repositories/repositories.dto.ts | 61 +++++ .../repositories/repositories.service.ts | 36 +++ app/server/utils/restic.ts | 59 ++++- 9 files changed, 464 insertions(+), 70 deletions(-) diff --git a/app/client/api-client/@tanstack/react-query.gen.ts b/app/client/api-client/@tanstack/react-query.gen.ts index cb1ffead..f0e1b7d8 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, createNotificationDestination, createRepository, createVolume, deleteBackupSchedule, deleteNotificationDestination, deleteRepository, deleteSnapshot, deleteVolume, doctorRepository, downloadResticPassword, getBackupSchedule, getBackupScheduleForVolume, getMe, getMirrorCompatibility, getNotificationDestination, getRepository, getScheduleMirrors, getScheduleNotifications, getSnapshotDetails, getStatus, getSystemInfo, getVolume, healthCheckVolume, listBackupSchedules, listFiles, listNotificationDestinations, listRcloneRemotes, listRepositories, listSnapshotFiles, listSnapshots, listVolumes, login, logout, mountVolume, type Options, register, reorderBackupSchedules, restoreSnapshot, runBackupNow, runForget, stopBackup, testConnection, testNotificationDestination, unmountVolume, updateBackupSchedule, updateNotificationDestination, updateRepository, updateScheduleMirrors, updateScheduleNotifications, updateVolume } from '../sdk.gen'; -import type { BrowseFilesystemData, BrowseFilesystemResponse, ChangePasswordData, ChangePasswordResponse, CreateBackupScheduleData, CreateBackupScheduleResponse, CreateNotificationDestinationData, CreateNotificationDestinationResponse, CreateRepositoryData, CreateRepositoryResponse, CreateVolumeData, CreateVolumeResponse, DeleteBackupScheduleData, DeleteBackupScheduleResponse, DeleteNotificationDestinationData, DeleteNotificationDestinationResponse, DeleteRepositoryData, DeleteRepositoryResponse, DeleteSnapshotData, DeleteSnapshotResponse, DeleteVolumeData, DeleteVolumeResponse, DoctorRepositoryData, DoctorRepositoryResponse, DownloadResticPasswordData, DownloadResticPasswordResponse, GetBackupScheduleData, GetBackupScheduleForVolumeData, GetBackupScheduleForVolumeResponse, GetBackupScheduleResponse, GetMeData, GetMeResponse, GetMirrorCompatibilityData, GetMirrorCompatibilityResponse, GetNotificationDestinationData, GetNotificationDestinationResponse, GetRepositoryData, GetRepositoryResponse, GetScheduleMirrorsData, GetScheduleMirrorsResponse, GetScheduleNotificationsData, GetScheduleNotificationsResponse, GetSnapshotDetailsData, GetSnapshotDetailsResponse, GetStatusData, GetStatusResponse, GetSystemInfoData, GetSystemInfoResponse, GetVolumeData, GetVolumeResponse, HealthCheckVolumeData, HealthCheckVolumeResponse, ListBackupSchedulesData, ListBackupSchedulesResponse, ListFilesData, ListFilesResponse, ListNotificationDestinationsData, ListNotificationDestinationsResponse, ListRcloneRemotesData, ListRcloneRemotesResponse, ListRepositoriesData, ListRepositoriesResponse, ListSnapshotFilesData, ListSnapshotFilesResponse, ListSnapshotsData, ListSnapshotsResponse, ListVolumesData, ListVolumesResponse, LoginData, LoginResponse, LogoutData, LogoutResponse, MountVolumeData, MountVolumeResponse, RegisterData, RegisterResponse, ReorderBackupSchedulesData, ReorderBackupSchedulesResponse, RestoreSnapshotData, RestoreSnapshotResponse, RunBackupNowData, RunBackupNowResponse, RunForgetData, RunForgetResponse, StopBackupData, StopBackupResponse, TestConnectionData, TestConnectionResponse, TestNotificationDestinationData, TestNotificationDestinationResponse, UnmountVolumeData, UnmountVolumeResponse, UpdateBackupScheduleData, UpdateBackupScheduleResponse, UpdateNotificationDestinationData, UpdateNotificationDestinationResponse, UpdateRepositoryData, UpdateRepositoryResponse, UpdateScheduleMirrorsData, UpdateScheduleMirrorsResponse, UpdateScheduleNotificationsData, UpdateScheduleNotificationsResponse, UpdateVolumeData, UpdateVolumeResponse } from '../types.gen'; +import { browseFilesystem, changePassword, createBackupSchedule, createNotificationDestination, createRepository, createVolume, deleteBackupSchedule, deleteNotificationDestination, deleteRepository, deleteSnapshot, deleteSnapshots, deleteVolume, doctorRepository, downloadResticPassword, getBackupSchedule, getBackupScheduleForVolume, getMe, getMirrorCompatibility, getNotificationDestination, getRepository, getScheduleMirrors, getScheduleNotifications, getSnapshotDetails, getStatus, getSystemInfo, getVolume, healthCheckVolume, listBackupSchedules, listFiles, listNotificationDestinations, listRcloneRemotes, listRepositories, listSnapshotFiles, listSnapshots, listVolumes, login, logout, mountVolume, type Options, register, reorderBackupSchedules, restoreSnapshot, runBackupNow, runForget, stopBackup, tagSnapshots, testConnection, testNotificationDestination, unmountVolume, updateBackupSchedule, updateNotificationDestination, updateRepository, updateScheduleMirrors, updateScheduleNotifications, updateVolume } from '../sdk.gen'; +import type { BrowseFilesystemData, BrowseFilesystemResponse, ChangePasswordData, ChangePasswordResponse, CreateBackupScheduleData, CreateBackupScheduleResponse, CreateNotificationDestinationData, CreateNotificationDestinationResponse, CreateRepositoryData, CreateRepositoryResponse, CreateVolumeData, CreateVolumeResponse, DeleteBackupScheduleData, DeleteBackupScheduleResponse, DeleteNotificationDestinationData, DeleteNotificationDestinationResponse, DeleteRepositoryData, DeleteRepositoryResponse, DeleteSnapshotData, DeleteSnapshotResponse, DeleteSnapshotsData, DeleteSnapshotsResponse, DeleteVolumeData, DeleteVolumeResponse, DoctorRepositoryData, DoctorRepositoryResponse, DownloadResticPasswordData, DownloadResticPasswordResponse, GetBackupScheduleData, GetBackupScheduleForVolumeData, GetBackupScheduleForVolumeResponse, GetBackupScheduleResponse, GetMeData, GetMeResponse, GetMirrorCompatibilityData, GetMirrorCompatibilityResponse, GetNotificationDestinationData, GetNotificationDestinationResponse, GetRepositoryData, GetRepositoryResponse, GetScheduleMirrorsData, GetScheduleMirrorsResponse, GetScheduleNotificationsData, GetScheduleNotificationsResponse, GetSnapshotDetailsData, GetSnapshotDetailsResponse, GetStatusData, GetStatusResponse, GetSystemInfoData, GetSystemInfoResponse, GetVolumeData, GetVolumeResponse, HealthCheckVolumeData, HealthCheckVolumeResponse, ListBackupSchedulesData, ListBackupSchedulesResponse, ListFilesData, ListFilesResponse, ListNotificationDestinationsData, ListNotificationDestinationsResponse, ListRcloneRemotesData, ListRcloneRemotesResponse, ListRepositoriesData, ListRepositoriesResponse, ListSnapshotFilesData, ListSnapshotFilesResponse, ListSnapshotsData, ListSnapshotsResponse, ListVolumesData, ListVolumesResponse, LoginData, LoginResponse, LogoutData, LogoutResponse, MountVolumeData, MountVolumeResponse, RegisterData, RegisterResponse, ReorderBackupSchedulesData, ReorderBackupSchedulesResponse, RestoreSnapshotData, RestoreSnapshotResponse, RunBackupNowData, RunBackupNowResponse, RunForgetData, RunForgetResponse, StopBackupData, StopBackupResponse, TagSnapshotsData, TagSnapshotsResponse, TestConnectionData, TestConnectionResponse, TestNotificationDestinationData, TestNotificationDestinationResponse, UnmountVolumeData, UnmountVolumeResponse, UpdateBackupScheduleData, UpdateBackupScheduleResponse, UpdateNotificationDestinationData, UpdateNotificationDestinationResponse, UpdateRepositoryData, UpdateRepositoryResponse, UpdateScheduleMirrorsData, UpdateScheduleMirrorsResponse, UpdateScheduleNotificationsData, UpdateScheduleNotificationsResponse, UpdateVolumeData, UpdateVolumeResponse } from '../types.gen'; /** * Register a new user @@ -439,6 +439,23 @@ export const updateRepositoryMutation = (options?: Partial>): UseMutationOptions> => { + const mutationOptions: UseMutationOptions> = { + mutationFn: async (fnOptions) => { + const { data } = await deleteSnapshots({ + ...options, + ...fnOptions, + throwOnError: true + }); + return data; + } + }; + return mutationOptions; +}; + export const listSnapshotsQueryKey = (options: Options) => createQueryKey('listSnapshots', options); /** @@ -544,6 +561,23 @@ export const doctorRepositoryMutation = (options?: Partial>): UseMutationOptions> => { + const mutationOptions: UseMutationOptions> = { + mutationFn: async (fnOptions) => { + const { data } = await tagSnapshots({ + ...options, + ...fnOptions, + throwOnError: true + }); + return data; + } + }; + return mutationOptions; +}; + export const listBackupSchedulesQueryKey = (options?: Options) => createQueryKey('listBackupSchedules', options); /** diff --git a/app/client/api-client/sdk.gen.ts b/app/client/api-client/sdk.gen.ts index 6c1978ce..a0e13c5b 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, CreateNotificationDestinationData, CreateNotificationDestinationResponses, CreateRepositoryData, CreateRepositoryResponses, CreateVolumeData, CreateVolumeResponses, DeleteBackupScheduleData, DeleteBackupScheduleResponses, DeleteNotificationDestinationData, DeleteNotificationDestinationErrors, DeleteNotificationDestinationResponses, DeleteRepositoryData, DeleteRepositoryResponses, DeleteSnapshotData, DeleteSnapshotResponses, DeleteVolumeData, DeleteVolumeResponses, DoctorRepositoryData, DoctorRepositoryResponses, DownloadResticPasswordData, DownloadResticPasswordResponses, GetBackupScheduleData, GetBackupScheduleForVolumeData, GetBackupScheduleForVolumeResponses, GetBackupScheduleResponses, GetMeData, GetMeResponses, GetMirrorCompatibilityData, GetMirrorCompatibilityResponses, GetNotificationDestinationData, GetNotificationDestinationErrors, GetNotificationDestinationResponses, GetRepositoryData, GetRepositoryResponses, GetScheduleMirrorsData, GetScheduleMirrorsResponses, GetScheduleNotificationsData, GetScheduleNotificationsResponses, GetSnapshotDetailsData, GetSnapshotDetailsResponses, GetStatusData, GetStatusResponses, GetSystemInfoData, GetSystemInfoResponses, GetVolumeData, GetVolumeErrors, GetVolumeResponses, HealthCheckVolumeData, HealthCheckVolumeErrors, HealthCheckVolumeResponses, ListBackupSchedulesData, ListBackupSchedulesResponses, ListFilesData, ListFilesResponses, ListNotificationDestinationsData, ListNotificationDestinationsResponses, ListRcloneRemotesData, ListRcloneRemotesResponses, ListRepositoriesData, ListRepositoriesResponses, ListSnapshotFilesData, ListSnapshotFilesResponses, ListSnapshotsData, ListSnapshotsResponses, ListVolumesData, ListVolumesResponses, LoginData, LoginResponses, LogoutData, LogoutResponses, MountVolumeData, MountVolumeResponses, RegisterData, RegisterResponses, ReorderBackupSchedulesData, ReorderBackupSchedulesResponses, RestoreSnapshotData, RestoreSnapshotResponses, RunBackupNowData, RunBackupNowResponses, RunForgetData, RunForgetResponses, StopBackupData, StopBackupErrors, StopBackupResponses, TestConnectionData, TestConnectionResponses, TestNotificationDestinationData, TestNotificationDestinationErrors, TestNotificationDestinationResponses, UnmountVolumeData, UnmountVolumeResponses, UpdateBackupScheduleData, UpdateBackupScheduleResponses, UpdateNotificationDestinationData, UpdateNotificationDestinationErrors, UpdateNotificationDestinationResponses, UpdateRepositoryData, UpdateRepositoryErrors, UpdateRepositoryResponses, UpdateScheduleMirrorsData, UpdateScheduleMirrorsResponses, UpdateScheduleNotificationsData, UpdateScheduleNotificationsResponses, UpdateVolumeData, UpdateVolumeErrors, UpdateVolumeResponses } from './types.gen'; +import type { BrowseFilesystemData, BrowseFilesystemResponses, ChangePasswordData, ChangePasswordResponses, CreateBackupScheduleData, CreateBackupScheduleResponses, CreateNotificationDestinationData, CreateNotificationDestinationResponses, CreateRepositoryData, CreateRepositoryResponses, CreateVolumeData, CreateVolumeResponses, DeleteBackupScheduleData, DeleteBackupScheduleResponses, DeleteNotificationDestinationData, DeleteNotificationDestinationErrors, DeleteNotificationDestinationResponses, DeleteRepositoryData, DeleteRepositoryResponses, DeleteSnapshotData, DeleteSnapshotResponses, DeleteSnapshotsData, DeleteSnapshotsResponses, DeleteVolumeData, DeleteVolumeResponses, DoctorRepositoryData, DoctorRepositoryResponses, DownloadResticPasswordData, DownloadResticPasswordResponses, GetBackupScheduleData, GetBackupScheduleForVolumeData, GetBackupScheduleForVolumeResponses, GetBackupScheduleResponses, GetMeData, GetMeResponses, GetMirrorCompatibilityData, GetMirrorCompatibilityResponses, GetNotificationDestinationData, GetNotificationDestinationErrors, GetNotificationDestinationResponses, GetRepositoryData, GetRepositoryResponses, GetScheduleMirrorsData, GetScheduleMirrorsResponses, GetScheduleNotificationsData, GetScheduleNotificationsResponses, GetSnapshotDetailsData, GetSnapshotDetailsResponses, GetStatusData, GetStatusResponses, GetSystemInfoData, GetSystemInfoResponses, GetVolumeData, GetVolumeErrors, GetVolumeResponses, HealthCheckVolumeData, HealthCheckVolumeErrors, HealthCheckVolumeResponses, ListBackupSchedulesData, ListBackupSchedulesResponses, ListFilesData, ListFilesResponses, ListNotificationDestinationsData, ListNotificationDestinationsResponses, ListRcloneRemotesData, ListRcloneRemotesResponses, ListRepositoriesData, ListRepositoriesResponses, ListSnapshotFilesData, ListSnapshotFilesResponses, ListSnapshotsData, ListSnapshotsResponses, ListVolumesData, ListVolumesResponses, LoginData, LoginResponses, LogoutData, LogoutResponses, MountVolumeData, MountVolumeResponses, RegisterData, RegisterResponses, ReorderBackupSchedulesData, ReorderBackupSchedulesResponses, RestoreSnapshotData, RestoreSnapshotResponses, RunBackupNowData, RunBackupNowResponses, RunForgetData, RunForgetResponses, StopBackupData, StopBackupErrors, StopBackupResponses, TagSnapshotsData, TagSnapshotsResponses, TestConnectionData, TestConnectionResponses, TestNotificationDestinationData, TestNotificationDestinationErrors, TestNotificationDestinationResponses, UnmountVolumeData, UnmountVolumeResponses, UpdateBackupScheduleData, UpdateBackupScheduleResponses, UpdateNotificationDestinationData, UpdateNotificationDestinationErrors, UpdateNotificationDestinationResponses, UpdateRepositoryData, UpdateRepositoryErrors, UpdateRepositoryResponses, UpdateScheduleMirrorsData, UpdateScheduleMirrorsResponses, UpdateScheduleNotificationsData, UpdateScheduleNotificationsResponses, UpdateVolumeData, UpdateVolumeErrors, UpdateVolumeResponses } from './types.gen'; export type Options = Options2 & { /** @@ -189,6 +189,18 @@ export const updateRepository = (options: } }); +/** + * Delete multiple snapshots from a repository + */ +export const deleteSnapshots = (options: Options) => (options.client ?? client).delete({ + url: '/api/v1/repositories/{id}/snapshots', + ...options, + headers: { + 'Content-Type': 'application/json', + ...options.headers + } +}); + /** * List all snapshots in a repository */ @@ -226,6 +238,18 @@ export const restoreSnapshot = (options: O */ export const doctorRepository = (options: Options) => (options.client ?? client).post({ url: '/api/v1/repositories/{id}/doctor', ...options }); +/** + * Tag multiple snapshots in a repository + */ +export const tagSnapshots = (options: Options) => (options.client ?? client).post({ + url: '/api/v1/repositories/{id}/snapshots/tag', + ...options, + headers: { + 'Content-Type': 'application/json', + ...options.headers + } +}); + /** * List all backup schedules */ diff --git a/app/client/api-client/types.gen.ts b/app/client/api-client/types.gen.ts index 3abd1800..9146e070 100644 --- a/app/client/api-client/types.gen.ts +++ b/app/client/api-client/types.gen.ts @@ -1195,6 +1195,28 @@ export type UpdateRepositoryResponses = { export type UpdateRepositoryResponse = UpdateRepositoryResponses[keyof UpdateRepositoryResponses]; +export type DeleteSnapshotsData = { + body?: { + snapshotIds: Array; + }; + path: { + id: string; + }; + query?: never; + url: '/api/v1/repositories/{id}/snapshots'; +}; + +export type DeleteSnapshotsResponses = { + /** + * Snapshots deleted successfully + */ + 200: { + message: string; + }; +}; + +export type DeleteSnapshotsResponse = DeleteSnapshotsResponses[keyof DeleteSnapshotsResponses]; + export type ListSnapshotsData = { body?: never; path: { @@ -1367,6 +1389,31 @@ export type DoctorRepositoryResponses = { export type DoctorRepositoryResponse = DoctorRepositoryResponses[keyof DoctorRepositoryResponses]; +export type TagSnapshotsData = { + body?: { + snapshotIds: Array; + add?: Array; + remove?: Array; + set?: Array; + }; + path: { + id: string; + }; + query?: never; + url: '/api/v1/repositories/{id}/snapshots/tag'; +}; + +export type TagSnapshotsResponses = { + /** + * Snapshots tagged successfully + */ + 200: { + message: string; + }; +}; + +export type TagSnapshotsResponse = TagSnapshotsResponses[keyof TagSnapshotsResponses]; + export type ListBackupSchedulesData = { body?: never; path?: never; diff --git a/app/client/components/snapshots-table.tsx b/app/client/components/snapshots-table.tsx index dfee2792..0f8574bb 100644 --- a/app/client/components/snapshots-table.tsx +++ b/app/client/components/snapshots-table.tsx @@ -1,11 +1,12 @@ import { useState } from "react"; import { useMutation, useQueryClient } from "@tanstack/react-query"; -import { Calendar, Clock, Database, HardDrive, Server, Trash2 } from "lucide-react"; +import { Calendar, Clock, Database, HardDrive, Tag, Trash2, X } from "lucide-react"; import { Link, useNavigate } from "react-router"; import { toast } from "sonner"; import { ByteSize } from "~/client/components/bytes-size"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "~/client/components/ui/table"; import { Button } from "~/client/components/ui/button"; +import { Checkbox } from "~/client/components/ui/checkbox"; import { AlertDialog, AlertDialogAction, @@ -16,8 +17,17 @@ import { AlertDialogHeader, AlertDialogTitle, } from "~/client/components/ui/alert-dialog"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "~/client/components/ui/dialog"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "~/client/components/ui/select"; import { formatDuration } from "~/utils/utils"; -import { deleteSnapshotMutation } from "~/client/api-client/@tanstack/react-query.gen"; +import { deleteSnapshotsMutation, tagSnapshotsMutation } from "~/client/api-client/@tanstack/react-query.gen"; import { parseError } from "~/client/lib/errors"; import type { BackupSchedule, Snapshot } from "../lib/types"; import { cn } from "../lib/utils"; @@ -31,68 +41,126 @@ type Props = { export const SnapshotsTable = ({ snapshots, repositoryId, backups }: Props) => { const navigate = useNavigate(); const queryClient = useQueryClient(); - const [showDeleteConfirm, setShowDeleteConfirm] = useState(false); - const [snapshotToDelete, setSnapshotToDelete] = useState(null); - const deleteSnapshot = useMutation({ - ...deleteSnapshotMutation(), + const [selectedIds, setSelectedIds] = useState>(new Set()); + const [showBulkDeleteConfirm, setShowBulkDeleteConfirm] = useState(false); + const [showReTagDialog, setShowReTagDialog] = useState(false); + const [targetScheduleId, setTargetScheduleId] = useState(""); + + const deleteSnapshots = useMutation({ + ...deleteSnapshotsMutation(), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ["listSnapshots"] }); - setShowDeleteConfirm(false); - setSnapshotToDelete(null); + setShowBulkDeleteConfirm(false); + setSelectedIds(new Set()); }, }); - const handleDeleteClick = (e: React.MouseEvent, snapshotId: string) => { - e.stopPropagation(); - setSnapshotToDelete(snapshotId); - setShowDeleteConfirm(true); - }; - - const handleConfirmDelete = () => { - if (snapshotToDelete) { - toast.promise( - deleteSnapshot.mutateAsync({ - path: { id: repositoryId, snapshotId: snapshotToDelete }, - }), - { - loading: "Deleting snapshot...", - success: "Snapshot deleted successfully", - error: (error) => parseError(error)?.message || "Failed to delete snapshot", - }, - ); - } - }; + const tagSnapshots = useMutation({ + ...tagSnapshotsMutation(), + onMutate: () => { + setShowReTagDialog(false); + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["listSnapshots"] }); + setShowReTagDialog(false); + setSelectedIds(new Set()); + setTargetScheduleId(""); + }, + }); const handleRowClick = (snapshotId: string) => { navigate(`/repositories/${repositoryId}/${snapshotId}`); }; + const toggleSelectAll = () => { + if (selectedIds.size === snapshots.length) { + setSelectedIds(new Set()); + } else { + setSelectedIds(new Set(snapshots.map((s) => s.short_id))); + } + }; + + const handleBulkDelete = () => { + toast.promise( + deleteSnapshots.mutateAsync({ + path: { id: repositoryId }, + body: { snapshotIds: Array.from(selectedIds) }, + }), + { + loading: `Deleting ${selectedIds.size} snapshots...`, + success: "Snapshots deleted successfully", + error: (error) => parseError(error)?.message || "Failed to delete snapshots", + }, + ); + }; + + const handleBulkReTag = () => { + const schedule = backups.find((b) => String(b.id) === targetScheduleId); + if (!schedule) return; + + toast.promise( + tagSnapshots.mutateAsync({ + path: { id: repositoryId }, + body: { + snapshotIds: Array.from(selectedIds), + set: [schedule.shortId], + }, + }), + { + loading: `Re-tagging ${selectedIds.size} snapshots...`, + success: `Snapshots re-tagged to ${schedule.name}`, + error: (error) => parseError(error)?.message || "Failed to re-tag snapshots", + }, + ); + }; + return ( <> -
+
+ + 0} + onCheckedChange={toggleSelectAll} + aria-label="Select all" + /> + Snapshot ID Schedule Date & Time Size Duration - Volume - Actions {snapshots.map((snapshot) => { const backup = backups.find((b) => snapshot.tags.includes(b.shortId)); + const isSelected = selectedIds.has(snapshot.short_id); return ( handleRowClick(snapshot.short_id)} > + e.stopPropagation()}> + { + const newSelected = new Set(selectedIds); + if (newSelected.has(snapshot.short_id)) { + newSelected.delete(snapshot.short_id); + } else { + newSelected.add(snapshot.short_id); + } + setSelectedIds(newSelected); + }} + aria-label={`Select snapshot ${snapshot.short_id}`} + /> +
@@ -134,32 +202,6 @@ export const SnapshotsTable = ({ snapshots, repositoryId, backups }: Props) => { {formatDuration(snapshot.duration / 1000)}
- -
- - e.stopPropagation()} - className="hover:underline" - > - {backup ? backup.volume.name : "-"} - - -
-
- - -
); })} @@ -167,27 +209,99 @@ export const SnapshotsTable = ({ snapshots, repositoryId, backups }: Props) => {
- + {selectedIds.size > 0 && ( +
+
+
+ + {selectedIds.size} selected +
+
+ + +
+
+
+ )} + + - Delete snapshot? + Delete {selectedIds.size} snapshots? - This action cannot be undone. This will permanently delete the snapshot and all its data from the - repository. + This action cannot be undone. This will permanently delete the selected snapshots and all their data from + the repository. Cancel - Delete snapshot + Delete {selectedIds.size} snapshots + + + + + Re-tag snapshots + + Select a backup schedule to re-tag the {selectedIds.size} selected snapshots. All {selectedIds.size}{" "} + selected snapshots will be associated with the chosen schedule. + + +
+ +
+ + + + +
+
); }; diff --git a/app/server/modules/repositories/__tests__/repositories.controller.test.ts b/app/server/modules/repositories/__tests__/repositories.controller.test.ts index d7afc68d..3f375df4 100644 --- a/app/server/modules/repositories/__tests__/repositories.controller.test.ts +++ b/app/server/modules/repositories/__tests__/repositories.controller.test.ts @@ -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" }, ]; diff --git a/app/server/modules/repositories/repositories.controller.ts b/app/server/modules/repositories/repositories.controller.ts index 142f6eeb..b36d8c25 100644 --- a/app/server/modules/repositories/repositories.controller.ts +++ b/app/server/modules/repositories/repositories.controller.ts @@ -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({ 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({ 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({ message: "Snapshots tagged" }, 200); + }) .patch("/:id", updateRepositoryDto, validator("json", updateRepositoryBody), async (c) => { const { id } = c.req.param(); const body = c.req.valid("json"); diff --git a/app/server/modules/repositories/repositories.dto.ts b/app/server/modules/repositories/repositories.dto.ts index dc28777b..56a215e3 100644 --- a/app/server/modules/repositories/repositories.dto.ts +++ b/app/server/modules/repositories/repositories.dto.ts @@ -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), + }, + }, + }, + }, +}); diff --git a/app/server/modules/repositories/repositories.service.ts b/app/server/modules/repositories/repositories.service.ts index 23f16a08..cdd8bb1d 100644 --- a/app/server/modules/repositories/repositories.service.ts +++ b/app/server/modules/repositories/repositories.service.ts @@ -389,6 +389,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 +473,6 @@ export const repositoriesService = { checkHealth, doctorRepository, deleteSnapshot, + deleteSnapshots, + tagSnapshots, }; diff --git a/app/server/utils/restic.ts b/app/server/utils/restic.ts index 8f696f35..a4880431 100644 --- a/app/server/utils/restic.ts +++ b/app/server/utils/restic.ts @@ -567,11 +567,15 @@ 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 }); @@ -585,6 +589,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(config, 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", @@ -841,6 +894,8 @@ export const restic = { snapshots, forget, deleteSnapshot, + deleteSnapshots, + tagSnapshots, unlock, ls, check,