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
65 changed files with 1255 additions and 1298 deletions

View File

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

View File

@@ -74,7 +74,7 @@ export function generateMetadata({
}, },
twitter: { twitter: {
card: "summary_large_image", card: "summary_large_image",
creator: "@getdokploy", creator: "@siumauricio",
title: page.data.title, title: page.data.title,
description: page.data.description, description: page.data.description,
images: [ images: [

View File

@@ -15,8 +15,6 @@ Configure the source of your code, the way your application is built, and also m
If you need to assign environment variables to your application, you can do so here. If you need to assign environment variables to your application, you can do so here.
In case you need to use a multiline variable, you can wrap it in double quotes just like this `'"here_is_my_private_key"'`.
## Monitoring ## Monitoring
Four graphs will be displayed for the use of memory, CPU, disk, and network. Note that the information is only updated if you are viewing the current page, otherwise it will not be updated. Four graphs will be displayed for the use of memory, CPU, disk, and network. Note that the information is only updated if you are viewing the current page, otherwise it will not be updated.

View File

@@ -26,8 +26,6 @@ Actions like deploying, updating, and deleting your database, and stopping it.
If you need to assign environment variables to your application, you can do so here. If you need to assign environment variables to your application, you can do so here.
In case you need to use a multiline variable, you can wrap it in double quotes just like this `'"here_is_my_private_key"'`.
## Monitoring ## Monitoring
Four graphs will be displayed for the use of memory, CPU, disk, and network. Note that the information is only updated if you are viewing the current page, otherwise it will not be updated. Four graphs will be displayed for the use of memory, CPU, disk, and network. Note that the information is only updated if you are viewing the current page, otherwise it will not be updated.

View File

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

View File

@@ -64,9 +64,6 @@
"docker/overview", "docker/overview",
"---Monitoring---", "---Monitoring---",
"monitoring/overview", "monitoring/overview",
"---Multi Server---",
"multi-server/overview",
"multi-server/example",
"---Cluster---", "---Cluster---",
"cluster/overview", "cluster/overview",
"---Deployments---", "---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. description: Solve the most common problems that occur when using Dokploy.
--- ---
## Applications Domain Not Working? WIP
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
```

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

@@ -17,7 +17,7 @@ import {
FormLabel, FormLabel,
FormMessage, FormMessage,
} from "@/components/ui/form"; } from "@/components/ui/form";
import { Input, NumberInput } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { import {
Select, Select,
SelectContent, SelectContent,
@@ -125,14 +125,28 @@ export const UpdatePort = ({ portId }: Props) => {
<FormItem> <FormItem>
<FormLabel>Published Port</FormLabel> <FormLabel>Published Port</FormLabel>
<FormControl> <FormControl>
<NumberInput placeholder="1-65535" {...field} /> <Input
placeholder="1-65535"
{...field}
value={field.value?.toString() || ""}
onChange={(e) => {
const value = e.target.value;
if (value === "") {
field.onChange(0);
} else {
const number = Number.parseInt(value, 10);
if (!Number.isNaN(number)) {
field.onChange(number);
}
}
}}
/>
</FormControl> </FormControl>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
)} )}
/> />
<FormField <FormField
control={form.control} control={form.control}
name="targetPort" name="targetPort"
@@ -140,7 +154,22 @@ export const UpdatePort = ({ portId }: Props) => {
<FormItem> <FormItem>
<FormLabel>Target Port</FormLabel> <FormLabel>Target Port</FormLabel>
<FormControl> <FormControl>
<Input placeholder="1-65535" {...field} /> <Input
placeholder="1-65535"
{...field}
value={field.value?.toString() || ""}
onChange={(e) => {
const value = e.target.value;
if (value === "") {
field.onChange(0);
} else {
const number = Number.parseInt(value, 10);
if (!Number.isNaN(number)) {
field.onChange(number);
}
}
}}
/>
</FormControl> </FormControl>
<FormMessage /> <FormMessage />

View File

@@ -19,15 +19,6 @@ import {
FormMessage, FormMessage,
} from "@/components/ui/form"; } from "@/components/ui/form";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Separator } from "@/components/ui/separator";
import { Switch } from "@/components/ui/switch"; import { Switch } from "@/components/ui/switch";
import { api } from "@/utils/api"; import { api } from "@/utils/api";
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
@@ -45,36 +36,6 @@ const AddRedirectchema = z.object({
type AddRedirect = z.infer<typeof AddRedirectchema>; type AddRedirect = z.infer<typeof AddRedirectchema>;
// Default presets
const redirectPresets = [
// {
// label: "Allow www & non-www.",
// redirect: {
// regex: "",
// permanent: false,
// replacement: "",
// },
// },
{
id: "to-www",
label: "Redirect to www",
redirect: {
regex: "^https?://(?:www.)?(.+)",
permanent: true,
replacement: "https://www.$${1}",
},
},
{
id: "to-non-www",
label: "Redirect to non-www",
redirect: {
regex: "^https?://www.(.+)",
permanent: true,
replacement: "https://$${1}",
},
},
];
interface Props { interface Props {
applicationId: string; applicationId: string;
children?: React.ReactNode; children?: React.ReactNode;
@@ -82,10 +43,9 @@ interface Props {
export const AddRedirect = ({ export const AddRedirect = ({
applicationId, applicationId,
children = <PlusIcon className="w-4 h-4" />, children = <PlusIcon className="h-4 w-4" />,
}: Props) => { }: Props) => {
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
const [presetSelected, setPresetSelected] = useState("");
const utils = api.useUtils(); const utils = api.useUtils();
const { mutateAsync, isLoading, error, isError } = const { mutateAsync, isLoading, error, isError } =
@@ -121,36 +81,19 @@ export const AddRedirect = ({
await utils.application.readTraefikConfig.invalidate({ await utils.application.readTraefikConfig.invalidate({
applicationId, applicationId,
}); });
onDialogToggle(false); setIsOpen(false);
}) })
.catch(() => { .catch(() => {
toast.error("Error to create the redirect"); toast.error("Error to create the redirect");
}); });
}; };
const onDialogToggle = (open: boolean) => {
setIsOpen(open);
// commented for the moment because not reseting the form if accidentally closed the dialog can be considered as a feature instead of a bug
// setPresetSelected("");
// form.reset();
};
const onPresetSelect = (presetId: string) => {
const redirectPreset = redirectPresets.find(
(preset) => preset.id === presetId,
)?.redirect;
if (!redirectPreset) return;
const { regex, permanent, replacement } = redirectPreset;
form.reset({ regex, permanent, replacement }, { keepDefaultValues: true });
setPresetSelected(presetId);
};
return ( return (
<Dialog open={isOpen} onOpenChange={onDialogToggle}> <Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogTrigger asChild> <DialogTrigger asChild>
<Button>{children}</Button> <Button>{children}</Button>
</DialogTrigger> </DialogTrigger>
<DialogContent className="max-h-screen overflow-y-auto sm:max-w-lg"> <DialogContent className="max-h-screen overflow-y-auto sm:max-w-lg">
<DialogHeader> <DialogHeader>
<DialogTitle>Redirects</DialogTitle> <DialogTitle>Redirects</DialogTitle>
<DialogDescription> <DialogDescription>
@@ -159,24 +102,6 @@ export const AddRedirect = ({
</DialogHeader> </DialogHeader>
{isError && <AlertBlock type="error">{error?.message}</AlertBlock>} {isError && <AlertBlock type="error">{error?.message}</AlertBlock>}
<div className="md:col-span-2">
<Label>Presets</Label>
<Select onValueChange={onPresetSelect} value={presetSelected}>
<SelectTrigger>
<SelectValue placeholder="No preset selected" />
</SelectTrigger>
<SelectContent>
{redirectPresets.map((preset) => (
<SelectItem key={preset.label} value={preset.id}>
{preset.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<Separator />
<Form {...form}> <Form {...form}>
<form <form
id="hook-form-add-redirect" id="hook-form-add-redirect"
@@ -217,7 +142,7 @@ export const AddRedirect = ({
control={form.control} control={form.control}
name="permanent" name="permanent"
render={({ field }) => ( render={({ field }) => (
<FormItem className="flex flex-row items-center justify-between p-3 mt-4 border rounded-lg shadow-sm"> <FormItem className="mt-4 flex flex-row items-center justify-between rounded-lg border p-3 shadow-sm">
<div className="space-y-0.5"> <div className="space-y-0.5">
<FormLabel>Permanent</FormLabel> <FormLabel>Permanent</FormLabel>
<FormDescription> <FormDescription>

View File

@@ -18,7 +18,7 @@ import {
FormLabel, FormLabel,
FormMessage, FormMessage,
} from "@/components/ui/form"; } from "@/components/ui/form";
import { Input, NumberInput } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { import {
Select, Select,
SelectContent, SelectContent,
@@ -140,7 +140,7 @@ export const AddDomain = ({
<DialogTrigger className="" asChild> <DialogTrigger className="" asChild>
{children} {children}
</DialogTrigger> </DialogTrigger>
<DialogContent className="max-h-screen overflow-y-auto sm:max-w-2xl"> <DialogContent className="max-h-screen overflow-y-auto sm:max-w-2xl">
<DialogHeader> <DialogHeader>
<DialogTitle>Domain</DialogTitle> <DialogTitle>Domain</DialogTitle>
<DialogDescription>{dictionary.dialogDescription}</DialogDescription> <DialogDescription>{dictionary.dialogDescription}</DialogDescription>
@@ -228,36 +228,19 @@ export const AddDomain = ({
<FormItem> <FormItem>
<FormLabel>Container Port</FormLabel> <FormLabel>Container Port</FormLabel>
<FormControl> <FormControl>
<NumberInput placeholder={"3000"} {...field} /> <Input
placeholder={"3000"}
{...field}
onChange={(e) => {
field.onChange(Number.parseInt(e.target.value));
}}
/>
</FormControl> </FormControl>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
); );
}} }}
/> />
<FormField
control={form.control}
name="https"
render={({ field }) => (
<FormItem className="flex flex-row items-center justify-between p-3 mt-4 border rounded-lg shadow-sm">
<div className="space-y-0.5">
<FormLabel>HTTPS</FormLabel>
<FormDescription>
Automatically provision SSL Certificate.
</FormDescription>
<FormMessage />
</div>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
{form.getValues().https && ( {form.getValues().https && (
<FormField <FormField
control={form.control} control={form.control}
@@ -287,6 +270,28 @@ export const AddDomain = ({
)} )}
/> />
)} )}
<FormField
control={form.control}
name="https"
render={({ field }) => (
<FormItem className="mt-4 flex flex-row items-center justify-between rounded-lg border p-3 shadow-sm">
<div className="space-y-0.5">
<FormLabel>HTTPS</FormLabel>
<FormDescription>
Automatically provision SSL Certificate.
</FormDescription>
<FormMessage />
</div>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
</div> </div>
</div> </div>
</form> </form>

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

@@ -18,7 +18,7 @@ import {
FormLabel, FormLabel,
FormMessage, FormMessage,
} from "@/components/ui/form"; } from "@/components/ui/form";
import { Input, NumberInput } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { import {
Select, Select,
SelectContent, SelectContent,
@@ -161,7 +161,7 @@ export const AddDomainCompose = ({
<DialogTrigger className="" asChild> <DialogTrigger className="" asChild>
{children} {children}
</DialogTrigger> </DialogTrigger>
<DialogContent className="max-h-screen overflow-y-auto sm:max-w-2xl"> <DialogContent className="max-h-screen overflow-y-auto sm:max-w-2xl">
<DialogHeader> <DialogHeader>
<DialogTitle>Domain</DialogTitle> <DialogTitle>Domain</DialogTitle>
<DialogDescription>{dictionary.dialogDescription}</DialogDescription> <DialogDescription>{dictionary.dialogDescription}</DialogDescription>
@@ -190,7 +190,7 @@ export const AddDomainCompose = ({
{errorServices?.message} {errorServices?.message}
</AlertBlock> </AlertBlock>
)} )}
<div className="flex flex-row items-end w-full gap-4"> <div className="flex flex-row gap-4 w-full items-end">
<FormField <FormField
control={form.control} control={form.control}
name="serviceName" name="serviceName"
@@ -364,36 +364,19 @@ export const AddDomainCompose = ({
<FormItem> <FormItem>
<FormLabel>Container Port</FormLabel> <FormLabel>Container Port</FormLabel>
<FormControl> <FormControl>
<NumberInput placeholder={"3000"} {...field} /> <Input
placeholder={"3000"}
{...field}
onChange={(e) => {
field.onChange(Number.parseInt(e.target.value));
}}
/>
</FormControl> </FormControl>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
); );
}} }}
/> />
<FormField
control={form.control}
name="https"
render={({ field }) => (
<FormItem className="flex flex-row items-center justify-between p-3 mt-4 border rounded-lg shadow-sm">
<div className="space-y-0.5">
<FormLabel>HTTPS</FormLabel>
<FormDescription>
Automatically provision SSL Certificate.
</FormDescription>
<FormMessage />
</div>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
{https && ( {https && (
<FormField <FormField
control={form.control} control={form.control}
@@ -423,6 +406,28 @@ export const AddDomainCompose = ({
)} )}
/> />
)} )}
<FormField
control={form.control}
name="https"
render={({ field }) => (
<FormItem className="mt-4 flex flex-row items-center justify-between rounded-lg border p-3 shadow-sm">
<div className="space-y-0.5">
<FormLabel>HTTPS</FormLabel>
<FormDescription>
Automatically provision SSL Certificate.
</FormDescription>
<FormMessage />
</div>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
</div> </div>
</div> </div>
</form> </form>

View File

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

View File

@@ -48,7 +48,6 @@ export const ShowExternalMariadbCredentials = ({ mariadbId }: Props) => {
const { data, refetch } = api.mariadb.one.useQuery({ mariadbId }); const { data, refetch } = api.mariadb.one.useQuery({ mariadbId });
const { mutateAsync, isLoading } = api.mariadb.saveExternalPort.useMutation(); const { mutateAsync, isLoading } = api.mariadb.saveExternalPort.useMutation();
const [connectionUrl, setConnectionUrl] = useState(""); const [connectionUrl, setConnectionUrl] = useState("");
const getIp = data?.server?.ipAddress || ip;
const form = useForm<DockerProvider>({ const form = useForm<DockerProvider>({
defaultValues: {}, defaultValues: {},
resolver: zodResolver(DockerProviderSchema), resolver: zodResolver(DockerProviderSchema),
@@ -80,7 +79,7 @@ export const ShowExternalMariadbCredentials = ({ mariadbId }: Props) => {
const buildConnectionUrl = () => { const buildConnectionUrl = () => {
const port = form.watch("externalPort") || data?.externalPort; const port = form.watch("externalPort") || data?.externalPort;
return `mariadb://${data?.databaseUser}:${data?.databasePassword}@${getIp}:${port}/${data?.databaseName}`; return `mariadb://${data?.databaseUser}:${data?.databasePassword}@${ip}:${port}/${data?.databaseName}`;
}; };
setConnectionUrl(buildConnectionUrl()); setConnectionUrl(buildConnectionUrl());
@@ -91,7 +90,7 @@ export const ShowExternalMariadbCredentials = ({ mariadbId }: Props) => {
form, form,
data?.databaseName, data?.databaseName,
data?.databaseUser, data?.databaseUser,
getIp, ip,
]); ]);
return ( return (
<> <>

View File

@@ -48,7 +48,7 @@ export const ShowExternalMongoCredentials = ({ mongoId }: Props) => {
const { data, refetch } = api.mongo.one.useQuery({ mongoId }); const { data, refetch } = api.mongo.one.useQuery({ mongoId });
const { mutateAsync, isLoading } = api.mongo.saveExternalPort.useMutation(); const { mutateAsync, isLoading } = api.mongo.saveExternalPort.useMutation();
const [connectionUrl, setConnectionUrl] = useState(""); const [connectionUrl, setConnectionUrl] = useState("");
const getIp = data?.server?.ipAddress || ip;
const form = useForm<DockerProvider>({ const form = useForm<DockerProvider>({
defaultValues: {}, defaultValues: {},
resolver: zodResolver(DockerProviderSchema), resolver: zodResolver(DockerProviderSchema),
@@ -80,7 +80,7 @@ export const ShowExternalMongoCredentials = ({ mongoId }: Props) => {
const buildConnectionUrl = () => { const buildConnectionUrl = () => {
const port = form.watch("externalPort") || data?.externalPort; const port = form.watch("externalPort") || data?.externalPort;
return `mongodb://${data?.databaseUser}:${data?.databasePassword}@${getIp}:${port}`; return `mongodb://${data?.databaseUser}:${data?.databasePassword}@${ip}:${port}`;
}; };
setConnectionUrl(buildConnectionUrl()); setConnectionUrl(buildConnectionUrl());
@@ -90,7 +90,7 @@ export const ShowExternalMongoCredentials = ({ mongoId }: Props) => {
data?.databasePassword, data?.databasePassword,
form, form,
data?.databaseUser, data?.databaseUser,
getIp, ip,
]); ]);
return ( return (

View File

@@ -48,7 +48,7 @@ export const ShowExternalMysqlCredentials = ({ mysqlId }: Props) => {
const { data, refetch } = api.mysql.one.useQuery({ mysqlId }); const { data, refetch } = api.mysql.one.useQuery({ mysqlId });
const { mutateAsync, isLoading } = api.mysql.saveExternalPort.useMutation(); const { mutateAsync, isLoading } = api.mysql.saveExternalPort.useMutation();
const [connectionUrl, setConnectionUrl] = useState(""); const [connectionUrl, setConnectionUrl] = useState("");
const getIp = data?.server?.ipAddress || ip;
const form = useForm<DockerProvider>({ const form = useForm<DockerProvider>({
defaultValues: {}, defaultValues: {},
resolver: zodResolver(DockerProviderSchema), resolver: zodResolver(DockerProviderSchema),
@@ -80,7 +80,7 @@ export const ShowExternalMysqlCredentials = ({ mysqlId }: Props) => {
const buildConnectionUrl = () => { const buildConnectionUrl = () => {
const port = form.watch("externalPort") || data?.externalPort; const port = form.watch("externalPort") || data?.externalPort;
return `mysql://${data?.databaseUser}:${data?.databasePassword}@${getIp}:${port}/${data?.databaseName}`; return `mysql://${data?.databaseUser}:${data?.databasePassword}@${ip}:${port}/${data?.databaseName}`;
}; };
setConnectionUrl(buildConnectionUrl()); setConnectionUrl(buildConnectionUrl());
@@ -91,7 +91,7 @@ export const ShowExternalMysqlCredentials = ({ mysqlId }: Props) => {
data?.databaseName, data?.databaseName,
data?.databaseUser, data?.databaseUser,
form, form,
getIp, ip,
]); ]);
return ( return (
<> <>

View File

@@ -48,7 +48,6 @@ export const ShowExternalPostgresCredentials = ({ postgresId }: Props) => {
const { data, refetch } = api.postgres.one.useQuery({ postgresId }); const { data, refetch } = api.postgres.one.useQuery({ postgresId });
const { mutateAsync, isLoading } = const { mutateAsync, isLoading } =
api.postgres.saveExternalPort.useMutation(); api.postgres.saveExternalPort.useMutation();
const getIp = data?.server?.ipAddress || ip;
const [connectionUrl, setConnectionUrl] = useState(""); const [connectionUrl, setConnectionUrl] = useState("");
const form = useForm<DockerProvider>({ const form = useForm<DockerProvider>({
@@ -80,9 +79,10 @@ export const ShowExternalPostgresCredentials = ({ postgresId }: Props) => {
useEffect(() => { useEffect(() => {
const buildConnectionUrl = () => { const buildConnectionUrl = () => {
const hostname = window.location.hostname;
const port = form.watch("externalPort") || data?.externalPort; const port = form.watch("externalPort") || data?.externalPort;
return `postgresql://${data?.databaseUser}:${data?.databasePassword}@${getIp}:${port}/${data?.databaseName}`; return `postgresql://${data?.databaseUser}:${data?.databasePassword}@${ip}:${port}/${data?.databaseName}`;
}; };
setConnectionUrl(buildConnectionUrl()); setConnectionUrl(buildConnectionUrl());
@@ -92,7 +92,7 @@ export const ShowExternalPostgresCredentials = ({ postgresId }: Props) => {
data?.databasePassword, data?.databasePassword,
form, form,
data?.databaseName, data?.databaseName,
getIp, ip,
]); ]);
return ( return (

View File

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

View File

@@ -39,7 +39,7 @@ import { slugify } from "@/lib/slug";
import { api } from "@/utils/api"; import { api } from "@/utils/api";
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
import { CircuitBoard, HelpCircle } from "lucide-react"; import { CircuitBoard, HelpCircle } from "lucide-react";
import { useEffect, useState } from "react"; import { useEffect } from "react";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { toast } from "sonner"; import { toast } from "sonner";
import { z } from "zod"; import { z } from "zod";
@@ -71,7 +71,6 @@ interface Props {
export const AddCompose = ({ projectId, projectName }: Props) => { export const AddCompose = ({ projectId, projectName }: Props) => {
const utils = api.useUtils(); const utils = api.useUtils();
const [visible, setVisible] = useState(false);
const slug = slugify(projectName); const slug = slugify(projectName);
const { data: servers } = api.server.withSSHKey.useQuery(); const { data: servers } = api.server.withSSHKey.useQuery();
const { mutateAsync, isLoading, error, isError } = const { mutateAsync, isLoading, error, isError } =
@@ -102,7 +101,6 @@ export const AddCompose = ({ projectId, projectName }: Props) => {
}) })
.then(async () => { .then(async () => {
toast.success("Compose Created"); toast.success("Compose Created");
setVisible(false);
await utils.project.one.invalidate({ await utils.project.one.invalidate({
projectId, projectId,
}); });
@@ -113,7 +111,7 @@ export const AddCompose = ({ projectId, projectName }: Props) => {
}; };
return ( return (
<Dialog open={visible} onOpenChange={setVisible}> <Dialog>
<DialogTrigger className="w-full"> <DialogTrigger className="w-full">
<DropdownMenuItem <DropdownMenuItem
className="w-full cursor-pointer space-x-3" className="w-full cursor-pointer space-x-3"
@@ -151,10 +149,7 @@ export const AddCompose = ({ projectId, projectName }: Props) => {
{...field} {...field}
onChange={(e) => { onChange={(e) => {
const val = e.target.value?.trim() || ""; const val = e.target.value?.trim() || "";
form.setValue( form.setValue("appName", `${slug}-${val}`);
"appName",
`${slug}-${val.toLowerCase()}`,
);
field.onChange(val); field.onChange(val);
}} }}
/> />

View File

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

View File

@@ -48,7 +48,6 @@ export const ShowExternalRedisCredentials = ({ redisId }: Props) => {
const { data, refetch } = api.redis.one.useQuery({ redisId }); const { data, refetch } = api.redis.one.useQuery({ redisId });
const { mutateAsync, isLoading } = api.redis.saveExternalPort.useMutation(); const { mutateAsync, isLoading } = api.redis.saveExternalPort.useMutation();
const [connectionUrl, setConnectionUrl] = useState(""); const [connectionUrl, setConnectionUrl] = useState("");
const getIp = data?.server?.ipAddress || ip;
const form = useForm<DockerProvider>({ const form = useForm<DockerProvider>({
defaultValues: {}, defaultValues: {},
@@ -82,11 +81,11 @@ export const ShowExternalRedisCredentials = ({ redisId }: Props) => {
const hostname = window.location.hostname; const hostname = window.location.hostname;
const port = form.watch("externalPort") || data?.externalPort; const port = form.watch("externalPort") || data?.externalPort;
return `redis://default:${data?.databasePassword}@${getIp}:${port}`; return `redis://default:${data?.databasePassword}@${ip}:${port}`;
}; };
setConnectionUrl(buildConnectionUrl()); setConnectionUrl(buildConnectionUrl());
}, [data?.appName, data?.externalPort, data?.databasePassword, form, getIp]); }, [data?.appName, data?.externalPort, data?.databasePassword, form, ip]);
return ( return (
<> <>
<div className="flex w-full flex-col gap-5 "> <div className="flex w-full flex-col gap-5 ">

View File

@@ -17,18 +17,10 @@ import {
FormMessage, FormMessage,
} from "@/components/ui/form"; } from "@/components/ui/form";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { api } from "@/utils/api"; import { api } from "@/utils/api";
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
import { AlertTriangle, Container } from "lucide-react"; import { AlertTriangle, Container } from "lucide-react";
import { useRouter } from "next/router";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { toast } from "sonner"; import { toast } from "sonner";
@@ -44,9 +36,10 @@ const AddRegistrySchema = z.object({
password: z.string().min(1, { password: z.string().min(1, {
message: "Password is required", message: "Password is required",
}), }),
registryUrl: z.string(), registryUrl: z.string().min(1, {
message: "Registry URL is required",
}),
imagePrefix: z.string(), imagePrefix: z.string(),
serverId: z.string().optional(),
}); });
type AddRegistry = z.infer<typeof AddRegistrySchema>; type AddRegistry = z.infer<typeof AddRegistrySchema>;
@@ -55,9 +48,9 @@ export const AddRegistry = () => {
const utils = api.useUtils(); const utils = api.useUtils();
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
const { mutateAsync, error, isError } = api.registry.create.useMutation(); const { mutateAsync, error, isError } = api.registry.create.useMutation();
const { data: servers } = api.server.withSSHKey.useQuery();
const { mutateAsync: testRegistry, isLoading } = const { mutateAsync: testRegistry, isLoading } =
api.registry.testRegistry.useMutation(); api.registry.testRegistry.useMutation();
const router = useRouter();
const form = useForm<AddRegistry>({ const form = useForm<AddRegistry>({
defaultValues: { defaultValues: {
username: "", username: "",
@@ -65,7 +58,6 @@ export const AddRegistry = () => {
registryUrl: "", registryUrl: "",
imagePrefix: "", imagePrefix: "",
registryName: "", registryName: "",
serverId: "",
}, },
resolver: zodResolver(AddRegistrySchema), resolver: zodResolver(AddRegistrySchema),
}); });
@@ -75,7 +67,6 @@ export const AddRegistry = () => {
const registryUrl = form.watch("registryUrl"); const registryUrl = form.watch("registryUrl");
const registryName = form.watch("registryName"); const registryName = form.watch("registryName");
const imagePrefix = form.watch("imagePrefix"); const imagePrefix = form.watch("imagePrefix");
const serverId = form.watch("serverId");
useEffect(() => { useEffect(() => {
form.reset({ form.reset({
@@ -83,7 +74,6 @@ export const AddRegistry = () => {
password: "", password: "",
registryUrl: "", registryUrl: "",
imagePrefix: "", imagePrefix: "",
serverId: "",
}); });
}, [form, form.reset, form.formState.isSubmitSuccessful]); }, [form, form.reset, form.formState.isSubmitSuccessful]);
@@ -95,7 +85,6 @@ export const AddRegistry = () => {
registryUrl: data.registryUrl, registryUrl: data.registryUrl,
registryType: "cloud", registryType: "cloud",
imagePrefix: data.imagePrefix, imagePrefix: data.imagePrefix,
serverId: data.serverId,
}) })
.then(async (data) => { .then(async (data) => {
await utils.registry.all.invalidate(); await utils.registry.all.invalidate();
@@ -222,77 +211,34 @@ export const AddRegistry = () => {
)} )}
/> />
</div> </div>
<DialogFooter className="flex flex-col w-full sm:justify-between gap-4 flex-wrap sm:flex-col"> <DialogFooter className="flex flex-row w-full sm:justify-between gap-4 flex-wrap">
<div className="flex flex-col gap-4 border p-2 rounded-lg"> <Button
<span className="text-sm text-muted-foreground"> type="button"
Select a server to test the registry. If you don't have a variant={"secondary"}
server choose the default one. isLoading={isLoading}
</span> onClick={async () => {
<FormField await testRegistry({
control={form.control} username: username,
name="serverId" password: password,
render={({ field }) => ( registryUrl: registryUrl,
<FormItem> registryName: registryName,
<FormLabel>Server (Optional)</FormLabel> registryType: "cloud",
<FormControl> imagePrefix: imagePrefix,
<Select })
onValueChange={field.onChange} .then((data) => {
defaultValue={field.value} if (data) {
> toast.success("Registry Tested Successfully");
<SelectTrigger className="w-full"> } else {
<SelectValue placeholder="Select a server" /> toast.error("Registry Test Failed");
</SelectTrigger> }
<SelectContent>
<SelectGroup>
<SelectLabel>Servers</SelectLabel>
{servers?.map((server) => (
<SelectItem
key={server.serverId}
value={server.serverId}
>
{server.name}
</SelectItem>
))}
<SelectItem value={"none"}>None</SelectItem>
</SelectGroup>
</SelectContent>
</Select>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Button
type="button"
variant={"secondary"}
isLoading={isLoading}
onClick={async () => {
await testRegistry({
username: username,
password: password,
registryUrl: registryUrl,
registryName: registryName,
registryType: "cloud",
imagePrefix: imagePrefix,
serverId: serverId,
}) })
.then((data) => { .catch(() => {
if (data) { toast.error("Error to test the registry");
toast.success("Registry Tested Successfully"); });
} else { }}
toast.error("Registry Test Failed"); >
} Test Registry
}) </Button>
.catch(() => {
toast.error("Error to test the registry");
});
}}
>
Test Registry
</Button>
</div>
<Button isLoading={form.formState.isSubmitting} type="submit"> <Button isLoading={form.formState.isSubmitting} type="submit">
Create Create
</Button> </Button>

View File

@@ -17,15 +17,6 @@ import {
FormMessage, FormMessage,
} from "@/components/ui/form"; } from "@/components/ui/form";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { api } from "@/utils/api"; import { api } from "@/utils/api";
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
@@ -43,9 +34,10 @@ const updateRegistry = z.object({
message: "Username is required", message: "Username is required",
}), }),
password: z.string(), password: z.string(),
registryUrl: z.string(), registryUrl: z.string().min(1, {
message: "Registry URL is required",
}),
imagePrefix: z.string(), imagePrefix: z.string(),
serverId: z.string().optional(),
}); });
type UpdateRegistry = z.infer<typeof updateRegistry>; type UpdateRegistry = z.infer<typeof updateRegistry>;
@@ -56,8 +48,6 @@ interface Props {
export const UpdateDockerRegistry = ({ registryId }: Props) => { export const UpdateDockerRegistry = ({ registryId }: Props) => {
const utils = api.useUtils(); const utils = api.useUtils();
const { data: servers } = api.server.withSSHKey.useQuery();
const { mutateAsync: testRegistry, isLoading } = const { mutateAsync: testRegistry, isLoading } =
api.registry.testRegistry.useMutation(); api.registry.testRegistry.useMutation();
const { data, refetch } = api.registry.one.useQuery( const { data, refetch } = api.registry.one.useQuery(
@@ -79,19 +69,15 @@ export const UpdateDockerRegistry = ({ registryId }: Props) => {
username: "", username: "",
password: "", password: "",
registryUrl: "", registryUrl: "",
serverId: "",
}, },
resolver: zodResolver(updateRegistry), resolver: zodResolver(updateRegistry),
}); });
console.log(form.formState.errors);
const password = form.watch("password"); const password = form.watch("password");
const username = form.watch("username"); const username = form.watch("username");
const registryUrl = form.watch("registryUrl"); const registryUrl = form.watch("registryUrl");
const registryName = form.watch("registryName"); const registryName = form.watch("registryName");
const imagePrefix = form.watch("imagePrefix"); const imagePrefix = form.watch("imagePrefix");
const serverId = form.watch("serverId");
useEffect(() => { useEffect(() => {
if (data) { if (data) {
@@ -101,7 +87,6 @@ export const UpdateDockerRegistry = ({ registryId }: Props) => {
username: data.username || "", username: data.username || "",
password: "", password: "",
registryUrl: data.registryUrl || "", registryUrl: data.registryUrl || "",
serverId: "",
}); });
} }
}, [form, form.reset, data]); }, [form, form.reset, data]);
@@ -114,7 +99,6 @@ export const UpdateDockerRegistry = ({ registryId }: Props) => {
username: data.username, username: data.username,
registryUrl: data.registryUrl, registryUrl: data.registryUrl,
imagePrefix: data.imagePrefix, imagePrefix: data.imagePrefix,
serverId: data.serverId,
}) })
.then(async (data) => { .then(async (data) => {
toast.success("Registry Updated"); toast.success("Registry Updated");
@@ -240,47 +224,13 @@ export const UpdateDockerRegistry = ({ registryId }: Props) => {
</div> </div>
</form> </form>
<DialogFooter className="flex flex-col w-full sm:justify-between gap-4 flex-wrap sm:flex-col"> <DialogFooter
<div className="flex flex-col gap-4 border p-2 rounded-lg"> className={cn(
<span className="text-sm text-muted-foreground"> isCloud ? "sm:justify-between " : "",
Select a server to test the registry. If you don't have a server "flex flex-row w-full gap-4 flex-wrap",
choose the default one. )}
</span> >
<FormField {isCloud && (
control={form.control}
name="serverId"
render={({ field }) => (
<FormItem>
<FormLabel>Server (Optional)</FormLabel>
<FormControl>
<Select
onValueChange={field.onChange}
defaultValue={field.value}
>
<SelectTrigger className="w-full">
<SelectValue placeholder="Select a server" />
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectLabel>Servers</SelectLabel>
{servers?.map((server) => (
<SelectItem
key={server.serverId}
value={server.serverId}
>
{server.name}
</SelectItem>
))}
<SelectItem value={"none"}>None</SelectItem>
</SelectGroup>
</SelectContent>
</Select>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Button <Button
type="button" type="button"
variant={"secondary"} variant={"secondary"}
@@ -293,7 +243,6 @@ export const UpdateDockerRegistry = ({ registryId }: Props) => {
registryName: registryName, registryName: registryName,
registryType: "cloud", registryType: "cloud",
imagePrefix: imagePrefix, imagePrefix: imagePrefix,
serverId: serverId,
}) })
.then((data) => { .then((data) => {
if (data) { if (data) {
@@ -309,12 +258,12 @@ export const UpdateDockerRegistry = ({ registryId }: Props) => {
> >
Test Registry Test Registry
</Button> </Button>
</div> )}
<Button <Button
isLoading={form.formState.isSubmitting} isLoading={form.formState.isSubmitting}
type="submit"
form="hook-form" form="hook-form"
type="submit"
> >
Update Update
</Button> </Button>

View File

@@ -17,13 +17,29 @@ import {
FormLabel, FormLabel,
FormMessage, FormMessage,
} from "@/components/ui/form"; } from "@/components/ui/form";
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { api } from "@/utils/api"; import { api } from "@/utils/api";
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
import { useEffect } from "react"; import { useEffect, useState } from "react";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { toast } from "sonner"; import { toast } from "sonner";
import { z } from "zod"; import { z } from "zod";
import {
getObjectSchema,
mergeFormValues,
providerSchemas,
providersData,
} from "../../application/domains/schema";
import { capitalize } from "lodash";
const addDestination = z.object({ const addDestination = z.object({
name: z.string().min(1, "Name is required"), name: z.string().min(1, "Name is required"),
@@ -38,43 +54,45 @@ type AddDestination = z.infer<typeof addDestination>;
export const AddDestination = () => { export const AddDestination = () => {
const utils = api.useUtils(); const utils = api.useUtils();
const [provider, setProviders] = useState<keyof typeof providerSchemas>("s3");
const { mutateAsync, isError, error, isLoading } = const { mutateAsync, isError, error, isLoading } =
api.destination.create.useMutation(); api.destination.create.useMutation();
const { mutateAsync: testConnection, isLoading: isLoadingConnection } = const { mutateAsync: testConnection, isLoading: isLoadingConnection } =
api.destination.testConnection.useMutation(); api.destination.testConnection.useMutation();
const form = useForm<AddDestination>({ const schema = providerSchemas[provider];
const form = useForm<z.infer<typeof schema>>({
defaultValues: { defaultValues: {
accessKeyId: "", ...getObjectSchema(schema),
bucket: "",
name: "",
region: "",
secretAccessKey: "",
endpoint: "",
}, },
resolver: zodResolver(addDestination), resolver: zodResolver(schema),
}); });
useEffect(() => { const {
form.reset(); register,
}, [form, form.reset, form.formState.isSubmitSuccessful]); handleSubmit,
control,
formState: { errors },
} = form;
const onSubmit = async (data: AddDestination) => { const onSubmit = async (data: z.infer<typeof schema>) => {
await mutateAsync({ // await mutateAsync({
accessKey: data.accessKeyId, // accessKey: data.accessKeyId,
bucket: data.bucket, // bucket: data.bucket,
endpoint: data.endpoint, // endpoint: data.endpoint,
name: data.name, // name: data.name,
region: data.region, // region: data.region,
secretAccessKey: data.secretAccessKey, // secretAccessKey: data.secretAccessKey,
}) // })
.then(async () => { // .then(async () => {
toast.success("Destination Created"); // toast.success("Destination Created");
await utils.destination.all.invalidate(); // await utils.destination.all.invalidate();
}) // })
.catch(() => { // .catch(() => {
toast.error("Error to create the Destination"); // toast.error("Error to create the Destination");
}); // });
}; };
const fields = Object.keys(schema.shape);
return ( return (
<Dialog> <Dialog>
<DialogTrigger className="" asChild> <DialogTrigger className="" asChild>
@@ -88,140 +106,117 @@ export const AddDestination = () => {
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
{isError && <AlertBlock type="error">{error?.message}</AlertBlock>} {isError && <AlertBlock type="error">{error?.message}</AlertBlock>}
<Form {...form}> <Form {...form}>
<form <form
id="hook-form-destination-add" id="hook-form-destination-add"
onSubmit={form.handleSubmit(onSubmit)} onSubmit={form.handleSubmit(onSubmit)}
className="grid w-full gap-4 " className="grid w-full gap-8 "
> >
<FormField <div className="flex flex-col gap-2">
control={form.control} {fields.map((input) => (
name="name" <FormField
render={({ field }) => { control={control}
return ( key={`${provider}.${input}`}
<FormItem> name={`${provider}.${input}`}
<FormLabel>Name</FormLabel> render={({ field }) => {
<FormControl> return (
<Input placeholder={"S3 Bucket"} {...field} /> <FormItem>
</FormControl> <FormLabel>{capitalize(input)}</FormLabel>
<FormMessage /> <FormControl>
</FormItem> <Input placeholder={"Value"} {...field} />
); </FormControl>
}} <span className="text-sm font-medium text-destructive">
/> {errors[input]?.message}
</span>
<FormField </FormItem>
control={form.control} );
name="accessKeyId" }}
render={({ field }) => { />
return ( ))}
<FormItem> </div>
<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>
)}
/>
</form> </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> </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> </DialogContent>
</Dialog> </Dialog>
); );

View File

@@ -212,21 +212,7 @@ export const AddServer = () => {
<FormItem> <FormItem>
<FormLabel>Port</FormLabel> <FormLabel>Port</FormLabel>
<FormControl> <FormControl>
<Input <Input placeholder="22" {...field} />
placeholder="22"
{...field}
onChange={(e) => {
const value = e.target.value;
if (value === "") {
field.onChange(0);
} else {
const number = Number.parseInt(value, 10);
if (!Number.isNaN(number)) {
field.onChange(number);
}
}
}}
/>
</FormControl> </FormControl>
<FormMessage /> <FormMessage />

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 { DateTooltip } from "@/components/shared/date-tooltip";
import { DialogAction } from "@/components/shared/dialog-action"; import { DialogAction } from "@/components/shared/dialog-action";
import { StatusTooltip } from "@/components/shared/status-tooltip"; import { StatusTooltip } from "@/components/shared/status-tooltip";
@@ -19,20 +17,22 @@ import {
DialogTrigger, DialogTrigger,
} from "@/components/ui/dialog"; } from "@/components/ui/dialog";
import { DropdownMenuItem } from "@/components/ui/dropdown-menu"; 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 { api } from "@/utils/api";
import copy from "copy-to-clipboard";
import { import {
CopyIcon, CopyIcon,
ExternalLinkIcon, ExternalLinkIcon,
RocketIcon, RocketIcon,
ServerIcon, ServerIcon,
} from "lucide-react"; } from "lucide-react";
import Link from "next/link";
import { useState } from "react"; import { useState } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import { ShowDeployment } from "../../application/deployments/show-deployment"; 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 { interface Props {
serverId: string; serverId: string;
@@ -59,6 +59,8 @@ export const SetupServer = ({ serverId }: Props) => {
const { mutateAsync, isLoading } = api.server.setup.useMutation(); const { mutateAsync, isLoading } = api.server.setup.useMutation();
console.log(server?.sshKey);
return ( return (
<Dialog open={isOpen} onOpenChange={setIsOpen}> <Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogTrigger asChild> <DialogTrigger asChild>
@@ -179,9 +181,7 @@ export const SetupServer = ({ serverId }: Props) => {
type="button" type="button"
className="absolute right-2 top-2" className="absolute right-2 top-2"
onClick={() => { onClick={() => {
copy( copy(server?.sshKey?.publicKey || "");
`echo "${server?.sshKey?.publicKey}" >> ~/.ssh/authorized_keys`,
);
toast.success("Copied to clipboard"); 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 { DialogAction } from "@/components/shared/dialog-action";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
@@ -31,6 +30,7 @@ import { SetupServer } from "./setup-server";
import { ShowDockerContainersModal } from "./show-docker-containers-modal"; import { ShowDockerContainersModal } from "./show-docker-containers-modal";
import { ShowTraefikFileSystemModal } from "./show-traefik-file-system-modal"; import { ShowTraefikFileSystemModal } from "./show-traefik-file-system-modal";
import { UpdateServer } from "./update-server"; import { UpdateServer } from "./update-server";
import { AlertBlock } from "@/components/shared/alert-block";
export const ShowServers = () => { export const ShowServers = () => {
const { data, refetch } = api.server.all.useQuery(); const { data, refetch } = api.server.all.useQuery();

View File

@@ -228,21 +228,7 @@ export const UpdateServer = ({ serverId }: Props) => {
<FormItem> <FormItem>
<FormLabel>Port</FormLabel> <FormLabel>Port</FormLabel>
<FormControl> <FormControl>
<Input <Input placeholder="22" {...field} />
placeholder="22"
{...field}
onChange={(e) => {
const value = e.target.value;
if (value === "") {
field.onChange(0);
} else {
const number = Number.parseInt(value, 10);
if (!Number.isNaN(number)) {
field.onChange(number);
}
}
}}
/>
</FormControl> </FormControl>
<FormMessage /> <FormMessage />

View File

@@ -31,39 +31,4 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(
); );
Input.displayName = "Input"; Input.displayName = "Input";
const NumberInput = React.forwardRef<HTMLInputElement, InputProps>( export { Input };
({ className, errorMessage, ...props }, ref) => {
return (
<Input
type="text"
className={cn("text-left", className)}
ref={ref}
{...props}
value={props.value === undefined ? undefined : String(props.value)}
onChange={(e) => {
const value = e.target.value;
if (value === "") {
props.onChange?.(e);
} else {
const number = Number.parseInt(value, 10);
if (!Number.isNaN(number)) {
const syntheticEvent = {
...e,
target: {
...e.target,
value: number,
},
};
props.onChange?.(
syntheticEvent as unknown as React.ChangeEvent<HTMLInputElement>,
);
}
}
}}
/>
);
},
);
NumberInput.displayName = "NumberInput";
export { Input, NumberInput };

View File

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

View File

@@ -1 +0,0 @@
ALTER TABLE "registry" ALTER COLUMN "registryUrl" SET DEFAULT '';

View File

@@ -1,5 +1,5 @@
{ {
"id": "8ffdfaff-f166-42dc-ac77-4fd9309d736a", "id": "ac691526-842c-4a05-84f0-5412425482db",
"prevId": "19a70a39-f719-400b-b61e-6ddf1bcc6ac5", "prevId": "19a70a39-f719-400b-b61e-6ddf1bcc6ac5",
"version": "6", "version": "6",
"dialect": "postgresql", "dialect": "postgresql",
@@ -1818,6 +1818,12 @@
"primaryKey": false, "primaryKey": false,
"notNull": true "notNull": true
}, },
"schema": {
"name": "schema",
"type": "json",
"primaryKey": false,
"notNull": false
},
"adminId": { "adminId": {
"name": "adminId", "name": "adminId",
"type": "text", "type": "text",
@@ -2923,8 +2929,7 @@
"name": "registryUrl", "name": "registryUrl",
"type": "text", "type": "text",
"primaryKey": false, "primaryKey": false,
"notNull": true, "notNull": true
"default": "''"
}, },
"createdAt": { "createdAt": {
"name": "createdAt", "name": "createdAt",

View File

@@ -271,8 +271,8 @@
{ {
"idx": 38, "idx": 38,
"version": "6", "version": "6",
"when": 1727942090102, "when": 1727036227151,
"tag": "0038_rapid_landau", "tag": "0038_familiar_shockwave",
"breakpoints": true "breakpoints": true
} }
] ]

View File

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

View File

@@ -38,29 +38,31 @@ export const destinationRouter = createTRPCRouter({
testConnection: adminProcedure testConnection: adminProcedure
.input(apiCreateDestination) .input(apiCreateDestination)
.mutation(async ({ input }) => { .mutation(async ({ input }) => {
const { secretAccessKey, bucket, region, endpoint, accessKey } = input; console.log(input);
// const { secretAccessKey, bucket, region, endpoint, accessKey } = input;
try { // try {
const rcloneFlags = [ // const rcloneFlags = [
// `--s3-provider=Cloudflare`, // // `--s3-provider=Cloudflare`,
`--s3-access-key-id=${accessKey}`, // `--s3-access-key-id=${accessKey}`,
`--s3-secret-access-key=${secretAccessKey}`, // `--s3-secret-access-key=${secretAccessKey}`,
`--s3-region=${region}`, // `--s3-region=${region}`,
`--s3-endpoint=${endpoint}`, // `--s3-endpoint=${endpoint}`,
"--s3-no-check-bucket", // "--s3-no-check-bucket",
"--s3-force-path-style", // "--s3-force-path-style",
]; // ];
const rcloneDestination = `:s3:${bucket}`; const connextion = buildRcloneCommand(input.json.provider, input.json);
const rcloneCommand = `rclone ls ${rcloneFlags.join(" ")} "${rcloneDestination}"`; console.log(connextion);
await execAsync(rcloneCommand); // const rcloneDestination = `:s3:${bucket}`;
} catch (error) { // const rcloneCommand = `rclone ls ${rcloneFlags.join(" ")} "${rcloneDestination}"`;
console.log(error); await execAsync(connextion);
throw new TRPCError({ // } catch (error) {
code: "BAD_REQUEST", // console.log(error);
message: "Error to connect to bucket", // throw new TRPCError({
cause: error, // code: "BAD_REQUEST",
}); // message: "Error to connect to bucket",
} // cause: error,
// });
// }
}), }),
one: protectedProcedure one: protectedProcedure
.input(apiFindOneDestination) .input(apiFindOneDestination)
@@ -68,7 +70,7 @@ export const destinationRouter = createTRPCRouter({
const destination = await findDestinationById(input.destinationId); const destination = await findDestinationById(input.destinationId);
return destination; return destination;
}), }),
all: protectedProcedure.query(async () => { all: adminProcedure.query(async () => {
return await db.query.destinations.findMany({}); return await db.query.destinations.findMany({});
}), }),
remove: adminProcedure remove: adminProcedure
@@ -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

@@ -7,7 +7,7 @@ import {
apiUpdateRegistry, apiUpdateRegistry,
} from "@/server/db/schema"; } from "@/server/db/schema";
import { initializeRegistry } from "@/server/setup/registry-setup"; import { initializeRegistry } from "@/server/setup/registry-setup";
import { execAsync, execAsyncRemote } from "@/server/utils/process/execAsync"; import { execAsync } from "@/server/utils/process/execAsync";
import { manageRegistry } from "@/server/utils/traefik/registry"; import { manageRegistry } from "@/server/utils/traefik/registry";
import { TRPCError } from "@trpc/server"; import { TRPCError } from "@trpc/server";
import { import {
@@ -58,13 +58,7 @@ export const registryRouter = createTRPCRouter({
.mutation(async ({ input }) => { .mutation(async ({ input }) => {
try { try {
const loginCommand = `echo ${input.password} | docker login ${input.registryUrl} --username ${input.username} --password-stdin`; const loginCommand = `echo ${input.password} | docker login ${input.registryUrl} --username ${input.username} --password-stdin`;
await execAsync(loginCommand);
if (input.serverId && input.serverId !== "none") {
await execAsyncRemote(input.serverId, loginCommand);
} else {
await execAsync(loginCommand);
}
return true; return true;
} catch (error) { } catch (error) {
console.log("Error Registry:", error); console.log("Error Registry:", error);
@@ -84,7 +78,6 @@ export const registryRouter = createTRPCRouter({
? input.registryUrl ? input.registryUrl
: "dokploy-registry.docker.localhost", : "dokploy-registry.docker.localhost",
imagePrefix: null, imagePrefix: null,
serverId: undefined,
}); });
await manageRegistry(selfHostedRegistry); await manageRegistry(selfHostedRegistry);
@@ -93,17 +86,3 @@ export const registryRouter = createTRPCRouter({
return selfHostedRegistry; return selfHostedRegistry;
}), }),
}); });
const shellEscape = (str: string) => {
const ret = [];
let s = str;
if (/[^A-Za-z0-9_\/:=-]/.test(s)) {
s = `'${s.replace(/'/g, "'\\''")}'`;
s = s
.replace(/^(?:'')+/g, "") // unduplicate single-quote at the beginning
.replace(/\\'''/g, "\\'"); // remove non-escaped single-quote if there are enclosed between 2 escaped
}
ret.push(s);
return ret.join(" ");
};

View File

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

View File

@@ -2,7 +2,7 @@ import { db } from "@/server/db";
import { type apiCreateRegistry, registry } from "@/server/db/schema"; import { type apiCreateRegistry, registry } from "@/server/db/schema";
import { initializeRegistry } from "@/server/setup/registry-setup"; import { initializeRegistry } from "@/server/setup/registry-setup";
import { removeService } from "@/server/utils/docker/utils"; import { removeService } from "@/server/utils/docker/utils";
import { execAsync, execAsyncRemote } from "@/server/utils/process/execAsync"; import { execAsync } from "@/server/utils/process/execAsync";
import { import {
manageRegistry, manageRegistry,
removeSelfHostedRegistry, removeSelfHostedRegistry,
@@ -32,10 +32,9 @@ export const createRegistry = async (input: typeof apiCreateRegistry._type) => {
message: "Error input: Inserting registry", message: "Error input: Inserting registry",
}); });
} }
const loginCommand = `echo ${input.password} | docker login ${input.registryUrl} --username ${input.username} --password-stdin`;
if (input.serverId && input.serverId !== "none") { if (newRegistry.registryType === "cloud") {
await execAsyncRemote(input.serverId, loginCommand); const loginCommand = `echo ${input.password} | docker login ${input.registryUrl} --username ${input.username} --password-stdin`;
} else if (newRegistry.registryType === "cloud") {
await execAsync(loginCommand); await execAsync(loginCommand);
} }
@@ -77,7 +76,7 @@ export const removeRegistry = async (registryId: string) => {
export const updateRegistry = async ( export const updateRegistry = async (
registryId: string, registryId: string,
registryData: Partial<Registry> & { serverId?: string | null }, registryData: Partial<Registry>,
) => { ) => {
try { try {
const response = await db const response = await db
@@ -93,13 +92,6 @@ export const updateRegistry = async (
await manageRegistry(response); await manageRegistry(response);
await initializeRegistry(response.username, response.password); await initializeRegistry(response.username, response.password);
} }
const loginCommand = `echo ${response?.password} | docker login ${response?.registryUrl} --username ${response?.username} --password-stdin`;
if (registryData?.serverId && registryData?.serverId !== "none") {
await execAsyncRemote(registryData.serverId, loginCommand);
} else if (response?.registryType === "cloud") {
await execAsync(loginCommand);
}
return response; return response;
} catch (error) { } catch (error) {

View File

@@ -1,8 +1,6 @@
import type { IncomingMessage } from "node:http"; import type { IncomingMessage } from "node:http";
import { TimeSpan } from "lucia"; import { TimeSpan } from "lucia";
import { Lucia } from "lucia/dist/core.js"; import { Lucia } from "lucia/dist/core.js";
import { findAdminByAuthId } from "../api/services/admin";
import { findUserByAuthId } from "../api/services/user";
import { type ReturnValidateToken, adapter } from "./auth"; import { type ReturnValidateToken, adapter } from "./auth";
export const luciaToken = new Lucia(adapter, { export const luciaToken = new Lucia(adapter, {
@@ -33,16 +31,6 @@ export const validateBearerToken = async (
}; };
} }
const result = await luciaToken.validateSession(sessionId); const result = await luciaToken.validateSession(sessionId);
if (result.user) {
if (result.user?.rol === "admin") {
const admin = await findAdminByAuthId(result.user.id);
result.user.adminId = admin.adminId;
} else if (result.user?.rol === "user") {
const userResult = await findUserByAuthId(result.user.id);
result.user.adminId = userResult.adminId;
}
}
return { return {
session: result.session, session: result.session,
...((result.user && { ...((result.user && {

View File

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

View File

@@ -23,7 +23,7 @@ export const registry = pgTable("registry", {
imagePrefix: text("imagePrefix"), imagePrefix: text("imagePrefix"),
username: text("username").notNull(), username: text("username").notNull(),
password: text("password").notNull(), password: text("password").notNull(),
registryUrl: text("registryUrl").notNull().default(""), registryUrl: text("registryUrl").notNull(),
createdAt: text("createdAt") createdAt: text("createdAt")
.notNull() .notNull()
.$defaultFn(() => new Date().toISOString()), .$defaultFn(() => new Date().toISOString()),
@@ -45,7 +45,7 @@ const createSchema = createInsertSchema(registry, {
registryName: z.string().min(1), registryName: z.string().min(1),
username: z.string().min(1), username: z.string().min(1),
password: z.string().min(1), password: z.string().min(1),
registryUrl: z.string(), registryUrl: z.string().min(1),
adminId: z.string().min(1), adminId: z.string().min(1),
registryId: z.string().min(1), registryId: z.string().min(1),
registryType: z.enum(["selfHosted", "cloud"]), registryType: z.enum(["selfHosted", "cloud"]),
@@ -62,10 +62,7 @@ export const apiCreateRegistry = createSchema
registryType: z.enum(["selfHosted", "cloud"]), registryType: z.enum(["selfHosted", "cloud"]),
imagePrefix: z.string().nullable().optional(), imagePrefix: z.string().nullable().optional(),
}) })
.required() .required();
.extend({
serverId: z.string().optional(),
});
export const apiTestRegistry = createSchema.pick({}).extend({ export const apiTestRegistry = createSchema.pick({}).extend({
registryName: z.string().min(1), registryName: z.string().min(1),
@@ -74,7 +71,6 @@ export const apiTestRegistry = createSchema.pick({}).extend({
registryUrl: z.string(), registryUrl: z.string(),
registryType: z.enum(["selfHosted", "cloud"]), registryType: z.enum(["selfHosted", "cloud"]),
imagePrefix: z.string().nullable().optional(), imagePrefix: z.string().nullable().optional(),
serverId: z.string().optional(),
}); });
export const apiRemoveRegistry = createSchema export const apiRemoveRegistry = createSchema
@@ -91,7 +87,6 @@ export const apiFindOneRegistry = createSchema
export const apiUpdateRegistry = createSchema.partial().extend({ export const apiUpdateRegistry = createSchema.partial().extend({
registryId: z.string().min(1), registryId: z.string().min(1),
serverId: z.string().optional(),
}); });
export const apiEnableSelfHostedRegistry = createSchema export const apiEnableSelfHostedRegistry = createSchema

View File

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

View File

@@ -1,4 +1,4 @@
import { type WriteStream, existsSync, mkdirSync } from "node:fs"; import type { WriteStream } from "node:fs";
import path from "node:path"; import path from "node:path";
import { buildStatic, getStaticCommand } from "@/server/utils/builders/static"; import { buildStatic, getStaticCommand } from "@/server/utils/builders/static";
import { nanoid } from "nanoid"; import { nanoid } from "nanoid";
@@ -42,6 +42,7 @@ export const buildNixpacks = async (
and copy the artifacts on the host filesystem. and copy the artifacts on the host filesystem.
Then, remove the container and create a static build. Then, remove the container and create a static build.
*/ */
if (publishDirectory) { if (publishDirectory) {
await spawnAsync( await spawnAsync(
"docker", "docker",
@@ -49,22 +50,12 @@ export const buildNixpacks = async (
writeToStream, writeToStream,
); );
const localPath = path.join(buildAppDirectory, publishDirectory);
if (!existsSync(path.dirname(localPath))) {
mkdirSync(path.dirname(localPath), { recursive: true });
}
// https://docs.docker.com/reference/cli/docker/container/cp/
const isDirectory =
publishDirectory.endsWith("/") || !path.extname(publishDirectory);
await spawnAsync( await spawnAsync(
"docker", "docker",
[ [
"cp", "cp",
`${buildContainerId}:/app/${publishDirectory}${isDirectory ? "/." : ""}`, `${buildContainerId}:/app/${publishDirectory}`,
localPath, path.join(buildAppDirectory, publishDirectory),
], ],
writeToStream, writeToStream,
); );
@@ -101,6 +92,7 @@ export const getNixpacksCommand = (
/* No need for any start command, since we'll use nginx later on */ /* No need for any start command, since we'll use nginx later on */
args.push("--no-error-without-start"); args.push("--no-error-without-start");
} }
console.log("args", args);
const command = `nixpacks ${args.join(" ")}`; const command = `nixpacks ${args.join(" ")}`;
let bashCommand = ` let bashCommand = `
echo "Starting nixpacks build..." >> ${logPath}; echo "Starting nixpacks build..." >> ${logPath};
@@ -117,14 +109,9 @@ echo "✅ Nixpacks build completed." >> ${logPath};
Then, remove the container and create a static build. Then, remove the container and create a static build.
*/ */
if (publishDirectory) { if (publishDirectory) {
const localPath = path.join(buildAppDirectory, publishDirectory);
const isDirectory =
publishDirectory.endsWith("/") || !path.extname(publishDirectory);
bashCommand += ` bashCommand += `
docker create --name ${buildContainerId} ${appName} docker create --name ${buildContainerId} ${appName}
mkdir -p ${localPath} docker cp ${buildContainerId}:/app/${publishDirectory} ${path.join(buildAppDirectory, publishDirectory)} >> ${logPath} 2>> ${logPath} || {
docker cp ${buildContainerId}:/app/${publishDirectory}${isDirectory ? "/." : ""} ${path.join(buildAppDirectory, publishDirectory)} >> ${logPath} 2>> ${logPath} || {
docker rm ${buildContainerId} docker rm ${buildContainerId}
echo "❌ Copying ${publishDirectory} to ${path.join(buildAppDirectory, publishDirectory)} failed" >> ${logPath}; echo "❌ Copying ${publishDirectory} to ${path.join(buildAppDirectory, publishDirectory)} failed" >> ${logPath};
exit 1; exit 1;

View File

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

View File

@@ -147,15 +147,15 @@ export const cloneRawBitbucketRepositoryRemote = async (compose: Compose) => {
const bitbucketProvider = await findBitbucketById(bitbucketId); const bitbucketProvider = await findBitbucketById(bitbucketId);
const basePath = COMPOSE_PATH; const basePath = COMPOSE_PATH;
const outputPath = join(basePath, appName, "code"); const outputPath = join(basePath, appName, "code");
await recreateDirectory(outputPath);
const repoclone = `bitbucket.org/${bitbucketOwner}/${bitbucketRepository}.git`; const repoclone = `bitbucket.org/${bitbucketOwner}/${bitbucketRepository}.git`;
const cloneUrl = `https://${bitbucketProvider?.bitbucketUsername}:${bitbucketProvider?.appPassword}@${repoclone}`; const cloneUrl = `https://${bitbucketProvider?.bitbucketUsername}:${bitbucketProvider?.appPassword}@${repoclone}`;
try { try {
const command = ` await execAsyncRemote(
rm -rf ${outputPath}; serverId,
git clone --branch ${bitbucketBranch} --depth 1 ${cloneUrl} ${outputPath} `git clone --branch ${bitbucketBranch} --depth 1 ${cloneUrl} ${outputPath}`,
`; );
await execAsyncRemote(serverId, command);
} catch (error) { } catch (error) {
throw error; throw error;
} }

View File

@@ -271,13 +271,13 @@ export const cloneRawGithubRepositoryRemote = async (compose: Compose) => {
const octokit = authGithub(githubProvider); const octokit = authGithub(githubProvider);
const token = await getGithubToken(octokit); const token = await getGithubToken(octokit);
const repoclone = `github.com/${owner}/${repository}.git`; const repoclone = `github.com/${owner}/${repository}.git`;
await recreateDirectory(outputPath);
const cloneUrl = `https://oauth2:${token}@${repoclone}`; const cloneUrl = `https://oauth2:${token}@${repoclone}`;
try { try {
const command = ` await execAsyncRemote(
rm -rf ${outputPath}; serverId,
git clone --branch ${branch} --depth 1 ${cloneUrl} ${outputPath} `git clone --branch ${branch} --depth 1 ${cloneUrl} ${outputPath}`,
`; );
await execAsyncRemote(serverId, command);
} catch (error) { } catch (error) {
throw error; throw error;
} }

View File

@@ -390,14 +390,14 @@ export const cloneRawGitlabRepositoryRemote = async (compose: Compose) => {
await refreshGitlabToken(gitlabId); await refreshGitlabToken(gitlabId);
const basePath = COMPOSE_PATH; const basePath = COMPOSE_PATH;
const outputPath = join(basePath, appName, "code"); const outputPath = join(basePath, appName, "code");
await recreateDirectory(outputPath);
const repoclone = `gitlab.com/${gitlabPathNamespace}.git`; const repoclone = `gitlab.com/${gitlabPathNamespace}.git`;
const cloneUrl = `https://oauth2:${gitlabProvider?.accessToken}@${repoclone}`; const cloneUrl = `https://oauth2:${gitlabProvider?.accessToken}@${repoclone}`;
try { try {
const command = ` await execAsyncRemote(
rm -rf ${outputPath}; serverId,
git clone --branch ${branch} --depth 1 ${cloneUrl} ${outputPath} `git clone --branch ${branch} --depth 1 ${cloneUrl} ${outputPath}`,
`; );
await execAsyncRemote(serverId, command);
} catch (error) { } catch (error) {
throw error; throw error;
} }

View File

@@ -70,7 +70,6 @@ export const createComposeFileRawRemote = async (compose: Compose) => {
try { try {
const encodedContent = encodeBase64(composeFile); const encodedContent = encodeBase64(composeFile);
const command = ` const command = `
rm -rf ${outputPath};
mkdir -p ${outputPath}; mkdir -p ${outputPath};
echo "${encodedContent}" | base64 -d > "${filePath}"; echo "${encodedContent}" | base64 -d > "${filePath}";
`; `;

View File

@@ -72,8 +72,6 @@ export const setupTerminalWebSocketServer = (
"StrictHostKeyChecking=no", "StrictHostKeyChecking=no",
"-i", "-i",
privateKey, privateKey,
"-p",
`${server.port}`,
`${server.username}@${server.ipAddress}`, `${server.username}@${server.ipAddress}`,
]; ];
const ptyProcess = spawn("ssh", sshCommand.slice(1), { const ptyProcess = spawn("ssh", sshCommand.slice(1), {

View File

@@ -33,7 +33,7 @@ export function Footer() {
<div className="flex flex-col items-center border-t border-slate-400/10 py-10 sm:flex-row-reverse sm:justify-between"> <div className="flex flex-col items-center border-t border-slate-400/10 py-10 sm:flex-row-reverse sm:justify-between">
<div className="flex gap-x-6"> <div className="flex gap-x-6">
<Link <Link
href="https://x.com/getdokploy" href="https://twitter.com/Siumauricio"
className="group" className="group"
aria-label="Dokploy on Twitter" aria-label="Dokploy on Twitter"
> >

View File

@@ -1,133 +1,114 @@
#!/bin/bash #!/bin/bash
install_dokploy() {
if [ "$(id -u)" != "0" ]; then
echo "This script must be run as root" >&2
exit 1
fi
# check if is Mac OS if [ "$(id -u)" != "0" ]; then
if [ "$(uname)" = "Darwin" ]; then echo "This script must be run as root" >&2
echo "This script must be run on Linux" >&2 exit 1
exit 1 fi
fi
# check if is Mac OS
if [ "$(uname)" = "Darwin" ]; then
echo "This script must be run on Linux" >&2
exit 1
fi
# check if is running inside a container # check if is running inside a container
if [ -f /.dockerenv ]; then if [ -f /.dockerenv ]; then
echo "This script must be run on Linux" >&2 echo "This script must be run on Linux" >&2
exit 1 exit 1
fi fi
# check if something is running on port 80 # check if something is running on port 80
if ss -tulnp | grep ':80 ' >/dev/null; then if ss -tulnp | grep ':80 ' >/dev/null; then
echo "Error: something is already running on port 80" >&2 echo "Error: something is already running on port 80" >&2
exit 1 exit 1
fi fi
# check if something is running on port 443 # check if something is running on port 443
if ss -tulnp | grep ':443 ' >/dev/null; then if ss -tulnp | grep ':443 ' >/dev/null; then
echo "Error: something is already running on port 443" >&2 echo "Error: something is already running on port 443" >&2
exit 1 exit 1
fi fi
command_exists() { command_exists() {
command -v "$@" > /dev/null 2>&1 command -v "$@" > /dev/null 2>&1
}
if command_exists docker; then
echo "Docker already installed"
else
curl -sSL https://get.docker.com | sh
fi
docker swarm leave --force 2>/dev/null
get_ip() {
# Try to get IPv4
local ipv4=$(curl -4s https://ifconfig.io 2>/dev/null)
if [ -n "$ipv4" ]; then
echo "$ipv4"
else
# Try to get IPv6
local ipv6=$(curl -6s https://ifconfig.io 2>/dev/null)
if [ -n "$ipv6" ]; then
echo "$ipv6"
fi
fi
}
advertise_addr=$(get_ip)
docker swarm init --advertise-addr $advertise_addr
echo "Swarm initialized"
docker network rm -f dokploy-network 2>/dev/null
docker network create --driver overlay --attachable dokploy-network
echo "Network created"
mkdir -p /etc/dokploy
chmod 777 /etc/dokploy
docker pull dokploy/dokploy:canary
# Installation
docker service create \
--name dokploy \
--replicas 1 \
--network dokploy-network \
--mount type=bind,source=/var/run/docker.sock,target=/var/run/docker.sock \
--mount type=bind,source=/etc/dokploy,target=/etc/dokploy \
--mount type=volume,source=dokploy-docker-config,target=/root/.docker \
--publish published=3000,target=3000,mode=host \
--update-parallelism 1 \
--update-order stop-first \
--constraint 'node.role == manager' \
-e RELEASE_TAG=canary \
dokploy/dokploy:canary
GREEN="\033[0;32m"
YELLOW="\033[1;33m"
BLUE="\033[0;34m"
NC="\033[0m" # No Color
format_ip_for_url() {
local ip="$1"
if echo "$ip" | grep -q ':'; then
# IPv6
echo "[${ip}]"
else
# IPv4
echo "${ip}"
fi
}
formatted_addr=$(format_ip_for_url "$advertise_addr")
echo ""
printf "${GREEN}Congratulations, Dokploy is installed!${NC}\n"
printf "${BLUE}Wait 15 seconds for the server to start${NC}\n"
printf "${YELLOW}Please go to http://${formatted_addr}:3000${NC}\n\n"
echo ""
} }
update_dokploy() { if command_exists docker; then
echo "Updating Dokploy..." echo "Docker already installed"
# Pull the latest canary image
docker pull dokploy/dokploy:canary
# Update the service
docker service update --image dokploy/dokploy:canary dokploy
echo "Dokploy has been updated to the latest canary version."
}
# Main script execution
if [ "$1" = "update" ]; then
update_dokploy
else else
install_dokploy curl -sSL https://get.docker.com | sh
fi fi
docker swarm leave --force 2>/dev/null
get_ip() {
# Try to get IPv4
local ipv4=$(curl -4s https://ifconfig.io 2>/dev/null)
if [ -n "$ipv4" ]; then
echo "$ipv4"
else
# Try to get IPv6
local ipv6=$(curl -6s https://ifconfig.io 2>/dev/null)
if [ -n "$ipv6" ]; then
echo "$ipv6"
fi
fi
}
advertise_addr=$(get_ip)
docker swarm init --advertise-addr $advertise_addr
echo "Swarm initialized"
docker network rm -f dokploy-network 2>/dev/null
docker network create --driver overlay --attachable dokploy-network
echo "Network created"
mkdir -p /etc/dokploy
chmod 777 /etc/dokploy
docker pull dokploy/dokploy:canary
# Installation
docker service create \
--name dokploy \
--replicas 1 \
--network dokploy-network \
--mount type=bind,source=/var/run/docker.sock,target=/var/run/docker.sock \
--mount type=bind,source=/etc/dokploy,target=/etc/dokploy \
--mount type=volume,source=dokploy-docker-config,target=/root/.docker \
--publish published=3000,target=3000,mode=host \
--update-parallelism 1 \
--update-order stop-first \
--constraint 'node.role == manager' \
-e RELEASE_TAG=canary \
dokploy/dokploy:canary
GREEN="\033[0;32m"
YELLOW="\033[1;33m"
BLUE="\033[0;34m"
NC="\033[0m" # No Color
format_ip_for_url() {
local ip="$1"
if echo "$ip" | grep -q ':'; then
# IPv6
echo "[${ip}]"
else
# IPv4
echo "${ip}"
fi
}
formatted_addr=$(format_ip_for_url "$advertise_addr")
echo ""
printf "${GREEN}Congratulations, Dokploy is installed!${NC}\n"
printf "${BLUE}Wait 15 seconds for the server to start${NC}\n"
printf "${YELLOW}Please go to http://${formatted_addr}:3000${NC}\n\n"
echo ""

View File

@@ -1,117 +1,97 @@
#!/bin/bash #!/bin/bash
install_dokploy() {
if [ "$(id -u)" != "0" ]; then
echo "This script must be run as root" >&2
exit 1
fi
# check if is Mac OS if [ "$(id -u)" != "0" ]; then
if [ "$(uname)" = "Darwin" ]; then echo "This script must be run as root" >&2
echo "This script must be run on Linux" >&2 exit 1
exit 1 fi
fi
# check if is Mac OS
if [ "$(uname)" = "Darwin" ]; then
echo "This script must be run on Linux" >&2
exit 1
fi
# check if is running inside a container # check if is running inside a container
if [ -f /.dockerenv ]; then if [ -f /.dockerenv ]; then
echo "This script must be run on Linux" >&2 echo "This script must be run on Linux" >&2
exit 1 exit 1
fi fi
# check if something is running on port 80 # check if something is running on port 80
if ss -tulnp | grep ':80 ' >/dev/null; then if ss -tulnp | grep ':80 ' >/dev/null; then
echo "Error: something is already running on port 80" >&2 echo "Error: something is already running on port 80" >&2
exit 1 exit 1
fi fi
# check if something is running on port 443 # check if something is running on port 443
if ss -tulnp | grep ':443 ' >/dev/null; then if ss -tulnp | grep ':443 ' >/dev/null; then
echo "Error: something is already running on port 443" >&2 echo "Error: something is already running on port 443" >&2
exit 1 exit 1
fi fi
command_exists() { command_exists() {
command -v "$@" > /dev/null 2>&1 command -v "$@" > /dev/null 2>&1
}
if command_exists docker; then
echo "Docker already installed"
else
curl -sSL https://get.docker.com | sh
fi
docker swarm leave --force 2>/dev/null
advertise_addr=$(curl -s ifconfig.me)
docker swarm init --advertise-addr $advertise_addr
echo "Swarm initialized"
docker network rm -f dokploy-network 2>/dev/null
docker network create --driver overlay --attachable dokploy-network
echo "Network created"
mkdir -p /etc/dokploy
chmod 777 /etc/dokploy
docker pull dokploy/dokploy:feature
# Installation
docker service create \
--name dokploy \
--replicas 1 \
--network dokploy-network \
--mount type=bind,source=/var/run/docker.sock,target=/var/run/docker.sock \
--mount type=bind,source=/etc/dokploy,target=/etc/dokploy \
--mount type=volume,source=dokploy-docker-config,target=/root/.docker \
--publish published=3000,target=3000,mode=host \
--update-parallelism 1 \
--update-order stop-first \
--constraint 'node.role == manager' \
-e RELEASE_TAG=feature \
dokploy/dokploy:feature
GREEN="\033[0;32m"
YELLOW="\033[1;33m"
BLUE="\033[0;34m"
NC="\033[0m" # No Color
format_ip_for_url() {
local ip="$1"
if echo "$ip" | grep -q ':'; then
# IPv6
echo "[${ip}]"
else
# IPv4
echo "${ip}"
fi
}
formatted_addr=$(format_ip_for_url "$advertise_addr")
echo ""
printf "${GREEN}Congratulations, Dokploy is installed!${NC}\n"
printf "${BLUE}Wait 15 seconds for the server to start${NC}\n"
printf "${YELLOW}Please go to http://${formatted_addr}:3000${NC}\n\n"
echo ""
} }
update_dokploy() { if command_exists docker; then
echo "Updating Dokploy..." echo "Docker already installed"
# Pull the latest feature image
docker pull dokploy/dokploy:feature
# Update the service
docker service update --image dokploy/dokploy:feature dokploy
echo "Dokploy has been updated to the latest feature version."
}
# Main script execution
if [ "$1" = "update" ]; then
update_dokploy
else else
install_dokploy curl -sSL https://get.docker.com | sh
fi fi
docker swarm leave --force 2>/dev/null
advertise_addr=$(curl -s ifconfig.me)
docker swarm init --advertise-addr $advertise_addr
echo "Swarm initialized"
docker network rm -f dokploy-network 2>/dev/null
docker network create --driver overlay --attachable dokploy-network
echo "Network created"
mkdir -p /etc/dokploy
chmod 777 /etc/dokploy
docker pull dokploy/dokploy:feature
# Installation
docker service create \
--name dokploy \
--replicas 1 \
--network dokploy-network \
--mount type=bind,source=/var/run/docker.sock,target=/var/run/docker.sock \
--mount type=bind,source=/etc/dokploy,target=/etc/dokploy \
--mount type=volume,source=dokploy-docker-config,target=/root/.docker \
--publish published=3000,target=3000,mode=host \
--update-parallelism 1 \
--update-order stop-first \
--constraint 'node.role == manager' \
-e RELEASE_TAG=feature \
dokploy/dokploy:feature
GREEN="\033[0;32m"
YELLOW="\033[1;33m"
BLUE="\033[0;34m"
NC="\033[0m" # No Color
format_ip_for_url() {
local ip="$1"
if echo "$ip" | grep -q ':'; then
# IPv6
echo "[${ip}]"
else
# IPv4
echo "${ip}"
fi
}
formatted_addr=$(format_ip_for_url "$advertise_addr")
echo ""
printf "${GREEN}Congratulations, Dokploy is installed!${NC}\n"
printf "${BLUE}Wait 15 seconds for the server to start${NC}\n"
printf "${YELLOW}Please go to http://${formatted_addr}:3000${NC}\n\n"
echo ""

View File

@@ -1,130 +1,112 @@
#!/bin/bash #!/bin/bash
install_dokploy() {
if [ "$(id -u)" != "0" ]; then
echo "This script must be run as root" >&2
exit 1
fi
# check if is Mac OS if [ "$(id -u)" != "0" ]; then
if [ "$(uname)" = "Darwin" ]; then echo "This script must be run as root" >&2
echo "This script must be run on Linux" >&2 exit 1
exit 1
fi
# check if is running inside a container
if [ -f /.dockerenv ]; then
echo "This script must be run on Linux" >&2
exit 1
fi
# check if something is running on port 80
if ss -tulnp | grep ':80 ' >/dev/null; then
echo "Error: something is already running on port 80" >&2
exit 1
fi
# check if something is running on port 443
if ss -tulnp | grep ':443 ' >/dev/null; then
echo "Error: something is already running on port 443" >&2
exit 1
fi
command_exists() {
command -v "$@" > /dev/null 2>&1
}
if command_exists docker; then
echo "Docker already installed"
else
curl -sSL https://get.docker.com | sh
fi
docker swarm leave --force 2>/dev/null
get_ip() {
# Try to get IPv4
local ipv4=$(curl -4s https://ifconfig.io 2>/dev/null)
if [ -n "$ipv4" ]; then
echo "$ipv4"
else
# Try to get IPv6
local ipv6=$(curl -6s https://ifconfig.io 2>/dev/null)
if [ -n "$ipv6" ]; then
echo "$ipv6"
fi
fi
}
advertise_addr=$(get_ip)
docker swarm init --advertise-addr $advertise_addr
echo "Swarm initialized"
docker network rm -f dokploy-network 2>/dev/null
docker network create --driver overlay --attachable dokploy-network
echo "Network created"
mkdir -p /etc/dokploy
chmod 777 /etc/dokploy
docker pull dokploy/dokploy:latest
# Installation
docker service create \
--name dokploy \
--replicas 1 \
--network dokploy-network \
--mount type=bind,source=/var/run/docker.sock,target=/var/run/docker.sock \
--mount type=bind,source=/etc/dokploy,target=/etc/dokploy \
--mount type=volume,source=dokploy-docker-config,target=/root/.docker \
--publish published=3000,target=3000,mode=host \
--update-parallelism 1 \
--update-order stop-first \
--constraint 'node.role == manager' \
dokploy/dokploy:latest
GREEN="\033[0;32m"
YELLOW="\033[1;33m"
BLUE="\033[0;34m"
NC="\033[0m" # No Color
format_ip_for_url() {
local ip="$1"
if echo "$ip" | grep -q ':'; then
# IPv6
echo "[${ip}]"
else
# IPv4
echo "${ip}"
fi
}
formatted_addr=$(format_ip_for_url "$advertise_addr")
echo ""
printf "${GREEN}Congratulations, Dokploy is installed!${NC}\n"
printf "${BLUE}Wait 15 seconds for the server to start${NC}\n"
printf "${YELLOW}Please go to http://${formatted_addr}:3000${NC}\n\n"
}
update_dokploy() {
echo "Updating Dokploy..."
# Pull the latest image
docker pull dokploy/dokploy:latest
# Update the service
docker service update --image dokploy/dokploy:latest dokploy
echo "Dokploy has been updated to the latest version."
}
# Main script execution
if [ "$1" = "update" ]; then
update_dokploy
else
install_dokploy
fi fi
# check if is Mac OS
if [ "$(uname)" = "Darwin" ]; then
echo "This script must be run on Linux" >&2
exit 1
fi
# check if is running inside a container
if [ -f /.dockerenv ]; then
echo "This script must be run on Linux" >&2
exit 1
fi
# check if something is running on port 80
if ss -tulnp | grep ':80 ' >/dev/null; then
echo "Error: something is already running on port 80" >&2
exit 1
fi
# check if something is running on port 443
if ss -tulnp | grep ':443 ' >/dev/null; then
echo "Error: something is already running on port 443" >&2
exit 1
fi
command_exists() {
command -v "$@" > /dev/null 2>&1
}
if command_exists docker; then
echo "Docker already installed"
else
curl -sSL https://get.docker.com | sh
fi
docker swarm leave --force 2>/dev/null
get_ip() {
# Try to get IPv4
local ipv4=$(curl -4s https://ifconfig.io 2>/dev/null)
if [ -n "$ipv4" ]; then
echo "$ipv4"
else
# Try to get IPv6
local ipv6=$(curl -6s https://ifconfig.io 2>/dev/null)
if [ -n "$ipv6" ]; then
echo "$ipv6"
fi
fi
}
advertise_addr=$(get_ip)
docker swarm init --advertise-addr $advertise_addr
echo "Swarm initialized"
docker network rm -f dokploy-network 2>/dev/null
docker network create --driver overlay --attachable dokploy-network
echo "Network created"
mkdir -p /etc/dokploy
chmod 777 /etc/dokploy
docker pull dokploy/dokploy:latest
# Installation
docker service create \
--name dokploy \
--replicas 1 \
--network dokploy-network \
--mount type=bind,source=/var/run/docker.sock,target=/var/run/docker.sock \
--mount type=bind,source=/etc/dokploy,target=/etc/dokploy \
--mount type=volume,source=dokploy-docker-config,target=/root/.docker \
--publish published=3000,target=3000,mode=host \
--update-parallelism 1 \
--update-order stop-first \
--constraint 'node.role == manager' \
dokploy/dokploy:latest
GREEN="\033[0;32m"
YELLOW="\033[1;33m"
BLUE="\033[0;34m"
NC="\033[0m" # No Color
format_ip_for_url() {
local ip="$1"
if echo "$ip" | grep -q ':'; then
# IPv6
echo "[${ip}]"
else
# IPv4
echo "${ip}"
fi
}
formatted_addr=$(format_ip_for_url "$advertise_addr")
echo ""
printf "${GREEN}Congratulations, Dokploy is installed!${NC}\n"
printf "${BLUE}Wait 15 seconds for the server to start${NC}\n"
printf "${YELLOW}Please go to http://${formatted_addr}:3000${NC}\n\n"
echo ""