feat: select folders to backup when creating a job

This commit is contained in:
Nicolas Meienberger
2025-11-02 17:32:40 +01:00
parent ccfa5e35e9
commit acc5f44565
12 changed files with 510 additions and 181 deletions

View File

@@ -11,6 +11,7 @@
import { ChevronDown, ChevronRight, File as FileIcon, Folder as FolderIcon, FolderOpen, Loader2 } from "lucide-react"; import { ChevronDown, ChevronRight, File as FileIcon, Folder as FolderIcon, FolderOpen, Loader2 } from "lucide-react";
import { memo, type ReactNode, useCallback, useEffect, useMemo, useState } from "react"; import { memo, type ReactNode, useCallback, useEffect, useMemo, useState } from "react";
import { cn } from "~/lib/utils"; import { cn } from "~/lib/utils";
import { Checkbox } from "~/components/ui/checkbox";
const NODE_PADDING_LEFT = 12; const NODE_PADDING_LEFT = 12;
@@ -31,6 +32,10 @@ interface Props {
expandedFolders?: Set<string>; expandedFolders?: Set<string>;
loadingFolders?: Set<string>; loadingFolders?: Set<string>;
className?: string; className?: string;
withCheckboxes?: boolean;
selectedPaths?: Set<string>;
onSelectionChange?: (selectedPaths: Set<string>) => void;
foldersOnly?: boolean;
} }
export const FileTree = memo((props: Props) => { export const FileTree = memo((props: Props) => {
@@ -43,11 +48,15 @@ export const FileTree = memo((props: Props) => {
expandedFolders = new Set(), expandedFolders = new Set(),
loadingFolders = new Set(), loadingFolders = new Set(),
className, className,
withCheckboxes = false,
selectedPaths = new Set(),
onSelectionChange,
foldersOnly = false,
} = props; } = props;
const fileList = useMemo(() => { const fileList = useMemo(() => {
return buildFileList(files); return buildFileList(files, foldersOnly);
}, [files]); }, [files, foldersOnly]);
const [collapsedFolders, setCollapsedFolders] = useState<Set<string>>(new Set()); const [collapsedFolders, setCollapsedFolders] = useState<Set<string>>(new Set());
@@ -117,6 +126,122 @@ export const FileTree = memo((props: Props) => {
[onFileSelect], [onFileSelect],
); );
const handleSelectionChange = useCallback(
(path: string, checked: boolean) => {
const newSelection = new Set(selectedPaths);
if (checked) {
// Add the path itself
newSelection.add(path);
// Remove any descendants from selection since parent now covers them
for (const item of fileList) {
if (item.fullPath.startsWith(`${path}/`)) {
newSelection.delete(item.fullPath);
}
}
} else {
// Remove the path itself
newSelection.delete(path);
// Check if any parent is selected - if so, we need to add siblings back
const pathSegments = path.split("/").filter(Boolean);
let parentIsSelected = false;
let selectedParentPath = "";
// Check each parent level to see if any are selected
for (let i = pathSegments.length - 1; i > 0; i--) {
const parentPath = `/${pathSegments.slice(0, i).join("/")}`;
if (newSelection.has(parentPath)) {
parentIsSelected = true;
selectedParentPath = parentPath;
break;
}
}
if (parentIsSelected) {
// Remove the selected parent
newSelection.delete(selectedParentPath);
// Add all siblings and descendants of the selected parent, except the unchecked path and its descendants
for (const item of fileList) {
if (
item.fullPath.startsWith(`${selectedParentPath}/`) &&
!item.fullPath.startsWith(`${path}/`) &&
item.fullPath !== path
) {
newSelection.add(item.fullPath);
}
}
}
}
onSelectionChange?.(newSelection);
},
[selectedPaths, onSelectionChange, fileList],
);
// Helper to check if a path is selected (either directly or via parent)
const isPathSelected = useCallback(
(path: string): boolean => {
// Check if directly selected
if (selectedPaths.has(path)) {
return true;
}
// Check if any parent is selected
const pathSegments = path.split("/").filter(Boolean);
for (let i = pathSegments.length - 1; i > 0; i--) {
const parentPath = `/${pathSegments.slice(0, i).join("/")}`;
if (selectedPaths.has(parentPath)) {
return true;
}
}
return false;
},
[selectedPaths],
);
// Determine if a folder is partially selected (some children selected)
const isPartiallySelected = useCallback(
(folderPath: string): boolean => {
// If the folder itself is selected, it's not partial
if (selectedPaths.has(folderPath)) {
return false;
}
// Check if this folder is implicitly selected via a parent
const pathSegments = folderPath.split("/").filter(Boolean);
for (let i = pathSegments.length - 1; i > 0; i--) {
const parentPath = `/${pathSegments.slice(0, i).join("/")}`;
if (selectedPaths.has(parentPath)) {
// Parent is selected, so this folder is fully selected, not partial
return false;
}
}
// Get all children of this folder
const children = fileList.filter((item) => item.fullPath.startsWith(`${folderPath}/`));
if (children.length === 0) {
return false;
}
// Check how many children are selected (directly or via their parents)
let selectedCount = 0;
for (const child of children) {
if (isPathSelected(child.fullPath)) {
selectedCount++;
}
}
// Partial if some but not all children are selected
return selectedCount > 0 && selectedCount < children.length;
},
[selectedPaths, fileList, isPathSelected],
);
return ( return (
<div className={cn("text-sm", className)}> <div className={cn("text-sm", className)}>
{filteredFileList.map((fileOrFolder) => { {filteredFileList.map((fileOrFolder) => {
@@ -128,6 +253,9 @@ export const FileTree = memo((props: Props) => {
selected={selectedFile === fileOrFolder.fullPath} selected={selectedFile === fileOrFolder.fullPath}
file={fileOrFolder} file={fileOrFolder}
onFileSelect={handleFileSelect} onFileSelect={handleFileSelect}
withCheckbox={withCheckboxes}
checked={isPathSelected(fileOrFolder.fullPath)}
onCheckboxChange={handleSelectionChange}
/> />
); );
} }
@@ -140,6 +268,10 @@ export const FileTree = memo((props: Props) => {
loading={loadingFolders.has(fileOrFolder.fullPath)} loading={loadingFolders.has(fileOrFolder.fullPath)}
onToggle={toggleCollapseState} onToggle={toggleCollapseState}
onHover={onFolderHover} onHover={onFolderHover}
withCheckbox={withCheckboxes}
checked={isPathSelected(fileOrFolder.fullPath) && !isPartiallySelected(fileOrFolder.fullPath)}
partiallyChecked={isPartiallySelected(fileOrFolder.fullPath)}
onCheckboxChange={handleSelectionChange}
/> />
); );
} }
@@ -158,60 +290,103 @@ interface FolderProps {
loading?: boolean; loading?: boolean;
onToggle: (fullPath: string) => void; onToggle: (fullPath: string) => void;
onHover?: (fullPath: string) => void; onHover?: (fullPath: string) => void;
withCheckbox?: boolean;
checked?: boolean;
partiallyChecked?: boolean;
onCheckboxChange?: (path: string, checked: boolean) => void;
} }
const Folder = memo(({ folder, collapsed, loading, onToggle, onHover }: FolderProps) => { const Folder = memo(
const { depth, name, fullPath } = folder; ({
const FolderIconComponent = collapsed ? FolderIcon : FolderOpen; folder,
collapsed,
loading,
onToggle,
onHover,
withCheckbox,
checked,
partiallyChecked,
onCheckboxChange,
}: FolderProps) => {
const { depth, name, fullPath } = folder;
const FolderIconComponent = collapsed ? FolderIcon : FolderOpen;
const handleClick = useCallback(() => { const handleChevronClick = useCallback(
onToggle(fullPath); (e: React.MouseEvent) => {
}, [onToggle, fullPath]); e.stopPropagation();
onToggle(fullPath);
},
[onToggle, fullPath],
);
const handleMouseEnter = useCallback(() => { const handleMouseEnter = useCallback(() => {
if (collapsed) { if (collapsed) {
onHover?.(fullPath); onHover?.(fullPath);
}
}, [onHover, fullPath, collapsed]);
return (
<NodeButton
className={cn("group hover:bg-accent/50 text-foreground")}
depth={depth}
icon={
loading ? (
<Loader2 className="w-4 h-4 shrink-0 animate-spin" />
) : collapsed ? (
<ChevronRight className="w-4 h-4 shrink-0" />
) : (
<ChevronDown className="w-4 h-4 shrink-0" />
)
} }
onClick={handleClick} }, [onHover, fullPath, collapsed]);
onMouseEnter={handleMouseEnter}
> const handleCheckboxChange = useCallback(
<FolderIconComponent className="w-4 h-4 shrink-0 text-strong-accent" /> (value: boolean) => {
<span className="truncate">{name}</span> onCheckboxChange?.(fullPath, value);
</NodeButton> },
); [onCheckboxChange, fullPath],
}); );
return (
<NodeButton
className={cn("group hover:bg-accent/50 text-foreground")}
depth={depth}
icon={
loading ? (
<Loader2 className="w-4 h-4 shrink-0 animate-spin" />
) : collapsed ? (
<ChevronRight className="w-4 h-4 shrink-0 cursor-pointer" onClick={handleChevronClick} />
) : (
<ChevronDown className="w-4 h-4 shrink-0 cursor-pointer" onClick={handleChevronClick} />
)
}
onMouseEnter={handleMouseEnter}
>
{withCheckbox && (
<Checkbox
checked={checked ? true : partiallyChecked ? "indeterminate" : false}
onCheckedChange={handleCheckboxChange}
onClick={(e) => e.stopPropagation()}
/>
)}
<FolderIconComponent className="w-4 h-4 shrink-0 text-strong-accent" />
<span className="truncate">{name}</span>
</NodeButton>
);
},
);
interface FileProps { interface FileProps {
file: FileNode; file: FileNode;
selected: boolean; selected: boolean;
onFileSelect: (filePath: string) => void; onFileSelect: (filePath: string) => void;
withCheckbox?: boolean;
checked?: boolean;
onCheckboxChange?: (path: string, checked: boolean) => void;
} }
const File = memo(({ file, onFileSelect, selected }: FileProps) => { const File = memo(({ file, onFileSelect, selected, withCheckbox, checked, onCheckboxChange }: FileProps) => {
const { depth, name, fullPath } = file; const { depth, name, fullPath } = file;
const handleClick = useCallback(() => { const handleClick = useCallback(() => {
onFileSelect(fullPath); onFileSelect(fullPath);
}, [onFileSelect, fullPath]); }, [onFileSelect, fullPath]);
const handleCheckboxChange = useCallback(
(value: boolean) => {
onCheckboxChange?.(fullPath, value);
},
[onCheckboxChange, fullPath],
);
return ( return (
<NodeButton <NodeButton
className={cn("group", { className={cn("group cursor-pointer", {
"hover:bg-accent/50 text-foreground": !selected, "hover:bg-accent/50 text-foreground": !selected,
"bg-accent text-accent-foreground": selected, "bg-accent text-accent-foreground": selected,
})} })}
@@ -219,6 +394,9 @@ const File = memo(({ file, onFileSelect, selected }: FileProps) => {
icon={<FileIcon className="w-4 h-4 shrink-0 text-gray-500" />} icon={<FileIcon className="w-4 h-4 shrink-0 text-gray-500" />}
onClick={handleClick} onClick={handleClick}
> >
{withCheckbox && (
<Checkbox checked={checked} onCheckedChange={handleCheckboxChange} onClick={(e) => e.stopPropagation()} />
)}
<span className="truncate">{name}</span> <span className="truncate">{name}</span>
</NodeButton> </NodeButton>
); );
@@ -267,10 +445,14 @@ interface FolderNode extends BaseNode {
kind: "folder"; kind: "folder";
} }
function buildFileList(files: FileEntry[]): Node[] { function buildFileList(files: FileEntry[], foldersOnly = false): Node[] {
const fileMap = new Map<string, Node>(); const fileMap = new Map<string, Node>();
for (const file of files) { for (const file of files) {
if (foldersOnly && file.type === "file") {
continue;
}
const segments = file.path.split("/").filter((segment) => segment); const segments = file.path.split("/").filter((segment) => segment);
const depth = segments.length - 1; const depth = segments.length - 1;
const name = segments[segments.length - 1]; const name = segments[segments.length - 1];

View File

@@ -0,0 +1,30 @@
import * as React from "react"
import * as CheckboxPrimitive from "@radix-ui/react-checkbox"
import { CheckIcon } from "lucide-react"
import { cn } from "~/lib/utils"
function Checkbox({
className,
...props
}: React.ComponentProps<typeof CheckboxPrimitive.Root>) {
return (
<CheckboxPrimitive.Root
data-slot="checkbox"
className={cn(
"peer border-input dark:bg-input/30 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground dark:data-[state=checked]:bg-primary data-[state=checked]:border-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive size-4 shrink-0 rounded-[4px] border shadow-xs transition-shadow outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...props}
>
<CheckboxPrimitive.Indicator
data-slot="checkbox-indicator"
className="grid place-content-center text-current transition-none"
>
<CheckIcon className="size-3.5" />
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
)
}
export { Checkbox }

View File

@@ -0,0 +1,165 @@
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { FolderOpen } from "lucide-react";
import { useCallback, useMemo, useState } from "react";
import { listFiles } from "~/api-client";
import { listFilesOptions } from "~/api-client/@tanstack/react-query.gen";
import { FileTree } from "~/components/file-tree";
interface FileEntry {
name: string;
path: string;
type: "file" | "directory";
size?: number;
modifiedAt?: number;
}
type VolumeFileBrowserProps = {
volumeName: string;
enabled?: boolean;
refetchInterval?: number | false;
withCheckboxes?: boolean;
selectedPaths?: Set<string>;
onSelectionChange?: (paths: Set<string>) => void;
foldersOnly?: boolean;
className?: string;
emptyMessage?: string;
emptyDescription?: string;
};
export const VolumeFileBrowser = ({
volumeName,
enabled = true,
refetchInterval,
withCheckboxes = false,
selectedPaths,
onSelectionChange,
foldersOnly = false,
className,
emptyMessage = "This volume appears to be empty.",
emptyDescription,
}: VolumeFileBrowserProps) => {
const queryClient = useQueryClient();
const [expandedFolders, setExpandedFolders] = useState<Set<string>>(new Set());
const [fetchedFolders, setFetchedFolders] = useState<Set<string>>(new Set(["/"]));
const [loadingFolders, setLoadingFolders] = useState<Set<string>>(new Set());
const [allFiles, setAllFiles] = useState<Map<string, FileEntry>>(new Map());
const { data, isLoading, error } = useQuery({
...listFilesOptions({ path: { name: volumeName } }),
enabled,
refetchInterval,
});
useMemo(() => {
if (data?.files) {
setAllFiles((prev) => {
const next = new Map(prev);
for (const file of data.files) {
next.set(file.path, file);
}
return next;
});
}
}, [data]);
const fileArray = useMemo(() => Array.from(allFiles.values()), [allFiles]);
const handleFolderExpand = useCallback(
async (folderPath: string) => {
setExpandedFolders((prev) => {
const next = new Set(prev);
next.add(folderPath);
return next;
});
if (!fetchedFolders.has(folderPath)) {
setLoadingFolders((prev) => new Set(prev).add(folderPath));
try {
const result = await listFiles({
path: { name: volumeName },
query: { path: folderPath },
throwOnError: true,
});
if (result.data.files) {
setAllFiles((prev) => {
const next = new Map(prev);
for (const file of result.data.files) {
next.set(file.path, file);
}
return next;
});
setFetchedFolders((prev) => new Set(prev).add(folderPath));
}
} catch (error) {
console.error("Failed to fetch folder contents:", error);
} finally {
setLoadingFolders((prev) => {
const next = new Set(prev);
next.delete(folderPath);
return next;
});
}
}
},
[volumeName, fetchedFolders],
);
const handleFolderHover = useCallback(
(folderPath: string) => {
if (!fetchedFolders.has(folderPath) && !loadingFolders.has(folderPath)) {
queryClient.prefetchQuery(
listFilesOptions({
path: { name: volumeName },
query: { path: folderPath },
}),
);
}
},
[volumeName, fetchedFolders, loadingFolders, queryClient],
);
if (isLoading && fileArray.length === 0) {
return (
<div className="flex items-center justify-center h-full min-h-[200px]">
<p className="text-muted-foreground">Loading files...</p>
</div>
);
}
if (error) {
return (
<div className="flex items-center justify-center h-full min-h-[200px]">
<p className="text-destructive">Failed to load files: {(error as Error).message}</p>
</div>
);
}
if (fileArray.length === 0) {
return (
<div className="flex flex-col items-center justify-center h-full text-center p-8 min-h-[200px]">
<FolderOpen className="mb-4 h-12 w-12 text-muted-foreground" />
<p className="text-muted-foreground">{emptyMessage}</p>
{emptyDescription && <p className="text-sm text-muted-foreground mt-2">{emptyDescription}</p>}
</div>
);
}
return (
<div className={className}>
<FileTree
files={fileArray}
onFolderExpand={handleFolderExpand}
onFolderHover={handleFolderHover}
expandedFolders={expandedFolders}
loadingFolders={loadingFolders}
withCheckboxes={withCheckboxes}
selectedPaths={selectedPaths}
onSelectionChange={onSelectionChange}
foldersOnly={foldersOnly}
/>
</div>
);
};

View File

@@ -1,6 +1,7 @@
import { arktypeResolver } from "@hookform/resolvers/arktype"; import { arktypeResolver } from "@hookform/resolvers/arktype";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { type } from "arktype"; import { type } from "arktype";
import { useCallback, useState } from "react";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { listRepositoriesOptions } from "~/api-client/@tanstack/react-query.gen"; import { listRepositoriesOptions } from "~/api-client/@tanstack/react-query.gen";
import { RepositoryIcon } from "~/components/repository-icon"; import { RepositoryIcon } from "~/components/repository-icon";
@@ -8,6 +9,7 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "~/com
import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from "~/components/ui/form"; import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from "~/components/ui/form";
import { Input } from "~/components/ui/input"; import { Input } from "~/components/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "~/components/ui/select"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "~/components/ui/select";
import { VolumeFileBrowser } from "~/components/volume-file-browser";
import type { BackupSchedule, Volume } from "~/lib/types"; import type { BackupSchedule, Volume } from "~/lib/types";
import { deepClean } from "~/utils/object"; import { deepClean } from "~/utils/object";
@@ -69,6 +71,7 @@ const backupScheduleToFormValues = (schedule?: BackupSchedule): BackupScheduleFo
frequency, frequency,
dailyTime, dailyTime,
weeklyDay, weeklyDay,
includePatterns: schedule.includePatterns || undefined,
...schedule.retentionPolicy, ...schedule.retentionPolicy,
}; };
}; };
@@ -86,6 +89,16 @@ export const CreateScheduleForm = ({ initialValues, formId, onSubmit, volume }:
const frequency = form.watch("frequency"); const frequency = form.watch("frequency");
const formValues = form.watch(); const formValues = form.watch();
const [selectedPaths, setSelectedPaths] = useState<Set<string>>(new Set(initialValues?.includePatterns || []));
const handleSelectionChange = useCallback(
(paths: Set<string>) => {
setSelectedPaths(paths);
form.setValue("includePatterns", Array.from(paths));
},
[form],
);
return ( return (
<Form {...form}> <Form {...form}>
<form <form
@@ -204,6 +217,38 @@ export const CreateScheduleForm = ({ initialValues, formId, onSubmit, volume }:
</CardContent> </CardContent>
</Card> </Card>
<Card>
<CardHeader>
<CardTitle>Backup paths</CardTitle>
<CardDescription>
Select which folders to include in the backup. If no paths are selected, the entire volume will be
backed up.
</CardDescription>
</CardHeader>
<CardContent>
<VolumeFileBrowser
volumeName={volume.name}
selectedPaths={selectedPaths}
onSelectionChange={handleSelectionChange}
withCheckboxes={true}
foldersOnly={true}
className="overflow-auto flex-1 border rounded-md bg-card p-2 min-h-[300px] max-h-[400px]"
/>
{selectedPaths.size > 0 && (
<div className="mt-4">
<p className="text-xs text-muted-foreground mb-2">Selected paths:</p>
<div className="flex flex-wrap gap-2">
{Array.from(selectedPaths).map((path) => (
<span key={path} className="text-xs bg-accent px-2 py-1 rounded-md font-mono">
{path}
</span>
))}
</div>
</div>
)}
</CardContent>
</Card>
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle>Retention policy</CardTitle> <CardTitle>Retention policy</CardTitle>

View File

@@ -87,6 +87,8 @@ export default function ScheduleDetailsPage() {
enabled: schedule.enabled, enabled: schedule.enabled,
cronExpression, cronExpression,
retentionPolicy: Object.keys(retentionPolicy).length > 0 ? retentionPolicy : undefined, retentionPolicy: Object.keys(retentionPolicy).length > 0 ? retentionPolicy : undefined,
includePatterns: formValues.includePatterns,
excludePatterns: formValues.excludePatterns,
}, },
}); });
}; };
@@ -101,6 +103,8 @@ export default function ScheduleDetailsPage() {
enabled, enabled,
cronExpression: schedule.cronExpression, cronExpression: schedule.cronExpression,
retentionPolicy: schedule.retentionPolicy || undefined, retentionPolicy: schedule.retentionPolicy || undefined,
includePatterns: schedule.includePatterns || undefined,
excludePatterns: schedule.excludePatterns || undefined,
}, },
}); });
}; };

View File

@@ -38,7 +38,6 @@ export const clientLoader = async () => {
export default function CreateBackup({ loaderData }: Route.ComponentProps) { export default function CreateBackup({ loaderData }: Route.ComponentProps) {
const navigate = useNavigate(); const navigate = useNavigate();
const queryClient = useQueryClient();
const formId = useId(); const formId = useId();
const [selectedVolumeId, setSelectedVolumeId] = useState<number | undefined>(); const [selectedVolumeId, setSelectedVolumeId] = useState<number | undefined>();
@@ -56,7 +55,6 @@ export default function CreateBackup({ loaderData }: Route.ComponentProps) {
...createBackupScheduleMutation(), ...createBackupScheduleMutation(),
onSuccess: (data) => { onSuccess: (data) => {
toast.success("Backup job created successfully"); toast.success("Backup job created successfully");
queryClient.invalidateQueries({ queryKey: ["listBackupSchedules"] });
navigate(`/backups/${data.id}`); navigate(`/backups/${data.id}`);
}, },
onError: (error) => { onError: (error) => {
@@ -86,6 +84,8 @@ export default function CreateBackup({ loaderData }: Route.ComponentProps) {
enabled: true, enabled: true,
cronExpression, cronExpression,
retentionPolicy: Object.keys(retentionPolicy).length > 0 ? retentionPolicy : undefined, retentionPolicy: Object.keys(retentionPolicy).length > 0 ? retentionPolicy : undefined,
includePatterns: formValues.includePatterns,
excludePatterns: formValues.excludePatterns,
}, },
}); });
}; };

View File

@@ -1,9 +1,5 @@
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { FolderOpen } from "lucide-react"; import { FolderOpen } from "lucide-react";
import { useCallback, useMemo, useState } from "react"; import { VolumeFileBrowser } from "~/components/volume-file-browser";
import { listFilesOptions } from "~/api-client/@tanstack/react-query.gen";
import { listFiles } from "~/api-client/sdk.gen";
import { FileTree } from "~/components/file-tree";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "~/components/ui/card"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "~/components/ui/card";
import type { Volume } from "~/lib/types"; import type { Volume } from "~/lib/types";
@@ -11,99 +7,7 @@ type Props = {
volume: Volume; volume: Volume;
}; };
interface FileEntry {
name: string;
path: string;
type: "file" | "directory";
size?: number;
modifiedAt?: number;
}
export const FilesTabContent = ({ volume }: Props) => { export const FilesTabContent = ({ volume }: Props) => {
const queryClient = useQueryClient();
const [expandedFolders, setExpandedFolders] = useState<Set<string>>(new Set());
const [fetchedFolders, setFetchedFolders] = useState<Set<string>>(new Set(["/"]));
const [loadingFolders, setLoadingFolders] = useState<Set<string>>(new Set());
const [allFiles, setAllFiles] = useState<Map<string, FileEntry>>(new Map());
const { data, isLoading, error } = useQuery({
...listFilesOptions({ path: { name: volume.name } }),
enabled: volume.status === "mounted",
refetchInterval: 10000,
});
useMemo(() => {
if (data?.files) {
setAllFiles((prev) => {
const next = new Map(prev);
for (const file of data.files) {
next.set(file.path, file);
}
return next;
});
}
}, [data]);
const handleFolderExpand = useCallback(
async (folderPath: string) => {
setExpandedFolders((prev) => {
const next = new Set(prev);
next.add(folderPath);
return next;
});
if (!fetchedFolders.has(folderPath)) {
setLoadingFolders((prev) => new Set(prev).add(folderPath));
try {
const result = await listFiles({
path: { name: volume.name },
query: { path: folderPath },
throwOnError: true,
});
if (result.data?.files) {
setAllFiles((prev) => {
const next = new Map(prev);
for (const file of result.data.files) {
next.set(file.path, file);
}
return next;
});
}
setFetchedFolders((prev) => new Set(prev).add(folderPath));
} catch (error) {
console.error("Failed to fetch folder contents:", error);
} finally {
setLoadingFolders((prev) => {
const next = new Set(prev);
next.delete(folderPath);
return next;
});
}
}
},
[volume.name, fetchedFolders],
);
// Prefetch folder contents on hover
const handleFolderHover = useCallback(
(folderPath: string) => {
if (!fetchedFolders.has(folderPath) && !loadingFolders.has(folderPath)) {
queryClient.prefetchQuery(
listFilesOptions({
path: { name: volume.name },
query: { path: folderPath },
}),
);
}
},
[volume.name, fetchedFolders, loadingFolders, queryClient],
);
const fileArray = useMemo(() => Array.from(allFiles.values()), [allFiles]);
if (volume.status !== "mounted") { if (volume.status !== "mounted") {
return ( return (
<Card> <Card>
@@ -123,38 +27,14 @@ export const FilesTabContent = ({ volume }: Props) => {
<CardDescription>Browse the files and folders in this volume.</CardDescription> <CardDescription>Browse the files and folders in this volume.</CardDescription>
</CardHeader> </CardHeader>
<CardContent className="flex-1 overflow-hidden flex flex-col"> <CardContent className="flex-1 overflow-hidden flex flex-col">
{isLoading && ( <VolumeFileBrowser
<div className="flex items-center justify-center h-full"> volumeName={volume.name}
<p className="text-muted-foreground">Loading files...</p> enabled={volume.status === "mounted"}
</div> refetchInterval={10000}
)} className="overflow-auto flex-1 border rounded-md bg-card p-2"
{error && ( emptyMessage="This volume is empty."
<div className="flex items-center justify-center h-full"> emptyDescription="Files and folders will appear here once you add them."
<p className="text-destructive">Failed to load files: {error.message}</p> />
</div>
)}
{!isLoading && !error && (
<div className="overflow-auto flex-1 border rounded-md bg-card">
{fileArray.length === 0 ? (
<div className="flex flex-col items-center justify-center h-full text-center p-8">
<FolderOpen className="mb-4 h-12 w-12 text-muted-foreground" />
<p className="text-muted-foreground">This volume is empty.</p>
<p className="text-sm text-muted-foreground mt-2">
Files and folders will appear here once you add them.
</p>
</div>
) : (
<FileTree
files={fileArray}
onFolderExpand={handleFolderExpand}
onFolderHover={handleFolderHover}
expandedFolders={expandedFolders}
loadingFolders={loadingFolders}
className="p-2"
/>
)}
</div>
)}
</CardContent> </CardContent>
</Card> </Card>
); );

View File

@@ -12,6 +12,7 @@
"@hookform/resolvers": "^5.2.2", "@hookform/resolvers": "^5.2.2",
"@ironmount/schemas": "workspace:*", "@ironmount/schemas": "workspace:*",
"@radix-ui/react-alert-dialog": "^1.1.15", "@radix-ui/react-alert-dialog": "^1.1.15",
"@radix-ui/react-checkbox": "^1.3.3",
"@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-label": "^2.1.7", "@radix-ui/react-label": "^2.1.7",
"@radix-ui/react-scroll-area": "^1.2.10", "@radix-ui/react-scroll-area": "^1.2.10",

View File

@@ -83,7 +83,7 @@ export const repositoriesController = new Hono()
const result = await repositoriesService.listSnapshotFiles(name, snapshotId, path); const result = await repositoriesService.listSnapshotFiles(name, snapshotId, path);
c.header("Cache-Control", "max-age=300, stale-while-revalidate=600"); // c.header("Cache-Control", "max-age=300, stale-while-revalidate=600");
return c.json<ListSnapshotFilesDto>(result, 200); return c.json<ListSnapshotFilesDto>(result, 200);
}, },

View File

@@ -8,6 +8,7 @@ import { REPOSITORY_BASE, RESTIC_PASS_FILE } from "../core/constants";
import { logger } from "./logger"; import { logger } from "./logger";
import { cryptoUtils } from "./crypto"; import { cryptoUtils } from "./crypto";
import type { RetentionPolicy } from "../modules/backups/backups.dto"; import type { RetentionPolicy } from "../modules/backups/backups.dto";
import { getVolumePath } from "../modules/volumes/helpers";
const backupOutputSchema = type({ const backupOutputSchema = type({
message_type: "'summary'", message_type: "'summary'",
@@ -119,7 +120,20 @@ const backup = async (
const repoUrl = buildRepoUrl(config); const repoUrl = buildRepoUrl(config);
const env = await buildEnv(config); const env = await buildEnv(config);
const args: string[] = ["--repo", repoUrl, "backup", source]; const args: string[] = ["--repo", repoUrl, "backup", "--one-file-system"];
let includeFile: string | null = null;
if (options?.include && options.include.length > 0) {
const tmp = await fs.mkdtemp("restic-include");
includeFile = path.join(tmp, `include.txt`);
const includePaths = options.include.map((p) => path.join(source, p));
await fs.writeFile(includeFile, includePaths.join("\n"), "utf-8");
args.push("--files-from", includeFile);
} else {
args.push(source);
}
if (options?.exclude && options.exclude.length > 0) { if (options?.exclude && options.exclude.length > 0) {
for (const pattern of options.exclude) { for (const pattern of options.exclude) {
@@ -127,17 +141,15 @@ const backup = async (
} }
} }
if (options?.include && options.include.length > 0) {
for (const pattern of options.include) {
args.push("--include", pattern);
}
}
args.push("--json"); args.push("--json");
await $`restic unlock --repo ${repoUrl}`.env(env).nothrow(); await $`restic unlock --repo ${repoUrl}`.env(env).nothrow();
const res = await $`restic ${args}`.env(env).nothrow(); const res = await $`restic ${args}`.env(env).nothrow();
if (includeFile) {
await fs.unlink(includeFile).catch(() => {});
}
if (res.exitCode !== 0) { if (res.exitCode !== 0) {
logger.error(`Restic backup failed: ${res.stderr}`); logger.error(`Restic backup failed: ${res.stderr}`);
throw new Error(`Restic backup failed: ${res.stderr}`); throw new Error(`Restic backup failed: ${res.stderr}`);
@@ -186,10 +198,12 @@ const restore = async (
args[args.length - 4] = `${snapshotId}:${options.path}`; args[args.length - 4] = `${snapshotId}:${options.path}`;
} }
// Create a temporary file for include patterns if provided
let includeFile: string | null = null;
if (options?.include && options.include.length > 0) { if (options?.include && options.include.length > 0) {
for (const pattern of options.include) { includeFile = `/tmp/restic-include-${crypto.randomBytes(8).toString("hex")}.txt`;
args.push("--include", pattern); await fs.writeFile(includeFile, options.include.join("\n"), "utf-8");
} args.push("--include", includeFile);
} }
if (options?.exclude && options.exclude.length > 0) { if (options?.exclude && options.exclude.length > 0) {
@@ -202,6 +216,11 @@ const restore = async (
const res = await $`restic ${args}`.env(env).nothrow(); const res = await $`restic ${args}`.env(env).nothrow();
// Clean up the temporary include file
if (includeFile) {
await fs.unlink(includeFile).catch(() => {});
}
if (res.exitCode !== 0) { if (res.exitCode !== 0) {
logger.error(`Restic restore failed: ${res.stderr}`); logger.error(`Restic restore failed: ${res.stderr}`);
throw new Error(`Restic restore failed: ${res.stderr}`); throw new Error(`Restic restore failed: ${res.stderr}`);

View File

@@ -16,6 +16,7 @@
"@hookform/resolvers": "^5.2.2", "@hookform/resolvers": "^5.2.2",
"@ironmount/schemas": "workspace:*", "@ironmount/schemas": "workspace:*",
"@radix-ui/react-alert-dialog": "^1.1.15", "@radix-ui/react-alert-dialog": "^1.1.15",
"@radix-ui/react-checkbox": "^1.3.3",
"@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-label": "^2.1.7", "@radix-ui/react-label": "^2.1.7",
"@radix-ui/react-scroll-area": "^1.2.10", "@radix-ui/react-scroll-area": "^1.2.10",
@@ -344,6 +345,8 @@
"@radix-ui/react-arrow": ["@radix-ui/react-arrow@1.1.7", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-F+M1tLhO+mlQaOWspE8Wstg+z6PwxwRd8oQ8IXceWz92kfAmalTRf0EjrouQeo7QssEPfCn05B4Ihs1K9WQ/7w=="], "@radix-ui/react-arrow": ["@radix-ui/react-arrow@1.1.7", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-F+M1tLhO+mlQaOWspE8Wstg+z6PwxwRd8oQ8IXceWz92kfAmalTRf0EjrouQeo7QssEPfCn05B4Ihs1K9WQ/7w=="],
"@radix-ui/react-checkbox": ["@radix-ui/react-checkbox@1.3.3", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-previous": "1.1.1", "@radix-ui/react-use-size": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-wBbpv+NQftHDdG86Qc0pIyXk5IR3tM8Vd0nWLKDcX8nNn4nXFOFwsKuqw2okA/1D/mpaAkmuyndrPJTYDNZtFw=="],
"@radix-ui/react-collection": ["@radix-ui/react-collection@1.1.7", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-Fh9rGN0MoI4ZFUNyfFVNU4y9LUz93u9/0K+yLgA2bwRojxM8JU1DyvvMBabnZPBgMWREAJvU2jjVzq+LrFUglw=="], "@radix-ui/react-collection": ["@radix-ui/react-collection@1.1.7", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-Fh9rGN0MoI4ZFUNyfFVNU4y9LUz93u9/0K+yLgA2bwRojxM8JU1DyvvMBabnZPBgMWREAJvU2jjVzq+LrFUglw=="],
"@radix-ui/react-compose-refs": ["@radix-ui/react-compose-refs@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg=="], "@radix-ui/react-compose-refs": ["@radix-ui/react-compose-refs@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg=="],

View File

@@ -14,8 +14,8 @@ services:
volumes: volumes:
- /var/run/docker.sock:/var/run/docker.sock - /var/run/docker.sock:/var/run/docker.sock
- /run/docker/plugins:/run/docker/plugins - /run/docker/plugins:/run/docker/plugins
# - /proc:/host/proc:ro - /proc:/host/proc:ro
- ./data:/data - ironmount_data:/data
- ./apps/client/app:/app/apps/client/app - ./apps/client/app:/app/apps/client/app
- ./apps/server/src:/app/apps/server/src - ./apps/server/src:/app/apps/server/src