Merge branch 'canary' into feat/cloud

This commit is contained in:
Mauricio Siu
2024-10-03 13:39:06 -06:00
41 changed files with 901 additions and 506 deletions

View File

@@ -15,6 +15,8 @@ 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.
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
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,6 +26,8 @@ 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.
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
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

@@ -31,8 +31,7 @@ The following templates are available:
- **Wordpress**: Open Source Content Management System
- **Open WebUI**: Free and Open Source ChatGPT Alternative
- **Teable**: Open Source Airtable Alternative, Developer Friendly, No-code Database Built on Postgres
- **Roundcube**: Free and open source webmail software for the masses, written in PHP, uses SMTP[^1].
## Create your own template
@@ -41,3 +40,5 @@ We accept contributions to upload new templates to the dokploy repository.
Make sure to follow the guidelines for creating a template:
[Steps to create your own template](https://github.com/Dokploy/dokploy/blob/canary/CONTRIBUTING.md#templates)
[^1]: Please note that if you're self-hosting a mail server you need port 25 to be open for SMTP (Mail Transmission Protocol that allows you to send and receive) to work properly. Some VPS providers like [Hetzner](https://docs.hetzner.com/cloud/servers/faq/#why-can-i-not-send-any-mails-from-my-server) block this port by default for new clients.

View File

@@ -10,8 +10,10 @@ export function useMDXComponents(components: MDXComponents): MDXComponents {
p: ({ children }) => (
<p className="text-[#3E4342] dark:text-muted-foreground">{children}</p>
),
li: ({ children }) => (
<li className="text-[#3E4342] dark:text-muted-foreground">{children}</li>
li: ({ children, id }) => (
<li {...{ id }} className="text-[#3E4342] dark:text-muted-foreground">
{children}
</li>
),
};
}

View File

@@ -17,7 +17,7 @@ import {
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { Input, NumberInput } from "@/components/ui/input";
import {
Select,
SelectContent,
@@ -125,28 +125,14 @@ export const UpdatePort = ({ portId }: Props) => {
<FormItem>
<FormLabel>Published Port</FormLabel>
<FormControl>
<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);
}
}
}}
/>
<NumberInput placeholder="1-65535" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="targetPort"
@@ -154,22 +140,7 @@ export const UpdatePort = ({ portId }: Props) => {
<FormItem>
<FormLabel>Target Port</FormLabel>
<FormControl>
<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);
}
}
}}
/>
<Input placeholder="1-65535" {...field} />
</FormControl>
<FormMessage />

View File

@@ -19,6 +19,15 @@ import {
FormMessage,
} from "@/components/ui/form";
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 { api } from "@/utils/api";
import { zodResolver } from "@hookform/resolvers/zod";
@@ -36,6 +45,36 @@ const AddRedirectchema = z.object({
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 {
applicationId: string;
children?: React.ReactNode;
@@ -43,9 +82,10 @@ interface Props {
export const AddRedirect = ({
applicationId,
children = <PlusIcon className="h-4 w-4" />,
children = <PlusIcon className="w-4 h-4" />,
}: Props) => {
const [isOpen, setIsOpen] = useState(false);
const [presetSelected, setPresetSelected] = useState("");
const utils = api.useUtils();
const { mutateAsync, isLoading, error, isError } =
@@ -81,19 +121,36 @@ export const AddRedirect = ({
await utils.application.readTraefikConfig.invalidate({
applicationId,
});
setIsOpen(false);
onDialogToggle(false);
})
.catch(() => {
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 (
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<Dialog open={isOpen} onOpenChange={onDialogToggle}>
<DialogTrigger asChild>
<Button>{children}</Button>
</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>
<DialogTitle>Redirects</DialogTitle>
<DialogDescription>
@@ -102,6 +159,24 @@ export const AddRedirect = ({
</DialogHeader>
{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
id="hook-form-add-redirect"
@@ -142,7 +217,7 @@ export const AddRedirect = ({
control={form.control}
name="permanent"
render={({ field }) => (
<FormItem className="mt-4 flex flex-row items-center justify-between rounded-lg border p-3 shadow-sm">
<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>Permanent</FormLabel>
<FormDescription>

View File

@@ -18,7 +18,7 @@ import {
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { Input, NumberInput } from "@/components/ui/input";
import {
Select,
SelectContent,
@@ -140,7 +140,7 @@ export const AddDomain = ({
<DialogTrigger className="" asChild>
{children}
</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>
<DialogTitle>Domain</DialogTitle>
<DialogDescription>{dictionary.dialogDescription}</DialogDescription>
@@ -228,19 +228,36 @@ export const AddDomain = ({
<FormItem>
<FormLabel>Container Port</FormLabel>
<FormControl>
<Input
placeholder={"3000"}
{...field}
onChange={(e) => {
field.onChange(Number.parseInt(e.target.value));
}}
/>
<NumberInput placeholder={"3000"} {...field} />
</FormControl>
<FormMessage />
</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 && (
<FormField
control={form.control}
@@ -270,28 +287,6 @@ 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>
</form>

View File

@@ -18,7 +18,7 @@ import {
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { Input, NumberInput } from "@/components/ui/input";
import {
Select,
SelectContent,
@@ -161,7 +161,7 @@ export const AddDomainCompose = ({
<DialogTrigger className="" asChild>
{children}
</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>
<DialogTitle>Domain</DialogTitle>
<DialogDescription>{dictionary.dialogDescription}</DialogDescription>
@@ -190,7 +190,7 @@ export const AddDomainCompose = ({
{errorServices?.message}
</AlertBlock>
)}
<div className="flex flex-row gap-4 w-full items-end">
<div className="flex flex-row items-end w-full gap-4">
<FormField
control={form.control}
name="serviceName"
@@ -364,19 +364,36 @@ export const AddDomainCompose = ({
<FormItem>
<FormLabel>Container Port</FormLabel>
<FormControl>
<Input
placeholder={"3000"}
{...field}
onChange={(e) => {
field.onChange(Number.parseInt(e.target.value));
}}
/>
<NumberInput placeholder={"3000"} {...field} />
</FormControl>
<FormMessage />
</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 && (
<FormField
control={form.control}
@@ -406,28 +423,6 @@ 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>
</form>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -212,7 +212,21 @@ export const AddServer = () => {
<FormItem>
<FormLabel>Port</FormLabel>
<FormControl>
<Input placeholder="22" {...field} />
<Input
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>
<FormMessage />

View File

@@ -228,7 +228,21 @@ export const UpdateServer = ({ serverId }: Props) => {
<FormItem>
<FormLabel>Port</FormLabel>
<FormControl>
<Input placeholder="22" {...field} />
<Input
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>
<FormMessage />

View File

@@ -31,4 +31,39 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(
);
Input.displayName = "Input";
export { Input };
const NumberInput = React.forwardRef<HTMLInputElement, InputProps>(
({ 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 "registry" ALTER COLUMN "registryUrl" SET DEFAULT '';

View File

@@ -1,5 +1,5 @@
{
"id": "e4ae5e44-8d12-400c-9d7d-3dbbf1e348e5",
"id": "8ffdfaff-f166-42dc-ac77-4fd9309d736a",
"prevId": "19a70a39-f719-400b-b61e-6ddf1bcc6ac5",
"version": "6",
"dialect": "postgresql",
@@ -2923,7 +2923,8 @@
"name": "registryUrl",
"type": "text",
"primaryKey": false,
"notNull": true
"notNull": true,
"default": "''"
},
"createdAt": {
"name": "createdAt",
@@ -3252,13 +3253,6 @@
"primaryKey": true,
"notNull": true
},
"privateKey": {
"name": "privateKey",
"type": "text",
"primaryKey": false,
"notNull": true,
"default": "''"
},
"publicKey": {
"name": "publicKey",
"type": "text",

View File

@@ -271,15 +271,8 @@
{
"idx": 38,
"version": "6",
"when": 1727903587684,
"tag": "0038_mushy_blindfold",
"breakpoints": true
},
{
"idx": 39,
"version": "6",
"when": 1727937385754,
"tag": "0039_workable_speed_demon",
"when": 1727942090102,
"tag": "0038_rapid_landau",
"breakpoints": true
}
]

View File

@@ -1,6 +1,6 @@
{
"name": "dokploy",
"version": "v0.9.0",
"version": "v0.9.4",
"private": true,
"license": "Apache-2.0",
"type": "module",
@@ -11,7 +11,7 @@
"build-next": "next build",
"setup": "tsx -r dotenv/config setup.ts && sleep 5 && pnpm run migration:run",
"reset-password": "node dist/reset-password.mjs",
"dev": "tsx watch -r dotenv/config ./server/server.ts --project tsconfig.server.json ",
"dev": "tsx -r dotenv/config ./server/server.ts --project tsconfig.server.json ",
"studio": "drizzle-kit studio --config ./server/db/drizzle.config.ts",
"migration:generate": "drizzle-kit generate --config ./server/db/drizzle.config.ts",
"migration:run": "tsx -r dotenv/config migration.ts",

View File

@@ -0,0 +1,15 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="9.14 141.8 573.65 573.65">
<style type="text/css">
.st0{fill-rule:evenodd;clip-rule:evenodd;fill:#404F54;}
.st1{fill-rule:evenodd;clip-rule:evenodd;fill:#E5E5E5;}
.st2{fill-rule:evenodd;clip-rule:evenodd;fill:#CCCCCC;}
.st3{fill-rule:evenodd;clip-rule:evenodd;fill:#37BEFF;}
</style>
<polygon class="st3" points="582.79,549.77 295.96,384.1 295.96,207.27 582.79,372.95 "/>
<polygon class="st0" points="9.14,549.77 295.96,384.1 295.96,207.27 9.14,372.95 "/>
<path class="st2" d="M295.96,141.8c109.56,0,198.41,88.85,198.41,198.41c0,109.56-88.85,198.41-198.41,198.41 c-109.56,0-198.41-88.85-198.41-198.41C97.55,230.65,186.4,141.8,295.96,141.8"/>
<path class="st1" d="M295.96,141.8c109.6,0,198.48,88.85,198.48,198.41c0,109.56-88.88,198.41-198.48,198.41 c-62.91-42.34-88.94-127.64-88.94-198.3S233.05,184.22,295.96,141.8"/>
<polygon class="st3" points="582.79,372.95 295.96,538.62 295.96,715.45 582.79,549.77 "/>
<polygon class="st0" points="9.14,372.95 295.96,538.62 295.96,715.45 9.14,549.77 "/>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

@@ -68,7 +68,7 @@ export const destinationRouter = createTRPCRouter({
const destination = await findDestinationById(input.destinationId);
return destination;
}),
all: adminProcedure.query(async () => {
all: protectedProcedure.query(async () => {
return await db.query.destinations.findMany({});
}),
remove: adminProcedure

View File

@@ -8,6 +8,7 @@ import {
} from "@/server/db/schema";
import { TRPCError } from "@trpc/server";
import {
execAsyncRemote,
initializeRegistry,
execAsync,
manageRegistry,
@@ -58,7 +59,13 @@ export const registryRouter = createTRPCRouter({
.mutation(async ({ input }) => {
try {
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;
} catch (error) {
console.log("Error Registry:", error);
@@ -78,6 +85,7 @@ export const registryRouter = createTRPCRouter({
? input.registryUrl
: "dokploy-registry.docker.localhost",
imagePrefix: null,
serverId: undefined,
});
await manageRegistry(selfHostedRegistry);
@@ -86,3 +94,17 @@ export const registryRouter = createTRPCRouter({
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

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

View File

@@ -0,0 +1,17 @@
services:
roundcubemail:
image: roundcube/roundcubemail:1.6.9-apache
volumes:
- ./www:/var/www/html
- ./db/sqlite:/var/roundcube/db
environment:
- ROUNDCUBEMAIL_DB_TYPE=sqlite
- ROUNDCUBEMAIL_SKIN=elastic
- ROUNDCUBEMAIL_DEFAULT_HOST=${DEFAULT_HOST}
- ROUNDCUBEMAIL_SMTP_SERVER=${SMTP_SERVER}
networks:
- dokploy-network
networks:
dokploy-network:
external: true

View File

@@ -0,0 +1,24 @@
import {
type DomainSchema,
type Schema,
type Template,
generateRandomDomain,
} from "../utils";
export function generate(schema: Schema): Template {
const randomDomain = generateRandomDomain(schema);
const envs = [
"DEFAULT_HOST=tls://mail.example.com",
"SMTP_SERVER=tls://mail.example.com",
];
const domains: DomainSchema[] = [
{
host: randomDomain,
port: 80,
serviceName: "roundcubemail",
},
];
return { envs, domains };
}

View File

@@ -497,4 +497,19 @@ export const templates: TemplateData[] = [
tags: ["self-hosted", "storage"],
load: () => import("./gitea/index").then((m) => m.generate),
},
{
id: "roundcube",
name: "Roundcube",
version: "1.6.9",
description:
"Free and open source webmail software for the masses, written in PHP.",
logo: "roundcube.svg",
links: {
github: "https://github.com/roundcube/roundcubemail",
website: "https://roundcube.net/",
docs: "https://roundcube.net/about/",
},
tags: ["self-hosted", "email", "webmail"],
load: () => import("./roundcube/index").then((m) => m.generate),
},
];

View File

@@ -1,114 +1,133 @@
#!/bin/bash
install_dokploy() {
if [ "$(id -u)" != "0" ]; then
echo "This script must be run as root" >&2
exit 1
fi
if [ "$(id -u)" != "0" ]; then
echo "This script must be run as root" >&2
exit 1
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 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 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 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
# 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
}
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"
if command_exists docker; then
echo "Docker already installed"
else
# Try to get IPv6
local ipv6=$(curl -6s https://ifconfig.io 2>/dev/null)
if [ -n "$ipv6" ]; then
echo "$ipv6"
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
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 ""
}
advertise_addr=$(get_ip)
update_dokploy() {
echo "Updating Dokploy..."
# Pull the latest canary image
docker pull dokploy/dokploy:canary
docker swarm init --advertise-addr $advertise_addr
# Update the service
docker service update --image dokploy/dokploy:canary dokploy
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
echo "Dokploy has been updated to the latest canary version."
}
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 ""
# Main script execution
if [ "$1" = "update" ]; then
update_dokploy
else
install_dokploy
fi

View File

@@ -1,97 +1,117 @@
#!/bin/bash
if [ "$(id -u)" != "0" ]; then
echo "This script must be run as root" >&2
exit 1
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
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}"
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 [ "$(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
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 ""
}
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() {
echo "Updating Dokploy..."
# 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
install_dokploy
fi

View File

@@ -1,112 +1,130 @@
#!/bin/bash
install_dokploy() {
if [ "$(id -u)" != "0" ]; then
echo "This script must be run as root" >&2
exit 1
fi
if [ "$(id -u)" != "0" ]; then
echo "This script must be run as root" >&2
exit 1
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 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 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 443
if ss -tulnp | grep ':443 ' >/dev/null; then
echo "Error: something is already running on port 443" >&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
command_exists() {
command -v "$@" > /dev/null 2>&1
}
# 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"
if command_exists docker; then
echo "Docker already installed"
else
# Try to get IPv6
local ipv6=$(curl -6s https://ifconfig.io 2>/dev/null)
if [ -n "$ipv6" ]; then
echo "$ipv6"
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
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"
}
advertise_addr=$(get_ip)
update_dokploy() {
echo "Updating Dokploy..."
# Pull the latest image
docker pull dokploy/dokploy:latest
docker swarm init --advertise-addr $advertise_addr
# Update the service
docker service update --image dokploy/dokploy:latest dokploy
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
echo "Dokploy has been updated to the latest version."
}
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 ""
# Main script execution
if [ "$1" = "update" ]; then
update_dokploy
else
install_dokploy
fi

View File

@@ -2,6 +2,8 @@ import type { IncomingMessage } from "node:http";
import { TimeSpan } from "lucia";
import { Lucia } from "lucia/dist/core.js";
import { type ReturnValidateToken, adapter } from "./auth";
import { findAdminByAuthId } from "../services/admin";
import { findUserByAuthId } from "../services/user";
export const luciaToken = new Lucia(adapter, {
sessionCookie: {
@@ -31,6 +33,16 @@ export const validateBearerToken = async (
};
}
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 {
session: result.session,
...((result.user && {

View File

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

View File

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

View File

@@ -1,4 +1,4 @@
import type { WriteStream } from "node:fs";
import { type WriteStream, existsSync, mkdirSync } from "node:fs";
import path from "node:path";
import { buildStatic, getStaticCommand } from "@/server/utils/builders/static";
import { nanoid } from "nanoid";
@@ -42,7 +42,6 @@ export const buildNixpacks = async (
and copy the artifacts on the host filesystem.
Then, remove the container and create a static build.
*/
if (publishDirectory) {
await spawnAsync(
"docker",
@@ -50,12 +49,22 @@ export const buildNixpacks = async (
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(
"docker",
[
"cp",
`${buildContainerId}:/app/${publishDirectory}`,
path.join(buildAppDirectory, publishDirectory),
`${buildContainerId}:/app/${publishDirectory}${isDirectory ? "/." : ""}`,
localPath,
],
writeToStream,
);
@@ -108,9 +117,14 @@ echo "✅ Nixpacks build completed." >> ${logPath};
Then, remove the container and create a static build.
*/
if (publishDirectory) {
const localPath = path.join(buildAppDirectory, publishDirectory);
const isDirectory =
publishDirectory.endsWith("/") || !path.extname(publishDirectory);
bashCommand += `
docker create --name ${buildContainerId} ${appName}
docker cp ${buildContainerId}:/app/${publishDirectory} ${path.join(buildAppDirectory, publishDirectory)} >> ${logPath} 2>> ${logPath} || {
mkdir -p ${localPath}
docker cp ${buildContainerId}:/app/${publishDirectory}${isDirectory ? "/." : ""} ${path.join(buildAppDirectory, publishDirectory)} >> ${logPath} 2>> ${logPath} || {
docker rm ${buildContainerId}
echo "❌ Copying ${publishDirectory} to ${path.join(buildAppDirectory, publishDirectory)} failed" >> ${logPath};
exit 1;

View File

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

View File

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

View File

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

View File

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