feat: re-tag snapshots in bulk

This commit is contained in:
Nicolas Meienberger 2025-12-28 20:49:17 +01:00 committed by Nico
parent a635c98366
commit 18d767a406
8 changed files with 192 additions and 90 deletions

View file

@ -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<Options<DoctorReposit
return mutationOptions;
};
/**
* Tag multiple snapshots in a repository
*/
export const tagSnapshotsMutation = (options?: Partial<Options<TagSnapshotsData>>): UseMutationOptions<TagSnapshotsResponse, DefaultError, Options<TagSnapshotsData>> => {
const mutationOptions: UseMutationOptions<TagSnapshotsResponse, DefaultError, Options<TagSnapshotsData>> = {
mutationFn: async (fnOptions) => {
const { data } = await tagSnapshots({
...options,
...fnOptions,
throwOnError: true
});
return data;
}
};
return mutationOptions;
};
export const listBackupSchedulesQueryKey = (options?: Options<ListBackupSchedulesData>) => createQueryKey('listBackupSchedules', options);
/**

View file

@ -2,7 +2,7 @@
import type { Client, Options as Options2, TDataShape } from './client';
import { client } from './client.gen';
import type { BrowseFilesystemData, BrowseFilesystemResponses, 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<TData extends TDataShape = TDataShape, ThrowOnError extends boolean = boolean> = Options2<TData, ThrowOnError> & {
/**
@ -238,6 +238,18 @@ export const restoreSnapshot = <ThrowOnError extends boolean = false>(options: O
*/
export const doctorRepository = <ThrowOnError extends boolean = false>(options: Options<DoctorRepositoryData, ThrowOnError>) => (options.client ?? client).post<DoctorRepositoryResponses, unknown, ThrowOnError>({ url: '/api/v1/repositories/{id}/doctor', ...options });
/**
* Tag multiple snapshots in a repository
*/
export const tagSnapshots = <ThrowOnError extends boolean = false>(options: Options<TagSnapshotsData, ThrowOnError>) => (options.client ?? client).post<TagSnapshotsResponses, unknown, ThrowOnError>({
url: '/api/v1/repositories/{id}/snapshots/tag',
...options,
headers: {
'Content-Type': 'application/json',
...options.headers
}
});
/**
* List all backup schedules
*/

View file

@ -1389,6 +1389,31 @@ export type DoctorRepositoryResponses = {
export type DoctorRepositoryResponse = DoctorRepositoryResponses[keyof DoctorRepositoryResponses];
export type TagSnapshotsData = {
body?: {
snapshotIds: Array<string>;
add?: Array<string>;
remove?: Array<string>;
set?: Array<string>;
};
path: {
id: string;
};
query?: never;
url: '/api/v1/repositories/{id}/snapshots/tag';
};
export type TagSnapshotsResponses = {
/**
* Snapshots tagged successfully
*/
200: {
message: string;
};
};
export type TagSnapshotsResponse = TagSnapshotsResponses[keyof TagSnapshotsResponses];
export type ListBackupSchedulesData = {
body?: never;
path?: never;

View file

@ -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<string | null>(null);
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
const [showBulkDeleteConfirm, setShowBulkDeleteConfirm] = useState(false);
const [showReTagDialog, setShowReTagDialog] = useState(false);
const [targetScheduleId, setTargetScheduleId] = useState<string>("");
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) => {
<TableHead className="uppercase">Date & Time</TableHead>
<TableHead className="uppercase">Size</TableHead>
<TableHead className="uppercase hidden md:table-cell text-right">Duration</TableHead>
<TableHead className="uppercase hidden text-right lg:table-cell">Volume</TableHead>
<TableHead className="uppercase text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
@ -211,32 +202,6 @@ export const SnapshotsTable = ({ snapshots, repositoryId, backups }: Props) => {
<span className="text-sm text-muted-foreground">{formatDuration(snapshot.duration / 1000)}</span>
</div>
</TableCell>
<TableCell className="hidden lg:table-cell">
<div className="flex items-center justify-end gap-2">
<Server className={cn("h-4 w-4 text-muted-foreground", { hidden: !backup })} />
<Link
hidden={!backup}
to={backup ? `/volumes/${backup.volume.name}` : "#"}
onClick={(e) => e.stopPropagation()}
className="hover:underline"
>
<span className="text-sm">{backup ? backup.volume.name : "-"}</span>
</Link>
<span hidden={!!backup} className="text-sm text-muted-foreground">
-
</span>
</div>
</TableCell>
<TableCell className="text-right">
<Button
variant="ghost"
size="sm"
onClick={(e) => handleDeleteClick(e, snapshot.short_id)}
disabled={deleteSnapshot.isPending}
>
<Trash2 className="h-4 w-4 text-destructive" />
</Button>
</TableCell>
</TableRow>
);
})}
@ -244,28 +209,6 @@ export const SnapshotsTable = ({ snapshots, repositoryId, backups }: Props) => {
</Table>
</div>
<AlertDialog open={showDeleteConfirm} onOpenChange={setShowDeleteConfirm}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete snapshot?</AlertDialogTitle>
<AlertDialogDescription>
This action cannot be undone. This will permanently delete the snapshot and all its data from the
repository.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={handleConfirmDelete}
disabled={deleteSnapshot.isPending}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
Delete snapshot
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
{selectedIds.size > 0 && (
<div className="fixed bottom-6 left-1/2 -translate-x-1/2 z-50 animate-in fade-in slide-in-from-bottom-4 duration-300">
<div className="bg-card border shadow-2xl rounded-full px-4 py-2 flex items-center gap-4 min-w-75 justify-between">

View file

@ -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<DeleteSnapshotsResponseDto>({ message: "Snapshots deleted" }, 200);
})
.post("/:id/snapshots/tag", tagSnapshotsDto, validator("json", tagSnapshotsBody), async (c) => {
const { id } = c.req.param();
const { snapshotIds, ...tags } = c.req.valid("json");
await repositoriesService.tagSnapshots(id, snapshotIds, tags);
return c.json<TagSnapshotsResponseDto>({ message: "Snapshots tagged" }, 200);
})
.patch("/:id", updateRepositoryDto, validator("json", updateRepositoryBody), async (c) => {
const { id } = c.req.param();
const body = c.req.valid("json");

View file

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

View file

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

View file

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