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 { 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<string>;
|
||||
loadingFolders?: Set<string>;
|
||||
className?: string;
|
||||
withCheckboxes?: boolean;
|
||||
selectedPaths?: Set<string>;
|
||||
onSelectionChange?: (selectedPaths: Set<string>) => 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<Set<string>>(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 (
|
||||
<div className={cn("text-sm", className)}>
|
||||
{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,15 +290,34 @@ 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 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(() => {
|
||||
const handleChevronClick = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
onToggle(fullPath);
|
||||
}, [onToggle, fullPath]);
|
||||
},
|
||||
[onToggle, fullPath],
|
||||
);
|
||||
|
||||
const handleMouseEnter = useCallback(() => {
|
||||
if (collapsed) {
|
||||
@@ -174,6 +325,13 @@ const Folder = memo(({ folder, collapsed, loading, onToggle, onHover }: FolderPr
|
||||
}
|
||||
}, [onHover, fullPath, collapsed]);
|
||||
|
||||
const handleCheckboxChange = useCallback(
|
||||
(value: boolean) => {
|
||||
onCheckboxChange?.(fullPath, value);
|
||||
},
|
||||
[onCheckboxChange, fullPath],
|
||||
);
|
||||
|
||||
return (
|
||||
<NodeButton
|
||||
className={cn("group hover:bg-accent/50 text-foreground")}
|
||||
@@ -182,36 +340,53 @@ const Folder = memo(({ folder, collapsed, loading, onToggle, onHover }: FolderPr
|
||||
loading ? (
|
||||
<Loader2 className="w-4 h-4 shrink-0 animate-spin" />
|
||||
) : collapsed ? (
|
||||
<ChevronRight className="w-4 h-4 shrink-0" />
|
||||
<ChevronRight className="w-4 h-4 shrink-0 cursor-pointer" onClick={handleChevronClick} />
|
||||
) : (
|
||||
<ChevronDown className="w-4 h-4 shrink-0" />
|
||||
<ChevronDown className="w-4 h-4 shrink-0 cursor-pointer" onClick={handleChevronClick} />
|
||||
)
|
||||
}
|
||||
onClick={handleClick}
|
||||
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 {
|
||||
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 (
|
||||
<NodeButton
|
||||
className={cn("group", {
|
||||
className={cn("group cursor-pointer", {
|
||||
"hover:bg-accent/50 text-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" />}
|
||||
onClick={handleClick}
|
||||
>
|
||||
{withCheckbox && (
|
||||
<Checkbox checked={checked} onCheckedChange={handleCheckboxChange} onClick={(e) => e.stopPropagation()} />
|
||||
)}
|
||||
<span className="truncate">{name}</span>
|
||||
</NodeButton>
|
||||
);
|
||||
@@ -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<string, Node>();
|
||||
|
||||
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];
|
||||
|
||||
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 { 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<Set<string>>(new Set(initialValues?.includePatterns || []));
|
||||
|
||||
const handleSelectionChange = useCallback(
|
||||
(paths: Set<string>) => {
|
||||
setSelectedPaths(paths);
|
||||
form.setValue("includePatterns", Array.from(paths));
|
||||
},
|
||||
[form],
|
||||
);
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form
|
||||
@@ -204,6 +217,38 @@ export const CreateScheduleForm = ({ initialValues, formId, onSubmit, volume }:
|
||||
</CardContent>
|
||||
</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>
|
||||
<CardHeader>
|
||||
<CardTitle>Retention policy</CardTitle>
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
@@ -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<number | undefined>();
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
@@ -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<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") {
|
||||
return (
|
||||
<Card>
|
||||
@@ -123,38 +27,14 @@ export const FilesTabContent = ({ volume }: Props) => {
|
||||
<CardDescription>Browse the files and folders in this volume.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="flex-1 overflow-hidden flex flex-col">
|
||||
{isLoading && (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<p className="text-muted-foreground">Loading files...</p>
|
||||
</div>
|
||||
)}
|
||||
{error && (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<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"
|
||||
<VolumeFileBrowser
|
||||
volumeName={volume.name}
|
||||
enabled={volume.status === "mounted"}
|
||||
refetchInterval={10000}
|
||||
className="overflow-auto flex-1 border rounded-md bg-card p-2"
|
||||
emptyMessage="This volume is empty."
|
||||
emptyDescription="Files and folders will appear here once you add them."
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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<ListSnapshotFilesDto>(result, 200);
|
||||
},
|
||||
|
||||
@@ -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}`);
|
||||
|
||||
3
bun.lock
3
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=="],
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user