From acc5f445651d143cd23ff723049402db637de055 Mon Sep 17 00:00:00 2001 From: Nicolas Meienberger Date: Sun, 2 Nov 2025 17:32:40 +0100 Subject: [PATCH] feat: select folders to backup when creating a job --- apps/client/app/components/file-tree.tsx | 256 +++++++++++++++--- apps/client/app/components/ui/checkbox.tsx | 30 ++ .../app/components/volume-file-browser.tsx | 165 +++++++++++ .../components/create-schedule-form.tsx | 45 +++ .../modules/backups/routes/backup-details.tsx | 4 + .../modules/backups/routes/create-backup.tsx | 4 +- .../client/app/modules/volumes/tabs/files.tsx | 138 +--------- apps/client/package.json | 1 + .../repositories/repositories.controller.ts | 2 +- apps/server/src/utils/restic.ts | 39 ++- bun.lock | 3 + docker-compose.yml | 4 +- 12 files changed, 510 insertions(+), 181 deletions(-) create mode 100644 apps/client/app/components/ui/checkbox.tsx create mode 100644 apps/client/app/components/volume-file-browser.tsx diff --git a/apps/client/app/components/file-tree.tsx b/apps/client/app/components/file-tree.tsx index 05f760f..6c50538 100644 --- a/apps/client/app/components/file-tree.tsx +++ b/apps/client/app/components/file-tree.tsx @@ -11,6 +11,7 @@ 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 { cn } from "~/lib/utils"; +import { Checkbox } from "~/components/ui/checkbox"; const NODE_PADDING_LEFT = 12; @@ -31,6 +32,10 @@ interface Props { expandedFolders?: Set; loadingFolders?: Set; className?: string; + withCheckboxes?: boolean; + selectedPaths?: Set; + onSelectionChange?: (selectedPaths: Set) => void; + foldersOnly?: boolean; } export const FileTree = memo((props: Props) => { @@ -43,11 +48,15 @@ export const FileTree = memo((props: Props) => { expandedFolders = new Set(), loadingFolders = new Set(), className, + withCheckboxes = false, + selectedPaths = new Set(), + onSelectionChange, + foldersOnly = false, } = props; const fileList = useMemo(() => { - return buildFileList(files); - }, [files]); + return buildFileList(files, foldersOnly); + }, [files, foldersOnly]); const [collapsedFolders, setCollapsedFolders] = useState>(new Set()); @@ -117,6 +126,122 @@ export const FileTree = memo((props: Props) => { [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 (
{filteredFileList.map((fileOrFolder) => { @@ -128,6 +253,9 @@ export const FileTree = memo((props: Props) => { selected={selectedFile === fileOrFolder.fullPath} file={fileOrFolder} 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)} onToggle={toggleCollapseState} 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; onToggle: (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 { depth, name, fullPath } = folder; - const FolderIconComponent = collapsed ? FolderIcon : FolderOpen; +const Folder = memo( + ({ + folder, + collapsed, + loading, + onToggle, + onHover, + withCheckbox, + checked, + partiallyChecked, + onCheckboxChange, + }: FolderProps) => { + const { depth, name, fullPath } = folder; + const FolderIconComponent = collapsed ? FolderIcon : FolderOpen; - const handleClick = useCallback(() => { - onToggle(fullPath); - }, [onToggle, fullPath]); + const handleChevronClick = useCallback( + (e: React.MouseEvent) => { + e.stopPropagation(); + onToggle(fullPath); + }, + [onToggle, fullPath], + ); - const handleMouseEnter = useCallback(() => { - if (collapsed) { - onHover?.(fullPath); - } - }, [onHover, fullPath, collapsed]); - - return ( - - ) : collapsed ? ( - - ) : ( - - ) + const handleMouseEnter = useCallback(() => { + if (collapsed) { + onHover?.(fullPath); } - onClick={handleClick} - onMouseEnter={handleMouseEnter} - > - - {name} - - ); -}); + }, [onHover, fullPath, collapsed]); + + const handleCheckboxChange = useCallback( + (value: boolean) => { + onCheckboxChange?.(fullPath, value); + }, + [onCheckboxChange, fullPath], + ); + + return ( + + ) : collapsed ? ( + + ) : ( + + ) + } + onMouseEnter={handleMouseEnter} + > + {withCheckbox && ( + e.stopPropagation()} + /> + )} + + {name} + + ); + }, +); interface FileProps { file: FileNode; selected: boolean; 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 handleClick = useCallback(() => { onFileSelect(fullPath); }, [onFileSelect, fullPath]); + const handleCheckboxChange = useCallback( + (value: boolean) => { + onCheckboxChange?.(fullPath, value); + }, + [onCheckboxChange, fullPath], + ); + return ( { icon={} onClick={handleClick} > + {withCheckbox && ( + e.stopPropagation()} /> + )} {name} ); @@ -267,10 +445,14 @@ interface FolderNode extends BaseNode { kind: "folder"; } -function buildFileList(files: FileEntry[]): Node[] { +function buildFileList(files: FileEntry[], foldersOnly = false): Node[] { const fileMap = new Map(); for (const file of files) { + if (foldersOnly && file.type === "file") { + continue; + } + const segments = file.path.split("/").filter((segment) => segment); const depth = segments.length - 1; const name = segments[segments.length - 1]; diff --git a/apps/client/app/components/ui/checkbox.tsx b/apps/client/app/components/ui/checkbox.tsx new file mode 100644 index 0000000..bd6c4df --- /dev/null +++ b/apps/client/app/components/ui/checkbox.tsx @@ -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) { + return ( + + + + + + ) +} + +export { Checkbox } diff --git a/apps/client/app/components/volume-file-browser.tsx b/apps/client/app/components/volume-file-browser.tsx new file mode 100644 index 0000000..228aea3 --- /dev/null +++ b/apps/client/app/components/volume-file-browser.tsx @@ -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; + onSelectionChange?: (paths: Set) => 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>(new Set()); + const [fetchedFolders, setFetchedFolders] = useState>(new Set(["/"])); + const [loadingFolders, setLoadingFolders] = useState>(new Set()); + const [allFiles, setAllFiles] = useState>(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 ( +
+

Loading files...

+
+ ); + } + + if (error) { + return ( +
+

Failed to load files: {(error as Error).message}

+
+ ); + } + + if (fileArray.length === 0) { + return ( +
+ +

{emptyMessage}

+ {emptyDescription &&

{emptyDescription}

} +
+ ); + } + + return ( +
+ +
+ ); +}; diff --git a/apps/client/app/modules/backups/components/create-schedule-form.tsx b/apps/client/app/modules/backups/components/create-schedule-form.tsx index 16762dd..498e10d 100644 --- a/apps/client/app/modules/backups/components/create-schedule-form.tsx +++ b/apps/client/app/modules/backups/components/create-schedule-form.tsx @@ -1,6 +1,7 @@ import { arktypeResolver } from "@hookform/resolvers/arktype"; import { useQuery } from "@tanstack/react-query"; import { type } from "arktype"; +import { useCallback, useState } from "react"; import { useForm } from "react-hook-form"; import { listRepositoriesOptions } from "~/api-client/@tanstack/react-query.gen"; 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 { Input } from "~/components/ui/input"; 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 { deepClean } from "~/utils/object"; @@ -69,6 +71,7 @@ const backupScheduleToFormValues = (schedule?: BackupSchedule): BackupScheduleFo frequency, dailyTime, weeklyDay, + includePatterns: schedule.includePatterns || undefined, ...schedule.retentionPolicy, }; }; @@ -86,6 +89,16 @@ export const CreateScheduleForm = ({ initialValues, formId, onSubmit, volume }: const frequency = form.watch("frequency"); const formValues = form.watch(); + const [selectedPaths, setSelectedPaths] = useState>(new Set(initialValues?.includePatterns || [])); + + const handleSelectionChange = useCallback( + (paths: Set) => { + setSelectedPaths(paths); + form.setValue("includePatterns", Array.from(paths)); + }, + [form], + ); + return (
+ + + Backup paths + + Select which folders to include in the backup. If no paths are selected, the entire volume will be + backed up. + + + + + {selectedPaths.size > 0 && ( +
+

Selected paths:

+
+ {Array.from(selectedPaths).map((path) => ( + + {path} + + ))} +
+
+ )} +
+
+ Retention policy diff --git a/apps/client/app/modules/backups/routes/backup-details.tsx b/apps/client/app/modules/backups/routes/backup-details.tsx index f610409..6b442e2 100644 --- a/apps/client/app/modules/backups/routes/backup-details.tsx +++ b/apps/client/app/modules/backups/routes/backup-details.tsx @@ -87,6 +87,8 @@ export default function ScheduleDetailsPage() { enabled: schedule.enabled, cronExpression, retentionPolicy: Object.keys(retentionPolicy).length > 0 ? retentionPolicy : undefined, + includePatterns: formValues.includePatterns, + excludePatterns: formValues.excludePatterns, }, }); }; @@ -101,6 +103,8 @@ export default function ScheduleDetailsPage() { enabled, cronExpression: schedule.cronExpression, retentionPolicy: schedule.retentionPolicy || undefined, + includePatterns: schedule.includePatterns || undefined, + excludePatterns: schedule.excludePatterns || undefined, }, }); }; diff --git a/apps/client/app/modules/backups/routes/create-backup.tsx b/apps/client/app/modules/backups/routes/create-backup.tsx index 0d7a60b..400147c 100644 --- a/apps/client/app/modules/backups/routes/create-backup.tsx +++ b/apps/client/app/modules/backups/routes/create-backup.tsx @@ -38,7 +38,6 @@ export const clientLoader = async () => { export default function CreateBackup({ loaderData }: Route.ComponentProps) { const navigate = useNavigate(); - const queryClient = useQueryClient(); const formId = useId(); const [selectedVolumeId, setSelectedVolumeId] = useState(); @@ -56,7 +55,6 @@ export default function CreateBackup({ loaderData }: Route.ComponentProps) { ...createBackupScheduleMutation(), onSuccess: (data) => { toast.success("Backup job created successfully"); - queryClient.invalidateQueries({ queryKey: ["listBackupSchedules"] }); navigate(`/backups/${data.id}`); }, onError: (error) => { @@ -86,6 +84,8 @@ export default function CreateBackup({ loaderData }: Route.ComponentProps) { enabled: true, cronExpression, retentionPolicy: Object.keys(retentionPolicy).length > 0 ? retentionPolicy : undefined, + includePatterns: formValues.includePatterns, + excludePatterns: formValues.excludePatterns, }, }); }; diff --git a/apps/client/app/modules/volumes/tabs/files.tsx b/apps/client/app/modules/volumes/tabs/files.tsx index 93264ff..1e13028 100644 --- a/apps/client/app/modules/volumes/tabs/files.tsx +++ b/apps/client/app/modules/volumes/tabs/files.tsx @@ -1,9 +1,5 @@ -import { useQuery, useQueryClient } from "@tanstack/react-query"; import { FolderOpen } from "lucide-react"; -import { useCallback, useMemo, useState } from "react"; -import { listFilesOptions } from "~/api-client/@tanstack/react-query.gen"; -import { listFiles } from "~/api-client/sdk.gen"; -import { FileTree } from "~/components/file-tree"; +import { VolumeFileBrowser } from "~/components/volume-file-browser"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "~/components/ui/card"; import type { Volume } from "~/lib/types"; @@ -11,99 +7,7 @@ type Props = { volume: Volume; }; -interface FileEntry { - name: string; - path: string; - type: "file" | "directory"; - size?: number; - modifiedAt?: number; -} - export const FilesTabContent = ({ volume }: Props) => { - const queryClient = useQueryClient(); - const [expandedFolders, setExpandedFolders] = useState>(new Set()); - const [fetchedFolders, setFetchedFolders] = useState>(new Set(["/"])); - const [loadingFolders, setLoadingFolders] = useState>(new Set()); - const [allFiles, setAllFiles] = useState>(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") { return ( @@ -123,38 +27,14 @@ export const FilesTabContent = ({ volume }: Props) => { Browse the files and folders in this volume. - {isLoading && ( -
-

Loading files...

-
- )} - {error && ( -
-

Failed to load files: {error.message}

-
- )} - {!isLoading && !error && ( -
- {fileArray.length === 0 ? ( -
- -

This volume is empty.

-

- Files and folders will appear here once you add them. -

-
- ) : ( - - )} -
- )} +
); diff --git a/apps/client/package.json b/apps/client/package.json index b808ea2..9abd821 100644 --- a/apps/client/package.json +++ b/apps/client/package.json @@ -12,6 +12,7 @@ "@hookform/resolvers": "^5.2.2", "@ironmount/schemas": "workspace:*", "@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-label": "^2.1.7", "@radix-ui/react-scroll-area": "^1.2.10", diff --git a/apps/server/src/modules/repositories/repositories.controller.ts b/apps/server/src/modules/repositories/repositories.controller.ts index 225cd79..7685caa 100644 --- a/apps/server/src/modules/repositories/repositories.controller.ts +++ b/apps/server/src/modules/repositories/repositories.controller.ts @@ -83,7 +83,7 @@ export const repositoriesController = new Hono() 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(result, 200); }, diff --git a/apps/server/src/utils/restic.ts b/apps/server/src/utils/restic.ts index 91982f0..a75329c 100644 --- a/apps/server/src/utils/restic.ts +++ b/apps/server/src/utils/restic.ts @@ -8,6 +8,7 @@ import { REPOSITORY_BASE, RESTIC_PASS_FILE } from "../core/constants"; import { logger } from "./logger"; import { cryptoUtils } from "./crypto"; import type { RetentionPolicy } from "../modules/backups/backups.dto"; +import { getVolumePath } from "../modules/volumes/helpers"; const backupOutputSchema = type({ message_type: "'summary'", @@ -119,7 +120,20 @@ const backup = async ( const repoUrl = buildRepoUrl(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) { 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"); await $`restic unlock --repo ${repoUrl}`.env(env).nothrow(); const res = await $`restic ${args}`.env(env).nothrow(); + if (includeFile) { + await fs.unlink(includeFile).catch(() => {}); + } + if (res.exitCode !== 0) { logger.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}`; } + // Create a temporary file for include patterns if provided + let includeFile: string | null = null; if (options?.include && options.include.length > 0) { - for (const pattern of options.include) { - args.push("--include", pattern); - } + includeFile = `/tmp/restic-include-${crypto.randomBytes(8).toString("hex")}.txt`; + await fs.writeFile(includeFile, options.include.join("\n"), "utf-8"); + args.push("--include", includeFile); } if (options?.exclude && options.exclude.length > 0) { @@ -202,6 +216,11 @@ const restore = async ( 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) { logger.error(`Restic restore failed: ${res.stderr}`); throw new Error(`Restic restore failed: ${res.stderr}`); diff --git a/bun.lock b/bun.lock index c80f7e6..ba3f823 100644 --- a/bun.lock +++ b/bun.lock @@ -16,6 +16,7 @@ "@hookform/resolvers": "^5.2.2", "@ironmount/schemas": "workspace:*", "@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-label": "^2.1.7", "@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-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-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=="], diff --git a/docker-compose.yml b/docker-compose.yml index 9913599..dc5f3cd 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -14,8 +14,8 @@ services: volumes: - /var/run/docker.sock:/var/run/docker.sock - /run/docker/plugins:/run/docker/plugins - # - /proc:/host/proc:ro - - ./data:/data + - /proc:/host/proc:ro + - ironmount_data:/data - ./apps/client/app:/app/apps/client/app - ./apps/server/src:/app/apps/server/src