mirror of
https://github.com/nicotsx/ironmount.git
synced 2025-12-10 12:10:51 +01:00
feat: select folders to backup when creating a job
This commit is contained in:
@@ -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];
|
||||||
|
|||||||
30
apps/client/app/components/ui/checkbox.tsx
Normal file
30
apps/client/app/components/ui/checkbox.tsx
Normal 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 }
|
||||||
165
apps/client/app/components/volume-file-browser.tsx
Normal file
165
apps/client/app/components/volume-file-browser.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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}`);
|
||||||
|
|||||||
3
bun.lock
3
bun.lock
@@ -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=="],
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user