Compare commits

...

3 Commits

Author SHA1 Message Date
Nicolas Meienberger
67b1accbd0 docs: add warning for location of /var/lib/ironmount 2025-11-16 12:53:13 +01:00
Nicolas Meienberger
98924ea59d fix: timezone parsing cron 2025-11-16 11:51:00 +01:00
Nico
e5435969be feat: remove individual snapshot (#26) 2025-11-16 11:14:18 +01:00
14 changed files with 468 additions and 114 deletions

View File

@@ -36,7 +36,7 @@ In order to run Ironmount, you need to have Docker and Docker Compose installed
```yaml
services:
ironmount:
image: ghcr.io/nicotsx/ironmount:v0.9
image: ghcr.io/nicotsx/ironmount:v0.10
container_name: ironmount
restart: unless-stopped
cap_add:
@@ -46,9 +46,13 @@ services:
devices:
- /dev/fuse:/dev/fuse
volumes:
- /etc/localtime:/etc/localtime:ro
- /var/lib/ironmount:/var/lib/ironmount
```
> [!WARNING]
> Do not try to change the location of the bind mount `/var/lib/ironmount` on your host or store it on a network share. You will likely face permission issues and strong performance degradation.
Then, run the following command to start Ironmount:
```bash
@@ -68,7 +72,7 @@ If you want to track a local directory on the same server where Ironmount is run
```diff
services:
ironmount:
image: ghcr.io/nicotsx/ironmount:v0.9
image: ghcr.io/nicotsx/ironmount:v0.10
container_name: ironmount
restart: unless-stopped
cap_add:
@@ -78,6 +82,7 @@ services:
devices:
- /dev/fuse:/dev/fuse
volumes:
- /etc/localtime:/etc/localtime:ro
- /var/lib/ironmount:/var/lib/ironmount
+ - /path/to/your/directory:/mydata
```
@@ -133,7 +138,7 @@ Ironmount can use [rclone](https://rclone.org/) to support 40+ cloud storage pro
```diff
services:
ironmount:
image: ghcr.io/nicotsx/ironmount:v0.9
image: ghcr.io/nicotsx/ironmount:v0.10
container_name: ironmount
restart: unless-stopped
cap_add:
@@ -143,6 +148,7 @@ Ironmount can use [rclone](https://rclone.org/) to support 40+ cloud storage pro
devices:
- /dev/fuse:/dev/fuse
volumes:
- /etc/localtime:/etc/localtime:ro
- /var/lib/ironmount:/var/lib/ironmount
+ - ~/.config/rclone:/root/.config/rclone
```
@@ -189,7 +195,7 @@ In order to enable this feature, you need to change your bind mount `/var/lib/ir
```diff
services:
ironmount:
image: ghcr.io/nicotsx/ironmount:v0.9
image: ghcr.io/nicotsx/ironmount:v0.10
container_name: ironmount
restart: unless-stopped
ports:
@@ -197,6 +203,7 @@ services:
devices:
- /dev/fuse:/dev/fuse
volumes:
- /etc/localtime:/etc/localtime:ro
- - /var/lib/ironmount:/var/lib/ironmount
+ - /var/lib/ironmount:/var/lib/ironmount:rshared
```
@@ -217,7 +224,7 @@ In order to enable this feature, you need to run Ironmount with several items sh
```diff
services:
ironmount:
image: ghcr.io/nicotsx/ironmount:v0.9
image: ghcr.io/nicotsx/ironmount:v0.10
container_name: ironmount
restart: unless-stopped
cap_add:
@@ -227,6 +234,7 @@ services:
devices:
- /dev/fuse:/dev/fuse
volumes:
- /etc/localtime:/etc/localtime:ro
- - /var/lib/ironmount:/var/lib/ironmount
+ - /var/lib/ironmount:/var/lib/ironmount:rshared
+ - /run/docker/plugins:/run/docker/plugins

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, 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 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 { 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, 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
@@ -460,6 +460,23 @@ export const listSnapshotsOptions = (options: Options<ListSnapshotsData>) => que
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);
/**

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, 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> & {
/**
@@ -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
*/
@@ -422,7 +432,7 @@ export const stopBackup = <ThrowOnError extends boolean = false>(options: Option
* Manually apply retention policy to clean up old snapshots
*/
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',
...options
});

View File

@@ -711,30 +711,42 @@ export type ListRepositoriesResponses = {
bucket: string;
endpoint: string;
secretAccessKey: string;
customPassword?: string;
isExistingRepository?: boolean;
} | {
accessKeyId: string;
backend: 's3';
bucket: string;
endpoint: string;
secretAccessKey: string;
customPassword?: string;
isExistingRepository?: boolean;
} | {
accountKey: string;
accountName: string;
backend: 'azure';
container: string;
customPassword?: string;
endpointSuffix?: string;
isExistingRepository?: boolean;
} | {
backend: 'gcs';
bucket: string;
credentialsJson: string;
projectId: string;
customPassword?: string;
isExistingRepository?: boolean;
} | {
backend: 'local';
name: string;
customPassword?: string;
isExistingRepository?: boolean;
} | {
backend: 'rclone';
path: string;
remote: string;
customPassword?: string;
isExistingRepository?: boolean;
};
createdAt: number;
id: string;
@@ -757,30 +769,42 @@ export type CreateRepositoryData = {
bucket: string;
endpoint: string;
secretAccessKey: string;
customPassword?: string;
isExistingRepository?: boolean;
} | {
accessKeyId: string;
backend: 's3';
bucket: string;
endpoint: string;
secretAccessKey: string;
customPassword?: string;
isExistingRepository?: boolean;
} | {
accountKey: string;
accountName: string;
backend: 'azure';
container: string;
customPassword?: string;
endpointSuffix?: string;
isExistingRepository?: boolean;
} | {
backend: 'gcs';
bucket: string;
credentialsJson: string;
projectId: string;
customPassword?: string;
isExistingRepository?: boolean;
} | {
backend: 'local';
name: string;
customPassword?: string;
isExistingRepository?: boolean;
} | {
backend: 'rclone';
path: string;
remote: string;
customPassword?: string;
isExistingRepository?: boolean;
};
name: string;
compressionMode?: 'auto' | 'better' | 'fastest' | 'max' | 'off';
@@ -865,30 +889,42 @@ export type GetRepositoryResponses = {
bucket: string;
endpoint: string;
secretAccessKey: string;
customPassword?: string;
isExistingRepository?: boolean;
} | {
accessKeyId: string;
backend: 's3';
bucket: string;
endpoint: string;
secretAccessKey: string;
customPassword?: string;
isExistingRepository?: boolean;
} | {
accountKey: string;
accountName: string;
backend: 'azure';
container: string;
customPassword?: string;
endpointSuffix?: string;
isExistingRepository?: boolean;
} | {
backend: 'gcs';
bucket: string;
credentialsJson: string;
projectId: string;
customPassword?: string;
isExistingRepository?: boolean;
} | {
backend: 'local';
name: string;
customPassword?: string;
isExistingRepository?: boolean;
} | {
backend: 'rclone';
path: string;
remote: string;
customPassword?: string;
isExistingRepository?: boolean;
};
createdAt: number;
id: string;
@@ -929,6 +965,27 @@ export type 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 = {
body?: never;
path: {
@@ -1079,30 +1136,42 @@ export type ListBackupSchedulesResponses = {
bucket: string;
endpoint: string;
secretAccessKey: string;
customPassword?: string;
isExistingRepository?: boolean;
} | {
accessKeyId: string;
backend: 's3';
bucket: string;
endpoint: string;
secretAccessKey: string;
customPassword?: string;
isExistingRepository?: boolean;
} | {
accountKey: string;
accountName: string;
backend: 'azure';
container: string;
customPassword?: string;
endpointSuffix?: string;
isExistingRepository?: boolean;
} | {
backend: 'gcs';
bucket: string;
credentialsJson: string;
projectId: string;
customPassword?: string;
isExistingRepository?: boolean;
} | {
backend: 'local';
name: string;
customPassword?: string;
isExistingRepository?: boolean;
} | {
backend: 'rclone';
path: string;
remote: string;
customPassword?: string;
isExistingRepository?: boolean;
};
createdAt: number;
id: string;
@@ -1280,30 +1349,42 @@ export type GetBackupScheduleResponses = {
bucket: string;
endpoint: string;
secretAccessKey: string;
customPassword?: string;
isExistingRepository?: boolean;
} | {
accessKeyId: string;
backend: 's3';
bucket: string;
endpoint: string;
secretAccessKey: string;
customPassword?: string;
isExistingRepository?: boolean;
} | {
accountKey: string;
accountName: string;
backend: 'azure';
container: string;
customPassword?: string;
endpointSuffix?: string;
isExistingRepository?: boolean;
} | {
backend: 'gcs';
bucket: string;
credentialsJson: string;
projectId: string;
customPassword?: string;
isExistingRepository?: boolean;
} | {
backend: 'local';
name: string;
customPassword?: string;
isExistingRepository?: boolean;
} | {
backend: 'rclone';
path: string;
remote: string;
customPassword?: string;
isExistingRepository?: boolean;
};
createdAt: number;
id: string;
@@ -1462,30 +1543,42 @@ export type GetBackupScheduleForVolumeResponses = {
bucket: string;
endpoint: string;
secretAccessKey: string;
customPassword?: string;
isExistingRepository?: boolean;
} | {
accessKeyId: string;
backend: 's3';
bucket: string;
endpoint: string;
secretAccessKey: string;
customPassword?: string;
isExistingRepository?: boolean;
} | {
accountKey: string;
accountName: string;
backend: 'azure';
container: string;
customPassword?: string;
endpointSuffix?: string;
isExistingRepository?: boolean;
} | {
backend: 'gcs';
bucket: string;
credentialsJson: string;
projectId: string;
customPassword?: string;
isExistingRepository?: boolean;
} | {
backend: 'local';
name: string;
customPassword?: string;
isExistingRepository?: boolean;
} | {
backend: 'rclone';
path: string;
remote: string;
customPassword?: string;
isExistingRepository?: boolean;
};
createdAt: number;
id: string;
@@ -1611,13 +1704,6 @@ export type RunForgetData = {
url: '/api/v1/backups/{scheduleId}/forget';
};
export type RunForgetErrors = {
/**
* No retention policy configured for this schedule
*/
400: unknown;
};
export type RunForgetResponses = {
/**
* 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 { toast } from "sonner";
import { ByteSize } from "~/client/components/bytes-size";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "~/client/components/ui/table";
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 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];
@@ -15,81 +31,149 @@ type Props = {
export const SnapshotsTable = ({ snapshots, repositoryName }: Props) => {
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) => {
navigate(`/repositories/${repositoryName}/${snapshotId}`);
};
return (
<div className="overflow-x-auto">
<Table className="border-t">
<TableHeader className="bg-card-header">
<TableRow>
<TableHead className="uppercase">Snapshot ID</TableHead>
<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">Paths</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{snapshots.map((snapshot) => (
<TableRow
key={snapshot.short_id}
className="hover:bg-accent/50 cursor-pointer"
onClick={() => handleRowClick(snapshot.short_id)}
>
<TableCell className="font-mono text-sm">
<div className="flex items-center gap-2">
<HardDrive className="h-4 w-4 text-muted-foreground" />
<span className="text-strong-accent">{snapshot.short_id}</span>
</div>
</TableCell>
<TableCell>
<div className="flex items-center gap-2">
<Calendar className="h-4 w-4 text-muted-foreground" />
<span className="text-sm">{new Date(snapshot.time).toLocaleString()}</span>
</div>
</TableCell>
<TableCell>
<div className="flex items-center gap-2">
<Database className="h-4 w-4 text-muted-foreground" />
<span className="font-medium">
<ByteSize bytes={snapshot.size} base={1024} />
</span>
</div>
</TableCell>
<TableCell className="hidden md:table-cell">
<div className="flex items-center justify-end gap-2">
<Clock className="h-4 w-4 text-muted-foreground" />
<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">
<FolderTree className="h-4 w-4 text-muted-foreground" />
<Tooltip>
<TooltipTrigger asChild>
<span className="text-xs bg-primary/10 text-primary rounded-md px-2 py-1 cursor-help">
{snapshot.paths.length} {snapshot.paths.length === 1 ? "path" : "paths"}
</span>
</TooltipTrigger>
<TooltipContent side="top" className="max-w-md">
<div className="flex flex-col gap-1">
{snapshot.paths.map((path) => (
<div key={`${snapshot.short_id}-${path}`} className="text-xs font-mono">
{path}
</div>
))}
</div>
</TooltipContent>
</Tooltip>
</div>
</TableCell>
<>
<div className="overflow-x-auto">
<Table className="border-t">
<TableHeader className="bg-card-header">
<TableRow>
<TableHead className="uppercase">Snapshot ID</TableHead>
<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">Paths</TableHead>
<TableHead className="uppercase text-right">Actions</TableHead>
</TableRow>
))}
</TableBody>
</Table>
</div>
</TableHeader>
<TableBody>
{snapshots.map((snapshot) => (
<TableRow
key={snapshot.short_id}
className="hover:bg-accent/50 cursor-pointer"
onClick={() => handleRowClick(snapshot.short_id)}
>
<TableCell className="font-mono text-sm">
<div className="flex items-center gap-2">
<HardDrive className="h-4 w-4 text-muted-foreground" />
<span className="text-strong-accent">{snapshot.short_id}</span>
</div>
</TableCell>
<TableCell>
<div className="flex items-center gap-2">
<Calendar className="h-4 w-4 text-muted-foreground" />
<span className="text-sm">{new Date(snapshot.time).toLocaleString()}</span>
</div>
</TableCell>
<TableCell>
<div className="flex items-center gap-2">
<Database className="h-4 w-4 text-muted-foreground" />
<span className="font-medium">
<ByteSize bytes={snapshot.size} base={1024} />
</span>
</div>
</TableCell>
<TableCell className="hidden md:table-cell">
<div className="flex items-center justify-end gap-2">
<Clock className="h-4 w-4 text-muted-foreground" />
<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">
<FolderTree className="h-4 w-4 text-muted-foreground" />
<Tooltip>
<TooltipTrigger asChild>
<span className="text-xs bg-primary/10 text-primary rounded-md px-2 py-1 cursor-help">
{snapshot.paths.length} {snapshot.paths.length === 1 ? "path" : "paths"}
</span>
</TooltipTrigger>
<TooltipContent side="top" className="max-w-md">
<div className="flex flex-col gap-1">
{snapshot.paths.map((path) => (
<div key={`${snapshot.short_id}-${path}`} className="text-xs font-mono">
{path}
</div>
))}
</div>
</TooltipContent>
</Tooltip>
</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>
))}
</TableBody>
</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>
</>
);
};

View File

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

View File

@@ -26,10 +26,12 @@ interface Props {
snapshot: Snapshot;
repositoryName: string;
volume?: Volume;
onDeleteSnapshot?: (snapshotId: string) => void;
isDeletingSnapshot?: boolean;
}
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;
@@ -136,30 +138,43 @@ export const SnapshotFileBrowser = (props: Props) => {
<CardTitle>File Browser</CardTitle>
<CardDescription>{`Viewing snapshot from ${new Date(snapshot?.time ?? 0).toLocaleString()}`}</CardDescription>
</div>
{selectedPaths.size > 0 && (
<Tooltip>
<TooltipTrigger asChild>
<span tabIndex={isReadOnly ? 0 : undefined}>
<Button
onClick={handleRestoreClick}
variant="primary"
size="sm"
disabled={isRestoring || isReadOnly}
>
{isRestoring
? "Restoring..."
: `Restore ${selectedPaths.size} selected ${selectedPaths.size === 1 ? "item" : "items"}`}
</Button>
</span>
</TooltipTrigger>
{isReadOnly && (
<TooltipContent className="text-center">
<p>Volume is mounted as read-only.</p>
<p>Please remount with read-only disabled to restore files.</p>
</TooltipContent>
)}
</Tooltip>
)}
<div className="flex gap-2">
{selectedPaths.size > 0 && (
<Tooltip>
<TooltipTrigger asChild>
<span tabIndex={isReadOnly ? 0 : undefined}>
<Button
onClick={handleRestoreClick}
variant="primary"
size="sm"
disabled={isRestoring || isReadOnly}
>
{isRestoring
? "Restoring..."
: `Restore ${selectedPaths.size} selected ${selectedPaths.size === 1 ? "item" : "items"}`}
</Button>
</span>
</TooltipTrigger>
{isReadOnly && (
<TooltipContent className="text-center">
<p>Volume is mounted as read-only.</p>
<p>Please remount with read-only disabled to restore files.</p>
</TooltipContent>
)}
</Tooltip>
)}
{onDeleteSnapshot && (
<Button
variant="destructive"
size="sm"
onClick={() => onDeleteSnapshot(snapshot.short_id)}
disabled={isDeletingSnapshot}
loading={isDeletingSnapshot}
>
{isDeletingSnapshot ? "Deleting..." : "Delete Snapshot"}
</Button>
)}
</div>
</div>
</CardHeader>
<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 { toast } from "sonner";
import { Button } from "~/client/components/ui/button";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "~/client/components/ui/alert-dialog";
import {
getBackupScheduleOptions,
runBackupNowMutation,
@@ -10,6 +20,7 @@ import {
listSnapshotsOptions,
updateBackupScheduleMutation,
stopBackupMutation,
deleteSnapshotMutation,
} from "~/client/api-client/@tanstack/react-query.gen";
import { parseError } from "~/client/lib/errors";
import { getCronExpression } from "~/utils/utils";
@@ -50,6 +61,8 @@ export default function ScheduleDetailsPage({ params, loaderData }: Route.Compon
const [isEditMode, setIsEditMode] = useState(false);
const formId = useId();
const [selectedSnapshotId, setSelectedSnapshotId] = useState<string>();
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
const [snapshotToDelete, setSnapshotToDelete] = useState<string | null>(null);
const { data: schedule } = useQuery({
...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) => {
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) {
return (
<div>
@@ -191,8 +235,32 @@ export default function ScheduleDetailsPage({ params, loaderData }: Route.Compon
snapshot={selectedSnapshot}
repositoryName={schedule.repository.name}
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>
);
}

View File

@@ -17,7 +17,7 @@ const calculateNextRun = (cronExpression: string): number => {
try {
const interval = CronExpressionParser.parse(cronExpression, {
currentDate: new Date(),
tz: "UTC",
tz: Intl.DateTimeFormat().resolvedOptions().timeZone,
});
return interval.next().getTime();

View File

@@ -4,6 +4,7 @@ import {
createRepositoryBody,
createRepositoryDto,
deleteRepositoryDto,
deleteSnapshotDto,
doctorRepositoryDto,
getRepositoryDto,
getSnapshotDetailsDto,
@@ -16,6 +17,7 @@ import {
restoreSnapshotBody,
restoreSnapshotDto,
type DeleteRepositoryDto,
type DeleteSnapshotDto,
type DoctorRepositoryDto,
type GetRepositoryDto,
type GetSnapshotDetailsDto,
@@ -142,4 +144,11 @@ export const repositoriesController = new Hono()
const result = await repositoriesService.doctorRepository(name);
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 = {
listRepositories,
createRepository,
@@ -338,4 +350,5 @@ export const repositoriesService = {
getSnapshotDetails,
checkHealth,
doctorRepository,
deleteSnapshot,
};

View File

@@ -441,6 +441,22 @@ const forget = async (config: RepositoryConfig, options: RetentionPolicy, extra:
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({
name: "string",
type: "string",
@@ -601,6 +617,7 @@ export const restic = {
restore,
snapshots,
forget,
deleteSnapshot,
unlock,
ls,
check,

View File

@@ -15,6 +15,7 @@ services:
ports:
- "4096:4096"
volumes:
- /etc/localtime:/etc/localtime:ro
- /var/lib/ironmount:/var/lib/ironmount
- ./app:/app/app
@@ -37,6 +38,7 @@ services:
ports:
- "4096:4096"
volumes:
- /etc/localtime:/etc/localtime:ro
- /var/lib/ironmount:/var/lib/ironmount:rshared
- /run/docker/plugins:/run/docker/plugins
- /var/run/docker.sock:/var/run/docker.sock