mirror of
https://github.com/nicotsx/ironmount.git
synced 2025-12-10 12:10:51 +01:00
Compare commits
7 Commits
v0.7.0-alp
...
v0.7.0-alp
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cfeff643c4 | ||
|
|
c898e1ce07 | ||
|
|
c179a16d15 | ||
|
|
00916a1fd2 | ||
|
|
18f863cbac | ||
|
|
1b8595c17e | ||
|
|
6e6becec3b |
@@ -6,7 +6,7 @@
|
||||
</a>
|
||||
<br />
|
||||
<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>
|
||||
<p align="center">
|
||||
Backup management with scheduling and monitoring
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Link } from "react-router";
|
||||
import { Link, useMatches, type UIMatch } from "react-router";
|
||||
import {
|
||||
Breadcrumb,
|
||||
BreadcrumbItem,
|
||||
@@ -7,14 +7,38 @@ import {
|
||||
BreadcrumbPage,
|
||||
BreadcrumbSeparator,
|
||||
} 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() {
|
||||
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 (
|
||||
<Breadcrumb>
|
||||
<BreadcrumbLink asChild></BreadcrumbLink>
|
||||
<BreadcrumbList>
|
||||
{breadcrumbs.map((breadcrumb, index) => {
|
||||
const isLast = index === breadcrumbs.length - 1;
|
||||
@@ -22,14 +46,12 @@ export function AppBreadcrumb() {
|
||||
return (
|
||||
<div key={`${breadcrumb.label}-${index}`} className="contents">
|
||||
<BreadcrumbItem>
|
||||
{isLast || breadcrumb.isCurrentPage ? (
|
||||
{isLast || !breadcrumb.href ? (
|
||||
<BreadcrumbPage>{breadcrumb.label}</BreadcrumbPage>
|
||||
) : breadcrumb.href ? (
|
||||
) : (
|
||||
<BreadcrumbLink asChild>
|
||||
<Link to={breadcrumb.href}>{breadcrumb.label}</Link>
|
||||
</BreadcrumbLink>
|
||||
) : (
|
||||
<BreadcrumbPage>{breadcrumb.label}</BreadcrumbPage>
|
||||
)}
|
||||
</BreadcrumbItem>
|
||||
{!isLast && <BreadcrumbSeparator />}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -1,8 +1,8 @@
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { FileTree, type FileEntry } from "./file-tree";
|
||||
import { FileTree } from "./file-tree";
|
||||
import { ScrollArea } from "./ui/scroll-area";
|
||||
import { browseFilesystemOptions } from "../api-client/@tanstack/react-query.gen";
|
||||
import { useFileBrowser } from "../hooks/use-file-browser";
|
||||
|
||||
type Props = {
|
||||
onSelectPath: (path: string) => void;
|
||||
@@ -11,82 +11,23 @@ type Props = {
|
||||
|
||||
export const DirectoryBrowser = ({ onSelectPath, selectedPath }: 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 } = useQuery({
|
||||
...browseFilesystemOptions({ query: { path: "/" } }),
|
||||
});
|
||||
|
||||
useMemo(() => {
|
||||
if (data?.directories) {
|
||||
setAllFiles((prev) => {
|
||||
const next = new Map(prev);
|
||||
for (const dir of data.directories) {
|
||||
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;
|
||||
});
|
||||
}
|
||||
}
|
||||
const fileBrowser = useFileBrowser({
|
||||
initialData: data,
|
||||
isLoading,
|
||||
fetchFolder: async (path) => {
|
||||
return await queryClient.ensureQueryData(browseFilesystemOptions({ query: { path } }));
|
||||
},
|
||||
[fetchedFolders, queryClient],
|
||||
);
|
||||
|
||||
const handleFolderHover = useCallback(
|
||||
(folderPath: string) => {
|
||||
if (!fetchedFolders.has(folderPath) && !loadingFolders.has(folderPath)) {
|
||||
queryClient.prefetchQuery(browseFilesystemOptions({ query: { path: folderPath } }));
|
||||
}
|
||||
prefetchFolder: (path) => {
|
||||
queryClient.prefetchQuery(browseFilesystemOptions({ query: { path } }));
|
||||
},
|
||||
[fetchedFolders, loadingFolders, queryClient],
|
||||
);
|
||||
});
|
||||
|
||||
if (isLoading && fileArray.length === 0) {
|
||||
if (fileBrowser.isLoading) {
|
||||
return (
|
||||
<div className="border rounded-lg overflow-hidden">
|
||||
<ScrollArea className="h-64">
|
||||
@@ -96,7 +37,7 @@ export const DirectoryBrowser = ({ onSelectPath, selectedPath }: Props) => {
|
||||
);
|
||||
}
|
||||
|
||||
if (fileArray.length === 0) {
|
||||
if (fileBrowser.isEmpty) {
|
||||
return (
|
||||
<div className="border rounded-lg overflow-hidden">
|
||||
<ScrollArea className="h-64">
|
||||
@@ -110,11 +51,11 @@ export const DirectoryBrowser = ({ onSelectPath, selectedPath }: Props) => {
|
||||
<div className="border rounded-lg overflow-hidden">
|
||||
<ScrollArea className="h-64">
|
||||
<FileTree
|
||||
files={fileArray}
|
||||
onFolderExpand={handleFolderExpand}
|
||||
onFolderHover={handleFolderHover}
|
||||
expandedFolders={expandedFolders}
|
||||
loadingFolders={loadingFolders}
|
||||
files={fileBrowser.fileArray}
|
||||
onFolderExpand={fileBrowser.handleFolderExpand}
|
||||
onFolderHover={fileBrowser.handleFolderHover}
|
||||
expandedFolders={fileBrowser.expandedFolders}
|
||||
loadingFolders={fileBrowser.loadingFolders}
|
||||
foldersOnly
|
||||
selectableFolders
|
||||
selectedFolder={selectedPath}
|
||||
|
||||
@@ -1,16 +1,8 @@
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { FolderOpen } from "lucide-react";
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { FileTree } from "~/client/components/file-tree";
|
||||
import { listFilesOptions } from "../api-client/@tanstack/react-query.gen";
|
||||
|
||||
interface FileEntry {
|
||||
name: string;
|
||||
path: string;
|
||||
type: "file" | "directory";
|
||||
size?: number;
|
||||
modifiedAt?: number;
|
||||
}
|
||||
import { useFileBrowser } from "../hooks/use-file-browser";
|
||||
|
||||
type VolumeFileBrowserProps = {
|
||||
volumeName: string;
|
||||
@@ -36,89 +28,34 @@ export const VolumeFileBrowser = ({
|
||||
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,
|
||||
});
|
||||
|
||||
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 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;
|
||||
});
|
||||
}
|
||||
}
|
||||
const fileBrowser = useFileBrowser({
|
||||
initialData: data,
|
||||
isLoading,
|
||||
fetchFolder: async (path) => {
|
||||
return await queryClient.ensureQueryData(
|
||||
listFilesOptions({
|
||||
path: { name: volumeName },
|
||||
query: { path },
|
||||
}),
|
||||
);
|
||||
},
|
||||
[volumeName, fetchedFolders, queryClient.ensureQueryData],
|
||||
);
|
||||
|
||||
const handleFolderHover = useCallback(
|
||||
(folderPath: string) => {
|
||||
if (!fetchedFolders.has(folderPath) && !loadingFolders.has(folderPath)) {
|
||||
queryClient.prefetchQuery(
|
||||
listFilesOptions({
|
||||
path: { name: volumeName },
|
||||
query: { path: folderPath },
|
||||
}),
|
||||
);
|
||||
}
|
||||
prefetchFolder: (path) => {
|
||||
queryClient.prefetchQuery(
|
||||
listFilesOptions({
|
||||
path: { name: volumeName },
|
||||
query: { path },
|
||||
}),
|
||||
);
|
||||
},
|
||||
[volumeName, fetchedFolders, loadingFolders, queryClient],
|
||||
);
|
||||
});
|
||||
|
||||
if (isLoading && fileArray.length === 0) {
|
||||
if (fileBrowser.isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full min-h-[200px]">
|
||||
<p className="text-muted-foreground">Loading files...</p>
|
||||
@@ -134,7 +71,7 @@ export const VolumeFileBrowser = ({
|
||||
);
|
||||
}
|
||||
|
||||
if (fileArray.length === 0) {
|
||||
if (fileBrowser.isEmpty) {
|
||||
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" />
|
||||
@@ -147,11 +84,11 @@ export const VolumeFileBrowser = ({
|
||||
return (
|
||||
<div className={className}>
|
||||
<FileTree
|
||||
files={fileArray}
|
||||
onFolderExpand={handleFolderExpand}
|
||||
onFolderHover={handleFolderHover}
|
||||
expandedFolders={expandedFolders}
|
||||
loadingFolders={loadingFolders}
|
||||
files={fileBrowser.fileArray}
|
||||
onFolderExpand={fileBrowser.handleFolderExpand}
|
||||
onFolderHover={fileBrowser.handleFolderHover}
|
||||
expandedFolders={fileBrowser.expandedFolders}
|
||||
loadingFolders={fileBrowser.loadingFolders}
|
||||
withCheckboxes={withCheckboxes}
|
||||
selectedPaths={selectedPaths}
|
||||
onSelectionChange={onSelectionChange}
|
||||
|
||||
135
app/client/hooks/use-file-browser.ts
Normal file
135
app/client/hooks/use-file-browser.ts
Normal 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,
|
||||
};
|
||||
};
|
||||
@@ -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);
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { useCallback, useState } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
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 { Button } from "~/client/components/ui/button";
|
||||
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 { toast } from "sonner";
|
||||
import { listSnapshotFilesOptions, restoreSnapshotMutation } from "~/client/api-client/@tanstack/react-query.gen";
|
||||
import { useFileBrowser } from "~/client/hooks/use-file-browser";
|
||||
|
||||
interface Props {
|
||||
snapshot: Snapshot;
|
||||
@@ -33,10 +34,6 @@ export const SnapshotFileBrowser = (props: Props) => {
|
||||
const isReadOnly = volume?.config && "readOnly" in volume.config && volume.config.readOnly === true;
|
||||
|
||||
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 [showRestoreDialog, setShowRestoreDialog] = useState(false);
|
||||
const [deleteExtraFiles, setDeleteExtraFiles] = useState(false);
|
||||
@@ -72,89 +69,30 @@ export const SnapshotFileBrowser = (props: Props) => {
|
||||
[volumeBasePath],
|
||||
);
|
||||
|
||||
useMemo(() => {
|
||||
if (filesData?.files) {
|
||||
setAllFiles((prev) => {
|
||||
const next = new Map(prev);
|
||||
for (const file of filesData.files) {
|
||||
const strippedPath = stripBasePath(file.path);
|
||||
if (strippedPath !== "/") {
|
||||
next.set(strippedPath, { ...file, path: strippedPath });
|
||||
}
|
||||
}
|
||||
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;
|
||||
});
|
||||
}
|
||||
}
|
||||
const fileBrowser = useFileBrowser({
|
||||
initialData: filesData,
|
||||
isLoading: filesLoading,
|
||||
fetchFolder: async (path) => {
|
||||
return await queryClient.ensureQueryData(
|
||||
listSnapshotFilesOptions({
|
||||
path: { name: repositoryName, snapshotId: snapshot.short_id },
|
||||
query: { path },
|
||||
}),
|
||||
);
|
||||
},
|
||||
[repositoryName, snapshot, fetchedFolders, queryClient, stripBasePath, addBasePath],
|
||||
);
|
||||
|
||||
const handleFolderHover = useCallback(
|
||||
(folderPath: string) => {
|
||||
if (!fetchedFolders.has(folderPath) && !loadingFolders.has(folderPath)) {
|
||||
const fullPath = addBasePath(folderPath);
|
||||
|
||||
queryClient.prefetchQuery({
|
||||
...listSnapshotFilesOptions({
|
||||
path: { name: repositoryName, snapshotId: snapshot.short_id },
|
||||
query: { path: fullPath },
|
||||
}),
|
||||
});
|
||||
}
|
||||
prefetchFolder: (path) => {
|
||||
queryClient.prefetchQuery(
|
||||
listSnapshotFilesOptions({
|
||||
path: { name: repositoryName, snapshotId: snapshot.short_id },
|
||||
query: { path },
|
||||
}),
|
||||
);
|
||||
},
|
||||
[repositoryName, snapshot, fetchedFolders, loadingFolders, queryClient, addBasePath],
|
||||
);
|
||||
pathTransform: {
|
||||
strip: stripBasePath,
|
||||
add: addBasePath,
|
||||
},
|
||||
});
|
||||
|
||||
const { mutate: restoreSnapshot, isPending: isRestoring } = useMutation({
|
||||
...restoreSnapshotMutation(),
|
||||
@@ -225,27 +163,27 @@ export const SnapshotFileBrowser = (props: Props) => {
|
||||
</div>
|
||||
</CardHeader>
|
||||
<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">
|
||||
<p className="text-muted-foreground">Loading files...</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{fileArray.length === 0 && !filesLoading && (
|
||||
{fileBrowser.isEmpty && (
|
||||
<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" />
|
||||
<p className="text-muted-foreground">No files in this snapshot</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{fileArray.length > 0 && (
|
||||
{!fileBrowser.isLoading && !fileBrowser.isEmpty && (
|
||||
<div className="overflow-auto flex-1 border border-border rounded-md bg-card m-4">
|
||||
<FileTree
|
||||
files={fileArray}
|
||||
onFolderExpand={handleFolderExpand}
|
||||
onFolderHover={handleFolderHover}
|
||||
expandedFolders={expandedFolders}
|
||||
loadingFolders={loadingFolders}
|
||||
files={fileBrowser.fileArray}
|
||||
onFolderExpand={fileBrowser.handleFolderExpand}
|
||||
onFolderHover={fileBrowser.handleFolderHover}
|
||||
expandedFolders={fileBrowser.expandedFolders}
|
||||
loadingFolders={fileBrowser.loadingFolders}
|
||||
className="px-2 py-2"
|
||||
withCheckboxes={true}
|
||||
selectedPaths={selectedPaths}
|
||||
|
||||
@@ -20,6 +20,13 @@ import { SnapshotFileBrowser } from "../components/snapshot-file-browser";
|
||||
import { SnapshotTimeline } from "../components/snapshot-timeline";
|
||||
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) {
|
||||
return [
|
||||
{ title: "Backup Job Details" },
|
||||
|
||||
@@ -9,6 +9,10 @@ import type { Route } from "./+types/backups";
|
||||
import { listBackupSchedules } from "~/client/api-client";
|
||||
import { listBackupSchedulesOptions } from "~/client/api-client/@tanstack/react-query.gen";
|
||||
|
||||
export const handle = {
|
||||
breadcrumb: () => [{ label: "Backups" }],
|
||||
};
|
||||
|
||||
export function meta(_: Route.MetaArgs) {
|
||||
return [
|
||||
{ title: "Backup Jobs" },
|
||||
|
||||
@@ -18,6 +18,10 @@ import { CreateScheduleForm, type BackupScheduleFormValues } from "../components
|
||||
import type { Route } from "./+types/create-backup";
|
||||
import { listRepositories, listVolumes } from "~/client/api-client";
|
||||
|
||||
export const handle = {
|
||||
breadcrumb: () => [{ label: "Backups", href: "/backups" }, { label: "Create" }],
|
||||
};
|
||||
|
||||
export function meta(_: Route.MetaArgs) {
|
||||
return [
|
||||
{ title: "Create Backup Job" },
|
||||
|
||||
89
app/client/modules/repositories/routes/create-repository.tsx
Normal file
89
app/client/modules/repositories/routes/create-repository.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -1,10 +1,9 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Database, RotateCcw } from "lucide-react";
|
||||
import { Database, Plus, RotateCcw } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { useNavigate } from "react-router";
|
||||
import { listRepositories } from "~/client/api-client/sdk.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 { Button } from "~/client/components/ui/button";
|
||||
import { Card } from "~/client/components/ui/card";
|
||||
@@ -15,6 +14,10 @@ import type { Route } from "./+types/repositories";
|
||||
import { cn } from "~/client/lib/utils";
|
||||
import { EmptyState } from "~/client/components/empty-state";
|
||||
|
||||
export const handle = {
|
||||
breadcrumb: () => [{ label: "Repositories" }],
|
||||
};
|
||||
|
||||
export function meta(_: Route.MetaArgs) {
|
||||
return [
|
||||
{ title: "Repositories" },
|
||||
@@ -35,7 +38,6 @@ export default function Repositories({ loaderData }: Route.ComponentProps) {
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [statusFilter, setStatusFilter] = useState("");
|
||||
const [backendFilter, setBackendFilter] = useState("");
|
||||
const [createRepositoryOpen, setCreateRepositoryOpen] = useState(false);
|
||||
|
||||
const clearFilters = () => {
|
||||
setSearchQuery("");
|
||||
@@ -69,7 +71,12 @@ export default function Repositories({ loaderData }: Route.ComponentProps) {
|
||||
icon={Database}
|
||||
title="No repository"
|
||||
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>
|
||||
)}
|
||||
</span>
|
||||
<CreateRepositoryDialog open={createRepositoryOpen} setOpen={setCreateRepositoryOpen} />
|
||||
<Button onClick={() => navigate("/repositories/create")}>
|
||||
<Plus size={16} className="mr-2" />
|
||||
Create Repository
|
||||
</Button>
|
||||
</div>
|
||||
<div className="overflow-x-auto">
|
||||
<Table className="border-t">
|
||||
|
||||
@@ -27,6 +27,13 @@ import { RepositoryInfoTabContent } from "../tabs/info";
|
||||
import { RepositorySnapshotsTabContent } from "../tabs/snapshots";
|
||||
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) {
|
||||
return [
|
||||
{ title: params.name },
|
||||
|
||||
@@ -7,6 +7,14 @@ import { SnapshotFileBrowser } from "~/client/modules/backups/components/snapsho
|
||||
import { getSnapshotDetails } from "~/client/api-client";
|
||||
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) {
|
||||
return [
|
||||
{ title: `Snapshot ${params.snapshotId}` },
|
||||
|
||||
@@ -24,6 +24,10 @@ import {
|
||||
logoutMutation,
|
||||
} from "~/client/api-client/@tanstack/react-query.gen";
|
||||
|
||||
export const handle = {
|
||||
breadcrumb: () => [{ label: "Settings" }],
|
||||
};
|
||||
|
||||
export function meta(_: Route.MetaArgs) {
|
||||
return [
|
||||
{ title: "Settings" },
|
||||
|
||||
83
app/client/modules/volumes/routes/create-volume.tsx
Normal file
83
app/client/modules/volumes/routes/create-volume.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -31,6 +31,10 @@ import {
|
||||
unmountVolumeMutation,
|
||||
} 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) {
|
||||
return [
|
||||
{ title: params.name },
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { HardDrive, RotateCcw } from "lucide-react";
|
||||
import { HardDrive, Plus, RotateCcw } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { useNavigate } from "react-router";
|
||||
import { CreateVolumeDialog } from "~/client/components/create-volume-dialog";
|
||||
import { EmptyState } from "~/client/components/empty-state";
|
||||
import { StatusDot } from "~/client/components/status-dot";
|
||||
import { Button } from "~/client/components/ui/button";
|
||||
@@ -15,6 +14,10 @@ import type { Route } from "./+types/volumes";
|
||||
import { listVolumes } from "~/client/api-client";
|
||||
import { listVolumesOptions } from "~/client/api-client/@tanstack/react-query.gen";
|
||||
|
||||
export const handle = {
|
||||
breadcrumb: () => [{ label: "Volumes" }],
|
||||
};
|
||||
|
||||
export function meta(_: Route.MetaArgs) {
|
||||
return [
|
||||
{ title: "Volumes" },
|
||||
@@ -32,7 +35,6 @@ export const clientLoader = async () => {
|
||||
};
|
||||
|
||||
export default function Volumes({ loaderData }: Route.ComponentProps) {
|
||||
const [createVolumeOpen, setCreateVolumeOpen] = useState(false);
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [statusFilter, setStatusFilter] = useState("");
|
||||
const [backendFilter, setBackendFilter] = useState("");
|
||||
@@ -69,7 +71,12 @@ export default function Volumes({ loaderData }: Route.ComponentProps) {
|
||||
icon={HardDrive}
|
||||
title="No volume"
|
||||
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>
|
||||
)}
|
||||
</span>
|
||||
<CreateVolumeDialog open={createVolumeOpen} setOpen={setCreateVolumeOpen} />
|
||||
<Button onClick={() => navigate("/volumes/create")}>
|
||||
<Plus size={16} className="mr-2" />
|
||||
Create Volume
|
||||
</Button>
|
||||
</div>
|
||||
<div className="overflow-x-auto">
|
||||
<Table className="border-t">
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { redirect } from "react-router";
|
||||
|
||||
export const loader = async () => {
|
||||
return redirect("/volumes");
|
||||
};
|
||||
|
||||
export const clientLoader = async () => {
|
||||
return redirect("/volumes");
|
||||
};
|
||||
|
||||
export const loader = async () => {
|
||||
return redirect("/volumes");
|
||||
};
|
||||
|
||||
@@ -7,11 +7,13 @@ export default [
|
||||
layout("./client/components/layout.tsx", [
|
||||
route("/", "./client/routes/root.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("backups", "./client/modules/backups/routes/backups.tsx"),
|
||||
route("backups/create", "./client/modules/backups/routes/create-backup.tsx"),
|
||||
route("backups/:id", "./client/modules/backups/routes/backup-details.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/:snapshotId", "./client/modules/repositories/routes/snapshot-details.tsx"),
|
||||
route("settings", "./client/modules/settings/routes/settings.tsx"),
|
||||
|
||||
1
app/schemas/node_modules/arktype
generated
vendored
1
app/schemas/node_modules/arktype
generated
vendored
@@ -1 +0,0 @@
|
||||
../../../node_modules/.bun/arktype@2.1.26/node_modules/arktype
|
||||
@@ -39,6 +39,14 @@ class SchedulerClass {
|
||||
this.tasks = [];
|
||||
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();
|
||||
|
||||
@@ -25,7 +25,7 @@ import { toMessage } from "../../utils/errors";
|
||||
const COOKIE_NAME = "session_id";
|
||||
const COOKIE_OPTIONS = {
|
||||
httpOnly: true,
|
||||
secure: process.env.NODE_ENV === "production",
|
||||
secure: false,
|
||||
sameSite: "lax" as const,
|
||||
path: "/",
|
||||
};
|
||||
|
||||
@@ -13,6 +13,7 @@ import { CleanupSessionsJob } from "../../jobs/cleanup-sessions";
|
||||
|
||||
export const startup = async () => {
|
||||
await Scheduler.start();
|
||||
await Scheduler.clear();
|
||||
|
||||
await restic.ensurePassfile().catch((err) => {
|
||||
logger.error(`Error ensuring restic passfile exists: ${err.message}`);
|
||||
|
||||
@@ -18,7 +18,7 @@ services:
|
||||
- /var/lib/ironmount:/var/lib/ironmount
|
||||
|
||||
- ./app:/app/app
|
||||
# - ~/.config/rclone:/root/.config/rclone
|
||||
- ~/.config/rclone:/root/.config/rclone
|
||||
|
||||
ironmount-prod:
|
||||
build:
|
||||
|
||||
BIN
screenshots/backup-details.png
Normal file
BIN
screenshots/backup-details.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 296 KiB |
Reference in New Issue
Block a user