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
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cfeff643c4 | ||
|
|
c898e1ce07 | ||
|
|
c179a16d15 | ||
|
|
00916a1fd2 | ||
|
|
18f863cbac | ||
|
|
1b8595c17e | ||
|
|
6e6becec3b |
@@ -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
|
||||||
|
|||||||
@@ -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 />}
|
||||||
|
|||||||
@@ -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 { 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}
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
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 { 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}
|
||||||
|
|||||||
@@ -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" },
|
||||||
|
|||||||
@@ -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" },
|
||||||
|
|||||||
@@ -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" },
|
||||||
|
|||||||
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 { 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">
|
||||||
|
|||||||
@@ -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 },
|
||||||
|
|||||||
@@ -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}` },
|
||||||
|
|||||||
@@ -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" },
|
||||||
|
|||||||
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,
|
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 },
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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");
|
||||||
|
};
|
||||||
|
|||||||
@@ -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
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 = [];
|
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();
|
||||||
|
|||||||
@@ -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: "/",
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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}`);
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ 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
|
||||||
|
|
||||||
ironmount-prod:
|
ironmount-prod:
|
||||||
build:
|
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