Compare commits

..

1 Commits

Author SHA1 Message Date
Mauricio Siu
525b420e75 refactor(destinations): start of multiple destinations 2024-09-22 16:35:18 -06:00
34 changed files with 4585 additions and 464 deletions

View File

@@ -15,7 +15,9 @@ jobs:
name: Build and push AMD64 image
command: |
docker login -u $DOCKERHUB_USERNAME -p $DOCKERHUB_TOKEN
if [ "${CIRCLE_BRANCH}" == "main" ]; then
if [ "${CIRCLE_BRANCH}" == "139-multi-server-feature" ]; then
TAG="feature"
elif [ "${CIRCLE_BRANCH}" == "main" ]; then
TAG="latest"
else
TAG="canary"
@@ -38,7 +40,9 @@ jobs:
name: Build and push ARM64 image
command: |
docker login -u $DOCKERHUB_USERNAME -p $DOCKERHUB_TOKEN
if [ "${CIRCLE_BRANCH}" == "main" ]; then
if [ "${CIRCLE_BRANCH}" == "139-multi-server-feature" ]; then
TAG="feature"
elif [ "${CIRCLE_BRANCH}" == "main" ]; then
TAG="latest"
else
TAG="canary"
@@ -71,6 +75,12 @@ jobs:
dokploy/dokploy:${TAG}-amd64 \
dokploy/dokploy:${TAG}-arm64
docker manifest push dokploy/dokploy:${VERSION}
elif [ "${CIRCLE_BRANCH}" == "139-multi-server-feature" ]; then
TAG="feature"
docker manifest create dokploy/dokploy:${TAG} \
dokploy/dokploy:${TAG}-amd64 \
dokploy/dokploy:${TAG}-arm64
docker manifest push dokploy/dokploy:${TAG}
else
TAG="canary"
docker manifest create dokploy/dokploy:${TAG} \
@@ -88,12 +98,14 @@ workflows:
only:
- main
- canary
- 139-multi-server-feature
- build-arm64:
filters:
branches:
only:
- main
- canary
- 139-multi-server-feature
- combine-manifests:
requires:
- build-amd64
@@ -103,3 +115,4 @@ workflows:
only:
- main
- canary
- 139-multi-server-feature

View File

@@ -1,23 +1,22 @@
---
title: "Comparison"
description: "A comparison of Dokploy, CapRover, Dokku, and Coolify"
title: 'Comparison'
description: 'A comparison of Dokploy, CapRover, Dokku, and Coolify'
---
Comparison of the following deployment tools:
| Feature | Dokploy | CapRover | Dokku | Coolify |
| --------------------------------------- | ------- | --------------------- | --------------------- | ------- |
| **User Interface** | ✅ | ✅ | ❌ | ✅ |
| **Docker compose support** || ❌ | ❌ | ✅ |
| **API/CLI** | ✅ | ✅ | ✅ | ✅ |
| **Multi node support** | ✅ | ✅ | ❌ | ✅ |
| **Traefik Integration** | ✅ | ✅ | Available via Plugins | ✅ |
| **User Permission Management** | ✅ | ❌ | ❌ | ✅ |
| **Advanced User Permission Management** | ✅ | ❌ | ❌ | ❌ |
| **Terminal Access Built In** | ✅ | ❌ | ❌ | ✅ |
| **Database Support** | ✅ | ✅ | ❌ | ✅ |
| **Monitoring** | ✅ | ✅ | ❌ | ❌ |
| **Backups** | ✅ | Available via Plugins | Available via Plugins | ✅ |
| **Open Source** | ✅ | ✅ | ✅ | ✅ |
| **Multi Server Support** | ✅ | ❌ | ❌ | ✅ |
| **Cloud/Paid Version** | ❌ | ✅ | ✅ | ✅ |
| Feature | Dokploy | CapRover | Dokku | Coolify |
|-----------------------------------|---------------------------------------|--------------------------------------|--------------------------------------|--------------------------------------|
| **User Interface** | ✅ | ✅ | ❌ | ✅ |
| **Docker compose support** | | ❌ | ❌ | ✅ |
| **API/CLI** | ✅ | ✅ | ✅ | ✅ |
| **Multi node support** | ✅ | ✅ | ❌ | ✅ |
| **Traefik Integration** | ✅ | ✅ | Available via Plugins | ✅ |
| **User Permission Management** | ✅ | ❌ | ❌ | ✅ |
| **Advanced User Permission Management** | ✅ | ❌ | ❌ | ❌ |
| **Terminal Access Built In** | ✅ | ❌ | ❌ | ✅ |
| **Database Support** | ✅ | ✅ | ❌ | ✅ |
| **Monitoring** | ✅ | ✅ | ❌ | ❌ |
| **Backups** | ✅ | Available via Plugins | Available via Plugins | ✅ |
| **Open Source** | ✅ | ✅ | ✅ | ✅ |
| **Cloud/Paid Version** | ❌ | ✅ | ❌ | ✅ |

View File

@@ -64,9 +64,6 @@
"docker/overview",
"---Monitoring---",
"monitoring/overview",
"---Multi Server---",
"multi-server/overview",
"multi-server/example",
"---Cluster---",
"cluster/overview",
"---Deployments---",

View File

@@ -1,117 +0,0 @@
---
title: Example
description: "Example to setup a remote server and deploy application in a VPS."
---
import { Callout } from "fumadocs-ui/components/callout";
Multi server allows you to deploy your apps remotely to different servers without needing to build and run them where the Dokploy UI is installed.
## Requirements
1. To install Dokploy UI, follow the [installation guide](en/docs/core/get-started/installation).
2. Create an SSH key by going to `/dashboard/settings/ssh-keys` and add a new key. Be sure to copy the public key.
<ImageZoom
src="/assets/ssh-keys.png"
alt="Architecture Diagram"
width={1000}
height={600}
className="rounded-lg"
/>
3. Decide which remote server to deploy your apps on. We recommend these reliable providers:
- [Hostinger](https://www.hostinger.com/vps-hosting?ref=dokploy) Get 20% off with this [referral link](https://www.hostinger.com/vps-hosting?REFERRALCODE=1SIUMAURICI97).
- [DigitalOcean](https://www.digitalocean.com/pricing/droplets#basic-droplets) Get $200 credits for free with this [referral link](https://m.do.co/c/db24efd43f35).
- [Hetzner](https://www.hetzner.com/cloud/) Get €20 credits with this [referral link](https://hetzner.cloud/?ref=vou4fhxJ1W2D).
- [Linode](https://www.linode.com/es/pricing/#compute-shared).
- [Vultr](https://www.vultr.com/pricing/#cloud-compute).
- [Scaleway](https://www.scaleway.com/en/pricing/?tags=baremetal,available).
- [Google Cloud](https://cloud.google.com/).
- [AWS](https://aws.amazon.com/ec2/pricing/).
4. When creating the server, it should ask for SSH keys. Ideally, use your computer's public key and the key you generated in the previous step. Here's how to add the public key in Hostinger:
<ImageZoom
src="/assets/hostinger-add-sshkey.png"
alt="Adding SSH key"
width={1000}
height={600}
className="rounded-lg"
/>
<Callout>The steps are similar across other providers.</Callout>
5. Copy the servers IP address and ensure you know the username (often `root`). Fill in all fields and click `Create`.
<ImageZoom
src="/assets/multi-server-add-server.png"
alt="Add server"
width={1000}
height={600}
className="rounded-lg"
/>
6. To test connectivity, open the server dropdown and click `Enter Terminal`. If everything is correct, you should be able to interact with the remote server.
7. Click `Setup Server` to proceed. There are two tabs: SSH Keys and Deployments. This guide explains the easy way, but you can follow the manual process via the Dokploy UI if you prefer.
<ImageZoom
src="/assets/multi-server-setup-2.png"
alt="Setup process"
width={1000}
height={600}
className="rounded-lg"
/>
8. Click `Deployments`, then `Setup Server`. If everything is correct, you should see output similar to this:
<ImageZoom
src="/assets/multi-server-setup-3.png"
alt="Server setup output"
width={1000}
height={600}
className="rounded-lg"
/>
<Callout>
You only need to run this setup once. If Dokploy updates later, check the
release notes to see if rerunning this command is required.
</Callout>
9. You're ready to deploy your apps! Let's test it out:
<ImageZoom
src="/assets/multi-server-add-app.png"
alt="Add app"
width={1000}
height={600}
className="rounded-lg"
/>
10. To check which server an app belongs to, youll see the server name at the top. If no server is selected, it defaults to `Dokploy Server`. Click `Deploy` to start building your app on the remote server. You can check the `Logs` tab to see the build process. For this example, well use a test repo:
Repo: `https://github.com/Dokploy/examples.git`
Branch: `main`
Build Path: `/astro`
<ImageZoom
src="/assets/multi-server-setup-app.png"
alt="App setup"
width={1000}
height={600}
className="rounded-lg"
/>
11. Once the build is done, go to `Domains` and create a free domain. Just click `Create` and youre good to go! 🎊
{" "}
<ImageZoom
src="/assets/multi-server-finish.png"
alt="Finished setup"
width={1000}
height={600}
className="rounded-lg"
/>

View File

@@ -1,29 +0,0 @@
---
title: Overview
description: "Deploy your apps to multiple servers remotely."
---
import { Callout } from "fumadocs-ui/components/callout";
Multi server allows you to deploy your apps remotely to different servers without needing to build and run them where the Dokploy UI is installed.
To use the multi-server feature, you need to have Dokploy UI installed either locally or on a remote server. We recommend using a remote server for better connectivity, security, and isolation, for remote instances we install only a traefik instance.
If you plan to only deploy apps to remote servers and use Dokploy UI for managing deployments, Dokploy will use around 250 MB of RAM and minimal CPU, so a low-resource server should be sufficient.
All the features we have documented previously are supported by Dokploy Multi Server. The only feature not supported is remote server monitoring, due to performance reasons. However, all functionalities should work the same as when deploying on the same server where Dokploy UI is installed.
## Features
1. **Enter the terminal**: Allows you to access the terminal of the remote server.
2. **Setup Server**: Allows you to configure the remote server.
- **SSH Keys**: Steps to add SSH keys to the remote server.
- **Deployments**: Steps to configure the remote server for deploying applications.
3. **Edit Server**: Allows you to modify the remote server's details, such as SSH key, name, description, IP, etc.
4. **View Actions**: Lets you perform actions like managing the Traefik instance, storage, and activating Docker cleanup.
5. **Show Traefik File System**: Displays the contents of the remote server's directory.
6. **Show Docker Containers**: Shows the Docker containers running on the remote server.
<Callout>
Remote server monitoring is not supported due to performance reasons.
</Callout>

View File

@@ -3,90 +3,4 @@ title: Overview
description: Solve the most common problems that occur when using Dokploy.
---
## Applications Domain Not Working?
You see the deployment succeeded, and logs are running, but the domain isn't working? Here's what to check:
1. **Correct Port Mapping**: Ensure the domain is using the correct port for your application. For example, if you're using Next.js, the port should be `3000`, or for Laravel, it should be `8000`. If you change the app port, update the domain to reflect that.
2. **Avoid Using `Ports` in Advanced Settings**: Generally, there's no need to use the `Ports` feature unless you want to access your app via `IP:port`. Leaving this feature enabled may interfere with your domain.
3. **Let's Encrypt Certificates**: It's crucial to point the domain to your servers IP **before** adding it in Dokploy. If the domain is added first, the certificate wont be generated, and you may need to recreate the domain or restart Traefik.
4. **Listen on 0.0.0.0, Not 127.0.0.1**: If your app is bound to `127.0.0.1` (which is common in Vite apps), switch it to `0.0.0.0` to allow external access.
## Logs and Monitoring Not Working After Changing Application Placement?
This is expected behavior. If the application is running on a different node (worker), the UI wont have access to logs or monitoring, as they're not on the same node.
## Mounts Are Causing My Application Not to Run?
Docker Swarm won't run your application if there are invalid mounts, even if the deployment shows as successful. Double-check your mounts to ensure they are valid.
## Volumes in Docker Compose Not Working?
For Docker Compose, all file mounts defined in the `volumes` section will be stored in the `files` folder. This is the default directory structure:
## I added a volume to my docker compose, but is not finding the volume?
For docker compose all the file mounts you've created in the volumes section will be stored to files folder, this is the default structure of the docker compose.
```
/application-name
/code
/files
```
So instead of using this invalid way to mount a volume:
```yaml
volumes:
- "/folder:/path/in/container" ❌
```
You should use this format:
```yaml
volumes:
- "../files/my-database:/var/lib/mysql" ✅
- "../files/my-configs:/etc/my-app/config" ✅
```
## Logs Not Loading When Deploying to a Remote Server?
There are a few potential reasons for this:
1. **Slow Server:**: If the server is too slow, it may struggle to handle concurrent requests, leading to SSL handshake errors.
2. **Insufficient Disk Space:** If the server doesn't have enough disk space, the logs may not load.
## Docker Compose Domain Not Working?
When adding a domain in your Docker Compose file, its not necessary to expose the ports directly. Simply specify the port where your app is running. Exposing the ports can lead to conflicts with other applications or ports.
Example of what not to do:
```yaml
services:
app:
image: dokploy/dokploy:latest
ports:
- 3000:3000
```
Recommended approach:
```yaml
services:
app:
image: dokploy/dokploy:latest
ports:
- 3000
- 80
```
Then, when creating the domain in Dokploy, specify the service name and port, like this:
```yaml
domain: my-app.com
serviceName: app
port: 3000
```
WIP

Binary file not shown.

Before

Width:  |  Height:  |  Size: 130 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 87 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 257 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 85 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 236 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 193 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 107 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 113 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 127 KiB

View File

@@ -0,0 +1,358 @@
import { UseFormGetValues } from "react-hook-form";
import { z } from "zod";
export const providersData = [
{
name: "S3",
type: "s3",
properties: [
{
name: "accessKey",
type: "text",
label: "Access Key",
description: "Your S3 Access Key",
required: true,
default: "",
},
{
name: "secretAccessKey",
type: "password",
label: "Secret Access Key",
description: "Your S3 Secret Access Key",
required: true,
default: "",
},
{
name: "region",
type: "text",
label: "Region",
description: "AWS Region, e.g., us-east-1",
required: true,
default: "",
},
{
name: "endpoint",
type: "text",
label: "Endpoint",
description: "S3 Endpoint URL",
required: true,
default: "https://s3.amazonaws.com",
},
{
name: "bucket",
type: "text",
label: "Bucket Name",
description: "Name of the S3 bucket",
required: true,
default: "",
},
{
name: "provider",
type: "select",
label: "S3 Provider",
description: "Select your S3 provider",
required: false,
default: "AWS",
options: ["AWS", "Ceph", "Minio", "Alibaba", "Other"],
},
{
name: "storageClass",
type: "text",
label: "Storage Class",
description: "S3 Storage Class, e.g., STANDARD, REDUCED_REDUNDANCY",
required: false,
default: "",
},
{
name: "acl",
type: "text",
label: "ACL",
description: "Access Control List settings for S3",
required: false,
default: "",
},
],
},
{
name: "GCS",
type: "gcs",
properties: [
{
name: "serviceAccountFile",
type: "text",
label: "Service Account File",
description:
"Path to the JSON file containing your service account key",
required: false,
default: "",
},
{
name: "clientId",
type: "text",
label: "Client ID",
description:
"Your GCS OAuth Client ID (required if Service Account File not provided)",
required: false,
default: "",
},
{
name: "clientSecret",
type: "password",
label: "Client Secret",
description:
"Your GCS OAuth Client Secret (required if Service Account File not provided)",
required: false,
default: "",
},
{
name: "projectNumber",
type: "text",
label: "Project Number",
description:
"Your GCS Project Number (required if Service Account File not provided)",
required: false,
default: "",
},
{
name: "bucket",
type: "text",
label: "Bucket Name",
description: "Name of the GCS bucket",
required: true,
default: "",
},
{
name: "objectAcl",
type: "text",
label: "Object ACL",
description: "Access Control List for objects uploaded to GCS",
required: false,
default: "",
},
{
name: "bucketAcl",
type: "text",
label: "Bucket ACL",
description: "Access Control List for the GCS bucket",
required: false,
default: "",
},
],
},
{
name: "Azure Blob",
type: "azureblob",
properties: [
{
name: "account",
type: "text",
label: "Account Name",
description: "Your Azure Storage account name",
required: true,
default: "",
},
{
name: "key",
type: "password",
label: "Account Key",
description: "Your Azure Storage account access key",
required: true,
default: "",
},
{
name: "endpoint",
type: "text",
label: "Endpoint",
description: "Custom endpoint for Azure Blob Storage (if any)",
required: false,
default: "",
},
{
name: "container",
type: "text",
label: "Container Name",
description: "Name of the Azure Blob container",
required: true,
default: "",
},
],
},
{
name: "Dropbox",
type: "dropbox",
properties: [
{
name: "token",
type: "password",
label: "Access Token",
description: "Your Dropbox access token",
required: true,
default: "",
},
{
name: "path",
type: "text",
label: "Destination Path",
description: "Path in Dropbox where the files will be uploaded",
required: false,
default: "/",
},
],
},
{
name: "FTP",
type: "ftp",
properties: [
{
name: "host",
type: "text",
label: "FTP Host",
description: "Hostname or IP address of the FTP server",
required: true,
default: "",
},
{
name: "port",
type: "number",
label: "FTP Port",
description: "Port number of the FTP server",
required: false,
default: 21,
},
{
name: "user",
type: "text",
label: "Username",
description: "FTP username",
required: true,
default: "",
},
{
name: "pass",
type: "password",
label: "Password",
description: "FTP password",
required: true,
default: "",
},
{
name: "secure",
type: "checkbox",
label: "Use FTPS",
description: "Enable FTPS (FTP over SSL/TLS)",
required: false,
default: false,
},
{
name: "path",
type: "text",
label: "Destination Path",
description: "Remote path on the FTP server",
required: false,
default: "/",
},
],
},
];
/**
* S3 Provider Schema
*/
export const s3Schema = z.object({
accessKey: z.string().nonempty({ message: "Access Key is required" }),
secretAccessKey: z
.string()
.nonempty({ message: "Secret Access Key is required" }),
region: z.string().nonempty({ message: "Region is required" }),
endpoint: z
.string()
.nonempty({ message: "Endpoint is required" })
.default("https://s3.amazonaws.com"),
bucket: z.string().nonempty({ message: "Bucket Name is required" }),
provider: z
.enum(["AWS", "Ceph", "Minio", "Alibaba", "Other"])
.optional()
.default("AWS"),
storageClass: z.string().optional(),
acl: z.string().optional(),
});
/**
* Azure Blob Storage Provider Schema
*/
export const azureBlobSchema = z.object({
account: z.string().nonempty({ message: "Account Name is required" }),
key: z.string().nonempty({ message: "Account Key is required" }),
endpoint: z.string().optional(),
container: z.string().nonempty({ message: "Container Name is required" }),
});
/**
* Dropbox Provider Schema
*/
export const dropboxSchema = z.object({
token: z.string().nonempty({ message: "Access Token is required" }),
path: z.string().optional().default("/"),
});
/**
* FTP Provider Schema
*/
export const ftpSchema = z.object({
host: z.string().nonempty({ message: "FTP Host is required" }),
port: z.number().optional().default(21),
user: z.string().nonempty({ message: "Username is required" }),
pass: z.string().nonempty({ message: "Password is required" }),
secure: z.boolean().optional().default(false),
path: z.string().optional().default("/"),
});
/**
* Exporting all schemas in a single object for convenience
*/
export const providerSchemas = {
s3: s3Schema,
azureblob: azureBlobSchema,
dropbox: dropboxSchema,
ftp: ftpSchema,
};
export const getObjectSchema = (schema: z.ZodTypeAny) => {
const initialValues: any = {};
if (schema instanceof z.ZodObject) {
const shape = schema._def.shape();
for (const [key, fieldSchema] of Object.entries(shape)) {
if ("_def" in fieldSchema && "defaultValue" in fieldSchema._def) {
initialValues[key] = fieldSchema._def.defaultValue();
} else {
initialValues[key] = "";
}
}
}
return initialValues;
};
export const mergeFormValues = (
schema: z.ZodTypeAny,
values: Record<string, any>,
) => {
const initialSchemaObj = getObjectSchema(schema);
const properties: any = {};
for (const key in values) {
const keysMatch = Object.keys(initialSchemaObj).filter((k) => k === key);
if (keysMatch.length === 0) {
continue;
}
properties[keysMatch[0] as keyof typeof initialSchemaObj] =
values[key] || "";
}
return properties;
};

View File

@@ -119,6 +119,7 @@ export const ComposeActions = ({ composeId }: Props) => {
</DropdownMenuContent>
</DropdownMenu>
)}
{data?.server?.name}
</div>
);
};

View File

@@ -145,10 +145,7 @@ export const AddApplication = ({ projectId, projectName }: Props) => {
{...field}
onChange={(e) => {
const val = e.target.value?.trim() || "";
form.setValue(
"appName",
`${slug}-${val.toLowerCase().replaceAll(" ", "-")}`,
);
form.setValue("appName", `${slug}-${val}`);
field.onChange(val);
}}
/>

View File

@@ -149,10 +149,7 @@ export const AddCompose = ({ projectId, projectName }: Props) => {
{...field}
onChange={(e) => {
const val = e.target.value?.trim() || "";
form.setValue(
"appName",
`${slug}-${val.toLowerCase()}`,
);
form.setValue("appName", `${slug}-${val}`);
field.onChange(val);
}}
/>

View File

@@ -361,10 +361,7 @@ export const AddDatabase = ({ projectId, projectName }: Props) => {
{...field}
onChange={(e) => {
const val = e.target.value?.trim() || "";
form.setValue(
"appName",
`${slug}-${val.toLowerCase()}`,
);
form.setValue("appName", `${slug}-${val}`);
field.onChange(val);
}}
/>

View File

@@ -17,13 +17,29 @@ import {
FormLabel,
FormMessage,
} from "@/components/ui/form";
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Input } from "@/components/ui/input";
import { api } from "@/utils/api";
import { zodResolver } from "@hookform/resolvers/zod";
import { useEffect } from "react";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
import {
getObjectSchema,
mergeFormValues,
providerSchemas,
providersData,
} from "../../application/domains/schema";
import { capitalize } from "lodash";
const addDestination = z.object({
name: z.string().min(1, "Name is required"),
@@ -38,43 +54,45 @@ type AddDestination = z.infer<typeof addDestination>;
export const AddDestination = () => {
const utils = api.useUtils();
const [provider, setProviders] = useState<keyof typeof providerSchemas>("s3");
const { mutateAsync, isError, error, isLoading } =
api.destination.create.useMutation();
const { mutateAsync: testConnection, isLoading: isLoadingConnection } =
api.destination.testConnection.useMutation();
const form = useForm<AddDestination>({
const schema = providerSchemas[provider];
const form = useForm<z.infer<typeof schema>>({
defaultValues: {
accessKeyId: "",
bucket: "",
name: "",
region: "",
secretAccessKey: "",
endpoint: "",
...getObjectSchema(schema),
},
resolver: zodResolver(addDestination),
resolver: zodResolver(schema),
});
useEffect(() => {
form.reset();
}, [form, form.reset, form.formState.isSubmitSuccessful]);
const {
register,
handleSubmit,
control,
formState: { errors },
} = form;
const onSubmit = async (data: AddDestination) => {
await mutateAsync({
accessKey: data.accessKeyId,
bucket: data.bucket,
endpoint: data.endpoint,
name: data.name,
region: data.region,
secretAccessKey: data.secretAccessKey,
})
.then(async () => {
toast.success("Destination Created");
await utils.destination.all.invalidate();
})
.catch(() => {
toast.error("Error to create the Destination");
});
const onSubmit = async (data: z.infer<typeof schema>) => {
// await mutateAsync({
// accessKey: data.accessKeyId,
// bucket: data.bucket,
// endpoint: data.endpoint,
// name: data.name,
// region: data.region,
// secretAccessKey: data.secretAccessKey,
// })
// .then(async () => {
// toast.success("Destination Created");
// await utils.destination.all.invalidate();
// })
// .catch(() => {
// toast.error("Error to create the Destination");
// });
};
const fields = Object.keys(schema.shape);
return (
<Dialog>
<DialogTrigger className="" asChild>
@@ -88,140 +106,117 @@ export const AddDestination = () => {
</DialogDescription>
</DialogHeader>
{isError && <AlertBlock type="error">{error?.message}</AlertBlock>}
<Form {...form}>
<form
id="hook-form-destination-add"
onSubmit={form.handleSubmit(onSubmit)}
className="grid w-full gap-4 "
className="grid w-full gap-8 "
>
<FormField
control={form.control}
name="name"
render={({ field }) => {
return (
<FormItem>
<FormLabel>Name</FormLabel>
<FormControl>
<Input placeholder={"S3 Bucket"} {...field} />
</FormControl>
<FormMessage />
</FormItem>
);
}}
/>
<FormField
control={form.control}
name="accessKeyId"
render={({ field }) => {
return (
<FormItem>
<FormLabel>Access Key Id</FormLabel>
<FormControl>
<Input placeholder={"xcas41dasde"} {...field} />
</FormControl>
<FormMessage />
</FormItem>
);
}}
/>
<FormField
control={form.control}
name="secretAccessKey"
render={({ field }) => (
<FormItem>
<div className="space-y-0.5">
<FormLabel>Secret Access Key</FormLabel>
</div>
<FormControl>
<Input placeholder={"asd123asdasw"} {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="bucket"
render={({ field }) => (
<FormItem>
<div className="space-y-0.5">
<FormLabel>Bucket</FormLabel>
</div>
<FormControl>
<Input placeholder={"dokploy-bucket"} {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="region"
render={({ field }) => (
<FormItem>
<div className="space-y-0.5">
<FormLabel>Region</FormLabel>
</div>
<FormControl>
<Input placeholder={"us-east-1"} {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="endpoint"
render={({ field }) => (
<FormItem>
<FormLabel>Endpoint</FormLabel>
<FormControl>
<Input
placeholder={"https://us.bucket.aws/s3"}
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div className="flex flex-col gap-2">
{fields.map((input) => (
<FormField
control={control}
key={`${provider}.${input}`}
name={`${provider}.${input}`}
render={({ field }) => {
return (
<FormItem>
<FormLabel>{capitalize(input)}</FormLabel>
<FormControl>
<Input placeholder={"Value"} {...field} />
</FormControl>
<span className="text-sm font-medium text-destructive">
{errors[input]?.message}
</span>
</FormItem>
);
}}
/>
))}
</div>
</form>
<DialogFooter className="flex w-full flex-row !justify-between pt-3">
<Button
isLoading={isLoadingConnection}
type="button"
variant="secondary"
onClick={async () => {
await testConnection({
accessKey: form.getValues("accessKeyId"),
bucket: form.getValues("bucket"),
endpoint: form.getValues("endpoint"),
name: "Test",
region: form.getValues("region"),
secretAccessKey: form.getValues("secretAccessKey"),
})
.then(async () => {
toast.success("Connection Success");
})
.catch(() => {
toast.error("Error to connect the provider");
});
}}
>
Test connection
</Button>
<Button
isLoading={isLoading}
form="hook-form-destination-add"
type="submit"
>
Create
</Button>
</DialogFooter>
</Form>
<Select
onValueChange={(e) => {
setProviders(e as keyof typeof providerSchemas);
}}
value={provider}
>
<SelectTrigger>
<SelectValue placeholder="Select a provider" />
</SelectTrigger>
<SelectContent>
<SelectGroup>
{Object.keys(providerSchemas).map((registry) => (
<SelectItem key={registry} value={registry}>
{registry}
</SelectItem>
))}
<SelectLabel>Providers ({providersData?.length})</SelectLabel>
</SelectGroup>
</SelectContent>
</Select>
<DialogFooter className="flex w-full flex-row !justify-between pt-3">
<Button
isLoading={isLoadingConnection}
type="button"
variant="secondary"
onClick={async () => {
const result = form.getValues()[provider];
const hola = mergeFormValues(schema, result);
console.log(hola);
// const getPropertiesByForm = (form: any) => {
// const initialValues = getInitialValues(schema);
// console.log(form, initialValues);
// const properties: any = {};
// for (const key in form) {
// const keysMatch = Object.keys(initialValues).filter(
// (k) => k === key,
// );
// if (keysMatch.length === 0) {
// continue;
// }
// properties[keysMatch[0]] = form[key] || "";
// console.log(key);
// }
// return properties;
// };
// const result = form.getValues();
// const properties = getPropertiesByForm(result);
// console.log(properties);
await testConnection({
json: {
...hola,
provider: provider,
},
// accessKey: form.getValues("accessKeyId"),
// bucket: form.getValues("bucket"),
// endpoint: form.getValues("endpoint"),
// name: "Test",
// region: form.getValues("region"),
// secretAccessKey: form.getValues("secretAccessKey"),
})
.then(async () => {
toast.success("Connection Success");
})
.catch(() => {
toast.error("Error to connect the provider");
});
}}
>
Test connection
</Button>
<Button
// isLoading={isLoading}
form="hook-form-destination-add"
type="submit"
>
Create
</Button>
{/* */}
</DialogFooter>
</DialogContent>
</Dialog>
);

View File

@@ -1,5 +1,3 @@
import { AlertBlock } from "@/components/shared/alert-block";
import { CodeEditor } from "@/components/shared/code-editor";
import { DateTooltip } from "@/components/shared/date-tooltip";
import { DialogAction } from "@/components/shared/dialog-action";
import { StatusTooltip } from "@/components/shared/status-tooltip";
@@ -19,20 +17,22 @@ import {
DialogTrigger,
} from "@/components/ui/dialog";
import { DropdownMenuItem } from "@/components/ui/dropdown-menu";
import { Separator } from "@/components/ui/separator";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { api } from "@/utils/api";
import copy from "copy-to-clipboard";
import {
CopyIcon,
ExternalLinkIcon,
RocketIcon,
ServerIcon,
} from "lucide-react";
import Link from "next/link";
import { useState } from "react";
import { toast } from "sonner";
import { ShowDeployment } from "../../application/deployments/show-deployment";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { CodeEditor } from "@/components/shared/code-editor";
import copy from "copy-to-clipboard";
import Link from "next/link";
import { Separator } from "@/components/ui/separator";
import { AlertBlock } from "@/components/shared/alert-block";
interface Props {
serverId: string;
@@ -59,6 +59,8 @@ export const SetupServer = ({ serverId }: Props) => {
const { mutateAsync, isLoading } = api.server.setup.useMutation();
console.log(server?.sshKey);
return (
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogTrigger asChild>
@@ -179,9 +181,7 @@ export const SetupServer = ({ serverId }: Props) => {
type="button"
className="absolute right-2 top-2"
onClick={() => {
copy(
`echo "${server?.sshKey?.publicKey}" >> ~/.ssh/authorized_keys`,
);
copy(server?.sshKey?.publicKey || "");
toast.success("Copied to clipboard");
}}
>

View File

@@ -1,4 +1,3 @@
import { AlertBlock } from "@/components/shared/alert-block";
import { DialogAction } from "@/components/shared/dialog-action";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
@@ -31,6 +30,7 @@ import { SetupServer } from "./setup-server";
import { ShowDockerContainersModal } from "./show-docker-containers-modal";
import { ShowTraefikFileSystemModal } from "./show-traefik-file-system-modal";
import { UpdateServer } from "./update-server";
import { AlertBlock } from "@/components/shared/alert-block";
export const ShowServers = () => {
const { data, refetch } = api.server.all.useQuery();

View File

@@ -0,0 +1 @@
ALTER TABLE "destination" ADD COLUMN "schema" json;

File diff suppressed because it is too large Load Diff

View File

@@ -267,6 +267,13 @@
"when": 1726988289562,
"tag": "0037_legal_namor",
"breakpoints": true
},
{
"idx": 38,
"version": "6",
"when": 1727036227151,
"tag": "0038_familiar_shockwave",
"breakpoints": true
}
]
}

View File

@@ -1,6 +1,6 @@
{
"name": "dokploy",
"version": "v0.9.0",
"version": "v0.8.3",
"private": true,
"license": "Apache-2.0",
"type": "module",

View File

@@ -38,29 +38,31 @@ export const destinationRouter = createTRPCRouter({
testConnection: adminProcedure
.input(apiCreateDestination)
.mutation(async ({ input }) => {
const { secretAccessKey, bucket, region, endpoint, accessKey } = input;
try {
const rcloneFlags = [
// `--s3-provider=Cloudflare`,
`--s3-access-key-id=${accessKey}`,
`--s3-secret-access-key=${secretAccessKey}`,
`--s3-region=${region}`,
`--s3-endpoint=${endpoint}`,
"--s3-no-check-bucket",
"--s3-force-path-style",
];
const rcloneDestination = `:s3:${bucket}`;
const rcloneCommand = `rclone ls ${rcloneFlags.join(" ")} "${rcloneDestination}"`;
await execAsync(rcloneCommand);
} catch (error) {
console.log(error);
throw new TRPCError({
code: "BAD_REQUEST",
message: "Error to connect to bucket",
cause: error,
});
}
console.log(input);
// const { secretAccessKey, bucket, region, endpoint, accessKey } = input;
// try {
// const rcloneFlags = [
// // `--s3-provider=Cloudflare`,
// `--s3-access-key-id=${accessKey}`,
// `--s3-secret-access-key=${secretAccessKey}`,
// `--s3-region=${region}`,
// `--s3-endpoint=${endpoint}`,
// "--s3-no-check-bucket",
// "--s3-force-path-style",
// ];
const connextion = buildRcloneCommand(input.json.provider, input.json);
console.log(connextion);
// const rcloneDestination = `:s3:${bucket}`;
// const rcloneCommand = `rclone ls ${rcloneFlags.join(" ")} "${rcloneDestination}"`;
await execAsync(connextion);
// } catch (error) {
// console.log(error);
// throw new TRPCError({
// code: "BAD_REQUEST",
// message: "Error to connect to bucket",
// cause: error,
// });
// }
}),
one: protectedProcedure
.input(apiFindOneDestination)
@@ -97,3 +99,151 @@ export const destinationRouter = createTRPCRouter({
}
}),
});
function buildRcloneCommand(
providerType: string,
credentials: Record<string, string>,
): string {
let rcloneFlags: string[] = [];
let rcloneDestination = "";
let rcloneCommand = "";
switch (providerType) {
case "s3":
{
const {
accessKey,
secretAccessKey,
region,
endpoint,
bucket,
provider,
storageClass,
acl,
} = credentials;
if (!accessKey || !secretAccessKey || !region || !endpoint || !bucket) {
throw new Error("Missing required S3 credentials.");
}
rcloneFlags.push(`--s3-access-key-id=${accessKey}`);
rcloneFlags.push(`--s3-secret-access-key=${secretAccessKey}`);
rcloneFlags.push(`--s3-region=${region}`);
rcloneFlags.push(`--s3-endpoint=${endpoint}`);
rcloneFlags.push("--s3-no-check-bucket");
rcloneFlags.push("--s3-force-path-style");
if (provider && provider !== "AWS") {
rcloneFlags.push(`--s3-provider=${provider}`);
}
if (storageClass) {
rcloneFlags.push(`--s3-storage-class=${storageClass}`);
}
if (acl) {
rcloneFlags.push(`--s3-acl=${acl}`);
}
rcloneDestination = `:s3:${bucket}`;
}
break;
case "azureblob":
{
const { account, key, endpoint, container } = credentials;
if (!account || !key || !container) {
throw new Error("Missing required Azure Blob Storage credentials.");
}
rcloneFlags.push(`--azureblob-account=${account}`);
rcloneFlags.push(`--azureblob-key=${key}`);
if (endpoint) {
rcloneFlags.push(`--azureblob-endpoint=${endpoint}`);
}
rcloneDestination = `:azureblob:${container}`;
}
break;
case "ftp":
{
const { host, port, user, pass, secure, path } = credentials;
if (!host || !user || !pass) {
throw new Error("Missing required FTP credentials.");
}
rcloneFlags.push(`--ftp-host=${host}`);
rcloneFlags.push(`--ftp-user=${user}`);
rcloneFlags.push(`--ftp-pass=${pass}`);
if (port) {
rcloneFlags.push(`--ftp-port=${port}`);
}
if (secure === "true" || secure === "1") {
rcloneFlags.push("--ftp-tls");
}
rcloneDestination = `:ftp:${path || "/"}`;
}
break;
case "gcs":
{
const {
serviceAccountFile,
clientId,
clientSecret,
projectNumber,
bucket,
objectAcl,
bucketAcl,
} = credentials;
if (serviceAccountFile) {
rcloneFlags.push(`--gcs-service-account-file=${serviceAccountFile}`);
} else if (clientId && clientSecret && projectNumber) {
rcloneFlags.push(`--gcs-client-id=${clientId}`);
rcloneFlags.push(`--gcs-client-secret=${clientSecret}`);
rcloneFlags.push(`--gcs-project-number=${projectNumber}`);
} else {
throw new Error(
"Missing required GCS credentials. Provide either serviceAccountFile or clientId, clientSecret, and projectNumber.",
);
}
if (!bucket) {
throw new Error("Bucket name is required for GCS.");
}
if (objectAcl) {
rcloneFlags.push(`--gcs-object-acl=${objectAcl}`);
}
if (bucketAcl) {
rcloneFlags.push(`--gcs-bucket-acl=${bucketAcl}`);
}
rcloneDestination = `:gcs:${bucket}`;
}
break;
case "dropbox":
{
const { token, path } = credentials;
if (!token) {
throw new Error("Access token is required for Dropbox.");
}
// Warning: Passing tokens via command line can be insecure.
rcloneFlags.push(`--dropbox-token='{"access_token":"${token}"}'`);
rcloneDestination = `:dropbox:${path || "/"}`;
}
break;
default:
throw new Error(`Unsupported provider type: ${providerType}`);
}
// Assemble the Rclone command
rcloneCommand = `rclone ls ${rcloneFlags.join(" ")} "${rcloneDestination}"`;
return rcloneCommand;
}

View File

@@ -8,9 +8,11 @@ import {
} from "@/server/db/schema";
import { TRPCError } from "@trpc/server";
import * as bcrypt from "bcrypt";
import { isAfter } from "date-fns";
import { eq } from "drizzle-orm";
export type Admin = typeof admins.$inferSelect;
export const createInvitation = async (
input: typeof apiCreateUserInvitation._type,
) => {

View File

@@ -1,5 +1,5 @@
import { relations } from "drizzle-orm";
import { pgTable, text } from "drizzle-orm/pg-core";
import { json, pgTable, text } from "drizzle-orm/pg-core";
import { createInsertSchema } from "drizzle-zod";
import { nanoid } from "nanoid";
import { z } from "zod";
@@ -18,6 +18,7 @@ export const destinations = pgTable("destination", {
region: text("region").notNull(),
// maybe it can be null
endpoint: text("endpoint").notNull(),
schema: json("schema"),
adminId: text("adminId")
.notNull()
.references(() => admins.adminId, { onDelete: "cascade" }),
@@ -46,12 +47,15 @@ const createSchema = createInsertSchema(destinations, {
export const apiCreateDestination = createSchema
.pick({
name: true,
accessKey: true,
bucket: true,
region: true,
endpoint: true,
secretAccessKey: true,
// name: true,
// accessKey: true,
// bucket: true,
// region: true,
// endpoint: true,
// secretAccessKey: true,
})
.extend({
json: z.any(),
})
.required();

View File

@@ -65,6 +65,7 @@ const installRequirements = async (serverId: string, logPath: string) => {
return new Promise<void>((resolve, reject) => {
client
.once("ready", () => {
console.log("Client :: ready");
const bashCommand = `
${validatePorts()}

View File

@@ -92,6 +92,7 @@ export const getNixpacksCommand = (
/* No need for any start command, since we'll use nginx later on */
args.push("--no-error-without-start");
}
console.log("args", args);
const command = `nixpacks ${args.join(" ")}`;
let bashCommand = `
echo "Starting nixpacks build..." >> ${logPath};

View File

@@ -23,10 +23,14 @@ export const execAsyncRemote = async (
sleep(1000);
conn
.once("ready", () => {
console.log("Client :: ready");
conn.exec(command, (err, stream) => {
if (err) throw err;
stream
.on("close", (code: number, signal: string) => {
console.log(
`Stream :: close :: code: ${code}, signal: ${signal}`,
);
conn.end();
if (code === 0) {
resolve({ stdout, stderr });