diff --git a/app/client/api-client/@tanstack/react-query.gen.ts b/app/client/api-client/@tanstack/react-query.gen.ts index 454ba2ee..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, 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'; +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 @@ -561,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 b4f643fc..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, 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'; +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 & { /** @@ -238,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 ac275b15..9146e070 100644 --- a/app/client/api-client/types.gen.ts +++ b/app/client/api-client/types.gen.ts @@ -1389,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 cc13f4c7..0f8574bb 100644 --- a/app/client/components/snapshots-table.tsx +++ b/app/client/components/snapshots-table.tsx @@ -1,6 +1,6 @@ import { useState } from "react"; import { useMutation, useQueryClient } from "@tanstack/react-query"; -import { Calendar, Clock, Database, HardDrive, Server, Tag, Trash2, X } 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"; @@ -27,7 +27,7 @@ import { } from "~/client/components/ui/dialog"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "~/client/components/ui/select"; import { formatDuration } from "~/utils/utils"; -import { deleteSnapshotMutation, deleteSnapshotsMutation } 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"; @@ -41,23 +41,12 @@ 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 [selectedIds, setSelectedIds] = useState>(new Set()); const [showBulkDeleteConfirm, setShowBulkDeleteConfirm] = useState(false); const [showReTagDialog, setShowReTagDialog] = useState(false); const [targetScheduleId, setTargetScheduleId] = useState(""); - const deleteSnapshot = useMutation({ - ...deleteSnapshotMutation(), - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ["listSnapshots"] }); - setShowDeleteConfirm(false); - setSnapshotToDelete(null); - }, - }); - const deleteSnapshots = useMutation({ ...deleteSnapshotsMutation(), onSuccess: () => { @@ -67,26 +56,18 @@ export const SnapshotsTable = ({ snapshots, repositoryId, backups }: Props) => { }, }); - 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}`); @@ -116,10 +97,22 @@ export const SnapshotsTable = ({ snapshots, repositoryId, backups }: Props) => { 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(""); + 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 ( @@ -140,8 +133,6 @@ export const SnapshotsTable = ({ snapshots, repositoryId, backups }: Props) => { Date & Time Size Duration - Volume - Actions @@ -211,32 +202,6 @@ export const SnapshotsTable = ({ snapshots, repositoryId, backups }: Props) => { {formatDuration(snapshot.duration / 1000)} - -
- - e.stopPropagation()} - className="hover:underline" - > - {backup ? backup.volume.name : "-"} - - -
-
- - - ); })} @@ -244,28 +209,6 @@ export const SnapshotsTable = ({ snapshots, repositoryId, backups }: Props) => { - - - - Delete snapshot? - - This action cannot be undone. This will permanently delete the snapshot and all its data from the - repository. - - - - Cancel - - Delete snapshot - - - - - {selectedIds.size > 0 && (
diff --git a/app/server/modules/repositories/repositories.controller.ts b/app/server/modules/repositories/repositories.controller.ts index 86b9f698..b36d8c25 100644 --- a/app/server/modules/repositories/repositories.controller.ts +++ b/app/server/modules/repositories/repositories.controller.ts @@ -18,6 +18,8 @@ import { listSnapshotsFilters, restoreSnapshotBody, restoreSnapshotDto, + tagSnapshotsBody, + tagSnapshotsDto, updateRepositoryBody, updateRepositoryDto, type DeleteRepositoryDto, @@ -30,6 +32,7 @@ import { type ListSnapshotFilesDto, type ListSnapshotsDto, type RestoreSnapshotDto, + type TagSnapshotsResponseDto, type UpdateRepositoryDto, } from "./repositories.dto"; import { repositoriesService } from "./repositories.service"; @@ -171,6 +174,14 @@ export const repositoriesController = new Hono() 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 eedd8be3..4c00f30f 100644 --- a/app/server/modules/repositories/repositories.dto.ts +++ b/app/server/modules/repositories/repositories.dto.ts @@ -429,3 +429,35 @@ export const deleteSnapshotsDto = describeRoute({ }, }, }); + +/** + * Tag multiple snapshots + */ +export const tagSnapshotsBody = type({ + snapshotIds: "string[]", + 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 4421464a..cdd8bb1d 100644 --- a/app/server/modules/repositories/repositories.service.ts +++ b/app/server/modules/repositories/repositories.service.ts @@ -404,6 +404,25 @@ const deleteSnapshots = async (id: string, snapshotIds: string[]) => { } }; +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); @@ -455,4 +474,5 @@ export const repositoriesService = { doctorRepository, deleteSnapshot, deleteSnapshots, + tagSnapshots, }; diff --git a/app/server/utils/restic.ts b/app/server/utils/restic.ts index b4ddaf4b..a2acc52a 100644 --- a/app/server/utils/restic.ts +++ b/app/server/utils/restic.ts @@ -589,6 +589,47 @@ 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); + + 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", @@ -846,6 +887,7 @@ export const restic = { forget, deleteSnapshot, deleteSnapshots, + tagSnapshots, unlock, ls, check,