diff --git a/apps/dokploy/components/dashboard/project/add-compose.tsx b/apps/dokploy/components/dashboard/project/add-compose.tsx index 815c58ca82..0d6d7a7bc2 100644 --- a/apps/dokploy/components/dashboard/project/add-compose.tsx +++ b/apps/dokploy/components/dashboard/project/add-compose.tsx @@ -79,7 +79,7 @@ export const AddCompose = ({ environmentId, projectName }: Props) => { api.compose.create.useMutation(); // Get environment data to extract projectId - const { data: environment } = api.environment.one.useQuery({ environmentId }); + // const { data: environment } = api.environment.one.useQuery({ environmentId }); const hasServers = servers && servers.length > 0; // Show dropdown logic based on cloud environment @@ -117,6 +117,8 @@ export const AddCompose = ({ environmentId, projectName }: Props) => { await utils.environment.one.invalidate({ environmentId, }); + // Invalidate the project query to refresh the project data for the advance-breadcrumb + await utils.project.all.invalidate(); }) .catch(() => { toast.error("Error creating the compose"); diff --git a/apps/dokploy/components/dashboard/project/add-template.tsx b/apps/dokploy/components/dashboard/project/add-template.tsx index ef9a88e6f7..fd37e6a0c0 100644 --- a/apps/dokploy/components/dashboard/project/add-template.tsx +++ b/apps/dokploy/components/dashboard/project/add-template.tsx @@ -332,6 +332,7 @@ export const AddTemplate = ({ environmentId, baseUrl }: Props) => { viewMode === "detailed" && "border-b", )} > + {/** biome-ignore lint/performance/noImgElement: this is a valid use for img tag */} { const utils = api.useUtils(); const [isOpen, setIsOpen] = useState(false); + const [selectedTagIds, setSelectedTagIds] = useState([]); const { mutateAsync, error, isError } = projectId ? api.project.update.useMutation() @@ -75,6 +77,10 @@ export const HandleProject = ({ projectId }: Props) => { enabled: !!projectId, }, ); + + const { data: availableTags = [] } = api.tag.all.useQuery(); + const bulkAssignMutation = api.tag.bulkAssign.useMutation(); + const router = useRouter(); const form = useForm({ defaultValues: { @@ -89,6 +95,13 @@ export const HandleProject = ({ projectId }: Props) => { description: data?.description ?? "", name: data?.name ?? "", }); + // Load existing tags when editing a project + if (data?.projectTags) { + const tagIds = data.projectTags.map((pt) => pt.tagId); + setSelectedTagIds(tagIds); + } else { + setSelectedTagIds([]); + } }, [form, form.reset, form.formState.isSubmitSuccessful, data]); const onSubmit = async (data: AddProject) => { @@ -98,12 +111,26 @@ export const HandleProject = ({ projectId }: Props) => { projectId: projectId || "", }) .then(async (data) => { + // Assign tags to the project (both create and update) + const projectIdToUse = + projectId || + (data && "project" in data ? data.project.projectId : undefined); + + if (projectIdToUse) { + try { + await bulkAssignMutation.mutateAsync({ + projectId: projectIdToUse, + tagIds: selectedTagIds, + }); + } catch (error) { + toast.error("Failed to assign tags to project"); + } + } + await utils.project.all.invalidate(); toast.success(projectId ? "Project Updated" : "Project Created"); setIsOpen(false); if (!projectId) { - const projectIdToUse = - data && "project" in data ? data.project.projectId : undefined; const environmentIdToUse = data && "environment" in data ? data.environment.environmentId @@ -190,6 +217,20 @@ export const HandleProject = ({ projectId }: Props) => { )} /> + +
+ Tags + ({ + id: tag.tagId, + name: tag.name, + color: tag.color ?? undefined, + }))} + selectedTags={selectedTagIds} + onTagsChange={setSelectedTagIds} + placeholder="Select tags..." + /> +
diff --git a/apps/dokploy/components/dashboard/projects/show.tsx b/apps/dokploy/components/dashboard/projects/show.tsx index 7b5797b451..faf0995954 100644 --- a/apps/dokploy/components/dashboard/projects/show.tsx +++ b/apps/dokploy/components/dashboard/projects/show.tsx @@ -15,6 +15,8 @@ import { toast } from "sonner"; import { BreadcrumbSidebar } from "@/components/shared/breadcrumb-sidebar"; import { DateTooltip } from "@/components/shared/date-tooltip"; import { FocusShortcutInput } from "@/components/shared/focus-shortcut-input"; +import { TagBadge } from "@/components/shared/tag-badge"; +import { TagFilter } from "@/components/shared/tag-filter"; import { AlertDialog, AlertDialogAction, @@ -63,6 +65,7 @@ export const ShowProjects = () => { const { data: auth } = api.user.get.useQuery(); const { data: permissions } = api.user.getPermissions.useQuery(); const { mutateAsync } = api.project.remove.useMutation(); + const { data: availableTags } = api.tag.all.useQuery(); const [searchQuery, setSearchQuery] = useState( router.isReady && typeof router.query.q === "string" ? router.query.q : "", @@ -76,10 +79,31 @@ export const ShowProjects = () => { return "createdAt-desc"; }); + const [selectedTagIds, setSelectedTagIds] = useState(() => { + if (typeof window !== "undefined") { + const saved = localStorage.getItem("projectsTagFilter"); + return saved ? JSON.parse(saved) : []; + } + return []; + }); + useEffect(() => { localStorage.setItem("projectsSort", sortBy); }, [sortBy]); + useEffect(() => { + localStorage.setItem("projectsTagFilter", JSON.stringify(selectedTagIds)); + }, [selectedTagIds]); + + useEffect(() => { + if (!availableTags) return; + const validIds = new Set(availableTags.map((t) => t.tagId)); + setSelectedTagIds((prev) => { + const filtered = prev.filter((id) => validIds.has(id)); + return filtered.length === prev.length ? prev : filtered; + }); + }, [availableTags]); + useEffect(() => { if (!router.isReady) return; const urlQuery = typeof router.query.q === "string" ? router.query.q : ""; @@ -107,7 +131,7 @@ export const ShowProjects = () => { const filteredProjects = useMemo(() => { if (!data) return []; - const filtered = data.filter( + let filtered = data.filter( (project) => project.name .toLowerCase() @@ -117,6 +141,15 @@ export const ShowProjects = () => { .includes(debouncedSearchQuery.toLowerCase()), ); + // Filter by selected tags (OR logic: show projects with ANY selected tag) + if (selectedTagIds.length > 0) { + filtered = filtered.filter((project) => + project.projectTags?.some((pt) => + selectedTagIds.includes(pt.tag.tagId), + ), + ); + } + // Then sort the filtered results const [field, direction] = sortBy.split("-"); return [...filtered].sort((a, b) => { @@ -162,10 +195,15 @@ export const ShowProjects = () => { } return direction === "asc" ? comparison : -comparison; }); - }, [data, debouncedSearchQuery, sortBy]); + }, [data, debouncedSearchQuery, sortBy, selectedTagIds]); return ( <> + {!isCloud && ( +
+ +
+ )} @@ -208,29 +246,44 @@ export const ShowProjects = () => { -
- - +
+ ({ + id: tag.tagId, + name: tag.name, + color: tag.color || undefined, + })) || [] + } + selectedTags={selectedTagIds} + onTagsChange={setSelectedTagIds} + /> +
+ + +
{filteredProjects?.length === 0 && ( @@ -309,6 +362,19 @@ export const ShowProjects = () => { {project.description} + {project.projectTags && + project.projectTags.length > 0 && ( +
+ {project.projectTags.map((pt) => ( + + ))} +
+ )} + {hasNoEnvironments && (
@@ -429,7 +495,7 @@ export const ShowProjects = () => { -
+
Created diff --git a/apps/dokploy/components/dashboard/settings/billing/show-billing.tsx b/apps/dokploy/components/dashboard/settings/billing/show-billing.tsx index 6e56bdd708..2f04620f39 100644 --- a/apps/dokploy/components/dashboard/settings/billing/show-billing.tsx +++ b/apps/dokploy/components/dashboard/settings/billing/show-billing.tsx @@ -91,7 +91,10 @@ export const ShowBilling = () => { api.stripe.upgradeSubscription.useMutation(); const utils = api.useUtils(); - const [serverQuantity, setServerQuantity] = useState(3); + const [hobbyServerQuantity, setHobbyServerQuantity] = useState(1); + const [startupServerQuantity, setStartupServerQuantity] = useState( + STARTUP_SERVERS_INCLUDED, + ); const [isAnnual, setIsAnnual] = useState(false); const [upgradeTier, setUpgradeTier] = useState<"hobby" | "startup" | null>( null, @@ -111,6 +114,12 @@ export const ShowBilling = () => { productId: string, ) => { const stripe = await stripePromise; + const serverQuantity = + tier === "startup" + ? startupServerQuantity + : tier === "hobby" + ? hobbyServerQuantity + : hobbyServerQuantity; if (data && data.subscriptions.length === 0) { createCheckoutSession({ tier, @@ -679,7 +688,7 @@ export const ShowBilling = () => {

$ {calculatePriceHobby( - serverQuantity, + hobbyServerQuantity, isAnnual, ).toFixed(2)} /{isAnnual ? "yr" : "mo"} @@ -692,7 +701,8 @@ export const ShowBilling = () => {

$ {( - calculatePriceHobby(serverQuantity, true) / 12 + calculatePriceHobby(hobbyServerQuantity, true) / + 12 ).toFixed(2)} /mo

@@ -724,19 +734,19 @@ export const ShowBilling = () => { Servers: - setServerQuantity( + setHobbyServerQuantity( Math.max( 1, Number( @@ -750,7 +760,7 @@ export const ShowBilling = () => { @@ -775,7 +785,7 @@ export const ShowBilling = () => { onClick={() => handleCheckout("hobby", data!.hobbyProductId!) } - disabled={serverQuantity < 1} + disabled={hobbyServerQuantity < 1} > Get Started @@ -806,7 +816,7 @@ export const ShowBilling = () => {

$ {calculatePriceStartup( - serverQuantity, + startupServerQuantity, isAnnual, ).toFixed(2)} /{isAnnual ? "yr" : "mo"} @@ -819,7 +829,10 @@ export const ShowBilling = () => {

$ {( - calculatePriceStartup(serverQuantity, true) / 12 + calculatePriceStartup( + startupServerQuantity, + true, + ) / 12 ).toFixed(2)} /mo

@@ -856,13 +869,14 @@ export const ShowBilling = () => {
- setServerQuantity( + setStartupServerQuantity( Math.max( STARTUP_SERVERS_INCLUDED, Number( @@ -887,7 +901,9 @@ export const ShowBilling = () => { variant="outline" size="icon" className="h-8 w-8" - onClick={() => setServerQuantity((q) => q + 1)} + onClick={() => + setStartupServerQuantity((q) => q + 1) + } > @@ -917,7 +933,7 @@ export const ShowBilling = () => { ) } disabled={ - serverQuantity < STARTUP_SERVERS_INCLUDED + startupServerQuantity < STARTUP_SERVERS_INCLUDED } > Get Started @@ -1009,7 +1025,7 @@ export const ShowBilling = () => {

${" "} {calculatePrice( - serverQuantity, + hobbyServerQuantity, isAnnual, ).toFixed(2)}{" "} USD @@ -1018,7 +1034,10 @@ export const ShowBilling = () => {

${" "} {( - calculatePrice(serverQuantity, isAnnual) / 12 + calculatePrice( + hobbyServerQuantity, + isAnnual, + ) / 12 ).toFixed(2)}{" "} / Month USD

@@ -1026,9 +1045,10 @@ export const ShowBilling = () => { ) : (

${" "} - {calculatePrice(serverQuantity, isAnnual).toFixed( - 2, - )}{" "} + {calculatePrice( + hobbyServerQuantity, + isAnnual, + ).toFixed(2)}{" "} USD

)} @@ -1071,26 +1091,28 @@ export const ShowBilling = () => {
- {serverQuantity} Servers + {hobbyServerQuantity} Servers
{ - setServerQuantity( + setHobbyServerQuantity( e.target.value as unknown as number, ); }} @@ -1099,7 +1121,9 @@ export const ShowBilling = () => { diff --git a/apps/dokploy/components/dashboard/settings/certificates/utils.ts b/apps/dokploy/components/dashboard/settings/certificates/utils.ts index e2aa59ef3a..79b763a970 100644 --- a/apps/dokploy/components/dashboard/settings/certificates/utils.ts +++ b/apps/dokploy/components/dashboard/settings/certificates/utils.ts @@ -14,13 +14,13 @@ export const extractExpirationDate = (certData: string): Date | null => { // Helper: read ASN.1 length field function readLength(pos: number): { length: number; offset: number } { - // biome-ignore lint/style/noParameterAssign: + // biome-ignore lint/style/noParameterAssign: this is for dynamic length calculation let len = der[pos++]; if (len & 0x80) { const bytes = len & 0x7f; len = 0; for (let i = 0; i < bytes; i++) { - // biome-ignore lint/style/noParameterAssign: + // biome-ignore lint/style/noParameterAssign: this is for dynamic length calculation len = (len << 8) + der[pos++]; } } diff --git a/apps/dokploy/components/dashboard/settings/destination/handle-destinations.tsx b/apps/dokploy/components/dashboard/settings/destination/handle-destinations.tsx index 966c8e5f5b..4ba9072ec0 100644 --- a/apps/dokploy/components/dashboard/settings/destination/handle-destinations.tsx +++ b/apps/dokploy/components/dashboard/settings/destination/handle-destinations.tsx @@ -1,443 +1,213 @@ import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema"; import { PenBoxIcon, PlusIcon } from "lucide-react"; -import { useEffect, useState } from "react"; +import { useEffect, useMemo, useState } from "react"; import { useForm } from "react-hook-form"; import { toast } from "sonner"; import { z } from "zod"; import { AlertBlock } from "@/components/shared/alert-block"; import { Button } from "@/components/ui/button"; -import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, - DialogTrigger, -} from "@/components/ui/dialog"; -import { - Form, - FormControl, - FormField, - FormItem, - FormLabel, - FormMessage, -} from "@/components/ui/form"; +import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog"; +import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form"; import { Input } from "@/components/ui/input"; -import { - Select, - SelectContent, - SelectGroup, - SelectItem, - SelectLabel, - SelectTrigger, - SelectValue, -} from "@/components/ui/select"; +import { Select, SelectContent, SelectGroup, SelectItem, SelectLabel, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { Textarea } from "@/components/ui/textarea"; import { cn } from "@/lib/utils"; import { api } from "@/utils/api"; import { S3_PROVIDERS } from "./constants"; -const addDestination = z.object({ +const baseSchema = z.object({ name: z.string().min(1, "Name is required"), - provider: z.string().min(1, "Provider is required"), - accessKeyId: z.string().min(1, "Access Key Id is required"), - secretAccessKey: z.string().min(1, "Secret Access Key is required"), - bucket: z.string().min(1, "Bucket is required"), - region: z.string(), - endpoint: z.string().min(1, "Endpoint is required"), - serverId: z.string().optional(), + destinationType: z.enum(["s3", "sftp", "ftp", "gdrive"]), + provider: z.string().optional(), accessKeyId: z.string().optional(), secretAccessKey: z.string().optional(), bucket: z.string().optional(), region: z.string().optional(), endpoint: z.string().optional(), + host: z.string().optional(), port: z.string().optional(), username: z.string().optional(), password: z.string().optional(), remotePath: z.string().optional(), serviceAccountJson: z.string().optional(), serverId: z.string().optional(), }); - +const addDestination = z.discriminatedUnion("destinationType", [ + baseSchema.extend({ destinationType: z.literal("s3"), provider: z.string().min(1), accessKeyId: z.string().min(1), secretAccessKey: z.string().min(1), bucket: z.string().min(1), endpoint: z.string().min(1), region: z.string().optional() }), + baseSchema.extend({ destinationType: z.literal("sftp"), host: z.string().min(1), port: z.string().min(1), username: z.string().min(1), password: z.string().min(1), remotePath: z.string().min(1) }), + baseSchema.extend({ destinationType: z.literal("ftp"), host: z.string().min(1), port: z.string().min(1), username: z.string().min(1), password: z.string().min(1), remotePath: z.string().min(1) }), + baseSchema.extend({ destinationType: z.literal("gdrive"), serviceAccountJson: z.string().min(1), remotePath: z.string().min(1) }), +]); type AddDestination = z.infer; -interface Props { - destinationId?: string; -} +interface Props { destinationId?: string; } +const DESTINATION_TYPES = [ + { value: "s3", label: "S3" }, + { value: "sftp", label: "SFTP" }, + { value: "ftp", label: "FTP" }, + { value: "gdrive", label: "Google Drive" }, +] as const; export const HandleDestinations = ({ destinationId }: Props) => { const [open, setOpen] = useState(false); const utils = api.useUtils(); const { data: servers } = api.server.withSSHKey.useQuery(); const { data: isCloud } = api.settings.isCloud.useQuery(); - - const { mutateAsync, isError, error, isPending } = destinationId - ? api.destination.update.useMutation() - : api.destination.create.useMutation(); - - const { data: destination } = api.destination.one.useQuery( - { - destinationId: destinationId || "", - }, - { - enabled: !!destinationId, - refetchOnWindowFocus: false, - }, - ); - const { - mutateAsync: testConnection, - isPending: isPendingConnection, - error: connectionError, - isError: isErrorConnection, - } = api.destination.testConnection.useMutation(); + const { mutateAsync, isError, error, isPending } = destinationId ? api.destination.update.useMutation() : api.destination.create.useMutation(); + const { data: destination } = api.destination.one.useQuery({ destinationId: destinationId || "" }, { enabled: !!destinationId, refetchOnWindowFocus: false }); + const { mutateAsync: testConnection, isPending: isPendingConnection, error: connectionError, isError: isErrorConnection } = api.destination.testConnection.useMutation(); const form = useForm({ + resolver: zodResolver(addDestination), defaultValues: { - provider: "", - accessKeyId: "", - bucket: "", - name: "", - region: "", - secretAccessKey: "", - endpoint: "", + name: "", destinationType: "s3", provider: "", accessKeyId: "", secretAccessKey: "", bucket: "", region: "", endpoint: "", host: "", port: "22", username: "", password: "", remotePath: "", serviceAccountJson: "", serverId: undefined, }, - resolver: zodResolver(addDestination), }); + const destinationType = form.watch("destinationType"); + useEffect(() => { - if (destination) { - form.reset({ - name: destination.name, - provider: destination.provider || "", - accessKeyId: destination.accessKey, - secretAccessKey: destination.secretAccessKey, - bucket: destination.bucket, - region: destination.region, - endpoint: destination.endpoint, - }); - } else { - form.reset(); - } - }, [form, form.reset, form.formState.isSubmitSuccessful, destination]); + if (!destination) return void form.reset(); + form.reset({ + name: destination.name, + destinationType: destination.destinationType, + provider: destination.provider || "", + accessKeyId: destination.accessKey || "", + secretAccessKey: destination.secretAccessKey || "", + bucket: destination.bucket, + region: destination.region || "", + endpoint: destination.endpoint || "", + host: destination.host || "", + port: destination.port || (destination.destinationType === "ftp" ? "21" : "22"), + username: destination.username || "", + password: destination.password || "", + remotePath: destination.remoteBasePath || destination.bucket || "", + serviceAccountJson: destination.destinationType === "gdrive" ? destination.accessKey || "" : "", + }); + }, [destination, form]); + + const connectionHint = useMemo(() => { + if (destinationType === "s3") return "rclone ls :s3:"; + if (destinationType === "sftp") return "rclone ls :sftp:"; + if (destinationType === "ftp") return "rclone ls :ftp:"; + return "rclone ls :drive:"; + }, [destinationType]); + + const toPayload = (data: AddDestination, serverId?: string) => { + if (data.destinationType === "s3") return { name: data.name, destinationType: "s3", provider: data.provider, accessKey: data.accessKeyId, secretAccessKey: data.secretAccessKey, bucket: data.bucket, region: data.region || "", endpoint: data.endpoint, host: "", port: "", username: "", password: "", remoteBasePath: "", serverId }; + if (data.destinationType === "gdrive") return { name: data.name, destinationType: "gdrive", provider: "", accessKey: data.serviceAccountJson, secretAccessKey: "", bucket: data.remotePath, region: "", endpoint: "", host: "", port: "", username: "", password: "", remoteBasePath: data.remotePath, serverId }; + const defaultPort = data.destinationType === "ftp" ? "21" : "22"; + return { name: data.name, destinationType: data.destinationType, provider: "", accessKey: "", secretAccessKey: "", bucket: data.remotePath, region: "", endpoint: "", host: data.host, port: data.port || defaultPort, username: data.username, password: data.password, remoteBasePath: data.remotePath, serverId }; + }; const onSubmit = async (data: AddDestination) => { - await mutateAsync({ - provider: data.provider || "", - accessKey: data.accessKeyId, - bucket: data.bucket, - endpoint: data.endpoint, - name: data.name, - region: data.region, - secretAccessKey: data.secretAccessKey, - destinationId: destinationId || "", - }) + const payload = { ...toPayload(data, data.serverId), destinationId: destinationId || "" }; + await mutateAsync(payload as never) .then(async () => { toast.success(`Destination ${destinationId ? "Updated" : "Created"}`); await utils.destination.all.invalidate(); - if (destinationId) { - await utils.destination.one.invalidate({ destinationId }); - } + if (destinationId) await utils.destination.one.invalidate({ destinationId }); setOpen(false); }) - .catch(() => { - toast.error( - `Error ${destinationId ? "Updating" : "Creating"} the Destination`, - ); - }); + .catch(() => toast.error(`Error ${destinationId ? "Updating" : "Creating"} the Destination`)); }; const handleTestConnection = async (serverId?: string) => { - const result = await form.trigger([ - "provider", - "accessKeyId", - "secretAccessKey", - "bucket", - "endpoint", - ]); - - if (!result) { - const errors = form.formState.errors; - const errorFields = Object.entries(errors) - .map(([field, error]) => `${field}: ${error?.message}`) - .filter(Boolean) - .join("\n"); - - toast.error("Please fill all required fields", { - description: errorFields, - }); - return; + const requiredByType = destinationType === "s3" ? ["provider", "accessKeyId", "secretAccessKey", "bucket", "endpoint"] : destinationType === "gdrive" ? ["serviceAccountJson", "remotePath"] : ["host", "port", "username", "password", "remotePath"]; + const valid = await form.trigger(["name", ...(requiredByType as Array)]); + if (!valid) { + const errorFields = Object.entries(form.formState.errors).map(([field, fieldError]) => `${field}: ${fieldError?.message}`).filter(Boolean).join("\n"); + return void toast.error("Please fill all required fields", { description: errorFields }); } - - if (isCloud && !serverId) { - toast.error("Please select a server"); - return; - } - - const provider = form.getValues("provider"); - const accessKey = form.getValues("accessKeyId"); - const secretKey = form.getValues("secretAccessKey"); - const bucket = form.getValues("bucket"); - const endpoint = form.getValues("endpoint"); - const region = form.getValues("region"); - - const connectionString = `:s3,provider=${provider},access_key_id=${accessKey},secret_access_key=${secretKey},endpoint=${endpoint}${region ? `,region=${region}` : ""}:${bucket}`; - - await testConnection({ - provider, - accessKey, - bucket, - endpoint, - name: "Test", - region, - secretAccessKey: secretKey, - serverId, - }) - .then(() => { - toast.success("Connection Success"); - }) - .catch((e) => { - toast.error("Error connecting to provider", { - description: `${e.message}\n\nTry manually: rclone ls ${connectionString}`, - }); - }); + if (isCloud && !serverId) return void toast.error("Please select a server"); + await testConnection(toPayload(form.getValues(), serverId) as never) + .then(() => toast.success("Connection Success")) + .catch((e) => toast.error("Error connecting to destination", { description: `${e.message}\n\nTry manually: ${connectionHint}` })); }; + const renderInput = (name: keyof AddDestination, label: string, placeholder: string, type = "text") => ( + ( + + {label} + + + + )} /> + ); + return ( - - {destinationId ? ( - - ) : ( - - )} + + {destinationId ? : } - - {destinationId ? "Update" : "Add"} Destination - - - In this section, you can configure and add new destinations for your - backups. Please ensure that you provide the correct information to - guarantee secure and efficient storage. - + {destinationId ? "Update" : "Add"} Destination + Configure S3, SFTP, FTP and Google Drive destinations for automatic backups. - {(isError || isErrorConnection) && ( - - {connectionError?.message || error?.message} - - )} - + {(isError || isErrorConnection) && {connectionError?.message || error?.message}}
- - { - return ( - - Name - - - - - - ); - }} - /> - { - return ( + + {renderInput("name", "Name", "Backup Destination")} + ( + + Destination Type + + + + + + )} /> + {destinationType === "s3" && ( + <> + ( Provider - + + {S3_PROVIDERS.map((p) => {p.name})} - ); - }} - /> - - { - return ( - - Access Key Id - - - - - - ); - }} - /> - ( - -
- Secret Access Key -
- - - - -
- )} - /> - ( - -
- Bucket -
- - - - -
- )} - /> - ( - -
- Region -
- - - - -
- )} - /> - ( + )} /> + {renderInput("accessKeyId", "Access Key Id", "AKIA...")} + {renderInput("secretAccessKey", "Secret Access Key", "secret")} + {renderInput("bucket", "Bucket", "dokploy-bucket")} + {renderInput("region", "Region", "us-east-1")} + {renderInput("endpoint", "Endpoint", "https://s3.amazonaws.com")} + + )} + {(destinationType === "sftp" || destinationType === "ftp") && <> + {renderInput("host", "Host", "backup.example.com")} + {renderInput("port", "Port", destinationType === "ftp" ? "21" : "22")} + {renderInput("username", "Username", "dokploy")} + {renderInput("password", "Password", "password", "password")} + {renderInput("remotePath", "Remote Path", "/backups/dokploy")} + } + {destinationType === "gdrive" && <> + ( - Endpoint - - - + Service Account JSON +