refactor: split forms (#124)
* refactor: split create-volume-form * refactor: split create-repository-form * chore: pr feedbacks
This commit is contained in:
parent
9199a743db
commit
2ddf3c6597
22 changed files with 1798 additions and 1505 deletions
|
|
@ -1,793 +0,0 @@
|
||||||
import { arktypeResolver } from "@hookform/resolvers/arktype";
|
|
||||||
import { type } from "arktype";
|
|
||||||
import { useEffect, useState } from "react";
|
|
||||||
import { useForm } from "react-hook-form";
|
|
||||||
import { Check, Pencil, Save, X } from "lucide-react";
|
|
||||||
import { cn, slugify } from "~/client/lib/utils";
|
|
||||||
import { deepClean } from "~/utils/object";
|
|
||||||
import { Button } from "./ui/button";
|
|
||||||
import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from "./ui/form";
|
|
||||||
import { Input } from "./ui/input";
|
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select";
|
|
||||||
import { useQuery } from "@tanstack/react-query";
|
|
||||||
import { Alert, AlertDescription } from "./ui/alert";
|
|
||||||
import { ExternalLink, AlertTriangle } from "lucide-react";
|
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip";
|
|
||||||
import { useSystemInfo } from "~/client/hooks/use-system-info";
|
|
||||||
import { COMPRESSION_MODES, repositoryConfigSchema } from "~/schemas/restic";
|
|
||||||
import { listRcloneRemotesOptions } from "../api-client/@tanstack/react-query.gen";
|
|
||||||
import { Checkbox } from "./ui/checkbox";
|
|
||||||
import { DirectoryBrowser } from "./directory-browser";
|
|
||||||
import {
|
|
||||||
AlertDialog,
|
|
||||||
AlertDialogAction,
|
|
||||||
AlertDialogCancel,
|
|
||||||
AlertDialogContent,
|
|
||||||
AlertDialogDescription,
|
|
||||||
AlertDialogFooter,
|
|
||||||
AlertDialogHeader,
|
|
||||||
AlertDialogTitle,
|
|
||||||
} from "./ui/alert-dialog";
|
|
||||||
import { Textarea } from "./ui/textarea";
|
|
||||||
|
|
||||||
export const formSchema = type({
|
|
||||||
name: "2<=string<=32",
|
|
||||||
compressionMode: type.valueOf(COMPRESSION_MODES).optional(),
|
|
||||||
}).and(repositoryConfigSchema);
|
|
||||||
const cleanSchema = type.pipe((d) => formSchema(deepClean(d)));
|
|
||||||
|
|
||||||
export type RepositoryFormValues = typeof formSchema.inferIn;
|
|
||||||
|
|
||||||
type Props = {
|
|
||||||
onSubmit: (values: RepositoryFormValues) => void;
|
|
||||||
mode?: "create" | "update";
|
|
||||||
initialValues?: Partial<RepositoryFormValues>;
|
|
||||||
formId?: string;
|
|
||||||
loading?: boolean;
|
|
||||||
className?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
const defaultValuesForType = {
|
|
||||||
local: { backend: "local" 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 },
|
|
||||||
azure: { backend: "azure" as const, compressionMode: "auto" as const },
|
|
||||||
rclone: { backend: "rclone" as const, compressionMode: "auto" as const },
|
|
||||||
rest: { backend: "rest" as const, compressionMode: "auto" as const },
|
|
||||||
sftp: { backend: "sftp" as const, compressionMode: "auto" as const, port: 22 },
|
|
||||||
};
|
|
||||||
|
|
||||||
export const CreateRepositoryForm = ({
|
|
||||||
onSubmit,
|
|
||||||
mode = "create",
|
|
||||||
initialValues,
|
|
||||||
formId,
|
|
||||||
loading,
|
|
||||||
className,
|
|
||||||
}: Props) => {
|
|
||||||
const form = useForm<RepositoryFormValues>({
|
|
||||||
resolver: arktypeResolver(cleanSchema as unknown as typeof formSchema),
|
|
||||||
defaultValues: initialValues,
|
|
||||||
resetOptions: {
|
|
||||||
keepDefaultValues: true,
|
|
||||||
keepDirtyValues: false,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const { watch, setValue } = form;
|
|
||||||
|
|
||||||
const watchedBackend = watch("backend");
|
|
||||||
const watchedIsExistingRepository = watch("isExistingRepository");
|
|
||||||
|
|
||||||
const [passwordMode, setPasswordMode] = useState<"default" | "custom">("default");
|
|
||||||
const [showPathBrowser, setShowPathBrowser] = useState(false);
|
|
||||||
const [showPathWarning, setShowPathWarning] = useState(false);
|
|
||||||
|
|
||||||
const { capabilities } = useSystemInfo();
|
|
||||||
|
|
||||||
const { data: rcloneRemotes, isLoading: isLoadingRemotes } = useQuery({
|
|
||||||
...listRcloneRemotesOptions(),
|
|
||||||
enabled: capabilities.rclone,
|
|
||||||
});
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
form.reset({
|
|
||||||
name: form.getValues().name,
|
|
||||||
isExistingRepository: form.getValues().isExistingRepository,
|
|
||||||
customPassword: form.getValues().customPassword,
|
|
||||||
...defaultValuesForType[watchedBackend as keyof typeof defaultValuesForType],
|
|
||||||
});
|
|
||||||
}, [watchedBackend, form]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Form {...form}>
|
|
||||||
<form id={formId} onSubmit={form.handleSubmit(onSubmit)} className={cn("space-y-4", className)}>
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="name"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel>Name</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
<Input
|
|
||||||
{...field}
|
|
||||||
placeholder="Repository name"
|
|
||||||
onChange={(e) => field.onChange(slugify(e.target.value))}
|
|
||||||
max={32}
|
|
||||||
min={2}
|
|
||||||
/>
|
|
||||||
</FormControl>
|
|
||||||
<FormDescription>Unique identifier for the repository.</FormDescription>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="backend"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel>Backend</FormLabel>
|
|
||||||
<Select onValueChange={field.onChange} defaultValue={field.value} value={field.value}>
|
|
||||||
<FormControl>
|
|
||||||
<SelectTrigger>
|
|
||||||
<SelectValue placeholder="Select a backend" />
|
|
||||||
</SelectTrigger>
|
|
||||||
</FormControl>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="local">Local</SelectItem>
|
|
||||||
<SelectItem value="s3">S3</SelectItem>
|
|
||||||
<SelectItem value="r2">Cloudflare R2</SelectItem>
|
|
||||||
<SelectItem value="gcs">Google Cloud Storage</SelectItem>
|
|
||||||
<SelectItem value="azure">Azure Blob Storage</SelectItem>
|
|
||||||
<SelectItem value="rest">REST Server</SelectItem>
|
|
||||||
<SelectItem value="sftp">SFTP</SelectItem>
|
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger>
|
|
||||||
<SelectItem disabled={!capabilities.rclone} value="rclone">
|
|
||||||
rclone (40+ cloud providers)
|
|
||||||
</SelectItem>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent className={cn({ hidden: capabilities.rclone })}>
|
|
||||||
<p>Setup rclone to use this backend</p>
|
|
||||||
</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
<FormDescription>Choose the storage backend for this repository.</FormDescription>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="compressionMode"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel>Compression Mode</FormLabel>
|
|
||||||
<Select onValueChange={field.onChange} defaultValue={field.value} value={field.value}>
|
|
||||||
<FormControl>
|
|
||||||
<SelectTrigger>
|
|
||||||
<SelectValue placeholder="Select compression mode" />
|
|
||||||
</SelectTrigger>
|
|
||||||
</FormControl>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="off">Off</SelectItem>
|
|
||||||
<SelectItem value="auto">Auto (fast)</SelectItem>
|
|
||||||
<SelectItem value="max">Max (slower, better compression)</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
<FormDescription>Compression mode for backups stored in this repository.</FormDescription>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="isExistingRepository"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem className="flex flex-row items-center space-x-3">
|
|
||||||
<FormControl>
|
|
||||||
<Checkbox
|
|
||||||
checked={field.value}
|
|
||||||
onCheckedChange={(checked) => {
|
|
||||||
field.onChange(checked);
|
|
||||||
if (!checked) {
|
|
||||||
setPasswordMode("default");
|
|
||||||
setValue("customPassword", undefined);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</FormControl>
|
|
||||||
<div className="space-y-1">
|
|
||||||
<FormLabel>Import existing repository</FormLabel>
|
|
||||||
<FormDescription>Check this if the repository already exists at the specified location</FormDescription>
|
|
||||||
</div>
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
{watchedIsExistingRepository && (
|
|
||||||
<>
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel>Repository Password</FormLabel>
|
|
||||||
<Select
|
|
||||||
onValueChange={(value) => {
|
|
||||||
setPasswordMode(value as "default" | "custom");
|
|
||||||
if (value === "default") {
|
|
||||||
setValue("customPassword", undefined);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
defaultValue={passwordMode}
|
|
||||||
value={passwordMode}
|
|
||||||
>
|
|
||||||
<FormControl>
|
|
||||||
<SelectTrigger>
|
|
||||||
<SelectValue placeholder="Select password option" />
|
|
||||||
</SelectTrigger>
|
|
||||||
</FormControl>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="default">Use Zerobyte's password</SelectItem>
|
|
||||||
<SelectItem value="custom">Enter password manually</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
<FormDescription>
|
|
||||||
Choose whether to use Zerobyte's master password or enter a custom password for the existing repository.
|
|
||||||
</FormDescription>
|
|
||||||
</FormItem>
|
|
||||||
|
|
||||||
{passwordMode === "custom" && (
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="customPassword"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel>Repository Password</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
<Input type="password" placeholder="Enter repository password" {...field} />
|
|
||||||
</FormControl>
|
|
||||||
<FormDescription>
|
|
||||||
The password used to encrypt this repository. It will be stored securely.
|
|
||||||
</FormDescription>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{watchedBackend === "local" && (
|
|
||||||
<>
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel>Repository Directory</FormLabel>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<div className="flex-1 text-sm font-mono bg-muted px-3 py-2 rounded-md border">
|
|
||||||
{form.watch("path") || "/var/lib/zerobyte/repositories"}
|
|
||||||
</div>
|
|
||||||
<Button type="button" variant="outline" onClick={() => setShowPathWarning(true)} size="sm">
|
|
||||||
<Pencil className="h-4 w-4 mr-2" />
|
|
||||||
Change
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
<FormDescription>The directory where the repository will be stored.</FormDescription>
|
|
||||||
</FormItem>
|
|
||||||
|
|
||||||
<AlertDialog open={showPathWarning} onOpenChange={setShowPathWarning}>
|
|
||||||
<AlertDialogContent>
|
|
||||||
<AlertDialogHeader>
|
|
||||||
<AlertDialogTitle className="flex items-center gap-2">
|
|
||||||
<AlertTriangle className="h-5 w-5 text-yellow-500" />
|
|
||||||
Important: Host mount required
|
|
||||||
</AlertDialogTitle>
|
|
||||||
<AlertDialogDescription className="space-y-3">
|
|
||||||
<p>When selecting a custom path, ensure it is mounted from the host machine into the container.</p>
|
|
||||||
<p className="font-medium">
|
|
||||||
If the path is not a host mount, you will lose your repository data when the container restarts.
|
|
||||||
</p>
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
The default path <code className="bg-muted px-1 rounded">/var/lib/zerobyte/repositories</code> is
|
|
||||||
already mounted from the host and is safe to use.
|
|
||||||
</p>
|
|
||||||
</AlertDialogDescription>
|
|
||||||
</AlertDialogHeader>
|
|
||||||
<AlertDialogFooter>
|
|
||||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
|
||||||
<AlertDialogAction
|
|
||||||
onClick={() => {
|
|
||||||
setShowPathBrowser(true);
|
|
||||||
setShowPathWarning(false);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
I Understand, Continue
|
|
||||||
</AlertDialogAction>
|
|
||||||
</AlertDialogFooter>
|
|
||||||
</AlertDialogContent>
|
|
||||||
</AlertDialog>
|
|
||||||
|
|
||||||
<AlertDialog open={showPathBrowser} onOpenChange={setShowPathBrowser}>
|
|
||||||
<AlertDialogContent className="max-w-2xl">
|
|
||||||
<AlertDialogHeader>
|
|
||||||
<AlertDialogTitle>Select Repository Directory</AlertDialogTitle>
|
|
||||||
<AlertDialogDescription>
|
|
||||||
Choose a directory from the filesystem to store the repository.
|
|
||||||
</AlertDialogDescription>
|
|
||||||
</AlertDialogHeader>
|
|
||||||
<div className="py-4">
|
|
||||||
<DirectoryBrowser
|
|
||||||
onSelectPath={(path) => form.setValue("path", path)}
|
|
||||||
selectedPath={form.watch("path") || "/var/lib/zerobyte/repositories"}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<AlertDialogFooter>
|
|
||||||
<AlertDialogCancel>
|
|
||||||
<X className="h-4 w-4 mr-2" />
|
|
||||||
Cancel
|
|
||||||
</AlertDialogCancel>
|
|
||||||
<AlertDialogAction onClick={() => setShowPathBrowser(false)}>
|
|
||||||
<Check className="h-4 w-4 mr-2" />
|
|
||||||
Done
|
|
||||||
</AlertDialogAction>
|
|
||||||
</AlertDialogFooter>
|
|
||||||
</AlertDialogContent>
|
|
||||||
</AlertDialog>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{watchedBackend === "s3" && (
|
|
||||||
<>
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="endpoint"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel>Endpoint</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
<Input placeholder="s3.amazonaws.com" {...field} />
|
|
||||||
</FormControl>
|
|
||||||
<FormDescription>S3-compatible endpoint URL.</FormDescription>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="bucket"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel>Bucket</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
<Input placeholder="my-backup-bucket" {...field} />
|
|
||||||
</FormControl>
|
|
||||||
<FormDescription>S3 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="AKIAIOSFODNN7EXAMPLE" {...field} />
|
|
||||||
</FormControl>
|
|
||||||
<FormDescription>S3 access key ID for authentication.</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>S3 secret access key for authentication.</FormDescription>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{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" && (
|
|
||||||
<>
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="bucket"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel>Bucket</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
<Input placeholder="my-backup-bucket" {...field} />
|
|
||||||
</FormControl>
|
|
||||||
<FormDescription>GCS bucket name for storing backups.</FormDescription>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="projectId"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel>Project ID</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
<Input placeholder="my-gcp-project-123" {...field} />
|
|
||||||
</FormControl>
|
|
||||||
<FormDescription>Google Cloud project ID.</FormDescription>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="credentialsJson"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel>Service Account JSON</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
<Input type="password" placeholder="Paste service account JSON key..." {...field} />
|
|
||||||
</FormControl>
|
|
||||||
<FormDescription>Service account JSON credentials for authentication.</FormDescription>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{watchedBackend === "azure" && (
|
|
||||||
<>
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="container"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel>Container</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
<Input placeholder="my-backup-container" {...field} />
|
|
||||||
</FormControl>
|
|
||||||
<FormDescription>Azure Blob Storage container name for storing backups.</FormDescription>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="accountName"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel>Account Name</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
<Input placeholder="mystorageaccount" {...field} />
|
|
||||||
</FormControl>
|
|
||||||
<FormDescription>Azure Storage account name.</FormDescription>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="accountKey"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel>Account Key</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
<Input type="password" placeholder="••••••••" {...field} />
|
|
||||||
</FormControl>
|
|
||||||
<FormDescription>Azure Storage account key for authentication.</FormDescription>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="endpointSuffix"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel>Endpoint Suffix (Optional)</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
<Input placeholder="core.windows.net" {...field} />
|
|
||||||
</FormControl>
|
|
||||||
<FormDescription>Custom Azure endpoint suffix (defaults to core.windows.net).</FormDescription>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{watchedBackend === "rclone" &&
|
|
||||||
(!rcloneRemotes || rcloneRemotes.length === 0 ? (
|
|
||||||
<Alert>
|
|
||||||
<AlertDescription className="space-y-2">
|
|
||||||
<p className="font-medium">No rclone remotes configured</p>
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
To use rclone, you need to configure remotes on your host system
|
|
||||||
</p>
|
|
||||||
<a
|
|
||||||
href="https://rclone.org/docs/"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="text-sm text-strong-accent inline-flex items-center gap-1"
|
|
||||||
>
|
|
||||||
View rclone documentation
|
|
||||||
<ExternalLink className="w-3 h-3" />
|
|
||||||
</a>
|
|
||||||
</AlertDescription>
|
|
||||||
</Alert>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="remote"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel>Remote</FormLabel>
|
|
||||||
<Select onValueChange={(v) => field.onChange(v)} defaultValue={field.value} value={field.value}>
|
|
||||||
<FormControl>
|
|
||||||
<SelectTrigger>
|
|
||||||
<SelectValue placeholder="Select an rclone remote" />
|
|
||||||
</SelectTrigger>
|
|
||||||
</FormControl>
|
|
||||||
<SelectContent>
|
|
||||||
{isLoadingRemotes ? (
|
|
||||||
<SelectItem value="loading" disabled>
|
|
||||||
Loading remotes...
|
|
||||||
</SelectItem>
|
|
||||||
) : (
|
|
||||||
rcloneRemotes.map((remote: { name: string; type: string }) => (
|
|
||||||
<SelectItem key={remote.name} value={remote.name}>
|
|
||||||
{remote.name} ({remote.type})
|
|
||||||
</SelectItem>
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
<FormDescription>Select the rclone remote configured on your host system.</FormDescription>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="path"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel>Path</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
<Input placeholder="backups/zerobyte" {...field} />
|
|
||||||
</FormControl>
|
|
||||||
<FormDescription>Path within the remote where backups will be stored.</FormDescription>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
))}
|
|
||||||
|
|
||||||
{watchedBackend === "rest" && (
|
|
||||||
<>
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="url"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel>REST Server URL</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
<Input placeholder="http://192.168.1.30:8000" {...field} />
|
|
||||||
</FormControl>
|
|
||||||
<FormDescription>URL of the REST server.</FormDescription>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="path"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel>Repository Path (Optional)</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
<Input placeholder="my-backup-repo" {...field} />
|
|
||||||
</FormControl>
|
|
||||||
<FormDescription>Path to the repository on the REST server (leave empty for root).</FormDescription>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="username"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel>Username (Optional)</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
<Input placeholder="username" {...field} />
|
|
||||||
</FormControl>
|
|
||||||
<FormDescription>Username for REST server authentication.</FormDescription>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="password"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel>Password (Optional)</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
<Input type="password" placeholder="••••••••" {...field} />
|
|
||||||
</FormControl>
|
|
||||||
<FormDescription>Password for REST server authentication.</FormDescription>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{watchedBackend === "sftp" && (
|
|
||||||
<>
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="host"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel>Host</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
<Input placeholder="192.168.1.100" {...field} />
|
|
||||||
</FormControl>
|
|
||||||
<FormDescription>SFTP server hostname or IP address.</FormDescription>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="port"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel>Port</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
<Input
|
|
||||||
type="number"
|
|
||||||
placeholder="22"
|
|
||||||
{...field}
|
|
||||||
onChange={(e) => field.onChange(parseInt(e.target.value, 10))}
|
|
||||||
/>
|
|
||||||
</FormControl>
|
|
||||||
<FormDescription>SSH port (default: 22).</FormDescription>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="user"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel>User</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
<Input placeholder="backup-user" {...field} />
|
|
||||||
</FormControl>
|
|
||||||
<FormDescription>SSH username for authentication.</FormDescription>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="path"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel>Path</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
<Input placeholder="backups/ironmount" {...field} />
|
|
||||||
</FormControl>
|
|
||||||
<FormDescription>Repository path on the SFTP server. </FormDescription>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="privateKey"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel>SSH Private Key</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
<Textarea
|
|
||||||
{...field}
|
|
||||||
placeholder="-----BEGIN OPENSSH PRIVATE KEY----- ... -----END OPENSSH PRIVATE KEY-----"
|
|
||||||
/>
|
|
||||||
</FormControl>
|
|
||||||
<FormDescription>Paste the contents of your SSH private key.</FormDescription>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{mode === "update" && (
|
|
||||||
<Button type="submit" className="w-full" loading={loading}>
|
|
||||||
<Save className="h-4 w-4 mr-2" />
|
|
||||||
Save Changes
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</form>
|
|
||||||
</Form>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
@ -1,709 +0,0 @@
|
||||||
import { arktypeResolver } from "@hookform/resolvers/arktype";
|
|
||||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
|
||||||
import { type } from "arktype";
|
|
||||||
import { CheckCircle, ExternalLink, Loader2, Pencil, Plug, Save, XCircle } from "lucide-react";
|
|
||||||
import { useEffect, useState } from "react";
|
|
||||||
import { useForm } from "react-hook-form";
|
|
||||||
import { cn, slugify } from "~/client/lib/utils";
|
|
||||||
import { deepClean } from "~/utils/object";
|
|
||||||
import { DirectoryBrowser } from "./directory-browser";
|
|
||||||
import { Button } from "./ui/button";
|
|
||||||
import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from "./ui/form";
|
|
||||||
import { Input } from "./ui/input";
|
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select";
|
|
||||||
import { volumeConfigSchema } from "~/schemas/volumes";
|
|
||||||
import { listRcloneRemotesOptions, testConnectionMutation } from "../api-client/@tanstack/react-query.gen";
|
|
||||||
import { Alert, AlertDescription } from "./ui/alert";
|
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip";
|
|
||||||
import { useSystemInfo } from "~/client/hooks/use-system-info";
|
|
||||||
|
|
||||||
export const formSchema = type({
|
|
||||||
name: "2<=string<=32",
|
|
||||||
}).and(volumeConfigSchema);
|
|
||||||
const cleanSchema = type.pipe((d) => formSchema(deepClean(d)));
|
|
||||||
|
|
||||||
export type FormValues = typeof formSchema.inferIn;
|
|
||||||
|
|
||||||
type Props = {
|
|
||||||
onSubmit: (values: FormValues) => void;
|
|
||||||
mode?: "create" | "update";
|
|
||||||
initialValues?: Partial<FormValues>;
|
|
||||||
formId?: string;
|
|
||||||
loading?: boolean;
|
|
||||||
className?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
const defaultValuesForType = {
|
|
||||||
directory: { backend: "directory" as const, path: "/" },
|
|
||||||
nfs: { backend: "nfs" as const, port: 2049, version: "4.1" as const },
|
|
||||||
smb: { backend: "smb" as const, port: 445, vers: "3.0" as const },
|
|
||||||
webdav: { backend: "webdav" as const, port: 80, ssl: false },
|
|
||||||
rclone: { backend: "rclone" as const, remote: "", path: "" },
|
|
||||||
};
|
|
||||||
|
|
||||||
export const CreateVolumeForm = ({ onSubmit, mode = "create", initialValues, formId, loading, className }: Props) => {
|
|
||||||
const form = useForm<FormValues>({
|
|
||||||
resolver: arktypeResolver(cleanSchema as unknown as typeof formSchema),
|
|
||||||
defaultValues: initialValues,
|
|
||||||
resetOptions: {
|
|
||||||
keepDefaultValues: true,
|
|
||||||
keepDirtyValues: false,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const { watch, getValues } = form;
|
|
||||||
|
|
||||||
const { capabilities } = useSystemInfo();
|
|
||||||
|
|
||||||
const { data: rcloneRemotes, isLoading: isLoadingRemotes } = useQuery({
|
|
||||||
...listRcloneRemotesOptions(),
|
|
||||||
enabled: capabilities.rclone,
|
|
||||||
});
|
|
||||||
|
|
||||||
const watchedBackend = watch("backend");
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (mode === "create") {
|
|
||||||
form.reset({
|
|
||||||
name: form.getValues().name,
|
|
||||||
...defaultValuesForType[watchedBackend as keyof typeof defaultValuesForType],
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}, [watchedBackend, form, mode]);
|
|
||||||
|
|
||||||
const [testMessage, setTestMessage] = useState<{ success: boolean; message: string } | null>(null);
|
|
||||||
|
|
||||||
const testBackendConnection = useMutation({
|
|
||||||
...testConnectionMutation(),
|
|
||||||
onMutate: () => {
|
|
||||||
setTestMessage(null);
|
|
||||||
},
|
|
||||||
onError: (error) => {
|
|
||||||
setTestMessage({
|
|
||||||
success: false,
|
|
||||||
message: error?.message || "Failed to test connection. Please try again.",
|
|
||||||
});
|
|
||||||
},
|
|
||||||
onSuccess: (data) => {
|
|
||||||
setTestMessage(data);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const handleTestConnection = async () => {
|
|
||||||
const formValues = getValues();
|
|
||||||
|
|
||||||
if (formValues.backend === "nfs" || formValues.backend === "smb" || formValues.backend === "webdav") {
|
|
||||||
testBackendConnection.mutate({
|
|
||||||
body: { config: formValues },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Form {...form}>
|
|
||||||
<form id={formId} onSubmit={form.handleSubmit(onSubmit)} className={cn("space-y-4", className)}>
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="name"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel>Name</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
<Input
|
|
||||||
{...field}
|
|
||||||
placeholder="Volume name"
|
|
||||||
onChange={(e) => field.onChange(slugify(e.target.value))}
|
|
||||||
max={32}
|
|
||||||
min={1}
|
|
||||||
/>
|
|
||||||
</FormControl>
|
|
||||||
<FormDescription>Unique identifier for the volume.</FormDescription>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="backend"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel>Backend</FormLabel>
|
|
||||||
<Select onValueChange={field.onChange} defaultValue={field.value} value={field.value}>
|
|
||||||
<FormControl>
|
|
||||||
<SelectTrigger>
|
|
||||||
<SelectValue placeholder="Select a backend" />
|
|
||||||
</SelectTrigger>
|
|
||||||
</FormControl>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="directory">Directory</SelectItem>
|
|
||||||
<SelectItem value="nfs">NFS</SelectItem>
|
|
||||||
<SelectItem value="smb">SMB</SelectItem>
|
|
||||||
<SelectItem value="webdav">WebDAV</SelectItem>
|
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger>
|
|
||||||
<SelectItem disabled={!capabilities.rclone} value="rclone">
|
|
||||||
rclone (40+ cloud providers)
|
|
||||||
</SelectItem>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent className={cn({ hidden: capabilities.rclone })}>
|
|
||||||
<p>Setup rclone to use this backend</p>
|
|
||||||
</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
<FormDescription>Choose the storage backend for this volume.</FormDescription>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{watchedBackend === "directory" && (
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="path"
|
|
||||||
render={({ field }) => {
|
|
||||||
return (
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel>Directory Path</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
{field.value ? (
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<div className="flex-1 border rounded-md p-3 bg-muted/50">
|
|
||||||
<div className="text-xs font-medium text-muted-foreground mb-1">Selected path:</div>
|
|
||||||
<div className="text-sm font-mono break-all">{field.value}</div>
|
|
||||||
</div>
|
|
||||||
<Button type="button" variant="outline" size="sm" onClick={() => field.onChange("")}>
|
|
||||||
<Pencil className="h-4 w-4 mr-2" />
|
|
||||||
Change
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<DirectoryBrowser onSelectPath={(path) => field.onChange(path)} selectedPath={field.value} />
|
|
||||||
)}
|
|
||||||
</FormControl>
|
|
||||||
<FormDescription>Browse and select a directory on the host filesystem to track.</FormDescription>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{watchedBackend === "nfs" && (
|
|
||||||
<>
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="server"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel>Server</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
<Input placeholder="192.168.1.100" {...field} />
|
|
||||||
</FormControl>
|
|
||||||
<FormDescription>NFS server IP address or hostname.</FormDescription>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="exportPath"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel>Export Path</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
<Input placeholder="/export/data" {...field} />
|
|
||||||
</FormControl>
|
|
||||||
<FormDescription>Path to the NFS export on the server.</FormDescription>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="port"
|
|
||||||
defaultValue={2049}
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel>Port</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
<Input
|
|
||||||
type="number"
|
|
||||||
placeholder="2049"
|
|
||||||
{...field}
|
|
||||||
onChange={(e) => field.onChange(parseInt(e.target.value, 10) || undefined)}
|
|
||||||
/>
|
|
||||||
</FormControl>
|
|
||||||
<FormDescription>NFS server port (default: 2049).</FormDescription>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="version"
|
|
||||||
defaultValue="4.1"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel>Version</FormLabel>
|
|
||||||
<Select onValueChange={field.onChange} defaultValue="4.1">
|
|
||||||
<FormControl>
|
|
||||||
<SelectTrigger>
|
|
||||||
<SelectValue placeholder="Select NFS version" />
|
|
||||||
</SelectTrigger>
|
|
||||||
</FormControl>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="3">NFS v3</SelectItem>
|
|
||||||
<SelectItem value="4">NFS v4</SelectItem>
|
|
||||||
<SelectItem value="4.1">NFS v4.1</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
<FormDescription>NFS protocol version to use.</FormDescription>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="readOnly"
|
|
||||||
defaultValue={false}
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel>Read-only Mode</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
<div className="flex items-center space-x-2">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={field.value ?? false}
|
|
||||||
onChange={(e) => field.onChange(e.target.checked)}
|
|
||||||
className="rounded border-gray-300"
|
|
||||||
/>
|
|
||||||
<span className="text-sm">Mount volume as read-only</span>
|
|
||||||
</div>
|
|
||||||
</FormControl>
|
|
||||||
<FormDescription>
|
|
||||||
Prevent any modifications to the volume. Recommended for backup sources and sensitive data.
|
|
||||||
</FormDescription>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{watchedBackend === "webdav" && (
|
|
||||||
<>
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="server"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel>Server</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
<Input placeholder="example.com" {...field} />
|
|
||||||
</FormControl>
|
|
||||||
<FormDescription>WebDAV server hostname or IP address.</FormDescription>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="path"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel>Path</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
<Input placeholder="/webdav" {...field} />
|
|
||||||
</FormControl>
|
|
||||||
<FormDescription>Path to the WebDAV directory on the server.</FormDescription>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="username"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel>Username (Optional)</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
<Input placeholder="admin" {...field} />
|
|
||||||
</FormControl>
|
|
||||||
<FormDescription>Username for WebDAV authentication (optional).</FormDescription>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="password"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel>Password (Optional)</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
<Input type="password" placeholder="••••••••" {...field} />
|
|
||||||
</FormControl>
|
|
||||||
<FormDescription>Password for WebDAV authentication (optional).</FormDescription>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="port"
|
|
||||||
defaultValue={80}
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel>Port</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
<Input
|
|
||||||
type="number"
|
|
||||||
placeholder="80"
|
|
||||||
{...field}
|
|
||||||
onChange={(e) => field.onChange(parseInt(e.target.value, 10) || undefined)}
|
|
||||||
/>
|
|
||||||
</FormControl>
|
|
||||||
<FormDescription>WebDAV server port (default: 80 for HTTP, 443 for HTTPS).</FormDescription>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="ssl"
|
|
||||||
defaultValue={false}
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel>Use SSL/HTTPS</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
<div className="flex items-center space-x-2">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={field.value ?? false}
|
|
||||||
onChange={(e) => field.onChange(e.target.checked)}
|
|
||||||
className="rounded border-gray-300"
|
|
||||||
/>
|
|
||||||
<span className="text-sm">Enable HTTPS for secure connections</span>
|
|
||||||
</div>
|
|
||||||
</FormControl>
|
|
||||||
<FormDescription>Use HTTPS instead of HTTP for secure connections.</FormDescription>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="readOnly"
|
|
||||||
defaultValue={false}
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel>Read-only Mode</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
<div className="flex items-center space-x-2">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={field.value ?? false}
|
|
||||||
onChange={(e) => field.onChange(e.target.checked)}
|
|
||||||
className="rounded border-gray-300"
|
|
||||||
/>
|
|
||||||
<span className="text-sm">Mount volume as read-only</span>
|
|
||||||
</div>
|
|
||||||
</FormControl>
|
|
||||||
<FormDescription>
|
|
||||||
Prevent any modifications to the volume. Recommended for backup sources and sensitive data.
|
|
||||||
</FormDescription>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{watchedBackend === "smb" && (
|
|
||||||
<>
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="server"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel>Server</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
<Input placeholder="192.168.1.100" value={field.value} onChange={field.onChange} />
|
|
||||||
</FormControl>
|
|
||||||
<FormDescription>SMB server IP address or hostname.</FormDescription>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="share"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel>Share</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
<Input placeholder="myshare" value={field.value} onChange={field.onChange} />
|
|
||||||
</FormControl>
|
|
||||||
<FormDescription>SMB share name on the server.</FormDescription>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="username"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel>Username</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
<Input placeholder="admin" value={field.value} onChange={field.onChange} />
|
|
||||||
</FormControl>
|
|
||||||
<FormDescription>Username for SMB authentication.</FormDescription>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="password"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel>Password</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
<Input type="password" placeholder="••••••••" value={field.value} onChange={field.onChange} />
|
|
||||||
</FormControl>
|
|
||||||
<FormDescription>Password for SMB authentication.</FormDescription>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="vers"
|
|
||||||
defaultValue="3.0"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel>SMB Version</FormLabel>
|
|
||||||
<Select onValueChange={field.onChange} defaultValue={field.value || "3.0"}>
|
|
||||||
<FormControl>
|
|
||||||
<SelectTrigger>
|
|
||||||
<SelectValue placeholder="Select SMB version" />
|
|
||||||
</SelectTrigger>
|
|
||||||
</FormControl>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="1.0">SMB v1.0</SelectItem>
|
|
||||||
<SelectItem value="2.0">SMB v2.0</SelectItem>
|
|
||||||
<SelectItem value="2.1">SMB v2.1</SelectItem>
|
|
||||||
<SelectItem value="3.0">SMB v3.0</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
<FormDescription>SMB protocol version to use (default: 3.0).</FormDescription>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="domain"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel>Domain (Optional)</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
<Input placeholder="WORKGROUP" value={field.value} onChange={field.onChange} />
|
|
||||||
</FormControl>
|
|
||||||
<FormDescription>Domain or workgroup for authentication (optional).</FormDescription>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="port"
|
|
||||||
defaultValue={445}
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel>Port</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
<Input
|
|
||||||
type="number"
|
|
||||||
placeholder="445"
|
|
||||||
value={field.value}
|
|
||||||
defaultValue={445}
|
|
||||||
onChange={(e) => field.onChange(parseInt(e.target.value, 10) || undefined)}
|
|
||||||
/>
|
|
||||||
</FormControl>
|
|
||||||
<FormDescription>SMB server port (default: 445).</FormDescription>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="readOnly"
|
|
||||||
defaultValue={false}
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel>Read-only Mode</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
<div className="flex items-center space-x-2">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={field.value ?? false}
|
|
||||||
onChange={(e) => field.onChange(e.target.checked)}
|
|
||||||
className="rounded border-gray-300"
|
|
||||||
/>
|
|
||||||
<span className="text-sm">Mount volume as read-only</span>
|
|
||||||
</div>
|
|
||||||
</FormControl>
|
|
||||||
<FormDescription>
|
|
||||||
Prevent any modifications to the volume. Recommended for backup sources and sensitive data.
|
|
||||||
</FormDescription>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{watchedBackend === "rclone" &&
|
|
||||||
(!rcloneRemotes || rcloneRemotes.length === 0 ? (
|
|
||||||
<Alert>
|
|
||||||
<AlertDescription className="space-y-2">
|
|
||||||
<p className="font-medium">No rclone remotes configured</p>
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
To use rclone, you need to configure remotes on your host system
|
|
||||||
</p>
|
|
||||||
<a
|
|
||||||
href="https://rclone.org/docs/"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="text-sm text-strong-accent inline-flex items-center gap-1"
|
|
||||||
>
|
|
||||||
View rclone documentation
|
|
||||||
<ExternalLink className="w-3 h-3" />
|
|
||||||
</a>
|
|
||||||
</AlertDescription>
|
|
||||||
</Alert>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="remote"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel>Remote</FormLabel>
|
|
||||||
<Select onValueChange={(v) => field.onChange(v)} defaultValue={field.value} value={field.value}>
|
|
||||||
<FormControl>
|
|
||||||
<SelectTrigger>
|
|
||||||
<SelectValue placeholder="Select an rclone remote" />
|
|
||||||
</SelectTrigger>
|
|
||||||
</FormControl>
|
|
||||||
<SelectContent>
|
|
||||||
{isLoadingRemotes ? (
|
|
||||||
<SelectItem value="loading" disabled>
|
|
||||||
Loading remotes...
|
|
||||||
</SelectItem>
|
|
||||||
) : (
|
|
||||||
rcloneRemotes.map((remote: { name: string; type: string }) => (
|
|
||||||
<SelectItem key={remote.name} value={remote.name}>
|
|
||||||
{remote.name} ({remote.type})
|
|
||||||
</SelectItem>
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
<FormDescription>Select the rclone remote configured on your host system.</FormDescription>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="path"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel>Path</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
<Input placeholder="/" {...field} />
|
|
||||||
</FormControl>
|
|
||||||
<FormDescription>Path on the remote to mount. Use "/" for the root.</FormDescription>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="readOnly"
|
|
||||||
defaultValue={false}
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel>Read-only Mode</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
<div className="flex items-center space-x-2">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={field.value ?? false}
|
|
||||||
onChange={(e) => field.onChange(e.target.checked)}
|
|
||||||
className="rounded border-gray-300"
|
|
||||||
/>
|
|
||||||
<span className="text-sm">Mount volume as read-only</span>
|
|
||||||
</div>
|
|
||||||
</FormControl>
|
|
||||||
<FormDescription>
|
|
||||||
Prevent any modifications to the volume. Recommended for backup sources and sensitive data.
|
|
||||||
</FormDescription>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
))} {watchedBackend && watchedBackend !== "directory" && watchedBackend !== "rclone" && (
|
|
||||||
<div className="space-y-3">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="outline"
|
|
||||||
onClick={handleTestConnection}
|
|
||||||
disabled={testBackendConnection.isPending}
|
|
||||||
className="flex-1"
|
|
||||||
>
|
|
||||||
{testBackendConnection.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
|
||||||
{!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 && <Plug className="mr-2 h-4 w-4" />}
|
|
||||||
{testBackendConnection.isPending
|
|
||||||
? "Testing..."
|
|
||||||
: testMessage
|
|
||||||
? testMessage.success
|
|
||||||
? "Connection Successful"
|
|
||||||
: "Test Failed"
|
|
||||||
: "Test Connection"}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
{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>
|
|
||||||
)}
|
|
||||||
{mode === "update" && (
|
|
||||||
<Button type="submit" className="w-full" loading={loading}>
|
|
||||||
<Save className="h-4 w-4 mr-2" />
|
|
||||||
Save Changes
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</form>
|
|
||||||
</Form>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
@ -0,0 +1,275 @@
|
||||||
|
import { arktypeResolver } from "@hookform/resolvers/arktype";
|
||||||
|
import { type } from "arktype";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { useForm } from "react-hook-form";
|
||||||
|
import { Save } from "lucide-react";
|
||||||
|
import { cn, slugify } from "~/client/lib/utils";
|
||||||
|
import { deepClean } from "~/utils/object";
|
||||||
|
import { Button } from "../../../components/ui/button";
|
||||||
|
import {
|
||||||
|
Form,
|
||||||
|
FormControl,
|
||||||
|
FormDescription,
|
||||||
|
FormField,
|
||||||
|
FormItem,
|
||||||
|
FormLabel,
|
||||||
|
FormMessage,
|
||||||
|
} from "../../../components/ui/form";
|
||||||
|
import { Input } from "../../../components/ui/input";
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "../../../components/ui/select";
|
||||||
|
import { Tooltip, TooltipContent, TooltipTrigger } from "../../../components/ui/tooltip";
|
||||||
|
import { useSystemInfo } from "~/client/hooks/use-system-info";
|
||||||
|
import { COMPRESSION_MODES, repositoryConfigSchema } from "~/schemas/restic";
|
||||||
|
import { Checkbox } from "../../../components/ui/checkbox";
|
||||||
|
import {
|
||||||
|
LocalRepositoryForm,
|
||||||
|
S3RepositoryForm,
|
||||||
|
R2RepositoryForm,
|
||||||
|
GCSRepositoryForm,
|
||||||
|
AzureRepositoryForm,
|
||||||
|
RcloneRepositoryForm,
|
||||||
|
RestRepositoryForm,
|
||||||
|
SftpRepositoryForm,
|
||||||
|
} from "./repository-forms";
|
||||||
|
|
||||||
|
export const formSchema = type({
|
||||||
|
name: "2<=string<=32",
|
||||||
|
compressionMode: type.valueOf(COMPRESSION_MODES).optional(),
|
||||||
|
}).and(repositoryConfigSchema);
|
||||||
|
const cleanSchema = type.pipe((d) => formSchema(deepClean(d)));
|
||||||
|
|
||||||
|
export type RepositoryFormValues = typeof formSchema.inferIn;
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
onSubmit: (values: RepositoryFormValues) => void;
|
||||||
|
mode?: "create" | "update";
|
||||||
|
initialValues?: Partial<RepositoryFormValues>;
|
||||||
|
formId?: string;
|
||||||
|
loading?: boolean;
|
||||||
|
className?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const defaultValuesForType = {
|
||||||
|
local: { backend: "local" 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 },
|
||||||
|
azure: { backend: "azure" as const, compressionMode: "auto" as const },
|
||||||
|
rclone: { backend: "rclone" as const, compressionMode: "auto" as const },
|
||||||
|
rest: { backend: "rest" as const, compressionMode: "auto" as const },
|
||||||
|
sftp: { backend: "sftp" as const, compressionMode: "auto" as const, port: 22 },
|
||||||
|
};
|
||||||
|
|
||||||
|
export const CreateRepositoryForm = ({
|
||||||
|
onSubmit,
|
||||||
|
mode = "create",
|
||||||
|
initialValues,
|
||||||
|
formId,
|
||||||
|
loading,
|
||||||
|
className,
|
||||||
|
}: Props) => {
|
||||||
|
const form = useForm<RepositoryFormValues>({
|
||||||
|
resolver: arktypeResolver(cleanSchema as unknown as typeof formSchema),
|
||||||
|
defaultValues: initialValues,
|
||||||
|
resetOptions: {
|
||||||
|
keepDefaultValues: true,
|
||||||
|
keepDirtyValues: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const { watch, setValue } = form;
|
||||||
|
|
||||||
|
const watchedBackend = watch("backend");
|
||||||
|
const watchedIsExistingRepository = watch("isExistingRepository");
|
||||||
|
|
||||||
|
const [passwordMode, setPasswordMode] = useState<"default" | "custom">("default");
|
||||||
|
|
||||||
|
const { capabilities } = useSystemInfo();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
form.reset({
|
||||||
|
name: form.getValues().name,
|
||||||
|
isExistingRepository: form.getValues().isExistingRepository,
|
||||||
|
customPassword: form.getValues().customPassword,
|
||||||
|
...defaultValuesForType[watchedBackend as keyof typeof defaultValuesForType],
|
||||||
|
});
|
||||||
|
}, [watchedBackend, form]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Form {...form}>
|
||||||
|
<form id={formId} onSubmit={form.handleSubmit(onSubmit)} className={cn("space-y-4", className)}>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="name"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Name</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
{...field}
|
||||||
|
placeholder="Repository name"
|
||||||
|
onChange={(e) => field.onChange(slugify(e.target.value))}
|
||||||
|
maxLength={32}
|
||||||
|
minLength={2}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormDescription>Unique identifier for the repository.</FormDescription>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="backend"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Backend</FormLabel>
|
||||||
|
<Select onValueChange={field.onChange} defaultValue={field.value} value={field.value}>
|
||||||
|
<FormControl>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Select a backend" />
|
||||||
|
</SelectTrigger>
|
||||||
|
</FormControl>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="local">Local</SelectItem>
|
||||||
|
<SelectItem value="s3">S3</SelectItem>
|
||||||
|
<SelectItem value="r2">Cloudflare R2</SelectItem>
|
||||||
|
<SelectItem value="gcs">Google Cloud Storage</SelectItem>
|
||||||
|
<SelectItem value="azure">Azure Blob Storage</SelectItem>
|
||||||
|
<SelectItem value="rest">REST Server</SelectItem>
|
||||||
|
<SelectItem value="sftp">SFTP</SelectItem>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger>
|
||||||
|
<SelectItem disabled={!capabilities.rclone} value="rclone">
|
||||||
|
rclone (40+ cloud providers)
|
||||||
|
</SelectItem>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent className={cn({ hidden: capabilities.rclone })}>
|
||||||
|
<p>Setup rclone to use this backend</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<FormDescription>Choose the storage backend for this repository.</FormDescription>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="compressionMode"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Compression Mode</FormLabel>
|
||||||
|
<Select onValueChange={field.onChange} defaultValue={field.value} value={field.value}>
|
||||||
|
<FormControl>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Select compression mode" />
|
||||||
|
</SelectTrigger>
|
||||||
|
</FormControl>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="off">Off</SelectItem>
|
||||||
|
<SelectItem value="auto">Auto (fast)</SelectItem>
|
||||||
|
<SelectItem value="max">Max (slower, better compression)</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<FormDescription>Compression mode for backups stored in this repository.</FormDescription>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="isExistingRepository"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem className="flex flex-row items-center space-x-3">
|
||||||
|
<FormControl>
|
||||||
|
<Checkbox
|
||||||
|
checked={field.value}
|
||||||
|
onCheckedChange={(checked) => {
|
||||||
|
field.onChange(checked);
|
||||||
|
if (!checked) {
|
||||||
|
setPasswordMode("default");
|
||||||
|
setValue("customPassword", undefined);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<FormLabel>Import existing repository</FormLabel>
|
||||||
|
<FormDescription>Check this if the repository already exists at the specified location</FormDescription>
|
||||||
|
</div>
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
{watchedIsExistingRepository && (
|
||||||
|
<>
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Repository Password</FormLabel>
|
||||||
|
<Select
|
||||||
|
onValueChange={(value) => {
|
||||||
|
setPasswordMode(value as "default" | "custom");
|
||||||
|
if (value === "default") {
|
||||||
|
setValue("customPassword", undefined);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
defaultValue={passwordMode}
|
||||||
|
value={passwordMode}
|
||||||
|
>
|
||||||
|
<FormControl>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Select password option" />
|
||||||
|
</SelectTrigger>
|
||||||
|
</FormControl>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="default">Use Zerobyte's password</SelectItem>
|
||||||
|
<SelectItem value="custom">Enter password manually</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<FormDescription>
|
||||||
|
Choose whether to use Zerobyte's master password or enter a custom password for the existing repository.
|
||||||
|
</FormDescription>
|
||||||
|
</FormItem>
|
||||||
|
|
||||||
|
{passwordMode === "custom" && (
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="customPassword"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Repository Password</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input type="password" placeholder="Enter repository password" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormDescription>
|
||||||
|
The password used to encrypt this repository. It will be stored securely.
|
||||||
|
</FormDescription>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{watchedBackend === "local" && <LocalRepositoryForm form={form} />}
|
||||||
|
{watchedBackend === "s3" && <S3RepositoryForm form={form} />}
|
||||||
|
{watchedBackend === "r2" && <R2RepositoryForm form={form} />}
|
||||||
|
{watchedBackend === "gcs" && <GCSRepositoryForm form={form} />}
|
||||||
|
{watchedBackend === "azure" && <AzureRepositoryForm form={form} />}
|
||||||
|
{watchedBackend === "rclone" && <RcloneRepositoryForm form={form} />}
|
||||||
|
{watchedBackend === "rest" && <RestRepositoryForm form={form} />}
|
||||||
|
{watchedBackend === "sftp" && <SftpRepositoryForm form={form} />}
|
||||||
|
|
||||||
|
{mode === "update" && (
|
||||||
|
<Button type="submit" className="w-full" loading={loading}>
|
||||||
|
<Save className="h-4 w-4 mr-2" />
|
||||||
|
Save Changes
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,78 @@
|
||||||
|
import type { UseFormReturn } from "react-hook-form";
|
||||||
|
import {
|
||||||
|
FormControl,
|
||||||
|
FormDescription,
|
||||||
|
FormField,
|
||||||
|
FormItem,
|
||||||
|
FormLabel,
|
||||||
|
FormMessage,
|
||||||
|
} from "../../../../components/ui/form";
|
||||||
|
import { Input } from "../../../../components/ui/input";
|
||||||
|
import type { RepositoryFormValues } from "../create-repository-form";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
form: UseFormReturn<RepositoryFormValues>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const AzureRepositoryForm = ({ form }: Props) => {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="container"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Container</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input placeholder="my-backup-container" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormDescription>Azure Blob Storage container name for storing backups.</FormDescription>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="accountName"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Account Name</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input placeholder="mystorageaccount" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormDescription>Azure Storage account name.</FormDescription>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="accountKey"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Account Key</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input type="password" placeholder="••••••••" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormDescription>Azure Storage account key for authentication.</FormDescription>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="endpointSuffix"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Endpoint Suffix (Optional)</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input placeholder="core.windows.net" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormDescription>Custom Azure endpoint suffix (defaults to core.windows.net).</FormDescription>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,65 @@
|
||||||
|
import type { UseFormReturn } from "react-hook-form";
|
||||||
|
import {
|
||||||
|
FormControl,
|
||||||
|
FormDescription,
|
||||||
|
FormField,
|
||||||
|
FormItem,
|
||||||
|
FormLabel,
|
||||||
|
FormMessage,
|
||||||
|
} from "../../../../components/ui/form";
|
||||||
|
import { Input } from "../../../../components/ui/input";
|
||||||
|
import { Textarea } from "../../../../components/ui/textarea";
|
||||||
|
import type { RepositoryFormValues } from "../create-repository-form";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
form: UseFormReturn<RepositoryFormValues>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const GCSRepositoryForm = ({ form }: Props) => {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="bucket"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Bucket</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input placeholder="my-backup-bucket" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormDescription>GCS bucket name for storing backups.</FormDescription>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="projectId"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Project ID</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input placeholder="my-gcp-project-123" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormDescription>Google Cloud project ID.</FormDescription>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="credentialsJson"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Service Account JSON</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Textarea placeholder="Paste service account JSON key..." {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormDescription>Service account JSON credentials for authentication.</FormDescription>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,8 @@
|
||||||
|
export { LocalRepositoryForm } from "./local-repository-form";
|
||||||
|
export { S3RepositoryForm } from "./s3-repository-form";
|
||||||
|
export { R2RepositoryForm } from "./r2-repository-form";
|
||||||
|
export { GCSRepositoryForm } from "./gcs-repository-form";
|
||||||
|
export { AzureRepositoryForm } from "./azure-repository-form";
|
||||||
|
export { RcloneRepositoryForm } from "./rclone-repository-form";
|
||||||
|
export { RestRepositoryForm } from "./rest-repository-form";
|
||||||
|
export { SftpRepositoryForm } from "./sftp-repository-form";
|
||||||
|
|
@ -0,0 +1,103 @@
|
||||||
|
import { useState } from "react";
|
||||||
|
import type { UseFormReturn } from "react-hook-form";
|
||||||
|
import { Check, Pencil, X, AlertTriangle } from "lucide-react";
|
||||||
|
import { Button } from "../../../../components/ui/button";
|
||||||
|
import { FormItem, FormLabel, FormDescription } from "../../../../components/ui/form";
|
||||||
|
import { DirectoryBrowser } from "../../../../components/directory-browser";
|
||||||
|
import {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogTitle,
|
||||||
|
} from "../../../../components/ui/alert-dialog";
|
||||||
|
import type { RepositoryFormValues } from "../create-repository-form";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
form: UseFormReturn<RepositoryFormValues>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const LocalRepositoryForm = ({ form }: Props) => {
|
||||||
|
const [showPathBrowser, setShowPathBrowser] = useState(false);
|
||||||
|
const [showPathWarning, setShowPathWarning] = useState(false);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Repository Directory</FormLabel>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="flex-1 text-sm font-mono bg-muted px-3 py-2 rounded-md border">
|
||||||
|
{form.watch("path") || "/var/lib/zerobyte/repositories"}
|
||||||
|
</div>
|
||||||
|
<Button type="button" variant="outline" onClick={() => setShowPathWarning(true)} size="sm">
|
||||||
|
<Pencil className="h-4 w-4 mr-2" />
|
||||||
|
Change
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<FormDescription>The directory where the repository will be stored.</FormDescription>
|
||||||
|
</FormItem>
|
||||||
|
|
||||||
|
<AlertDialog open={showPathWarning} onOpenChange={setShowPathWarning}>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle className="flex items-center gap-2">
|
||||||
|
<AlertTriangle className="h-5 w-5 text-yellow-500" />
|
||||||
|
Important: Host mount required
|
||||||
|
</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription className="space-y-3">
|
||||||
|
<p>When selecting a custom path, ensure it is mounted from the host machine into the container.</p>
|
||||||
|
<p className="font-medium">
|
||||||
|
If the path is not a host mount, you will lose your repository data when the container restarts.
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
The default path <code className="bg-muted px-1 rounded">/var/lib/zerobyte/repositories</code> is
|
||||||
|
already mounted from the host and is safe to use.
|
||||||
|
</p>
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||||
|
<AlertDialogAction
|
||||||
|
onClick={() => {
|
||||||
|
setShowPathBrowser(true);
|
||||||
|
setShowPathWarning(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
I Understand, Continue
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
|
||||||
|
<AlertDialog open={showPathBrowser} onOpenChange={setShowPathBrowser}>
|
||||||
|
<AlertDialogContent className="max-w-2xl">
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>Select Repository Directory</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
Choose a directory from the filesystem to store the repository.
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<div className="py-4">
|
||||||
|
<DirectoryBrowser
|
||||||
|
onSelectPath={(path) => form.setValue("path", path)}
|
||||||
|
selectedPath={form.watch("path") || "/var/lib/zerobyte/repositories"}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel>
|
||||||
|
<X className="h-4 w-4 mr-2" />
|
||||||
|
Cancel
|
||||||
|
</AlertDialogCancel>
|
||||||
|
<AlertDialogAction onClick={() => setShowPathBrowser(false)}>
|
||||||
|
<Check className="h-4 w-4 mr-2" />
|
||||||
|
Done
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,80 @@
|
||||||
|
import type { UseFormReturn } from "react-hook-form";
|
||||||
|
import {
|
||||||
|
FormControl,
|
||||||
|
FormDescription,
|
||||||
|
FormField,
|
||||||
|
FormItem,
|
||||||
|
FormLabel,
|
||||||
|
FormMessage,
|
||||||
|
} from "../../../../components/ui/form";
|
||||||
|
import { Input } from "../../../../components/ui/input";
|
||||||
|
import type { RepositoryFormValues } from "../create-repository-form";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
form: UseFormReturn<RepositoryFormValues>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const R2RepositoryForm = ({ form }: Props) => {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<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>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,96 @@
|
||||||
|
import type { UseFormReturn } from "react-hook-form";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { ExternalLink } from "lucide-react";
|
||||||
|
import {
|
||||||
|
FormControl,
|
||||||
|
FormDescription,
|
||||||
|
FormField,
|
||||||
|
FormItem,
|
||||||
|
FormLabel,
|
||||||
|
FormMessage,
|
||||||
|
} from "../../../../components/ui/form";
|
||||||
|
import { Input } from "../../../../components/ui/input";
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "../../../../components/ui/select";
|
||||||
|
import { Alert, AlertDescription } from "../../../../components/ui/alert";
|
||||||
|
import { listRcloneRemotesOptions } from "../../../../api-client/@tanstack/react-query.gen";
|
||||||
|
import type { RepositoryFormValues } from "../create-repository-form";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
form: UseFormReturn<RepositoryFormValues>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const RcloneRepositoryForm = ({ form }: Props) => {
|
||||||
|
const { data: rcloneRemotes, isLoading: isLoadingRemotes } = useQuery(listRcloneRemotesOptions());
|
||||||
|
|
||||||
|
if (!isLoadingRemotes && (!rcloneRemotes || rcloneRemotes.length === 0)) {
|
||||||
|
return (
|
||||||
|
<Alert>
|
||||||
|
<AlertDescription className="space-y-2">
|
||||||
|
<p className="font-medium">No rclone remotes configured</p>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
To use rclone, you need to configure remotes on your host system
|
||||||
|
</p>
|
||||||
|
<a
|
||||||
|
href="https://rclone.org/docs/"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="text-sm text-strong-accent inline-flex items-center gap-1"
|
||||||
|
>
|
||||||
|
View rclone documentation
|
||||||
|
<ExternalLink className="w-3 h-3" />
|
||||||
|
</a>
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="remote"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Remote</FormLabel>
|
||||||
|
<Select onValueChange={(v) => field.onChange(v)} defaultValue={field.value} value={field.value}>
|
||||||
|
<FormControl>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Select an rclone remote" />
|
||||||
|
</SelectTrigger>
|
||||||
|
</FormControl>
|
||||||
|
<SelectContent>
|
||||||
|
{isLoadingRemotes ? (
|
||||||
|
<SelectItem value="loading" disabled>
|
||||||
|
Loading remotes...
|
||||||
|
</SelectItem>
|
||||||
|
) : (
|
||||||
|
rcloneRemotes?.map((remote: { name: string; type: string }) => (
|
||||||
|
<SelectItem key={remote.name} value={remote.name}>
|
||||||
|
{remote.name} ({remote.type})
|
||||||
|
</SelectItem>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<FormDescription>Select the rclone remote configured on your host system.</FormDescription>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="path"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Path</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input placeholder="backups/zerobyte" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormDescription>Path within the remote where backups will be stored.</FormDescription>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,78 @@
|
||||||
|
import type { UseFormReturn } from "react-hook-form";
|
||||||
|
import {
|
||||||
|
FormControl,
|
||||||
|
FormDescription,
|
||||||
|
FormField,
|
||||||
|
FormItem,
|
||||||
|
FormLabel,
|
||||||
|
FormMessage,
|
||||||
|
} from "../../../../components/ui/form";
|
||||||
|
import { Input } from "../../../../components/ui/input";
|
||||||
|
import type { RepositoryFormValues } from "../create-repository-form";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
form: UseFormReturn<RepositoryFormValues>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const RestRepositoryForm = ({ form }: Props) => {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="url"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>REST Server URL</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input placeholder="http://192.168.1.30:8000" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormDescription>URL of the REST server.</FormDescription>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="path"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Repository Path (Optional)</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input placeholder="my-backup-repo" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormDescription>Path to the repository on the REST server (leave empty for root).</FormDescription>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="username"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Username (Optional)</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input placeholder="username" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormDescription>Username for REST server authentication.</FormDescription>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="password"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Password (Optional)</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input type="password" placeholder="••••••••" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormDescription>Password for REST server authentication.</FormDescription>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,78 @@
|
||||||
|
import type { UseFormReturn } from "react-hook-form";
|
||||||
|
import {
|
||||||
|
FormControl,
|
||||||
|
FormDescription,
|
||||||
|
FormField,
|
||||||
|
FormItem,
|
||||||
|
FormLabel,
|
||||||
|
FormMessage,
|
||||||
|
} from "../../../../components/ui/form";
|
||||||
|
import { Input } from "../../../../components/ui/input";
|
||||||
|
import type { RepositoryFormValues } from "../create-repository-form";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
form: UseFormReturn<RepositoryFormValues>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const S3RepositoryForm = ({ form }: Props) => {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="endpoint"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Endpoint</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input placeholder="s3.amazonaws.com" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormDescription>S3-compatible endpoint URL.</FormDescription>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="bucket"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Bucket</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input placeholder="my-backup-bucket" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormDescription>S3 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="AKIAIOSFODNN7EXAMPLE" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormDescription>S3 access key ID for authentication.</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>S3 secret access key for authentication.</FormDescription>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,101 @@
|
||||||
|
import type { UseFormReturn } from "react-hook-form";
|
||||||
|
import {
|
||||||
|
FormControl,
|
||||||
|
FormDescription,
|
||||||
|
FormField,
|
||||||
|
FormItem,
|
||||||
|
FormLabel,
|
||||||
|
FormMessage,
|
||||||
|
} from "../../../../components/ui/form";
|
||||||
|
import { Input } from "../../../../components/ui/input";
|
||||||
|
import { Textarea } from "../../../../components/ui/textarea";
|
||||||
|
import type { RepositoryFormValues } from "../create-repository-form";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
form: UseFormReturn<RepositoryFormValues>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const SftpRepositoryForm = ({ form }: Props) => {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="host"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Host</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input placeholder="192.168.1.100" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormDescription>SFTP server hostname or IP address.</FormDescription>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="port"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Port</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
placeholder="22"
|
||||||
|
{...field}
|
||||||
|
onChange={(e) => field.onChange(parseInt(e.target.value, 10))}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormDescription>SSH port (default: 22).</FormDescription>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="user"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>User</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input placeholder="backup-user" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormDescription>SSH username for authentication.</FormDescription>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="path"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Path</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input placeholder="backups/ironmount" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormDescription>Repository path on the SFTP server. </FormDescription>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="privateKey"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>SSH Private Key</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Textarea
|
||||||
|
{...field}
|
||||||
|
placeholder="-----BEGIN OPENSSH PRIVATE KEY----- ... -----END OPENSSH PRIVATE KEY-----"
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormDescription>Paste the contents of your SSH private key.</FormDescription>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -4,7 +4,10 @@ import { useId } from "react";
|
||||||
import { useNavigate } from "react-router";
|
import { useNavigate } from "react-router";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { createRepositoryMutation } from "~/client/api-client/@tanstack/react-query.gen";
|
import { createRepositoryMutation } from "~/client/api-client/@tanstack/react-query.gen";
|
||||||
import { CreateRepositoryForm, type RepositoryFormValues } from "~/client/components/create-repository-form";
|
import {
|
||||||
|
CreateRepositoryForm,
|
||||||
|
type RepositoryFormValues,
|
||||||
|
} from "~/client/modules/repositories/components/create-repository-form";
|
||||||
import { Button } from "~/client/components/ui/button";
|
import { Button } from "~/client/components/ui/button";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "~/client/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "~/client/components/ui/card";
|
||||||
import { parseError } from "~/client/lib/errors";
|
import { parseError } from "~/client/lib/errors";
|
||||||
|
|
|
||||||
218
app/client/modules/volumes/components/create-volume-form.tsx
Normal file
218
app/client/modules/volumes/components/create-volume-form.tsx
Normal file
|
|
@ -0,0 +1,218 @@
|
||||||
|
import { arktypeResolver } from "@hookform/resolvers/arktype";
|
||||||
|
import { useMutation } from "@tanstack/react-query";
|
||||||
|
import { type } from "arktype";
|
||||||
|
import { CheckCircle, Loader2, Plug, Save, XCircle } from "lucide-react";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { useForm } from "react-hook-form";
|
||||||
|
import { cn, slugify } from "~/client/lib/utils";
|
||||||
|
import { deepClean } from "~/utils/object";
|
||||||
|
import { Button } from "../../../components/ui/button";
|
||||||
|
import {
|
||||||
|
Form,
|
||||||
|
FormControl,
|
||||||
|
FormDescription,
|
||||||
|
FormField,
|
||||||
|
FormItem,
|
||||||
|
FormLabel,
|
||||||
|
FormMessage,
|
||||||
|
} from "../../../components/ui/form";
|
||||||
|
import { Input } from "../../../components/ui/input";
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "../../../components/ui/select";
|
||||||
|
import { volumeConfigSchema } from "~/schemas/volumes";
|
||||||
|
import { testConnectionMutation } from "../../../api-client/@tanstack/react-query.gen";
|
||||||
|
import { Tooltip, TooltipContent, TooltipTrigger } from "../../../components/ui/tooltip";
|
||||||
|
import { useSystemInfo } from "~/client/hooks/use-system-info";
|
||||||
|
import { DirectoryForm, NFSForm, SMBForm, WebDAVForm, RcloneForm } from "./volume-forms";
|
||||||
|
|
||||||
|
export const formSchema = type({
|
||||||
|
name: "2<=string<=32",
|
||||||
|
}).and(volumeConfigSchema);
|
||||||
|
const cleanSchema = type.pipe((d) => formSchema(deepClean(d)));
|
||||||
|
|
||||||
|
export type FormValues = typeof formSchema.inferIn;
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
onSubmit: (values: FormValues) => void;
|
||||||
|
mode?: "create" | "update";
|
||||||
|
initialValues?: Partial<FormValues>;
|
||||||
|
formId?: string;
|
||||||
|
loading?: boolean;
|
||||||
|
className?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const defaultValuesForType = {
|
||||||
|
directory: { backend: "directory" as const, path: "/" },
|
||||||
|
nfs: { backend: "nfs" as const, port: 2049, version: "4.1" as const },
|
||||||
|
smb: { backend: "smb" as const, port: 445, vers: "3.0" as const },
|
||||||
|
webdav: { backend: "webdav" as const, port: 80, ssl: false },
|
||||||
|
rclone: { backend: "rclone" as const, remote: "", path: "" },
|
||||||
|
};
|
||||||
|
|
||||||
|
export const CreateVolumeForm = ({ onSubmit, mode = "create", initialValues, formId, loading, className }: Props) => {
|
||||||
|
const form = useForm<FormValues>({
|
||||||
|
resolver: arktypeResolver(cleanSchema as unknown as typeof formSchema),
|
||||||
|
defaultValues: initialValues || {
|
||||||
|
name: "",
|
||||||
|
backend: "directory",
|
||||||
|
},
|
||||||
|
resetOptions: {
|
||||||
|
keepDefaultValues: true,
|
||||||
|
keepDirtyValues: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const { watch, getValues } = form;
|
||||||
|
|
||||||
|
const { capabilities } = useSystemInfo();
|
||||||
|
|
||||||
|
const watchedBackend = watch("backend");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (mode === "create") {
|
||||||
|
form.reset({
|
||||||
|
name: form.getValues().name,
|
||||||
|
...defaultValuesForType[watchedBackend as keyof typeof defaultValuesForType],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [watchedBackend, form, mode]);
|
||||||
|
|
||||||
|
const [testMessage, setTestMessage] = useState<{ success: boolean; message: string } | null>(null);
|
||||||
|
|
||||||
|
const testBackendConnection = useMutation({
|
||||||
|
...testConnectionMutation(),
|
||||||
|
onMutate: () => {
|
||||||
|
setTestMessage(null);
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
setTestMessage({
|
||||||
|
success: false,
|
||||||
|
message: error?.message || "Failed to test connection. Please try again.",
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onSuccess: (data) => {
|
||||||
|
setTestMessage(data);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleTestConnection = async () => {
|
||||||
|
const formValues = getValues();
|
||||||
|
|
||||||
|
if (formValues.backend === "nfs" || formValues.backend === "smb" || formValues.backend === "webdav") {
|
||||||
|
testBackendConnection.mutate({
|
||||||
|
body: { config: formValues },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Form {...form}>
|
||||||
|
<form id={formId} onSubmit={form.handleSubmit(onSubmit)} className={cn("space-y-4", className)}>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="name"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Name</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
{...field}
|
||||||
|
placeholder="Volume name"
|
||||||
|
onChange={(e) => field.onChange(slugify(e.target.value))}
|
||||||
|
maxLength={32}
|
||||||
|
minLength={2}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormDescription>Unique identifier for the volume.</FormDescription>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="backend"
|
||||||
|
defaultValue="directory"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Backend</FormLabel>
|
||||||
|
<Select onValueChange={field.onChange} value={field.value}>
|
||||||
|
<FormControl>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Select a backend" />
|
||||||
|
</SelectTrigger>
|
||||||
|
</FormControl>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="directory">Directory</SelectItem>
|
||||||
|
<SelectItem value="nfs">NFS</SelectItem>
|
||||||
|
<SelectItem value="smb">SMB</SelectItem>
|
||||||
|
<SelectItem value="webdav">WebDAV</SelectItem>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger>
|
||||||
|
<SelectItem disabled={!capabilities.rclone} value="rclone">
|
||||||
|
rclone (40+ cloud providers)
|
||||||
|
</SelectItem>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent className={cn({ hidden: capabilities.rclone })}>
|
||||||
|
<p>Setup rclone to use this backend</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<FormDescription>Choose the storage backend for this volume.</FormDescription>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
{watchedBackend === "directory" && <DirectoryForm form={form} />}
|
||||||
|
{watchedBackend === "nfs" && <NFSForm form={form} />}
|
||||||
|
{watchedBackend === "webdav" && <WebDAVForm form={form} />}
|
||||||
|
{watchedBackend === "smb" && <SMBForm form={form} />}
|
||||||
|
{watchedBackend === "rclone" && <RcloneForm form={form} />}
|
||||||
|
{watchedBackend && watchedBackend !== "directory" && watchedBackend !== "rclone" && (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={handleTestConnection}
|
||||||
|
disabled={testBackendConnection.isPending}
|
||||||
|
className="flex-1"
|
||||||
|
>
|
||||||
|
{testBackendConnection.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||||
|
{!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 && <Plug className="mr-2 h-4 w-4" />}
|
||||||
|
{testBackendConnection.isPending
|
||||||
|
? "Testing..."
|
||||||
|
: testMessage
|
||||||
|
? testMessage.success
|
||||||
|
? "Connection Successful"
|
||||||
|
: "Test Failed"
|
||||||
|
: "Test Connection"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{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>
|
||||||
|
)}
|
||||||
|
{mode === "update" && (
|
||||||
|
<Button type="submit" className="w-full" loading={loading}>
|
||||||
|
<Save className="h-4 w-4 mr-2" />
|
||||||
|
Save changes
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,51 @@
|
||||||
|
import { Pencil } from "lucide-react";
|
||||||
|
import type { UseFormReturn } from "react-hook-form";
|
||||||
|
import type { FormValues } from "../create-volume-form";
|
||||||
|
import { DirectoryBrowser } from "../../../../components/directory-browser";
|
||||||
|
import { Button } from "../../../../components/ui/button";
|
||||||
|
import {
|
||||||
|
FormControl,
|
||||||
|
FormDescription,
|
||||||
|
FormField,
|
||||||
|
FormItem,
|
||||||
|
FormLabel,
|
||||||
|
FormMessage,
|
||||||
|
} from "../../../../components/ui/form";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
form: UseFormReturn<FormValues>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const DirectoryForm = ({ form }: Props) => {
|
||||||
|
return (
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="path"
|
||||||
|
render={({ field }) => {
|
||||||
|
return (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Directory Path</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
{field.value ? (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="flex-1 border rounded-md p-3 bg-muted/50">
|
||||||
|
<div className="text-xs font-medium text-muted-foreground mb-1">Selected path:</div>
|
||||||
|
<div className="text-sm font-mono break-all">{field.value}</div>
|
||||||
|
</div>
|
||||||
|
<Button type="button" variant="outline" size="sm" onClick={() => field.onChange("")}>
|
||||||
|
<Pencil className="h-4 w-4 mr-2" />
|
||||||
|
Change
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<DirectoryBrowser onSelectPath={(path) => field.onChange(path)} selectedPath={field.value} />
|
||||||
|
)}
|
||||||
|
</FormControl>
|
||||||
|
<FormDescription>Browse and select a directory on the host filesystem to track.</FormDescription>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,5 @@
|
||||||
|
export { DirectoryForm } from "./directory-form";
|
||||||
|
export { NFSForm } from "./nfs-form";
|
||||||
|
export { SMBForm } from "./smb-form";
|
||||||
|
export { WebDAVForm } from "./webdav-form";
|
||||||
|
export { RcloneForm } from "./rclone-form";
|
||||||
120
app/client/modules/volumes/components/volume-forms/nfs-form.tsx
Normal file
120
app/client/modules/volumes/components/volume-forms/nfs-form.tsx
Normal file
|
|
@ -0,0 +1,120 @@
|
||||||
|
import type { UseFormReturn } from "react-hook-form";
|
||||||
|
import type { FormValues } from "../create-volume-form";
|
||||||
|
import {
|
||||||
|
FormControl,
|
||||||
|
FormDescription,
|
||||||
|
FormField,
|
||||||
|
FormItem,
|
||||||
|
FormLabel,
|
||||||
|
FormMessage,
|
||||||
|
} from "../../../../components/ui/form";
|
||||||
|
import { Input } from "../../../../components/ui/input";
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "../../../../components/ui/select";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
form: UseFormReturn<FormValues>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const NFSForm = ({ form }: Props) => {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="server"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Server</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input placeholder="192.168.1.100" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormDescription>NFS server IP address or hostname.</FormDescription>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="exportPath"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Export Path</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input placeholder="/export/data" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormDescription>Path to the NFS export on the server.</FormDescription>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="port"
|
||||||
|
defaultValue={2049}
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Port</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
placeholder="2049"
|
||||||
|
{...field}
|
||||||
|
onChange={(e) => field.onChange(parseInt(e.target.value, 10) || undefined)}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormDescription>NFS server port (default: 2049).</FormDescription>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="version"
|
||||||
|
defaultValue="4.1"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Version</FormLabel>
|
||||||
|
<Select onValueChange={field.onChange} defaultValue="4.1">
|
||||||
|
<FormControl>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Select NFS version" />
|
||||||
|
</SelectTrigger>
|
||||||
|
</FormControl>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="3">NFS v3</SelectItem>
|
||||||
|
<SelectItem value="4">NFS v4</SelectItem>
|
||||||
|
<SelectItem value="4.1">NFS v4.1</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<FormDescription>NFS protocol version to use.</FormDescription>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="readOnly"
|
||||||
|
defaultValue={false}
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Read-only Mode</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={field.value ?? false}
|
||||||
|
onChange={(e) => field.onChange(e.target.checked)}
|
||||||
|
className="rounded border-gray-300"
|
||||||
|
/>
|
||||||
|
<span className="text-sm">Mount volume as read-only</span>
|
||||||
|
</div>
|
||||||
|
</FormControl>
|
||||||
|
<FormDescription>
|
||||||
|
Prevent any modifications to the volume. Recommended for backup sources and sensitive data.
|
||||||
|
</FormDescription>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,127 @@
|
||||||
|
import { ExternalLink } from "lucide-react";
|
||||||
|
import type { UseFormReturn } from "react-hook-form";
|
||||||
|
import type { FormValues } from "../create-volume-form";
|
||||||
|
import {
|
||||||
|
FormControl,
|
||||||
|
FormDescription,
|
||||||
|
FormField,
|
||||||
|
FormItem,
|
||||||
|
FormLabel,
|
||||||
|
FormMessage,
|
||||||
|
} from "../../../../components/ui/form";
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "../../../../components/ui/select";
|
||||||
|
import { Alert, AlertDescription } from "../../../../components/ui/alert";
|
||||||
|
import { Input } from "../../../../components/ui/input";
|
||||||
|
import { listRcloneRemotesOptions } from "~/client/api-client/@tanstack/react-query.gen";
|
||||||
|
import { useSystemInfo } from "~/client/hooks/use-system-info";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
form: UseFormReturn<FormValues>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const RcloneForm = ({ form }: Props) => {
|
||||||
|
const { capabilities } = useSystemInfo();
|
||||||
|
|
||||||
|
const { data: rcloneRemotes, isLoading: isLoadingRemotes } = useQuery({
|
||||||
|
...listRcloneRemotesOptions(),
|
||||||
|
enabled: capabilities.rclone,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!isLoadingRemotes && (!rcloneRemotes || rcloneRemotes.length === 0)) {
|
||||||
|
return (
|
||||||
|
<Alert>
|
||||||
|
<AlertDescription className="space-y-2">
|
||||||
|
<p className="font-medium">No rclone remotes configured</p>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
To use rclone, you need to configure remotes on your host system
|
||||||
|
</p>
|
||||||
|
<a
|
||||||
|
href="https://rclone.org/docs/"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="text-sm text-strong-accent inline-flex items-center gap-1"
|
||||||
|
>
|
||||||
|
View rclone documentation
|
||||||
|
<ExternalLink className="w-3 h-3" />
|
||||||
|
</a>
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="remote"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Remote</FormLabel>
|
||||||
|
<Select onValueChange={(v) => field.onChange(v)}>
|
||||||
|
<FormControl>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Select an rclone remote" />
|
||||||
|
</SelectTrigger>
|
||||||
|
</FormControl>
|
||||||
|
<SelectContent>
|
||||||
|
{isLoadingRemotes ? (
|
||||||
|
<SelectItem value="loading" disabled>
|
||||||
|
Loading remotes...
|
||||||
|
</SelectItem>
|
||||||
|
) : (
|
||||||
|
rcloneRemotes?.map((remote) => (
|
||||||
|
<SelectItem key={remote.name} value={remote.name}>
|
||||||
|
{remote.name} ({remote.type})
|
||||||
|
</SelectItem>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<FormDescription>Select the rclone remote configured on your host system.</FormDescription>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="path"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Path</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input placeholder="/" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormDescription>Path on the remote to mount. Use "/" for the root.</FormDescription>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="readOnly"
|
||||||
|
defaultValue={false}
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Read-only Mode</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={field.value ?? false}
|
||||||
|
onChange={(e) => field.onChange(e.target.checked)}
|
||||||
|
className="rounded border-gray-300"
|
||||||
|
/>
|
||||||
|
<span className="text-sm">Mount volume as read-only</span>
|
||||||
|
</div>
|
||||||
|
</FormControl>
|
||||||
|
<FormDescription>
|
||||||
|
Prevent any modifications to the volume. Recommended for backup sources and sensitive data.
|
||||||
|
</FormDescription>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
163
app/client/modules/volumes/components/volume-forms/smb-form.tsx
Normal file
163
app/client/modules/volumes/components/volume-forms/smb-form.tsx
Normal file
|
|
@ -0,0 +1,163 @@
|
||||||
|
import type { UseFormReturn } from "react-hook-form";
|
||||||
|
import type { FormValues } from "../create-volume-form";
|
||||||
|
import {
|
||||||
|
FormControl,
|
||||||
|
FormDescription,
|
||||||
|
FormField,
|
||||||
|
FormItem,
|
||||||
|
FormLabel,
|
||||||
|
FormMessage,
|
||||||
|
} from "../../../../components/ui/form";
|
||||||
|
import { Input } from "../../../../components/ui/input";
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "../../../../components/ui/select";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
form: UseFormReturn<FormValues>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const SMBForm = ({ form }: Props) => {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="server"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Server</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input placeholder="192.168.1.100" value={field.value} onChange={field.onChange} />
|
||||||
|
</FormControl>
|
||||||
|
<FormDescription>SMB server IP address or hostname.</FormDescription>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="share"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Share</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input placeholder="myshare" value={field.value} onChange={field.onChange} />
|
||||||
|
</FormControl>
|
||||||
|
<FormDescription>SMB share name on the server.</FormDescription>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="username"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Username</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input placeholder="admin" value={field.value} onChange={field.onChange} />
|
||||||
|
</FormControl>
|
||||||
|
<FormDescription>Username for SMB authentication.</FormDescription>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="password"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Password</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input type="password" placeholder="••••••••" value={field.value} onChange={field.onChange} />
|
||||||
|
</FormControl>
|
||||||
|
<FormDescription>Password for SMB authentication.</FormDescription>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="vers"
|
||||||
|
defaultValue="3.0"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>SMB Version</FormLabel>
|
||||||
|
<Select onValueChange={field.onChange} value={field.value}>
|
||||||
|
<FormControl>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Select SMB version" />
|
||||||
|
</SelectTrigger>
|
||||||
|
</FormControl>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="1.0">SMB v1.0</SelectItem>
|
||||||
|
<SelectItem value="2.0">SMB v2.0</SelectItem>
|
||||||
|
<SelectItem value="2.1">SMB v2.1</SelectItem>
|
||||||
|
<SelectItem value="3.0">SMB v3.0</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<FormDescription>SMB protocol version to use (default: 3.0).</FormDescription>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="domain"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Domain (Optional)</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input placeholder="WORKGROUP" value={field.value} onChange={field.onChange} />
|
||||||
|
</FormControl>
|
||||||
|
<FormDescription>Domain or workgroup for authentication (optional).</FormDescription>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="port"
|
||||||
|
defaultValue={445}
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Port</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
placeholder="445"
|
||||||
|
value={field.value}
|
||||||
|
onChange={(e) => field.onChange(parseInt(e.target.value, 10) || undefined)}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormDescription>SMB server port (default: 445).</FormDescription>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="readOnly"
|
||||||
|
defaultValue={false}
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Read-only Mode</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={field.value ?? false}
|
||||||
|
onChange={(e) => field.onChange(e.target.checked)}
|
||||||
|
className="rounded border-gray-300"
|
||||||
|
/>
|
||||||
|
<span className="text-sm">Mount volume as read-only</span>
|
||||||
|
</div>
|
||||||
|
</FormControl>
|
||||||
|
<FormDescription>
|
||||||
|
Prevent any modifications to the volume. Recommended for backup sources and sensitive data.
|
||||||
|
</FormDescription>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,146 @@
|
||||||
|
import type { UseFormReturn } from "react-hook-form";
|
||||||
|
import type { FormValues } from "../create-volume-form";
|
||||||
|
import {
|
||||||
|
FormControl,
|
||||||
|
FormDescription,
|
||||||
|
FormField,
|
||||||
|
FormItem,
|
||||||
|
FormLabel,
|
||||||
|
FormMessage,
|
||||||
|
} from "../../../../components/ui/form";
|
||||||
|
import { Input } from "../../../../components/ui/input";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
form: UseFormReturn<FormValues>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const WebDAVForm = ({ form }: Props) => {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="server"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Server</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input placeholder="example.com" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormDescription>WebDAV server hostname or IP address.</FormDescription>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="path"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Path</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input placeholder="/webdav" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormDescription>Path to the WebDAV directory on the server.</FormDescription>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="username"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Username (Optional)</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input placeholder="admin" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormDescription>Username for WebDAV authentication (optional).</FormDescription>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="password"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Password (Optional)</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input type="password" placeholder="••••••••" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormDescription>Password for WebDAV authentication (optional).</FormDescription>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="port"
|
||||||
|
defaultValue={80}
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Port</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
placeholder="80"
|
||||||
|
{...field}
|
||||||
|
onChange={(e) => field.onChange(parseInt(e.target.value, 10) || undefined)}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormDescription>WebDAV server port (default: 80 for HTTP, 443 for HTTPS).</FormDescription>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="ssl"
|
||||||
|
defaultValue={false}
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Use SSL/HTTPS</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={field.value ?? false}
|
||||||
|
onChange={(e) => field.onChange(e.target.checked)}
|
||||||
|
className="rounded border-gray-300"
|
||||||
|
/>
|
||||||
|
<span className="text-sm">Enable HTTPS for secure connections</span>
|
||||||
|
</div>
|
||||||
|
</FormControl>
|
||||||
|
<FormDescription>Use HTTPS instead of HTTP for secure connections.</FormDescription>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="readOnly"
|
||||||
|
defaultValue={false}
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Read-only Mode</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={field.value ?? false}
|
||||||
|
onChange={(e) => field.onChange(e.target.checked)}
|
||||||
|
className="rounded border-gray-300"
|
||||||
|
/>
|
||||||
|
<span className="text-sm">Mount volume as read-only</span>
|
||||||
|
</div>
|
||||||
|
</FormControl>
|
||||||
|
<FormDescription>
|
||||||
|
Prevent any modifications to the volume. Recommended for backup sources and sensitive data.
|
||||||
|
</FormDescription>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -4,7 +4,7 @@ import { useId } from "react";
|
||||||
import { useNavigate } from "react-router";
|
import { useNavigate } from "react-router";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { createVolumeMutation } from "~/client/api-client/@tanstack/react-query.gen";
|
import { createVolumeMutation } from "~/client/api-client/@tanstack/react-query.gen";
|
||||||
import { CreateVolumeForm, type FormValues } from "~/client/components/create-volume-form";
|
import { CreateVolumeForm, type FormValues } from "~/client/modules/volumes/components/create-volume-form";
|
||||||
import { Button } from "~/client/components/ui/button";
|
import { Button } from "~/client/components/ui/button";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "~/client/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "~/client/components/ui/card";
|
||||||
import { parseError } from "~/client/lib/errors";
|
import { parseError } from "~/client/lib/errors";
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@ import { useState } from "react";
|
||||||
import { useNavigate } from "react-router";
|
import { useNavigate } from "react-router";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { Check } from "lucide-react";
|
import { Check } from "lucide-react";
|
||||||
import { CreateVolumeForm, type FormValues } from "~/client/components/create-volume-form";
|
import { CreateVolumeForm, type FormValues } from "~/client/modules/volumes/components/create-volume-form";
|
||||||
import {
|
import {
|
||||||
AlertDialog,
|
AlertDialog,
|
||||||
AlertDialogAction,
|
AlertDialogAction,
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue