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:
@@ -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 {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
@@ -8,79 +7,27 @@ import {
|
|||||||
DialogTrigger,
|
DialogTrigger,
|
||||||
} from "@/components/ui/dialog";
|
} from "@/components/ui/dialog";
|
||||||
import { DropdownMenuItem } from "@/components/ui/dropdown-menu";
|
import { DropdownMenuItem } from "@/components/ui/dropdown-menu";
|
||||||
import {
|
|
||||||
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 { api } from "@/utils/api";
|
||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
|
||||||
import dynamic from "next/dynamic";
|
import dynamic from "next/dynamic";
|
||||||
import type React from "react";
|
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), {
|
const Terminal = dynamic(() => import("./terminal").then((e) => e.Terminal), {
|
||||||
ssr: false,
|
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 {
|
interface Props {
|
||||||
children?: React.ReactNode;
|
children?: React.ReactNode;
|
||||||
|
serverId: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const TerminalModal = ({ children }: Props) => {
|
export const TerminalModal = ({ children, serverId }: Props) => {
|
||||||
const { data, refetch } = api.admin.one.useQuery();
|
const { data } = api.server.one.useQuery(
|
||||||
const [user, setUser] = useState("root");
|
{
|
||||||
const [terminalUser, setTerminalUser] = useState("root");
|
serverId,
|
||||||
|
|
||||||
const { mutateAsync, isLoading } =
|
|
||||||
api.settings.saveSSHPrivateKey.useMutation();
|
|
||||||
|
|
||||||
const form = useForm<AddSSHPrivateKey>({
|
|
||||||
defaultValues: {
|
|
||||||
sshPrivateKey: "",
|
|
||||||
},
|
},
|
||||||
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 (
|
return (
|
||||||
<Dialog>
|
<Dialog>
|
||||||
<DialogTrigger asChild>
|
<DialogTrigger asChild>
|
||||||
@@ -92,75 +39,14 @@ export const TerminalModal = ({ children }: Props) => {
|
|||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
</DialogTrigger>
|
</DialogTrigger>
|
||||||
<DialogContent className="max-h-screen overflow-y-auto sm:max-w-7xl">
|
<DialogContent className="max-h-screen overflow-y-auto sm:max-w-7xl">
|
||||||
<DialogHeader className="flex flex-row justify-between pt-4">
|
<DialogHeader className="flex flex-col gap-1">
|
||||||
<div>
|
<DialogTitle>Terminal ({data?.name})</DialogTitle>
|
||||||
<DialogTitle>Terminal</DialogTitle>
|
<DialogDescription>Easy way to access the server</DialogDescription>
|
||||||
<DialogDescription>Easy way to access the server</DialogDescription>
|
|
||||||
</div>
|
|
||||||
{data?.haveSSH && (
|
|
||||||
<div>
|
|
||||||
<RemoveSSHPrivateKey />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</DialogHeader>
|
</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 className="flex flex-col gap-4">
|
||||||
</div>
|
<Terminal id="terminal" serverId={serverId} />
|
||||||
)}
|
</div>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -7,10 +7,10 @@ import { AttachAddon } from "@xterm/addon-attach";
|
|||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
id: string;
|
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);
|
const termRef = useRef(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -33,7 +33,7 @@ export const Terminal: React.FC<Props> = ({ id, userSSH = "root" }) => {
|
|||||||
|
|
||||||
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
|
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 ws = new WebSocket(wsUrl);
|
||||||
const addonAttach = new AttachAddon(ws);
|
const addonAttach = new AttachAddon(ws);
|
||||||
@@ -46,7 +46,7 @@ export const Terminal: React.FC<Props> = ({ id, userSSH = "root" }) => {
|
|||||||
return () => {
|
return () => {
|
||||||
ws.readyState === WebSocket.OPEN && ws.close();
|
ws.readyState === WebSocket.OPEN && ws.close();
|
||||||
};
|
};
|
||||||
}, [id, userSSH]);
|
}, [id, serverId]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
|
|||||||
@@ -74,7 +74,7 @@ export const SettingsLayout = ({ children }: Props) => {
|
|||||||
{
|
{
|
||||||
title: "Cluster",
|
title: "Cluster",
|
||||||
label: "",
|
label: "",
|
||||||
icon: Server,
|
icon: BoxesIcon,
|
||||||
href: "/dashboard/settings/cluster",
|
href: "/dashboard/settings/cluster",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -83,6 +83,12 @@ export const SettingsLayout = ({ children }: Props) => {
|
|||||||
icon: Bell,
|
icon: Bell,
|
||||||
href: "/dashboard/settings/notifications",
|
href: "/dashboard/settings/notifications",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
title: "Servers",
|
||||||
|
label: "",
|
||||||
|
icon: Server,
|
||||||
|
href: "/dashboard/settings/servers",
|
||||||
|
},
|
||||||
]
|
]
|
||||||
: []),
|
: []),
|
||||||
...(user?.canAccessToSSHKeys
|
...(user?.canAccessToSSHKeys
|
||||||
@@ -117,6 +123,7 @@ export const SettingsLayout = ({ children }: Props) => {
|
|||||||
import {
|
import {
|
||||||
Activity,
|
Activity,
|
||||||
Bell,
|
Bell,
|
||||||
|
BoxesIcon,
|
||||||
Database,
|
Database,
|
||||||
GitBranch,
|
GitBranch,
|
||||||
KeyIcon,
|
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,
|
"when": 1725519351871,
|
||||||
"tag": "0036_tired_ronan",
|
"tag": "0036_tired_ronan",
|
||||||
"breakpoints": true
|
"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": "^3.23.4",
|
||||||
"zod-form-data": "^2.0.2",
|
"zod-form-data": "^2.0.2",
|
||||||
"@radix-ui/react-primitive": "2.0.0",
|
"@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": {
|
"devDependencies": {
|
||||||
"@biomejs/biome": "1.8.3",
|
"@biomejs/biome": "1.8.3",
|
||||||
@@ -167,7 +168,8 @@
|
|||||||
"typescript": "^5.4.2",
|
"typescript": "^5.4.2",
|
||||||
"vite-tsconfig-paths": "4.3.2",
|
"vite-tsconfig-paths": "4.3.2",
|
||||||
"vitest": "^1.6.0",
|
"vitest": "^1.6.0",
|
||||||
"xterm-readline": "1.1.1"
|
"xterm-readline": "1.1.1",
|
||||||
|
"@types/ssh2": "1.15.1"
|
||||||
},
|
},
|
||||||
"ct3aMetadata": {
|
"ct3aMetadata": {
|
||||||
"initVersion": "7.25.2"
|
"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 router = useRouter();
|
||||||
const form = useForm<Login>({
|
const form = useForm<Login>({
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
email: "",
|
email: "user1@hotmail.com",
|
||||||
password: "",
|
password: "Password123",
|
||||||
},
|
},
|
||||||
resolver: zodResolver(loginSchema),
|
resolver: zodResolver(loginSchema),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import {
|
|||||||
} from "@/components/ui/form";
|
} from "@/components/ui/form";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { isAdminPresent } from "@/server/api/services/admin";
|
import { isAdminPresent } from "@/server/api/services/admin";
|
||||||
|
import { IS_CLOUD } from "@/server/constants";
|
||||||
import { api } from "@/utils/api";
|
import { api } from "@/utils/api";
|
||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
import { AlertTriangle } from "lucide-react";
|
import { AlertTriangle } from "lucide-react";
|
||||||
@@ -220,6 +221,11 @@ const Register = ({ hasAdmin }: Props) => {
|
|||||||
|
|
||||||
export default Register;
|
export default Register;
|
||||||
export async function getServerSideProps() {
|
export async function getServerSideProps() {
|
||||||
|
if (IS_CLOUD) {
|
||||||
|
return {
|
||||||
|
props: {},
|
||||||
|
};
|
||||||
|
}
|
||||||
const hasAdmin = await isAdminPresent();
|
const hasAdmin = await isAdminPresent();
|
||||||
|
|
||||||
if (hasAdmin) {
|
if (hasAdmin) {
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ import { securityRouter } from "./routers/security";
|
|||||||
import { settingsRouter } from "./routers/settings";
|
import { settingsRouter } from "./routers/settings";
|
||||||
import { sshRouter } from "./routers/ssh-key";
|
import { sshRouter } from "./routers/ssh-key";
|
||||||
import { userRouter } from "./routers/user";
|
import { userRouter } from "./routers/user";
|
||||||
|
import { serverRouter } from "./routers/server";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* This is the primary router for your server.
|
* This is the primary router for your server.
|
||||||
@@ -66,6 +67,7 @@ export const appRouter = createTRPCRouter({
|
|||||||
bitbucket: bitbucketRouter,
|
bitbucket: bitbucketRouter,
|
||||||
gitlab: gitlabRouter,
|
gitlab: gitlabRouter,
|
||||||
github: githubRouter,
|
github: githubRouter,
|
||||||
|
server: serverRouter,
|
||||||
});
|
});
|
||||||
|
|
||||||
// export type definition of API
|
// export type definition of API
|
||||||
|
|||||||
@@ -29,20 +29,23 @@ import {
|
|||||||
protectedProcedure,
|
protectedProcedure,
|
||||||
publicProcedure,
|
publicProcedure,
|
||||||
} from "../trpc";
|
} from "../trpc";
|
||||||
|
import { IS_CLOUD } from "@/server/constants";
|
||||||
|
|
||||||
export const authRouter = createTRPCRouter({
|
export const authRouter = createTRPCRouter({
|
||||||
createAdmin: publicProcedure
|
createAdmin: publicProcedure
|
||||||
.input(apiCreateAdmin)
|
.input(apiCreateAdmin)
|
||||||
.mutation(async ({ ctx, input }) => {
|
.mutation(async ({ ctx, input }) => {
|
||||||
try {
|
try {
|
||||||
const admin = await db.query.admins.findFirst({});
|
if (!IS_CLOUD) {
|
||||||
|
const admin = await db.query.admins.findFirst({});
|
||||||
if (admin) {
|
if (admin) {
|
||||||
throw new TRPCError({
|
throw new TRPCError({
|
||||||
code: "BAD_REQUEST",
|
code: "BAD_REQUEST",
|
||||||
message: "Admin already exists",
|
message: "Admin already exists",
|
||||||
});
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const newAdmin = await createAdmin(input);
|
const newAdmin = await createAdmin(input);
|
||||||
const session = await lucia.createSession(newAdmin.id || "", {});
|
const session = await lucia.createSession(newAdmin.id || "", {});
|
||||||
ctx.res.appendHeader(
|
ctx.res.appendHeader(
|
||||||
@@ -51,6 +54,7 @@ export const authRouter = createTRPCRouter({
|
|||||||
);
|
);
|
||||||
return true;
|
return true;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
console.log(error);
|
||||||
throw new TRPCError({
|
throw new TRPCError({
|
||||||
code: "BAD_REQUEST",
|
code: "BAD_REQUEST",
|
||||||
message: "Error to create the main admin",
|
message: "Error to create the main admin",
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
import {
|
import {
|
||||||
apiFindAllByApplication,
|
apiFindAllByApplication,
|
||||||
apiFindAllByCompose,
|
apiFindAllByCompose,
|
||||||
|
apiFindAllByServer,
|
||||||
} from "@/server/db/schema";
|
} from "@/server/db/schema";
|
||||||
import {
|
import {
|
||||||
findAllDeploymentsByApplicationId,
|
findAllDeploymentsByApplicationId,
|
||||||
findAllDeploymentsByComposeId,
|
findAllDeploymentsByComposeId,
|
||||||
|
findAllDeploymentsByServerId,
|
||||||
} from "../services/deployment";
|
} from "../services/deployment";
|
||||||
import { createTRPCRouter, protectedProcedure } from "../trpc";
|
import { createTRPCRouter, protectedProcedure } from "../trpc";
|
||||||
|
|
||||||
@@ -20,4 +22,9 @@ export const deploymentRouter = createTRPCRouter({
|
|||||||
.query(async ({ input }) => {
|
.query(async ({ input }) => {
|
||||||
return await findAllDeploymentsByComposeId(input.composeId);
|
return await findAllDeploymentsByComposeId(input.composeId);
|
||||||
}),
|
}),
|
||||||
|
allByServer: protectedProcedure
|
||||||
|
.input(apiFindAllByServer)
|
||||||
|
.query(async ({ input }) => {
|
||||||
|
return await findAllDeploymentsByServerId(input.serverId);
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ import {
|
|||||||
checkProjectAccess,
|
checkProjectAccess,
|
||||||
findUserByAuthId,
|
findUserByAuthId,
|
||||||
} from "../services/user";
|
} from "../services/user";
|
||||||
|
import { findAdmin, findAdminByAuthId } from "../services/admin";
|
||||||
|
|
||||||
export const projectRouter = createTRPCRouter({
|
export const projectRouter = createTRPCRouter({
|
||||||
create: protectedProcedure
|
create: protectedProcedure
|
||||||
@@ -43,7 +44,8 @@ export const projectRouter = createTRPCRouter({
|
|||||||
if (ctx.user.rol === "user") {
|
if (ctx.user.rol === "user") {
|
||||||
await checkProjectAccess(ctx.user.authId, "create");
|
await checkProjectAccess(ctx.user.authId, "create");
|
||||||
}
|
}
|
||||||
const project = await createProject(input);
|
|
||||||
|
const project = await createProject(input, ctx.user.adminId);
|
||||||
if (ctx.user.rol === "user") {
|
if (ctx.user.rol === "user") {
|
||||||
await addNewProject(ctx.user.authId, project.projectId);
|
await addNewProject(ctx.user.authId, project.projectId);
|
||||||
}
|
}
|
||||||
@@ -153,6 +155,7 @@ export const projectRouter = createTRPCRouter({
|
|||||||
|
|
||||||
return query;
|
return query;
|
||||||
}
|
}
|
||||||
|
|
||||||
return await db.query.projects.findMany({
|
return await db.query.projects.findMany({
|
||||||
with: {
|
with: {
|
||||||
applications: true,
|
applications: true,
|
||||||
@@ -163,6 +166,7 @@ export const projectRouter = createTRPCRouter({
|
|||||||
redis: true,
|
redis: true,
|
||||||
compose: true,
|
compose: true,
|
||||||
},
|
},
|
||||||
|
where: eq(projects.adminId, ctx.user.adminId),
|
||||||
orderBy: desc(projects.createdAt),
|
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 {
|
import {
|
||||||
type apiCreateDeployment,
|
type apiCreateDeployment,
|
||||||
type apiCreateDeploymentCompose,
|
type apiCreateDeploymentCompose,
|
||||||
|
type apiCreateDeploymentServer,
|
||||||
deployments,
|
deployments,
|
||||||
} from "@/server/db/schema";
|
} from "@/server/db/schema";
|
||||||
import { removeDirectoryIfExistsContent } from "@/server/utils/filesystem/directory";
|
import { removeDirectoryIfExistsContent } from "@/server/utils/filesystem/directory";
|
||||||
@@ -13,6 +14,7 @@ import { format } from "date-fns";
|
|||||||
import { desc, eq } from "drizzle-orm";
|
import { desc, eq } from "drizzle-orm";
|
||||||
import { type Application, findApplicationById } from "./application";
|
import { type Application, findApplicationById } from "./application";
|
||||||
import { type Compose, findComposeById } from "./compose";
|
import { type Compose, findComposeById } from "./compose";
|
||||||
|
import { findServerById, type Server } from "./server";
|
||||||
|
|
||||||
export type Deployment = typeof deployments.$inferSelect;
|
export type Deployment = typeof deployments.$inferSelect;
|
||||||
|
|
||||||
@@ -240,3 +242,81 @@ export const updateDeploymentStatus = async (
|
|||||||
|
|
||||||
return application;
|
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";
|
} from "@/server/db/schema";
|
||||||
import { TRPCError } from "@trpc/server";
|
import { TRPCError } from "@trpc/server";
|
||||||
import { eq } from "drizzle-orm";
|
import { eq } from "drizzle-orm";
|
||||||
import { findAdmin } from "./admin";
|
|
||||||
|
|
||||||
export type Project = typeof projects.$inferSelect;
|
export type Project = typeof projects.$inferSelect;
|
||||||
|
|
||||||
export const createProject = async (input: typeof apiCreateProject._type) => {
|
export const createProject = async (
|
||||||
const admin = await findAdmin();
|
input: typeof apiCreateProject._type,
|
||||||
|
adminId: string,
|
||||||
|
) => {
|
||||||
const newProject = await db
|
const newProject = await db
|
||||||
.insert(projects)
|
.insert(projects)
|
||||||
.values({
|
.values({
|
||||||
...input,
|
...input,
|
||||||
adminId: admin.adminId,
|
adminId: adminId,
|
||||||
})
|
})
|
||||||
.returning()
|
.returning()
|
||||||
.then((value) => value[0]);
|
.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 { ZodError } from "zod";
|
||||||
import { validateRequest } from "../auth/auth";
|
import { validateRequest } from "../auth/auth";
|
||||||
import { validateBearerToken } from "../auth/token";
|
import { validateBearerToken } from "../auth/token";
|
||||||
|
import { findAdminByAuthId } from "./services/admin";
|
||||||
|
import { findUserByAuthId } from "./services/user";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 1. CONTEXT
|
* 1. CONTEXT
|
||||||
@@ -32,7 +34,7 @@ import { validateBearerToken } from "../auth/token";
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
interface CreateContextOptions {
|
interface CreateContextOptions {
|
||||||
user: (User & { authId: string }) | null;
|
user: (User & { authId: string; adminId: string }) | null;
|
||||||
session: Session | null;
|
session: Session | null;
|
||||||
req: CreateNextContextOptions["req"];
|
req: CreateNextContextOptions["req"];
|
||||||
res: CreateNextContextOptions["res"];
|
res: CreateNextContextOptions["res"];
|
||||||
@@ -86,6 +88,7 @@ export const createTRPCContext = async (opts: CreateNextContextOptions) => {
|
|||||||
rol: user.rol,
|
rol: user.rol,
|
||||||
id: user.id,
|
id: user.id,
|
||||||
secret: user.secret,
|
secret: user.secret,
|
||||||
|
adminId: user.adminId,
|
||||||
},
|
},
|
||||||
}) || {
|
}) || {
|
||||||
user: null,
|
user: null,
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ import { Lucia } from "lucia/dist/core.js";
|
|||||||
import type { Session, User } from "lucia/dist/core.js";
|
import type { Session, User } from "lucia/dist/core.js";
|
||||||
import { db } from "../db";
|
import { db } from "../db";
|
||||||
import { type DatabaseUser, auth, sessionTable } from "../db/schema";
|
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;
|
globalThis.crypto = webcrypto as Crypto;
|
||||||
export const adapter = new DrizzlePostgreSQLAdapter(db, sessionTable, auth);
|
export const adapter = new DrizzlePostgreSQLAdapter(db, sessionTable, auth);
|
||||||
@@ -22,6 +24,7 @@ export const lucia = new Lucia(adapter, {
|
|||||||
email: attributes.email,
|
email: attributes.email,
|
||||||
rol: attributes.rol,
|
rol: attributes.rol,
|
||||||
secret: attributes.secret !== null,
|
secret: attributes.secret !== null,
|
||||||
|
adminId: attributes.adminId,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -29,12 +32,15 @@ export const lucia = new Lucia(adapter, {
|
|||||||
declare module "lucia" {
|
declare module "lucia" {
|
||||||
interface Register {
|
interface Register {
|
||||||
Lucia: typeof lucia;
|
Lucia: typeof lucia;
|
||||||
DatabaseUserAttributes: Omit<DatabaseUser, "id"> & { authId: string };
|
DatabaseUserAttributes: Omit<DatabaseUser, "id"> & {
|
||||||
|
authId: string;
|
||||||
|
adminId: string;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ReturnValidateToken = Promise<{
|
export type ReturnValidateToken = Promise<{
|
||||||
user: (User & { authId: string }) | null;
|
user: (User & { authId: string; adminId: string }) | null;
|
||||||
session: Session | null;
|
session: Session | null;
|
||||||
}>;
|
}>;
|
||||||
|
|
||||||
@@ -63,6 +69,17 @@ export async function validateRequest(
|
|||||||
lucia.createBlankSessionCookie().serialize(),
|
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 {
|
return {
|
||||||
session: result.session,
|
session: result.session,
|
||||||
...((result.user && {
|
...((result.user && {
|
||||||
@@ -72,6 +89,7 @@ export async function validateRequest(
|
|||||||
rol: result.user.rol,
|
rol: result.user.rol,
|
||||||
id: result.user.id,
|
id: result.user.id,
|
||||||
secret: result.user.secret,
|
secret: result.user.secret,
|
||||||
|
adminId: result.user.adminId,
|
||||||
},
|
},
|
||||||
}) || {
|
}) || {
|
||||||
user: null,
|
user: null,
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ export const BASE_PATH =
|
|||||||
process.env.NODE_ENV === "production"
|
process.env.NODE_ENV === "production"
|
||||||
? "/etc/dokploy"
|
? "/etc/dokploy"
|
||||||
: path.join(process.cwd(), ".docker");
|
: path.join(process.cwd(), ".docker");
|
||||||
|
export const IS_CLOUD = process.env.IS_CLOUD === "true";
|
||||||
export const MAIN_TRAEFIK_PATH = `${BASE_PATH}/traefik`;
|
export const MAIN_TRAEFIK_PATH = `${BASE_PATH}/traefik`;
|
||||||
export const DYNAMIC_TRAEFIK_PATH = `${BASE_PATH}/traefik/dynamic`;
|
export const DYNAMIC_TRAEFIK_PATH = `${BASE_PATH}/traefik/dynamic`;
|
||||||
export const LOGS_PATH = `${BASE_PATH}/logs`;
|
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 REGISTRY_PATH = `${DYNAMIC_TRAEFIK_PATH}/registry`;
|
||||||
export const MONITORING_PATH = `${BASE_PATH}/monitoring`;
|
export const MONITORING_PATH = `${BASE_PATH}/monitoring`;
|
||||||
export const docker = new Docker();
|
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 { z } from "zod";
|
||||||
import { applications } from "./application";
|
import { applications } from "./application";
|
||||||
import { compose } from "./compose";
|
import { compose } from "./compose";
|
||||||
|
import { server } from "./server";
|
||||||
|
|
||||||
export const deploymentStatus = pgEnum("deploymentStatus", [
|
export const deploymentStatus = pgEnum("deploymentStatus", [
|
||||||
"running",
|
"running",
|
||||||
@@ -28,6 +29,9 @@ export const deployments = pgTable("deployment", {
|
|||||||
composeId: text("composeId").references(() => compose.composeId, {
|
composeId: text("composeId").references(() => compose.composeId, {
|
||||||
onDelete: "cascade",
|
onDelete: "cascade",
|
||||||
}),
|
}),
|
||||||
|
serverId: text("serverId").references(() => server.serverId, {
|
||||||
|
onDelete: "cascade",
|
||||||
|
}),
|
||||||
createdAt: text("createdAt")
|
createdAt: text("createdAt")
|
||||||
.notNull()
|
.notNull()
|
||||||
.$defaultFn(() => new Date().toISOString()),
|
.$defaultFn(() => new Date().toISOString()),
|
||||||
@@ -42,6 +46,10 @@ export const deploymentsRelations = relations(deployments, ({ one }) => ({
|
|||||||
fields: [deployments.composeId],
|
fields: [deployments.composeId],
|
||||||
references: [compose.composeId],
|
references: [compose.composeId],
|
||||||
}),
|
}),
|
||||||
|
server: one(server, {
|
||||||
|
fields: [deployments.serverId],
|
||||||
|
references: [server.serverId],
|
||||||
|
}),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const schema = createInsertSchema(deployments, {
|
const schema = createInsertSchema(deployments, {
|
||||||
@@ -77,6 +85,18 @@ export const apiCreateDeploymentCompose = schema
|
|||||||
composeId: z.string().min(1),
|
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
|
export const apiFindAllByApplication = schema
|
||||||
.pick({
|
.pick({
|
||||||
applicationId: true,
|
applicationId: true,
|
||||||
@@ -94,3 +114,12 @@ export const apiFindAllByCompose = schema
|
|||||||
composeId: z.string().min(1),
|
composeId: z.string().min(1),
|
||||||
})
|
})
|
||||||
.required();
|
.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 "./bitbucket";
|
||||||
export * from "./github";
|
export * from "./github";
|
||||||
export * from "./gitlab";
|
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 { compose } from "@/server/db/schema/compose";
|
||||||
import { sshKeyCreate, sshKeyType } from "@/server/db/validations";
|
import { sshKeyCreate, sshKeyType } from "@/server/db/validations";
|
||||||
import { relations } from "drizzle-orm";
|
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 { createInsertSchema } from "drizzle-zod";
|
||||||
import { nanoid } from "nanoid";
|
import { nanoid } from "nanoid";
|
||||||
|
import { server } from "./server";
|
||||||
|
|
||||||
export const sshKeys = pgTable("ssh-key", {
|
export const sshKeys = pgTable("ssh-key", {
|
||||||
sshKeyId: text("sshKeyId")
|
sshKeyId: text("sshKeyId")
|
||||||
@@ -23,6 +24,7 @@ export const sshKeys = pgTable("ssh-key", {
|
|||||||
export const sshKeysRelations = relations(sshKeys, ({ many }) => ({
|
export const sshKeysRelations = relations(sshKeys, ({ many }) => ({
|
||||||
applications: many(applications),
|
applications: many(applications),
|
||||||
compose: many(compose),
|
compose: many(compose),
|
||||||
|
servers: many(server),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const createSchema = createInsertSchema(
|
const createSchema = createInsertSchema(
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
import { type ConnectionOptions, Queue } from "bullmq";
|
import { type ConnectionOptions, Queue } from "bullmq";
|
||||||
|
|
||||||
export const redisConfig: ConnectionOptions = {
|
export const redisConfig: ConnectionOptions = {
|
||||||
host: process.env.NODE_ENV === "production" ? "dokploy-redis" : "127.0.0.1",
|
host: "31.220.108.27",
|
||||||
port: 6379,
|
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
|
// TODO: maybe add a options to clean the queue to the times
|
||||||
const myQueue = new Queue("deployments", {
|
const myQueue = new Queue("deployments", {
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import * as path from "node:path";
|
|||||||
import { SSH_PATH } from "@/server/constants";
|
import { SSH_PATH } from "@/server/constants";
|
||||||
import { spawnAsync } from "../process/spawnAsync";
|
import { spawnAsync } from "../process/spawnAsync";
|
||||||
|
|
||||||
const readSSHKey = async (id: string) => {
|
export const readSSHKey = async (id: string) => {
|
||||||
try {
|
try {
|
||||||
if (!fs.existsSync(SSH_PATH)) {
|
if (!fs.existsSync(SSH_PATH)) {
|
||||||
fs.mkdirSync(SSH_PATH, { recursive: true });
|
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 type http from "node:http";
|
||||||
import { tmpdir } from "node:os";
|
|
||||||
import { join } from "node:path";
|
|
||||||
import { spawn } from "node-pty";
|
import { spawn } from "node-pty";
|
||||||
import { publicIpv4, publicIpv6 } from "public-ip";
|
import { publicIpv4, publicIpv6 } from "public-ip";
|
||||||
import { WebSocketServer } from "ws";
|
import { WebSocketServer } from "ws";
|
||||||
import { findAdmin } from "../api/services/admin";
|
|
||||||
import { validateWebSocketRequest } from "../auth/auth";
|
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 () => {
|
export const getPublicIpWithFallback = async () => {
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
@@ -50,63 +49,59 @@ export const setupTerminalWebSocketServer = (
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-misused-promises
|
|
||||||
wssTerm.on("connection", async (ws, req) => {
|
wssTerm.on("connection", async (ws, req) => {
|
||||||
const url = new URL(req.url || "", `http://${req.headers.host}`);
|
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);
|
const { user, session } = await validateWebSocketRequest(req);
|
||||||
if (!user || !session) {
|
if (!user || !session || !serverId) {
|
||||||
ws.close();
|
ws.close();
|
||||||
return;
|
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 server = await findServerById(serverId);
|
||||||
const ip =
|
|
||||||
process.env.NODE_ENV === "production"
|
|
||||||
? await getPublicIpWithFallback()
|
|
||||||
: "localhost";
|
|
||||||
|
|
||||||
const sshCommand = [
|
if (!server) {
|
||||||
"ssh",
|
ws.close();
|
||||||
...((process.env.NODE_ENV === "production" && ["-i", tempKeyPath]) ||
|
return;
|
||||||
[]),
|
|
||||||
`${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();
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|||||||
14
pnpm-lock.yaml
generated
14
pnpm-lock.yaml
generated
@@ -380,6 +380,9 @@ importers:
|
|||||||
sonner:
|
sonner:
|
||||||
specifier: ^1.4.0
|
specifier: ^1.4.0
|
||||||
version: 1.5.0(react-dom@18.2.0(react@18.2.0))(react@18.2.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:
|
superjson:
|
||||||
specifier: ^2.2.1
|
specifier: ^2.2.1
|
||||||
version: 2.2.1
|
version: 2.2.1
|
||||||
@@ -459,6 +462,9 @@ importers:
|
|||||||
'@types/react-dom':
|
'@types/react-dom':
|
||||||
specifier: 18.3.0
|
specifier: 18.3.0
|
||||||
version: 18.3.0
|
version: 18.3.0
|
||||||
|
'@types/ssh2':
|
||||||
|
specifier: 1.15.1
|
||||||
|
version: 1.15.1
|
||||||
'@types/swagger-ui-react':
|
'@types/swagger-ui-react':
|
||||||
specifier: ^4.18.3
|
specifier: ^4.18.3
|
||||||
version: 4.18.3
|
version: 4.18.3
|
||||||
@@ -3832,8 +3838,8 @@ packages:
|
|||||||
'@types/serve-static@1.15.7':
|
'@types/serve-static@1.15.7':
|
||||||
resolution: {integrity: sha512-W8Ym+h8nhuRwaKPaDw34QUkwsGi6Rc4yYqvKFo5rm2FUEhCFbzVWrxXUxuKK8TASjWsysJY0nsmNCGhCOIsrOw==}
|
resolution: {integrity: sha512-W8Ym+h8nhuRwaKPaDw34QUkwsGi6Rc4yYqvKFo5rm2FUEhCFbzVWrxXUxuKK8TASjWsysJY0nsmNCGhCOIsrOw==}
|
||||||
|
|
||||||
'@types/ssh2@1.15.0':
|
'@types/ssh2@1.15.1':
|
||||||
resolution: {integrity: sha512-YcT8jP5F8NzWeevWvcyrrLB3zcneVjzYY9ZDSMAMboI+2zR1qYWFhwsyOFVzT7Jorn67vqxC0FRiw8YyG9P1ww==}
|
resolution: {integrity: sha512-ZIbEqKAsi5gj35y4P4vkJYly642wIbY6PqoN0xiyQGshKUGXR9WQjF/iF9mXBQ8uBKy3ezfsCkcoHKhd0BzuDA==}
|
||||||
|
|
||||||
'@types/swagger-ui-react@4.18.3':
|
'@types/swagger-ui-react@4.18.3':
|
||||||
resolution: {integrity: sha512-Mo/R7IjDVwtiFPs84pWvh5pI9iyNGBjmfielxqbOh2Jv+8WVSDVe8Nu25kb5BOuV2xmGS3o33jr6nwDJMBcX+Q==}
|
resolution: {integrity: sha512-Mo/R7IjDVwtiFPs84pWvh5pI9iyNGBjmfielxqbOh2Jv+8WVSDVe8Nu25kb5BOuV2xmGS3o33jr6nwDJMBcX+Q==}
|
||||||
@@ -12449,7 +12455,7 @@ snapshots:
|
|||||||
'@types/docker-modem@3.0.6':
|
'@types/docker-modem@3.0.6':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@types/node': 20.14.10
|
'@types/node': 20.14.10
|
||||||
'@types/ssh2': 1.15.0
|
'@types/ssh2': 1.15.1
|
||||||
|
|
||||||
'@types/dockerode@3.3.23':
|
'@types/dockerode@3.3.23':
|
||||||
dependencies:
|
dependencies:
|
||||||
@@ -12579,7 +12585,7 @@ snapshots:
|
|||||||
'@types/node': 20.14.10
|
'@types/node': 20.14.10
|
||||||
'@types/send': 0.17.4
|
'@types/send': 0.17.4
|
||||||
|
|
||||||
'@types/ssh2@1.15.0':
|
'@types/ssh2@1.15.1':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@types/node': 18.19.42
|
'@types/node': 18.19.42
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user