Compare commits

..

21 Commits

Author SHA1 Message Date
Nicolas Meienberger
d78b4adfd9 chore: update readme 2025-11-15 10:37:35 +01:00
Nicolas Meienberger
4d3ec524e2 chore: add all caps for dev container 2025-11-15 10:23:15 +01:00
Nicolas Meienberger
681cf5dff1 fix: hide test-connection button for directories 2025-11-15 10:15:25 +01:00
Nicolas Meienberger
31da747c2d fix: mount and unmount command not properly throwing errors 2025-11-15 10:08:16 +01:00
Nicolas Meienberger
b86081b2e8 Merge branch 'altendorfme-backup-file-path' 2025-11-15 09:51:05 +01:00
Nicolas Meienberger
3622fd57ef refactor(repository): keep the error if repo is already init 2025-11-15 09:45:04 +01:00
Nicolas Meienberger
5b1d7eff17 chore: update .gitignore 2025-11-15 09:45:04 +01:00
Nicolas Meienberger
2b3d8dffc5 Merge branch 'altendorfme-main' 2025-11-15 09:42:50 +01:00
Nicolas Meienberger
f517438a8e refactor(repository): keep the error if repo is already init 2025-11-15 09:42:29 +01:00
Nicolas Meienberger
1ddd4d701b chore: update .gitignore 2025-11-15 09:39:49 +01:00
Renan Bernordi
9a1797b8b2 backup file and folders 2025-11-14 23:21:13 -03:00
Renan Bernordi
52046c88cc support cloudflare r2 2025-11-14 22:37:27 -03:00
Nicolas Meienberger
951d9d970c chore: update readme 2025-11-14 22:44:58 +01:00
Nicolas Meienberger
ffc821af2b chore: update readme 2025-11-14 21:36:33 +01:00
Nicolas Meienberger
cfeff643c4 refactor(create-volume): from dialog to page 2025-11-14 21:23:52 +01:00
Nicolas Meienberger
c898e1ce07 refactor(create-repository): from dialog to page 2025-11-14 21:10:40 +01:00
Nicolas Meienberger
c179a16d15 refactor: small code style 2025-11-14 20:59:13 +01:00
Nicolas Meienberger
00916a1fd2 refactor(browsers): create hook for common operations 2025-11-14 20:56:06 +01:00
Nicolas Meienberger
18f863cbac chore: remove node_modules folder 2025-11-14 20:03:11 +01:00
Nicolas Meienberger
1b8595c17e fix: cookie not secure 2025-11-14 19:13:14 +01:00
Nicolas Meienberger
6e6becec3b refactor(breadcrumbs): use handler & match pattern 2025-11-13 22:28:53 +01:00
39 changed files with 650 additions and 554 deletions

3
.gitignore vendored
View File

@@ -9,3 +9,6 @@
.env .env
.turbo .turbo
CLAUDE.md CLAUDE.md
mutagen.yml.lock
notes.md

View File

@@ -6,7 +6,7 @@
</a> </a>
<br /> <br />
<figure> <figure>
<img src="https://github.com/nicotsx/ironmount/blob/main/screenshots/volume-details.png?raw=true" alt="Demo" /> <img src="https://github.com/nicotsx/ironmount/blob/main/screenshots/backup-details.png?raw=true" alt="Demo" />
<figcaption> <figcaption>
<p align="center"> <p align="center">
Backup management with scheduling and monitoring Backup management with scheduling and monitoring
@@ -36,10 +36,11 @@ In order to run Ironmount, you need to have Docker and Docker Compose installed
```yaml ```yaml
services: services:
ironmount: ironmount:
image: ghcr.io/nicotsx/ironmount:v0.6 image: ghcr.io/nicotsx/ironmount:v0.8
container_name: ironmount container_name: ironmount
restart: unless-stopped restart: unless-stopped
privileged: true cap_add:
- SYS_ADMIN
ports: ports:
- "4096:4096" - "4096:4096"
devices: devices:
@@ -67,7 +68,7 @@ If you want to track a local directory on the same server where Ironmount is run
```diff ```diff
services: services:
ironmount: ironmount:
image: ghcr.io/nicotsx/ironmount:v0.6 image: ghcr.io/nicotsx/ironmount:v0.8
container_name: ironmount container_name: ironmount
restart: unless-stopped restart: unless-stopped
cap_add: cap_add:
@@ -132,10 +133,11 @@ Ironmount can use [rclone](https://rclone.org/) to support 40+ cloud storage pro
```diff ```diff
services: services:
ironmount: ironmount:
image: ghcr.io/nicotsx/ironmount:v0.6 image: ghcr.io/nicotsx/ironmount:v0.8
container_name: ironmount container_name: ironmount
restart: unless-stopped restart: unless-stopped
privileged: true cap_add:
- SYS_ADMIN
ports: ports:
- "4096:4096" - "4096:4096"
devices: devices:
@@ -187,7 +189,7 @@ In order to enable this feature, you need to change your bind mount `/var/lib/ir
```diff ```diff
services: services:
ironmount: ironmount:
image: ghcr.io/nicotsx/ironmount:v0.6 image: ghcr.io/nicotsx/ironmount:v0.8
container_name: ironmount container_name: ironmount
restart: unless-stopped restart: unless-stopped
ports: ports:
@@ -215,7 +217,7 @@ In order to enable this feature, you need to run Ironmount with several items sh
```diff ```diff
services: services:
ironmount: ironmount:
image: ghcr.io/nicotsx/ironmount:v0.6 image: ghcr.io/nicotsx/ironmount:v0.8
container_name: ironmount container_name: ironmount
restart: unless-stopped restart: unless-stopped
cap_add: cap_add:

View File

@@ -1,4 +1,4 @@
import { Link } from "react-router"; import { Link, useMatches, type UIMatch } from "react-router";
import { import {
Breadcrumb, Breadcrumb,
BreadcrumbItem, BreadcrumbItem,
@@ -7,14 +7,38 @@ import {
BreadcrumbPage, BreadcrumbPage,
BreadcrumbSeparator, BreadcrumbSeparator,
} from "~/client/components/ui/breadcrumb"; } from "~/client/components/ui/breadcrumb";
import { useBreadcrumbs } from "~/client/lib/breadcrumbs";
export interface BreadcrumbItemData {
label: string;
href?: string;
}
interface RouteHandle {
breadcrumb?: (match: UIMatch) => BreadcrumbItemData[] | null;
}
export function AppBreadcrumb() { export function AppBreadcrumb() {
const breadcrumbs = useBreadcrumbs(); const matches = useMatches();
// Find the last match with a breadcrumb handler
const lastMatchWithBreadcrumb = [...matches].reverse().find((match) => {
const handle = match.handle as RouteHandle | undefined;
return handle?.breadcrumb;
});
if (!lastMatchWithBreadcrumb) {
return null;
}
const handle = lastMatchWithBreadcrumb.handle as RouteHandle;
const breadcrumbs = handle.breadcrumb?.(lastMatchWithBreadcrumb);
if (!breadcrumbs || breadcrumbs.length === 0) {
return null;
}
return ( return (
<Breadcrumb> <Breadcrumb>
<BreadcrumbLink asChild></BreadcrumbLink>
<BreadcrumbList> <BreadcrumbList>
{breadcrumbs.map((breadcrumb, index) => { {breadcrumbs.map((breadcrumb, index) => {
const isLast = index === breadcrumbs.length - 1; const isLast = index === breadcrumbs.length - 1;
@@ -22,14 +46,12 @@ export function AppBreadcrumb() {
return ( return (
<div key={`${breadcrumb.label}-${index}`} className="contents"> <div key={`${breadcrumb.label}-${index}`} className="contents">
<BreadcrumbItem> <BreadcrumbItem>
{isLast || breadcrumb.isCurrentPage ? ( {isLast || !breadcrumb.href ? (
<BreadcrumbPage>{breadcrumb.label}</BreadcrumbPage> <BreadcrumbPage>{breadcrumb.label}</BreadcrumbPage>
) : breadcrumb.href ? ( ) : (
<BreadcrumbLink asChild> <BreadcrumbLink asChild>
<Link to={breadcrumb.href}>{breadcrumb.label}</Link> <Link to={breadcrumb.href}>{breadcrumb.label}</Link>
</BreadcrumbLink> </BreadcrumbLink>
) : (
<BreadcrumbPage>{breadcrumb.label}</BreadcrumbPage>
)} )}
</BreadcrumbItem> </BreadcrumbItem>
{!isLast && <BreadcrumbSeparator />} {!isLast && <BreadcrumbSeparator />}

View File

@@ -1,66 +0,0 @@
import { useMutation } from "@tanstack/react-query";
import { Plus } from "lucide-react";
import { useId } from "react";
import { toast } from "sonner";
import { parseError } from "~/client/lib/errors";
import { CreateRepositoryForm } from "./create-repository-form";
import { Button } from "./ui/button";
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from "./ui/dialog";
import { ScrollArea } from "./ui/scroll-area";
import { createRepositoryMutation } from "../api-client/@tanstack/react-query.gen";
type Props = {
open: boolean;
setOpen: (open: boolean) => void;
};
export const CreateRepositoryDialog = ({ open, setOpen }: Props) => {
const formId = useId();
const create = useMutation({
...createRepositoryMutation(),
onSuccess: () => {
toast.success("Repository created successfully");
setOpen(false);
},
onError: (error) => {
toast.error("Failed to create repository", {
description: parseError(error)?.message,
});
},
});
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button>
<Plus size={16} className="mr-2" />
Create Repository
</Button>
</DialogTrigger>
<DialogContent>
<ScrollArea className="h-[500px] p-4">
<DialogHeader>
<DialogTitle>Create repository</DialogTitle>
</DialogHeader>
<CreateRepositoryForm
className="mt-4"
mode="create"
formId={formId}
onSubmit={(values) => {
create.mutate({ body: { config: values, name: values.name, compressionMode: values.compressionMode } });
}}
/>
<DialogFooter className="mt-4">
<Button type="button" variant="secondary" onClick={() => setOpen(false)}>
Cancel
</Button>
<Button type="submit" form={formId} disabled={create.isPending}>
Create
</Button>
</DialogFooter>
</ScrollArea>
</DialogContent>
</Dialog>
);
};

View File

@@ -36,6 +36,7 @@ type Props = {
const defaultValuesForType = { const defaultValuesForType = {
local: { backend: "local" as const, compressionMode: "auto" as const }, local: { backend: "local" as const, compressionMode: "auto" as const },
s3: { backend: "s3" as const, compressionMode: "auto" as const }, s3: { backend: "s3" as const, compressionMode: "auto" as const },
r2: { backend: "r2" as const, compressionMode: "auto" as const },
gcs: { backend: "gcs" as const, compressionMode: "auto" as const }, gcs: { backend: "gcs" as const, compressionMode: "auto" as const },
azure: { backend: "azure" as const, compressionMode: "auto" as const }, azure: { backend: "azure" as const, compressionMode: "auto" as const },
rclone: { backend: "rclone" as const, compressionMode: "auto" as const }, rclone: { backend: "rclone" as const, compressionMode: "auto" as const },
@@ -115,6 +116,7 @@ export const CreateRepositoryForm = ({
<SelectContent> <SelectContent>
<SelectItem value="local">Local</SelectItem> <SelectItem value="local">Local</SelectItem>
<SelectItem value="s3">S3</SelectItem> <SelectItem value="s3">S3</SelectItem>
<SelectItem value="r2">Cloudflare R2</SelectItem>
<SelectItem value="gcs">Google Cloud Storage</SelectItem> <SelectItem value="gcs">Google Cloud Storage</SelectItem>
<SelectItem value="azure">Azure Blob Storage</SelectItem> <SelectItem value="azure">Azure Blob Storage</SelectItem>
<Tooltip> <Tooltip>
@@ -222,6 +224,67 @@ export const CreateRepositoryForm = ({
</> </>
)} )}
{watchedBackend === "r2" && (
<>
<FormField
control={form.control}
name="endpoint"
render={({ field }) => (
<FormItem>
<FormLabel>Endpoint</FormLabel>
<FormControl>
<Input placeholder="<account-id>.r2.cloudflarestorage.com" {...field} />
</FormControl>
<FormDescription>R2 endpoint (without https://). Find in R2 dashboard under bucket settings.</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="bucket"
render={({ field }) => (
<FormItem>
<FormLabel>Bucket</FormLabel>
<FormControl>
<Input placeholder="my-backup-bucket" {...field} />
</FormControl>
<FormDescription>R2 bucket name for storing backups.</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="accessKeyId"
render={({ field }) => (
<FormItem>
<FormLabel>Access Key ID</FormLabel>
<FormControl>
<Input placeholder="Access Key ID from R2 API tokens" {...field} />
</FormControl>
<FormDescription>R2 API token Access Key ID (create in Cloudflare R2 dashboard).</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="secretAccessKey"
render={({ field }) => (
<FormItem>
<FormLabel>Secret Access Key</FormLabel>
<FormControl>
<Input type="password" placeholder="••••••••" {...field} />
</FormControl>
<FormDescription>R2 API token Secret Access Key (shown once when creating token).</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</>
)}
{watchedBackend === "gcs" && ( {watchedBackend === "gcs" && (
<> <>
<FormField <FormField

View File

@@ -1,66 +0,0 @@
import { useMutation } from "@tanstack/react-query";
import { Plus } from "lucide-react";
import { useId } from "react";
import { toast } from "sonner";
import { parseError } from "~/client/lib/errors";
import { CreateVolumeForm } from "./create-volume-form";
import { Button } from "./ui/button";
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from "./ui/dialog";
import { ScrollArea } from "./ui/scroll-area";
import { createVolumeMutation } from "../api-client/@tanstack/react-query.gen";
type Props = {
open: boolean;
setOpen: (open: boolean) => void;
};
export const CreateVolumeDialog = ({ open, setOpen }: Props) => {
const formId = useId();
const create = useMutation({
...createVolumeMutation(),
onSuccess: () => {
toast.success("Volume created successfully");
setOpen(false);
},
onError: (error) => {
toast.error("Failed to create volume", {
description: parseError(error)?.message,
});
},
});
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button>
<Plus size={16} className="mr-2" />
Create volume
</Button>
</DialogTrigger>
<DialogContent>
<ScrollArea className="h-[500px] p-4">
<DialogHeader>
<DialogTitle>Create volume</DialogTitle>
</DialogHeader>
<CreateVolumeForm
className="mt-4"
mode="create"
formId={formId}
onSubmit={(values) => {
create.mutate({ body: { config: values, name: values.name } });
}}
/>
<DialogFooter className="mt-4">
<Button type="button" variant="secondary" onClick={() => setOpen(false)}>
Cancel
</Button>
<Button type="submit" form={formId} disabled={create.isPending}>
Create
</Button>
</DialogFooter>
</ScrollArea>
</DialogContent>
</Dialog>
);
};

View File

@@ -536,42 +536,44 @@ export const CreateVolumeForm = ({ onSubmit, mode = "create", initialValues, for
</> </>
)} )}
<div className="space-y-3"> {watchedBackend !== "directory" && (
<div className="flex items-center gap-2"> <div className="space-y-3">
<Button <div className="flex items-center gap-2">
type="button" <Button
variant="outline" type="button"
onClick={handleTestConnection} variant="outline"
disabled={testBackendConnection.isPending} onClick={handleTestConnection}
className="flex-1" disabled={testBackendConnection.isPending}
> className="flex-1"
{testBackendConnection.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />} >
{!testBackendConnection.isPending && testMessage?.success && ( {testBackendConnection.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
<CheckCircle className="mr-2 h-4 w-4 text-green-500" /> {!testBackendConnection.isPending && testMessage?.success && (
)} <CheckCircle className="mr-2 h-4 w-4 text-green-500" />
{!testBackendConnection.isPending && testMessage && !testMessage.success && ( )}
<XCircle className="mr-2 h-4 w-4 text-red-500" /> {!testBackendConnection.isPending && testMessage && !testMessage.success && (
)} <XCircle className="mr-2 h-4 w-4 text-red-500" />
{testBackendConnection.isPending )}
? "Testing..." {testBackendConnection.isPending
: testMessage ? "Testing..."
? testMessage.success : testMessage
? "Connection Successful" ? testMessage.success
: "Test Failed" ? "Connection Successful"
: "Test Connection"} : "Test Failed"
</Button> : "Test Connection"}
</div> </Button>
{testMessage && (
<div
className={cn("text-xs p-2 rounded-md text-wrap wrap-anywhere", {
"bg-green-50 text-green-700 border border-green-200": testMessage.success,
"bg-red-50 text-red-700 border border-red-200": !testMessage.success,
})}
>
{testMessage.message}
</div> </div>
)} {testMessage && (
</div> <div
className={cn("text-xs p-2 rounded-md text-wrap wrap-anywhere", {
"bg-green-50 text-green-700 border border-green-200": testMessage.success,
"bg-red-50 text-red-700 border border-red-200": !testMessage.success,
})}
>
{testMessage.message}
</div>
)}
</div>
)}
{mode === "update" && ( {mode === "update" && (
<Button type="submit" className="w-full" loading={loading}> <Button type="submit" className="w-full" loading={loading}>
Save Changes Save Changes

View File

@@ -1,8 +1,8 @@
import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useCallback, useMemo, useState } from "react"; import { FileTree } from "./file-tree";
import { FileTree, type FileEntry } from "./file-tree";
import { ScrollArea } from "./ui/scroll-area"; import { ScrollArea } from "./ui/scroll-area";
import { browseFilesystemOptions } from "../api-client/@tanstack/react-query.gen"; import { browseFilesystemOptions } from "../api-client/@tanstack/react-query.gen";
import { useFileBrowser } from "../hooks/use-file-browser";
type Props = { type Props = {
onSelectPath: (path: string) => void; onSelectPath: (path: string) => void;
@@ -11,82 +11,23 @@ type Props = {
export const DirectoryBrowser = ({ onSelectPath, selectedPath }: Props) => { export const DirectoryBrowser = ({ onSelectPath, selectedPath }: Props) => {
const queryClient = useQueryClient(); 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 } = useQuery({ const { data, isLoading } = useQuery({
...browseFilesystemOptions({ query: { path: "/" } }), ...browseFilesystemOptions({ query: { path: "/" } }),
}); });
useMemo(() => { const fileBrowser = useFileBrowser({
if (data?.directories) { initialData: data,
setAllFiles((prev) => { isLoading,
const next = new Map(prev); fetchFolder: async (path) => {
for (const dir of data.directories) { return await queryClient.ensureQueryData(browseFilesystemOptions({ query: { path } }));
next.set(dir.path, { name: dir.name, path: dir.path, type: "folder" });
}
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 queryClient.ensureQueryData(
browseFilesystemOptions({
query: { path: folderPath },
}),
);
if (result.directories) {
setAllFiles((prev) => {
const next = new Map(prev);
for (const dir of result.directories) {
next.set(dir.path, { name: dir.name, path: dir.path, type: "folder" });
}
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;
});
}
}
}, },
[fetchedFolders, queryClient], prefetchFolder: (path) => {
); queryClient.prefetchQuery(browseFilesystemOptions({ query: { path } }));
const handleFolderHover = useCallback(
(folderPath: string) => {
if (!fetchedFolders.has(folderPath) && !loadingFolders.has(folderPath)) {
queryClient.prefetchQuery(browseFilesystemOptions({ query: { path: folderPath } }));
}
}, },
[fetchedFolders, loadingFolders, queryClient], });
);
if (isLoading && fileArray.length === 0) { if (fileBrowser.isLoading) {
return ( return (
<div className="border rounded-lg overflow-hidden"> <div className="border rounded-lg overflow-hidden">
<ScrollArea className="h-64"> <ScrollArea className="h-64">
@@ -96,7 +37,7 @@ export const DirectoryBrowser = ({ onSelectPath, selectedPath }: Props) => {
); );
} }
if (fileArray.length === 0) { if (fileBrowser.isEmpty) {
return ( return (
<div className="border rounded-lg overflow-hidden"> <div className="border rounded-lg overflow-hidden">
<ScrollArea className="h-64"> <ScrollArea className="h-64">
@@ -110,11 +51,11 @@ export const DirectoryBrowser = ({ onSelectPath, selectedPath }: Props) => {
<div className="border rounded-lg overflow-hidden"> <div className="border rounded-lg overflow-hidden">
<ScrollArea className="h-64"> <ScrollArea className="h-64">
<FileTree <FileTree
files={fileArray} files={fileBrowser.fileArray}
onFolderExpand={handleFolderExpand} onFolderExpand={fileBrowser.handleFolderExpand}
onFolderHover={handleFolderHover} onFolderHover={fileBrowser.handleFolderHover}
expandedFolders={expandedFolders} expandedFolders={fileBrowser.expandedFolders}
loadingFolders={loadingFolders} loadingFolders={fileBrowser.loadingFolders}
foldersOnly foldersOnly
selectableFolders selectableFolders
selectedFolder={selectedPath} selectedFolder={selectedPath}

View File

@@ -1,16 +1,8 @@
import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useQuery, useQueryClient } from "@tanstack/react-query";
import { FolderOpen } from "lucide-react"; import { FolderOpen } from "lucide-react";
import { useCallback, useMemo, useState } from "react";
import { FileTree } from "~/client/components/file-tree"; import { FileTree } from "~/client/components/file-tree";
import { listFilesOptions } from "../api-client/@tanstack/react-query.gen"; import { listFilesOptions } from "../api-client/@tanstack/react-query.gen";
import { useFileBrowser } from "../hooks/use-file-browser";
interface FileEntry {
name: string;
path: string;
type: "file" | "directory";
size?: number;
modifiedAt?: number;
}
type VolumeFileBrowserProps = { type VolumeFileBrowserProps = {
volumeName: string; volumeName: string;
@@ -36,89 +28,34 @@ export const VolumeFileBrowser = ({
emptyDescription, emptyDescription,
}: VolumeFileBrowserProps) => { }: VolumeFileBrowserProps) => {
const queryClient = useQueryClient(); 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({ const { data, isLoading, error } = useQuery({
...listFilesOptions({ path: { name: volumeName } }), ...listFilesOptions({ path: { name: volumeName } }),
enabled, enabled,
}); });
useMemo(() => { const fileBrowser = useFileBrowser({
if (data?.files) { initialData: data,
setAllFiles((prev) => { isLoading,
const next = new Map(prev); fetchFolder: async (path) => {
for (const file of data.files) { return await queryClient.ensureQueryData(
next.set(file.path, file); listFilesOptions({
} path: { name: volumeName },
return next; query: { path },
}); }),
} );
}, [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 queryClient.ensureQueryData(
listFilesOptions({
path: { name: volumeName },
query: { path: folderPath },
}),
);
if (result.files) {
setAllFiles((prev) => {
const next = new Map(prev);
for (const file of result.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, queryClient.ensureQueryData], prefetchFolder: (path) => {
); queryClient.prefetchQuery(
listFilesOptions({
const handleFolderHover = useCallback( path: { name: volumeName },
(folderPath: string) => { query: { path },
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) { if (fileBrowser.isLoading) {
return ( return (
<div className="flex items-center justify-center h-full min-h-[200px]"> <div className="flex items-center justify-center h-full min-h-[200px]">
<p className="text-muted-foreground">Loading files...</p> <p className="text-muted-foreground">Loading files...</p>
@@ -134,7 +71,7 @@ export const VolumeFileBrowser = ({
); );
} }
if (fileArray.length === 0) { if (fileBrowser.isEmpty) {
return ( return (
<div className="flex flex-col items-center justify-center h-full text-center p-8 min-h-[200px]"> <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" /> <FolderOpen className="mb-4 h-12 w-12 text-muted-foreground" />
@@ -147,11 +84,11 @@ export const VolumeFileBrowser = ({
return ( return (
<div className={className}> <div className={className}>
<FileTree <FileTree
files={fileArray} files={fileBrowser.fileArray}
onFolderExpand={handleFolderExpand} onFolderExpand={fileBrowser.handleFolderExpand}
onFolderHover={handleFolderHover} onFolderHover={fileBrowser.handleFolderHover}
expandedFolders={expandedFolders} expandedFolders={fileBrowser.expandedFolders}
loadingFolders={loadingFolders} loadingFolders={fileBrowser.loadingFolders}
withCheckboxes={withCheckboxes} withCheckboxes={withCheckboxes}
selectedPaths={selectedPaths} selectedPaths={selectedPaths}
onSelectionChange={onSelectionChange} onSelectionChange={onSelectionChange}

View File

@@ -0,0 +1,135 @@
import { useCallback, useMemo, useState } from "react";
import type { FileEntry } from "../components/file-tree";
type FetchFolderFn = (
path: string,
) => Promise<{ files?: FileEntry[]; directories?: Array<{ name: string; path: string }> }>;
type PathTransformFns = {
strip?: (path: string) => string;
add?: (path: string) => string;
};
type UseFileBrowserOptions = {
initialData?: { files?: FileEntry[]; directories?: Array<{ name: string; path: string }> };
isLoading?: boolean;
fetchFolder: FetchFolderFn;
prefetchFolder?: (path: string) => void;
pathTransform?: PathTransformFns;
rootPath?: string;
};
export const useFileBrowser = (props: UseFileBrowserOptions) => {
const { initialData, isLoading, fetchFolder, prefetchFolder, pathTransform, rootPath = "/" } = props;
const [expandedFolders, setExpandedFolders] = useState<Set<string>>(new Set());
const [fetchedFolders, setFetchedFolders] = useState<Set<string>>(new Set([rootPath]));
const [loadingFolders, setLoadingFolders] = useState<Set<string>>(new Set());
const [allFiles, setAllFiles] = useState<Map<string, FileEntry>>(new Map());
const stripPath = pathTransform?.strip;
const addPath = pathTransform?.add;
useMemo(() => {
if (initialData?.files) {
const files = initialData.files;
setAllFiles((prev) => {
const next = new Map(prev);
for (const file of files) {
const path = stripPath ? stripPath(file.path) : file.path;
if (path !== rootPath) {
next.set(path, { ...file, path });
}
}
return next;
});
if (rootPath) {
setFetchedFolders((prev) => new Set(prev).add(rootPath));
}
} else if (initialData?.directories) {
const directories = initialData.directories;
setAllFiles((prev) => {
const next = new Map(prev);
for (const dir of directories) {
next.set(dir.path, { name: dir.name, path: dir.path, type: "folder" });
}
return next;
});
}
}, [initialData, stripPath, rootPath]);
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 pathToFetch = addPath ? addPath(folderPath) : folderPath;
const result = await fetchFolder(pathToFetch);
if (result.files) {
const files = result.files;
setAllFiles((prev) => {
const next = new Map(prev);
for (const file of files) {
const strippedPath = stripPath ? stripPath(file.path) : file.path;
// Skip the directory itself
if (strippedPath !== folderPath) {
next.set(strippedPath, { ...file, path: strippedPath });
}
}
return next;
});
} else if (result.directories) {
const directories = result.directories;
setAllFiles((prev) => {
const next = new Map(prev);
for (const dir of directories) {
next.set(dir.path, { name: dir.name, path: dir.path, type: "folder" });
}
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;
});
}
}
},
[fetchedFolders, fetchFolder, stripPath, addPath],
);
const handleFolderHover = useCallback(
(folderPath: string) => {
if (!fetchedFolders.has(folderPath) && !loadingFolders.has(folderPath) && prefetchFolder) {
const pathToPrefetch = addPath ? addPath(folderPath) : folderPath;
prefetchFolder(pathToPrefetch);
}
},
[fetchedFolders, loadingFolders, prefetchFolder, addPath],
);
return {
fileArray,
expandedFolders,
loadingFolders,
handleFolderExpand,
handleFolderHover,
isLoading: isLoading && fileArray.length === 0,
isEmpty: fileArray.length === 0 && !isLoading,
};
};

View File

@@ -1,86 +0,0 @@
import { useLocation, useParams } from "react-router";
export interface BreadcrumbItem {
label: string;
href?: string;
isCurrentPage?: boolean;
}
/**
* Generates breadcrumb items based on the current route
* @param pathname - Current pathname from useLocation
* @param params - Route parameters from useParams
* @returns Array of breadcrumb items
*/
export function generateBreadcrumbs(pathname: string, params: Record<string, string | undefined>): BreadcrumbItem[] {
const breadcrumbs: BreadcrumbItem[] = [];
if (pathname.startsWith("/repositories")) {
breadcrumbs.push({
label: "Repositories",
href: "/repositories",
isCurrentPage: pathname === "/repositories",
});
if (pathname.startsWith("/repositories/") && params.name) {
const isSnapshotPage = !!params.snapshotId;
breadcrumbs.push({
label: params.name,
href: isSnapshotPage ? `/repositories/${params.name}` : undefined,
isCurrentPage: !isSnapshotPage,
});
if (isSnapshotPage && params.snapshotId) {
breadcrumbs.push({
label: params.snapshotId,
isCurrentPage: true,
});
}
}
return breadcrumbs;
}
if (pathname.startsWith("/backups")) {
breadcrumbs.push({
label: "Backups",
href: "/backups",
isCurrentPage: pathname === "/backups",
});
if (pathname.startsWith("/backups/") && params.id) {
breadcrumbs.push({
label: `Schedule #${params.id}`,
isCurrentPage: true,
});
}
return breadcrumbs;
}
breadcrumbs.push({
label: "Volumes",
href: "/volumes",
isCurrentPage: pathname === "/volumes",
});
if (pathname.startsWith("/volumes/") && params.name) {
breadcrumbs.push({
label: params.name,
isCurrentPage: true,
});
}
return breadcrumbs;
}
/**
* Hook to get breadcrumb data for the current route
*/
export function useBreadcrumbs(): BreadcrumbItem[] {
const location = useLocation();
const params = useParams();
return generateBreadcrumbs(location.pathname, params);
}

View File

@@ -254,7 +254,7 @@ export const CreateScheduleForm = ({ initialValues, formId, onSubmit, volume }:
<CardHeader> <CardHeader>
<CardTitle>Backup paths</CardTitle> <CardTitle>Backup paths</CardTitle>
<CardDescription> <CardDescription>
Select which folders to include in the backup. If no paths are selected, the entire volume will be Select which folders or files to include in the backup. If no paths are selected, the entire volume will be
backed up. backed up.
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
@@ -264,7 +264,7 @@ export const CreateScheduleForm = ({ initialValues, formId, onSubmit, volume }:
selectedPaths={selectedPaths} selectedPaths={selectedPaths}
onSelectionChange={handleSelectionChange} onSelectionChange={handleSelectionChange}
withCheckboxes={true} withCheckboxes={true}
foldersOnly={true} foldersOnly={false}
className="flex-1 border rounded-md bg-card p-2 min-h-[300px] max-h-[400px] overflow-auto" className="flex-1 border rounded-md bg-card p-2 min-h-[300px] max-h-[400px] overflow-auto"
/> />
{selectedPaths.size > 0 && ( {selectedPaths.size > 0 && (

View File

@@ -1,7 +1,7 @@
import { useCallback, useMemo, useState } from "react"; import { useCallback, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { FileIcon } from "lucide-react"; import { FileIcon } from "lucide-react";
import { FileTree, type FileEntry } from "~/client/components/file-tree"; import { FileTree } from "~/client/components/file-tree";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "~/client/components/ui/card"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "~/client/components/ui/card";
import { Button } from "~/client/components/ui/button"; import { Button } from "~/client/components/ui/button";
import { Checkbox } from "~/client/components/ui/checkbox"; import { Checkbox } from "~/client/components/ui/checkbox";
@@ -20,6 +20,7 @@ import { Tooltip, TooltipContent, TooltipTrigger } from "~/client/components/ui/
import type { Snapshot, Volume } from "~/client/lib/types"; import type { Snapshot, Volume } from "~/client/lib/types";
import { toast } from "sonner"; import { toast } from "sonner";
import { listSnapshotFilesOptions, restoreSnapshotMutation } from "~/client/api-client/@tanstack/react-query.gen"; import { listSnapshotFilesOptions, restoreSnapshotMutation } from "~/client/api-client/@tanstack/react-query.gen";
import { useFileBrowser } from "~/client/hooks/use-file-browser";
interface Props { interface Props {
snapshot: Snapshot; snapshot: Snapshot;
@@ -33,10 +34,6 @@ export const SnapshotFileBrowser = (props: Props) => {
const isReadOnly = volume?.config && "readOnly" in volume.config && volume.config.readOnly === true; const isReadOnly = volume?.config && "readOnly" in volume.config && volume.config.readOnly === true;
const queryClient = useQueryClient(); 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 [selectedPaths, setSelectedPaths] = useState<Set<string>>(new Set()); const [selectedPaths, setSelectedPaths] = useState<Set<string>>(new Set());
const [showRestoreDialog, setShowRestoreDialog] = useState(false); const [showRestoreDialog, setShowRestoreDialog] = useState(false);
const [deleteExtraFiles, setDeleteExtraFiles] = useState(false); const [deleteExtraFiles, setDeleteExtraFiles] = useState(false);
@@ -72,89 +69,30 @@ export const SnapshotFileBrowser = (props: Props) => {
[volumeBasePath], [volumeBasePath],
); );
useMemo(() => { const fileBrowser = useFileBrowser({
if (filesData?.files) { initialData: filesData,
setAllFiles((prev) => { isLoading: filesLoading,
const next = new Map(prev); fetchFolder: async (path) => {
for (const file of filesData.files) { return await queryClient.ensureQueryData(
const strippedPath = stripBasePath(file.path); listSnapshotFilesOptions({
if (strippedPath !== "/") { path: { name: repositoryName, snapshotId: snapshot.short_id },
next.set(strippedPath, { ...file, path: strippedPath }); query: { path },
} }),
} );
return next;
});
setFetchedFolders((prev) => new Set(prev).add("/"));
}
}, [filesData, stripBasePath]);
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 fullPath = addBasePath(folderPath);
const result = await queryClient.ensureQueryData(
listSnapshotFilesOptions({
path: { name: repositoryName, snapshotId: snapshot.short_id },
query: { path: fullPath },
}),
);
if (result.files) {
setAllFiles((prev) => {
const next = new Map(prev);
for (const file of result.files) {
const strippedPath = stripBasePath(file.path);
// Skip the directory itself
if (strippedPath !== folderPath) {
next.set(strippedPath, { ...file, path: strippedPath });
}
}
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;
});
}
}
}, },
[repositoryName, snapshot, fetchedFolders, queryClient, stripBasePath, addBasePath], prefetchFolder: (path) => {
); queryClient.prefetchQuery(
listSnapshotFilesOptions({
const handleFolderHover = useCallback( path: { name: repositoryName, snapshotId: snapshot.short_id },
(folderPath: string) => { query: { path },
if (!fetchedFolders.has(folderPath) && !loadingFolders.has(folderPath)) { }),
const fullPath = addBasePath(folderPath); );
queryClient.prefetchQuery({
...listSnapshotFilesOptions({
path: { name: repositoryName, snapshotId: snapshot.short_id },
query: { path: fullPath },
}),
});
}
}, },
[repositoryName, snapshot, fetchedFolders, loadingFolders, queryClient, addBasePath], pathTransform: {
); strip: stripBasePath,
add: addBasePath,
},
});
const { mutate: restoreSnapshot, isPending: isRestoring } = useMutation({ const { mutate: restoreSnapshot, isPending: isRestoring } = useMutation({
...restoreSnapshotMutation(), ...restoreSnapshotMutation(),
@@ -225,27 +163,27 @@ export const SnapshotFileBrowser = (props: Props) => {
</div> </div>
</CardHeader> </CardHeader>
<CardContent className="flex-1 overflow-hidden flex flex-col p-0"> <CardContent className="flex-1 overflow-hidden flex flex-col p-0">
{filesLoading && fileArray.length === 0 && ( {fileBrowser.isLoading && (
<div className="flex items-center justify-center flex-1"> <div className="flex items-center justify-center flex-1">
<p className="text-muted-foreground">Loading files...</p> <p className="text-muted-foreground">Loading files...</p>
</div> </div>
)} )}
{fileArray.length === 0 && !filesLoading && ( {fileBrowser.isEmpty && (
<div className="flex flex-col items-center justify-center flex-1 text-center p-8"> <div className="flex flex-col items-center justify-center flex-1 text-center p-8">
<FileIcon className="w-12 h-12 text-muted-foreground/50 mb-4" /> <FileIcon className="w-12 h-12 text-muted-foreground/50 mb-4" />
<p className="text-muted-foreground">No files in this snapshot</p> <p className="text-muted-foreground">No files in this snapshot</p>
</div> </div>
)} )}
{fileArray.length > 0 && ( {!fileBrowser.isLoading && !fileBrowser.isEmpty && (
<div className="overflow-auto flex-1 border border-border rounded-md bg-card m-4"> <div className="overflow-auto flex-1 border border-border rounded-md bg-card m-4">
<FileTree <FileTree
files={fileArray} files={fileBrowser.fileArray}
onFolderExpand={handleFolderExpand} onFolderExpand={fileBrowser.handleFolderExpand}
onFolderHover={handleFolderHover} onFolderHover={fileBrowser.handleFolderHover}
expandedFolders={expandedFolders} expandedFolders={fileBrowser.expandedFolders}
loadingFolders={loadingFolders} loadingFolders={fileBrowser.loadingFolders}
className="px-2 py-2" className="px-2 py-2"
withCheckboxes={true} withCheckboxes={true}
selectedPaths={selectedPaths} selectedPaths={selectedPaths}

View File

@@ -20,6 +20,13 @@ import { SnapshotFileBrowser } from "../components/snapshot-file-browser";
import { SnapshotTimeline } from "../components/snapshot-timeline"; import { SnapshotTimeline } from "../components/snapshot-timeline";
import { getBackupSchedule } from "~/client/api-client"; import { getBackupSchedule } from "~/client/api-client";
export const handle = {
breadcrumb: (match: Route.MetaArgs) => [
{ label: "Backups", href: "/backups" },
{ label: `Schedule #${match.params.id}` },
],
};
export function meta(_: Route.MetaArgs) { export function meta(_: Route.MetaArgs) {
return [ return [
{ title: "Backup Job Details" }, { title: "Backup Job Details" },

View File

@@ -9,6 +9,10 @@ import type { Route } from "./+types/backups";
import { listBackupSchedules } from "~/client/api-client"; import { listBackupSchedules } from "~/client/api-client";
import { listBackupSchedulesOptions } from "~/client/api-client/@tanstack/react-query.gen"; import { listBackupSchedulesOptions } from "~/client/api-client/@tanstack/react-query.gen";
export const handle = {
breadcrumb: () => [{ label: "Backups" }],
};
export function meta(_: Route.MetaArgs) { export function meta(_: Route.MetaArgs) {
return [ return [
{ title: "Backup Jobs" }, { title: "Backup Jobs" },

View File

@@ -18,6 +18,10 @@ import { CreateScheduleForm, type BackupScheduleFormValues } from "../components
import type { Route } from "./+types/create-backup"; import type { Route } from "./+types/create-backup";
import { listRepositories, listVolumes } from "~/client/api-client"; import { listRepositories, listVolumes } from "~/client/api-client";
export const handle = {
breadcrumb: () => [{ label: "Backups", href: "/backups" }, { label: "Create" }],
};
export function meta(_: Route.MetaArgs) { export function meta(_: Route.MetaArgs) {
return [ return [
{ title: "Create Backup Job" }, { title: "Create Backup Job" },

View File

@@ -0,0 +1,89 @@
import { useMutation } from "@tanstack/react-query";
import { Database } from "lucide-react";
import { useId } from "react";
import { useNavigate } from "react-router";
import { toast } from "sonner";
import { createRepositoryMutation } from "~/client/api-client/@tanstack/react-query.gen";
import { CreateRepositoryForm, type RepositoryFormValues } from "~/client/components/create-repository-form";
import { Button } from "~/client/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "~/client/components/ui/card";
import { parseError } from "~/client/lib/errors";
import type { Route } from "./+types/create-repository";
import { Alert, AlertDescription } from "~/client/components/ui/alert";
export const handle = {
breadcrumb: () => [{ label: "Repositories", href: "/repositories" }, { label: "Create" }],
};
export function meta(_: Route.MetaArgs) {
return [
{ title: "Create Repository" },
{
name: "description",
content: "Create a new backup repository with encryption and compression.",
},
];
}
export default function CreateRepository() {
const navigate = useNavigate();
const formId = useId();
const createRepository = useMutation({
...createRepositoryMutation(),
onSuccess: (data) => {
toast.success("Repository created successfully");
navigate(`/repositories/${data.repository.name}`);
},
});
const handleSubmit = (values: RepositoryFormValues) => {
createRepository.mutate({
body: {
config: values,
name: values.name,
compressionMode: values.compressionMode,
},
});
};
return (
<div className="container mx-auto space-y-6">
<Card>
<CardHeader>
<div className="flex items-center gap-3">
<div className="flex items-center justify-center w-10 h-10 rounded-lg bg-primary/10">
<Database className="w-5 h-5 text-primary" />
</div>
<CardTitle>Create Repository</CardTitle>
</div>
</CardHeader>
<CardContent className="space-y-6">
{createRepository.isError && (
<Alert variant="destructive">
<AlertDescription>
<strong>Failed to create repository:</strong>
<br />
{parseError(createRepository.error)?.message}
</AlertDescription>
</Alert>
)}
<CreateRepositoryForm
mode="create"
formId={formId}
onSubmit={handleSubmit}
loading={createRepository.isPending}
/>
<div className="flex justify-end gap-2 pt-4 border-t">
<Button type="button" variant="secondary" onClick={() => navigate("/repositories")}>
Cancel
</Button>
<Button type="submit" form={formId} loading={createRepository.isPending}>
Create Repository
</Button>
</div>
</CardContent>
</Card>
</div>
);
}

View File

@@ -1,10 +1,9 @@
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { Database, RotateCcw } from "lucide-react"; import { Database, Plus, RotateCcw } from "lucide-react";
import { useState } from "react"; import { useState } from "react";
import { useNavigate } from "react-router"; import { useNavigate } from "react-router";
import { listRepositories } from "~/client/api-client/sdk.gen"; import { listRepositories } from "~/client/api-client/sdk.gen";
import { listRepositoriesOptions } from "~/client/api-client/@tanstack/react-query.gen"; import { listRepositoriesOptions } from "~/client/api-client/@tanstack/react-query.gen";
import { CreateRepositoryDialog } from "~/client/components/create-repository-dialog";
import { RepositoryIcon } from "~/client/components/repository-icon"; import { RepositoryIcon } from "~/client/components/repository-icon";
import { Button } from "~/client/components/ui/button"; import { Button } from "~/client/components/ui/button";
import { Card } from "~/client/components/ui/card"; import { Card } from "~/client/components/ui/card";
@@ -15,6 +14,10 @@ import type { Route } from "./+types/repositories";
import { cn } from "~/client/lib/utils"; import { cn } from "~/client/lib/utils";
import { EmptyState } from "~/client/components/empty-state"; import { EmptyState } from "~/client/components/empty-state";
export const handle = {
breadcrumb: () => [{ label: "Repositories" }],
};
export function meta(_: Route.MetaArgs) { export function meta(_: Route.MetaArgs) {
return [ return [
{ title: "Repositories" }, { title: "Repositories" },
@@ -35,7 +38,6 @@ export default function Repositories({ loaderData }: Route.ComponentProps) {
const [searchQuery, setSearchQuery] = useState(""); const [searchQuery, setSearchQuery] = useState("");
const [statusFilter, setStatusFilter] = useState(""); const [statusFilter, setStatusFilter] = useState("");
const [backendFilter, setBackendFilter] = useState(""); const [backendFilter, setBackendFilter] = useState("");
const [createRepositoryOpen, setCreateRepositoryOpen] = useState(false);
const clearFilters = () => { const clearFilters = () => {
setSearchQuery(""); setSearchQuery("");
@@ -69,7 +71,12 @@ export default function Repositories({ loaderData }: Route.ComponentProps) {
icon={Database} icon={Database}
title="No repository" title="No repository"
description="Repositories are remote storage locations where you can backup your volumes securely. Encrypted and optimized for storage efficiency." description="Repositories are remote storage locations where you can backup your volumes securely. Encrypted and optimized for storage efficiency."
button={<CreateRepositoryDialog open={createRepositoryOpen} setOpen={setCreateRepositoryOpen} />} button={
<Button onClick={() => navigate("/repositories/create")}>
<Plus size={16} className="mr-2" />
Create Repository
</Button>
}
/> />
); );
} }
@@ -112,7 +119,10 @@ export default function Repositories({ loaderData }: Route.ComponentProps) {
</Button> </Button>
)} )}
</span> </span>
<CreateRepositoryDialog open={createRepositoryOpen} setOpen={setCreateRepositoryOpen} /> <Button onClick={() => navigate("/repositories/create")}>
<Plus size={16} className="mr-2" />
Create Repository
</Button>
</div> </div>
<div className="overflow-x-auto"> <div className="overflow-x-auto">
<Table className="border-t"> <Table className="border-t">

View File

@@ -27,6 +27,13 @@ import { RepositoryInfoTabContent } from "../tabs/info";
import { RepositorySnapshotsTabContent } from "../tabs/snapshots"; import { RepositorySnapshotsTabContent } from "../tabs/snapshots";
import { Loader2 } from "lucide-react"; import { Loader2 } from "lucide-react";
export const handle = {
breadcrumb: (match: Route.MetaArgs) => [
{ label: "Repositories", href: "/repositories" },
{ label: match.params.name },
],
};
export function meta({ params }: Route.MetaArgs) { export function meta({ params }: Route.MetaArgs) {
return [ return [
{ title: params.name }, { title: params.name },

View File

@@ -7,6 +7,14 @@ import { SnapshotFileBrowser } from "~/client/modules/backups/components/snapsho
import { getSnapshotDetails } from "~/client/api-client"; import { getSnapshotDetails } from "~/client/api-client";
import type { Route } from "./+types/snapshot-details"; import type { Route } from "./+types/snapshot-details";
export const handle = {
breadcrumb: (match: Route.MetaArgs) => [
{ label: "Repositories", href: "/repositories" },
{ label: match.params.name, href: `/repositories/${match.params.name}` },
{ label: match.params.snapshotId },
],
};
export function meta({ params }: Route.MetaArgs) { export function meta({ params }: Route.MetaArgs) {
return [ return [
{ title: `Snapshot ${params.snapshotId}` }, { title: `Snapshot ${params.snapshotId}` },

View File

@@ -24,6 +24,10 @@ import {
logoutMutation, logoutMutation,
} from "~/client/api-client/@tanstack/react-query.gen"; } from "~/client/api-client/@tanstack/react-query.gen";
export const handle = {
breadcrumb: () => [{ label: "Settings" }],
};
export function meta(_: Route.MetaArgs) { export function meta(_: Route.MetaArgs) {
return [ return [
{ title: "Settings" }, { title: "Settings" },

View File

@@ -0,0 +1,83 @@
import { useMutation } from "@tanstack/react-query";
import { HardDrive } from "lucide-react";
import { useId } from "react";
import { useNavigate } from "react-router";
import { toast } from "sonner";
import { createVolumeMutation } from "~/client/api-client/@tanstack/react-query.gen";
import { CreateVolumeForm, type FormValues } from "~/client/components/create-volume-form";
import { Button } from "~/client/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "~/client/components/ui/card";
import { parseError } from "~/client/lib/errors";
import type { Route } from "./+types/create-volume";
import { Alert, AlertDescription } from "~/client/components/ui/alert";
export const handle = {
breadcrumb: () => [{ label: "Volumes", href: "/volumes" }, { label: "Create" }],
};
export function meta(_: Route.MetaArgs) {
return [
{ title: "Create Volume" },
{
name: "description",
content: "Create a new storage volume with automatic mounting and health checks.",
},
];
}
export default function CreateVolume() {
const navigate = useNavigate();
const formId = useId();
const createVolume = useMutation({
...createVolumeMutation(),
onSuccess: (data) => {
toast.success("Volume created successfully");
navigate(`/volumes/${data.name}`);
},
});
const handleSubmit = (values: FormValues) => {
createVolume.mutate({
body: {
config: values,
name: values.name,
},
});
};
return (
<div className="container mx-auto space-y-6">
<Card>
<CardHeader>
<div className="flex items-center gap-3">
<div className="flex items-center justify-center w-10 h-10 rounded-lg bg-primary/10">
<HardDrive className="w-5 h-5 text-primary" />
</div>
<CardTitle>Create Volume</CardTitle>
</div>
</CardHeader>
<CardContent className="space-y-6">
{createVolume.isError && (
<Alert variant="destructive">
<AlertDescription>
<strong>Failed to create volume:</strong>
<br />
{parseError(createVolume.error)?.message}
</AlertDescription>
</Alert>
)}
<CreateVolumeForm mode="create" formId={formId} onSubmit={handleSubmit} loading={createVolume.isPending} />
<div className="flex justify-end gap-2 pt-4 border-t">
<Button type="button" variant="secondary" onClick={() => navigate("/volumes")}>
Cancel
</Button>
<Button type="submit" form={formId} loading={createVolume.isPending}>
Create Volume
</Button>
</div>
</CardContent>
</Card>
</div>
);
}

View File

@@ -31,6 +31,10 @@ import {
unmountVolumeMutation, unmountVolumeMutation,
} from "~/client/api-client/@tanstack/react-query.gen"; } from "~/client/api-client/@tanstack/react-query.gen";
export const handle = {
breadcrumb: (match: Route.MetaArgs) => [{ label: "Volumes", href: "/volumes" }, { label: match.params.name }],
};
export function meta({ params }: Route.MetaArgs) { export function meta({ params }: Route.MetaArgs) {
return [ return [
{ title: params.name }, { title: params.name },

View File

@@ -1,8 +1,7 @@
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { HardDrive, RotateCcw } from "lucide-react"; import { HardDrive, Plus, RotateCcw } from "lucide-react";
import { useState } from "react"; import { useState } from "react";
import { useNavigate } from "react-router"; import { useNavigate } from "react-router";
import { CreateVolumeDialog } from "~/client/components/create-volume-dialog";
import { EmptyState } from "~/client/components/empty-state"; import { EmptyState } from "~/client/components/empty-state";
import { StatusDot } from "~/client/components/status-dot"; import { StatusDot } from "~/client/components/status-dot";
import { Button } from "~/client/components/ui/button"; import { Button } from "~/client/components/ui/button";
@@ -15,6 +14,10 @@ import type { Route } from "./+types/volumes";
import { listVolumes } from "~/client/api-client"; import { listVolumes } from "~/client/api-client";
import { listVolumesOptions } from "~/client/api-client/@tanstack/react-query.gen"; import { listVolumesOptions } from "~/client/api-client/@tanstack/react-query.gen";
export const handle = {
breadcrumb: () => [{ label: "Volumes" }],
};
export function meta(_: Route.MetaArgs) { export function meta(_: Route.MetaArgs) {
return [ return [
{ title: "Volumes" }, { title: "Volumes" },
@@ -32,7 +35,6 @@ export const clientLoader = async () => {
}; };
export default function Volumes({ loaderData }: Route.ComponentProps) { export default function Volumes({ loaderData }: Route.ComponentProps) {
const [createVolumeOpen, setCreateVolumeOpen] = useState(false);
const [searchQuery, setSearchQuery] = useState(""); const [searchQuery, setSearchQuery] = useState("");
const [statusFilter, setStatusFilter] = useState(""); const [statusFilter, setStatusFilter] = useState("");
const [backendFilter, setBackendFilter] = useState(""); const [backendFilter, setBackendFilter] = useState("");
@@ -69,7 +71,12 @@ export default function Volumes({ loaderData }: Route.ComponentProps) {
icon={HardDrive} icon={HardDrive}
title="No volume" title="No volume"
description="Manage and monitor all your storage backends in one place with advanced features like automatic mounting and health checks." description="Manage and monitor all your storage backends in one place with advanced features like automatic mounting and health checks."
button={<CreateVolumeDialog open={createVolumeOpen} setOpen={setCreateVolumeOpen} />} button={
<Button onClick={() => navigate("/volumes/create")}>
<Plus size={16} className="mr-2" />
Create Volume
</Button>
}
/> />
); );
} }
@@ -111,7 +118,10 @@ export default function Volumes({ loaderData }: Route.ComponentProps) {
</Button> </Button>
)} )}
</span> </span>
<CreateVolumeDialog open={createVolumeOpen} setOpen={setCreateVolumeOpen} /> <Button onClick={() => navigate("/volumes/create")}>
<Plus size={16} className="mr-2" />
Create Volume
</Button>
</div> </div>
<div className="overflow-x-auto"> <div className="overflow-x-auto">
<Table className="border-t"> <Table className="border-t">

View File

@@ -1,9 +1,9 @@
import { redirect } from "react-router"; import { redirect } from "react-router";
export const loader = async () => {
return redirect("/volumes");
};
export const clientLoader = async () => { export const clientLoader = async () => {
return redirect("/volumes"); return redirect("/volumes");
}; };
export const loader = async () => {
return redirect("/volumes");
};

View File

@@ -7,11 +7,13 @@ export default [
layout("./client/components/layout.tsx", [ layout("./client/components/layout.tsx", [
route("/", "./client/routes/root.tsx"), route("/", "./client/routes/root.tsx"),
route("volumes", "./client/modules/volumes/routes/volumes.tsx"), route("volumes", "./client/modules/volumes/routes/volumes.tsx"),
route("volumes/create", "./client/modules/volumes/routes/create-volume.tsx"),
route("volumes/:name", "./client/modules/volumes/routes/volume-details.tsx"), route("volumes/:name", "./client/modules/volumes/routes/volume-details.tsx"),
route("backups", "./client/modules/backups/routes/backups.tsx"), route("backups", "./client/modules/backups/routes/backups.tsx"),
route("backups/create", "./client/modules/backups/routes/create-backup.tsx"), route("backups/create", "./client/modules/backups/routes/create-backup.tsx"),
route("backups/:id", "./client/modules/backups/routes/backup-details.tsx"), route("backups/:id", "./client/modules/backups/routes/backup-details.tsx"),
route("repositories", "./client/modules/repositories/routes/repositories.tsx"), route("repositories", "./client/modules/repositories/routes/repositories.tsx"),
route("repositories/create", "./client/modules/repositories/routes/create-repository.tsx"),
route("repositories/:name", "./client/modules/repositories/routes/repository-details.tsx"), route("repositories/:name", "./client/modules/repositories/routes/repository-details.tsx"),
route("repositories/:name/:snapshotId", "./client/modules/repositories/routes/snapshot-details.tsx"), route("repositories/:name/:snapshotId", "./client/modules/repositories/routes/snapshot-details.tsx"),
route("settings", "./client/modules/settings/routes/settings.tsx"), route("settings", "./client/modules/settings/routes/settings.tsx"),

1
app/schemas/node_modules/arktype generated vendored
View File

@@ -1 +0,0 @@
../../../node_modules/.bun/arktype@2.1.26/node_modules/arktype

View File

@@ -3,6 +3,7 @@ import { type } from "arktype";
export const REPOSITORY_BACKENDS = { export const REPOSITORY_BACKENDS = {
local: "local", local: "local",
s3: "s3", s3: "s3",
r2: "r2",
gcs: "gcs", gcs: "gcs",
azure: "azure", azure: "azure",
rclone: "rclone", rclone: "rclone",
@@ -18,6 +19,14 @@ export const s3RepositoryConfigSchema = type({
secretAccessKey: "string", secretAccessKey: "string",
}); });
export const r2RepositoryConfigSchema = type({
backend: "'r2'",
endpoint: "string",
bucket: "string",
accessKeyId: "string",
secretAccessKey: "string",
});
export const localRepositoryConfigSchema = type({ export const localRepositoryConfigSchema = type({
backend: "'local'", backend: "'local'",
name: "string", name: "string",
@@ -45,6 +54,7 @@ export const rcloneRepositoryConfigSchema = type({
}); });
export const repositoryConfigSchema = s3RepositoryConfigSchema export const repositoryConfigSchema = s3RepositoryConfigSchema
.or(r2RepositoryConfigSchema)
.or(localRepositoryConfigSchema) .or(localRepositoryConfigSchema)
.or(gcsRepositoryConfigSchema) .or(gcsRepositoryConfigSchema)
.or(azureRepositoryConfigSchema) .or(azureRepositoryConfigSchema)

View File

@@ -39,6 +39,14 @@ class SchedulerClass {
this.tasks = []; this.tasks = [];
logger.info("Scheduler stopped"); logger.info("Scheduler stopped");
} }
async clear() {
for (const task of this.tasks) {
task.destroy();
}
this.tasks = [];
logger.info("Scheduler cleared all tasks");
}
} }
export const Scheduler = new SchedulerClass(); export const Scheduler = new SchedulerClass();

View File

@@ -19,7 +19,9 @@ export class CleanupDanglingMountsJob extends Job {
const matchingVolume = allVolumes.find((v) => getVolumePath(v) === mount.mountPoint); const matchingVolume = allVolumes.find((v) => getVolumePath(v) === mount.mountPoint);
if (!matchingVolume) { if (!matchingVolume) {
logger.info(`Found dangling mount at ${mount.mountPoint}, attempting to unmount...`); logger.info(`Found dangling mount at ${mount.mountPoint}, attempting to unmount...`);
await executeUnmount(mount.mountPoint); await executeUnmount(mount.mountPoint).catch((err) => {
logger.warn(`Failed to unmount dangling mount ${mount.mountPoint}: ${toMessage(err)}`);
});
await fs.rmdir(path.dirname(mount.mountPoint)).catch((err) => { await fs.rmdir(path.dirname(mount.mountPoint)).catch((err) => {
logger.warn( logger.warn(

View File

@@ -25,7 +25,7 @@ import { toMessage } from "../../utils/errors";
const COOKIE_NAME = "session_id"; const COOKIE_NAME = "session_id";
const COOKIE_OPTIONS = { const COOKIE_OPTIONS = {
httpOnly: true, httpOnly: true,
secure: process.env.NODE_ENV === "production", secure: false,
sameSite: "lax" as const, sameSite: "lax" as const,
path: "/", path: "/",
}; };

View File

@@ -13,6 +13,10 @@ export const executeMount = async (args: string[]): Promise<void> => {
if (stderr?.trim()) { if (stderr?.trim()) {
logger.warn(stderr.trim()); logger.warn(stderr.trim());
} }
if (result.exitCode !== 0) {
throw new Error(`Mount command failed with exit code ${result.exitCode}: ${stderr?.trim()}`);
}
}; };
export const executeUnmount = async (path: string): Promise<void> => { export const executeUnmount = async (path: string): Promise<void> => {
@@ -24,6 +28,10 @@ export const executeUnmount = async (path: string): Promise<void> => {
if (stderr?.trim()) { if (stderr?.trim()) {
logger.warn(stderr.trim()); logger.warn(stderr.trim());
} }
if (result.exitCode !== 0) {
throw new Error(`Mount command failed with exit code ${result.exitCode}: ${stderr?.trim()}`);
}
}; };
export const createTestFile = async (path: string): Promise<void> => { export const createTestFile = async (path: string): Promise<void> => {

View File

@@ -13,6 +13,7 @@ import { CleanupSessionsJob } from "../../jobs/cleanup-sessions";
export const startup = async () => { export const startup = async () => {
await Scheduler.start(); await Scheduler.start();
await Scheduler.clear();
await restic.ensurePassfile().catch((err) => { await restic.ensurePassfile().catch((err) => {
logger.error(`Error ensuring restic passfile exists: ${err.message}`); logger.error(`Error ensuring restic passfile exists: ${err.message}`);

View File

@@ -7,6 +7,7 @@ import { repositoriesTable } from "../../db/schema";
import { toMessage } from "../../utils/errors"; import { toMessage } from "../../utils/errors";
import { restic } from "../../utils/restic"; import { restic } from "../../utils/restic";
import { cryptoUtils } from "../../utils/crypto"; import { cryptoUtils } from "../../utils/crypto";
import { logger } from "../../utils/logger";
import type { CompressionMode, RepositoryConfig } from "~/schemas/restic"; import type { CompressionMode, RepositoryConfig } from "~/schemas/restic";
const listRepositories = async () => { const listRepositories = async () => {
@@ -19,6 +20,7 @@ const encryptConfig = async (config: RepositoryConfig): Promise<RepositoryConfig
switch (config.backend) { switch (config.backend) {
case "s3": case "s3":
case "r2":
encryptedConfig.accessKeyId = await cryptoUtils.encrypt(config.accessKeyId); encryptedConfig.accessKeyId = await cryptoUtils.encrypt(config.accessKeyId);
encryptedConfig.secretAccessKey = await cryptoUtils.encrypt(config.secretAccessKey); encryptedConfig.secretAccessKey = await cryptoUtils.encrypt(config.secretAccessKey);
break; break;
@@ -80,6 +82,7 @@ const createRepository = async (name: string, config: RepositoryConfig, compress
} }
const errorMessage = toMessage(error); const errorMessage = toMessage(error);
await db.delete(repositoriesTable).where(eq(repositoriesTable.id, id)); await db.delete(repositoriesTable).where(eq(repositoriesTable.id, id));
throw new InternalServerError(`Failed to initialize repository: ${errorMessage}`); throw new InternalServerError(`Failed to initialize repository: ${errorMessage}`);

View File

@@ -74,6 +74,10 @@ const buildRepoUrl = (config: RepositoryConfig): string => {
return `${REPOSITORY_BASE}/${config.name}`; return `${REPOSITORY_BASE}/${config.name}`;
case "s3": case "s3":
return `s3:${config.endpoint}/${config.bucket}`; return `s3:${config.endpoint}/${config.bucket}`;
case "r2": {
const endpoint = config.endpoint.replace(/^https?:\/\//, '');
return `s3:${endpoint}/${config.bucket}`;
}
case "gcs": case "gcs":
return `gs:${config.bucket}:/`; return `gs:${config.bucket}:/`;
case "azure": case "azure":
@@ -98,6 +102,12 @@ const buildEnv = async (config: RepositoryConfig) => {
env.AWS_ACCESS_KEY_ID = await cryptoUtils.decrypt(config.accessKeyId); env.AWS_ACCESS_KEY_ID = await cryptoUtils.decrypt(config.accessKeyId);
env.AWS_SECRET_ACCESS_KEY = await cryptoUtils.decrypt(config.secretAccessKey); env.AWS_SECRET_ACCESS_KEY = await cryptoUtils.decrypt(config.secretAccessKey);
break; break;
case "r2":
env.AWS_ACCESS_KEY_ID = await cryptoUtils.decrypt(config.accessKeyId);
env.AWS_SECRET_ACCESS_KEY = await cryptoUtils.decrypt(config.secretAccessKey);
env.AWS_REGION = "auto";
env.AWS_S3_FORCE_PATH_STYLE = "true";
break;
case "gcs": { case "gcs": {
const decryptedCredentials = await cryptoUtils.decrypt(config.credentialsJson); const decryptedCredentials = await cryptoUtils.decrypt(config.credentialsJson);
const credentialsPath = path.join("/tmp", `gcs-credentials-${crypto.randomBytes(8).toString("hex")}.json`); const credentialsPath = path.join("/tmp", `gcs-credentials-${crypto.randomBytes(8).toString("hex")}.json`);

View File

@@ -18,7 +18,10 @@ services:
- /var/lib/ironmount:/var/lib/ironmount - /var/lib/ironmount:/var/lib/ironmount
- ./app:/app/app - ./app:/app/app
# - ~/.config/rclone:/root/.config/rclone - ~/.config/rclone:/root/.config/rclone
- /var/lib/ironmount:/var/lib/ironmount:rshared
- /run/docker/plugins:/run/docker/plugins
- /var/run/docker.sock:/var/run/docker.sock
ironmount-prod: ironmount-prod:
build: build:

View File

@@ -1 +0,0 @@
proj_Nwis7nYU1DiPGTtNlwRKBVtdgo5cOWPsnwbtxj2Urg0

View File

@@ -1,4 +0,0 @@
docker run --rm -it -v nicolas:/data alpine sh -lc 'echo hello > /data/hi && cat /data/hi'
mount -t davfs http://192.168.2.42 /mnt/webdav

Binary file not shown.

After

Width:  |  Height:  |  Size: 296 KiB