From a635c98366bd38470558f7973a5d806aaebce03e Mon Sep 17 00:00:00 2001 From: Nicolas Meienberger Date: Sun, 28 Dec 2025 19:59:34 +0100 Subject: [PATCH] feat: delete snapshots in bulk --- .../api-client/@tanstack/react-query.gen.ts | 21 +- app/client/api-client/sdk.gen.ts | 14 +- app/client/api-client/types.gen.ts | 22 +++ app/client/components/snapshots-table.tsx | 179 +++++++++++++++++- .../__tests__/repositories.controller.test.ts | 1 + .../repositories/repositories.controller.ts | 11 ++ .../modules/repositories/repositories.dto.ts | 29 +++ .../repositories/repositories.service.ts | 16 ++ app/server/utils/restic.ts | 9 +- 9 files changed, 293 insertions(+), 9 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..454ba2ee 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, 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, 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); /** diff --git a/app/client/api-client/sdk.gen.ts b/app/client/api-client/sdk.gen.ts index 6c1978ce..b4f643fc 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, 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 */ diff --git a/app/client/api-client/types.gen.ts b/app/client/api-client/types.gen.ts index 3abd1800..ac275b15 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: { diff --git a/app/client/components/snapshots-table.tsx b/app/client/components/snapshots-table.tsx index dfee2792..cc13f4c7 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, Server, 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 { deleteSnapshotMutation, deleteSnapshotsMutation } 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"; @@ -34,6 +44,11 @@ export const SnapshotsTable = ({ snapshots, repositoryId, backups }: Props) => { const [showDeleteConfirm, setShowDeleteConfirm] = useState(false); const [snapshotToDelete, setSnapshotToDelete] = useState(null); + const [selectedIds, setSelectedIds] = useState>(new Set()); + const [showBulkDeleteConfirm, setShowBulkDeleteConfirm] = useState(false); + const [showReTagDialog, setShowReTagDialog] = useState(false); + const [targetScheduleId, setTargetScheduleId] = useState(""); + const deleteSnapshot = useMutation({ ...deleteSnapshotMutation(), onSuccess: () => { @@ -43,6 +58,15 @@ export const SnapshotsTable = ({ snapshots, repositoryId, backups }: Props) => { }, }); + const deleteSnapshots = useMutation({ + ...deleteSnapshotsMutation(), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["listSnapshots"] }); + setShowBulkDeleteConfirm(false); + setSelectedIds(new Set()); + }, + }); + const handleDeleteClick = (e: React.MouseEvent, snapshotId: string) => { e.stopPropagation(); setSnapshotToDelete(snapshotId); @@ -68,12 +92,49 @@ export const SnapshotsTable = ({ snapshots, repositoryId, backups }: Props) => { 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); + toast.success(`Re-tagged ${selectedIds.size} snapshots to ${schedule?.name} (Backend not implemented)`); + setShowReTagDialog(false); + setSelectedIds(new Set()); + setTargetScheduleId(""); + }; + return ( <> -
+
+ + 0} + onCheckedChange={toggleSelectAll} + aria-label="Select all" + /> + Snapshot ID Schedule Date & Time @@ -86,13 +147,29 @@ export const SnapshotsTable = ({ snapshots, repositoryId, backups }: Props) => { {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}`} + /> +
@@ -188,6 +265,100 @@ export const SnapshotsTable = ({ snapshots, repositoryId, backups }: Props) => { + + {selectedIds.size > 0 && ( +
+
+
+ + {selectedIds.size} selected +
+
+ + +
+
+
+ )} + + + + + Delete {selectedIds.size} snapshots? + + This action cannot be undone. This will permanently delete the selected snapshots and all their data from + the repository. + + + + Cancel + + 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..86b9f698 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, @@ -20,6 +22,7 @@ import { updateRepositoryDto, type DeleteRepositoryDto, type DeleteSnapshotDto, + type DeleteSnapshotsResponseDto, type DoctorRepositoryDto, type GetRepositoryDto, type GetSnapshotDetailsDto, @@ -160,6 +163,14 @@ 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); + }) .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..eedd8be3 100644 --- a/app/server/modules/repositories/repositories.dto.ts +++ b/app/server/modules/repositories/repositories.dto.ts @@ -400,3 +400,32 @@ export const deleteSnapshotDto = describeRoute({ }, }, }); + +/** + * Delete multiple snapshots + */ +export const deleteSnapshotsBody = type({ + snapshotIds: "string[]", +}); + +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), + }, + }, + }, + }, +}); diff --git a/app/server/modules/repositories/repositories.service.ts b/app/server/modules/repositories/repositories.service.ts index 23f16a08..4421464a 100644 --- a/app/server/modules/repositories/repositories.service.ts +++ b/app/server/modules/repositories/repositories.service.ts @@ -389,6 +389,21 @@ 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 updateRepository = async (id: string, updates: { name?: string; compressionMode?: CompressionMode }) => { const existing = await findRepository(id); @@ -439,4 +454,5 @@ export const repositoriesService = { checkHealth, doctorRepository, deleteSnapshot, + deleteSnapshots, }; diff --git a/app/server/utils/restic.ts b/app/server/utils/restic.ts index 8f696f35..b4ddaf4b 100644 --- a/app/server/utils/restic.ts +++ b/app/server/utils/restic.ts @@ -567,11 +567,11 @@ 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"]; + const args: string[] = ["--repo", repoUrl, "forget", ...snapshotIds, "--prune"]; addCommonArgs(args, env); const res = await safeSpawn({ command: "restic", args, env }); @@ -585,6 +585,10 @@ const deleteSnapshot = async (config: RepositoryConfig, snapshotId: string) => { return { success: true }; }; +const deleteSnapshot = async (config: RepositoryConfig, snapshotId: string) => { + return deleteSnapshots(config, [snapshotId]); +}; + const lsNodeSchema = type({ name: "string", type: "string", @@ -841,6 +845,7 @@ export const restic = { snapshots, forget, deleteSnapshot, + deleteSnapshots, unlock, ls, check,