mirror of
https://github.com/Dokploy/dokploy
synced 2025-06-26 18:27:59 +00:00
feat: init multi server feature
This commit is contained in:
parent
0b18f86a91
commit
bd0bbdea26
@ -0,0 +1,253 @@
|
||||
import { AlertBlock } from "@/components/shared/alert-block";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { api } from "@/utils/api";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { PlusIcon } from "lucide-react";
|
||||
import { useRouter } from "next/router";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
import { z } from "zod";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectLabel,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
|
||||
const Schema = z.object({
|
||||
name: z.string().min(1, {
|
||||
message: "Name is required",
|
||||
}),
|
||||
description: z.string().optional(),
|
||||
ipAddress: z.string().min(1, {
|
||||
message: "IP Address is required",
|
||||
}),
|
||||
port: z.number().optional(),
|
||||
username: z.string().optional(),
|
||||
sshKeyId: z.string().min(1, {
|
||||
message: "SSH Key is required",
|
||||
}),
|
||||
});
|
||||
|
||||
type Schema = z.infer<typeof Schema>;
|
||||
|
||||
export const AddServer = () => {
|
||||
const utils = api.useUtils();
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const { data: sshKeys } = api.sshKey.all.useQuery();
|
||||
const { mutateAsync, error, isError } = api.server.create.useMutation();
|
||||
const form = useForm<Schema>({
|
||||
defaultValues: {
|
||||
description: "",
|
||||
name: "",
|
||||
ipAddress: "",
|
||||
port: 22,
|
||||
username: "root",
|
||||
sshKeyId: "",
|
||||
},
|
||||
resolver: zodResolver(Schema),
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
form.reset({
|
||||
description: "",
|
||||
name: "",
|
||||
ipAddress: "",
|
||||
port: 22,
|
||||
username: "root",
|
||||
sshKeyId: "",
|
||||
});
|
||||
}, [form, form.reset, form.formState.isSubmitSuccessful]);
|
||||
|
||||
const onSubmit = async (data: Schema) => {
|
||||
await mutateAsync({
|
||||
name: data.name,
|
||||
description: data.description || "",
|
||||
ipAddress: data.ipAddress || "",
|
||||
port: data.port || 22,
|
||||
username: data.username || "root",
|
||||
sshKeyId: data.sshKeyId || "",
|
||||
})
|
||||
.then(async (data) => {
|
||||
await utils.server.all.invalidate();
|
||||
toast.success("Server Created");
|
||||
setIsOpen(false);
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Error to create a server");
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button>
|
||||
<PlusIcon className="h-4 w-4" />
|
||||
Create Server
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="sm:max-w-3xl ">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Add Server</DialogTitle>
|
||||
<DialogDescription>
|
||||
Add a server to deploy your applications remotely.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
{isError && <AlertBlock type="error">{error?.message}</AlertBlock>}
|
||||
<Form {...form}>
|
||||
<form
|
||||
id="hook-form-add-server"
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
className="grid w-full gap-4"
|
||||
>
|
||||
<div className="flex flex-col gap-4 ">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Name</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="Hostinger Server" {...field} />
|
||||
</FormControl>
|
||||
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="description"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Description</FormLabel>
|
||||
<FormControl>
|
||||
<Textarea
|
||||
placeholder="This server is for databases..."
|
||||
className="resize-none"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="sshKeyId"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Select a SSH Key</FormLabel>
|
||||
<Select
|
||||
onValueChange={field.onChange}
|
||||
defaultValue={field.value}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select a SSH Key" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
{sshKeys?.map((sshKey) => (
|
||||
<SelectItem
|
||||
key={sshKey.sshKeyId}
|
||||
value={sshKey.sshKeyId}
|
||||
>
|
||||
{sshKey.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
<SelectLabel>
|
||||
Registries ({sshKeys?.length})
|
||||
</SelectLabel>
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="ipAddress"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>IP Address</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="192.168.1.100" {...field} />
|
||||
</FormControl>
|
||||
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="port"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Port</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="22" {...field} />
|
||||
</FormControl>
|
||||
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="username"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Username</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="root" {...field} />
|
||||
</FormControl>
|
||||
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</form>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
isLoading={form.formState.isSubmitting}
|
||||
form="hook-form-add-server"
|
||||
type="submit"
|
||||
>
|
||||
Create
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
@ -0,0 +1,172 @@
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
import { DropdownMenuItem } from "@/components/ui/dropdown-menu";
|
||||
import { api } from "@/utils/api";
|
||||
import { useUrl } from "@/utils/hooks/use-url";
|
||||
import { RocketIcon, ServerIcon } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { DateTooltip } from "@/components/shared/date-tooltip";
|
||||
import { StatusTooltip } from "@/components/shared/status-tooltip";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { ShowDeployment } from "../../application/deployments/show-deployment";
|
||||
import { DialogAction } from "@/components/shared/dialog-action";
|
||||
|
||||
interface Props {
|
||||
serverId: string;
|
||||
}
|
||||
|
||||
export const SetupServer = ({ serverId }: Props) => {
|
||||
const utils = api.useUtils();
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const url = useUrl();
|
||||
const { data: server } = api.server.one.useQuery(
|
||||
{
|
||||
serverId,
|
||||
},
|
||||
{
|
||||
enabled: !!serverId,
|
||||
},
|
||||
);
|
||||
|
||||
const { mutateAsync } = api.server.setup.useMutation();
|
||||
|
||||
const [activeLog, setActiveLog] = useState<string | null>(null);
|
||||
const { data: deployments, refetch } = api.deployment.allByServer.useQuery(
|
||||
{ serverId },
|
||||
{
|
||||
enabled: !!serverId,
|
||||
refetchInterval: 1000,
|
||||
},
|
||||
);
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<DropdownMenuItem
|
||||
className="w-full cursor-pointer "
|
||||
onSelect={(e) => e.preventDefault()}
|
||||
>
|
||||
Setup Server
|
||||
</DropdownMenuItem>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="sm:max-w-4xl overflow-y-auto max-h-screen ">
|
||||
<DialogHeader>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<ServerIcon className="size-5" /> Setup Server
|
||||
</DialogTitle>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
To setup a server, please click on the button below.
|
||||
</p>
|
||||
</div>
|
||||
</DialogHeader>
|
||||
|
||||
<div id="hook-form-add-gitlab" className="grid w-full gap-1">
|
||||
<CardContent className="p-0">
|
||||
<div className="flex flex-col gap-4">
|
||||
<Card className="bg-background">
|
||||
<CardHeader className="flex flex-row items-center justify-between flex-wrap gap-2">
|
||||
<div className="flex flex-row gap-2 justify-between w-full items-end max-sm:flex-col">
|
||||
<div className="flex flex-col gap-1">
|
||||
<CardTitle className="text-xl">Deployments</CardTitle>
|
||||
<CardDescription>
|
||||
See all the 5 Server Setup
|
||||
</CardDescription>
|
||||
</div>
|
||||
<DialogAction
|
||||
title={"Setup Server?"}
|
||||
description="This will setup the server and all associated data"
|
||||
onClick={async () => {
|
||||
await mutateAsync({
|
||||
serverId: server?.serverId || "",
|
||||
})
|
||||
.then(async () => {
|
||||
refetch();
|
||||
toast.success("Server setup successfully");
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Error configuring server");
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Button>Setup Server</Button>
|
||||
</DialogAction>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-4">
|
||||
{server?.deployments?.length === 0 ? (
|
||||
<div className="flex w-full flex-col items-center justify-center gap-3 pt-10">
|
||||
<RocketIcon className="size-8 text-muted-foreground" />
|
||||
<span className="text-base text-muted-foreground">
|
||||
No deployments found
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-4">
|
||||
{deployments?.map((deployment) => (
|
||||
<div
|
||||
key={deployment.deploymentId}
|
||||
className="flex items-center justify-between rounded-lg border p-4 gap-2"
|
||||
>
|
||||
<div className="flex flex-col">
|
||||
<span className="flex items-center gap-4 font-medium capitalize text-foreground">
|
||||
{deployment.status}
|
||||
|
||||
<StatusTooltip
|
||||
status={deployment?.status}
|
||||
className="size-2.5"
|
||||
/>
|
||||
</span>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{deployment.title}
|
||||
</span>
|
||||
{deployment.description && (
|
||||
<span className="break-all text-sm text-muted-foreground">
|
||||
{deployment.description}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-col items-end gap-2">
|
||||
<div className="text-sm capitalize text-muted-foreground">
|
||||
<DateTooltip date={deployment.createdAt} />
|
||||
</div>
|
||||
|
||||
<Button
|
||||
onClick={() => {
|
||||
setActiveLog(deployment.logPath);
|
||||
}}
|
||||
>
|
||||
View
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<ShowDeployment
|
||||
open={activeLog !== null}
|
||||
onClose={() => setActiveLog(null)}
|
||||
logPath={activeLog}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</CardContent>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
@ -0,0 +1,163 @@
|
||||
import { api } from "@/utils/api";
|
||||
import { format } from "date-fns";
|
||||
import { AddServer } from "./add-server";
|
||||
import { KeyIcon, MoreHorizontal, ServerIcon } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { DialogAction } from "@/components/shared/dialog-action";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { toast } from "sonner";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCaption,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { SetupServer } from "./setup-server";
|
||||
import { TerminalModal } from "../web-server/terminal-modal";
|
||||
export const ShowServers = () => {
|
||||
const { data, refetch } = api.server.all.useQuery();
|
||||
const { mutateAsync } = api.server.remove.useMutation();
|
||||
const { data: sshKeys } = api.sshKey.all.useQuery();
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-6">
|
||||
<div className="space-y-2 flex flex-row justify-between items-end">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">Servers</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Add servers to deploy your applications remotely.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{sshKeys && sshKeys?.length > 0 && (
|
||||
<div>
|
||||
<AddServer />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-1 md:grid-cols-1">
|
||||
{sshKeys?.length === 0 ? (
|
||||
<div className="flex flex-col items-center gap-3 min-h-[25vh] justify-center">
|
||||
<KeyIcon className="size-8" />
|
||||
<span className="text-base text-muted-foreground">
|
||||
No SSH Keys found. Add a SSH Key to start adding servers.{" "}
|
||||
<Link
|
||||
href="/dashboard/settings/ssh-keys"
|
||||
className="text-primary"
|
||||
>
|
||||
Add SSH Key
|
||||
</Link>
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
data &&
|
||||
data.length === 0 && (
|
||||
<div className="flex flex-col items-center gap-3 min-h-[25vh] justify-center">
|
||||
<ServerIcon className="size-8" />
|
||||
<span className="text-base text-muted-foreground">
|
||||
No Servers found. Add a server to deploy your applications
|
||||
remotely.
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
{data && data?.length > 0 && (
|
||||
<div className="flex flex-col gap-6">
|
||||
<Table>
|
||||
<TableCaption>See all servers</TableCaption>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-[100px]">Name</TableHead>
|
||||
<TableHead className="text-center">IP Address</TableHead>
|
||||
<TableHead className="text-center">Port</TableHead>
|
||||
<TableHead className="text-center">Username</TableHead>
|
||||
<TableHead className="text-center">Created</TableHead>
|
||||
<TableHead className="text-right">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{data?.map((server) => {
|
||||
return (
|
||||
<TableRow key={server.serverId}>
|
||||
<TableCell className="w-[100px]">{server.name}</TableCell>
|
||||
<TableCell className="text-center">
|
||||
<Badge>{server.ipAddress}</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-center">
|
||||
{server.port}
|
||||
</TableCell>
|
||||
<TableCell className="text-center">
|
||||
{server.username}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{format(new Date(server.createdAt), "PPpp")}
|
||||
</span>
|
||||
</TableCell>
|
||||
|
||||
<TableCell className="text-right flex justify-end">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" className="h-8 w-8 p-0">
|
||||
<span className="sr-only">Open menu</span>
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuLabel>Actions</DropdownMenuLabel>
|
||||
<TerminalModal serverId={server.serverId}>
|
||||
<span>Enter the terminal</span>
|
||||
</TerminalModal>
|
||||
<SetupServer serverId={server.serverId} />
|
||||
|
||||
<DialogAction
|
||||
title={"Delete Server"}
|
||||
description="This will delete the server and all associated data"
|
||||
onClick={async () => {
|
||||
await mutateAsync({
|
||||
serverId: server.serverId,
|
||||
})
|
||||
.then(() => {
|
||||
refetch();
|
||||
toast.success(
|
||||
`Server ${server.name} deleted succesfully`,
|
||||
);
|
||||
})
|
||||
.catch((err) => {
|
||||
toast.error(err.message);
|
||||
});
|
||||
}}
|
||||
>
|
||||
<DropdownMenuItem
|
||||
className="w-full cursor-pointer text-red-500 hover:!text-red-600"
|
||||
onSelect={(e) => e.preventDefault()}
|
||||
>
|
||||
Delete Server
|
||||
</DropdownMenuItem>
|
||||
</DialogAction>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
@ -1,4 +1,3 @@
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@ -8,79 +7,27 @@ import {
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
import { DropdownMenuItem } from "@/components/ui/dropdown-menu";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { api } from "@/utils/api";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import dynamic from "next/dynamic";
|
||||
import type React from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
import { z } from "zod";
|
||||
import { RemoveSSHPrivateKey } from "./remove-ssh-private-key";
|
||||
|
||||
const Terminal = dynamic(() => import("./terminal").then((e) => e.Terminal), {
|
||||
ssr: false,
|
||||
});
|
||||
|
||||
const addSSHPrivateKey = z.object({
|
||||
sshPrivateKey: z
|
||||
.string({
|
||||
required_error: "SSH private key is required",
|
||||
})
|
||||
.min(1, "SSH private key is required"),
|
||||
});
|
||||
|
||||
type AddSSHPrivateKey = z.infer<typeof addSSHPrivateKey>;
|
||||
|
||||
interface Props {
|
||||
children?: React.ReactNode;
|
||||
serverId: string;
|
||||
}
|
||||
|
||||
export const TerminalModal = ({ children }: Props) => {
|
||||
const { data, refetch } = api.admin.one.useQuery();
|
||||
const [user, setUser] = useState("root");
|
||||
const [terminalUser, setTerminalUser] = useState("root");
|
||||
|
||||
const { mutateAsync, isLoading } =
|
||||
api.settings.saveSSHPrivateKey.useMutation();
|
||||
|
||||
const form = useForm<AddSSHPrivateKey>({
|
||||
defaultValues: {
|
||||
sshPrivateKey: "",
|
||||
export const TerminalModal = ({ children, serverId }: Props) => {
|
||||
const { data } = api.server.one.useQuery(
|
||||
{
|
||||
serverId,
|
||||
},
|
||||
resolver: zodResolver(addSSHPrivateKey),
|
||||
});
|
||||
{ enabled: !!serverId },
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (data) {
|
||||
form.reset({});
|
||||
}
|
||||
}, [data, form, form.reset]);
|
||||
|
||||
const onSubmit = async (formData: AddSSHPrivateKey) => {
|
||||
await mutateAsync({
|
||||
sshPrivateKey: formData.sshPrivateKey,
|
||||
})
|
||||
.then(async () => {
|
||||
toast.success("SSH Key Updated");
|
||||
await refetch();
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Error to Update the ssh key");
|
||||
});
|
||||
};
|
||||
return (
|
||||
<Dialog>
|
||||
<DialogTrigger asChild>
|
||||
@ -92,75 +39,14 @@ export const TerminalModal = ({ children }: Props) => {
|
||||
</DropdownMenuItem>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="max-h-screen overflow-y-auto sm:max-w-7xl">
|
||||
<DialogHeader className="flex flex-row justify-between pt-4">
|
||||
<div>
|
||||
<DialogTitle>Terminal</DialogTitle>
|
||||
<DialogDescription>Easy way to access the server</DialogDescription>
|
||||
</div>
|
||||
{data?.haveSSH && (
|
||||
<div>
|
||||
<RemoveSSHPrivateKey />
|
||||
</div>
|
||||
)}
|
||||
<DialogHeader className="flex flex-col gap-1">
|
||||
<DialogTitle>Terminal ({data?.name})</DialogTitle>
|
||||
<DialogDescription>Easy way to access the server</DialogDescription>
|
||||
</DialogHeader>
|
||||
{!data?.haveSSH ? (
|
||||
<div>
|
||||
<div className="flex flex-col gap-4">
|
||||
<Form {...form}>
|
||||
<form
|
||||
id="hook-form"
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
className="grid w-full gap-8 "
|
||||
>
|
||||
<div className="grid w-full">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="sshPrivateKey"
|
||||
render={({ field }) => {
|
||||
return (
|
||||
<FormItem>
|
||||
<FormLabel>SSH Private Key</FormLabel>
|
||||
<FormDescription>
|
||||
In order to access the server you need to add an
|
||||
ssh private key
|
||||
</FormDescription>
|
||||
<FormControl>
|
||||
<Textarea
|
||||
placeholder={
|
||||
"-----BEGIN CERTIFICATE-----\nMIIFRDCCAyygAwIBAgIUEPOR47ys6VDwMVB9tYoeEka83uQwDQYJKoZIhvcNAQELBQAwGTEXMBUGA1UEAwwObWktZG9taW5pby5jb20wHhcNMjQwMzExMDQyNzU3WhcN\n------END CERTIFICATE-----"
|
||||
}
|
||||
className="h-32"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex w-full justify-end">
|
||||
<Button isLoading={isLoading} type="submit">
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label>Log in as</Label>
|
||||
<div className="flex flex-row gap-4">
|
||||
<Input value={user} onChange={(e) => setUser(e.target.value)} />
|
||||
<Button onClick={() => setTerminalUser(user)}>Login</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Terminal id="terminal" userSSH={terminalUser} />
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-col gap-4">
|
||||
<Terminal id="terminal" serverId={serverId} />
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
|
@ -7,10 +7,10 @@ import { AttachAddon } from "@xterm/addon-attach";
|
||||
|
||||
interface Props {
|
||||
id: string;
|
||||
userSSH?: string;
|
||||
serverId: string;
|
||||
}
|
||||
|
||||
export const Terminal: React.FC<Props> = ({ id, userSSH = "root" }) => {
|
||||
export const Terminal: React.FC<Props> = ({ id, serverId }) => {
|
||||
const termRef = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
@ -33,7 +33,7 @@ export const Terminal: React.FC<Props> = ({ id, userSSH = "root" }) => {
|
||||
|
||||
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
|
||||
|
||||
const wsUrl = `${protocol}//${window.location.host}/terminal?userSSH=${userSSH}`;
|
||||
const wsUrl = `${protocol}//${window.location.host}/terminal?serverId=${serverId}`;
|
||||
|
||||
const ws = new WebSocket(wsUrl);
|
||||
const addonAttach = new AttachAddon(ws);
|
||||
@ -46,7 +46,7 @@ export const Terminal: React.FC<Props> = ({ id, userSSH = "root" }) => {
|
||||
return () => {
|
||||
ws.readyState === WebSocket.OPEN && ws.close();
|
||||
};
|
||||
}, [id, userSSH]);
|
||||
}, [id, serverId]);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
|
@ -74,7 +74,7 @@ export const SettingsLayout = ({ children }: Props) => {
|
||||
{
|
||||
title: "Cluster",
|
||||
label: "",
|
||||
icon: Server,
|
||||
icon: BoxesIcon,
|
||||
href: "/dashboard/settings/cluster",
|
||||
},
|
||||
{
|
||||
@ -83,6 +83,12 @@ export const SettingsLayout = ({ children }: Props) => {
|
||||
icon: Bell,
|
||||
href: "/dashboard/settings/notifications",
|
||||
},
|
||||
{
|
||||
title: "Servers",
|
||||
label: "",
|
||||
icon: Server,
|
||||
href: "/dashboard/settings/servers",
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(user?.canAccessToSSHKeys
|
||||
@ -117,6 +123,7 @@ export const SettingsLayout = ({ children }: Props) => {
|
||||
import {
|
||||
Activity,
|
||||
Bell,
|
||||
BoxesIcon,
|
||||
Database,
|
||||
GitBranch,
|
||||
KeyIcon,
|
||||
|
18
apps/dokploy/drizzle/0037_small_adam_warlock.sql
Normal file
18
apps/dokploy/drizzle/0037_small_adam_warlock.sql
Normal file
@ -0,0 +1,18 @@
|
||||
CREATE TABLE IF NOT EXISTS "server" (
|
||||
"serverId" text PRIMARY KEY NOT NULL,
|
||||
"name" text NOT NULL,
|
||||
"description" text,
|
||||
"ipAddress" text NOT NULL,
|
||||
"port" integer NOT NULL,
|
||||
"username" text DEFAULT 'root' NOT NULL,
|
||||
"appName" text,
|
||||
"createdAt" text NOT NULL,
|
||||
"adminId" text NOT NULL,
|
||||
CONSTRAINT "server_appName_unique" UNIQUE("appName")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE "server" ADD CONSTRAINT "server_adminId_admin_adminId_fk" FOREIGN KEY ("adminId") REFERENCES "public"."admin"("adminId") ON DELETE cascade ON UPDATE no action;
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN null;
|
||||
END $$;
|
6
apps/dokploy/drizzle/0038_thankful_magneto.sql
Normal file
6
apps/dokploy/drizzle/0038_thankful_magneto.sql
Normal file
@ -0,0 +1,6 @@
|
||||
ALTER TABLE "deployment" ADD COLUMN "serverId" text;--> statement-breakpoint
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE "deployment" ADD CONSTRAINT "deployment_serverId_server_serverId_fk" FOREIGN KEY ("serverId") REFERENCES "public"."server"("serverId") ON DELETE cascade ON UPDATE no action;
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN null;
|
||||
END $$;
|
8
apps/dokploy/drizzle/0039_military_doctor_faustus.sql
Normal file
8
apps/dokploy/drizzle/0039_military_doctor_faustus.sql
Normal file
@ -0,0 +1,8 @@
|
||||
ALTER TABLE "server" DROP CONSTRAINT "server_appName_unique";--> statement-breakpoint
|
||||
ALTER TABLE "server" ALTER COLUMN "appName" SET NOT NULL;--> statement-breakpoint
|
||||
ALTER TABLE "server" ADD COLUMN "sshKeyId" text;--> statement-breakpoint
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE "server" ADD CONSTRAINT "server_sshKeyId_ssh-key_sshKeyId_fk" FOREIGN KEY ("sshKeyId") REFERENCES "public"."ssh-key"("sshKeyId") ON DELETE set null ON UPDATE no action;
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN null;
|
||||
END $$;
|
3653
apps/dokploy/drizzle/meta/0037_snapshot.json
Normal file
3653
apps/dokploy/drizzle/meta/0037_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
3672
apps/dokploy/drizzle/meta/0038_snapshot.json
Normal file
3672
apps/dokploy/drizzle/meta/0038_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
3683
apps/dokploy/drizzle/meta/0039_snapshot.json
Normal file
3683
apps/dokploy/drizzle/meta/0039_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@ -260,6 +260,27 @@
|
||||
"when": 1725519351871,
|
||||
"tag": "0036_tired_ronan",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 37,
|
||||
"version": "6",
|
||||
"when": 1725773488051,
|
||||
"tag": "0037_small_adam_warlock",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 38,
|
||||
"version": "6",
|
||||
"when": 1725773967628,
|
||||
"tag": "0038_thankful_magneto",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 39,
|
||||
"version": "6",
|
||||
"when": 1725776089878,
|
||||
"tag": "0039_military_doctor_faustus",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
@ -130,7 +130,8 @@
|
||||
"zod": "^3.23.4",
|
||||
"zod-form-data": "^2.0.2",
|
||||
"@radix-ui/react-primitive": "2.0.0",
|
||||
"@radix-ui/react-use-controllable-state": "1.1.0"
|
||||
"@radix-ui/react-use-controllable-state": "1.1.0",
|
||||
"ssh2": "1.15.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "1.8.3",
|
||||
@ -167,7 +168,8 @@
|
||||
"typescript": "^5.4.2",
|
||||
"vite-tsconfig-paths": "4.3.2",
|
||||
"vitest": "^1.6.0",
|
||||
"xterm-readline": "1.1.1"
|
||||
"xterm-readline": "1.1.1",
|
||||
"@types/ssh2": "1.15.1"
|
||||
},
|
||||
"ct3aMetadata": {
|
||||
"initVersion": "7.25.2"
|
||||
|
49
apps/dokploy/pages/dashboard/settings/servers.tsx
Normal file
49
apps/dokploy/pages/dashboard/settings/servers.tsx
Normal file
@ -0,0 +1,49 @@
|
||||
import { ShowServers } from "@/components/dashboard/settings/servers/show-servers";
|
||||
import { DashboardLayout } from "@/components/layouts/dashboard-layout";
|
||||
import { SettingsLayout } from "@/components/layouts/settings-layout";
|
||||
import { validateRequest } from "@/server/auth/auth";
|
||||
import type { GetServerSidePropsContext } from "next";
|
||||
import React, { type ReactElement } from "react";
|
||||
|
||||
const Page = () => {
|
||||
return (
|
||||
<div className="flex flex-col gap-4 w-full">
|
||||
<ShowServers />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Page;
|
||||
|
||||
Page.getLayout = (page: ReactElement) => {
|
||||
return (
|
||||
<DashboardLayout tab={"settings"}>
|
||||
<SettingsLayout>{page}</SettingsLayout>
|
||||
</DashboardLayout>
|
||||
);
|
||||
};
|
||||
export async function getServerSideProps(
|
||||
ctx: GetServerSidePropsContext<{ serviceId: string }>,
|
||||
) {
|
||||
const { user } = await validateRequest(ctx.req, ctx.res);
|
||||
if (!user) {
|
||||
return {
|
||||
redirect: {
|
||||
permanent: true,
|
||||
destination: "/",
|
||||
},
|
||||
};
|
||||
}
|
||||
if (user.rol === "user") {
|
||||
return {
|
||||
redirect: {
|
||||
permanent: true,
|
||||
destination: "/dashboard/settings/profile",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
props: {},
|
||||
};
|
||||
}
|
@ -69,8 +69,8 @@ export default function Home({ hasAdmin }: Props) {
|
||||
const router = useRouter();
|
||||
const form = useForm<Login>({
|
||||
defaultValues: {
|
||||
email: "",
|
||||
password: "",
|
||||
email: "user1@hotmail.com",
|
||||
password: "Password123",
|
||||
},
|
||||
resolver: zodResolver(loginSchema),
|
||||
});
|
||||
|
@ -16,6 +16,7 @@ import {
|
||||
} from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { isAdminPresent } from "@/server/api/services/admin";
|
||||
import { IS_CLOUD } from "@/server/constants";
|
||||
import { api } from "@/utils/api";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { AlertTriangle } from "lucide-react";
|
||||
@ -220,6 +221,11 @@ const Register = ({ hasAdmin }: Props) => {
|
||||
|
||||
export default Register;
|
||||
export async function getServerSideProps() {
|
||||
if (IS_CLOUD) {
|
||||
return {
|
||||
props: {},
|
||||
};
|
||||
}
|
||||
const hasAdmin = await isAdminPresent();
|
||||
|
||||
if (hasAdmin) {
|
||||
|
@ -29,6 +29,7 @@ import { securityRouter } from "./routers/security";
|
||||
import { settingsRouter } from "./routers/settings";
|
||||
import { sshRouter } from "./routers/ssh-key";
|
||||
import { userRouter } from "./routers/user";
|
||||
import { serverRouter } from "./routers/server";
|
||||
|
||||
/**
|
||||
* This is the primary router for your server.
|
||||
@ -66,6 +67,7 @@ export const appRouter = createTRPCRouter({
|
||||
bitbucket: bitbucketRouter,
|
||||
gitlab: gitlabRouter,
|
||||
github: githubRouter,
|
||||
server: serverRouter,
|
||||
});
|
||||
|
||||
// export type definition of API
|
||||
|
@ -29,20 +29,23 @@ import {
|
||||
protectedProcedure,
|
||||
publicProcedure,
|
||||
} from "../trpc";
|
||||
import { IS_CLOUD } from "@/server/constants";
|
||||
|
||||
export const authRouter = createTRPCRouter({
|
||||
createAdmin: publicProcedure
|
||||
.input(apiCreateAdmin)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
try {
|
||||
const admin = await db.query.admins.findFirst({});
|
||||
|
||||
if (admin) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: "Admin already exists",
|
||||
});
|
||||
if (!IS_CLOUD) {
|
||||
const admin = await db.query.admins.findFirst({});
|
||||
if (admin) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: "Admin already exists",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const newAdmin = await createAdmin(input);
|
||||
const session = await lucia.createSession(newAdmin.id || "", {});
|
||||
ctx.res.appendHeader(
|
||||
@ -51,6 +54,7 @@ export const authRouter = createTRPCRouter({
|
||||
);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: "Error to create the main admin",
|
||||
|
@ -1,10 +1,12 @@
|
||||
import {
|
||||
apiFindAllByApplication,
|
||||
apiFindAllByCompose,
|
||||
apiFindAllByServer,
|
||||
} from "@/server/db/schema";
|
||||
import {
|
||||
findAllDeploymentsByApplicationId,
|
||||
findAllDeploymentsByComposeId,
|
||||
findAllDeploymentsByServerId,
|
||||
} from "../services/deployment";
|
||||
import { createTRPCRouter, protectedProcedure } from "../trpc";
|
||||
|
||||
@ -20,4 +22,9 @@ export const deploymentRouter = createTRPCRouter({
|
||||
.query(async ({ input }) => {
|
||||
return await findAllDeploymentsByComposeId(input.composeId);
|
||||
}),
|
||||
allByServer: protectedProcedure
|
||||
.input(apiFindAllByServer)
|
||||
.query(async ({ input }) => {
|
||||
return await findAllDeploymentsByServerId(input.serverId);
|
||||
}),
|
||||
});
|
||||
|
@ -34,6 +34,7 @@ import {
|
||||
checkProjectAccess,
|
||||
findUserByAuthId,
|
||||
} from "../services/user";
|
||||
import { findAdmin, findAdminByAuthId } from "../services/admin";
|
||||
|
||||
export const projectRouter = createTRPCRouter({
|
||||
create: protectedProcedure
|
||||
@ -43,7 +44,8 @@ export const projectRouter = createTRPCRouter({
|
||||
if (ctx.user.rol === "user") {
|
||||
await checkProjectAccess(ctx.user.authId, "create");
|
||||
}
|
||||
const project = await createProject(input);
|
||||
|
||||
const project = await createProject(input, ctx.user.adminId);
|
||||
if (ctx.user.rol === "user") {
|
||||
await addNewProject(ctx.user.authId, project.projectId);
|
||||
}
|
||||
@ -153,6 +155,7 @@ export const projectRouter = createTRPCRouter({
|
||||
|
||||
return query;
|
||||
}
|
||||
|
||||
return await db.query.projects.findMany({
|
||||
with: {
|
||||
applications: true,
|
||||
@ -163,6 +166,7 @@ export const projectRouter = createTRPCRouter({
|
||||
redis: true,
|
||||
compose: true,
|
||||
},
|
||||
where: eq(projects.adminId, ctx.user.adminId),
|
||||
orderBy: desc(projects.createdAt),
|
||||
});
|
||||
}),
|
||||
|
97
apps/dokploy/server/api/routers/server.ts
Normal file
97
apps/dokploy/server/api/routers/server.ts
Normal file
@ -0,0 +1,97 @@
|
||||
import { createTRPCRouter, protectedProcedure } from "@/server/api/trpc";
|
||||
import { db } from "@/server/db";
|
||||
import {
|
||||
apiCreateServer,
|
||||
apiFindOneServer,
|
||||
apiRemoveProject,
|
||||
apiRemoveServer,
|
||||
apiUpdateServer,
|
||||
server,
|
||||
} from "@/server/db/schema";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { desc } from "drizzle-orm";
|
||||
import {
|
||||
deleteServer,
|
||||
findServerById,
|
||||
updateServerById,
|
||||
createServer,
|
||||
} from "../services/server";
|
||||
import { setupServer } from "@/server/utils/servers/setup-server";
|
||||
import { removeDeploymentsByServerId } from "../services/deployment";
|
||||
|
||||
export const serverRouter = createTRPCRouter({
|
||||
create: protectedProcedure
|
||||
.input(apiCreateServer)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
try {
|
||||
const project = await createServer(input, ctx.user.adminId);
|
||||
return project;
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: "Error to create the server",
|
||||
cause: error,
|
||||
});
|
||||
}
|
||||
}),
|
||||
|
||||
one: protectedProcedure
|
||||
.input(apiFindOneServer)
|
||||
.query(async ({ input, ctx }) => {
|
||||
return await findServerById(input.serverId);
|
||||
}),
|
||||
all: protectedProcedure.query(async ({ ctx }) => {
|
||||
return await db.query.server.findMany({
|
||||
orderBy: desc(server.createdAt),
|
||||
});
|
||||
}),
|
||||
setup: protectedProcedure
|
||||
.input(apiFindOneServer)
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
try {
|
||||
const currentServer = await setupServer(input.serverId);
|
||||
return currentServer;
|
||||
} catch (error) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: "Error to setup this server",
|
||||
cause: error,
|
||||
});
|
||||
}
|
||||
}),
|
||||
remove: protectedProcedure
|
||||
.input(apiRemoveServer)
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
try {
|
||||
const currentServer = await findServerById(input.serverId);
|
||||
await removeDeploymentsByServerId(currentServer);
|
||||
await deleteServer(input.serverId);
|
||||
|
||||
return currentServer;
|
||||
} catch (error) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: "Error to delete this server",
|
||||
cause: error,
|
||||
});
|
||||
}
|
||||
}),
|
||||
update: protectedProcedure
|
||||
.input(apiUpdateServer)
|
||||
.mutation(async ({ input }) => {
|
||||
try {
|
||||
const currentServer = await updateServerById(input.serverId, {
|
||||
...input,
|
||||
});
|
||||
|
||||
return currentServer;
|
||||
} catch (error) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: "Error to update this server",
|
||||
cause: error,
|
||||
});
|
||||
}
|
||||
}),
|
||||
});
|
@ -5,6 +5,7 @@ import { db } from "@/server/db";
|
||||
import {
|
||||
type apiCreateDeployment,
|
||||
type apiCreateDeploymentCompose,
|
||||
type apiCreateDeploymentServer,
|
||||
deployments,
|
||||
} from "@/server/db/schema";
|
||||
import { removeDirectoryIfExistsContent } from "@/server/utils/filesystem/directory";
|
||||
@ -13,6 +14,7 @@ import { format } from "date-fns";
|
||||
import { desc, eq } from "drizzle-orm";
|
||||
import { type Application, findApplicationById } from "./application";
|
||||
import { type Compose, findComposeById } from "./compose";
|
||||
import { findServerById, type Server } from "./server";
|
||||
|
||||
export type Deployment = typeof deployments.$inferSelect;
|
||||
|
||||
@ -240,3 +242,81 @@ export const updateDeploymentStatus = async (
|
||||
|
||||
return application;
|
||||
};
|
||||
|
||||
export const createServerDeployment = async (
|
||||
deployment: Omit<
|
||||
typeof apiCreateDeploymentServer._type,
|
||||
"deploymentId" | "createdAt" | "status" | "logPath"
|
||||
>,
|
||||
) => {
|
||||
try {
|
||||
const server = await findServerById(deployment.serverId);
|
||||
|
||||
await removeLastFiveDeployments(deployment.serverId);
|
||||
const formattedDateTime = format(new Date(), "yyyy-MM-dd:HH:mm:ss");
|
||||
const fileName = `${server.appName}-${formattedDateTime}.log`;
|
||||
const logFilePath = path.join(LOGS_PATH, server.appName, fileName);
|
||||
await fsPromises.mkdir(path.join(LOGS_PATH, server.appName), {
|
||||
recursive: true,
|
||||
});
|
||||
await fsPromises.writeFile(logFilePath, "Initializing Setup Server");
|
||||
const deploymentCreate = await db
|
||||
.insert(deployments)
|
||||
.values({
|
||||
serverId: server.serverId,
|
||||
title: deployment.title || "Deployment",
|
||||
description: deployment.description || "",
|
||||
status: "running",
|
||||
logPath: logFilePath,
|
||||
})
|
||||
.returning();
|
||||
if (deploymentCreate.length === 0 || !deploymentCreate[0]) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: "Error to create the deployment",
|
||||
});
|
||||
}
|
||||
return deploymentCreate[0];
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: "Error to create the deployment",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
export const removeLastFiveDeployments = async (serverId: string) => {
|
||||
const deploymentList = await db.query.deployments.findMany({
|
||||
where: eq(deployments.serverId, serverId),
|
||||
orderBy: desc(deployments.createdAt),
|
||||
});
|
||||
if (deploymentList.length >= 5) {
|
||||
const deploymentsToDelete = deploymentList.slice(4);
|
||||
for (const oldDeployment of deploymentsToDelete) {
|
||||
const logPath = path.join(oldDeployment.logPath);
|
||||
if (existsSync(logPath)) {
|
||||
await fsPromises.unlink(logPath);
|
||||
}
|
||||
await removeDeployment(oldDeployment.deploymentId);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export const removeDeploymentsByServerId = async (server: Server) => {
|
||||
const { appName } = server;
|
||||
const logsPath = path.join(LOGS_PATH, appName);
|
||||
await removeDirectoryIfExistsContent(logsPath);
|
||||
await db
|
||||
.delete(deployments)
|
||||
.where(eq(deployments.serverId, server.serverId))
|
||||
.returning();
|
||||
};
|
||||
|
||||
export const findAllDeploymentsByServerId = async (serverId: string) => {
|
||||
const deploymentsList = await db.query.deployments.findMany({
|
||||
where: eq(deployments.serverId, serverId),
|
||||
orderBy: desc(deployments.createdAt),
|
||||
});
|
||||
return deploymentsList;
|
||||
};
|
||||
|
@ -11,17 +11,18 @@ import {
|
||||
} from "@/server/db/schema";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { findAdmin } from "./admin";
|
||||
|
||||
export type Project = typeof projects.$inferSelect;
|
||||
|
||||
export const createProject = async (input: typeof apiCreateProject._type) => {
|
||||
const admin = await findAdmin();
|
||||
export const createProject = async (
|
||||
input: typeof apiCreateProject._type,
|
||||
adminId: string,
|
||||
) => {
|
||||
const newProject = await db
|
||||
.insert(projects)
|
||||
.values({
|
||||
...input,
|
||||
adminId: admin.adminId,
|
||||
adminId: adminId,
|
||||
})
|
||||
.returning()
|
||||
.then((value) => value[0]);
|
||||
|
72
apps/dokploy/server/api/services/server.ts
Normal file
72
apps/dokploy/server/api/services/server.ts
Normal file
@ -0,0 +1,72 @@
|
||||
import { db } from "@/server/db";
|
||||
import { type apiCreateServer, server } from "@/server/db/schema";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { eq } from "drizzle-orm";
|
||||
|
||||
export type Server = typeof server.$inferSelect;
|
||||
|
||||
export const createServer = async (
|
||||
input: typeof apiCreateServer._type,
|
||||
adminId: string,
|
||||
) => {
|
||||
const newServer = await db
|
||||
.insert(server)
|
||||
.values({
|
||||
...input,
|
||||
adminId: adminId,
|
||||
})
|
||||
.returning()
|
||||
.then((value) => value[0]);
|
||||
|
||||
if (!newServer) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: "Error to create the server",
|
||||
});
|
||||
}
|
||||
|
||||
return newServer;
|
||||
};
|
||||
|
||||
export const findServerById = async (serverId: string) => {
|
||||
const currentServer = await db.query.server.findFirst({
|
||||
where: eq(server.serverId, serverId),
|
||||
with: {
|
||||
deployments: true,
|
||||
sshKey: true,
|
||||
},
|
||||
});
|
||||
if (!currentServer) {
|
||||
throw new TRPCError({
|
||||
code: "NOT_FOUND",
|
||||
message: "Server not found",
|
||||
});
|
||||
}
|
||||
return currentServer;
|
||||
};
|
||||
|
||||
export const deleteServer = async (serverId: string) => {
|
||||
const currentServer = await db
|
||||
.delete(server)
|
||||
.where(eq(server.serverId, serverId))
|
||||
.returning()
|
||||
.then((value) => value[0]);
|
||||
|
||||
return currentServer;
|
||||
};
|
||||
|
||||
export const updateServerById = async (
|
||||
serverId: string,
|
||||
serverData: Partial<Server>,
|
||||
) => {
|
||||
const result = await db
|
||||
.update(server)
|
||||
.set({
|
||||
...serverData,
|
||||
})
|
||||
.where(eq(server.serverId, serverId))
|
||||
.returning()
|
||||
.then((res) => res[0]);
|
||||
|
||||
return result;
|
||||
};
|
@ -22,6 +22,8 @@ import superjson from "superjson";
|
||||
import { ZodError } from "zod";
|
||||
import { validateRequest } from "../auth/auth";
|
||||
import { validateBearerToken } from "../auth/token";
|
||||
import { findAdminByAuthId } from "./services/admin";
|
||||
import { findUserByAuthId } from "./services/user";
|
||||
|
||||
/**
|
||||
* 1. CONTEXT
|
||||
@ -32,7 +34,7 @@ import { validateBearerToken } from "../auth/token";
|
||||
*/
|
||||
|
||||
interface CreateContextOptions {
|
||||
user: (User & { authId: string }) | null;
|
||||
user: (User & { authId: string; adminId: string }) | null;
|
||||
session: Session | null;
|
||||
req: CreateNextContextOptions["req"];
|
||||
res: CreateNextContextOptions["res"];
|
||||
@ -86,6 +88,7 @@ export const createTRPCContext = async (opts: CreateNextContextOptions) => {
|
||||
rol: user.rol,
|
||||
id: user.id,
|
||||
secret: user.secret,
|
||||
adminId: user.adminId,
|
||||
},
|
||||
}) || {
|
||||
user: null,
|
||||
|
@ -6,6 +6,8 @@ import { Lucia } from "lucia/dist/core.js";
|
||||
import type { Session, User } from "lucia/dist/core.js";
|
||||
import { db } from "../db";
|
||||
import { type DatabaseUser, auth, sessionTable } from "../db/schema";
|
||||
import { findUserByAuthId } from "../api/services/user";
|
||||
import { findAdminByAuthId } from "../api/services/admin";
|
||||
|
||||
globalThis.crypto = webcrypto as Crypto;
|
||||
export const adapter = new DrizzlePostgreSQLAdapter(db, sessionTable, auth);
|
||||
@ -22,6 +24,7 @@ export const lucia = new Lucia(adapter, {
|
||||
email: attributes.email,
|
||||
rol: attributes.rol,
|
||||
secret: attributes.secret !== null,
|
||||
adminId: attributes.adminId,
|
||||
};
|
||||
},
|
||||
});
|
||||
@ -29,12 +32,15 @@ export const lucia = new Lucia(adapter, {
|
||||
declare module "lucia" {
|
||||
interface Register {
|
||||
Lucia: typeof lucia;
|
||||
DatabaseUserAttributes: Omit<DatabaseUser, "id"> & { authId: string };
|
||||
DatabaseUserAttributes: Omit<DatabaseUser, "id"> & {
|
||||
authId: string;
|
||||
adminId: string;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export type ReturnValidateToken = Promise<{
|
||||
user: (User & { authId: string }) | null;
|
||||
user: (User & { authId: string; adminId: string }) | null;
|
||||
session: Session | null;
|
||||
}>;
|
||||
|
||||
@ -63,6 +69,17 @@ export async function validateRequest(
|
||||
lucia.createBlankSessionCookie().serialize(),
|
||||
);
|
||||
}
|
||||
|
||||
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 && {
|
||||
@ -72,6 +89,7 @@ export async function validateRequest(
|
||||
rol: result.user.rol,
|
||||
id: result.user.id,
|
||||
secret: result.user.secret,
|
||||
adminId: result.user.adminId,
|
||||
},
|
||||
}) || {
|
||||
user: null,
|
||||
|
@ -5,6 +5,7 @@ export const BASE_PATH =
|
||||
process.env.NODE_ENV === "production"
|
||||
? "/etc/dokploy"
|
||||
: path.join(process.cwd(), ".docker");
|
||||
export const IS_CLOUD = process.env.IS_CLOUD === "true";
|
||||
export const MAIN_TRAEFIK_PATH = `${BASE_PATH}/traefik`;
|
||||
export const DYNAMIC_TRAEFIK_PATH = `${BASE_PATH}/traefik/dynamic`;
|
||||
export const LOGS_PATH = `${BASE_PATH}/logs`;
|
||||
@ -15,3 +16,27 @@ export const CERTIFICATES_PATH = `${DYNAMIC_TRAEFIK_PATH}/certificates`;
|
||||
export const REGISTRY_PATH = `${DYNAMIC_TRAEFIK_PATH}/registry`;
|
||||
export const MONITORING_PATH = `${BASE_PATH}/monitoring`;
|
||||
export const docker = new Docker();
|
||||
|
||||
export const getPaths = (basePath: string) => {
|
||||
// return [
|
||||
// MAIN_TRAEFIK_PATH: `${basePath}/traefik`,
|
||||
// DYNAMIC_TRAEFIK_PATH: `${basePath}/traefik/dynamic`,
|
||||
// LOGS_PATH: `${basePath}/logs`,
|
||||
// APPLICATIONS_PATH: `${basePath}/applications`,
|
||||
// COMPOSE_PATH: `${basePath}/compose`,
|
||||
// SSH_PATH: `${basePath}/ssh`,
|
||||
// CERTIFICATES_PATH: `${basePath}/certificates`,
|
||||
// MONITORING_PATH: `${basePath}/monitoring`,
|
||||
// ];
|
||||
|
||||
return [
|
||||
`${basePath}/traefik`,
|
||||
`${basePath}/traefik/dynamic`,
|
||||
`${basePath}/logs`,
|
||||
`${basePath}/applications`,
|
||||
`${basePath}/compose`,
|
||||
`${basePath}/ssh`,
|
||||
`${basePath}/certificates`,
|
||||
`${basePath}/monitoring`,
|
||||
];
|
||||
};
|
||||
|
@ -5,6 +5,7 @@ import { nanoid } from "nanoid";
|
||||
import { z } from "zod";
|
||||
import { applications } from "./application";
|
||||
import { compose } from "./compose";
|
||||
import { server } from "./server";
|
||||
|
||||
export const deploymentStatus = pgEnum("deploymentStatus", [
|
||||
"running",
|
||||
@ -28,6 +29,9 @@ export const deployments = pgTable("deployment", {
|
||||
composeId: text("composeId").references(() => compose.composeId, {
|
||||
onDelete: "cascade",
|
||||
}),
|
||||
serverId: text("serverId").references(() => server.serverId, {
|
||||
onDelete: "cascade",
|
||||
}),
|
||||
createdAt: text("createdAt")
|
||||
.notNull()
|
||||
.$defaultFn(() => new Date().toISOString()),
|
||||
@ -42,6 +46,10 @@ export const deploymentsRelations = relations(deployments, ({ one }) => ({
|
||||
fields: [deployments.composeId],
|
||||
references: [compose.composeId],
|
||||
}),
|
||||
server: one(server, {
|
||||
fields: [deployments.serverId],
|
||||
references: [server.serverId],
|
||||
}),
|
||||
}));
|
||||
|
||||
const schema = createInsertSchema(deployments, {
|
||||
@ -77,6 +85,18 @@ export const apiCreateDeploymentCompose = schema
|
||||
composeId: z.string().min(1),
|
||||
});
|
||||
|
||||
export const apiCreateDeploymentServer = schema
|
||||
.pick({
|
||||
title: true,
|
||||
status: true,
|
||||
logPath: true,
|
||||
serverId: true,
|
||||
description: true,
|
||||
})
|
||||
.extend({
|
||||
serverId: z.string().min(1),
|
||||
});
|
||||
|
||||
export const apiFindAllByApplication = schema
|
||||
.pick({
|
||||
applicationId: true,
|
||||
@ -94,3 +114,12 @@ export const apiFindAllByCompose = schema
|
||||
composeId: z.string().min(1),
|
||||
})
|
||||
.required();
|
||||
|
||||
export const apiFindAllByServer = schema
|
||||
.pick({
|
||||
serverId: true,
|
||||
})
|
||||
.extend({
|
||||
serverId: z.string().min(1),
|
||||
})
|
||||
.required();
|
||||
|
@ -27,3 +27,4 @@ export * from "./git-provider";
|
||||
export * from "./bitbucket";
|
||||
export * from "./github";
|
||||
export * from "./gitlab";
|
||||
export * from "./server";
|
||||
|
82
apps/dokploy/server/db/schema/server.ts
Normal file
82
apps/dokploy/server/db/schema/server.ts
Normal file
@ -0,0 +1,82 @@
|
||||
import { relations } from "drizzle-orm";
|
||||
import { integer, pgTable, text } from "drizzle-orm/pg-core";
|
||||
import { createInsertSchema } from "drizzle-zod";
|
||||
import { nanoid } from "nanoid";
|
||||
import { z } from "zod";
|
||||
import { admins } from "./admin";
|
||||
import { generateAppName } from "./utils";
|
||||
import { deployments } from "./deployment";
|
||||
import { sshKeys } from "./ssh-key";
|
||||
|
||||
export const server = pgTable("server", {
|
||||
serverId: text("serverId")
|
||||
.notNull()
|
||||
.primaryKey()
|
||||
.$defaultFn(() => nanoid()),
|
||||
name: text("name").notNull(),
|
||||
description: text("description"),
|
||||
ipAddress: text("ipAddress").notNull(),
|
||||
port: integer("port").notNull(),
|
||||
username: text("username").notNull().default("root"),
|
||||
appName: text("appName")
|
||||
.notNull()
|
||||
.$defaultFn(() => generateAppName("server")),
|
||||
createdAt: text("createdAt")
|
||||
.notNull()
|
||||
.$defaultFn(() => new Date().toISOString()),
|
||||
adminId: text("adminId")
|
||||
.notNull()
|
||||
.references(() => admins.adminId, { onDelete: "cascade" }),
|
||||
sshKeyId: text("sshKeyId").references(() => sshKeys.sshKeyId, {
|
||||
onDelete: "set null",
|
||||
}),
|
||||
});
|
||||
|
||||
export const serverRelations = relations(server, ({ one, many }) => ({
|
||||
admin: one(admins, {
|
||||
fields: [server.adminId],
|
||||
references: [admins.adminId],
|
||||
}),
|
||||
deployments: many(deployments),
|
||||
sshKey: one(sshKeys, {
|
||||
fields: [server.sshKeyId],
|
||||
references: [sshKeys.sshKeyId],
|
||||
}),
|
||||
}));
|
||||
|
||||
const createSchema = createInsertSchema(server, {
|
||||
serverId: z.string().min(1),
|
||||
name: z.string().min(1),
|
||||
description: z.string().optional(),
|
||||
});
|
||||
|
||||
export const apiCreateServer = createSchema
|
||||
.pick({
|
||||
name: true,
|
||||
description: true,
|
||||
ipAddress: true,
|
||||
port: true,
|
||||
username: true,
|
||||
sshKeyId: true,
|
||||
})
|
||||
.required();
|
||||
|
||||
export const apiFindOneServer = createSchema
|
||||
.pick({
|
||||
serverId: true,
|
||||
})
|
||||
.required();
|
||||
|
||||
export const apiRemoveServer = createSchema
|
||||
.pick({
|
||||
serverId: true,
|
||||
})
|
||||
.required();
|
||||
|
||||
export const apiUpdateServer = createSchema
|
||||
.pick({
|
||||
name: true,
|
||||
description: true,
|
||||
serverId: true,
|
||||
})
|
||||
.required();
|
@ -2,9 +2,10 @@ import { applications } from "@/server/db/schema/application";
|
||||
import { compose } from "@/server/db/schema/compose";
|
||||
import { sshKeyCreate, sshKeyType } from "@/server/db/validations";
|
||||
import { relations } from "drizzle-orm";
|
||||
import { pgTable, text, time } from "drizzle-orm/pg-core";
|
||||
import { pgTable, text } from "drizzle-orm/pg-core";
|
||||
import { createInsertSchema } from "drizzle-zod";
|
||||
import { nanoid } from "nanoid";
|
||||
import { server } from "./server";
|
||||
|
||||
export const sshKeys = pgTable("ssh-key", {
|
||||
sshKeyId: text("sshKeyId")
|
||||
@ -23,6 +24,7 @@ export const sshKeys = pgTable("ssh-key", {
|
||||
export const sshKeysRelations = relations(sshKeys, ({ many }) => ({
|
||||
applications: many(applications),
|
||||
compose: many(compose),
|
||||
servers: many(server),
|
||||
}));
|
||||
|
||||
const createSchema = createInsertSchema(
|
||||
|
@ -1,8 +1,10 @@
|
||||
import { type ConnectionOptions, Queue } from "bullmq";
|
||||
|
||||
export const redisConfig: ConnectionOptions = {
|
||||
host: process.env.NODE_ENV === "production" ? "dokploy-redis" : "127.0.0.1",
|
||||
port: 6379,
|
||||
host: "31.220.108.27",
|
||||
password: "xYBugfHkULig1iLN",
|
||||
// host: process.env.NODE_ENV === "production" ? "dokploy-redis" : "127.0.0.1",
|
||||
port: 1233,
|
||||
};
|
||||
// TODO: maybe add a options to clean the queue to the times
|
||||
const myQueue = new Queue("deployments", {
|
||||
|
@ -3,7 +3,7 @@ import * as path from "node:path";
|
||||
import { SSH_PATH } from "@/server/constants";
|
||||
import { spawnAsync } from "../process/spawnAsync";
|
||||
|
||||
const readSSHKey = async (id: string) => {
|
||||
export const readSSHKey = async (id: string) => {
|
||||
try {
|
||||
if (!fs.existsSync(SSH_PATH)) {
|
||||
fs.mkdirSync(SSH_PATH, { recursive: true });
|
||||
|
235
apps/dokploy/server/utils/servers/setup-server.ts
Normal file
235
apps/dokploy/server/utils/servers/setup-server.ts
Normal file
@ -0,0 +1,235 @@
|
||||
import { findServerById } from "@/server/api/services/server";
|
||||
import { recreateDirectory } from "../filesystem/directory";
|
||||
import { slugify } from "@/lib/slug";
|
||||
import path from "node:path";
|
||||
import {
|
||||
APPLICATIONS_PATH,
|
||||
BASE_PATH,
|
||||
CERTIFICATES_PATH,
|
||||
DYNAMIC_TRAEFIK_PATH,
|
||||
getPaths,
|
||||
LOGS_PATH,
|
||||
MAIN_TRAEFIK_PATH,
|
||||
MONITORING_PATH,
|
||||
SSH_PATH,
|
||||
} from "@/server/constants";
|
||||
import {
|
||||
createServerDeployment,
|
||||
updateDeploymentStatus,
|
||||
} from "@/server/api/services/deployment";
|
||||
import { chmodSync, createWriteStream } from "node:fs";
|
||||
import { Client } from "ssh2";
|
||||
import { readSSHKey } from "../filesystem/ssh";
|
||||
|
||||
export const setupServer = async (serverId: string) => {
|
||||
const server = await findServerById(serverId);
|
||||
|
||||
const slugifyName = slugify(`server ${server.name}`);
|
||||
|
||||
const fullPath = path.join(LOGS_PATH, slugifyName);
|
||||
|
||||
await recreateDirectory(fullPath);
|
||||
|
||||
const deployment = await createServerDeployment({
|
||||
serverId: server.serverId,
|
||||
title: "Setup Server",
|
||||
description: "Setup Server",
|
||||
});
|
||||
const writeStream = createWriteStream(deployment.logPath, { flags: "a" });
|
||||
|
||||
try {
|
||||
writeStream.write("\nInstalling Server Dependencies: ✅\n");
|
||||
await connectToServer(serverId, deployment.logPath);
|
||||
|
||||
writeStream.close();
|
||||
|
||||
await updateDeploymentStatus(deployment.deploymentId, "done");
|
||||
} catch (err) {
|
||||
console.log(err);
|
||||
await updateDeploymentStatus(deployment.deploymentId, "error");
|
||||
writeStream.write(err);
|
||||
writeStream.close();
|
||||
}
|
||||
};
|
||||
|
||||
const setupTraefikInstance = async (serverId: string) => {};
|
||||
|
||||
const connectToServer = async (serverId: string, logPath: string) => {
|
||||
const writeStream = createWriteStream(logPath, { flags: "a" });
|
||||
const client = new Client();
|
||||
const server = await findServerById(serverId);
|
||||
if (!server.sshKeyId) return;
|
||||
const keys = await readSSHKey(server.sshKeyId);
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
client
|
||||
.on("ready", () => {
|
||||
console.log("Client :: ready");
|
||||
const bashCommand = `
|
||||
# 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
|
||||
echo "Installing Docker ✅"
|
||||
curl -sSL https://get.docker.com | sh -s -- --version 27.2.0
|
||||
fi
|
||||
|
||||
# Check if the node is already part of a Docker Swarm
|
||||
if docker info | grep -q 'Swarm: active'; then
|
||||
echo "Already part of a Docker Swarm ✅"
|
||||
else
|
||||
# Get IP address
|
||||
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)
|
||||
|
||||
# Initialize Docker Swarm
|
||||
docker swarm init --advertise-addr \$advertise_addr
|
||||
echo "Swarm initialized ✅"
|
||||
fi
|
||||
|
||||
# Check if the dokploy-network already exists
|
||||
if docker network ls | grep -q 'dokploy-network'; then
|
||||
echo "Network dokploy-network already exists ✅"
|
||||
else
|
||||
# Create the dokploy-network if it doesn't exist
|
||||
docker network create --driver overlay --attachable dokploy-network
|
||||
echo "Network created ✅"
|
||||
fi
|
||||
|
||||
# Check if the /etc/dokploy directory exists
|
||||
if [ -d /etc/dokploy ]; then
|
||||
echo "/etc/dokploy already exists ✅"
|
||||
else
|
||||
# Create the /etc/dokploy directory
|
||||
mkdir -p /etc/dokploy
|
||||
chmod 777 /etc/dokploy
|
||||
echo "Directory /etc/dokploy created ✅"
|
||||
fi
|
||||
|
||||
${setupDirectories()}
|
||||
|
||||
`;
|
||||
|
||||
client.exec(bashCommand, (err, stream) => {
|
||||
if (err) {
|
||||
writeStream.write(err);
|
||||
reject(err);
|
||||
return;
|
||||
}
|
||||
stream
|
||||
.on("close", () => {
|
||||
writeStream.write("Connection closed ✅");
|
||||
client.end();
|
||||
resolve();
|
||||
})
|
||||
.on("data", (data) => {
|
||||
writeStream.write(data.toString());
|
||||
console.log(`OUTPUT: ${data}`);
|
||||
})
|
||||
.stderr.on("data", (data) => {
|
||||
writeStream.write(data.toString());
|
||||
console.log(`STDERR: ${data}`);
|
||||
});
|
||||
});
|
||||
})
|
||||
.connect({
|
||||
host: server.ipAddress,
|
||||
port: server.port,
|
||||
username: server.username,
|
||||
privateKey: keys.privateKey,
|
||||
timeout: 10000,
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const setupDirectories = () => {
|
||||
// const directories = [
|
||||
// BASE_PATH,
|
||||
// MAIN_TRAEFIK_PATH,
|
||||
// DYNAMIC_TRAEFIK_PATH,
|
||||
// LOGS_PATH,
|
||||
// APPLICATIONS_PATH,
|
||||
// SSH_PATH,
|
||||
// CERTIFICATES_PATH,
|
||||
// MONITORING_PATH,
|
||||
// ];
|
||||
|
||||
const directories = getPaths("/etc/dokploy");
|
||||
|
||||
const createDirsCommand = directories
|
||||
.map((dir) => `mkdir -p "${dir}"`)
|
||||
.join(" && ");
|
||||
|
||||
const chmodCommand = `chmod 700 "${SSH_PATH}"`;
|
||||
|
||||
const command = `
|
||||
${createDirsCommand}
|
||||
${chmodCommand}
|
||||
`;
|
||||
|
||||
console.log(command);
|
||||
return command;
|
||||
};
|
||||
|
||||
export const setupSwarm = async () => {
|
||||
const command = `
|
||||
# Check if the node is already part of a Docker Swarm
|
||||
if docker info | grep -q 'Swarm: active'; then
|
||||
echo "Already part of a Docker Swarm ✅"
|
||||
else
|
||||
# Get IP address
|
||||
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)
|
||||
|
||||
# Initialize Docker Swarm
|
||||
docker swarm init --advertise-addr \$advertise_addr
|
||||
echo "Swarm initialized ✅"
|
||||
fi
|
||||
`;
|
||||
|
||||
console.log(command);
|
||||
return command;
|
||||
};
|
||||
|
||||
// mkdir -p "/Users/mauricio/Documents/Github/Personal/dokploy/apps/dokploy/.docker" && mkdir -p "/Users/mauricio/Documents/Github/Personal/dokploy/apps/dokploy/.docker/traefik" && mkdir -p "/Users/mauricio/Documents/Github/Personal/dokploy/apps/dokploy/.docker/traefik/dynamic" && mkdir -p "/Users/mauricio/Documents/Github/Personal/dokploy/apps/dokploy/.docker/logs" && mkdir -p "/Users/mauricio/Documents/Github/Personal/dokploy/apps/dokploy/.docker/applications" && mkdir -p "/Users/mauricio/Documents/Github/Personal/dokploy/apps/dokploy/.docker/ssh" && mkdir -p "/Users/mauricio/Documents/Github/Personal/dokploy/apps/dokploy/.docker/traefik/dynamic/certificates" && mkdir -p "/Users/mauricio/Documents/Github/Personal/dokploy/apps/dokploy/.docker/monitoring"
|
||||
// chmod 700 "/Users/mauricio/Documents/Github/Personal/dokploy/apps/dokploy/.docker/ssh"
|
@ -1,12 +1,11 @@
|
||||
import { writeFileSync } from "node:fs";
|
||||
import type http from "node:http";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { spawn } from "node-pty";
|
||||
import { publicIpv4, publicIpv6 } from "public-ip";
|
||||
import { WebSocketServer } from "ws";
|
||||
import { findAdmin } from "../api/services/admin";
|
||||
import { validateWebSocketRequest } from "../auth/auth";
|
||||
import { findServerById } from "../api/services/server";
|
||||
import { SSH_PATH } from "../constants";
|
||||
import path from "node:path";
|
||||
|
||||
export const getPublicIpWithFallback = async () => {
|
||||
// @ts-ignore
|
||||
@ -50,63 +49,59 @@ export const setupTerminalWebSocketServer = (
|
||||
}
|
||||
});
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-misused-promises
|
||||
wssTerm.on("connection", async (ws, req) => {
|
||||
const url = new URL(req.url || "", `http://${req.headers.host}`);
|
||||
const userSSH = url.searchParams.get("userSSH");
|
||||
const serverId = url.searchParams.get("serverId");
|
||||
const { user, session } = await validateWebSocketRequest(req);
|
||||
if (!user || !session) {
|
||||
if (!user || !session || !serverId) {
|
||||
ws.close();
|
||||
return;
|
||||
}
|
||||
if (user) {
|
||||
const admin = await findAdmin();
|
||||
const privateKey = admin.sshPrivateKey || "";
|
||||
const tempDir = tmpdir();
|
||||
const tempKeyPath = join(tempDir, "temp_ssh_key");
|
||||
writeFileSync(tempKeyPath, privateKey, { encoding: "utf8", mode: 0o600 });
|
||||
|
||||
const sshUser = userSSH;
|
||||
const ip =
|
||||
process.env.NODE_ENV === "production"
|
||||
? await getPublicIpWithFallback()
|
||||
: "localhost";
|
||||
const server = await findServerById(serverId);
|
||||
|
||||
const sshCommand = [
|
||||
"ssh",
|
||||
...((process.env.NODE_ENV === "production" && ["-i", tempKeyPath]) ||
|
||||
[]),
|
||||
`${sshUser}@${ip}`,
|
||||
];
|
||||
const ptyProcess = spawn("ssh", sshCommand.slice(1), {
|
||||
name: "xterm-256color",
|
||||
cwd: process.env.HOME,
|
||||
env: process.env,
|
||||
encoding: "utf8",
|
||||
cols: 80,
|
||||
rows: 30,
|
||||
});
|
||||
|
||||
ptyProcess.onData((data) => {
|
||||
ws.send(data);
|
||||
});
|
||||
ws.on("message", (message) => {
|
||||
try {
|
||||
let command: string | Buffer[] | Buffer | ArrayBuffer;
|
||||
if (Buffer.isBuffer(message)) {
|
||||
command = message.toString("utf8");
|
||||
} else {
|
||||
command = message;
|
||||
}
|
||||
ptyProcess.write(command.toString());
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
}
|
||||
});
|
||||
|
||||
ws.on("close", () => {
|
||||
ptyProcess.kill();
|
||||
});
|
||||
if (!server) {
|
||||
ws.close();
|
||||
return;
|
||||
}
|
||||
|
||||
const privateKey = path.join(SSH_PATH, `${server.sshKeyId}_rsa`);
|
||||
const sshCommand = [
|
||||
"ssh",
|
||||
"-o",
|
||||
"StrictHostKeyChecking=no",
|
||||
"-i",
|
||||
privateKey,
|
||||
`${server.username}@${server.ipAddress}`,
|
||||
];
|
||||
const ptyProcess = spawn("ssh", sshCommand.slice(1), {
|
||||
name: "xterm-256color",
|
||||
cwd: process.env.HOME,
|
||||
env: process.env,
|
||||
encoding: "utf8",
|
||||
cols: 80,
|
||||
rows: 30,
|
||||
});
|
||||
|
||||
ptyProcess.onData((data) => {
|
||||
ws.send(data);
|
||||
});
|
||||
ws.on("message", (message) => {
|
||||
try {
|
||||
let command: string | Buffer[] | Buffer | ArrayBuffer;
|
||||
if (Buffer.isBuffer(message)) {
|
||||
command = message.toString("utf8");
|
||||
} else {
|
||||
command = message;
|
||||
}
|
||||
ptyProcess.write(command.toString());
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
}
|
||||
});
|
||||
|
||||
ws.on("close", () => {
|
||||
ptyProcess.kill();
|
||||
});
|
||||
});
|
||||
};
|
||||
|
@ -380,6 +380,9 @@ importers:
|
||||
sonner:
|
||||
specifier: ^1.4.0
|
||||
version: 1.5.0(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
|
||||
ssh2:
|
||||
specifier: 1.15.0
|
||||
version: 1.15.0
|
||||
superjson:
|
||||
specifier: ^2.2.1
|
||||
version: 2.2.1
|
||||
@ -459,6 +462,9 @@ importers:
|
||||
'@types/react-dom':
|
||||
specifier: 18.3.0
|
||||
version: 18.3.0
|
||||
'@types/ssh2':
|
||||
specifier: 1.15.1
|
||||
version: 1.15.1
|
||||
'@types/swagger-ui-react':
|
||||
specifier: ^4.18.3
|
||||
version: 4.18.3
|
||||
@ -3832,8 +3838,8 @@ packages:
|
||||
'@types/serve-static@1.15.7':
|
||||
resolution: {integrity: sha512-W8Ym+h8nhuRwaKPaDw34QUkwsGi6Rc4yYqvKFo5rm2FUEhCFbzVWrxXUxuKK8TASjWsysJY0nsmNCGhCOIsrOw==}
|
||||
|
||||
'@types/ssh2@1.15.0':
|
||||
resolution: {integrity: sha512-YcT8jP5F8NzWeevWvcyrrLB3zcneVjzYY9ZDSMAMboI+2zR1qYWFhwsyOFVzT7Jorn67vqxC0FRiw8YyG9P1ww==}
|
||||
'@types/ssh2@1.15.1':
|
||||
resolution: {integrity: sha512-ZIbEqKAsi5gj35y4P4vkJYly642wIbY6PqoN0xiyQGshKUGXR9WQjF/iF9mXBQ8uBKy3ezfsCkcoHKhd0BzuDA==}
|
||||
|
||||
'@types/swagger-ui-react@4.18.3':
|
||||
resolution: {integrity: sha512-Mo/R7IjDVwtiFPs84pWvh5pI9iyNGBjmfielxqbOh2Jv+8WVSDVe8Nu25kb5BOuV2xmGS3o33jr6nwDJMBcX+Q==}
|
||||
@ -12449,7 +12455,7 @@ snapshots:
|
||||
'@types/docker-modem@3.0.6':
|
||||
dependencies:
|
||||
'@types/node': 20.14.10
|
||||
'@types/ssh2': 1.15.0
|
||||
'@types/ssh2': 1.15.1
|
||||
|
||||
'@types/dockerode@3.3.23':
|
||||
dependencies:
|
||||
@ -12579,7 +12585,7 @@ snapshots:
|
||||
'@types/node': 20.14.10
|
||||
'@types/send': 0.17.4
|
||||
|
||||
'@types/ssh2@1.15.0':
|
||||
'@types/ssh2@1.15.1':
|
||||
dependencies:
|
||||
'@types/node': 18.19.42
|
||||
|
||||
|
Loading…
Reference in New Issue
Block a user