From 99d4d46338815e2c68fddd19d40a163b60262829 Mon Sep 17 00:00:00 2001 From: Nicolas Meienberger Date: Wed, 5 Nov 2025 19:00:44 +0100 Subject: [PATCH] feat(restore): delete files not in snapshot option --- apps/client/app/api-client/types.gen.ts | 1 + .../components/snapshot-file-browser.tsx | 23 ++++++++++++++++--- .../repositories/repositories.controller.ts | 4 ++-- .../modules/repositories/repositories.dto.ts | 1 + .../repositories/repositories.service.ts | 2 +- apps/server/src/utils/restic.ts | 9 +++++++- 6 files changed, 33 insertions(+), 7 deletions(-) diff --git a/apps/client/app/api-client/types.gen.ts b/apps/client/app/api-client/types.gen.ts index 9a314a0..5b43ce3 100644 --- a/apps/client/app/api-client/types.gen.ts +++ b/apps/client/app/api-client/types.gen.ts @@ -871,6 +871,7 @@ export type ListSnapshotFilesResponse = ListSnapshotFilesResponses[keyof ListSna export type RestoreSnapshotData = { body?: { snapshotId: string; + delete?: boolean; exclude?: Array; include?: Array; }; diff --git a/apps/client/app/modules/backups/components/snapshot-file-browser.tsx b/apps/client/app/modules/backups/components/snapshot-file-browser.tsx index 7dd87c9..59cd363 100644 --- a/apps/client/app/modules/backups/components/snapshot-file-browser.tsx +++ b/apps/client/app/modules/backups/components/snapshot-file-browser.tsx @@ -5,6 +5,8 @@ import { listSnapshotFilesOptions, restoreSnapshotMutation } from "~/api-client/ import { FileTree, type FileEntry } from "~/components/file-tree"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "~/components/ui/card"; import { Button } from "~/components/ui/button"; +import { Checkbox } from "~/components/ui/checkbox"; +import { Label } from "~/components/ui/label"; import { AlertDialog, AlertDialogAction, @@ -33,6 +35,7 @@ export const SnapshotFileBrowser = (props: Props) => { const [allFiles, setAllFiles] = useState>(new Map()); const [selectedPaths, setSelectedPaths] = useState>(new Set()); const [showRestoreDialog, setShowRestoreDialog] = useState(false); + const [deleteExtraFiles, setDeleteExtraFiles] = useState(false); const volumeBasePath = snapshot.paths[0]?.match(/^(.*?_data)(\/|$)/)?.[1] || ""; @@ -175,10 +178,12 @@ export const SnapshotFileBrowser = (props: Props) => { body: { snapshotId: snapshot.short_id, include: includePaths, + delete: deleteExtraFiles, }, }); + setShowRestoreDialog(false); - }, [selectedPaths, addBasePath, repositoryName, snapshot.short_id, restoreSnapshot]); + }, [selectedPaths, addBasePath, repositoryName, snapshot.short_id, restoreSnapshot, deleteExtraFiles]); return (
@@ -235,10 +240,22 @@ export const SnapshotFileBrowser = (props: Props) => { Confirm Restore - This will restore {selectedPaths.size} selected {selectedPaths.size === 1 ? "item" : "items"} from the - snapshot. Existing files will be overwritten by what's in the snapshot. This action cannot be undone. + {selectedPaths.size > 0 + ? `This will restore ${selectedPaths.size} selected ${selectedPaths.size === 1 ? "item" : "items"} from the snapshot.` + : "This will restore everything from the snapshot."}{" "} + Existing files will be overwritten by what's in the snapshot. This action cannot be undone. +
+ setDeleteExtraFiles(checked === true)} + /> + +
Cancel Confirm diff --git a/apps/server/src/modules/repositories/repositories.controller.ts b/apps/server/src/modules/repositories/repositories.controller.ts index e105b1e..afc564d 100644 --- a/apps/server/src/modules/repositories/repositories.controller.ts +++ b/apps/server/src/modules/repositories/repositories.controller.ts @@ -111,9 +111,9 @@ export const repositoriesController = new Hono() ) .post("/:name/restore", restoreSnapshotDto, validator("json", restoreSnapshotBody), async (c) => { const { name } = c.req.param(); - const { snapshotId, include, exclude } = c.req.valid("json"); + const { snapshotId, ...options } = c.req.valid("json"); - const result = await repositoriesService.restoreSnapshot(name, snapshotId, { include, exclude }); + const result = await repositoriesService.restoreSnapshot(name, snapshotId, options); return c.json(result, 200); }); diff --git a/apps/server/src/modules/repositories/repositories.dto.ts b/apps/server/src/modules/repositories/repositories.dto.ts index 149ba89..6762acc 100644 --- a/apps/server/src/modules/repositories/repositories.dto.ts +++ b/apps/server/src/modules/repositories/repositories.dto.ts @@ -242,6 +242,7 @@ export const restoreSnapshotBody = type({ snapshotId: "string", include: "string[]?", exclude: "string[]?", + delete: "boolean?", }); export type RestoreSnapshotBody = typeof restoreSnapshotBody.infer; diff --git a/apps/server/src/modules/repositories/repositories.service.ts b/apps/server/src/modules/repositories/repositories.service.ts index fb7d9cb..68677de 100644 --- a/apps/server/src/modules/repositories/repositories.service.ts +++ b/apps/server/src/modules/repositories/repositories.service.ts @@ -163,7 +163,7 @@ const listSnapshotFiles = async (name: string, snapshotId: string, path?: string const restoreSnapshot = async ( name: string, snapshotId: string, - options?: { include?: string[]; exclude?: string[] }, + options?: { include?: string[]; exclude?: string[]; delete?: boolean }, ) => { const repository = await db.query.repositoriesTable.findFirst({ where: eq(repositoriesTable.name, name), diff --git a/apps/server/src/utils/restic.ts b/apps/server/src/utils/restic.ts index 3dafe13..77c351d 100644 --- a/apps/server/src/utils/restic.ts +++ b/apps/server/src/utils/restic.ts @@ -194,6 +194,7 @@ const restore = async ( include?: string[]; exclude?: string[]; path?: string; + delete?: boolean; }, ) => { const repoUrl = buildRepoUrl(config); @@ -205,7 +206,11 @@ const restore = async ( args[args.length - 4] = `${snapshotId}:${options.path}`; } - if (options?.include && options.include.length === 0) { + if (options?.delete) { + args.push("--delete"); + } + + if (options?.include?.length) { for (const pattern of options.include) { args.push("--include", pattern); } @@ -219,6 +224,8 @@ const restore = async ( args.push("--json"); + console.log("Restic restore command:", ["restic", ...args].join(" ")); + const res = await $`restic ${args}`.env(env).nothrow(); if (res.exitCode !== 0) {