diff --git a/app/client/components/__test__/restore-form.test.tsx b/app/client/components/__test__/restore-form.test.tsx index 824232d73..eb6b6ccdd 100644 --- a/app/client/components/__test__/restore-form.test.tsx +++ b/app/client/components/__test__/restore-form.test.tsx @@ -26,6 +26,19 @@ const originalEventSource = globalThis.EventSource; beforeEach(() => { globalThis.EventSource = MockEventSource as unknown as typeof EventSource; + server.use( + http.get("/api/v1/system/info", () => + HttpResponse.json({ + runtime: "server", + capabilities: { + rclone: false, + sysAdmin: false, + volumeBackends: ["directory"], + repositoryBackends: ["local", "s3", "r2", "gcs", "azure", "sftp", "rest"], + }, + }), + ), + ); }); afterEach(() => { @@ -62,7 +75,7 @@ describe("RestoreForm", () => { repository={fromAny({ shortId: "repo-1", name: "Repo 1" })} snapshotId="snap-1" returnPath="/repositories/repo-1/snap-1" - queryBasePath="/mnt/project/subdir" + snapshotSourcePathPlan={{ queryBasePath: "/mnt/project/subdir", requiresCustomTarget: false }} displayBasePath="/mnt" />, ); @@ -81,7 +94,7 @@ describe("RestoreForm", () => { }); }); - test("restores the selected full path when the display root is unrelated", async () => { + test("requires a custom target when the display root is unrelated", async () => { let restoreRequestBody: unknown; server.use( @@ -115,7 +128,7 @@ describe("RestoreForm", () => { repository={fromAny({ shortId: "repo-1", name: "Repo 1" })} snapshotId="snap-1" returnPath="/repositories/repo-1/snap-1" - queryBasePath="/mnt/project" + snapshotSourcePathPlan={{ queryBasePath: "/mnt/project", requiresCustomTarget: false }} displayBasePath="/other/root" />, ); @@ -152,4 +165,25 @@ describe("RestoreForm", () => { }); }); }); + + test("allows original restore when Windows snapshot paths match a Windows host", () => { + server.use( + http.get("/api/v1/repositories/:shortId/snapshots/:snapshotId/files", () => + HttpResponse.json({ files: [] }), + ), + ); + + render( + , + ); + + expect(screen.queryByText("Source paths do not match")).toBeNull(); + expect(screen.getByRole("button", { name: "Original location" }).hasAttribute("disabled")).toBe(false); + }); }); diff --git a/app/client/components/file-browsers/__tests__/snapshot-tree-browser.test.tsx b/app/client/components/file-browsers/__tests__/snapshot-tree-browser.test.tsx index 23df62525..1794e0ac4 100644 --- a/app/client/components/file-browsers/__tests__/snapshot-tree-browser.test.tsx +++ b/app/client/components/file-browsers/__tests__/snapshot-tree-browser.test.tsx @@ -208,6 +208,33 @@ describe("SnapshotTreeBrowser", () => { expect(selectedKind === "dir").toBe(true); }); + test("maps Windows display roots to restic snapshot tree paths", async () => { + mockListSnapshotFiles({ + files: [ + { name: "Downloads", path: "/C/Users/nicolas/Downloads", type: "dir" }, + { name: "a.txt", path: "/C/Users/nicolas/Downloads/a.txt", type: "file" }, + ], + }); + + let selectedPaths: Set | undefined; + + renderSnapshotTreeBrowser({ + queryBasePath: "/C/Users/nicolas", + displayBasePath: "C:\\Users\\Nicolas", + withCheckboxes: true, + onSelectionChange: (paths) => { + selectedPaths = paths; + }, + }); + + const row = await screen.findByRole("button", { name: "Downloads" }); + const checkbox = within(row).getByRole("checkbox"); + + await userEvent.click(checkbox); + + expect(selectedPaths ? Array.from(selectedPaths) : []).toEqual(["/C/Users/nicolas/Downloads"]); + }); + test("uses the query base path for the initial request when display base path is broader", async () => { const requests = mockListSnapshotFiles(); diff --git a/app/client/components/file-browsers/snapshot-tree-browser.tsx b/app/client/components/file-browsers/snapshot-tree-browser.tsx index cf16b3d88..d929a3ab4 100644 --- a/app/client/components/file-browsers/snapshot-tree-browser.tsx +++ b/app/client/components/file-browsers/snapshot-tree-browser.tsx @@ -4,23 +4,7 @@ import { listSnapshotFilesOptions } from "~/client/api-client/@tanstack/react-qu import { FileBrowser, type FileBrowserUiProps } from "~/client/components/file-browsers/file-browser"; import { useFileBrowser } from "~/client/hooks/use-file-browser"; import { parseError } from "~/client/lib/errors"; -import { isPathWithin, normalizeAbsolutePath } from "@zerobyte/core/utils"; - -function createPathPrefixFns(basePath: string) { - return { - strip(path: string) { - if (basePath === "/") return path; - if (path === basePath) return "/"; - if (path.startsWith(`${basePath}/`)) return path.slice(basePath.length); - return path; - }, - add(displayPath: string) { - if (basePath === "/") return displayPath; - if (displayPath === "/") return basePath; - return `${basePath}${displayPath}`; - }, - }; -} +import { createSnapshotPathContext } from "@zerobyte/core/restic"; type SnapshotTreeBrowserProps = FileBrowserUiProps & { repositoryId: string; @@ -45,11 +29,11 @@ export const SnapshotTreeBrowser = (props: SnapshotTreeBrowserProps) => { const { selectedPaths, onSelectionChange, onSingleSelectionKindChange, ...fileBrowserUiProps } = uiProps; const queryClient = useQueryClient(); - const normalizedQueryBasePath = normalizeAbsolutePath(queryBasePath); - const normalizedDisplayBasePath = normalizeAbsolutePath(displayBasePath ?? "/"); - const effectiveDisplayBasePath = isPathWithin(normalizedDisplayBasePath, normalizedQueryBasePath) - ? normalizedDisplayBasePath - : "/"; + const snapshotPathContext = useMemo( + () => createSnapshotPathContext({ snapshotPaths: [queryBasePath], displayBasePath }), + [displayBasePath, queryBasePath], + ); + const normalizedQueryBasePath = snapshotPathContext.browser.initialQueryPath(); const { data, isLoading, error } = useQuery({ ...listSnapshotFilesOptions({ @@ -59,7 +43,13 @@ export const SnapshotTreeBrowser = (props: SnapshotTreeBrowserProps) => { enabled, }); - const displayPathFns = useMemo(() => createPathPrefixFns(effectiveDisplayBasePath), [effectiveDisplayBasePath]); + const displayPathFns = useMemo( + () => ({ + strip: snapshotPathContext.browser.toDisplayPath, + add: snapshotPathContext.browser.toSnapshotPath, + }), + [snapshotPathContext], + ); const displaySelectedPaths = useMemo(() => { if (!selectedPaths) return undefined; diff --git a/app/client/components/restore-form.tsx b/app/client/components/restore-form.tsx index 917795814..b348625e1 100644 --- a/app/client/components/restore-form.tsx +++ b/app/client/components/restore-form.tsx @@ -22,38 +22,40 @@ import { SnapshotTreeBrowser } from "~/client/components/file-browsers/snapshot- import { RestoreProgress } from "~/client/components/restore-progress"; import { restoreSnapshotMutation } from "~/client/api-client/@tanstack/react-query.gen"; import { type RestoreCompletedEvent, useServerEvents } from "~/client/hooks/use-server-events"; -import { OVERWRITE_MODES, type OverwriteMode } from "@zerobyte/core/restic"; -import { isPathWithin } from "@zerobyte/core/utils"; +import { createSnapshotPathContext, OVERWRITE_MODES, type OverwriteMode } from "@zerobyte/core/restic"; import type { Repository } from "~/client/lib/types"; import { handleRepositoryError } from "~/client/lib/errors"; import { useNavigate } from "@tanstack/react-router"; import { cn } from "~/client/lib/utils"; type RestoreLocation = "original" | "custom"; +type SnapshotRestorePlan = { queryBasePath: string; requiresCustomTarget: boolean }; interface RestoreFormProps { repository: Repository; snapshotId: string; returnPath: string; - queryBasePath?: string; + snapshotSourcePathPlan: SnapshotRestorePlan; displayBasePath?: string; - hasNonPosixSnapshotPaths?: boolean; } export function RestoreForm({ repository, snapshotId, returnPath, - queryBasePath, + snapshotSourcePathPlan, displayBasePath, - hasNonPosixSnapshotPaths = false, }: RestoreFormProps) { const navigate = useNavigate(); const { addEventListener } = useServerEvents(); - const snapshotBasePath = queryBasePath ?? "/"; - const hasMismatchedDisplayBasePath = displayBasePath && !isPathWithin(displayBasePath, snapshotBasePath); - const restoreRequiresCustomTarget = hasNonPosixSnapshotPaths || hasMismatchedDisplayBasePath; + const snapshotBasePath = snapshotSourcePathPlan.queryBasePath; + const snapshotPathContext = createSnapshotPathContext({ + snapshotPaths: [snapshotBasePath], + displayBasePath, + }); + const hasMismatchedDisplayBasePath = displayBasePath && !snapshotPathContext.browser.isDisplayBaseCompatible(); + const restoreRequiresCustomTarget = snapshotSourcePathPlan.requiresCustomTarget || !!hasMismatchedDisplayBasePath; const [restoreLocation, setRestoreLocation] = useState( restoreRequiresCustomTarget ? "custom" : "original", diff --git a/app/client/modules/backups/components/snapshot-file-browser.tsx b/app/client/modules/backups/components/snapshot-file-browser.tsx index b11365225..e03c1edf7 100644 --- a/app/client/modules/backups/components/snapshot-file-browser.tsx +++ b/app/client/modules/backups/components/snapshot-file-browser.tsx @@ -6,7 +6,7 @@ import { useTimeFormat } from "~/client/lib/datetime"; import { cn } from "~/client/lib/utils"; import { Link } from "@tanstack/react-router"; import { SnapshotTreeBrowser } from "~/client/components/file-browsers/snapshot-tree-browser"; -import { findCommonAncestor } from "@zerobyte/core/utils"; +import { createSnapshotPathContext } from "@zerobyte/core/restic"; interface Props { snapshot: Snapshot; @@ -30,8 +30,7 @@ export const SnapshotFileBrowser = (props: Props) => { const { snapshot, repositoryId, backupId, displayBasePath, onDeleteSnapshot, isDeletingSnapshot } = props; const { formatDateTime } = useTimeFormat(); - const hasNonPosixSnapshotPaths = snapshot.paths.some((path) => !path.startsWith("/")); - const queryBasePath = hasNonPosixSnapshotPaths ? "/" : findCommonAncestor(snapshot.paths); + const snapshotPathContext = createSnapshotPathContext({ snapshotPaths: snapshot.paths, displayBasePath }); return (
@@ -80,7 +79,7 @@ export const SnapshotFileBrowser = (props: Props) => { diff --git a/app/client/modules/repositories/routes/restore-snapshot.tsx b/app/client/modules/repositories/routes/restore-snapshot.tsx index 4f2ec36f5..84f99e3ed 100644 --- a/app/client/modules/repositories/routes/restore-snapshot.tsx +++ b/app/client/modules/repositories/routes/restore-snapshot.tsx @@ -1,26 +1,26 @@ import { RestoreForm } from "~/client/components/restore-form"; import type { Repository } from "~/client/lib/types"; +type SnapshotRestorePlan = { queryBasePath: string; requiresCustomTarget: boolean }; + type Props = { repository: Repository; snapshotId: string; returnPath: string; - queryBasePath?: string; + snapshotSourcePathPlan: SnapshotRestorePlan; displayBasePath?: string; - hasNonPosixSnapshotPaths?: boolean; }; export function RestoreSnapshotPage(props: Props) { - const { returnPath, snapshotId, repository, queryBasePath, displayBasePath, hasNonPosixSnapshotPaths } = props; + const { returnPath, snapshotId, repository, snapshotSourcePathPlan, displayBasePath } = props; return ( ); } diff --git a/app/routes/(dashboard)/backups/$backupId/$snapshotId.restore.tsx b/app/routes/(dashboard)/backups/$backupId/$snapshotId.restore.tsx index 26d1f0c55..35b283f62 100644 --- a/app/routes/(dashboard)/backups/$backupId/$snapshotId.restore.tsx +++ b/app/routes/(dashboard)/backups/$backupId/$snapshotId.restore.tsx @@ -1,9 +1,12 @@ import { createFileRoute } from "@tanstack/react-router"; import { getBackupSchedule } from "~/client/api-client"; -import { getRepositoryOptions, getSnapshotDetailsOptions } from "~/client/api-client/@tanstack/react-query.gen"; +import { + getRepositoryOptions, + getSnapshotDetailsOptions, + getSnapshotRestorePlanOptions, +} from "~/client/api-client/@tanstack/react-query.gen"; import { RestoreSnapshotPage } from "~/client/modules/repositories/routes/restore-snapshot"; import { getVolumeMountPath } from "~/client/lib/volume-path"; -import { findCommonAncestor } from "@zerobyte/core/utils"; export const Route = createFileRoute("/(dashboard)/backups/$backupId/$snapshotId/restore")({ component: RouteComponent, @@ -15,7 +18,7 @@ export const Route = createFileRoute("/(dashboard)/backups/$backupId/$snapshotId throw new Response("Not Found", { status: 404 }); } - const [snapshot, repository] = await Promise.all([ + const [snapshot, repository, restorePlan] = await Promise.all([ context.queryClient.ensureQueryData({ ...getSnapshotDetailsOptions({ path: { shortId: schedule.data.repository.shortId, snapshotId: params.snapshotId }, @@ -24,17 +27,21 @@ export const Route = createFileRoute("/(dashboard)/backups/$backupId/$snapshotId context.queryClient.ensureQueryData({ ...getRepositoryOptions({ path: { shortId: schedule.data.repository.shortId } }), }), + context.queryClient.ensureQueryData({ + ...getSnapshotRestorePlanOptions({ + path: { shortId: schedule.data.repository.shortId, snapshotId: params.snapshotId }, + }), + }), ]); - const hasNonPosixSnapshotPaths = snapshot.paths.some((path) => !path.startsWith("/")); + const displayBasePath = getVolumeMountPath(schedule.data.volume); return { snapshot, repository, schedule: schedule.data, - queryBasePath: hasNonPosixSnapshotPaths ? "/" : findCommonAncestor(snapshot.paths), - displayBasePath: getVolumeMountPath(schedule.data.volume), - hasNonPosixSnapshotPaths, + displayBasePath, + snapshotSourcePathPlan: restorePlan, }; }, head: ({ params }) => ({ @@ -58,16 +65,15 @@ export const Route = createFileRoute("/(dashboard)/backups/$backupId/$snapshotId function RouteComponent() { const { backupId, snapshotId } = Route.useParams(); - const { repository, queryBasePath, displayBasePath, hasNonPosixSnapshotPaths } = Route.useLoaderData(); + const { repository, displayBasePath, snapshotSourcePathPlan } = Route.useLoaderData(); return ( ); } diff --git a/app/routes/(dashboard)/repositories/$repositoryId/$snapshotId/restore.tsx b/app/routes/(dashboard)/repositories/$repositoryId/$snapshotId/restore.tsx index aed977aad..48d16de57 100644 --- a/app/routes/(dashboard)/repositories/$repositoryId/$snapshotId/restore.tsx +++ b/app/routes/(dashboard)/repositories/$repositoryId/$snapshotId/restore.tsx @@ -1,19 +1,29 @@ import { createFileRoute } from "@tanstack/react-router"; import { getBackupSchedule } from "~/client/api-client"; -import { getRepositoryOptions, getSnapshotDetailsOptions } from "~/client/api-client/@tanstack/react-query.gen"; +import { + getRepositoryOptions, + getSnapshotDetailsOptions, + getSnapshotRestorePlanOptions, +} from "~/client/api-client/@tanstack/react-query.gen"; import { RestoreSnapshotPage } from "~/client/modules/repositories/routes/restore-snapshot"; import { getVolumeMountPath } from "~/client/lib/volume-path"; -import { findCommonAncestor } from "@zerobyte/core/utils"; export const Route = createFileRoute("/(dashboard)/repositories/$repositoryId/$snapshotId/restore")({ component: RouteComponent, errorComponent: (e) =>
{e.error.message}
, loader: async ({ params, context }) => { - const [snapshot, repository] = await Promise.all([ + const [snapshot, repository, restorePlan] = await Promise.all([ context.queryClient.ensureQueryData({ ...getSnapshotDetailsOptions({ path: { shortId: params.repositoryId, snapshotId: params.snapshotId } }), }), - context.queryClient.ensureQueryData({ ...getRepositoryOptions({ path: { shortId: params.repositoryId } }) }), + context.queryClient.ensureQueryData({ + ...getRepositoryOptions({ path: { shortId: params.repositoryId } }), + }), + context.queryClient.ensureQueryData({ + ...getSnapshotRestorePlanOptions({ + path: { shortId: params.repositoryId, snapshotId: params.snapshotId }, + }), + }), ]); let displayBasePath: string | undefined; @@ -25,21 +35,24 @@ export const Route = createFileRoute("/(dashboard)/repositories/$repositoryId/$s } } - const hasNonPosixSnapshotPaths = snapshot.paths.some((path) => !path.startsWith("/")); - return { snapshot, repository, - queryBasePath: hasNonPosixSnapshotPaths ? "/" : findCommonAncestor(snapshot.paths), displayBasePath, - hasNonPosixSnapshotPaths, + snapshotSourcePathPlan: restorePlan, }; }, staticData: { breadcrumb: (match) => [ { label: "Repositories", href: "/repositories" }, - { label: match.loaderData?.repository?.name || "Repository", href: `/repositories/${match.params.repositoryId}` }, - { label: match.params.snapshotId, href: `/repositories/${match.params.repositoryId}/${match.params.snapshotId}` }, + { + label: match.loaderData?.repository?.name || "Repository", + href: `/repositories/${match.params.repositoryId}`, + }, + { + label: match.params.snapshotId, + href: `/repositories/${match.params.repositoryId}/${match.params.snapshotId}`, + }, { label: "Restore" }, ], }, @@ -56,16 +69,15 @@ export const Route = createFileRoute("/(dashboard)/repositories/$repositoryId/$s function RouteComponent() { const { repositoryId, snapshotId } = Route.useParams(); - const { repository, queryBasePath, displayBasePath, hasNonPosixSnapshotPaths } = Route.useLoaderData(); + const { repository, displayBasePath, snapshotSourcePathPlan } = Route.useLoaderData(); return ( ); }