feat: remove individual snapshot (#26)

This commit is contained in:
Nico
2025-11-16 11:14:18 +01:00
committed by GitHub
parent c0bef7f65e
commit e5435969be
11 changed files with 452 additions and 108 deletions

View File

@@ -3,8 +3,8 @@
import { type DefaultError, queryOptions, type UseMutationOptions } from '@tanstack/react-query'; import { type DefaultError, queryOptions, type UseMutationOptions } from '@tanstack/react-query';
import { client } from '../client.gen'; import { client } from '../client.gen';
import { browseFilesystem, changePassword, createBackupSchedule, createRepository, createVolume, deleteBackupSchedule, deleteRepository, deleteVolume, doctorRepository, downloadResticPassword, getBackupSchedule, getBackupScheduleForVolume, getContainersUsingVolume, getMe, getRepository, getSnapshotDetails, getStatus, getSystemInfo, getVolume, healthCheckVolume, listBackupSchedules, listFiles, listRcloneRemotes, listRepositories, listSnapshotFiles, listSnapshots, listVolumes, login, logout, mountVolume, type Options, register, restoreSnapshot, runBackupNow, runForget, stopBackup, testConnection, unmountVolume, updateBackupSchedule, updateVolume } from '../sdk.gen'; import { browseFilesystem, changePassword, createBackupSchedule, createRepository, createVolume, deleteBackupSchedule, deleteRepository, deleteSnapshot, deleteVolume, doctorRepository, downloadResticPassword, getBackupSchedule, getBackupScheduleForVolume, getContainersUsingVolume, getMe, getRepository, getSnapshotDetails, getStatus, getSystemInfo, getVolume, healthCheckVolume, listBackupSchedules, listFiles, listRcloneRemotes, listRepositories, listSnapshotFiles, listSnapshots, listVolumes, login, logout, mountVolume, type Options, register, restoreSnapshot, runBackupNow, runForget, stopBackup, testConnection, unmountVolume, updateBackupSchedule, updateVolume } from '../sdk.gen';
import type { BrowseFilesystemData, BrowseFilesystemResponse, ChangePasswordData, ChangePasswordResponse, CreateBackupScheduleData, CreateBackupScheduleResponse, CreateRepositoryData, CreateRepositoryResponse, CreateVolumeData, CreateVolumeResponse, DeleteBackupScheduleData, DeleteBackupScheduleResponse, DeleteRepositoryData, DeleteRepositoryResponse, DeleteVolumeData, DeleteVolumeResponse, DoctorRepositoryData, DoctorRepositoryResponse, DownloadResticPasswordData, DownloadResticPasswordResponse, GetBackupScheduleData, GetBackupScheduleForVolumeData, GetBackupScheduleForVolumeResponse, GetBackupScheduleResponse, GetContainersUsingVolumeData, GetContainersUsingVolumeResponse, GetMeData, GetMeResponse, GetRepositoryData, GetRepositoryResponse, GetSnapshotDetailsData, GetSnapshotDetailsResponse, GetStatusData, GetStatusResponse, GetSystemInfoData, GetSystemInfoResponse, GetVolumeData, GetVolumeResponse, HealthCheckVolumeData, HealthCheckVolumeResponse, ListBackupSchedulesData, ListBackupSchedulesResponse, ListFilesData, ListFilesResponse, ListRcloneRemotesData, ListRcloneRemotesResponse, ListRepositoriesData, ListRepositoriesResponse, ListSnapshotFilesData, ListSnapshotFilesResponse, ListSnapshotsData, ListSnapshotsResponse, ListVolumesData, ListVolumesResponse, LoginData, LoginResponse, LogoutData, LogoutResponse, MountVolumeData, MountVolumeResponse, RegisterData, RegisterResponse, RestoreSnapshotData, RestoreSnapshotResponse, RunBackupNowData, RunBackupNowResponse, RunForgetData, RunForgetResponse, StopBackupData, StopBackupResponse, TestConnectionData, TestConnectionResponse, UnmountVolumeData, UnmountVolumeResponse, UpdateBackupScheduleData, UpdateBackupScheduleResponse, UpdateVolumeData, UpdateVolumeResponse } from '../types.gen'; import type { BrowseFilesystemData, BrowseFilesystemResponse, ChangePasswordData, ChangePasswordResponse, CreateBackupScheduleData, CreateBackupScheduleResponse, CreateRepositoryData, CreateRepositoryResponse, CreateVolumeData, CreateVolumeResponse, DeleteBackupScheduleData, DeleteBackupScheduleResponse, DeleteRepositoryData, DeleteRepositoryResponse, DeleteSnapshotData, DeleteSnapshotResponse, DeleteVolumeData, DeleteVolumeResponse, DoctorRepositoryData, DoctorRepositoryResponse, DownloadResticPasswordData, DownloadResticPasswordResponse, GetBackupScheduleData, GetBackupScheduleForVolumeData, GetBackupScheduleForVolumeResponse, GetBackupScheduleResponse, GetContainersUsingVolumeData, GetContainersUsingVolumeResponse, GetMeData, GetMeResponse, GetRepositoryData, GetRepositoryResponse, GetSnapshotDetailsData, GetSnapshotDetailsResponse, GetStatusData, GetStatusResponse, GetSystemInfoData, GetSystemInfoResponse, GetVolumeData, GetVolumeResponse, HealthCheckVolumeData, HealthCheckVolumeResponse, ListBackupSchedulesData, ListBackupSchedulesResponse, ListFilesData, ListFilesResponse, ListRcloneRemotesData, ListRcloneRemotesResponse, ListRepositoriesData, ListRepositoriesResponse, ListSnapshotFilesData, ListSnapshotFilesResponse, ListSnapshotsData, ListSnapshotsResponse, ListVolumesData, ListVolumesResponse, LoginData, LoginResponse, LogoutData, LogoutResponse, MountVolumeData, MountVolumeResponse, RegisterData, RegisterResponse, RestoreSnapshotData, RestoreSnapshotResponse, RunBackupNowData, RunBackupNowResponse, RunForgetData, RunForgetResponse, StopBackupData, StopBackupResponse, TestConnectionData, TestConnectionResponse, UnmountVolumeData, UnmountVolumeResponse, UpdateBackupScheduleData, UpdateBackupScheduleResponse, UpdateVolumeData, UpdateVolumeResponse } from '../types.gen';
/** /**
* Register a new user * Register a new user
@@ -460,6 +460,23 @@ export const listSnapshotsOptions = (options: Options<ListSnapshotsData>) => que
queryKey: listSnapshotsQueryKey(options) queryKey: listSnapshotsQueryKey(options)
}); });
/**
* Delete a specific snapshot from a repository
*/
export const deleteSnapshotMutation = (options?: Partial<Options<DeleteSnapshotData>>): UseMutationOptions<DeleteSnapshotResponse, DefaultError, Options<DeleteSnapshotData>> => {
const mutationOptions: UseMutationOptions<DeleteSnapshotResponse, DefaultError, Options<DeleteSnapshotData>> = {
mutationFn: async (fnOptions) => {
const { data } = await deleteSnapshot({
...options,
...fnOptions,
throwOnError: true
});
return data;
}
};
return mutationOptions;
};
export const getSnapshotDetailsQueryKey = (options: Options<GetSnapshotDetailsData>) => createQueryKey("getSnapshotDetails", options); export const getSnapshotDetailsQueryKey = (options: Options<GetSnapshotDetailsData>) => createQueryKey("getSnapshotDetails", options);
/** /**

View File

@@ -2,7 +2,7 @@
import type { Client, Options as Options2, TDataShape } from './client'; import type { Client, Options as Options2, TDataShape } from './client';
import { client } from './client.gen'; import { client } from './client.gen';
import type { BrowseFilesystemData, BrowseFilesystemResponses, ChangePasswordData, ChangePasswordResponses, CreateBackupScheduleData, CreateBackupScheduleResponses, CreateRepositoryData, CreateRepositoryResponses, CreateVolumeData, CreateVolumeResponses, DeleteBackupScheduleData, DeleteBackupScheduleResponses, DeleteRepositoryData, DeleteRepositoryResponses, DeleteVolumeData, DeleteVolumeResponses, DoctorRepositoryData, DoctorRepositoryResponses, DownloadResticPasswordData, DownloadResticPasswordResponses, GetBackupScheduleData, GetBackupScheduleForVolumeData, GetBackupScheduleForVolumeResponses, GetBackupScheduleResponses, GetContainersUsingVolumeData, GetContainersUsingVolumeErrors, GetContainersUsingVolumeResponses, GetMeData, GetMeResponses, GetRepositoryData, GetRepositoryResponses, GetSnapshotDetailsData, GetSnapshotDetailsResponses, GetStatusData, GetStatusResponses, GetSystemInfoData, GetSystemInfoResponses, GetVolumeData, GetVolumeErrors, GetVolumeResponses, HealthCheckVolumeData, HealthCheckVolumeErrors, HealthCheckVolumeResponses, ListBackupSchedulesData, ListBackupSchedulesResponses, ListFilesData, ListFilesResponses, ListRcloneRemotesData, ListRcloneRemotesResponses, ListRepositoriesData, ListRepositoriesResponses, ListSnapshotFilesData, ListSnapshotFilesResponses, ListSnapshotsData, ListSnapshotsResponses, ListVolumesData, ListVolumesResponses, LoginData, LoginResponses, LogoutData, LogoutResponses, MountVolumeData, MountVolumeResponses, RegisterData, RegisterResponses, RestoreSnapshotData, RestoreSnapshotResponses, RunBackupNowData, RunBackupNowResponses, RunForgetData, RunForgetErrors, RunForgetResponses, StopBackupData, StopBackupErrors, StopBackupResponses, TestConnectionData, TestConnectionResponses, UnmountVolumeData, UnmountVolumeResponses, UpdateBackupScheduleData, UpdateBackupScheduleResponses, UpdateVolumeData, UpdateVolumeErrors, UpdateVolumeResponses } from './types.gen'; import type { BrowseFilesystemData, BrowseFilesystemResponses, ChangePasswordData, ChangePasswordResponses, CreateBackupScheduleData, CreateBackupScheduleResponses, CreateRepositoryData, CreateRepositoryResponses, CreateVolumeData, CreateVolumeResponses, DeleteBackupScheduleData, DeleteBackupScheduleResponses, DeleteRepositoryData, DeleteRepositoryResponses, DeleteSnapshotData, DeleteSnapshotResponses, DeleteVolumeData, DeleteVolumeResponses, DoctorRepositoryData, DoctorRepositoryResponses, DownloadResticPasswordData, DownloadResticPasswordResponses, GetBackupScheduleData, GetBackupScheduleForVolumeData, GetBackupScheduleForVolumeResponses, GetBackupScheduleResponses, GetContainersUsingVolumeData, GetContainersUsingVolumeErrors, GetContainersUsingVolumeResponses, GetMeData, GetMeResponses, GetRepositoryData, GetRepositoryResponses, GetSnapshotDetailsData, GetSnapshotDetailsResponses, GetStatusData, GetStatusResponses, GetSystemInfoData, GetSystemInfoResponses, GetVolumeData, GetVolumeErrors, GetVolumeResponses, HealthCheckVolumeData, HealthCheckVolumeErrors, HealthCheckVolumeResponses, ListBackupSchedulesData, ListBackupSchedulesResponses, ListFilesData, ListFilesResponses, ListRcloneRemotesData, ListRcloneRemotesResponses, ListRepositoriesData, ListRepositoriesResponses, ListSnapshotFilesData, ListSnapshotFilesResponses, ListSnapshotsData, ListSnapshotsResponses, ListVolumesData, ListVolumesResponses, LoginData, LoginResponses, LogoutData, LogoutResponses, MountVolumeData, MountVolumeResponses, RegisterData, RegisterResponses, RestoreSnapshotData, RestoreSnapshotResponses, RunBackupNowData, RunBackupNowResponses, RunForgetData, RunForgetResponses, StopBackupData, StopBackupErrors, StopBackupResponses, TestConnectionData, TestConnectionResponses, UnmountVolumeData, UnmountVolumeResponses, UpdateBackupScheduleData, UpdateBackupScheduleResponses, UpdateVolumeData, UpdateVolumeErrors, UpdateVolumeResponses } from './types.gen';
export type Options<TData extends TDataShape = TDataShape, ThrowOnError extends boolean = boolean> = Options2<TData, ThrowOnError> & { export type Options<TData extends TDataShape = TDataShape, ThrowOnError extends boolean = boolean> = Options2<TData, ThrowOnError> & {
/** /**
@@ -286,6 +286,16 @@ export const listSnapshots = <ThrowOnError extends boolean = false>(options: Opt
}); });
}; };
/**
* Delete a specific snapshot from a repository
*/
export const deleteSnapshot = <ThrowOnError extends boolean = false>(options: Options<DeleteSnapshotData, ThrowOnError>) => {
return (options.client ?? client).delete<DeleteSnapshotResponses, unknown, ThrowOnError>({
url: '/api/v1/repositories/{name}/snapshots/{snapshotId}',
...options
});
};
/** /**
* Get details of a specific snapshot * Get details of a specific snapshot
*/ */
@@ -422,7 +432,7 @@ export const stopBackup = <ThrowOnError extends boolean = false>(options: Option
* Manually apply retention policy to clean up old snapshots * Manually apply retention policy to clean up old snapshots
*/ */
export const runForget = <ThrowOnError extends boolean = false>(options: Options<RunForgetData, ThrowOnError>) => { export const runForget = <ThrowOnError extends boolean = false>(options: Options<RunForgetData, ThrowOnError>) => {
return (options.client ?? client).post<RunForgetResponses, RunForgetErrors, ThrowOnError>({ return (options.client ?? client).post<RunForgetResponses, unknown, ThrowOnError>({
url: '/api/v1/backups/{scheduleId}/forget', url: '/api/v1/backups/{scheduleId}/forget',
...options ...options
}); });

View File

@@ -711,30 +711,42 @@ export type ListRepositoriesResponses = {
bucket: string; bucket: string;
endpoint: string; endpoint: string;
secretAccessKey: string; secretAccessKey: string;
customPassword?: string;
isExistingRepository?: boolean;
} | { } | {
accessKeyId: string; accessKeyId: string;
backend: 's3'; backend: 's3';
bucket: string; bucket: string;
endpoint: string; endpoint: string;
secretAccessKey: string; secretAccessKey: string;
customPassword?: string;
isExistingRepository?: boolean;
} | { } | {
accountKey: string; accountKey: string;
accountName: string; accountName: string;
backend: 'azure'; backend: 'azure';
container: string; container: string;
customPassword?: string;
endpointSuffix?: string; endpointSuffix?: string;
isExistingRepository?: boolean;
} | { } | {
backend: 'gcs'; backend: 'gcs';
bucket: string; bucket: string;
credentialsJson: string; credentialsJson: string;
projectId: string; projectId: string;
customPassword?: string;
isExistingRepository?: boolean;
} | { } | {
backend: 'local'; backend: 'local';
name: string; name: string;
customPassword?: string;
isExistingRepository?: boolean;
} | { } | {
backend: 'rclone'; backend: 'rclone';
path: string; path: string;
remote: string; remote: string;
customPassword?: string;
isExistingRepository?: boolean;
}; };
createdAt: number; createdAt: number;
id: string; id: string;
@@ -757,30 +769,42 @@ export type CreateRepositoryData = {
bucket: string; bucket: string;
endpoint: string; endpoint: string;
secretAccessKey: string; secretAccessKey: string;
customPassword?: string;
isExistingRepository?: boolean;
} | { } | {
accessKeyId: string; accessKeyId: string;
backend: 's3'; backend: 's3';
bucket: string; bucket: string;
endpoint: string; endpoint: string;
secretAccessKey: string; secretAccessKey: string;
customPassword?: string;
isExistingRepository?: boolean;
} | { } | {
accountKey: string; accountKey: string;
accountName: string; accountName: string;
backend: 'azure'; backend: 'azure';
container: string; container: string;
customPassword?: string;
endpointSuffix?: string; endpointSuffix?: string;
isExistingRepository?: boolean;
} | { } | {
backend: 'gcs'; backend: 'gcs';
bucket: string; bucket: string;
credentialsJson: string; credentialsJson: string;
projectId: string; projectId: string;
customPassword?: string;
isExistingRepository?: boolean;
} | { } | {
backend: 'local'; backend: 'local';
name: string; name: string;
customPassword?: string;
isExistingRepository?: boolean;
} | { } | {
backend: 'rclone'; backend: 'rclone';
path: string; path: string;
remote: string; remote: string;
customPassword?: string;
isExistingRepository?: boolean;
}; };
name: string; name: string;
compressionMode?: 'auto' | 'better' | 'fastest' | 'max' | 'off'; compressionMode?: 'auto' | 'better' | 'fastest' | 'max' | 'off';
@@ -865,30 +889,42 @@ export type GetRepositoryResponses = {
bucket: string; bucket: string;
endpoint: string; endpoint: string;
secretAccessKey: string; secretAccessKey: string;
customPassword?: string;
isExistingRepository?: boolean;
} | { } | {
accessKeyId: string; accessKeyId: string;
backend: 's3'; backend: 's3';
bucket: string; bucket: string;
endpoint: string; endpoint: string;
secretAccessKey: string; secretAccessKey: string;
customPassword?: string;
isExistingRepository?: boolean;
} | { } | {
accountKey: string; accountKey: string;
accountName: string; accountName: string;
backend: 'azure'; backend: 'azure';
container: string; container: string;
customPassword?: string;
endpointSuffix?: string; endpointSuffix?: string;
isExistingRepository?: boolean;
} | { } | {
backend: 'gcs'; backend: 'gcs';
bucket: string; bucket: string;
credentialsJson: string; credentialsJson: string;
projectId: string; projectId: string;
customPassword?: string;
isExistingRepository?: boolean;
} | { } | {
backend: 'local'; backend: 'local';
name: string; name: string;
customPassword?: string;
isExistingRepository?: boolean;
} | { } | {
backend: 'rclone'; backend: 'rclone';
path: string; path: string;
remote: string; remote: string;
customPassword?: string;
isExistingRepository?: boolean;
}; };
createdAt: number; createdAt: number;
id: string; id: string;
@@ -929,6 +965,27 @@ export type ListSnapshotsResponses = {
export type ListSnapshotsResponse = ListSnapshotsResponses[keyof ListSnapshotsResponses]; export type ListSnapshotsResponse = ListSnapshotsResponses[keyof ListSnapshotsResponses];
export type DeleteSnapshotData = {
body?: never;
path: {
name: string;
snapshotId: string;
};
query?: never;
url: '/api/v1/repositories/{name}/snapshots/{snapshotId}';
};
export type DeleteSnapshotResponses = {
/**
* Snapshot deleted successfully
*/
200: {
message: string;
};
};
export type DeleteSnapshotResponse = DeleteSnapshotResponses[keyof DeleteSnapshotResponses];
export type GetSnapshotDetailsData = { export type GetSnapshotDetailsData = {
body?: never; body?: never;
path: { path: {
@@ -1079,30 +1136,42 @@ export type ListBackupSchedulesResponses = {
bucket: string; bucket: string;
endpoint: string; endpoint: string;
secretAccessKey: string; secretAccessKey: string;
customPassword?: string;
isExistingRepository?: boolean;
} | { } | {
accessKeyId: string; accessKeyId: string;
backend: 's3'; backend: 's3';
bucket: string; bucket: string;
endpoint: string; endpoint: string;
secretAccessKey: string; secretAccessKey: string;
customPassword?: string;
isExistingRepository?: boolean;
} | { } | {
accountKey: string; accountKey: string;
accountName: string; accountName: string;
backend: 'azure'; backend: 'azure';
container: string; container: string;
customPassword?: string;
endpointSuffix?: string; endpointSuffix?: string;
isExistingRepository?: boolean;
} | { } | {
backend: 'gcs'; backend: 'gcs';
bucket: string; bucket: string;
credentialsJson: string; credentialsJson: string;
projectId: string; projectId: string;
customPassword?: string;
isExistingRepository?: boolean;
} | { } | {
backend: 'local'; backend: 'local';
name: string; name: string;
customPassword?: string;
isExistingRepository?: boolean;
} | { } | {
backend: 'rclone'; backend: 'rclone';
path: string; path: string;
remote: string; remote: string;
customPassword?: string;
isExistingRepository?: boolean;
}; };
createdAt: number; createdAt: number;
id: string; id: string;
@@ -1280,30 +1349,42 @@ export type GetBackupScheduleResponses = {
bucket: string; bucket: string;
endpoint: string; endpoint: string;
secretAccessKey: string; secretAccessKey: string;
customPassword?: string;
isExistingRepository?: boolean;
} | { } | {
accessKeyId: string; accessKeyId: string;
backend: 's3'; backend: 's3';
bucket: string; bucket: string;
endpoint: string; endpoint: string;
secretAccessKey: string; secretAccessKey: string;
customPassword?: string;
isExistingRepository?: boolean;
} | { } | {
accountKey: string; accountKey: string;
accountName: string; accountName: string;
backend: 'azure'; backend: 'azure';
container: string; container: string;
customPassword?: string;
endpointSuffix?: string; endpointSuffix?: string;
isExistingRepository?: boolean;
} | { } | {
backend: 'gcs'; backend: 'gcs';
bucket: string; bucket: string;
credentialsJson: string; credentialsJson: string;
projectId: string; projectId: string;
customPassword?: string;
isExistingRepository?: boolean;
} | { } | {
backend: 'local'; backend: 'local';
name: string; name: string;
customPassword?: string;
isExistingRepository?: boolean;
} | { } | {
backend: 'rclone'; backend: 'rclone';
path: string; path: string;
remote: string; remote: string;
customPassword?: string;
isExistingRepository?: boolean;
}; };
createdAt: number; createdAt: number;
id: string; id: string;
@@ -1462,30 +1543,42 @@ export type GetBackupScheduleForVolumeResponses = {
bucket: string; bucket: string;
endpoint: string; endpoint: string;
secretAccessKey: string; secretAccessKey: string;
customPassword?: string;
isExistingRepository?: boolean;
} | { } | {
accessKeyId: string; accessKeyId: string;
backend: 's3'; backend: 's3';
bucket: string; bucket: string;
endpoint: string; endpoint: string;
secretAccessKey: string; secretAccessKey: string;
customPassword?: string;
isExistingRepository?: boolean;
} | { } | {
accountKey: string; accountKey: string;
accountName: string; accountName: string;
backend: 'azure'; backend: 'azure';
container: string; container: string;
customPassword?: string;
endpointSuffix?: string; endpointSuffix?: string;
isExistingRepository?: boolean;
} | { } | {
backend: 'gcs'; backend: 'gcs';
bucket: string; bucket: string;
credentialsJson: string; credentialsJson: string;
projectId: string; projectId: string;
customPassword?: string;
isExistingRepository?: boolean;
} | { } | {
backend: 'local'; backend: 'local';
name: string; name: string;
customPassword?: string;
isExistingRepository?: boolean;
} | { } | {
backend: 'rclone'; backend: 'rclone';
path: string; path: string;
remote: string; remote: string;
customPassword?: string;
isExistingRepository?: boolean;
}; };
createdAt: number; createdAt: number;
id: string; id: string;
@@ -1611,13 +1704,6 @@ export type RunForgetData = {
url: '/api/v1/backups/{scheduleId}/forget'; url: '/api/v1/backups/{scheduleId}/forget';
}; };
export type RunForgetErrors = {
/**
* No retention policy configured for this schedule
*/
400: unknown;
};
export type RunForgetResponses = { export type RunForgetResponses = {
/** /**
* Retention policy applied successfully * Retention policy applied successfully

View File

@@ -1,10 +1,26 @@
import { Calendar, Clock, Database, FolderTree, HardDrive } from "lucide-react"; import { useState } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { Calendar, Clock, Database, FolderTree, HardDrive, Trash2 } from "lucide-react";
import { useNavigate } from "react-router"; import { useNavigate } from "react-router";
import { toast } from "sonner";
import { ByteSize } from "~/client/components/bytes-size"; import { ByteSize } from "~/client/components/bytes-size";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "~/client/components/ui/table"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "~/client/components/ui/table";
import { Tooltip, TooltipContent, TooltipTrigger } from "~/client/components/ui/tooltip"; import { Tooltip, TooltipContent, TooltipTrigger } from "~/client/components/ui/tooltip";
import { Button } from "~/client/components/ui/button";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "~/client/components/ui/alert-dialog";
import { formatDuration } from "~/utils/utils"; import { formatDuration } from "~/utils/utils";
import type { ListSnapshotsResponse } from "../api-client"; import type { ListSnapshotsResponse } from "../api-client";
import { deleteSnapshotMutation } from "~/client/api-client/@tanstack/react-query.gen";
import { parseError } from "~/client/lib/errors";
type Snapshot = ListSnapshotsResponse[number]; type Snapshot = ListSnapshotsResponse[number];
@@ -15,12 +31,46 @@ type Props = {
export const SnapshotsTable = ({ snapshots, repositoryName }: Props) => { export const SnapshotsTable = ({ snapshots, repositoryName }: Props) => {
const navigate = useNavigate(); const navigate = useNavigate();
const queryClient = useQueryClient();
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
const [snapshotToDelete, setSnapshotToDelete] = useState<string | null>(null);
const deleteSnapshot = useMutation({
...deleteSnapshotMutation(),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["listSnapshots"] });
setShowDeleteConfirm(false);
setSnapshotToDelete(null);
},
});
const handleDeleteClick = (e: React.MouseEvent, snapshotId: string) => {
e.stopPropagation();
setSnapshotToDelete(snapshotId);
setShowDeleteConfirm(true);
};
const handleConfirmDelete = () => {
if (snapshotToDelete) {
toast.promise(
deleteSnapshot.mutateAsync({
path: { name: repositoryName, snapshotId: snapshotToDelete },
}),
{
loading: "Deleting snapshot...",
success: "Snapshot deleted successfully",
error: (error) => parseError(error)?.message || "Failed to delete snapshot",
},
);
}
};
const handleRowClick = (snapshotId: string) => { const handleRowClick = (snapshotId: string) => {
navigate(`/repositories/${repositoryName}/${snapshotId}`); navigate(`/repositories/${repositoryName}/${snapshotId}`);
}; };
return ( return (
<>
<div className="overflow-x-auto"> <div className="overflow-x-auto">
<Table className="border-t"> <Table className="border-t">
<TableHeader className="bg-card-header"> <TableHeader className="bg-card-header">
@@ -30,6 +80,7 @@ export const SnapshotsTable = ({ snapshots, repositoryName }: Props) => {
<TableHead className="uppercase">Size</TableHead> <TableHead className="uppercase">Size</TableHead>
<TableHead className="uppercase hidden md:table-cell text-right">Duration</TableHead> <TableHead className="uppercase hidden md:table-cell text-right">Duration</TableHead>
<TableHead className="uppercase hidden text-right lg:table-cell">Paths</TableHead> <TableHead className="uppercase hidden text-right lg:table-cell">Paths</TableHead>
<TableHead className="uppercase text-right">Actions</TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
@@ -86,10 +137,43 @@ export const SnapshotsTable = ({ snapshots, repositoryName }: Props) => {
</Tooltip> </Tooltip>
</div> </div>
</TableCell> </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> </TableRow>
))} ))}
</TableBody> </TableBody>
</Table> </Table>
</div> </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>
</>
); );
}; };

View File

@@ -254,8 +254,8 @@ export const CreateScheduleForm = ({ initialValues, formId, onSubmit, volume }:
<CardHeader> <CardHeader>
<CardTitle>Backup paths</CardTitle> <CardTitle>Backup paths</CardTitle>
<CardDescription> <CardDescription>
Select which folders or files to include in the backup. If no paths are selected, the entire volume will be Select which folders or files to include in the backup. If no paths are selected, the entire volume will
backed up. be backed up.
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
<CardContent> <CardContent>

View File

@@ -26,10 +26,12 @@ interface Props {
snapshot: Snapshot; snapshot: Snapshot;
repositoryName: string; repositoryName: string;
volume?: Volume; volume?: Volume;
onDeleteSnapshot?: (snapshotId: string) => void;
isDeletingSnapshot?: boolean;
} }
export const SnapshotFileBrowser = (props: Props) => { export const SnapshotFileBrowser = (props: Props) => {
const { snapshot, repositoryName, volume } = props; const { snapshot, repositoryName, volume, onDeleteSnapshot, isDeletingSnapshot } = props;
const isReadOnly = volume?.config && "readOnly" in volume.config && volume.config.readOnly === true; const isReadOnly = volume?.config && "readOnly" in volume.config && volume.config.readOnly === true;
@@ -136,6 +138,7 @@ export const SnapshotFileBrowser = (props: Props) => {
<CardTitle>File Browser</CardTitle> <CardTitle>File Browser</CardTitle>
<CardDescription>{`Viewing snapshot from ${new Date(snapshot?.time ?? 0).toLocaleString()}`}</CardDescription> <CardDescription>{`Viewing snapshot from ${new Date(snapshot?.time ?? 0).toLocaleString()}`}</CardDescription>
</div> </div>
<div className="flex gap-2">
{selectedPaths.size > 0 && ( {selectedPaths.size > 0 && (
<Tooltip> <Tooltip>
<TooltipTrigger asChild> <TooltipTrigger asChild>
@@ -160,6 +163,18 @@ export const SnapshotFileBrowser = (props: Props) => {
)} )}
</Tooltip> </Tooltip>
)} )}
{onDeleteSnapshot && (
<Button
variant="destructive"
size="sm"
onClick={() => onDeleteSnapshot(snapshot.short_id)}
disabled={isDeletingSnapshot}
loading={isDeletingSnapshot}
>
{isDeletingSnapshot ? "Deleting..." : "Delete Snapshot"}
</Button>
)}
</div>
</div> </div>
</CardHeader> </CardHeader>
<CardContent className="flex-1 overflow-hidden flex flex-col p-0"> <CardContent className="flex-1 overflow-hidden flex flex-col p-0">

View File

@@ -3,6 +3,16 @@ import { useQuery, useMutation } from "@tanstack/react-query";
import { redirect, useNavigate } from "react-router"; import { redirect, useNavigate } from "react-router";
import { toast } from "sonner"; import { toast } from "sonner";
import { Button } from "~/client/components/ui/button"; import { Button } from "~/client/components/ui/button";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "~/client/components/ui/alert-dialog";
import { import {
getBackupScheduleOptions, getBackupScheduleOptions,
runBackupNowMutation, runBackupNowMutation,
@@ -10,6 +20,7 @@ import {
listSnapshotsOptions, listSnapshotsOptions,
updateBackupScheduleMutation, updateBackupScheduleMutation,
stopBackupMutation, stopBackupMutation,
deleteSnapshotMutation,
} from "~/client/api-client/@tanstack/react-query.gen"; } from "~/client/api-client/@tanstack/react-query.gen";
import { parseError } from "~/client/lib/errors"; import { parseError } from "~/client/lib/errors";
import { getCronExpression } from "~/utils/utils"; import { getCronExpression } from "~/utils/utils";
@@ -50,6 +61,8 @@ export default function ScheduleDetailsPage({ params, loaderData }: Route.Compon
const [isEditMode, setIsEditMode] = useState(false); const [isEditMode, setIsEditMode] = useState(false);
const formId = useId(); const formId = useId();
const [selectedSnapshotId, setSelectedSnapshotId] = useState<string>(); const [selectedSnapshotId, setSelectedSnapshotId] = useState<string>();
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
const [snapshotToDelete, setSnapshotToDelete] = useState<string | null>(null);
const { data: schedule } = useQuery({ const { data: schedule } = useQuery({
...getBackupScheduleOptions({ path: { scheduleId: params.id } }), ...getBackupScheduleOptions({ path: { scheduleId: params.id } }),
@@ -110,6 +123,17 @@ export default function ScheduleDetailsPage({ params, loaderData }: Route.Compon
}, },
}); });
const deleteSnapshot = useMutation({
...deleteSnapshotMutation(),
onSuccess: () => {
setShowDeleteConfirm(false);
setSnapshotToDelete(null);
if (selectedSnapshotId === snapshotToDelete) {
setSelectedSnapshotId(undefined);
}
},
});
const handleSubmit = (formValues: BackupScheduleFormValues) => { const handleSubmit = (formValues: BackupScheduleFormValues) => {
if (!schedule) return; if (!schedule) return;
@@ -150,6 +174,26 @@ export default function ScheduleDetailsPage({ params, loaderData }: Route.Compon
}); });
}; };
const handleDeleteSnapshot = (snapshotId: string) => {
setSnapshotToDelete(snapshotId);
setShowDeleteConfirm(true);
};
const handleConfirmDelete = () => {
if (snapshotToDelete) {
toast.promise(
deleteSnapshot.mutateAsync({
path: { name: schedule.repository.name, snapshotId: snapshotToDelete },
}),
{
loading: "Deleting snapshot...",
success: "Snapshot deleted successfully",
error: (error) => parseError(error)?.message || "Failed to delete snapshot",
},
);
}
};
if (isEditMode) { if (isEditMode) {
return ( return (
<div> <div>
@@ -191,8 +235,32 @@ export default function ScheduleDetailsPage({ params, loaderData }: Route.Compon
snapshot={selectedSnapshot} snapshot={selectedSnapshot}
repositoryName={schedule.repository.name} repositoryName={schedule.repository.name}
volume={schedule.volume} volume={schedule.volume}
onDeleteSnapshot={handleDeleteSnapshot}
isDeletingSnapshot={deleteSnapshot.isPending}
/> />
)} )}
<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>
</div> </div>
); );
} }

View File

@@ -4,6 +4,7 @@ import {
createRepositoryBody, createRepositoryBody,
createRepositoryDto, createRepositoryDto,
deleteRepositoryDto, deleteRepositoryDto,
deleteSnapshotDto,
doctorRepositoryDto, doctorRepositoryDto,
getRepositoryDto, getRepositoryDto,
getSnapshotDetailsDto, getSnapshotDetailsDto,
@@ -16,6 +17,7 @@ import {
restoreSnapshotBody, restoreSnapshotBody,
restoreSnapshotDto, restoreSnapshotDto,
type DeleteRepositoryDto, type DeleteRepositoryDto,
type DeleteSnapshotDto,
type DoctorRepositoryDto, type DoctorRepositoryDto,
type GetRepositoryDto, type GetRepositoryDto,
type GetSnapshotDetailsDto, type GetSnapshotDetailsDto,
@@ -142,4 +144,11 @@ export const repositoriesController = new Hono()
const result = await repositoriesService.doctorRepository(name); const result = await repositoriesService.doctorRepository(name);
return c.json<DoctorRepositoryDto>(result, 200); return c.json<DoctorRepositoryDto>(result, 200);
})
.delete("/:name/snapshots/:snapshotId", deleteSnapshotDto, async (c) => {
const { name, snapshotId } = c.req.param();
await repositoriesService.deleteSnapshot(name, snapshotId);
return c.json<DeleteSnapshotDto>({ message: "Snapshot deleted" }, 200);
}); });

View File

@@ -326,3 +326,28 @@ export const listRcloneRemotesDto = describeRoute({
}, },
}, },
}); });
/**
* Delete a snapshot
*/
export const deleteSnapshotResponse = type({
message: "string",
});
export type DeleteSnapshotDto = typeof deleteSnapshotResponse.infer;
export const deleteSnapshotDto = describeRoute({
description: "Delete a specific snapshot from a repository",
tags: ["Repositories"],
operationId: "deleteSnapshot",
responses: {
200: {
description: "Snapshot deleted successfully",
content: {
"application/json": {
schema: resolver(deleteSnapshotResponse),
},
},
},
},
});

View File

@@ -327,6 +327,18 @@ const doctorRepository = async (name: string) => {
}; };
}; };
const deleteSnapshot = async (name: string, snapshotId: string) => {
const repository = await db.query.repositoriesTable.findFirst({
where: eq(repositoriesTable.name, name),
});
if (!repository) {
throw new NotFoundError("Repository not found");
}
await restic.deleteSnapshot(repository.config, snapshotId);
};
export const repositoriesService = { export const repositoriesService = {
listRepositories, listRepositories,
createRepository, createRepository,
@@ -338,4 +350,5 @@ export const repositoriesService = {
getSnapshotDetails, getSnapshotDetails,
checkHealth, checkHealth,
doctorRepository, doctorRepository,
deleteSnapshot,
}; };

View File

@@ -441,6 +441,22 @@ const forget = async (config: RepositoryConfig, options: RetentionPolicy, extra:
return { success: true }; return { success: true };
}; };
const deleteSnapshot = async (config: RepositoryConfig, snapshotId: string) => {
const repoUrl = buildRepoUrl(config);
const env = await buildEnv(config);
const args: string[] = ["--repo", repoUrl, "forget", snapshotId, "--prune"];
const res = await $`restic ${args}`.env(env).nothrow();
if (res.exitCode !== 0) {
logger.error(`Restic snapshot deletion failed: ${res.stderr}`);
throw new Error(`Failed to delete snapshot: ${res.stderr}`);
}
return { success: true };
};
const lsNodeSchema = type({ const lsNodeSchema = type({
name: "string", name: "string",
type: "string", type: "string",
@@ -601,6 +617,7 @@ export const restic = {
restore, restore,
snapshots, snapshots,
forget, forget,
deleteSnapshot,
unlock, unlock,
ls, ls,
check, check,