parent
6354705626
commit
2a1eaf29d0
12 changed files with 191 additions and 135 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -43,3 +43,4 @@ qa-output
|
||||||
|
|
||||||
.worktrees/
|
.worktrees/
|
||||||
.turbo
|
.turbo
|
||||||
|
.superset
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,86 @@
|
||||||
|
import { afterEach, describe, expect, mock, test } from "bun:test";
|
||||||
|
import { cleanup, fireEvent, render, screen, within } from "@testing-library/react";
|
||||||
|
|
||||||
|
const snapshotFiles = {
|
||||||
|
files: [
|
||||||
|
{ name: "project", path: "/mnt/project", type: "dir" },
|
||||||
|
{ name: "a.txt", path: "/mnt/project/a.txt", type: "file" },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
await mock.module("@tanstack/react-query", () => ({
|
||||||
|
useQuery: () => ({ data: snapshotFiles, isLoading: false, error: null }),
|
||||||
|
useQueryClient: () => ({
|
||||||
|
ensureQueryData: async () => snapshotFiles,
|
||||||
|
prefetchQuery: async () => undefined,
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { SnapshotTreeBrowser } from "../snapshot-tree-browser";
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
cleanup();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("SnapshotTreeBrowser", () => {
|
||||||
|
test("renders the query root folder when display base path is broader than query base path", () => {
|
||||||
|
render(
|
||||||
|
<SnapshotTreeBrowser
|
||||||
|
repositoryId="repo-1"
|
||||||
|
snapshotId="snap-1"
|
||||||
|
queryBasePath="/mnt/project"
|
||||||
|
displayBasePath="/mnt"
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
screen.getByRole("button", { name: "project" });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("shows selected folder state when full paths are provided from the parent", () => {
|
||||||
|
render(
|
||||||
|
<SnapshotTreeBrowser
|
||||||
|
repositoryId="repo-1"
|
||||||
|
snapshotId="snap-1"
|
||||||
|
queryBasePath="/mnt/project"
|
||||||
|
displayBasePath="/mnt"
|
||||||
|
withCheckboxes
|
||||||
|
selectedPaths={new Set(["/mnt/project"])}
|
||||||
|
onSelectionChange={() => {}}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
const row = screen.getByRole("button", { name: "project" });
|
||||||
|
const checkbox = within(row).getByRole("checkbox");
|
||||||
|
|
||||||
|
expect(checkbox.getAttribute("aria-checked")).toBe("true");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("returns the full snapshot path and kind when selecting a displayed folder", () => {
|
||||||
|
let selectedPaths: Set<string> | undefined;
|
||||||
|
let selectedKind: "file" | "dir" | null = null;
|
||||||
|
|
||||||
|
render(
|
||||||
|
<SnapshotTreeBrowser
|
||||||
|
repositoryId="repo-1"
|
||||||
|
snapshotId="snap-1"
|
||||||
|
queryBasePath="/mnt/project"
|
||||||
|
displayBasePath="/mnt"
|
||||||
|
withCheckboxes
|
||||||
|
onSelectionChange={(paths) => {
|
||||||
|
selectedPaths = paths;
|
||||||
|
}}
|
||||||
|
onSingleSelectionKindChange={(kind) => {
|
||||||
|
selectedKind = kind;
|
||||||
|
}}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
const row = screen.getByRole("button", { name: "project" });
|
||||||
|
const checkbox = within(row).getByRole("checkbox");
|
||||||
|
|
||||||
|
fireEvent.click(checkbox);
|
||||||
|
|
||||||
|
expect(selectedPaths ? Array.from(selectedPaths) : []).toEqual(["/mnt/project"]);
|
||||||
|
expect(selectedKind === "dir").toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -7,92 +7,91 @@ import { parseError } from "~/client/lib/errors";
|
||||||
import { normalizeAbsolutePath } from "@zerobyte/core/utils";
|
import { normalizeAbsolutePath } from "@zerobyte/core/utils";
|
||||||
import { logger } from "~/client/lib/logger";
|
import { logger } from "~/client/lib/logger";
|
||||||
|
|
||||||
|
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}`;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
type SnapshotTreeBrowserProps = FileBrowserUiProps & {
|
type SnapshotTreeBrowserProps = FileBrowserUiProps & {
|
||||||
repositoryId: string;
|
repositoryId: string;
|
||||||
snapshotId: string;
|
snapshotId: string;
|
||||||
basePath?: string;
|
queryBasePath?: string;
|
||||||
|
displayBasePath?: string;
|
||||||
pageSize?: number;
|
pageSize?: number;
|
||||||
enabled?: boolean;
|
enabled?: boolean;
|
||||||
onSingleSelectionKindChange?: (kind: "file" | "dir" | null) => void;
|
onSingleSelectionKindChange?: (kind: "file" | "dir" | null) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const SnapshotTreeBrowser = ({
|
export const SnapshotTreeBrowser = (props: SnapshotTreeBrowserProps) => {
|
||||||
repositoryId,
|
const {
|
||||||
snapshotId,
|
repositoryId,
|
||||||
basePath = "/",
|
snapshotId,
|
||||||
pageSize = 500,
|
queryBasePath = "/",
|
||||||
enabled = true,
|
displayBasePath,
|
||||||
...uiProps
|
pageSize = 500,
|
||||||
}: SnapshotTreeBrowserProps) => {
|
enabled = true,
|
||||||
|
...uiProps
|
||||||
|
} = props;
|
||||||
|
|
||||||
const { selectedPaths, onSelectionChange, onSingleSelectionKindChange, ...fileBrowserUiProps } = uiProps;
|
const { selectedPaths, onSelectionChange, onSingleSelectionKindChange, ...fileBrowserUiProps } = uiProps;
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const normalizedBasePath = normalizeAbsolutePath(basePath);
|
const normalizedQueryBasePath = normalizeAbsolutePath(queryBasePath);
|
||||||
|
const normalizedDisplayBasePath = normalizeAbsolutePath(displayBasePath ?? normalizedQueryBasePath);
|
||||||
|
|
||||||
const { data, isLoading, error } = useQuery({
|
const { data, isLoading, error } = useQuery({
|
||||||
...listSnapshotFilesOptions({
|
...listSnapshotFilesOptions({
|
||||||
path: { shortId: repositoryId, snapshotId },
|
path: { shortId: repositoryId, snapshotId },
|
||||||
query: { path: normalizedBasePath },
|
query: { path: normalizedQueryBasePath },
|
||||||
}),
|
}),
|
||||||
enabled,
|
enabled,
|
||||||
});
|
});
|
||||||
|
|
||||||
const stripBasePath = useCallback(
|
const displayPathFns = useMemo(() => createPathPrefixFns(normalizedDisplayBasePath), [normalizedDisplayBasePath]);
|
||||||
(path: string): string => {
|
|
||||||
if (normalizedBasePath === "/") return path;
|
|
||||||
if (path === normalizedBasePath) return "/";
|
|
||||||
if (path.startsWith(`${normalizedBasePath}/`)) {
|
|
||||||
return path.slice(normalizedBasePath.length);
|
|
||||||
}
|
|
||||||
return path;
|
|
||||||
},
|
|
||||||
[normalizedBasePath],
|
|
||||||
);
|
|
||||||
|
|
||||||
const addBasePath = useCallback(
|
|
||||||
(displayPath: string): string => {
|
|
||||||
if (normalizedBasePath === "/") return displayPath;
|
|
||||||
if (displayPath === "/") return normalizedBasePath;
|
|
||||||
return `${normalizedBasePath}${displayPath}`;
|
|
||||||
},
|
|
||||||
[normalizedBasePath],
|
|
||||||
);
|
|
||||||
|
|
||||||
const displaySelectedPaths = useMemo(() => {
|
const displaySelectedPaths = useMemo(() => {
|
||||||
if (!selectedPaths) return undefined;
|
if (!selectedPaths) return undefined;
|
||||||
|
|
||||||
const displayPaths = new Set<string>();
|
const displayPaths = new Set<string>();
|
||||||
for (const fullPath of selectedPaths) {
|
for (const fullPath of selectedPaths) {
|
||||||
displayPaths.add(stripBasePath(fullPath));
|
displayPaths.add(displayPathFns.strip(fullPath));
|
||||||
}
|
}
|
||||||
|
|
||||||
return displayPaths;
|
return displayPaths;
|
||||||
}, [selectedPaths, stripBasePath]);
|
}, [displayPathFns, selectedPaths]);
|
||||||
|
|
||||||
const fileBrowser = useFileBrowser({
|
const fileBrowser = useFileBrowser({
|
||||||
initialData: data,
|
initialData: data,
|
||||||
isLoading,
|
isLoading,
|
||||||
fetchFolder: async (path, offset = 0) => {
|
fetchFolder: async (displayPath, offset = 0) => {
|
||||||
return await queryClient.ensureQueryData(
|
return await queryClient.ensureQueryData(
|
||||||
listSnapshotFilesOptions({
|
listSnapshotFilesOptions({
|
||||||
path: { shortId: repositoryId, snapshotId },
|
path: { shortId: repositoryId, snapshotId },
|
||||||
query: { path, offset: offset, limit: pageSize },
|
query: { path: displayPath, offset: offset, limit: pageSize },
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
prefetchFolder: (path) => {
|
prefetchFolder: (displayPath) => {
|
||||||
void queryClient
|
void queryClient
|
||||||
.prefetchQuery(
|
.prefetchQuery(
|
||||||
listSnapshotFilesOptions({
|
listSnapshotFilesOptions({
|
||||||
path: { shortId: repositoryId, snapshotId },
|
path: { shortId: repositoryId, snapshotId },
|
||||||
query: { path, offset: 0, limit: pageSize },
|
query: { path: displayPath, offset: 0, limit: pageSize },
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
.catch((e) => logger.error(e));
|
.catch((e) => logger.error(e));
|
||||||
},
|
},
|
||||||
pathTransform: {
|
pathTransform: displayPathFns,
|
||||||
strip: stripBasePath,
|
|
||||||
add: addBasePath,
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const displayPathKinds = useMemo(() => {
|
const displayPathKinds = useMemo(() => {
|
||||||
|
|
@ -109,7 +108,7 @@ export const SnapshotTreeBrowser = ({
|
||||||
|
|
||||||
const nextFullPaths = new Set<string>();
|
const nextFullPaths = new Set<string>();
|
||||||
for (const displayPath of nextDisplayPaths) {
|
for (const displayPath of nextDisplayPaths) {
|
||||||
nextFullPaths.add(addBasePath(displayPath));
|
nextFullPaths.add(displayPathFns.add(displayPath));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (onSingleSelectionKindChange) {
|
if (onSingleSelectionKindChange) {
|
||||||
|
|
@ -127,7 +126,7 @@ export const SnapshotTreeBrowser = ({
|
||||||
|
|
||||||
onSelectionChange(nextFullPaths);
|
onSelectionChange(nextFullPaths);
|
||||||
},
|
},
|
||||||
[onSelectionChange, addBasePath, onSingleSelectionKindChange, displayPathKinds],
|
[displayPathFns, displayPathKinds, onSelectionChange, onSingleSelectionKindChange],
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
|
||||||
|
|
@ -33,14 +33,15 @@ interface RestoreFormProps {
|
||||||
repository: Repository;
|
repository: Repository;
|
||||||
snapshotId: string;
|
snapshotId: string;
|
||||||
returnPath: string;
|
returnPath: string;
|
||||||
basePath?: string;
|
queryBasePath?: string;
|
||||||
|
displayBasePath?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function RestoreForm({ repository, snapshotId, returnPath, basePath }: RestoreFormProps) {
|
export function RestoreForm({ repository, snapshotId, returnPath, queryBasePath, displayBasePath }: RestoreFormProps) {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { addEventListener } = useServerEvents();
|
const { addEventListener } = useServerEvents();
|
||||||
|
|
||||||
const volumeBasePath = basePath ?? "/";
|
const snapshotBasePath = queryBasePath ?? "/";
|
||||||
|
|
||||||
const [restoreLocation, setRestoreLocation] = useState<RestoreLocation>("original");
|
const [restoreLocation, setRestoreLocation] = useState<RestoreLocation>("original");
|
||||||
const [customTargetPath, setCustomTargetPath] = useState("");
|
const [customTargetPath, setCustomTargetPath] = useState("");
|
||||||
|
|
@ -346,7 +347,8 @@ export function RestoreForm({ repository, snapshotId, returnPath, basePath }: Re
|
||||||
<SnapshotTreeBrowser
|
<SnapshotTreeBrowser
|
||||||
repositoryId={repository.shortId}
|
repositoryId={repository.shortId}
|
||||||
snapshotId={snapshotId}
|
snapshotId={snapshotId}
|
||||||
basePath={volumeBasePath}
|
queryBasePath={snapshotBasePath}
|
||||||
|
displayBasePath={displayBasePath}
|
||||||
pageSize={500}
|
pageSize={500}
|
||||||
className="flex flex-1 min-h-0 flex-col"
|
className="flex flex-1 min-h-0 flex-col"
|
||||||
treeContainerClassName="overflow-auto flex-1 min-h-0 border border-border rounded-md bg-card m-4"
|
treeContainerClassName="overflow-auto flex-1 min-h-0 border border-border rounded-md bg-card m-4"
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,4 @@
|
||||||
import { RotateCcw, Trash2 } from "lucide-react";
|
import { RotateCcw, Trash2 } from "lucide-react";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "~/client/components/ui/card";
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "~/client/components/ui/card";
|
||||||
import { Button, buttonVariants } from "~/client/components/ui/button";
|
import { Button, buttonVariants } from "~/client/components/ui/button";
|
||||||
import type { Snapshot } from "~/client/lib/types";
|
import type { Snapshot } from "~/client/lib/types";
|
||||||
|
|
@ -7,14 +6,13 @@ import { useTimeFormat } from "~/client/lib/datetime";
|
||||||
import { cn } from "~/client/lib/utils";
|
import { cn } from "~/client/lib/utils";
|
||||||
import { Link } from "@tanstack/react-router";
|
import { Link } from "@tanstack/react-router";
|
||||||
import { SnapshotTreeBrowser } from "~/client/components/file-browsers/snapshot-tree-browser";
|
import { SnapshotTreeBrowser } from "~/client/components/file-browsers/snapshot-tree-browser";
|
||||||
import { getBackupScheduleOptions } from "~/client/api-client/@tanstack/react-query.gen";
|
import { findCommonAncestor } from "@zerobyte/core/utils";
|
||||||
import { getVolumeMountPath } from "~/client/lib/volume-path";
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
snapshot: Snapshot;
|
snapshot: Snapshot;
|
||||||
repositoryId: string;
|
repositoryId: string;
|
||||||
backupId?: string;
|
backupId?: string;
|
||||||
basePath?: string;
|
displayBasePath?: string;
|
||||||
onDeleteSnapshot?: (snapshotId: string) => void;
|
onDeleteSnapshot?: (snapshotId: string) => void;
|
||||||
isDeletingSnapshot?: boolean;
|
isDeletingSnapshot?: boolean;
|
||||||
}
|
}
|
||||||
|
|
@ -28,43 +26,11 @@ const treeProps = {
|
||||||
stateClassName: "flex-1 min-h-0",
|
stateClassName: "flex-1 min-h-0",
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
interface ScheduleAwareTreeBrowserProps {
|
|
||||||
scheduleShortId: string;
|
|
||||||
repositoryId: string;
|
|
||||||
snapshotId: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const ScheduleAwareTreeBrowser = ({ scheduleShortId, repositoryId, snapshotId }: ScheduleAwareTreeBrowserProps) => {
|
|
||||||
const { data: schedule, isPending } = useQuery({
|
|
||||||
...getBackupScheduleOptions({ path: { shortId: scheduleShortId } }),
|
|
||||||
retry: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (isPending) {
|
|
||||||
return <TreeBrowserFallback />;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<SnapshotTreeBrowser
|
|
||||||
repositoryId={repositoryId}
|
|
||||||
snapshotId={snapshotId}
|
|
||||||
basePath={schedule ? getVolumeMountPath(schedule.volume) : "/"}
|
|
||||||
{...treeProps}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const TreeBrowserFallback = () => (
|
|
||||||
<div className={cn(treeProps.treeContainerClassName, "flex items-center justify-center")}>
|
|
||||||
<p className="text-muted-foreground">Loading volume info...</p>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
export const SnapshotFileBrowser = (props: Props) => {
|
export const SnapshotFileBrowser = (props: Props) => {
|
||||||
const { snapshot, repositoryId, backupId, basePath, onDeleteSnapshot, isDeletingSnapshot } = props;
|
const { snapshot, repositoryId, backupId, displayBasePath, onDeleteSnapshot, isDeletingSnapshot } = props;
|
||||||
const { formatDateTime } = useTimeFormat();
|
const { formatDateTime } = useTimeFormat();
|
||||||
|
|
||||||
const scheduleShortId = !basePath ? backupId || snapshot.tags?.[0] : undefined;
|
const queryBasePath = findCommonAncestor(snapshot.paths);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
|
|
@ -110,27 +76,13 @@ export const SnapshotFileBrowser = (props: Props) => {
|
||||||
</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">
|
||||||
{basePath ? (
|
<SnapshotTreeBrowser
|
||||||
<SnapshotTreeBrowser
|
repositoryId={repositoryId}
|
||||||
repositoryId={repositoryId}
|
snapshotId={snapshot.short_id}
|
||||||
snapshotId={snapshot.short_id}
|
queryBasePath={queryBasePath}
|
||||||
basePath={basePath}
|
displayBasePath={displayBasePath}
|
||||||
{...treeProps}
|
{...treeProps}
|
||||||
/>
|
/>
|
||||||
) : scheduleShortId ? (
|
|
||||||
<ScheduleAwareTreeBrowser
|
|
||||||
scheduleShortId={scheduleShortId}
|
|
||||||
repositoryId={repositoryId}
|
|
||||||
snapshotId={snapshot.short_id}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<SnapshotTreeBrowser
|
|
||||||
repositoryId={repositoryId}
|
|
||||||
snapshotId={snapshot.short_id}
|
|
||||||
basePath="/"
|
|
||||||
{...treeProps}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -34,6 +34,7 @@ import { ScheduleNotificationsConfig } from "../components/schedule-notification
|
||||||
import { ScheduleMirrorsConfig } from "../components/schedule-mirrors-config";
|
import { ScheduleMirrorsConfig } from "../components/schedule-mirrors-config";
|
||||||
import { BackupSummaryCard } from "~/client/components/backup-summary-card";
|
import { BackupSummaryCard } from "~/client/components/backup-summary-card";
|
||||||
import { cn } from "~/client/lib/utils";
|
import { cn } from "~/client/lib/utils";
|
||||||
|
import { getVolumeMountPath } from "~/client/lib/volume-path";
|
||||||
import type {
|
import type {
|
||||||
BackupSchedule,
|
BackupSchedule,
|
||||||
NotificationDestination,
|
NotificationDestination,
|
||||||
|
|
@ -303,6 +304,7 @@ export function ScheduleDetailsPage(props: Props) {
|
||||||
snapshot={selectedSnapshot}
|
snapshot={selectedSnapshot}
|
||||||
repositoryId={schedule.repository.shortId}
|
repositoryId={schedule.repository.shortId}
|
||||||
backupId={schedule.shortId}
|
backupId={schedule.shortId}
|
||||||
|
displayBasePath={getVolumeMountPath(schedule.volume)}
|
||||||
onDeleteSnapshot={handleDeleteSnapshot}
|
onDeleteSnapshot={handleDeleteSnapshot}
|
||||||
isDeletingSnapshot={deleteSnapshot.isPending}
|
isDeletingSnapshot={deleteSnapshot.isPending}
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -5,11 +5,20 @@ type Props = {
|
||||||
repository: Repository;
|
repository: Repository;
|
||||||
snapshotId: string;
|
snapshotId: string;
|
||||||
returnPath: string;
|
returnPath: string;
|
||||||
basePath?: string;
|
queryBasePath?: string;
|
||||||
|
displayBasePath?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function RestoreSnapshotPage(props: Props) {
|
export function RestoreSnapshotPage(props: Props) {
|
||||||
const { returnPath, snapshotId, repository, basePath } = props;
|
const { returnPath, snapshotId, repository, queryBasePath, displayBasePath } = props;
|
||||||
|
|
||||||
return <RestoreForm repository={repository} snapshotId={snapshotId} returnPath={returnPath} basePath={basePath} />;
|
return (
|
||||||
|
<RestoreForm
|
||||||
|
repository={repository}
|
||||||
|
snapshotId={snapshotId}
|
||||||
|
returnPath={returnPath}
|
||||||
|
queryBasePath={queryBasePath}
|
||||||
|
displayBasePath={displayBasePath}
|
||||||
|
/>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -101,7 +101,7 @@ export function SnapshotDetailsPage({ repositoryId, snapshotId, initialSnapshot
|
||||||
<SnapshotFileBrowser
|
<SnapshotFileBrowser
|
||||||
repositoryId={repositoryId}
|
repositoryId={repositoryId}
|
||||||
snapshot={data}
|
snapshot={data}
|
||||||
basePath={backupSchedule ? getVolumeMountPath(backupSchedule.volume) : undefined}
|
displayBasePath={backupSchedule ? getVolumeMountPath(backupSchedule.volume) : undefined}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<SnapshotFileBrowserSkeleton />
|
<SnapshotFileBrowserSkeleton />
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ import { getBackupSchedule } from "~/client/api-client";
|
||||||
import { getRepositoryOptions, getSnapshotDetailsOptions } from "~/client/api-client/@tanstack/react-query.gen";
|
import { getRepositoryOptions, getSnapshotDetailsOptions } from "~/client/api-client/@tanstack/react-query.gen";
|
||||||
import { RestoreSnapshotPage } from "~/client/modules/repositories/routes/restore-snapshot";
|
import { RestoreSnapshotPage } from "~/client/modules/repositories/routes/restore-snapshot";
|
||||||
import { getVolumeMountPath } from "~/client/lib/volume-path";
|
import { getVolumeMountPath } from "~/client/lib/volume-path";
|
||||||
|
import { findCommonAncestor } from "@zerobyte/core/utils";
|
||||||
|
|
||||||
export const Route = createFileRoute("/(dashboard)/backups/$backupId/$snapshotId/restore")({
|
export const Route = createFileRoute("/(dashboard)/backups/$backupId/$snapshotId/restore")({
|
||||||
component: RouteComponent,
|
component: RouteComponent,
|
||||||
|
|
@ -29,7 +30,8 @@ export const Route = createFileRoute("/(dashboard)/backups/$backupId/$snapshotId
|
||||||
snapshot,
|
snapshot,
|
||||||
repository,
|
repository,
|
||||||
schedule: schedule.data,
|
schedule: schedule.data,
|
||||||
basePath: getVolumeMountPath(schedule.data.volume),
|
queryBasePath: findCommonAncestor(snapshot.paths),
|
||||||
|
displayBasePath: getVolumeMountPath(schedule.data.volume),
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
head: ({ params }) => ({
|
head: ({ params }) => ({
|
||||||
|
|
@ -53,14 +55,15 @@ export const Route = createFileRoute("/(dashboard)/backups/$backupId/$snapshotId
|
||||||
|
|
||||||
function RouteComponent() {
|
function RouteComponent() {
|
||||||
const { backupId, snapshotId } = Route.useParams();
|
const { backupId, snapshotId } = Route.useParams();
|
||||||
const { repository, basePath } = Route.useLoaderData();
|
const { repository, queryBasePath, displayBasePath } = Route.useLoaderData();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<RestoreSnapshotPage
|
<RestoreSnapshotPage
|
||||||
returnPath={`/backups/${backupId}`}
|
returnPath={`/backups/${backupId}`}
|
||||||
snapshotId={snapshotId}
|
snapshotId={snapshotId}
|
||||||
repository={repository}
|
repository={repository}
|
||||||
basePath={basePath}
|
queryBasePath={queryBasePath}
|
||||||
|
displayBasePath={displayBasePath}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ import { getBackupSchedule } from "~/client/api-client";
|
||||||
import { getRepositoryOptions, getSnapshotDetailsOptions } from "~/client/api-client/@tanstack/react-query.gen";
|
import { getRepositoryOptions, getSnapshotDetailsOptions } from "~/client/api-client/@tanstack/react-query.gen";
|
||||||
import { RestoreSnapshotPage } from "~/client/modules/repositories/routes/restore-snapshot";
|
import { RestoreSnapshotPage } from "~/client/modules/repositories/routes/restore-snapshot";
|
||||||
import { getVolumeMountPath } from "~/client/lib/volume-path";
|
import { getVolumeMountPath } from "~/client/lib/volume-path";
|
||||||
|
import { findCommonAncestor } from "@zerobyte/core/utils";
|
||||||
|
|
||||||
export const Route = createFileRoute("/(dashboard)/repositories/$repositoryId/$snapshotId/restore")({
|
export const Route = createFileRoute("/(dashboard)/repositories/$repositoryId/$snapshotId/restore")({
|
||||||
component: RouteComponent,
|
component: RouteComponent,
|
||||||
|
|
@ -15,16 +16,16 @@ export const Route = createFileRoute("/(dashboard)/repositories/$repositoryId/$s
|
||||||
context.queryClient.ensureQueryData({ ...getRepositoryOptions({ path: { shortId: params.repositoryId } }) }),
|
context.queryClient.ensureQueryData({ ...getRepositoryOptions({ path: { shortId: params.repositoryId } }) }),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
let basePath: string | undefined;
|
let displayBasePath: string | undefined;
|
||||||
const scheduleShortId = snapshot.tags?.[0];
|
const scheduleShortId = snapshot.tags?.[0];
|
||||||
if (scheduleShortId) {
|
if (scheduleShortId) {
|
||||||
const scheduleRes = await getBackupSchedule({ path: { shortId: scheduleShortId } });
|
const scheduleRes = await getBackupSchedule({ path: { shortId: scheduleShortId } });
|
||||||
if (scheduleRes.data) {
|
if (scheduleRes.data) {
|
||||||
basePath = getVolumeMountPath(scheduleRes.data.volume);
|
displayBasePath = getVolumeMountPath(scheduleRes.data.volume);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return { snapshot, repository, basePath };
|
return { snapshot, repository, queryBasePath: findCommonAncestor(snapshot.paths), displayBasePath };
|
||||||
},
|
},
|
||||||
staticData: {
|
staticData: {
|
||||||
breadcrumb: (match) => [
|
breadcrumb: (match) => [
|
||||||
|
|
@ -47,14 +48,15 @@ export const Route = createFileRoute("/(dashboard)/repositories/$repositoryId/$s
|
||||||
|
|
||||||
function RouteComponent() {
|
function RouteComponent() {
|
||||||
const { repositoryId, snapshotId } = Route.useParams();
|
const { repositoryId, snapshotId } = Route.useParams();
|
||||||
const { repository, basePath } = Route.useLoaderData();
|
const { repository, queryBasePath, displayBasePath } = Route.useLoaderData();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<RestoreSnapshotPage
|
<RestoreSnapshotPage
|
||||||
returnPath={`/repositories/${repositoryId}/${snapshotId}`}
|
returnPath={`/repositories/${repositoryId}/${snapshotId}`}
|
||||||
repository={repository}
|
repository={repository}
|
||||||
snapshotId={snapshotId}
|
snapshotId={snapshotId}
|
||||||
basePath={basePath}
|
queryBasePath={queryBasePath}
|
||||||
|
displayBasePath={displayBasePath}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -35,7 +35,7 @@ function getRunId(testInfo: TestInfo) {
|
||||||
return `${testInfo.parallelIndex}-${testInfo.retry}-${randomUUID().slice(0, 8)}`;
|
return `${testInfo.parallelIndex}-${testInfo.retry}-${randomUUID().slice(0, 8)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function getWorkerTestDataPath(runId: string) {
|
function getWorkerTestDataPath() {
|
||||||
fs.mkdirSync(testDataPath, { recursive: true });
|
fs.mkdirSync(testDataPath, { recursive: true });
|
||||||
return testDataPath;
|
return testDataPath;
|
||||||
}
|
}
|
||||||
|
|
@ -49,7 +49,7 @@ function getScenarioNames(runId: string): ScenarioNames {
|
||||||
}
|
}
|
||||||
|
|
||||||
function prepareTestFile(runId: string, fileName = "test.json"): string {
|
function prepareTestFile(runId: string, fileName = "test.json"): string {
|
||||||
const runPath = path.join(getWorkerTestDataPath(runId), runId);
|
const runPath = path.join(getWorkerTestDataPath(), runId);
|
||||||
fs.mkdirSync(runPath, { recursive: true });
|
fs.mkdirSync(runPath, { recursive: true });
|
||||||
|
|
||||||
const filePath = path.join(runPath, fileName);
|
const filePath = path.join(runPath, fileName);
|
||||||
|
|
@ -58,8 +58,8 @@ function prepareTestFile(runId: string, fileName = "test.json"): string {
|
||||||
return filePath;
|
return filePath;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function createBackupScenario(page: Page, names: ScenarioNames, runId: string, options: ScenarioOptions = {}) {
|
async function createBackupScenario(page: Page, names: ScenarioNames, options: ScenarioOptions = {}) {
|
||||||
getWorkerTestDataPath(runId);
|
getWorkerTestDataPath();
|
||||||
|
|
||||||
const volumeNameInput = page.getByRole("textbox", { name: "Name" });
|
const volumeNameInput = page.getByRole("textbox", { name: "Name" });
|
||||||
await expect(async () => {
|
await expect(async () => {
|
||||||
|
|
@ -153,7 +153,7 @@ test("can backup & restore a file", async ({ page }, testInfo) => {
|
||||||
await gotoAndWaitForAppReady(page, "/");
|
await gotoAndWaitForAppReady(page, "/");
|
||||||
await expect(page).toHaveURL("/volumes");
|
await expect(page).toHaveURL("/volumes");
|
||||||
|
|
||||||
await createBackupScenario(page, names, runId);
|
await createBackupScenario(page, names);
|
||||||
|
|
||||||
await page.getByRole("button", { name: "Backup now" }).click();
|
await page.getByRole("button", { name: "Backup now" }).click();
|
||||||
await expect(page.getByText("Backup started successfully")).toBeVisible();
|
await expect(page.getByText("Backup started successfully")).toBeVisible();
|
||||||
|
|
@ -177,7 +177,7 @@ test("can backup & restore a file", async ({ page }, testInfo) => {
|
||||||
test("can restore a single selected file to a custom location", async ({ page }, testInfo) => {
|
test("can restore a single selected file to a custom location", async ({ page }, testInfo) => {
|
||||||
const runId = getRunId(testInfo);
|
const runId = getRunId(testInfo);
|
||||||
const names = getScenarioNames(runId);
|
const names = getScenarioNames(runId);
|
||||||
const workerTestDataPath = getWorkerTestDataPath(runId);
|
const workerTestDataPath = getWorkerTestDataPath();
|
||||||
const fileName = `single-file-${runId}.json`;
|
const fileName = `single-file-${runId}.json`;
|
||||||
const filePath = prepareTestFile(runId, fileName);
|
const filePath = prepareTestFile(runId, fileName);
|
||||||
const restoreTargetPath = path.join(workerTestDataPath, fileName);
|
const restoreTargetPath = path.join(workerTestDataPath, fileName);
|
||||||
|
|
@ -188,7 +188,7 @@ test("can restore a single selected file to a custom location", async ({ page },
|
||||||
await gotoAndWaitForAppReady(page, "/");
|
await gotoAndWaitForAppReady(page, "/");
|
||||||
await expect(page).toHaveURL("/volumes");
|
await expect(page).toHaveURL("/volumes");
|
||||||
|
|
||||||
await createBackupScenario(page, names, runId);
|
await createBackupScenario(page, names);
|
||||||
|
|
||||||
await page.getByRole("button", { name: "Backup now" }).click();
|
await page.getByRole("button", { name: "Backup now" }).click();
|
||||||
await expect(page.getByText("Backup started successfully")).toBeVisible();
|
await expect(page.getByText("Backup started successfully")).toBeVisible();
|
||||||
|
|
@ -234,7 +234,7 @@ test("can re-tag a snapshot to another backup schedule", async ({ page }, testIn
|
||||||
await gotoAndWaitForAppReady(page, "/");
|
await gotoAndWaitForAppReady(page, "/");
|
||||||
await expect(page).toHaveURL("/volumes");
|
await expect(page).toHaveURL("/volumes");
|
||||||
|
|
||||||
await createBackupScenario(page, names, runId);
|
await createBackupScenario(page, names);
|
||||||
|
|
||||||
await page.getByRole("button", { name: "Backup now" }).click();
|
await page.getByRole("button", { name: "Backup now" }).click();
|
||||||
await expect(page.getByText("Backup started successfully")).toBeVisible();
|
await expect(page.getByText("Backup started successfully")).toBeVisible();
|
||||||
|
|
@ -277,7 +277,7 @@ test("can delete a snapshot from the repository snapshots tab", async ({ page },
|
||||||
await gotoAndWaitForAppReady(page, "/");
|
await gotoAndWaitForAppReady(page, "/");
|
||||||
await expect(page).toHaveURL("/volumes");
|
await expect(page).toHaveURL("/volumes");
|
||||||
|
|
||||||
await createBackupScenario(page, names, runId);
|
await createBackupScenario(page, names);
|
||||||
|
|
||||||
await page.getByRole("button", { name: "Backup now" }).click();
|
await page.getByRole("button", { name: "Backup now" }).click();
|
||||||
await expect(page.getByText("Backup started successfully")).toBeVisible();
|
await expect(page.getByText("Backup started successfully")).toBeVisible();
|
||||||
|
|
@ -302,7 +302,7 @@ test("can delete a snapshot from the repository snapshots tab", async ({ page },
|
||||||
test("can download a selected snapshot directory as a tar archive", async ({ page }, testInfo) => {
|
test("can download a selected snapshot directory as a tar archive", async ({ page }, testInfo) => {
|
||||||
const runId = getRunId(testInfo);
|
const runId = getRunId(testInfo);
|
||||||
const names = getScenarioNames(runId);
|
const names = getScenarioNames(runId);
|
||||||
const workerTestDataPath = getWorkerTestDataPath(runId);
|
const workerTestDataPath = getWorkerTestDataPath();
|
||||||
const fileName = `download-${runId}.json`;
|
const fileName = `download-${runId}.json`;
|
||||||
const filePath = prepareTestFile(runId, fileName);
|
const filePath = prepareTestFile(runId, fileName);
|
||||||
const downloadedPath = path.join(workerTestDataPath, `downloaded-${runId}.tar`);
|
const downloadedPath = path.join(workerTestDataPath, `downloaded-${runId}.tar`);
|
||||||
|
|
@ -312,7 +312,7 @@ test("can download a selected snapshot directory as a tar archive", async ({ pag
|
||||||
await gotoAndWaitForAppReady(page, "/");
|
await gotoAndWaitForAppReady(page, "/");
|
||||||
await expect(page).toHaveURL("/volumes");
|
await expect(page).toHaveURL("/volumes");
|
||||||
|
|
||||||
await createBackupScenario(page, names, runId);
|
await createBackupScenario(page, names);
|
||||||
|
|
||||||
await page.getByRole("button", { name: "Backup now" }).click();
|
await page.getByRole("button", { name: "Backup now" }).click();
|
||||||
await expect(page.getByText("Backup started successfully")).toBeVisible();
|
await expect(page.getByText("Backup started successfully")).toBeVisible();
|
||||||
|
|
@ -351,7 +351,7 @@ test("deleting a volume cascades and removes its backup schedule", async ({ page
|
||||||
await gotoAndWaitForAppReady(page, "/");
|
await gotoAndWaitForAppReady(page, "/");
|
||||||
await expect(page).toHaveURL("/volumes");
|
await expect(page).toHaveURL("/volumes");
|
||||||
|
|
||||||
await createBackupScenario(page, names, runId);
|
await createBackupScenario(page, names);
|
||||||
|
|
||||||
await gotoAndWaitForAppReady(page, "/backups");
|
await gotoAndWaitForAppReady(page, "/backups");
|
||||||
await page.getByText(names.backupName, { exact: true }).first().click();
|
await page.getByText(names.backupName, { exact: true }).first().click();
|
||||||
|
|
@ -378,7 +378,7 @@ test("deleting a volume cascades and removes its backup schedule", async ({ page
|
||||||
test("backup respects include globs, exclusion patterns, and exclude-if-present", async ({ page }, testInfo) => {
|
test("backup respects include globs, exclusion patterns, and exclude-if-present", async ({ page }, testInfo) => {
|
||||||
const runId = getRunId(testInfo);
|
const runId = getRunId(testInfo);
|
||||||
const names = getScenarioNames(runId);
|
const names = getScenarioNames(runId);
|
||||||
const workerTestDataPath = getWorkerTestDataPath(runId);
|
const workerTestDataPath = getWorkerTestDataPath();
|
||||||
|
|
||||||
const keptDir = `kept-${runId}`;
|
const keptDir = `kept-${runId}`;
|
||||||
const secondKeptDir = `second-kept-${runId}`;
|
const secondKeptDir = `second-kept-${runId}`;
|
||||||
|
|
@ -433,7 +433,7 @@ test("backup respects include globs, exclusion patterns, and exclude-if-present"
|
||||||
await gotoAndWaitForAppReady(page, "/");
|
await gotoAndWaitForAppReady(page, "/");
|
||||||
await expect(page).toHaveURL("/volumes");
|
await expect(page).toHaveURL("/volumes");
|
||||||
|
|
||||||
await createBackupScenario(page, names, runId, {
|
await createBackupScenario(page, names, {
|
||||||
includePatterns: [
|
includePatterns: [
|
||||||
`/${keptDir}`,
|
`/${keptDir}`,
|
||||||
`/${secondKeptDir}`,
|
`/${secondKeptDir}`,
|
||||||
|
|
@ -492,7 +492,7 @@ test("backup respects include globs, exclusion patterns, and exclude-if-present"
|
||||||
test("backup can include a selected folder whose name contains brackets", async ({ page }, testInfo) => {
|
test("backup can include a selected folder whose name contains brackets", async ({ page }, testInfo) => {
|
||||||
const runId = getRunId(testInfo);
|
const runId = getRunId(testInfo);
|
||||||
const names = getScenarioNames(runId);
|
const names = getScenarioNames(runId);
|
||||||
const workerTestDataPath = getWorkerTestDataPath(runId);
|
const workerTestDataPath = getWorkerTestDataPath();
|
||||||
const bracketDir = `movies [${runId}]`;
|
const bracketDir = `movies [${runId}]`;
|
||||||
const bracketPath = path.join(workerTestDataPath, bracketDir);
|
const bracketPath = path.join(workerTestDataPath, bracketDir);
|
||||||
const fileName = `inside-${runId}.txt`;
|
const fileName = `inside-${runId}.txt`;
|
||||||
|
|
@ -503,7 +503,7 @@ test("backup can include a selected folder whose name contains brackets", async
|
||||||
await gotoAndWaitForAppReady(page, "/");
|
await gotoAndWaitForAppReady(page, "/");
|
||||||
await expect(page).toHaveURL("/volumes");
|
await expect(page).toHaveURL("/volumes");
|
||||||
|
|
||||||
await createBackupScenario(page, names, runId, {
|
await createBackupScenario(page, names, {
|
||||||
selectedPaths: [`/${bracketDir}`],
|
selectedPaths: [`/${bracketDir}`],
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
{
|
{
|
||||||
"include": ["**/*"],
|
"include": ["app/**/*"],
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"lib": ["DOM", "DOM.Iterable", "ES2022"],
|
"lib": ["DOM", "DOM.Iterable", "ES2022"],
|
||||||
"types": ["node", "vite/client"],
|
"types": ["node", "vite/client"],
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue