mirror of
https://github.com/nicotsx/ironmount.git
synced 2025-12-10 12:10:51 +01:00
Compare commits
21 Commits
v0.7.0-alp
...
v0.8.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d78b4adfd9 | ||
|
|
4d3ec524e2 | ||
|
|
681cf5dff1 | ||
|
|
31da747c2d | ||
|
|
b86081b2e8 | ||
|
|
3622fd57ef | ||
|
|
5b1d7eff17 | ||
|
|
2b3d8dffc5 | ||
|
|
f517438a8e | ||
|
|
1ddd4d701b | ||
|
|
9a1797b8b2 | ||
|
|
52046c88cc | ||
|
|
951d9d970c | ||
|
|
ffc821af2b | ||
|
|
cfeff643c4 | ||
|
|
c898e1ce07 | ||
|
|
c179a16d15 | ||
|
|
00916a1fd2 | ||
|
|
18f863cbac | ||
|
|
1b8595c17e | ||
|
|
6e6becec3b |
3
.gitignore
vendored
3
.gitignore
vendored
@@ -9,3 +9,6 @@
|
|||||||
.env
|
.env
|
||||||
.turbo
|
.turbo
|
||||||
CLAUDE.md
|
CLAUDE.md
|
||||||
|
|
||||||
|
mutagen.yml.lock
|
||||||
|
notes.md
|
||||||
|
|||||||
18
README.md
18
README.md
@@ -6,7 +6,7 @@
|
|||||||
</a>
|
</a>
|
||||||
<br />
|
<br />
|
||||||
<figure>
|
<figure>
|
||||||
<img src="https://github.com/nicotsx/ironmount/blob/main/screenshots/volume-details.png?raw=true" alt="Demo" />
|
<img src="https://github.com/nicotsx/ironmount/blob/main/screenshots/backup-details.png?raw=true" alt="Demo" />
|
||||||
<figcaption>
|
<figcaption>
|
||||||
<p align="center">
|
<p align="center">
|
||||||
Backup management with scheduling and monitoring
|
Backup management with scheduling and monitoring
|
||||||
@@ -36,10 +36,11 @@ In order to run Ironmount, you need to have Docker and Docker Compose installed
|
|||||||
```yaml
|
```yaml
|
||||||
services:
|
services:
|
||||||
ironmount:
|
ironmount:
|
||||||
image: ghcr.io/nicotsx/ironmount:v0.6
|
image: ghcr.io/nicotsx/ironmount:v0.8
|
||||||
container_name: ironmount
|
container_name: ironmount
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
privileged: true
|
cap_add:
|
||||||
|
- SYS_ADMIN
|
||||||
ports:
|
ports:
|
||||||
- "4096:4096"
|
- "4096:4096"
|
||||||
devices:
|
devices:
|
||||||
@@ -67,7 +68,7 @@ If you want to track a local directory on the same server where Ironmount is run
|
|||||||
```diff
|
```diff
|
||||||
services:
|
services:
|
||||||
ironmount:
|
ironmount:
|
||||||
image: ghcr.io/nicotsx/ironmount:v0.6
|
image: ghcr.io/nicotsx/ironmount:v0.8
|
||||||
container_name: ironmount
|
container_name: ironmount
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
cap_add:
|
cap_add:
|
||||||
@@ -132,10 +133,11 @@ Ironmount can use [rclone](https://rclone.org/) to support 40+ cloud storage pro
|
|||||||
```diff
|
```diff
|
||||||
services:
|
services:
|
||||||
ironmount:
|
ironmount:
|
||||||
image: ghcr.io/nicotsx/ironmount:v0.6
|
image: ghcr.io/nicotsx/ironmount:v0.8
|
||||||
container_name: ironmount
|
container_name: ironmount
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
privileged: true
|
cap_add:
|
||||||
|
- SYS_ADMIN
|
||||||
ports:
|
ports:
|
||||||
- "4096:4096"
|
- "4096:4096"
|
||||||
devices:
|
devices:
|
||||||
@@ -187,7 +189,7 @@ In order to enable this feature, you need to change your bind mount `/var/lib/ir
|
|||||||
```diff
|
```diff
|
||||||
services:
|
services:
|
||||||
ironmount:
|
ironmount:
|
||||||
image: ghcr.io/nicotsx/ironmount:v0.6
|
image: ghcr.io/nicotsx/ironmount:v0.8
|
||||||
container_name: ironmount
|
container_name: ironmount
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
ports:
|
ports:
|
||||||
@@ -215,7 +217,7 @@ In order to enable this feature, you need to run Ironmount with several items sh
|
|||||||
```diff
|
```diff
|
||||||
services:
|
services:
|
||||||
ironmount:
|
ironmount:
|
||||||
image: ghcr.io/nicotsx/ironmount:v0.6
|
image: ghcr.io/nicotsx/ironmount:v0.8
|
||||||
container_name: ironmount
|
container_name: ironmount
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
cap_add:
|
cap_add:
|
||||||
|
|||||||
@@ -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>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -36,6 +36,7 @@ type Props = {
|
|||||||
const defaultValuesForType = {
|
const defaultValuesForType = {
|
||||||
local: { backend: "local" as const, compressionMode: "auto" as const },
|
local: { backend: "local" as const, compressionMode: "auto" as const },
|
||||||
s3: { backend: "s3" as const, compressionMode: "auto" as const },
|
s3: { backend: "s3" as const, compressionMode: "auto" as const },
|
||||||
|
r2: { backend: "r2" as const, compressionMode: "auto" as const },
|
||||||
gcs: { backend: "gcs" as const, compressionMode: "auto" as const },
|
gcs: { backend: "gcs" as const, compressionMode: "auto" as const },
|
||||||
azure: { backend: "azure" as const, compressionMode: "auto" as const },
|
azure: { backend: "azure" as const, compressionMode: "auto" as const },
|
||||||
rclone: { backend: "rclone" as const, compressionMode: "auto" as const },
|
rclone: { backend: "rclone" as const, compressionMode: "auto" as const },
|
||||||
@@ -115,6 +116,7 @@ export const CreateRepositoryForm = ({
|
|||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="local">Local</SelectItem>
|
<SelectItem value="local">Local</SelectItem>
|
||||||
<SelectItem value="s3">S3</SelectItem>
|
<SelectItem value="s3">S3</SelectItem>
|
||||||
|
<SelectItem value="r2">Cloudflare R2</SelectItem>
|
||||||
<SelectItem value="gcs">Google Cloud Storage</SelectItem>
|
<SelectItem value="gcs">Google Cloud Storage</SelectItem>
|
||||||
<SelectItem value="azure">Azure Blob Storage</SelectItem>
|
<SelectItem value="azure">Azure Blob Storage</SelectItem>
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
@@ -222,6 +224,67 @@ export const CreateRepositoryForm = ({
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{watchedBackend === "r2" && (
|
||||||
|
<>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="endpoint"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Endpoint</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input placeholder="<account-id>.r2.cloudflarestorage.com" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormDescription>R2 endpoint (without https://). Find in R2 dashboard under bucket settings.</FormDescription>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="bucket"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Bucket</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input placeholder="my-backup-bucket" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormDescription>R2 bucket name for storing backups.</FormDescription>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="accessKeyId"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Access Key ID</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input placeholder="Access Key ID from R2 API tokens" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormDescription>R2 API token Access Key ID (create in Cloudflare R2 dashboard).</FormDescription>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="secretAccessKey"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Secret Access Key</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input type="password" placeholder="••••••••" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormDescription>R2 API token Secret Access Key (shown once when creating token).</FormDescription>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
{watchedBackend === "gcs" && (
|
{watchedBackend === "gcs" && (
|
||||||
<>
|
<>
|
||||||
<FormField
|
<FormField
|
||||||
|
|||||||
@@ -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>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -536,42 +536,44 @@ export const CreateVolumeForm = ({ onSubmit, mode = "create", initialValues, for
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="space-y-3">
|
{watchedBackend !== "directory" && (
|
||||||
<div className="flex items-center gap-2">
|
<div className="space-y-3">
|
||||||
<Button
|
<div className="flex items-center gap-2">
|
||||||
type="button"
|
<Button
|
||||||
variant="outline"
|
type="button"
|
||||||
onClick={handleTestConnection}
|
variant="outline"
|
||||||
disabled={testBackendConnection.isPending}
|
onClick={handleTestConnection}
|
||||||
className="flex-1"
|
disabled={testBackendConnection.isPending}
|
||||||
>
|
className="flex-1"
|
||||||
{testBackendConnection.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
>
|
||||||
{!testBackendConnection.isPending && testMessage?.success && (
|
{testBackendConnection.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||||
<CheckCircle className="mr-2 h-4 w-4 text-green-500" />
|
{!testBackendConnection.isPending && testMessage?.success && (
|
||||||
)}
|
<CheckCircle className="mr-2 h-4 w-4 text-green-500" />
|
||||||
{!testBackendConnection.isPending && testMessage && !testMessage.success && (
|
)}
|
||||||
<XCircle className="mr-2 h-4 w-4 text-red-500" />
|
{!testBackendConnection.isPending && testMessage && !testMessage.success && (
|
||||||
)}
|
<XCircle className="mr-2 h-4 w-4 text-red-500" />
|
||||||
{testBackendConnection.isPending
|
)}
|
||||||
? "Testing..."
|
{testBackendConnection.isPending
|
||||||
: testMessage
|
? "Testing..."
|
||||||
? testMessage.success
|
: testMessage
|
||||||
? "Connection Successful"
|
? testMessage.success
|
||||||
: "Test Failed"
|
? "Connection Successful"
|
||||||
: "Test Connection"}
|
: "Test Failed"
|
||||||
</Button>
|
: "Test Connection"}
|
||||||
</div>
|
</Button>
|
||||||
{testMessage && (
|
|
||||||
<div
|
|
||||||
className={cn("text-xs p-2 rounded-md text-wrap wrap-anywhere", {
|
|
||||||
"bg-green-50 text-green-700 border border-green-200": testMessage.success,
|
|
||||||
"bg-red-50 text-red-700 border border-red-200": !testMessage.success,
|
|
||||||
})}
|
|
||||||
>
|
|
||||||
{testMessage.message}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
{testMessage && (
|
||||||
</div>
|
<div
|
||||||
|
className={cn("text-xs p-2 rounded-md text-wrap wrap-anywhere", {
|
||||||
|
"bg-green-50 text-green-700 border border-green-200": testMessage.success,
|
||||||
|
"bg-red-50 text-red-700 border border-red-200": !testMessage.success,
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
{testMessage.message}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{mode === "update" && (
|
{mode === "update" && (
|
||||||
<Button type="submit" className="w-full" loading={loading}>
|
<Button type="submit" className="w-full" loading={loading}>
|
||||||
Save Changes
|
Save Changes
|
||||||
|
|||||||
@@ -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);
|
|
||||||
}
|
|
||||||
@@ -254,7 +254,7 @@ export const CreateScheduleForm = ({ initialValues, formId, onSubmit, volume }:
|
|||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Backup paths</CardTitle>
|
<CardTitle>Backup paths</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
Select which folders to include in the backup. If no paths are selected, the entire volume will be
|
Select which folders or files to include in the backup. If no paths are selected, the entire volume will be
|
||||||
backed up.
|
backed up.
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
@@ -264,7 +264,7 @@ export const CreateScheduleForm = ({ initialValues, formId, onSubmit, volume }:
|
|||||||
selectedPaths={selectedPaths}
|
selectedPaths={selectedPaths}
|
||||||
onSelectionChange={handleSelectionChange}
|
onSelectionChange={handleSelectionChange}
|
||||||
withCheckboxes={true}
|
withCheckboxes={true}
|
||||||
foldersOnly={true}
|
foldersOnly={false}
|
||||||
className="flex-1 border rounded-md bg-card p-2 min-h-[300px] max-h-[400px] overflow-auto"
|
className="flex-1 border rounded-md bg-card p-2 min-h-[300px] max-h-[400px] overflow-auto"
|
||||||
/>
|
/>
|
||||||
{selectedPaths.size > 0 && (
|
{selectedPaths.size > 0 && (
|
||||||
|
|||||||
@@ -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
|
|
||||||
@@ -3,6 +3,7 @@ import { type } from "arktype";
|
|||||||
export const REPOSITORY_BACKENDS = {
|
export const REPOSITORY_BACKENDS = {
|
||||||
local: "local",
|
local: "local",
|
||||||
s3: "s3",
|
s3: "s3",
|
||||||
|
r2: "r2",
|
||||||
gcs: "gcs",
|
gcs: "gcs",
|
||||||
azure: "azure",
|
azure: "azure",
|
||||||
rclone: "rclone",
|
rclone: "rclone",
|
||||||
@@ -18,6 +19,14 @@ export const s3RepositoryConfigSchema = type({
|
|||||||
secretAccessKey: "string",
|
secretAccessKey: "string",
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const r2RepositoryConfigSchema = type({
|
||||||
|
backend: "'r2'",
|
||||||
|
endpoint: "string",
|
||||||
|
bucket: "string",
|
||||||
|
accessKeyId: "string",
|
||||||
|
secretAccessKey: "string",
|
||||||
|
});
|
||||||
|
|
||||||
export const localRepositoryConfigSchema = type({
|
export const localRepositoryConfigSchema = type({
|
||||||
backend: "'local'",
|
backend: "'local'",
|
||||||
name: "string",
|
name: "string",
|
||||||
@@ -45,6 +54,7 @@ export const rcloneRepositoryConfigSchema = type({
|
|||||||
});
|
});
|
||||||
|
|
||||||
export const repositoryConfigSchema = s3RepositoryConfigSchema
|
export const repositoryConfigSchema = s3RepositoryConfigSchema
|
||||||
|
.or(r2RepositoryConfigSchema)
|
||||||
.or(localRepositoryConfigSchema)
|
.or(localRepositoryConfigSchema)
|
||||||
.or(gcsRepositoryConfigSchema)
|
.or(gcsRepositoryConfigSchema)
|
||||||
.or(azureRepositoryConfigSchema)
|
.or(azureRepositoryConfigSchema)
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -19,7 +19,9 @@ export class CleanupDanglingMountsJob extends Job {
|
|||||||
const matchingVolume = allVolumes.find((v) => getVolumePath(v) === mount.mountPoint);
|
const matchingVolume = allVolumes.find((v) => getVolumePath(v) === mount.mountPoint);
|
||||||
if (!matchingVolume) {
|
if (!matchingVolume) {
|
||||||
logger.info(`Found dangling mount at ${mount.mountPoint}, attempting to unmount...`);
|
logger.info(`Found dangling mount at ${mount.mountPoint}, attempting to unmount...`);
|
||||||
await executeUnmount(mount.mountPoint);
|
await executeUnmount(mount.mountPoint).catch((err) => {
|
||||||
|
logger.warn(`Failed to unmount dangling mount ${mount.mountPoint}: ${toMessage(err)}`);
|
||||||
|
});
|
||||||
|
|
||||||
await fs.rmdir(path.dirname(mount.mountPoint)).catch((err) => {
|
await fs.rmdir(path.dirname(mount.mountPoint)).catch((err) => {
|
||||||
logger.warn(
|
logger.warn(
|
||||||
|
|||||||
@@ -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,10 @@ export const executeMount = async (args: string[]): Promise<void> => {
|
|||||||
if (stderr?.trim()) {
|
if (stderr?.trim()) {
|
||||||
logger.warn(stderr.trim());
|
logger.warn(stderr.trim());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (result.exitCode !== 0) {
|
||||||
|
throw new Error(`Mount command failed with exit code ${result.exitCode}: ${stderr?.trim()}`);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export const executeUnmount = async (path: string): Promise<void> => {
|
export const executeUnmount = async (path: string): Promise<void> => {
|
||||||
@@ -24,6 +28,10 @@ export const executeUnmount = async (path: string): Promise<void> => {
|
|||||||
if (stderr?.trim()) {
|
if (stderr?.trim()) {
|
||||||
logger.warn(stderr.trim());
|
logger.warn(stderr.trim());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (result.exitCode !== 0) {
|
||||||
|
throw new Error(`Mount command failed with exit code ${result.exitCode}: ${stderr?.trim()}`);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export const createTestFile = async (path: string): Promise<void> => {
|
export const createTestFile = async (path: string): Promise<void> => {
|
||||||
|
|||||||
@@ -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}`);
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { repositoriesTable } from "../../db/schema";
|
|||||||
import { toMessage } from "../../utils/errors";
|
import { toMessage } from "../../utils/errors";
|
||||||
import { restic } from "../../utils/restic";
|
import { restic } from "../../utils/restic";
|
||||||
import { cryptoUtils } from "../../utils/crypto";
|
import { cryptoUtils } from "../../utils/crypto";
|
||||||
|
import { logger } from "../../utils/logger";
|
||||||
import type { CompressionMode, RepositoryConfig } from "~/schemas/restic";
|
import type { CompressionMode, RepositoryConfig } from "~/schemas/restic";
|
||||||
|
|
||||||
const listRepositories = async () => {
|
const listRepositories = async () => {
|
||||||
@@ -19,6 +20,7 @@ const encryptConfig = async (config: RepositoryConfig): Promise<RepositoryConfig
|
|||||||
|
|
||||||
switch (config.backend) {
|
switch (config.backend) {
|
||||||
case "s3":
|
case "s3":
|
||||||
|
case "r2":
|
||||||
encryptedConfig.accessKeyId = await cryptoUtils.encrypt(config.accessKeyId);
|
encryptedConfig.accessKeyId = await cryptoUtils.encrypt(config.accessKeyId);
|
||||||
encryptedConfig.secretAccessKey = await cryptoUtils.encrypt(config.secretAccessKey);
|
encryptedConfig.secretAccessKey = await cryptoUtils.encrypt(config.secretAccessKey);
|
||||||
break;
|
break;
|
||||||
@@ -80,6 +82,7 @@ const createRepository = async (name: string, config: RepositoryConfig, compress
|
|||||||
}
|
}
|
||||||
|
|
||||||
const errorMessage = toMessage(error);
|
const errorMessage = toMessage(error);
|
||||||
|
|
||||||
await db.delete(repositoriesTable).where(eq(repositoriesTable.id, id));
|
await db.delete(repositoriesTable).where(eq(repositoriesTable.id, id));
|
||||||
|
|
||||||
throw new InternalServerError(`Failed to initialize repository: ${errorMessage}`);
|
throw new InternalServerError(`Failed to initialize repository: ${errorMessage}`);
|
||||||
|
|||||||
@@ -74,6 +74,10 @@ const buildRepoUrl = (config: RepositoryConfig): string => {
|
|||||||
return `${REPOSITORY_BASE}/${config.name}`;
|
return `${REPOSITORY_BASE}/${config.name}`;
|
||||||
case "s3":
|
case "s3":
|
||||||
return `s3:${config.endpoint}/${config.bucket}`;
|
return `s3:${config.endpoint}/${config.bucket}`;
|
||||||
|
case "r2": {
|
||||||
|
const endpoint = config.endpoint.replace(/^https?:\/\//, '');
|
||||||
|
return `s3:${endpoint}/${config.bucket}`;
|
||||||
|
}
|
||||||
case "gcs":
|
case "gcs":
|
||||||
return `gs:${config.bucket}:/`;
|
return `gs:${config.bucket}:/`;
|
||||||
case "azure":
|
case "azure":
|
||||||
@@ -98,6 +102,12 @@ const buildEnv = async (config: RepositoryConfig) => {
|
|||||||
env.AWS_ACCESS_KEY_ID = await cryptoUtils.decrypt(config.accessKeyId);
|
env.AWS_ACCESS_KEY_ID = await cryptoUtils.decrypt(config.accessKeyId);
|
||||||
env.AWS_SECRET_ACCESS_KEY = await cryptoUtils.decrypt(config.secretAccessKey);
|
env.AWS_SECRET_ACCESS_KEY = await cryptoUtils.decrypt(config.secretAccessKey);
|
||||||
break;
|
break;
|
||||||
|
case "r2":
|
||||||
|
env.AWS_ACCESS_KEY_ID = await cryptoUtils.decrypt(config.accessKeyId);
|
||||||
|
env.AWS_SECRET_ACCESS_KEY = await cryptoUtils.decrypt(config.secretAccessKey);
|
||||||
|
env.AWS_REGION = "auto";
|
||||||
|
env.AWS_S3_FORCE_PATH_STYLE = "true";
|
||||||
|
break;
|
||||||
case "gcs": {
|
case "gcs": {
|
||||||
const decryptedCredentials = await cryptoUtils.decrypt(config.credentialsJson);
|
const decryptedCredentials = await cryptoUtils.decrypt(config.credentialsJson);
|
||||||
const credentialsPath = path.join("/tmp", `gcs-credentials-${crypto.randomBytes(8).toString("hex")}.json`);
|
const credentialsPath = path.join("/tmp", `gcs-credentials-${crypto.randomBytes(8).toString("hex")}.json`);
|
||||||
|
|||||||
@@ -18,7 +18,10 @@ services:
|
|||||||
- /var/lib/ironmount:/var/lib/ironmount
|
- /var/lib/ironmount:/var/lib/ironmount
|
||||||
|
|
||||||
- ./app:/app/app
|
- ./app:/app/app
|
||||||
# - ~/.config/rclone:/root/.config/rclone
|
- ~/.config/rclone:/root/.config/rclone
|
||||||
|
- /var/lib/ironmount:/var/lib/ironmount:rshared
|
||||||
|
- /run/docker/plugins:/run/docker/plugins
|
||||||
|
- /var/run/docker.sock:/var/run/docker.sock
|
||||||
|
|
||||||
ironmount-prod:
|
ironmount-prod:
|
||||||
build:
|
build:
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
proj_Nwis7nYU1DiPGTtNlwRKBVtdgo5cOWPsnwbtxj2Urg0
|
|
||||||
4
notes.md
4
notes.md
@@ -1,4 +0,0 @@
|
|||||||
docker run --rm -it -v nicolas:/data alpine sh -lc 'echo hello > /data/hi && cat /data/hi'
|
|
||||||
|
|
||||||
mount -t davfs http://192.168.2.42 /mnt/webdav
|
|
||||||
|
|
||||||
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