Merge branch 'canary' into new-logs

This commit is contained in:
Mauricio Siu
2024-12-15 19:01:26 -06:00
120 changed files with 12687 additions and 1248 deletions

1
.gitignore vendored
View File

@@ -34,7 +34,6 @@ yarn-debug.log*
yarn-error.log*
# Editor
.vscode
.idea
# Misc

View File

@@ -1,3 +1,4 @@
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Dialog,
@@ -19,7 +20,7 @@ import {
import { Input } from "@/components/ui/input";
import { api } from "@/utils/api";
import { zodResolver } from "@hookform/resolvers/zod";
import { TrashIcon } from "lucide-react";
import { Copy, TrashIcon } from "lucide-react";
import { useRouter } from "next/router";
import { useState } from "react";
import { useForm } from "react-hook-form";
@@ -102,9 +103,26 @@ export const DeleteApplication = ({ applicationId }: Props) => {
name="projectName"
render={({ field }) => (
<FormItem>
<FormLabel>
To confirm, type "{data?.name}/{data?.appName}" in the box
below
<FormLabel className="flex items-center gap-2">
<span>
To confirm, type{" "}
<Badge
className="p-2 rounded-md ml-1 mr-1 hover:border-primary hover:text-primary-foreground hover:bg-primary hover:cursor-pointer"
variant="outline"
onClick={() => {
if (data?.name && data?.appName) {
navigator.clipboard.writeText(
`${data.name}/${data.appName}`,
);
toast.success("Copied to clipboard. Be careful!");
}
}}
>
{data?.name}/{data?.appName}&nbsp;
<Copy className="h-4 w-4 ml-1 text-muted-foreground" />
</Badge>{" "}
in the box below:
</span>
</FormLabel>
<FormControl>
<Input

View File

@@ -11,6 +11,7 @@ import {
} from "@/components/ui/alert-dialog";
import { Button } from "@/components/ui/button";
import { api } from "@/utils/api";
import { useRouter } from "next/router";
import { toast } from "sonner";
interface Props {
@@ -18,6 +19,7 @@ interface Props {
}
export const DeployApplication = ({ applicationId }: Props) => {
const router = useRouter();
const { data, refetch } = api.application.one.useQuery(
{
applicationId,
@@ -51,6 +53,9 @@ export const DeployApplication = ({ applicationId }: Props) => {
.then(async () => {
toast.success("Application deployed succesfully");
await refetch();
router.push(
`/dashboard/project/${data?.projectId}/services/application/${applicationId}?tab=deployments`,
);
})
.catch(() => {

View File

@@ -1,3 +1,4 @@
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Dialog,
@@ -19,6 +20,7 @@ import {
import { Input } from "@/components/ui/input";
import { api } from "@/utils/api";
import { zodResolver } from "@hookform/resolvers/zod";
import { Copy } from "lucide-react";
import { TrashIcon } from "lucide-react";
import { useRouter } from "next/router";
import { useState } from "react";
@@ -100,10 +102,27 @@ export const DeleteCompose = ({ composeId }: Props) => {
name="projectName"
render={({ field }) => (
<FormItem>
<FormLabel>
To confirm, type "{data?.name}/{data?.appName}" in the box
below
</FormLabel>{" "}
<FormLabel className="flex items-center gap-2">
<span>
To confirm, type{" "}
<Badge
className="p-2 rounded-md ml-1 mr-1 hover:border-primary hover:text-primary-foreground hover:bg-primary hover:cursor-pointer"
variant="outline"
onClick={() => {
if (data?.name && data?.appName) {
navigator.clipboard.writeText(
`${data.name}/${data.appName}`,
);
toast.success("Copied to clipboard. Be careful!");
}
}}
>
{data?.name}/{data?.appName}&nbsp;
<Copy className="h-4 w-4 ml-1 text-muted-foreground" />
</Badge>{" "}
in the box below:
</span>
</FormLabel>
<FormControl>
<Input
placeholder="Enter compose name to confirm"

View File

@@ -11,6 +11,7 @@ import {
} from "@/components/ui/alert-dialog";
import { Button } from "@/components/ui/button";
import { api } from "@/utils/api";
import { useRouter } from "next/router";
import { toast } from "sonner";
interface Props {
@@ -18,6 +19,7 @@ interface Props {
}
export const DeployCompose = ({ composeId }: Props) => {
const router = useRouter();
const { data, refetch } = api.compose.one.useQuery(
{
composeId,
@@ -48,9 +50,15 @@ export const DeployCompose = ({ composeId }: Props) => {
await refetch();
await deploy({
composeId,
}).catch(() => {
toast.error("Error to deploy Compose");
});
})
.then(async () => {
router.push(
`/dashboard/project/${data?.project.projectId}/services/compose/${composeId}?tab=deployments`
);
})
.catch(() => {
toast.error("Error to deploy Compose");
});
await refetch();
}}

View File

@@ -1,3 +1,4 @@
import { CodeEditor } from "@/components/shared/code-editor";
import {
Dialog,
DialogContent,
@@ -34,7 +35,7 @@ export const ShowContainerConfig = ({ containerId, serverId }: Props) => {
View Config
</DropdownMenuItem>
</DialogTrigger>
<DialogContent className={"w-full md:w-[70vw] max-w-max"}>
<DialogContent className={"w-full md:w-[70vw] min-w-[70vw]"}>
<DialogHeader>
<DialogTitle>Container Config</DialogTitle>
<DialogDescription>
@@ -44,7 +45,13 @@ export const ShowContainerConfig = ({ containerId, serverId }: Props) => {
<div className="text-wrap rounded-lg border p-4 text-sm bg-card overflow-y-auto max-h-[80vh]">
<code>
<pre className="whitespace-pre-wrap break-words">
{JSON.stringify(data, null, 2)}
<CodeEditor
language="json"
lineWrapping
lineNumbers={false}
readOnly
value={JSON.stringify(data, null, 2)}
/>
</pre>
</code>
</div>

View File

@@ -25,8 +25,6 @@ export const DockerTerminal: React.FC<Props> = ({
}
const term = new Terminal({
cursorBlink: true,
cols: 80,
rows: 30,
lineHeight: 1.4,
convertEol: true,
theme: {
@@ -45,6 +43,7 @@ export const DockerTerminal: React.FC<Props> = ({
const addonAttach = new AttachAddon(ws);
// @ts-ignore
term.open(termRef.current);
// @ts-ignore
term.loadAddon(addonFit);
term.loadAddon(addonAttach);
addonFit.fit();
@@ -66,7 +65,7 @@ export const DockerTerminal: React.FC<Props> = ({
</TabsList>
</Tabs>
</div>
<div className="w-full h-full rounded-lg p-2 bg-[#19191A]">
<div className="w-full h-full rounded-lg p-2 bg-transparent border">
<div id={id} ref={termRef} />
</div>
</div>

View File

@@ -1,3 +1,4 @@
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Dialog,
@@ -19,7 +20,7 @@ import {
import { Input } from "@/components/ui/input";
import { api } from "@/utils/api";
import { zodResolver } from "@hookform/resolvers/zod";
import { TrashIcon } from "lucide-react";
import { Copy, TrashIcon } from "lucide-react";
import { useRouter } from "next/router";
import { useState } from "react";
import { useForm } from "react-hook-form";
@@ -99,9 +100,26 @@ export const DeleteMariadb = ({ mariadbId }: Props) => {
name="projectName"
render={({ field }) => (
<FormItem>
<FormLabel>
To confirm, type "{data?.name}/{data?.appName}" in the box
below
<FormLabel className="flex items-center gap-2">
<span>
To confirm, type{" "}
<Badge
className="p-2 rounded-md ml-1 mr-1 hover:border-primary hover:text-primary-foreground hover:bg-primary hover:cursor-pointer"
variant="outline"
onClick={() => {
if (data?.name && data?.appName) {
navigator.clipboard.writeText(
`${data.name}/${data.appName}`,
);
toast.success("Copied to clipboard. Be careful!");
}
}}
>
{data?.name}/{data?.appName}&nbsp;
<Copy className="h-4 w-4 ml-1 text-muted-foreground" />
</Badge>{" "}
in the box below:
</span>
</FormLabel>
<FormControl>
<Input

View File

@@ -1,3 +1,4 @@
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Dialog,
@@ -19,7 +20,7 @@ import {
import { Input } from "@/components/ui/input";
import { api } from "@/utils/api";
import { zodResolver } from "@hookform/resolvers/zod";
import { TrashIcon } from "lucide-react";
import { Copy, TrashIcon } from "lucide-react";
import { useRouter } from "next/router";
import { useState } from "react";
import { useForm } from "react-hook-form";
@@ -98,9 +99,26 @@ export const DeleteMongo = ({ mongoId }: Props) => {
name="projectName"
render={({ field }) => (
<FormItem>
<FormLabel>
To confirm, type "{data?.name}/{data?.appName}" in the box
below
<FormLabel className="flex items-center gap-2">
<span>
To confirm, type{" "}
<Badge
className="p-2 rounded-md ml-1 mr-1 hover:border-primary hover:text-primary-foreground hover:bg-primary hover:cursor-pointer"
variant="outline"
onClick={() => {
if (data?.name && data?.appName) {
navigator.clipboard.writeText(
`${data.name}/${data.appName}`,
);
toast.success("Copied to clipboard. Be careful!");
}
}}
>
{data?.name}/{data?.appName}&nbsp;
<Copy className="h-4 w-4 ml-1 text-muted-foreground" />
</Badge>{" "}
in the box below:
</span>
</FormLabel>
<FormControl>
<Input

View File

@@ -1,3 +1,4 @@
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Dialog,
@@ -19,7 +20,7 @@ import {
import { Input } from "@/components/ui/input";
import { api } from "@/utils/api";
import { zodResolver } from "@hookform/resolvers/zod";
import { TrashIcon } from "lucide-react";
import { Copy, TrashIcon } from "lucide-react";
import { useRouter } from "next/router";
import { useState } from "react";
import { useForm } from "react-hook-form";
@@ -97,9 +98,26 @@ export const DeleteMysql = ({ mysqlId }: Props) => {
name="projectName"
render={({ field }) => (
<FormItem>
<FormLabel>
To confirm, type "{data?.name}/{data?.appName}" in the box
below
<FormLabel className="flex items-center gap-2">
<span>
To confirm, type{" "}
<Badge
className="p-2 rounded-md ml-1 mr-1 hover:border-primary hover:text-primary-foreground hover:bg-primary hover:cursor-pointer"
variant="outline"
onClick={() => {
if (data?.name && data?.appName) {
navigator.clipboard.writeText(
`${data.name}/${data.appName}`,
);
toast.success("Copied to clipboard. Be careful!");
}
}}
>
{data?.name}/{data?.appName}&nbsp;
<Copy className="h-4 w-4 ml-1 text-muted-foreground" />
</Badge>{" "}
in the box below:
</span>
</FormLabel>
<FormControl>
<Input

View File

@@ -1,3 +1,4 @@
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Dialog,
@@ -19,7 +20,7 @@ import {
import { Input } from "@/components/ui/input";
import { api } from "@/utils/api";
import { zodResolver } from "@hookform/resolvers/zod";
import { TrashIcon } from "lucide-react";
import { Copy, TrashIcon } from "lucide-react";
import { useRouter } from "next/router";
import { useState } from "react";
import { useForm } from "react-hook-form";
@@ -100,9 +101,26 @@ export const DeletePostgres = ({ postgresId }: Props) => {
name="projectName"
render={({ field }) => (
<FormItem>
<FormLabel>
To confirm, type "{data?.name}/{data?.appName}" in the box
below
<FormLabel className="flex items-center gap-2">
<span>
To confirm, type{" "}
<Badge
className="p-2 rounded-md ml-1 mr-1 hover:border-primary hover:text-primary-foreground hover:bg-primary hover:cursor-pointer"
variant="outline"
onClick={() => {
if (data?.name && data?.appName) {
navigator.clipboard.writeText(
`${data.name}/${data.appName}`,
);
toast.success("Copied to clipboard. Be careful!");
}
}}
>
{data?.name}/{data?.appName}&nbsp;
<Copy className="h-4 w-4 ml-1 text-muted-foreground" />
</Badge>{" "}
in the box below:
</span>
</FormLabel>
<FormControl>
<Input

View File

@@ -92,7 +92,8 @@ export const AddTemplate = ({ projectId }: Props) => {
template.tags.some((tag) => selectedTags.includes(tag));
const matchesQuery =
query === "" ||
template.name.toLowerCase().includes(query.toLowerCase());
template.name.toLowerCase().includes(query.toLowerCase()) ||
template.description.toLowerCase().includes(query.toLowerCase());
return matchesTags && matchesQuery;
}) || [];

View File

@@ -1,35 +1,35 @@
import { DateTooltip } from "@/components/shared/date-tooltip";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "@/components/ui/alert-dialog";
import { Button } from "@/components/ui/button";
import { Card, CardFooter, CardHeader, CardTitle } from "@/components/ui/card";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { api } from "@/utils/api";
import {
AlertTriangle,
BookIcon,
ExternalLink,
ExternalLinkIcon,
FolderInput,
MoreHorizontalIcon,
TrashIcon,
AlertTriangle,
BookIcon,
ExternalLink,
ExternalLinkIcon,
FolderInput,
MoreHorizontalIcon,
TrashIcon,
} from "lucide-react";
import Link from "next/link";
import { Fragment } from "react";
@@ -38,253 +38,257 @@ import { ProjectEnviroment } from "./project-enviroment";
import { UpdateProject } from "./update";
export const ShowProjects = () => {
const utils = api.useUtils();
const { data } = api.project.all.useQuery();
const { data: auth } = api.auth.get.useQuery();
const { data: user } = api.user.byAuthId.useQuery(
{
authId: auth?.id || "",
},
{
enabled: !!auth?.id && auth?.rol === "user",
},
);
const { mutateAsync } = api.project.remove.useMutation();
const utils = api.useUtils();
const { data } = api.project.all.useQuery();
const { data: auth } = api.auth.get.useQuery();
const { data: user } = api.user.byAuthId.useQuery(
{
authId: auth?.id || "",
},
{
enabled: !!auth?.id && auth?.rol === "user",
}
);
const { mutateAsync } = api.project.remove.useMutation();
return (
<>
{data?.length === 0 && (
<div className="mt-6 flex h-[50vh] w-full flex-col items-center justify-center space-y-4">
<FolderInput className="size-10 md:size-28 text-muted-foreground" />
<span className="text-center font-medium text-muted-foreground">
No projects added yet. Click on Create project.
</span>
</div>
)}
<div className="mt-6 w-full grid sm:grid-cols-2 lg:grid-cols-3 flex-wrap gap-5 pb-10">
{data?.map((project) => {
const emptyServices =
project?.mariadb.length === 0 &&
project?.mongo.length === 0 &&
project?.mysql.length === 0 &&
project?.postgres.length === 0 &&
project?.redis.length === 0 &&
project?.applications.length === 0 &&
project?.compose.length === 0;
return (
<>
{data?.length === 0 && (
<div className="mt-6 flex h-[50vh] w-full flex-col items-center justify-center space-y-4">
<FolderInput className="size-10 md:size-28 text-muted-foreground" />
<span className="text-center font-medium text-muted-foreground">
No projects added yet. Click on Create project.
</span>
</div>
)}
<div className="mt-6 w-full grid sm:grid-cols-2 lg:grid-cols-3 flex-wrap gap-5 pb-10">
{data?.map((project) => {
const emptyServices =
project?.mariadb.length === 0 &&
project?.mongo.length === 0 &&
project?.mysql.length === 0 &&
project?.postgres.length === 0 &&
project?.redis.length === 0 &&
project?.applications.length === 0 &&
project?.compose.length === 0;
const totalServices =
project?.mariadb.length +
project?.mongo.length +
project?.mysql.length +
project?.postgres.length +
project?.redis.length +
project?.applications.length +
project?.compose.length;
const totalServices =
project?.mariadb.length +
project?.mongo.length +
project?.mysql.length +
project?.postgres.length +
project?.redis.length +
project?.applications.length +
project?.compose.length;
const flattedDomains = [
...project.applications.flatMap((a) => a.domains),
...project.compose.flatMap((a) => a.domains),
];
const flattedDomains = [
...project.applications.flatMap((a) => a.domains),
...project.compose.flatMap((a) => a.domains),
];
const renderDomainsDropdown = (
item: typeof project.compose | typeof project.applications,
) =>
item[0] ? (
<DropdownMenuGroup>
<DropdownMenuLabel>
{"applicationId" in item[0] ? "Applications" : "Compose"}
</DropdownMenuLabel>
{item.map((a) => (
<Fragment
key={"applicationId" in a ? a.applicationId : a.composeId}
>
<DropdownMenuSeparator />
<DropdownMenuGroup>
<DropdownMenuLabel className="font-normal capitalize text-xs ">
{a.name}
</DropdownMenuLabel>
<DropdownMenuSeparator />
{a.domains.map((domain) => (
<DropdownMenuItem key={domain.domainId} asChild>
<Link
className="space-x-4 text-xs cursor-pointer justify-between"
target="_blank"
href={`${domain.https ? "https" : "http"}://${domain.host}${domain.path}`}
>
<span>{domain.host}</span>
<ExternalLink className="size-4 shrink-0" />
</Link>
</DropdownMenuItem>
))}
</DropdownMenuGroup>
</Fragment>
))}
</DropdownMenuGroup>
) : null;
const renderDomainsDropdown = (
item: typeof project.compose | typeof project.applications
) =>
item[0] ? (
<DropdownMenuGroup>
<DropdownMenuLabel>
{"applicationId" in item[0] ? "Applications" : "Compose"}
</DropdownMenuLabel>
{item.map((a) => (
<Fragment
key={"applicationId" in a ? a.applicationId : a.composeId}
>
<DropdownMenuSeparator />
<DropdownMenuGroup>
<DropdownMenuLabel className="font-normal capitalize text-xs ">
{a.name}
</DropdownMenuLabel>
<DropdownMenuSeparator />
{a.domains.map((domain) => (
<DropdownMenuItem key={domain.domainId} asChild>
<Link
className="space-x-4 text-xs cursor-pointer justify-between"
target="_blank"
href={`${domain.https ? "https" : "http"}://${
domain.host
}${domain.path}`}
>
<span>{domain.host}</span>
<ExternalLink className="size-4 shrink-0" />
</Link>
</DropdownMenuItem>
))}
</DropdownMenuGroup>
</Fragment>
))}
</DropdownMenuGroup>
) : null;
return (
<div key={project.projectId} className="w-full lg:max-w-md">
<Link href={`/dashboard/project/${project.projectId}`}>
<Card className="group relative w-full bg-transparent transition-colors hover:bg-card">
{flattedDomains.length > 1 ? (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
className="absolute -right-3 -top-3 size-9 translate-y-1 rounded-full p-0 opacity-0 transition-all duration-200 group-hover:translate-y-0 group-hover:opacity-100"
size="sm"
variant="default"
>
<ExternalLinkIcon className="size-3.5" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
className="w-[200px] space-y-2"
onClick={(e) => e.stopPropagation()}
>
{renderDomainsDropdown(project.applications)}
{renderDomainsDropdown(project.compose)}
</DropdownMenuContent>
</DropdownMenu>
) : flattedDomains[0] ? (
<Button
className="absolute -right-3 -top-3 size-9 translate-y-1 rounded-full p-0 opacity-0 transition-all duration-200 group-hover:translate-y-0 group-hover:opacity-100"
size="sm"
variant="default"
onClick={(e) => e.stopPropagation()}
>
<Link
href={`${flattedDomains[0].https ? "https" : "http"}://${flattedDomains[0].host}${flattedDomains[0].path}`}
target="_blank"
>
<ExternalLinkIcon className="size-3.5" />
</Link>
</Button>
) : null}
return (
<div key={project.projectId} className="w-full lg:max-w-md">
<Link href={`/dashboard/project/${project.projectId}`}>
<Card className="group relative w-full bg-transparent transition-colors hover:bg-card">
{flattedDomains.length > 1 ? (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
className="absolute -right-3 -top-3 size-9 translate-y-1 rounded-full p-0 opacity-0 transition-all duration-200 group-hover:translate-y-0 group-hover:opacity-100"
size="sm"
variant="default"
>
<ExternalLinkIcon className="size-3.5" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
className="w-[200px] space-y-2"
onClick={(e) => e.stopPropagation()}
>
{renderDomainsDropdown(project.applications)}
{renderDomainsDropdown(project.compose)}
</DropdownMenuContent>
</DropdownMenu>
) : flattedDomains[0] ? (
<Button
className="absolute -right-3 -top-3 size-9 translate-y-1 rounded-full p-0 opacity-0 transition-all duration-200 group-hover:translate-y-0 group-hover:opacity-100"
size="sm"
variant="default"
onClick={(e) => e.stopPropagation()}
>
<Link
href={`${
flattedDomains[0].https ? "https" : "http"
}://${flattedDomains[0].host}${flattedDomains[0].path}`}
target="_blank"
>
<ExternalLinkIcon className="size-3.5" />
</Link>
</Button>
) : null}
<CardHeader>
<CardTitle className="flex items-center justify-between gap-2">
<span className="flex flex-col gap-1.5">
<div className="flex items-center gap-2">
<BookIcon className="size-4 text-muted-foreground" />
<span className="text-base font-medium leading-none">
{project.name}
</span>
</div>
<CardHeader>
<CardTitle className="flex items-center justify-between gap-2">
<span className="flex flex-col gap-1.5">
<div className="flex items-center gap-2">
<BookIcon className="size-4 text-muted-foreground" />
<span className="text-base font-medium leading-none">
{project.name}
</span>
</div>
<span className="text-sm font-medium text-muted-foreground">
{project.description}
</span>
</span>
<div className="flex self-start space-x-1">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon"
className="px-2"
>
<MoreHorizontalIcon className="size-5" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent className="w-[200px] space-y-2">
<DropdownMenuLabel className="font-normal">
Actions
</DropdownMenuLabel>
<div onClick={(e) => e.stopPropagation()}>
<ProjectEnviroment
projectId={project.projectId}
/>
</div>
<div onClick={(e) => e.stopPropagation()}>
<UpdateProject projectId={project.projectId} />
</div>
<span className="text-sm font-medium text-muted-foreground">
{project.description}
</span>
</span>
<div className="flex self-start space-x-1">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon"
className="px-2"
>
<MoreHorizontalIcon className="size-5" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent className="w-[200px] space-y-2">
<DropdownMenuLabel className="font-normal">
Actions
</DropdownMenuLabel>
<div onClick={(e) => e.stopPropagation()}>
<ProjectEnviroment
projectId={project.projectId}
/>
</div>
<div onClick={(e) => e.stopPropagation()}>
<UpdateProject projectId={project.projectId} />
</div>
<div onClick={(e) => e.stopPropagation()}>
{(auth?.rol === "admin" ||
user?.canDeleteProjects) && (
<AlertDialog>
<AlertDialogTrigger className="w-full">
<DropdownMenuItem
className="w-full cursor-pointer space-x-3"
onSelect={(e) => e.preventDefault()}
>
<TrashIcon className="size-4" />
<span>Delete</span>
</DropdownMenuItem>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
Are you sure to delete this project?
</AlertDialogTitle>
{!emptyServices ? (
<div className="flex flex-row gap-4 rounded-lg bg-yellow-50 p-2 dark:bg-yellow-950">
<AlertTriangle className="text-yellow-600 dark:text-yellow-400" />
<span className="text-sm text-yellow-600 dark:text-yellow-400">
You have active services, please
delete them first
</span>
</div>
) : (
<AlertDialogDescription>
This action cannot be undone
</AlertDialogDescription>
)}
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>
Cancel
</AlertDialogCancel>
<AlertDialogAction
disabled={!emptyServices}
onClick={async () => {
await mutateAsync({
projectId: project.projectId,
})
.then(() => {
toast.success(
"Project delete succesfully",
);
})
.catch(() => {
toast.error(
"Error to delete this project",
);
})
.finally(() => {
utils.project.all.invalidate();
});
}}
>
Delete
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
)}
</div>
</DropdownMenuContent>
</DropdownMenu>
</div>
</CardTitle>
</CardHeader>
<CardFooter className="pt-4">
<div className="space-y-1 text-sm flex flex-row justify-between max-sm:flex-wrap w-full gap-2 sm:gap-4">
<DateTooltip date={project.createdAt}>
Created
</DateTooltip>
<span>
{totalServices}{" "}
{totalServices === 1 ? "service" : "services"}
</span>
</div>
</CardFooter>
</Card>
</Link>
</div>
);
})}
</div>
</>
);
<div onClick={(e) => e.stopPropagation()}>
{(auth?.rol === "admin" ||
user?.canDeleteProjects) && (
<AlertDialog>
<AlertDialogTrigger className="w-full">
<DropdownMenuItem
className="w-full cursor-pointer space-x-3"
onSelect={(e) => e.preventDefault()}
>
<TrashIcon className="size-4" />
<span>Delete</span>
</DropdownMenuItem>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
Are you sure to delete this project?
</AlertDialogTitle>
{!emptyServices ? (
<div className="flex flex-row gap-4 rounded-lg bg-yellow-50 p-2 dark:bg-yellow-950">
<AlertTriangle className="text-yellow-600 dark:text-yellow-400" />
<span className="text-sm text-yellow-600 dark:text-yellow-400">
You have active services, please
delete them first
</span>
</div>
) : (
<AlertDialogDescription>
This action cannot be undone
</AlertDialogDescription>
)}
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>
Cancel
</AlertDialogCancel>
<AlertDialogAction
disabled={!emptyServices}
onClick={async () => {
await mutateAsync({
projectId: project.projectId,
})
.then(() => {
toast.success(
"Project delete succesfully"
);
})
.catch(() => {
toast.error(
"Error to delete this project"
);
})
.finally(() => {
utils.project.all.invalidate();
});
}}
>
Delete
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
)}
</div>
</DropdownMenuContent>
</DropdownMenu>
</div>
</CardTitle>
</CardHeader>
<CardFooter className="pt-4">
<div className="space-y-1 text-sm flex flex-row justify-between max-sm:flex-wrap w-full gap-2 sm:gap-4">
<DateTooltip date={project.createdAt}>
Created
</DateTooltip>
<span>
{totalServices}{" "}
{totalServices === 1 ? "service" : "services"}
</span>
</div>
</CardFooter>
</Card>
</Link>
</div>
);
})}
</div>
</>
);
};

View File

@@ -1,3 +1,4 @@
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Dialog,
@@ -19,7 +20,7 @@ import {
import { Input } from "@/components/ui/input";
import { api } from "@/utils/api";
import { zodResolver } from "@hookform/resolvers/zod";
import { TrashIcon } from "lucide-react";
import { Copy, TrashIcon } from "lucide-react";
import { useRouter } from "next/router";
import { useState } from "react";
import { useForm } from "react-hook-form";
@@ -97,9 +98,26 @@ export const DeleteRedis = ({ redisId }: Props) => {
name="projectName"
render={({ field }) => (
<FormItem>
<FormLabel>
To confirm, type "{data?.name}/{data?.appName}" in the box
below
<FormLabel className="flex items-center gap-2">
<span>
To confirm, type{" "}
<Badge
className="p-2 rounded-md ml-1 mr-1 hover:border-primary hover:text-primary-foreground hover:bg-primary hover:cursor-pointer"
variant="outline"
onClick={() => {
if (data?.name && data?.appName) {
navigator.clipboard.writeText(
`${data.name}/${data.appName}`,
);
toast.success("Copied to clipboard. Be careful!");
}
}}
>
{data?.name}/{data?.appName}&nbsp;
<Copy className="h-4 w-4 ml-1 text-muted-foreground" />
</Badge>{" "}
in the box below:
</span>
</FormLabel>
<FormControl>
<Input

View File

@@ -0,0 +1,189 @@
"use client";
import React from "react";
import {
Command,
CommandEmpty,
CommandList,
CommandGroup,
CommandInput,
CommandItem,
CommandDialog,
CommandSeparator,
} from "@/components/ui/command";
import { useRouter } from "next/router";
import {
extractServices,
type Services,
} from "@/pages/dashboard/project/[projectId]";
import type { findProjectById } from "@dokploy/server/services/project";
import { BookIcon, CircuitBoard, GlobeIcon } from "lucide-react";
import {
MariadbIcon,
MongodbIcon,
MysqlIcon,
PostgresqlIcon,
RedisIcon,
} from "@/components/icons/data-tools-icons";
import { api } from "@/utils/api";
import { Badge } from "@/components/ui/badge";
import { StatusTooltip } from "../shared/status-tooltip";
type Project = Awaited<ReturnType<typeof findProjectById>>;
export const SearchCommand = () => {
const router = useRouter();
const [open, setOpen] = React.useState(false);
const [search, setSearch] = React.useState("");
const { data } = api.project.all.useQuery();
const { data: isCloud, isLoading } = api.settings.isCloud.useQuery();
React.useEffect(() => {
const down = (e: KeyboardEvent) => {
if (e.key === "j" && (e.metaKey || e.ctrlKey)) {
e.preventDefault();
setOpen((open) => !open);
}
};
document.addEventListener("keydown", down);
return () => document.removeEventListener("keydown", down);
}, []);
return (
<div>
<CommandDialog open={open} onOpenChange={setOpen}>
<CommandInput
placeholder={"Search projects or settings"}
value={search}
onValueChange={setSearch}
/>
<CommandList>
<CommandEmpty>
No projects added yet. Click on Create project.
</CommandEmpty>
<CommandGroup heading={"Projects"}>
<CommandList>
{data?.map((project) => (
<CommandItem
key={project.projectId}
onSelect={() => {
router.push(`/dashboard/project/${project.projectId}`);
setOpen(false);
}}
>
<BookIcon className="size-4 text-muted-foreground mr-2" />
{project.name}
</CommandItem>
))}
</CommandList>
</CommandGroup>
<CommandSeparator />
<CommandGroup heading={"Services"}>
<CommandList>
{data?.map((project) => {
const applications: Services[] = extractServices(project);
return applications.map((application) => (
<CommandItem
key={application.id}
onSelect={() => {
router.push(
`/dashboard/project/${project.projectId}/services/${application.type}/${application.id}`
);
setOpen(false);
}}
>
{application.type === "postgres" && (
<PostgresqlIcon className="h-6 w-6 mr-2" />
)}
{application.type === "redis" && (
<RedisIcon className="h-6 w-6 mr-2" />
)}
{application.type === "mariadb" && (
<MariadbIcon className="h-6 w-6 mr-2" />
)}
{application.type === "mongo" && (
<MongodbIcon className="h-6 w-6 mr-2" />
)}
{application.type === "mysql" && (
<MysqlIcon className="h-6 w-6 mr-2" />
)}
{application.type === "application" && (
<GlobeIcon className="h-6 w-6 mr-2" />
)}
{application.type === "compose" && (
<CircuitBoard className="h-6 w-6 mr-2" />
)}
<span className="flex-grow">
{project.name} / {application.name}{" "}
<div style={{ display: "none" }}>{application.id}</div>
</span>
<div>
<StatusTooltip status={application.status} />
</div>
</CommandItem>
));
})}
</CommandList>
</CommandGroup>
<CommandSeparator />
<CommandGroup heading={"Application"} hidden={true}>
<CommandItem
onSelect={() => {
router.push("/dashboard/projects");
setOpen(false);
}}
>
Projects
</CommandItem>
{!isCloud && (
<>
<CommandItem
onSelect={() => {
router.push("/dashboard/monitoring");
setOpen(false);
}}
>
Monitoring
</CommandItem>
<CommandItem
onSelect={() => {
router.push("/dashboard/traefik");
setOpen(false);
}}
>
Traefik
</CommandItem>
<CommandItem
onSelect={() => {
router.push("/dashboard/docker");
setOpen(false);
}}
>
Docker
</CommandItem>
<CommandItem
onSelect={() => {
router.push("/dashboard/requests");
setOpen(false);
}}
>
Requests
</CommandItem>
</>
)}
<CommandItem
onSelect={() => {
router.push("/dashboard/settings/server");
setOpen(false);
}}
>
Settings
</CommandItem>
</CommandGroup>
</CommandList>
</CommandDialog>
</div>
);
};

View File

@@ -107,7 +107,24 @@ export const AddGithubProvider = () => {
/>
<br />
<div className="flex w-full justify-end">
<div className="flex w-full items-center justify-between">
<a
href={
isOrganization && organizationName
? `https://github.com/organizations/${organizationName}/settings/installations`
: "https://github.com/settings/installations"
}
className={`text-muted-foreground text-sm hover:underline duration-300
${
isOrganization && !organizationName
? "pointer-events-none opacity-50"
: ""
}`}
target="_blank"
rel="noopener noreferrer"
>
Unsure if you already have an app?
</a>
<Button
disabled={isOrganization && organizationName.length < 1}
type="submit"

View File

@@ -33,6 +33,9 @@ const Schema = z.object({
name: z.string().min(1, {
message: "Name is required",
}),
gitlabUrl: z.string().min(1, {
message: "GitLab URL is required",
}),
applicationId: z.string().min(1, {
message: "Application ID is required",
}),
@@ -62,16 +65,22 @@ export const AddGitlabProvider = () => {
applicationSecret: "",
groupName: "",
redirectUri: webhookUrl,
name: "",
gitlabUrl: "https://gitlab.com",
},
resolver: zodResolver(Schema),
});
const gitlabUrl = form.watch("gitlabUrl");
useEffect(() => {
form.reset({
applicationId: "",
applicationSecret: "",
groupName: "",
redirectUri: webhookUrl,
name: "",
gitlabUrl: "https://gitlab.com",
});
}, [form, isOpen]);
@@ -83,6 +92,7 @@ export const AddGitlabProvider = () => {
authId: auth?.id || "",
name: data.name || "",
redirectUri: data.redirectUri || "",
gitlabUrl: data.gitlabUrl || "https://gitlab.com",
})
.then(async () => {
await utils.gitProvider.getAll.invalidate();
@@ -129,7 +139,7 @@ export const AddGitlabProvider = () => {
<li className="flex flex-row gap-2 items-center">
Go to your GitLab profile settings{" "}
<Link
href="https://gitlab.com/-/profile/applications"
href={`${gitlabUrl}/-/profile/applications`}
target="_blank"
>
<ExternalLink className="w-fit text-primary size-4" />
@@ -169,6 +179,20 @@ export const AddGitlabProvider = () => {
)}
/>
<FormField
control={form.control}
name="gitlabUrl"
render={({ field }) => (
<FormItem>
<FormLabel>Gitlab URL</FormLabel>
<FormControl>
<Input placeholder="https://gitlab.com/" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="redirectUri"

View File

@@ -30,6 +30,9 @@ const Schema = z.object({
name: z.string().min(1, {
message: "Name is required",
}),
gitlabUrl: z.string().url({
message: "Invalid Gitlab URL",
}),
groupName: z.string().optional(),
});
@@ -40,7 +43,7 @@ interface Props {
}
export const EditGitlabProvider = ({ gitlabId }: Props) => {
const { data: gitlab } = api.gitlab.one.useQuery(
const { data: gitlab, refetch } = api.gitlab.one.useQuery(
{
gitlabId,
},
@@ -57,6 +60,7 @@ export const EditGitlabProvider = ({ gitlabId }: Props) => {
defaultValues: {
groupName: "",
name: "",
gitlabUrl: "https://gitlab.com",
},
resolver: zodResolver(Schema),
});
@@ -67,6 +71,7 @@ export const EditGitlabProvider = ({ gitlabId }: Props) => {
form.reset({
groupName: gitlab?.groupName || "",
name: gitlab?.gitProvider.name || "",
gitlabUrl: gitlab?.gitlabUrl || "",
});
}, [form, isOpen]);
@@ -76,11 +81,13 @@ export const EditGitlabProvider = ({ gitlabId }: Props) => {
gitProviderId: gitlab?.gitProviderId || "",
groupName: data.groupName || "",
name: data.name || "",
gitlabUrl: data.gitlabUrl || "",
})
.then(async () => {
await utils.gitProvider.getAll.invalidate();
toast.success("Gitlab updated successfully");
setIsOpen(false);
refetch();
})
.catch(() => {
toast.error("Error to update Gitlab");
@@ -126,6 +133,19 @@ export const EditGitlabProvider = ({ gitlabId }: Props) => {
</FormItem>
)}
/>
<FormField
control={form.control}
name="gitlabUrl"
render={({ field }) => (
<FormItem>
<FormLabel>Gitlab Url</FormLabel>
<FormControl>
<Input placeholder="https://gitlab.com" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}

View File

@@ -23,12 +23,16 @@ export const ShowGitProviders = () => {
const url = useUrl();
const getGitlabUrl = (clientId: string, gitlabId: string) => {
const getGitlabUrl = (
clientId: string,
gitlabId: string,
gitlabUrl: string,
) => {
const redirectUri = `${url}/api/providers/gitlab/callback?gitlabId=${gitlabId}`;
const scope = "api read_user read_repository";
const authUrl = `https://gitlab.com/oauth/authorize?client_id=${clientId}&redirect_uri=${encodeURIComponent(redirectUri)}&response_type=code&scope=${encodeURIComponent(scope)}`;
const authUrl = `${gitlabUrl}/oauth/authorize?client_id=${clientId}&redirect_uri=${encodeURIComponent(redirectUri)}&response_type=code&scope=${encodeURIComponent(scope)}`;
return authUrl;
};
@@ -142,6 +146,7 @@ export const ShowGitProviders = () => {
href={getGitlabUrl(
gitProvider.gitlab?.applicationId || "",
gitProvider.gitlab?.gitlabId || "",
gitProvider.gitlab?.gitlabUrl,
)}
target="_blank"
className={buttonVariants({

View File

@@ -26,10 +26,12 @@ import { toast } from "sonner";
import { z } from "zod";
import { Disable2FA } from "./disable-2fa";
import { Enable2FA } from "./enable-2fa";
import { AlertBlock } from "@/components/shared/alert-block";
const profileSchema = z.object({
email: z.string(),
password: z.string().nullable(),
currentPassword: z.string().nullable(),
image: z.string().optional(),
});
@@ -52,7 +54,8 @@ const randomImages = [
export const ProfileForm = () => {
const { data, refetch } = api.auth.get.useQuery();
const { mutateAsync, isLoading } = api.auth.update.useMutation();
const { mutateAsync, isLoading, isError, error } =
api.auth.update.useMutation();
const { t } = useTranslation("settings");
const [gravatarHash, setGravatarHash] = useState<string | null>(null);
@@ -68,6 +71,7 @@ export const ProfileForm = () => {
email: data?.email || "",
password: "",
image: data?.image || "",
currentPassword: "",
},
resolver: zodResolver(profileSchema),
});
@@ -78,6 +82,7 @@ export const ProfileForm = () => {
email: data?.email || "",
password: "",
image: data?.image || "",
currentPassword: "",
});
if (data.email) {
@@ -94,6 +99,7 @@ export const ProfileForm = () => {
email: values.email.toLowerCase(),
password: values.password,
image: values.image,
currentPassword: values.currentPassword,
})
.then(async () => {
await refetch();
@@ -116,6 +122,8 @@ export const ProfileForm = () => {
{!data?.is2FAEnabled ? <Enable2FA /> : <Disable2FA />}
</CardHeader>
<CardContent className="space-y-2">
{isError && <AlertBlock type="error">{error?.message}</AlertBlock>}
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="grid gap-4">
<div className="space-y-4">
@@ -135,6 +143,24 @@ export const ProfileForm = () => {
</FormItem>
)}
/>
<FormField
control={form.control}
name="currentPassword"
render={({ field }) => (
<FormItem>
<FormLabel>Current Password</FormLabel>
<FormControl>
<Input
type="password"
placeholder={t("settings.profile.password")}
{...field}
value={field.value || ""}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="password"

View File

@@ -0,0 +1,130 @@
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { api } from "@/utils/api";
import { zodResolver } from "@hookform/resolvers/zod";
import { useTranslation } from "next-i18next";
import { useEffect } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
import { DialogAction } from "@/components/shared/dialog-action";
import { AlertBlock } from "@/components/shared/alert-block";
import { useRouter } from "next/router";
const profileSchema = z.object({
password: z.string().min(1, {
message: "Password is required",
}),
});
type Profile = z.infer<typeof profileSchema>;
export const RemoveSelfAccount = () => {
const { data } = api.auth.get.useQuery();
const { mutateAsync, isLoading, error, isError } =
api.auth.removeSelfAccount.useMutation();
const { t } = useTranslation("settings");
const router = useRouter();
const form = useForm<Profile>({
defaultValues: {
password: "",
},
resolver: zodResolver(profileSchema),
});
useEffect(() => {
if (data) {
form.reset({
password: "",
});
}
form.reset();
}, [form, form.reset, data]);
const onSubmit = async (values: Profile) => {
await mutateAsync({
password: values.password,
})
.then(async () => {
toast.success("Profile Deleted");
router.push("/");
})
.catch(() => {});
};
return (
<Card className="bg-transparent">
<CardHeader className="flex flex-row gap-2 flex-wrap justify-between items-center">
<div>
<CardTitle className="text-xl">Remove Self Account</CardTitle>
<CardDescription>
If you want to remove your account, you can do it here
</CardDescription>
</div>
</CardHeader>
<CardContent className="space-y-2">
{isError && <AlertBlock type="error">{error?.message}</AlertBlock>}
<Form {...form}>
<form
onSubmit={(e) => e.preventDefault()}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
}
}}
className="grid gap-4"
>
<div className="space-y-4">
<FormField
control={form.control}
name="password"
render={({ field }) => (
<FormItem>
<FormLabel>{t("settings.profile.password")}</FormLabel>
<FormControl>
<Input
type="password"
placeholder={t("settings.profile.password")}
{...field}
value={field.value || ""}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
</form>
</Form>
<div>
<DialogAction
title="Are you sure you want to remove your account?"
description="This action cannot be undone, all your projects/servers will be deleted."
onClick={() => form.handleSubmit(onSubmit)()}
>
<Button type="button" isLoading={isLoading} variant="destructive">
Remove
</Button>
</DialogAction>
</div>
</CardContent>
</Card>
);
};

View File

@@ -0,0 +1,167 @@
import { AlertBlock } from "@/components/shared/alert-block";
import { CodeEditor } from "@/components/shared/code-editor";
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 { api } from "@/utils/api";
import { zodResolver } from "@hookform/resolvers/zod";
import { FileTerminal } from "lucide-react";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
interface Props {
serverId: string;
}
const schema = z.object({
command: z.string().min(1, {
message: "Command is required",
}),
});
type Schema = z.infer<typeof schema>;
export const EditScript = ({ serverId }: Props) => {
const [isOpen, setIsOpen] = useState(false);
const { data: server } = api.server.one.useQuery(
{
serverId,
},
{
enabled: !!serverId,
},
);
const { mutateAsync, isLoading } = api.server.update.useMutation();
const { data: defaultCommand } = api.server.getDefaultCommand.useQuery(
{
serverId,
},
{
enabled: !!serverId,
},
);
const form = useForm<Schema>({
defaultValues: {
command: "",
},
resolver: zodResolver(schema),
});
useEffect(() => {
if (server) {
form.reset({
command: server.command || defaultCommand,
});
}
}, [server, defaultCommand]);
const onSubmit = async (formData: Schema) => {
if (server) {
await mutateAsync({
...server,
command: formData.command || "",
serverId,
})
.then((data) => {
toast.success("Script modified successfully");
})
.catch(() => {
toast.error("Error modifying the script");
});
}
};
return (
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogTrigger asChild>
<Button variant="outline">
Modify Script
<FileTerminal className="size-4 text-muted-foreground" />
</Button>
</DialogTrigger>
<DialogContent className="max-h-screen overflow-y-auto sm:max-w-5xl overflow-x-hidden">
<DialogHeader>
<DialogTitle>Modify Script</DialogTitle>
<DialogDescription>
Modify the script which install everything necessary to deploy
applications on your server,
</DialogDescription>
<AlertBlock type="warning">
We suggest to don't modify the script if you don't know what you are
doing
</AlertBlock>
</DialogHeader>
<div className="grid gap-4">
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
id="hook-form-delete-application"
className="grid w-full gap-4"
>
<FormField
control={form.control}
name="command"
render={({ field }) => (
<FormItem>
<FormLabel>Command</FormLabel>
<FormControl className="max-h-[75vh] max-w-[60rem] overflow-y-scroll overflow-x-hidden">
<CodeEditor
language="shell"
{...field}
placeholder={`
set -e
echo "Hello world"
`}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</form>
</Form>
</div>
<DialogFooter className="flex justify-between w-full">
<Button
variant="secondary"
onClick={() => {
form.reset({
command: defaultCommand || "",
});
}}
>
Reset
</Button>
<Button
isLoading={isLoading}
form="hook-form-delete-application"
type="submit"
>
Save
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
};

View File

@@ -262,16 +262,16 @@ export function StatusRow({
<div className="flex items-center gap-2">
{showIcon ? (
<>
{isEnabled ? (
<CheckCircle2 className="size-4 text-green-500" />
) : (
<XCircle className="size-4 text-red-500" />
)}
<span
className={`text-sm ${isEnabled ? "text-green-500" : "text-red-500"}`}
>
{description || (isEnabled ? "Installed" : "Not Installed")}
</span>
{isEnabled ? (
<CheckCircle2 className="size-4 text-green-500" />
) : (
<XCircle className="size-4 text-red-500" />
)}
</>
) : (
<span className="text-sm text-muted-foreground">{value}</span>

View File

@@ -32,6 +32,7 @@ import Link from "next/link";
import { useState } from "react";
import { toast } from "sonner";
import { ShowDeployment } from "../../application/deployments/show-deployment";
import { EditScript } from "./edit-script";
import { GPUSupport } from "./gpu-support";
import { ValidateServer } from "./validate-server";
@@ -89,7 +90,12 @@ export const SetupServer = ({ serverId }: Props) => {
</AlertBlock>
</div>
) : (
<div id="hook-form-add-gitlab" className="grid w-full gap-1">
<div id="hook-form-add-gitlab" className="grid w-full gap-4">
<AlertBlock type="warning">
Using a root user is required to ensure everything works as
expected.
</AlertBlock>
<Tabs defaultValue="ssh-keys">
<TabsList className="grid grid-cols-4 w-[600px]">
<TabsTrigger value="ssh-keys">SSH Keys</TabsTrigger>
@@ -139,7 +145,7 @@ export const SetupServer = ({ serverId }: Props) => {
Automatic process
</span>
<Link
href="https://docs.dokploy.com/en/docs/core/get-started/introduction"
href="https://docs.dokploy.com/docs/core/multi-server/instructions#requirements"
target="_blank"
className="text-primary flex flex-row gap-2"
>
@@ -198,6 +204,28 @@ export const SetupServer = ({ serverId }: Props) => {
</li>
</ul>
</div>
<div className="flex flex-col gap-2 w-full border rounded-lg p-4">
<span className="text-base font-semibold text-primary">
Supported Distros:
</span>
<p>
We strongly recommend to use the following distros to
ensure the best experience:
</p>
<ul>
<li>1. Ubuntu 24.04 LTS</li>
<li>2. Ubuntu 23.10 LTS </li>
<li>3. Ubuntu 22.04 LTS</li>
<li>4. Ubuntu 20.04 LTS</li>
<li>5. Ubuntu 18.04 LTS</li>
<li>6. Debian 12</li>
<li>7. Debian 11</li>
<li>8. Debian 10</li>
<li>9. Fedora 40</li>
<li>10. Centos 9</li>
<li>11. Centos 8</li>
</ul>
</div>
</div>
</TabsContent>
<TabsContent value="deployments">
@@ -214,24 +242,29 @@ export const SetupServer = ({ serverId }: Props) => {
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");
<div className="flex flex-row gap-2">
<EditScript serverId={server?.serverId || ""} />
<DialogAction
title={"Setup Server?"}
description="This will setup the server and all associated data"
onClick={async () => {
await mutateAsync({
serverId: server?.serverId || "",
})
.catch(() => {
toast.error("Error configuring server");
});
}}
>
<Button isLoading={isLoading}>Setup Server</Button>
</DialogAction>
.then(async () => {
refetch();
toast.success("Server setup successfully");
})
.catch(() => {
toast.error("Error configuring server");
});
}}
>
<Button isLoading={isLoading}>
Setup Server
</Button>
</DialogAction>
</div>
</div>
</CardHeader>
<CardContent className="flex flex-col gap-4">

View File

@@ -31,8 +31,12 @@ import { SetupServer } from "./setup-server";
import { ShowDockerContainersModal } from "./show-docker-containers-modal";
import { ShowTraefikFileSystemModal } from "./show-traefik-file-system-modal";
import { UpdateServer } from "./update-server";
import { useRouter } from "next/router";
import { WelcomeSuscription } from "./welcome-stripe/welcome-suscription";
export const ShowServers = () => {
const router = useRouter();
const query = router.query;
const { data, refetch } = api.server.all.useQuery();
const { mutateAsync } = api.server.remove.useMutation();
const { data: sshKeys } = api.sshKey.all.useQuery();
@@ -42,12 +46,26 @@ export const ShowServers = () => {
return (
<div className="p-6 space-y-6">
{query?.success && <WelcomeSuscription />}
<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 className="flex flex-col gap-2">
<div>
<h1 className="text-2xl font-bold">Servers</h1>
<p className="text-muted-foreground">
Add servers to deploy your applications remotely.
</p>
</div>
{isCloud && (
<span
className="text-primary cursor-pointer text-sm"
onClick={() => {
router.push("/dashboard/settings/servers?success=true");
}}
>
Reset Onboarding
</span>
)}
</div>
{sshKeys && sshKeys?.length > 0 && (
@@ -100,7 +118,9 @@ export const ShowServers = () => {
{data && data?.length > 0 && (
<div className="flex flex-col gap-6 overflow-auto">
<Table>
<TableCaption>See all servers</TableCaption>
<TableCaption>
<div className="flex flex-col gap-4">See all servers</div>
</TableCaption>
<TableHeader>
<TableRow>
<TableHead className="w-[100px]">Name</TableHead>

View File

@@ -1,4 +1,5 @@
import { AlertBlock } from "@/components/shared/alert-block";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
@@ -8,9 +9,8 @@ import {
} from "@/components/ui/card";
import { api } from "@/utils/api";
import { Loader2, PcCase, RefreshCw } from "lucide-react";
import { StatusRow } from "./gpu-support";
import { Button } from "@/components/ui/button";
import { useState } from "react";
import { StatusRow } from "./gpu-support";
interface Props {
serverId: string;
@@ -66,7 +66,7 @@ export const ValidateServer = ({ serverId }: Props) => {
{isLoading ? (
<div className="flex items-center justify-center text-muted-foreground py-4">
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
<span>Checking Server Configuration</span>
<span>Checking Server configuration</span>
</div>
) : (
<div className="grid w-full gap-4">
@@ -113,16 +113,31 @@ export const ValidateServer = ({ serverId }: Props) => {
}
/>
<StatusRow
label="Dokploy Network Installed"
isEnabled={data?.isDokployNetworkInstalled}
label="Docker Swarm Initialized"
isEnabled={data?.isSwarmInstalled}
description={
data?.isSwarmInstalled
? "Initialized"
: "Not Initialized"
}
/>
<StatusRow
label="Swarm Installed"
isEnabled={data?.isSwarmInstalled}
label="Dokploy Network Created"
isEnabled={data?.isDokployNetworkInstalled}
description={
data?.isDokployNetworkInstalled
? "Created"
: "Not Created"
}
/>
<StatusRow
label="Main Directory Created"
isEnabled={data?.isMainDirectoryInstalled}
description={
data?.isMainDirectoryInstalled
? "Created"
: "Not Created"
}
/>
</div>
</div>

View File

@@ -0,0 +1,284 @@
import { AlertBlock } from "@/components/shared/alert-block";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { DialogFooter } from "@/components/ui/dialog";
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Textarea } from "@/components/ui/textarea";
import { api } from "@/utils/api";
import { zodResolver } from "@hookform/resolvers/zod";
import { PlusIcon } from "lucide-react";
import Link from "next/link";
import { useRouter } from "next/router";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
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>;
interface Props {
stepper: any;
}
export const CreateServer = ({ stepper }: Props) => {
const { data: sshKeys } = api.sshKey.all.useQuery();
const [isOpen, setIsOpen] = useState(false);
const { data: canCreateMoreServers, refetch } =
api.stripe.canCreateMoreServers.useQuery();
const { mutateAsync, error, isError } = api.server.create.useMutation();
const cloudSSHKey = sshKeys?.find(
(sshKey) => sshKey.name === "dokploy-cloud-ssh-key",
);
const form = useForm<Schema>({
defaultValues: {
description: "Dokploy Cloud Server",
name: "My First Server",
ipAddress: "",
port: 22,
username: "root",
sshKeyId: cloudSSHKey?.sshKeyId || "",
},
resolver: zodResolver(Schema),
});
useEffect(() => {
form.reset({
description: "Dokploy Cloud Server",
name: "My First Server",
ipAddress: "",
port: 22,
username: "root",
sshKeyId: cloudSSHKey?.sshKeyId || "",
});
}, [form, form.reset, form.formState.isSubmitSuccessful, sshKeys]);
useEffect(() => {
refetch();
}, [isOpen]);
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) => {
toast.success("Server Created");
stepper.next();
})
.catch(() => {
toast.error("Error to create a server");
});
};
return (
<Card className="bg-background flex flex-col gap-4">
<div className="flex flex-col gap-2 pt-5 px-4">
{!canCreateMoreServers && (
<AlertBlock type="warning">
You cannot create more servers,{" "}
<Link href="/dashboard/settings/billing" className="text-primary">
Please upgrade your plan
</Link>
</AlertBlock>
)}
</div>
<CardContent className="flex flex-col">
<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>
{!cloudSSHKey && (
<AlertBlock>
Looks like you didn't have the SSH Key yet, you can create
one{" "}
<Link
href="/dashboard/settings/ssh-keys"
className="text-primary"
>
here
</Link>
</AlertBlock>
)}
<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}
onChange={(e) => {
const value = e.target.value;
if (value === "") {
field.onChange(0);
} else {
const number = Number.parseInt(value, 10);
if (!Number.isNaN(number)) {
field.onChange(number);
}
}
}}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<FormField
control={form.control}
name="username"
render={({ field }) => (
<FormItem>
<FormLabel>Username</FormLabel>
<FormControl>
<Input placeholder="root" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</form>
<DialogFooter className="pt-5">
<Button
isLoading={form.formState.isSubmitting}
disabled={!canCreateMoreServers}
form="hook-form-add-server"
type="submit"
>
Create
</Button>
</DialogFooter>
</Form>
</CardContent>
</Card>
);
};

View File

@@ -0,0 +1,152 @@
import { Card, CardContent } from "@/components/ui/card";
import { api } from "@/utils/api";
import { ExternalLinkIcon, Loader2 } from "lucide-react";
import copy from "copy-to-clipboard";
import { CopyIcon } from "lucide-react";
import { useEffect, useRef } from "react";
import { toast } from "sonner";
import { CodeEditor } from "@/components/shared/code-editor";
import Link from "next/link";
export const CreateSSHKey = () => {
const { data, refetch } = api.sshKey.all.useQuery();
const generateMutation = api.sshKey.generate.useMutation();
const { mutateAsync, isLoading } = api.sshKey.create.useMutation();
const hasCreatedKey = useRef(false);
const cloudSSHKey = data?.find(
(sshKey) => sshKey.name === "dokploy-cloud-ssh-key",
);
useEffect(() => {
const createKey = async () => {
if (!data || cloudSSHKey || hasCreatedKey.current || isLoading) {
return;
}
hasCreatedKey.current = true;
try {
const keys = await generateMutation.mutateAsync({
type: "rsa",
});
await mutateAsync({
name: "dokploy-cloud-ssh-key",
description: "Used on Dokploy Cloud",
privateKey: keys.privateKey,
publicKey: keys.publicKey,
});
await refetch();
} catch (error) {
console.error("Error creating SSH key:", error);
hasCreatedKey.current = false;
}
};
createKey();
}, [data]);
return (
<Card className="h-full bg-transparent">
<CardContent>
<div className="grid w-full gap-4 pt-4">
{isLoading ? (
<div className="min-h-[25vh] justify-center flex items-center gap-4">
<Loader2
className="animate-spin text-muted-foreground"
size={32}
/>
</div>
) : (
<>
<div className="flex flex-col gap-2 text-sm text-muted-foreground">
<p className="text-primary text-base font-semibold">
You have two options to add SSH Keys to your server:
</p>
<ul>
<li>1. Add The SSH Key to Server Manually</li>
<li>
2. Add the public SSH Key when you create a server in your
preffered provider (Hostinger, Digital Ocean, Hetzner, etc){" "}
</li>
</ul>
<div className="flex flex-col gap-2 w-full border rounded-lg p-4">
<span className="text-base font-semibold text-primary">
Option 1
</span>
<ul>
<li className="items-center flex gap-1">
1. Login to your server{" "}
</li>
<li>
2. When you are logged in run the following command
<div className="flex relative flex-col gap-4 w-full mt-2">
<CodeEditor
lineWrapping
language="properties"
value={`echo "${cloudSSHKey?.publicKey}" >> ~/.ssh/authorized_keys`}
readOnly
className="font-mono opacity-60"
/>
<button
type="button"
className="absolute right-2 top-2"
onClick={() => {
copy(
`echo "${cloudSSHKey?.publicKey}" >> ~/.ssh/authorized_keys`,
);
toast.success("Copied to clipboard");
}}
>
<CopyIcon className="size-4" />
</button>
</div>
</li>
<li className="mt-1">
3. You're done, follow the next step to insert the details
of your server.
</li>
</ul>
</div>
<div className="flex flex-col gap-2 w-full mt-2 border rounded-lg p-4">
<span className="text-base font-semibold text-primary">
Option 2
</span>
<div className="flex flex-col gap-4 w-full overflow-auto">
<div className="flex relative flex-col gap-2 overflow-y-auto">
<div className="text-sm text-primary flex flex-row gap-2 items-center">
Copy Public Key
<button
type="button"
className=" right-2 top-8"
onClick={() => {
copy(
cloudSSHKey?.publicKey || "Generate a SSH Key",
);
toast.success("SSH Copied to clipboard");
}}
>
<CopyIcon className="size-4 text-muted-foreground" />
</button>
</div>
</div>
</div>
<Link
href="https://docs.dokploy.com/docs/core/multi-server/instructions#requirements"
target="_blank"
className="text-primary flex flex-row gap-2"
>
View Tutorial <ExternalLinkIcon className="size-4" />
</Link>
</div>
</div>
</>
)}
</div>
</CardContent>
</Card>
);
};

View File

@@ -0,0 +1,163 @@
import { ShowDeployment } from "@/components/dashboard/application/deployments/show-deployment";
import { DateTooltip } from "@/components/shared/date-tooltip";
import { DialogAction } from "@/components/shared/dialog-action";
import { StatusTooltip } from "@/components/shared/status-tooltip";
import { Button } from "@/components/ui/button";
import {
Card,
CardHeader,
CardTitle,
CardDescription,
CardContent,
} from "@/components/ui/card";
import { RocketIcon } from "lucide-react";
import { toast } from "sonner";
import { EditScript } from "../edit-script";
import { api } from "@/utils/api";
import { useState } from "react";
import { Label } from "@/components/ui/label";
import {
Select,
SelectTrigger,
SelectValue,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
} from "@/components/ui/select";
export const Setup = () => {
const { data: servers } = api.server.all.useQuery();
const [serverId, setServerId] = useState<string>(
servers?.[0]?.serverId || "",
);
const { data: server } = api.server.one.useQuery(
{
serverId,
},
{
enabled: !!serverId,
},
);
const [activeLog, setActiveLog] = useState<string | null>(null);
const { data: deployments, refetch } = api.deployment.allByServer.useQuery(
{ serverId },
{
enabled: !!serverId,
},
);
const { mutateAsync, isLoading } = api.server.setup.useMutation();
return (
<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-col gap-2 w-full">
<Label>Select a server</Label>
<Select onValueChange={setServerId} defaultValue={serverId}>
<SelectTrigger>
<SelectValue placeholder="Select a server" />
</SelectTrigger>
<SelectContent>
<SelectGroup>
{servers?.map((server) => (
<SelectItem key={server.serverId} value={server.serverId}>
{server.name}
</SelectItem>
))}
<SelectLabel>Servers ({servers?.length})</SelectLabel>
</SelectGroup>
</SelectContent>
</Select>
</div>
<div className="flex flex-row gap-2 justify-between w-full 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>
<div className="flex flex-row gap-2">
<EditScript serverId={server?.serverId || ""} />
<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 isLoading={isLoading}>Setup Server</Button>
</DialogAction>
</div>
</div>
</CardHeader>
<CardContent className="flex flex-col gap-4 min-h-[30vh]">
{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>
);
};

View File

@@ -0,0 +1,189 @@
import { Button } from "@/components/ui/button";
import {
Card,
CardHeader,
CardTitle,
CardDescription,
CardContent,
} from "@/components/ui/card";
import { Loader2, PcCase, RefreshCw } from "lucide-react";
import { api } from "@/utils/api";
import { useState } from "react";
import { Label } from "@/components/ui/label";
import {
Select,
SelectTrigger,
SelectValue,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
} from "@/components/ui/select";
import { StatusRow } from "../gpu-support";
import { AlertBlock } from "@/components/shared/alert-block";
export const Verify = () => {
const { data: servers } = api.server.all.useQuery();
const [serverId, setServerId] = useState<string>(
servers?.[0]?.serverId || "",
);
const { data, refetch, error, isLoading, isError } =
api.server.validate.useQuery(
{ serverId },
{
enabled: !!serverId,
},
);
const [isRefreshing, setIsRefreshing] = useState(false);
const { data: server } = api.server.one.useQuery(
{
serverId,
},
{
enabled: !!serverId,
},
);
return (
<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-col gap-2 w-full">
<Label>Select a server</Label>
<Select onValueChange={setServerId} defaultValue={serverId}>
<SelectTrigger>
<SelectValue placeholder="Select a server" />
</SelectTrigger>
<SelectContent>
<SelectGroup>
{servers?.map((server) => (
<SelectItem key={server.serverId} value={server.serverId}>
{server.name}
</SelectItem>
))}
<SelectLabel>Servers ({servers?.length})</SelectLabel>
</SelectGroup>
</SelectContent>
</Select>
</div>
<div className="flex flex-row gap-2 justify-between w-full max-sm:flex-col">
<div className="flex flex-col gap-1">
<div className="flex items-center gap-2">
<PcCase className="size-5" />
<CardTitle className="text-xl">Setup Validation</CardTitle>
</div>
<CardDescription>
Check if your server is ready for deployment
</CardDescription>
</div>
<Button
isLoading={isRefreshing}
onClick={async () => {
setIsRefreshing(true);
await refetch();
setIsRefreshing(false);
}}
>
<RefreshCw className="size-4" />
Refresh
</Button>
</div>
<div className="flex items-center gap-2 w-full">
{isError && (
<AlertBlock type="error" className="w-full">
{error.message}
</AlertBlock>
)}
</div>
</CardHeader>
<CardContent className="flex flex-col gap-4 min-h-[25vh]">
{isLoading ? (
<div className="flex items-center justify-center text-muted-foreground py-4">
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
<span>Checking Server configuration</span>
</div>
) : (
<div className="grid w-full gap-4">
<div className="border rounded-lg p-4">
<h3 className="text-lg font-semibold mb-1">Status</h3>
<p className="text-sm text-muted-foreground mb-4">
Shows the server configuration status
</p>
<div className="grid gap-2.5">
<StatusRow
label="Docker Installed"
isEnabled={data?.docker?.enabled}
description={
data?.docker?.enabled
? `Installed: ${data?.docker?.version}`
: undefined
}
/>
<StatusRow
label="RClone Installed"
isEnabled={data?.rclone?.enabled}
description={
data?.rclone?.enabled
? `Installed: ${data?.rclone?.version}`
: undefined
}
/>
<StatusRow
label="Nixpacks Installed"
isEnabled={data?.nixpacks?.enabled}
description={
data?.nixpacks?.enabled
? `Installed: ${data?.nixpacks?.version}`
: undefined
}
/>
<StatusRow
label="Buildpacks Installed"
isEnabled={data?.buildpacks?.enabled}
description={
data?.buildpacks?.enabled
? `Installed: ${data?.buildpacks?.version}`
: undefined
}
/>
<StatusRow
label="Docker Swarm Initialized"
isEnabled={data?.isSwarmInstalled}
description={
data?.isSwarmInstalled
? "Initialized"
: "Not Initialized"
}
/>
<StatusRow
label="Dokploy Network Created"
isEnabled={data?.isDokployNetworkInstalled}
description={
data?.isDokployNetworkInstalled
? "Created"
: "Not Created"
}
/>
<StatusRow
label="Main Directory Created"
isEnabled={data?.isMainDirectoryInstalled}
description={
data?.isMainDirectoryInstalled
? "Created"
: "Not Created"
}
/>
</div>
</div>
</div>
)}
</CardContent>
</Card>
</div>
</CardContent>
);
};

View File

@@ -0,0 +1,424 @@
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { BookIcon, Puzzle } from "lucide-react";
import { useRouter } from "next/router";
import { useEffect, useState } from "react";
import { defineStepper } from "@stepperize/react";
import React from "react";
import { Separator } from "@/components/ui/separator";
import { AlertBlock } from "@/components/shared/alert-block";
import { CreateServer } from "./create-server";
import { CreateSSHKey } from "./create-ssh-key";
import { Setup } from "./setup";
import { Verify } from "./verify";
import {
Database,
Globe,
GitMerge,
Code,
Users,
Code2,
Plug,
} from "lucide-react";
import ConfettiExplosion from "react-confetti-explosion";
import Link from "next/link";
import { GithubIcon } from "@/components/icons/data-tools-icons";
interface Props {
children?: React.ReactNode;
}
export const { useStepper, steps, Scoped } = defineStepper(
{
id: "requisites",
title: "Requisites",
description: "Check your requisites",
},
{
id: "create-ssh-key",
title: "SSH Key",
description: "Create your ssh key",
},
{
id: "connect-server",
title: "Connect",
description: "Connect",
},
{ id: "setup", title: "Setup", description: "Setup your server" },
{ id: "verify", title: "Verify", description: "Verify your server" },
{ id: "complete", title: "Complete", description: "Checkout complete" },
);
export const WelcomeSuscription = ({ children }: Props) => {
const [showConfetti, setShowConfetti] = useState(false);
const stepper = useStepper();
const [isOpen, setIsOpen] = useState(true);
const { push } = useRouter();
useEffect(() => {
const confettiShown = localStorage.getItem("hasShownConfetti");
if (!confettiShown) {
setShowConfetti(true);
localStorage.setItem("hasShownConfetti", "true");
}
}, [showConfetti]);
return (
<Dialog open={isOpen}>
<DialogContent className="max-h-screen overflow-y-auto sm:max-w-7xl min-h-[75vh]">
{showConfetti ?? "Flaso"}
<div className="flex justify-center items-center w-full">
{showConfetti && (
<ConfettiExplosion
duration={3000}
force={0.3}
particleSize={12}
particleCount={300}
className="z-[9999]"
zIndex={9999}
width={1500}
/>
)}
</div>
<DialogHeader>
<DialogTitle className="text-2xl text-center">
Welcome To Dokploy Cloud 🎉
</DialogTitle>
<DialogDescription className="text-center">
Thank you for choosing Dokploy Cloud, before you start you need to
configure your remote server
</DialogDescription>
</DialogHeader>
<div className="grid gap-4">
<div className="flex justify-between">
<h2 className="text-lg font-semibold">Steps</h2>
<div className="flex items-center gap-2">
<span className="text-sm text-muted-foreground">
Step {stepper.current.index + 1} of {steps.length}
</span>
<div />
</div>
</div>
<Scoped>
<nav aria-label="Checkout Steps" className="group my-4">
<ol
className="flex items-center justify-between gap-2"
aria-orientation="horizontal"
>
{stepper.all.map((step, index, array) => (
<React.Fragment key={step.id}>
<li className="flex items-center gap-4 flex-shrink-0">
<Button
type="button"
role="tab"
variant={
index <= stepper.current.index
? "default"
: "secondary"
}
aria-current={
stepper.current.id === step.id ? "step" : undefined
}
aria-posinset={index + 1}
aria-setsize={steps.length}
aria-selected={stepper.current.id === step.id}
className="flex size-10 items-center justify-center rounded-full"
onClick={() => stepper.goTo(step.id)}
>
{index + 1}
</Button>
<span className="text-sm font-medium">{step.title}</span>
</li>
{index < array.length - 1 && (
<Separator
className={`flex-1 ${
index < stepper.current.index
? "bg-primary"
: "bg-muted"
}`}
/>
)}
</React.Fragment>
))}
</ol>
</nav>
{stepper.switch({
requisites: () => (
<div className="flex flex-col gap-2 border p-4 rounded-lg">
<span className="text-primary text-base font-bold">
Before getting started, please follow the steps below to
ensure the best experience:
</span>
<div>
<p className="text-primary text-sm font-medium">
Supported Distributions:
</p>
<ul className="list-inside list-disc pl-4 text-sm text-muted-foreground mt-4">
<li>Ubuntu 24.04 LTS</li>
<li>Ubuntu 23.10</li>
<li>Ubuntu 22.04 LTS</li>
<li>Ubuntu 20.04 LTS</li>
<li>Ubuntu 18.04 LTS</li>
<li>Debian 12</li>
<li>Debian 11</li>
<li>Debian 10</li>
<li>Fedora 40</li>
<li>CentOS 9</li>
<li>CentOS 8</li>
</ul>
</div>
<div>
<p className="text-primary text-sm font-medium">
You will need to purchase or rent a Virtual Private Server
(VPS) to proceed, we recommend to use one of these
providers since has been heavily tested.
</p>
<ul className="list-inside list-disc pl-4 text-sm text-muted-foreground mt-4">
<li>
<a
href="https://www.hostinger.com/vps-hosting?REFERRALCODE=1SIUMAURICI97"
className="text-link underline"
>
Hostinger - Get 20% Discount
</a>
</li>
<li>
<a
href="https://m.do.co/c/db24efd43f35"
className="text-link underline"
>
DigitalOcean - Get $200 Credits
</a>
</li>
<li>
<a
href="https://hetzner.cloud/?ref=vou4fhxJ1W2D"
className="text-link underline"
>
Hetzner - Get 20 Credits
</a>
</li>
<li>
<a
href="https://www.vultr.com/?ref=9679828"
className="text-link underline"
>
Vultr
</a>
</li>
<li>
<a
href="https://www.linode.com/es/pricing/#compute-shared"
className="text-link underline"
>
Linode
</a>
</li>
</ul>
<AlertBlock className="mt-4 px-4">
You are free to use whatever provider, but we recommend to
use one of the above, to avoid issues.
</AlertBlock>
</div>
</div>
),
"create-ssh-key": () => <CreateSSHKey />,
"connect-server": () => <CreateServer stepper={stepper} />,
setup: () => <Setup />,
verify: () => <Verify />,
complete: () => {
const features = [
{
title: "Scalable Deployments",
description:
"Deploy and scale your applications effortlessly to handle any workload.",
icon: <Database className="text-primary" />,
},
{
title: "Automated Backups",
description: "Protect your data with automatic backups",
icon: <Database className="text-primary" />,
},
{
title: "Open Source Templates",
description:
"Big list of common open source templates in one-click",
icon: <Puzzle className="text-primary" />,
},
{
title: "Custom Domains",
description:
"Link your own domains to your applications for a professional presence.",
icon: <Globe className="text-primary" />,
},
{
title: "CI/CD Integration",
description:
"Implement continuous integration and deployment workflows to streamline development.",
icon: <GitMerge className="text-primary" />,
},
{
title: "Database Management",
description:
"Efficiently manage your databases with intuitive tools.",
icon: <Database className="text-primary" />,
},
{
title: "Team Collaboration",
description:
"Collaborate with your team on shared projects with customizable permissions.",
icon: <Users className="text-primary" />,
},
{
title: "Multi-language Support",
description:
"Deploy applications in multiple programming languages to suit your needs.",
icon: <Code2 className="text-primary" />,
},
{
title: "API Access",
description:
"Integrate and manage your applications via robust and well-documented APIs.",
icon: <Plug className="text-primary" />,
},
];
return (
<div className="flex flex-col gap-6">
<div className="flex flex-col gap-2">
<h2 className="text-lg font-semibold">You're All Set!</h2>
<p className=" text-muted-foreground">
Did you know you can deploy any number of applications
that your server can handle?
</p>
<p className="text-muted-foreground">
Here are some of the things you can do with Dokploy
Cloud:
</p>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-6">
{features.map((feature, index) => (
<div
key={index}
className="flex flex-col items-start p-4 bg-card rounded-lg shadow-md hover:shadow-lg transition-shadow"
>
<div className="text-3xl mb-2">{feature.icon}</div>
<h3 className="text-lg font-medium mb-1">
{feature.title}
</h3>
<p className="text-sm text-muted-foreground">
{feature.description}
</p>
</div>
))}
</div>
<div className="flex flex-col gap-2 mt-4">
<span className="text-base text-primary">
Need Help? We are here to help you.
</span>
<span className="text-sm text-muted-foreground">
Join to our Discord server and we will help you.
</span>
<div className="flex flex-row gap-4">
<Button className="rounded-full bg-[#5965F2] hover:bg-[#4A55E0] w-fit">
<Link
href="https://discord.gg/2tBnJ3jDJc"
aria-label="Dokploy on GitHub"
target="_blank"
className="flex flex-row items-center gap-2 text-white"
>
<svg
role="img"
className="h-6 w-6 fill-white"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<path d="M20.317 4.3698a19.7913 19.7913 0 00-4.8851-1.5152.0741.0741 0 00-.0785.0371c-.211.3753-.4447.8648-.6083 1.2495-1.8447-.2762-3.68-.2762-5.4868 0-.1636-.3933-.4058-.8742-.6177-1.2495a.077.077 0 00-.0785-.037 19.7363 19.7363 0 00-4.8852 1.515.0699.0699 0 00-.0321.0277C.5334 9.0458-.319 13.5799.0992 18.0578a.0824.0824 0 00.0312.0561c2.0528 1.5076 4.0413 2.4228 5.9929 3.0294a.0777.0777 0 00.0842-.0276c.4616-.6304.8731-1.2952 1.226-1.9942a.076.076 0 00-.0416-.1057c-.6528-.2476-1.2743-.5495-1.8722-.8923a.077.077 0 01-.0076-.1277c.1258-.0943.2517-.1923.3718-.2914a.0743.0743 0 01.0776-.0105c3.9278 1.7933 8.18 1.7933 12.0614 0a.0739.0739 0 01.0785.0095c.1202.099.246.1981.3728.2924a.077.077 0 01-.0066.1276 12.2986 12.2986 0 01-1.873.8914.0766.0766 0 00-.0407.1067c.3604.698.7719 1.3628 1.225 1.9932a.076.076 0 00.0842.0286c1.961-.6067 3.9495-1.5219 6.0023-3.0294a.077.077 0 00.0313-.0552c.5004-5.177-.8382-9.6739-3.5485-13.6604a.061.061 0 00-.0312-.0286zM8.02 15.3312c-1.1825 0-2.1569-1.0857-2.1569-2.419 0-1.3332.9555-2.4189 2.157-2.4189 1.2108 0 2.1757 1.0952 2.1568 2.419 0 1.3332-.9555 2.4189-2.1569 2.4189zm7.9748 0c-1.1825 0-2.1569-1.0857-2.1569-2.419 0-1.3332.9554-2.4189 2.1569-2.4189 1.2108 0 2.1757 1.0952 2.1568 2.419 0 1.3332-.946 2.4189-2.1568 2.4189Z" />
</svg>
Join Discord
</Link>
</Button>
<Button className="rounded-full w-fit">
<Link
href="https://github.com/Dokploy/dokploy"
aria-label="Dokploy on GitHub"
target="_blank"
className="flex flex-row items-center gap-2 "
>
<GithubIcon />
Github
</Link>
</Button>
<Button
className="rounded-full w-fit"
variant="outline"
>
<Link
href="https://docs.dokploy.com/docs/core"
aria-label="Dokploy Docs"
target="_blank"
className="flex flex-row items-center gap-2 "
>
<BookIcon size={16} />
Docs
</Link>
</Button>
</div>
</div>
</div>
);
},
})}
</Scoped>
</div>
<DialogFooter>
<div className="flex items-center justify-between w-full">
{!stepper.isLast && (
<Button
variant="secondary"
onClick={() => {
setIsOpen(false);
push("/dashboard/settings/servers");
}}
>
Skip for now
</Button>
)}
<div className="flex items-center gap-2 w-full justify-end">
<Button
onClick={stepper.prev}
disabled={stepper.isFirst}
variant="secondary"
>
Back
</Button>
<Button
onClick={() => {
if (stepper.isLast) {
setIsOpen(false);
push("/dashboard/projects");
} else {
stepper.next();
}
}}
>
{stepper.isLast ? "Complete" : "Next"}
</Button>
</div>
</div>
</DialogFooter>
</DialogContent>
</Dialog>
);
};

View File

@@ -20,13 +20,11 @@ export const Terminal: React.FC<Props> = ({ id, serverId }) => {
}
const term = new XTerm({
cursorBlink: true,
cols: 80,
rows: 30,
lineHeight: 1.4,
convertEol: true,
theme: {
cursor: "transparent",
background: "#19191A",
background: "transparent",
},
});
const addonFit = new FitAddon();
@@ -40,6 +38,7 @@ export const Terminal: React.FC<Props> = ({ id, serverId }) => {
// @ts-ignore
term.open(termRef.current);
// @ts-ignore
term.loadAddon(addonFit);
term.loadAddon(addonAttach);
addonFit.fit();
@@ -50,7 +49,7 @@ export const Terminal: React.FC<Props> = ({ id, serverId }) => {
return (
<div className="flex flex-col gap-4">
<div className="w-full h-full bg-input rounded-lg p-2 ">
<div className="w-full h-full bg-transparent border rounded-lg p-2 ">
<div id={id} ref={termRef} className="rounded-xl" />
</div>
</div>

View File

@@ -1,25 +1,34 @@
import Head from "next/head";
import { Navbar } from "./navbar";
import { NavigationTabs, type TabState } from "./navigation-tabs";
interface Props {
children: React.ReactNode;
tab: TabState;
metaName?: string;
}
export const DashboardLayout = ({ children, tab }: Props) => {
export const DashboardLayout = ({ children, tab, metaName }: Props) => {
return (
<div>
<div
className="bg-radial relative flex flex-col bg-background min-h-screen w-full"
id="app-container"
>
<Navbar />
<main className="pt-6 flex w-full flex-col items-center">
<div className="w-full max-w-8xl px-4 lg:px-8">
<NavigationTabs tab={tab}>{children}</NavigationTabs>
</div>
</main>
<>
<Head>
<title>
{metaName ?? tab.charAt(0).toUpperCase() + tab.slice(1)} | Dokploy
</title>
</Head>
<div>
<div
className="bg-radial relative flex flex-col bg-background min-h-screen w-full"
id="app-container"
>
<Navbar />
<main className="pt-6 flex w-full flex-col items-center">
<div className="w-full max-w-8xl px-4 lg:px-8">
<NavigationTabs tab={tab}>{children}</NavigationTabs>
</div>
</main>
</div>
</div>
</div>
</>
);
};

View File

@@ -77,7 +77,7 @@ export const SettingsLayout = ({ children }: Props) => {
{
title: "Registry",
label: "",
icon: ListMusic,
icon: GalleryVerticalEnd,
href: "/dashboard/settings/registry",
},
@@ -150,6 +150,7 @@ import {
BoxesIcon,
CreditCardIcon,
Database,
GalleryVerticalEnd,
GitBranch,
KeyIcon,
KeyRound,

View File

@@ -2,7 +2,9 @@ import { cn } from "@/lib/utils";
import { json } from "@codemirror/lang-json";
import { yaml } from "@codemirror/lang-yaml";
import { StreamLanguage } from "@codemirror/language";
import { properties } from "@codemirror/legacy-modes/mode/properties";
import { shell } from "@codemirror/legacy-modes/mode/shell";
import { EditorView } from "@codemirror/view";
import { githubDark, githubLight } from "@uiw/codemirror-theme-github";
import CodeMirror, { type ReactCodeMirrorProps } from "@uiw/react-codemirror";
@@ -10,14 +12,16 @@ import { useTheme } from "next-themes";
interface Props extends ReactCodeMirrorProps {
wrapperClassName?: string;
disabled?: boolean;
language?: "yaml" | "json" | "properties";
language?: "yaml" | "json" | "properties" | "shell";
lineWrapping?: boolean;
lineNumbers?: boolean;
}
export const CodeEditor = ({
className,
wrapperClassName,
language = "yaml",
lineNumbers = true,
...props
}: Props) => {
const { resolvedTheme } = useTheme();
@@ -25,7 +29,7 @@ export const CodeEditor = ({
<div className={cn("relative overflow-auto", wrapperClassName)}>
<CodeMirror
basicSetup={{
lineNumbers: true,
lineNumbers,
foldGutter: true,
highlightSelectionMatches: true,
highlightActiveLine: !props.disabled,
@@ -37,7 +41,9 @@ export const CodeEditor = ({
? yaml()
: language === "json"
? json()
: StreamLanguage.define(properties),
: language === "shell"
? StreamLanguage.define(shell)
: StreamLanguage.define(properties),
props.lineWrapping ? EditorView.lineWrapping : [],
]}
{...props}

View File

@@ -0,0 +1 @@
ALTER TABLE "gitlab" ADD COLUMN "gitlabUrl" text DEFAULT 'https://gitlab.com' NOT NULL;

View File

@@ -0,0 +1 @@
ALTER TABLE "server" ADD COLUMN "command" text DEFAULT '' NOT NULL;

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -351,6 +351,20 @@
"when": 1733628762978,
"tag": "0049_dark_leopardon",
"breakpoints": true
},
{
"idx": 50,
"version": "6",
"when": 1733889104203,
"tag": "0050_nappy_wrecker",
"breakpoints": true
},
{
"idx": 51,
"version": "6",
"when": 1734241482851,
"tag": "0051_hard_gorgon",
"breakpoints": true
}
]
}

View File

@@ -11,6 +11,8 @@ export enum Languages {
Persian = "fa",
Korean = "ko",
Portuguese = "pt-br",
Italian = "it",
Japanese = "ja",
}
export type Language = keyof typeof Languages;

View File

@@ -1,23 +0,0 @@
/** @type {import('next-i18next').UserConfig} */
module.exports = {
fallbackLng: "en",
keySeparator: false,
i18n: {
defaultLocale: "en",
locales: [
"en",
"pl",
"ru",
"fr",
"de",
"tr",
"kz",
"zh-Hant",
"zh-Hans",
"fa",
"ko",
"pt-br",
],
localeDetection: false,
},
};

View File

@@ -35,6 +35,8 @@
"test": "vitest --config __test__/vitest.config.ts"
},
"dependencies": {
"react-confetti-explosion":"2.1.2",
"@stepperize/react": "4.0.1",
"@codemirror/lang-json": "^6.0.1",
"@codemirror/lang-yaml": "^6.1.1",
"@codemirror/language": "^6.10.1",

View File

@@ -1,5 +1,6 @@
import "@/styles/globals.css";
import { SearchCommand } from "@/components/dashboard/search-command";
import { Toaster } from "@/components/ui/sonner";
import { Languages } from "@/lib/languages";
import { api } from "@/utils/api";
@@ -33,10 +34,10 @@ const MyApp = ({
return (
<>
<style jsx global>{`
:root {
--font-inter: ${inter.style.fontFamily};
}
`}</style>
:root {
--font-inter: ${inter.style.fontFamily};
}
`}</style>
<Head>
<title>Dokploy</title>
</Head>
@@ -56,6 +57,7 @@ const MyApp = ({
forcedTheme={Component.theme}
>
<Toaster richColors />
<SearchCommand />
{getLayout(<Component {...pageProps} />)}
</ThemeProvider>
</>
@@ -63,20 +65,13 @@ const MyApp = ({
};
export default api.withTRPC(
appWithTranslation(
MyApp,
// keep this in sync with next-i18next.config.js
// if you want to know why don't just import the config file, this because next-i18next.config.js must be a CJS, but the rest of the code is ESM.
// Add the config here is due to the issue: https://github.com/i18next/next-i18next/issues/2259
// if one day every page is translated, we can safely remove this config.
{
i18n: {
defaultLocale: "en",
locales: Object.values(Languages),
localeDetection: false,
},
fallbackLng: "en",
keySeparator: false,
appWithTranslation(MyApp, {
i18n: {
defaultLocale: "en",
locales: Object.values(Languages),
localeDetection: false,
},
),
fallbackLng: "en",
keySeparator: false,
}),
);

View File

@@ -13,7 +13,7 @@ export default async function handler(
const gitlab = await findGitlabById(gitlabId as string);
const response = await fetch("https://gitlab.com/oauth/token", {
const response = await fetch(`${gitlab.gitlabUrl}/oauth/token`, {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",

View File

@@ -39,6 +39,7 @@ import type {
GetServerSidePropsContext,
InferGetServerSidePropsType,
} from "next";
import Head from "next/head";
import Link from "next/link";
import { useRouter } from "next/router";
import React, { type ReactElement } from "react";
@@ -189,6 +190,9 @@ const Project = (
<BreadcrumbLink>{data?.name}</BreadcrumbLink>
</BreadcrumbItem>
</Breadcrumb>
<Head>
<title>Project {data?.name} | Dokploy</title>
</Head>
<header className="mb-6 flex w-full items-center justify-between flex-wrap gap-2">
<div className="flex flex-col gap-2">
<h1 className="text-xl font-bold lg:text-3xl">{data?.name}</h1>

View File

@@ -41,9 +41,10 @@ import type {
GetServerSidePropsContext,
InferGetServerSidePropsType,
} from "next";
import Head from "next/head";
import Link from "next/link";
import { useRouter } from "next/router";
import React, { useState, type ReactElement } from "react";
import React, { useState, useEffect, type ReactElement } from "react";
import superjson from "superjson";
type TabState =
@@ -61,7 +62,14 @@ const Service = (
const { applicationId, activeTab } = props;
const router = useRouter();
const { projectId } = router.query;
const [tab, setSab] = useState<TabState>(activeTab);
const [tab, setTab] = useState<TabState>(activeTab);
useEffect(() => {
if (router.query.tab) {
setTab(router.query.tab as TabState);
}
}, [router.query.tab]);
const { data } = api.application.one.useQuery(
{ applicationId },
{
@@ -101,6 +109,11 @@ const Service = (
<BreadcrumbLink>{data?.name}</BreadcrumbLink>
</BreadcrumbItem>
</Breadcrumb>
<Head>
<title>
Project {data?.project.name} | {data?.name} | Dokploy
</title>
</Head>
<header className="mb-6 flex w-full items-center justify-between max-sm:flex-wrap gap-4">
<div className="flex flex-col justify-between w-fit gap-2">
<div className="flex flex-row items-center gap-2 xl:gap-4 flex-wrap">
@@ -185,9 +198,9 @@ const Service = (
defaultValue="general"
className="w-full"
onValueChange={(e) => {
setSab(e as TabState);
setTab(e as TabState);
const newPath = `/dashboard/project/${projectId}/services/application/${applicationId}?tab=${e}`;
router.push(newPath, undefined, { shallow: true });
router.push(newPath);
}}
>
<div className="flex flex-row items-center justify-between w-full gap-4">

View File

@@ -35,9 +35,10 @@ import type {
GetServerSidePropsContext,
InferGetServerSidePropsType,
} from "next";
import Head from "next/head";
import Link from "next/link";
import { useRouter } from "next/router";
import React, { useState, type ReactElement } from "react";
import React, { useState, useEffect, type ReactElement } from "react";
import superjson from "superjson";
type TabState =
@@ -54,7 +55,14 @@ const Service = (
const { composeId, activeTab } = props;
const router = useRouter();
const { projectId } = router.query;
const [tab, setSab] = useState<TabState>(activeTab);
const [tab, setTab] = useState<TabState>(activeTab);
useEffect(() => {
if (router.query.tab) {
setTab(router.query.tab as TabState);
}
}, [router.query.tab]);
const { data } = api.compose.one.useQuery(
{ composeId },
{
@@ -94,6 +102,11 @@ const Service = (
<BreadcrumbLink>{data?.name}</BreadcrumbLink>
</BreadcrumbItem>
</Breadcrumb>
<Head>
<title>
Project {data?.project.name} | {data?.name} | Dokploy
</title>
</Head>
<header className="mb-6 flex w-full items-center justify-between max-sm:flex-wrap gap-4">
<div className="flex flex-col justify-between w-fit gap-2">
<div className="flex flex-row items-center gap-2 xl:gap-4 flex-wrap">
@@ -177,9 +190,9 @@ const Service = (
defaultValue="general"
className="w-full"
onValueChange={(e) => {
setSab(e as TabState);
setTab(e as TabState);
const newPath = `/dashboard/project/${projectId}/services/compose/${composeId}?tab=${e}`;
router.push(newPath, undefined, { shallow: true });
router.push(newPath);
}}
>
<div className="flex flex-row items-center justify-between w-full gap-4">

View File

@@ -35,6 +35,7 @@ import type {
GetServerSidePropsContext,
InferGetServerSidePropsType,
} from "next";
import Head from "next/head";
import Link from "next/link";
import { useRouter } from "next/router";
import React, { useState, type ReactElement } from "react";
@@ -82,6 +83,11 @@ const Mariadb = (
<BreadcrumbLink>{data?.name}</BreadcrumbLink>
</BreadcrumbItem>
</Breadcrumb>
<Head>
<title>
Project {data?.project.name} | {data?.name} | Dokploy
</title>
</Head>
<header className="mb-6 flex w-full items-center justify-between max-sm:flex-wrap gap-4">
<div className="flex flex-col justify-between w-fit gap-2">
<div className="flex flex-row items-center gap-2 xl:gap-4 flex-wrap">

View File

@@ -35,6 +35,7 @@ import type {
GetServerSidePropsContext,
InferGetServerSidePropsType,
} from "next";
import Head from "next/head";
import Link from "next/link";
import { useRouter } from "next/router";
import React, { useState, type ReactElement } from "react";
@@ -83,6 +84,11 @@ const Mongo = (
<BreadcrumbLink>{data?.name}</BreadcrumbLink>
</BreadcrumbItem>
</Breadcrumb>
<Head>
<title>
Project {data?.project.name} | {data?.name} | Dokploy
</title>
</Head>
<header className="mb-6 flex w-full items-center justify-between max-sm:flex-wrap gap-4">
<div className="flex flex-col justify-between w-fit gap-2">
<div className="flex flex-row items-center gap-2 xl:gap-4 flex-wrap">

View File

@@ -35,6 +35,7 @@ import type {
GetServerSidePropsContext,
InferGetServerSidePropsType,
} from "next";
import Head from "next/head";
import Link from "next/link";
import { useRouter } from "next/router";
import React, { useState, type ReactElement } from "react";
@@ -81,6 +82,11 @@ const MySql = (
<BreadcrumbLink>{data?.name}</BreadcrumbLink>
</BreadcrumbItem>
</Breadcrumb>
<Head>
<title>
Project {data?.project.name} | {data?.name} | Dokploy
</title>
</Head>
<header className="mb-6 flex w-full items-center justify-between max-sm:flex-wrap gap-4">
<div className="flex flex-col justify-between w-fit gap-2">
<div className="flex flex-row items-center gap-2 xl:gap-4 flex-wrap">

View File

@@ -35,6 +35,7 @@ import type {
GetServerSidePropsContext,
InferGetServerSidePropsType,
} from "next";
import Head from "next/head";
import Link from "next/link";
import { useRouter } from "next/router";
import React, { useState, type ReactElement } from "react";
@@ -82,6 +83,11 @@ const Postgresql = (
<BreadcrumbLink>{data?.name}</BreadcrumbLink>
</BreadcrumbItem>
</Breadcrumb>
<Head>
<title>
Project {data?.project.name} | {data?.name} | Dokploy
</title>
</Head>
<header className="mb-6 flex w-full items-center justify-between max-sm:flex-wrap gap-4">
<div className="flex flex-col justify-between w-fit gap-2">
<div className="flex flex-row items-center gap-2 xl:gap-4 flex-wrap">

View File

@@ -34,6 +34,7 @@ import type {
GetServerSidePropsContext,
InferGetServerSidePropsType,
} from "next";
import Head from "next/head";
import Link from "next/link";
import { useRouter } from "next/router";
import React, { useState, type ReactElement } from "react";
@@ -81,6 +82,11 @@ const Redis = (
<BreadcrumbLink>{data?.name}</BreadcrumbLink>
</BreadcrumbItem>
</Breadcrumb>
<Head>
<title>
Project {data?.project.name} | {data?.name} | Dokploy
</title>
</Head>
<header className="mb-6 flex w-full items-center justify-between max-sm:flex-wrap gap-4">
<div className="flex flex-col justify-between w-fit gap-2">
<div className="flex flex-row items-center gap-2 xl:gap-4 flex-wrap">

View File

@@ -21,7 +21,7 @@ export default Page;
Page.getLayout = (page: ReactElement) => {
return (
<DashboardLayout tab={"settings"}>
<DashboardLayout tab={"settings"} metaName="Appearance">
<SettingsLayout>{page}</SettingsLayout>
</DashboardLayout>
);

View File

@@ -16,7 +16,7 @@ export default Page;
Page.getLayout = (page: ReactElement) => {
return (
<DashboardLayout tab={"settings"}>
<DashboardLayout tab={"settings"} metaName="Billing">
<SettingsLayout>{page}</SettingsLayout>
</DashboardLayout>
);

View File

@@ -19,7 +19,7 @@ export default Page;
Page.getLayout = (page: ReactElement) => {
return (
<DashboardLayout tab={"settings"}>
<DashboardLayout tab={"settings"} metaName="Certificates">
<SettingsLayout>{page}</SettingsLayout>
</DashboardLayout>
);

View File

@@ -20,7 +20,7 @@ export default Page;
Page.getLayout = (page: ReactElement) => {
return (
<DashboardLayout tab={"settings"}>
<DashboardLayout tab={"settings"} metaName="Nodes">
<SettingsLayout>{page}</SettingsLayout>
</DashboardLayout>
);

View File

@@ -20,7 +20,7 @@ export default Page;
Page.getLayout = (page: ReactElement) => {
return (
<DashboardLayout tab={"settings"}>
<DashboardLayout tab={"settings"} metaName="S3 Destinations">
<SettingsLayout>{page}</SettingsLayout>
</DashboardLayout>
);

View File

@@ -20,7 +20,7 @@ export default Page;
Page.getLayout = (page: ReactElement) => {
return (
<DashboardLayout tab={"settings"}>
<DashboardLayout tab={"settings"} metaName="Git Providers">
<SettingsLayout>{page}</SettingsLayout>
</DashboardLayout>
);

View File

@@ -20,7 +20,7 @@ export default Page;
Page.getLayout = (page: ReactElement) => {
return (
<DashboardLayout tab={"settings"}>
<DashboardLayout tab={"settings"} metaName="Notifications">
<SettingsLayout>{page}</SettingsLayout>
</DashboardLayout>
);

View File

@@ -1,5 +1,6 @@
import { GenerateToken } from "@/components/dashboard/settings/profile/generate-token";
import { ProfileForm } from "@/components/dashboard/settings/profile/profile-form";
import { RemoveSelfAccount } from "@/components/dashboard/settings/profile/remove-self-account";
import { DashboardLayout } from "@/components/layouts/dashboard-layout";
import { SettingsLayout } from "@/components/layouts/settings-layout";
import { appRouter } from "@/server/api/root";
@@ -21,10 +22,14 @@ const Page = () => {
enabled: !!data?.id && data?.rol === "user",
},
);
const { data: isCloud } = api.settings.isCloud.useQuery();
return (
<div className="flex flex-col gap-4 w-full">
<ProfileForm />
{(user?.canAccessToAPI || data?.rol === "admin") && <GenerateToken />}
{isCloud && <RemoveSelfAccount />}
</div>
);
};
@@ -33,7 +38,7 @@ export default Page;
Page.getLayout = (page: ReactElement) => {
return (
<DashboardLayout tab={"settings"}>
<DashboardLayout tab={"settings"} metaName="Profile">
<SettingsLayout>{page}</SettingsLayout>
</DashboardLayout>
);

View File

@@ -20,7 +20,7 @@ export default Page;
Page.getLayout = (page: ReactElement) => {
return (
<DashboardLayout tab={"settings"}>
<DashboardLayout tab={"settings"} metaName="Registry">
<SettingsLayout>{page}</SettingsLayout>
</DashboardLayout>
);

View File

@@ -23,7 +23,7 @@ export default Page;
Page.getLayout = (page: ReactElement) => {
return (
<DashboardLayout tab={"settings"}>
<DashboardLayout tab={"settings"} metaName="Server">
<SettingsLayout>{page}</SettingsLayout>
</DashboardLayout>
);

View File

@@ -20,7 +20,7 @@ export default Page;
Page.getLayout = (page: ReactElement) => {
return (
<DashboardLayout tab={"settings"}>
<DashboardLayout tab={"settings"} metaName="Servers">
<SettingsLayout>{page}</SettingsLayout>
</DashboardLayout>
);

View File

@@ -20,7 +20,7 @@ export default Page;
Page.getLayout = (page: ReactElement) => {
return (
<DashboardLayout tab={"settings"}>
<DashboardLayout tab={"settings"} metaName="SSH Keys">
<SettingsLayout>{page}</SettingsLayout>
</DashboardLayout>
);

View File

@@ -20,7 +20,7 @@ export default Page;
Page.getLayout = (page: ReactElement) => {
return (
<DashboardLayout tab={"settings"}>
<DashboardLayout tab={"settings"} metaName="Users">
<SettingsLayout>{page}</SettingsLayout>
</DashboardLayout>
);

View File

@@ -134,7 +134,7 @@ const Register = ({ isCloud }: Props) => {
</span>
</div>
)}
{data && (
{data?.type === "cloud" && (
<AlertBlock type="success" className="mx-4 my-2">
<span>
Registration succesfuly, Please check your inbox or spam

View File

@@ -0,0 +1 @@
{}

View File

@@ -0,0 +1,44 @@
{
"settings.common.save": "Salva",
"settings.server.domain.title": "Dominio del server",
"settings.server.domain.description": "Aggiungi un dominio alla tua applicazione server.",
"settings.server.domain.form.domain": "Dominio",
"settings.server.domain.form.letsEncryptEmail": "Email di Let's Encrypt",
"settings.server.domain.form.certificate.label": "Certificato",
"settings.server.domain.form.certificate.placeholder": "Seleziona un certificato",
"settings.server.domain.form.certificateOptions.none": "Nessuno",
"settings.server.domain.form.certificateOptions.letsencrypt": "Let's Encrypt (Predefinito)",
"settings.server.webServer.title": "Server Web",
"settings.server.webServer.description": "Ricarica o pulisci il server web.",
"settings.server.webServer.actions": "Azioni",
"settings.server.webServer.reload": "Ricarica",
"settings.server.webServer.watchLogs": "Guarda i log",
"settings.server.webServer.updateServerIp": "Aggiorna IP del server",
"settings.server.webServer.server.label": "Server",
"settings.server.webServer.traefik.label": "Traefik",
"settings.server.webServer.traefik.modifyEnv": "Modifica Env",
"settings.server.webServer.storage.label": "Spazio",
"settings.server.webServer.storage.cleanUnusedImages": "Pulisci immagini inutilizzate",
"settings.server.webServer.storage.cleanUnusedVolumes": "Pulisci volumi inutilizzati",
"settings.server.webServer.storage.cleanStoppedContainers": "Pulisci container fermati",
"settings.server.webServer.storage.cleanDockerBuilder": "Pulisci Docker Builder e sistema",
"settings.server.webServer.storage.cleanMonitoring": "Pulisci monitoraggio",
"settings.server.webServer.storage.cleanAll": "Pulisci tutto",
"settings.profile.title": "Account",
"settings.profile.description": "Modifica i dettagli del tuo profilo qui.",
"settings.profile.email": "Email",
"settings.profile.password": "Password",
"settings.profile.avatar": "Avatar",
"settings.appearance.title": "Aspetto",
"settings.appearance.description": "Personalizza il tema della tua dashboard.",
"settings.appearance.theme": "Tema",
"settings.appearance.themeDescription": "Seleziona un tema per la tua dashboard",
"settings.appearance.themes.light": "Chiaro",
"settings.appearance.themes.dark": "Scuro",
"settings.appearance.themes.system": "Sistema",
"settings.appearance.language": "Lingua",
"settings.appearance.languageDescription": "Seleziona una lingua per la tua dashboard"
}

View File

@@ -0,0 +1 @@
{}

View File

@@ -0,0 +1,44 @@
{
"settings.common.save": "保存",
"settings.server.domain.title": "サーバードメイン",
"settings.server.domain.description": "サーバーアプリケーションにドメインを追加",
"settings.server.domain.form.domain": "ドメイン",
"settings.server.domain.form.letsEncryptEmail": "Let's Encrypt メールアドレス",
"settings.server.domain.form.certificate.label": "証明書",
"settings.server.domain.form.certificate.placeholder": "証明書を選択",
"settings.server.domain.form.certificateOptions.none": "なし",
"settings.server.domain.form.certificateOptions.letsencrypt": "Let's Encrypt (デフォルト)",
"settings.server.webServer.title": "ウェブサーバー",
"settings.server.webServer.description": "ウェブサーバーをリロードまたはクリーンアップします",
"settings.server.webServer.actions": "アクション",
"settings.server.webServer.reload": "リロード",
"settings.server.webServer.watchLogs": "ログを監視",
"settings.server.webServer.updateServerIp": "サーバーIPを更新",
"settings.server.webServer.server.label": "サーバー",
"settings.server.webServer.traefik.label": "Traefik",
"settings.server.webServer.traefik.modifyEnv": "環境設定を変更",
"settings.server.webServer.storage.label": "ストレージ",
"settings.server.webServer.storage.cleanUnusedImages": "未使用のイメージを削除",
"settings.server.webServer.storage.cleanUnusedVolumes": "未使用のボリュームを削除",
"settings.server.webServer.storage.cleanStoppedContainers": "停止中のコンテナを削除",
"settings.server.webServer.storage.cleanDockerBuilder": "Docker ビルダー&システムをクリーンアップ",
"settings.server.webServer.storage.cleanMonitoring": "モニタリングをクリーンアップ",
"settings.server.webServer.storage.cleanAll": "すべてをクリーンアップ",
"settings.profile.title": "アカウント",
"settings.profile.description": "ここでプロフィールの詳細を変更できます",
"settings.profile.email": "メールアドレス",
"settings.profile.password": "パスワード",
"settings.profile.avatar": "アバター",
"settings.appearance.title": "外観",
"settings.appearance.description": "ダッシュボードのテーマをカスタマイズ",
"settings.appearance.theme": "テーマ",
"settings.appearance.themeDescription": "ダッシュボードのテーマを選択してください",
"settings.appearance.themes.light": "ライト",
"settings.appearance.themes.dark": "ダーク",
"settings.appearance.themes.system": "システム",
"settings.appearance.language": "言語",
"settings.appearance.languageDescription": "ダッシュボードの言語を選択してください"
}

View File

@@ -0,0 +1 @@
<svg width="2500" height="2500" viewBox="0 0 256 256" xmlns="http://www.w3.org/2000/svg" preserveAspectRatio="xMinYMin meet"><path d="M255.96 134.393c0-21.521-13.373-40.117-33.223-47.43a75.239 75.239 0 0 0 1.253-13.791c0-39.909-32.386-72.295-72.295-72.295-23.193 0-44.923 11.074-58.505 30.088-6.686-5.224-14.835-7.94-23.402-7.94-21.104 0-38.446 17.133-38.446 38.446 0 4.597.836 9.194 2.298 13.373C13.582 81.739 0 100.962 0 122.274c0 21.522 13.373 40.327 33.431 47.64-.835 4.388-1.253 8.985-1.253 13.79 0 39.7 32.386 72.087 72.086 72.087 23.402 0 44.924-11.283 58.505-30.088 6.686 5.223 15.044 8.149 23.611 8.149 21.104 0 38.446-17.134 38.446-38.446 0-4.597-.836-9.194-2.298-13.373 19.64-7.104 33.431-26.327 33.431-47.64z" fill="#FFF"/><path d="M100.085 110.364l57.043 26.119 57.669-50.565a64.312 64.312 0 0 0 1.253-12.746c0-35.52-28.834-64.355-64.355-64.355-21.313 0-41.162 10.447-53.072 27.998l-9.612 49.73 11.074 23.82z" fill="#F4BD19"/><path d="M40.953 170.75c-.835 4.179-1.253 8.567-1.253 12.955 0 35.52 29.043 64.564 64.564 64.564 21.522 0 41.372-10.656 53.49-28.208l9.403-49.729-12.746-24.238-57.251-26.118-56.207 50.774z" fill="#3CBEB1"/><path d="M40.536 71.918l39.073 9.194 8.775-44.506c-5.432-4.179-11.91-6.268-18.805-6.268-16.925 0-30.924 13.79-30.924 30.924 0 3.552.627 7.313 1.88 10.656z" fill="#E9478C"/><path d="M37.192 81.32c-17.551 5.642-29.67 22.567-29.67 40.954 0 17.97 11.074 34.059 27.79 40.327l54.953-49.73-10.03-21.52-43.043-10.03z" fill="#2C458F"/><path d="M167.784 219.852c5.432 4.18 11.91 6.478 18.596 6.478 16.925 0 30.924-13.79 30.924-30.924 0-3.761-.627-7.314-1.88-10.657l-39.073-9.193-8.567 44.296z" fill="#95C63D"/><path d="M175.724 165.317l43.043 10.03c17.551-5.85 29.67-22.566 29.67-40.954 0-17.97-11.074-33.849-27.79-40.326l-56.415 49.311 11.492 21.94z" fill="#176655"/></svg>

After

Width:  |  Height:  |  Size: 1.8 KiB

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 71 25"><path fill="#fff" d="M22.4 16a1.6 1.6 0 0 1 1.6 1.6v4.8a1.6 1.6 0 0 1-1.6 1.6h-4.8a1.6 1.6 0 0 1-1.6-1.6v-4.8a1.6 1.6 0 0 1 1.6-1.6zM6.4 0A1.6 1.6 0 0 1 8 1.6v4.8A1.6 1.6 0 0 1 6.4 8H1.6A1.6 1.6 0 0 1 0 6.4V1.6A1.6 1.6 0 0 1 1.6 0zM23.531 8.469c.3-.3.469-.707.469-1.132V1.6A1.6 1.6 0 0 0 22.4 0h-4.8A1.6 1.6 0 0 0 16 1.6v4.8A1.6 1.6 0 0 1 14.4 8H8.663a1.6 1.6 0 0 0-1.132.469L.47 15.53A1.6 1.6 0 0 0 0 16.663V22.4A1.6 1.6 0 0 0 1.6 24h4.8A1.6 1.6 0 0 0 8 22.4v-4.8A1.6 1.6 0 0 1 9.6 16h5.737a1.6 1.6 0 0 0 1.132-.469zM31.22 20V3.8h3.62v7.1q.42-.72 1.18-1.12.78-.42 1.78-.42 1.74 0 2.64 1.12.92 1.1.92 3.24V20h-3.62v-5.6q0-1.82-1.38-1.82-.74 0-1.14.52-.38.5-.38 1.44V20zm16.6.32q-2.46 0-3.74-1.24-1.26-1.24-1.26-3.62V9.68h3.64v5.66q0 1.76 1.38 1.76.7 0 1.02-.42t.32-1.34V9.68h3.64v5.78q0 2.38-1.28 3.62-1.26 1.24-3.72 1.24m6.546-.32V3.8h3.62V20zm5.955 4.9 2.58-5.46-4.24-9.76h3.98l2.1 6.06 1.94-6.06h3.88l-6.6 15.22z"/></svg>

After

Width:  |  Height:  |  Size: 996 B

View File

@@ -0,0 +1,6 @@
<svg width="128" height="128" viewBox="0 0 128 128" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="128" height="128" rx="28" fill="black"/>
<path d="M89.6763 65.8642L102.71 65.8642C104.285 65.8642 105.563 67.1415 105.563 68.717L105.563 77.7818C105.563 79.3573 104.285 80.6346 102.71 80.6346L91.1299 80.6346C90.3733 80.6346 89.6477 80.9351 89.1127 81.4701L71.4183 99.1645C70.8833 99.6995 70.1577 100 69.4011 100L59.5869 100C58.04 100 56.7749 98.7673 56.735 97.2209L56.4975 88.0155C56.4561 86.4116 57.7449 85.0891 59.3493 85.0891L68.0882 85.0891C68.8448 85.0891 69.5704 84.7885 70.1054 84.2535L87.6591 66.6998C88.1941 66.1648 88.9197 65.8642 89.6763 65.8642Z" fill="white"/>
<path d="M55.9431 27.707L68.9766 27.707C70.5521 27.707 71.8293 28.9843 71.8293 30.5598L71.8293 39.6246C71.8293 41.2001 70.5521 42.4774 68.9766 42.4774L57.3967 42.4774C56.6401 42.4774 55.9144 42.7779 55.3794 43.3129L37.685 61.0073C37.15 61.5423 36.4244 61.8429 35.6678 61.8429L25.8536 61.8429C24.3067 61.8429 23.0417 60.6101 23.0018 59.0637L22.7642 49.8583C22.7228 48.2544 24.0117 46.9319 25.616 46.9319L34.3549 46.9319C35.1115 46.9319 35.8371 46.6313 36.3721 46.0963L53.9258 28.5426C54.4608 28.0076 55.1865 27.707 55.9431 27.707Z" fill="white"/>
<path d="M89.6763 36.3423L102.71 36.3423C104.285 36.3423 105.563 37.6195 105.563 39.1951L105.563 48.2598C105.563 49.8354 104.285 51.1126 102.71 51.1126L91.1299 51.1126C90.3733 51.1126 89.6477 51.4132 89.1127 51.9482L71.4183 69.6426C70.8833 70.1776 70.1577 70.4782 69.4011 70.4782L58.5061 70.4782C57.7705 70.4782 57.0633 70.7623 56.5322 71.2714L36.7587 90.2227C36.2276 90.7318 35.5204 91.0159 34.7847 91.0159L26.1705 91.0159C24.5949 91.0159 23.3177 89.7387 23.3177 88.1632L23.3177 78.9108C23.3177 77.3353 24.5949 76.0581 26.1704 76.0581L34.7494 76.0581C35.506 76.0581 36.2316 75.7575 36.7666 75.2225L55.5864 56.4027C56.1214 55.8677 56.847 55.5672 57.6036 55.5672L68.0882 55.5672C68.8448 55.5672 69.5704 55.2666 70.1054 54.7316L87.6591 37.1778C88.1941 36.6428 88.9197 36.3423 89.6763 36.3423Z" fill="white"/>
</svg>

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 7.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

View File

@@ -20,6 +20,8 @@ import {
getUserByToken,
lucia,
luciaToken,
removeAdminByAuthId,
removeUserByAuthId,
sendDiscordNotification,
sendEmailNotification,
updateAuthById,
@@ -59,14 +61,20 @@ export const authRouter = createTRPCRouter({
if (IS_CLOUD) {
await sendDiscordNotificationWelcome(newAdmin);
await sendVerificationEmail(newAdmin.id);
return true;
return {
status: "success",
type: "cloud",
};
}
const session = await lucia.createSession(newAdmin.id || "", {});
ctx.res.appendHeader(
"Set-Cookie",
lucia.createSessionCookie(session.id).serialize(),
);
return true;
return {
status: "success",
type: "selfhosted",
};
} catch (error) {
throw new TRPCError({
code: "BAD_REQUEST",
@@ -178,6 +186,20 @@ export const authRouter = createTRPCRouter({
update: protectedProcedure
.input(apiUpdateAuth)
.mutation(async ({ ctx, input }) => {
const currentAuth = await findAuthByEmail(ctx.user.email);
if (input.password) {
const correctPassword = bcrypt.compareSync(
input.password,
currentAuth?.password || "",
);
if (!correctPassword) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Current password is incorrect",
});
}
}
const auth = await updateAuthById(ctx.user.authId, {
...(input.email && { email: input.email.toLowerCase() }),
...(input.password && {
@@ -188,6 +210,47 @@ export const authRouter = createTRPCRouter({
return auth;
}),
removeSelfAccount: protectedProcedure
.input(
z.object({
password: z.string().min(1),
}),
)
.mutation(async ({ ctx, input }) => {
if (!IS_CLOUD) {
throw new TRPCError({
code: "NOT_FOUND",
message: "This feature is only available in the cloud version",
});
}
const currentAuth = await findAuthByEmail(ctx.user.email);
const correctPassword = bcrypt.compareSync(
input.password,
currentAuth?.password || "",
);
if (!correctPassword) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Password is incorrect",
});
}
const { req, res } = ctx;
const { session } = await validateRequest(req, res);
if (!session) return false;
await lucia.invalidateSession(session.id);
res.setHeader("Set-Cookie", lucia.createBlankSessionCookie().serialize());
if (ctx.user.rol === "admin") {
await removeAdminByAuthId(ctx.user.authId);
} else {
await removeUserByAuthId(ctx.user.authId);
}
return true;
}),
generateToken: protectedProcedure.mutation(async ({ ctx, input }) => {
const auth = await findAuthById(ctx.user.authId);
@@ -440,7 +503,7 @@ export const sendDiscordNotificationWelcome = async (newAdmin: Auth) => {
webhookUrl: process.env.DISCORD_WEBHOOK_URL || "",
},
{
title: " New User Registered",
title: " New User Registered",
color: 0x00ff00,
fields: [
{

View File

@@ -142,6 +142,10 @@ export const gitlabRouter = createTRPCRouter({
name: input.name,
adminId: ctx.user.adminId,
});
await updateGitlab(input.gitlabId, {
...input,
});
} else {
await updateGitlab(input.gitlabId, {
...input,

View File

@@ -18,6 +18,7 @@ import {
import {
IS_CLOUD,
createServer,
defaultCommand,
deleteServer,
findAdminById,
findServerById,
@@ -69,6 +70,11 @@ export const serverRouter = createTRPCRouter({
return server;
}),
getDefaultCommand: protectedProcedure
.input(apiFindOneServer)
.query(async ({ input, ctx }) => {
return defaultCommand();
}),
all: protectedProcedure.query(async ({ ctx }) => {
const result = await db
.select({

View File

@@ -17,7 +17,7 @@ import {
updateSSHKeyById,
} from "@dokploy/server";
import { TRPCError } from "@trpc/server";
import { eq } from "drizzle-orm";
import { desc, eq } from "drizzle-orm";
export const sshRouter = createTRPCRouter({
create: protectedProcedure
@@ -71,6 +71,7 @@ export const sshRouter = createTRPCRouter({
all: protectedProcedure.query(async ({ ctx }) => {
return await db.query.sshKeys.findMany({
...(IS_CLOUD && { where: eq(sshKeys.adminId, ctx.user.adminId) }),
orderBy: desc(sshKeys.createdAt),
});
// TODO: Remove this line when the cloud version is ready
}),

View File

@@ -81,7 +81,7 @@ export const stripeRouter = createTRPCRouter({
adminId: admin.adminId,
},
allow_promotion_codes: true,
success_url: `${WEBSITE_URL}/dashboard/settings/billing`,
success_url: `${WEBSITE_URL}/dashboard/settings/servers?success=true`,
cancel_url: `${WEBSITE_URL}/dashboard/settings/billing`,
});

View File

@@ -102,21 +102,13 @@ export const setupDockerContainerTerminalWebSocketServer = (
port: server.port,
username: server.username,
privateKey: server.sshKey?.privateKey,
timeout: 99999,
});
} else {
const shell = getShell();
const ptyProcess = spawn(
shell,
["-c", `docker exec -it ${containerId} ${activeWay}`],
{
name: "xterm-256color",
cwd: process.env.HOME,
env: process.env,
encoding: "utf8",
cols: 80,
rows: 30,
},
{},
);
ptyProcess.onData((data) => {

View File

@@ -70,53 +70,44 @@ export const setupTerminalWebSocketServer = (
let stderr = "";
conn
.once("ready", () => {
conn.shell(
{
term: "terminal",
cols: 80,
rows: 30,
height: 30,
width: 80,
},
(err, stream) => {
if (err) throw err;
conn.shell({}, (err, stream) => {
if (err) throw err;
stream
.on("close", (code: number, signal: string) => {
ws.send(`\nContainer closed with code: ${code}\n`);
conn.end();
})
.on("data", (data: string) => {
stdout += data.toString();
ws.send(data.toString());
})
.stderr.on("data", (data) => {
stderr += data.toString();
ws.send(data.toString());
console.error("Error: ", data.toString());
});
stream
.on("close", (code: number, signal: string) => {
ws.send(`\nContainer closed with code: ${code}\n`);
conn.end();
})
.on("data", (data: string) => {
stdout += data.toString();
ws.send(data.toString());
})
.stderr.on("data", (data) => {
stderr += data.toString();
ws.send(data.toString());
console.error("Error: ", data.toString());
});
ws.on("message", (message) => {
try {
let command: string | Buffer[] | Buffer | ArrayBuffer;
if (Buffer.isBuffer(message)) {
command = message.toString("utf8");
} else {
command = message;
}
stream.write(command.toString());
} catch (error) {
// @ts-ignore
const errorMessage = error?.message as unknown as string;
ws.send(errorMessage);
ws.on("message", (message) => {
try {
let command: string | Buffer[] | Buffer | ArrayBuffer;
if (Buffer.isBuffer(message)) {
command = message.toString("utf8");
} else {
command = message;
}
});
stream.write(command.toString());
} catch (error) {
// @ts-ignore
const errorMessage = error?.message as unknown as string;
ws.send(errorMessage);
}
});
ws.on("close", () => {
stream.end();
});
},
);
ws.on("close", () => {
stream.end();
});
});
})
.on("error", (err) => {
if (err.level === "client-authentication") {
@@ -133,7 +124,6 @@ export const setupTerminalWebSocketServer = (
port: server.port,
username: server.username,
privateKey: server.sshKey?.privateKey,
timeout: 99999,
});
});
};

View File

@@ -0,0 +1,34 @@
version: '3.8'
services:
elasticsearch:
image: docker.elastic.co/elasticsearch/elasticsearch:8.10.2
container_name: elasticsearch
environment:
- discovery.type=single-node
- xpack.security.enabled=false
- bootstrap.memory_lock=true
- ES_JAVA_OPTS=-Xms512m -Xmx512m
ulimits:
memlock:
soft: -1
hard: -1
ports:
- "9200"
volumes:
- es_data:/usr/share/elasticsearch/data
kibana:
image: docker.elastic.co/kibana/kibana:8.10.2
container_name: kibana
environment:
- ELASTICSEARCH_HOSTS=http://elasticsearch:9200
ports:
- "5601"
depends_on:
- elasticsearch
volumes:
es_data:
driver: local

View File

@@ -0,0 +1,28 @@
import {
type DomainSchema,
type Schema,
type Template,
generateRandomDomain,
} from "../utils";
export function generate(schema: Schema): Template {
const mainDomain = generateRandomDomain(schema);
const apiDomain = generateRandomDomain(schema);
const domains: DomainSchema[] = [
{
host: mainDomain,
port: 5601,
serviceName: "kibana",
},
{
host: apiDomain,
port: 9200,
serviceName: "elasticsearch",
},
];
return {
domains,
};
}

View File

@@ -0,0 +1,184 @@
name: ${DOCKER_NAME}
version: "3"
services:
nginx:
networks:
- dokploy-network
image: "nginx:1.21.3"
ports:
- 80
volumes:
- ../files/volumes/nginx/.huly.nginx:/etc/nginx/conf.d/default.conf
restart: unless-stopped
mongodb:
networks:
- dokploy-network
image: "mongo:7-jammy"
environment:
- PUID=1000
- PGID=1000
volumes:
- db:/data/db
restart: unless-stopped
minio:
networks:
- dokploy-network
image: "minio/minio:RELEASE.2024-11-07T00-52-20Z"
command: server /data --address ":9000" --console-address ":9001"
volumes:
- files:/data
restart: unless-stopped
elastic:
networks:
- dokploy-network
image: "elasticsearch:7.14.2"
command: |
/bin/sh -c "./bin/elasticsearch-plugin list | grep -q ingest-attachment || yes | ./bin/elasticsearch-plugin install --silent ingest-attachment;
/usr/local/bin/docker-entrypoint.sh eswrapper"
volumes:
- elastic:/usr/share/elasticsearch/data
environment:
- ELASTICSEARCH_PORT_NUMBER=9200
- BITNAMI_DEBUG=true
- discovery.type=single-node
- ES_JAVA_OPTS=-Xms1024m -Xmx1024m
- http.cors.enabled=true
- http.cors.allow-origin=http://localhost:8082
healthcheck:
interval: 20s
retries: 10
test: curl -s http://localhost:9200/_cluster/health | grep -vq '"status":"red"'
restart: unless-stopped
rekoni:
networks:
- dokploy-network
image: hardcoreeng/rekoni-service:${HULY_VERSION}
environment:
- SECRET=${SECRET}
deploy:
resources:
limits:
memory: 500M
restart: unless-stopped
transactor:
networks:
- dokploy-network
image: hardcoreeng/transactor:${HULY_VERSION}
environment:
- SERVER_PORT=3333
- SERVER_SECRET=${SECRET}
- SERVER_CURSOR_MAXTIMEMS=30000
- DB_URL=mongodb://mongodb:27017
- MONGO_URL=mongodb://mongodb:27017
- STORAGE_CONFIG=minio|minio?accessKey=minioadmin&secretKey=minioadmin
- FRONT_URL=http://localhost:8087
- ACCOUNTS_URL=http://account:3000
- FULLTEXT_URL=http://fulltext:4700
- STATS_URL=http://stats:4900
- LAST_NAME_FIRST=${LAST_NAME_FIRST:-true}
restart: unless-stopped
collaborator:
networks:
- dokploy-network
image: hardcoreeng/collaborator:${HULY_VERSION}
environment:
- COLLABORATOR_PORT=3078
- SECRET=${SECRET}
- ACCOUNTS_URL=http://account:3000
- DB_URL=mongodb://mongodb:27017
- STATS_URL=http://stats:4900
- STORAGE_CONFIG=minio|minio?accessKey=minioadmin&secretKey=minioadmin
restart: unless-stopped
account:
networks:
- dokploy-network
image: hardcoreeng/account:${HULY_VERSION}
environment:
- SERVER_PORT=3000
- SERVER_SECRET=${SECRET}
- DB_URL=mongodb://mongodb:27017
- MONGO_URL=mongodb://mongodb:27017
- TRANSACTOR_URL=ws://transactor:3333;ws${SECURE:+s}://${HOST_ADDRESS}/_transactor
- STORAGE_CONFIG=minio|minio?accessKey=minioadmin&secretKey=minioadmin
- FRONT_URL=http://front:8080
- STATS_URL=http://stats:4900
- MODEL_ENABLED=*
- ACCOUNTS_URL=http://localhost:3000
- ACCOUNT_PORT=3000
restart: unless-stopped
workspace:
networks:
- dokploy-network
image: hardcoreeng/workspace:${HULY_VERSION}
environment:
- SERVER_SECRET=${SECRET}
- DB_URL=mongodb://mongodb:27017
- MONGO_URL=mongodb://mongodb:27017
- TRANSACTOR_URL=ws://transactor:3333;ws${SECURE:+s}://${HOST_ADDRESS}/_transactor
- STORAGE_CONFIG=minio|minio?accessKey=minioadmin&secretKey=minioadmin
- MODEL_ENABLED=*
- ACCOUNTS_URL=http://account:3000
- STATS_URL=http://stats:4900
restart: unless-stopped
front:
networks:
- dokploy-network
image: hardcoreeng/front:${HULY_VERSION}
environment:
- SERVER_PORT=8080
- SERVER_SECRET=${SECRET}
- LOVE_ENDPOINT=http${SECURE:+s}://${HOST_ADDRESS}/_love
- ACCOUNTS_URL=http${SECURE:+s}://${HOST_ADDRESS}/_accounts
- REKONI_URL=http${SECURE:+s}://${HOST_ADDRESS}/_rekoni
- CALENDAR_URL=http${SECURE:+s}://${HOST_ADDRESS}/_calendar
- GMAIL_URL=http${SECURE:+s}://${HOST_ADDRESS}/_gmail
- TELEGRAM_URL=http${SECURE:+s}://${HOST_ADDRESS}/_telegram
- STATS_URL=http${SECURE:+s}://${HOST_ADDRESS}/_stats
- UPLOAD_URL=/files
- ELASTIC_URL=http://elastic:9200
- COLLABORATOR_URL=ws${SECURE:+s}://${HOST_ADDRESS}/_collaborator
- STORAGE_CONFIG=minio|minio?accessKey=minioadmin&secretKey=minioadmin
- DB_URL=mongodb://mongodb:27017
- MONGO_URL=mongodb://mongodb:27017
- TITLE=${TITLE:-Huly Self Host}
- DEFAULT_LANGUAGE=${DEFAULT_LANGUAGE:-en}
- LAST_NAME_FIRST=${LAST_NAME_FIRST:-true}
- DESKTOP_UPDATES_CHANNEL=selfhost
restart: unless-stopped
fulltext:
networks:
- dokploy-network
image: hardcoreeng/fulltext:${HULY_VERSION}
environment:
- SERVER_SECRET=${SECRET}
- DB_URL=mongodb://mongodb:27017
- FULLTEXT_DB_URL=http://elastic:9200
- ELASTIC_INDEX_NAME=huly_storage_index
- STORAGE_CONFIG=minio|minio?accessKey=minioadmin&secretKey=minioadmin
- REKONI_URL=http://rekoni:4004
- ACCOUNTS_URL=http://account:3000
- STATS_URL=http://stats:4900
restart: unless-stopped
stats:
networks:
- dokploy-network
image: hardcoreeng/stats:${HULY_VERSION}
environment:
- PORT=4900
- SERVER_SECRET=${SECRET}
restart: unless-stopped
volumes:
db:
elastic:
files:

View File

@@ -0,0 +1,152 @@
import {
type DomainSchema,
type Schema,
type Template,
generateBase64,
generateRandomDomain,
} from "../utils";
export function generate(schema: Schema): Template {
const mainDomain = generateRandomDomain(schema);
const hulySecret = generateBase64(64);
const domains: DomainSchema[] = [
{
host: generateRandomDomain(schema),
port: 80,
serviceName: "nginx",
},
];
const envs = [
"HULY_VERSION=v0.6.377",
"DOCKER_NAME=huly",
"",
"# The address of the host or server from which you will access your Huly instance.",
"# This can be a domain name (e.g., huly.example.com) or an IP address (e.g., 192.168.1.1).",
`HOST_ADDRESS=${mainDomain}`,
"",
"# Set this variable to 'true' to enable SSL (HTTPS/WSS). ",
"# Leave it empty to use non-SSL (HTTP/WS).",
"SECURE=",
"",
"# Specify the IP address to bind to; leave blank to bind to all interfaces (0.0.0.0).",
"# Do not use IP:PORT format in HTTP_BIND or HTTP_PORT.",
"HTTP_PORT=80",
"HTTP_BIND=",
"",
"# Huly specific variables",
"TITLE=Huly",
"DEFAULT_LANGUAGE=en",
"LAST_NAME_FIRST=true",
"",
"# The following configs are auto-generated by the setup script. ",
"# Please do not manually overwrite.",
"",
"# Run with --secret to regenerate.",
`SECRET=${hulySecret}`,
];
const mounts: Template["mounts"] = [
{
filePath: "/volumes/nginx/.huly.nginx",
content: `server {
listen 80;
server_name _;
location / {
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_pass http://front:8080;
}
location /_accounts {
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
rewrite ^/_accounts(/.*)$ $1 break;
proxy_pass http://account:3000/;
}
#location /_love {
# proxy_set_header Host $host;
# proxy_set_header X-Real-IP $remote_addr;
# proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
# proxy_set_header X-Forwarded-Proto $scheme;
# proxy_http_version 1.1;
# proxy_set_header Upgrade $http_upgrade;
# proxy_set_header Connection "upgrade";
# rewrite ^/_love(/.*)$ $1 break;
# proxy_pass http://love:8096/;
#}
location /_collaborator {
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
rewrite ^/_collaborator(/.*)$ $1 break;
proxy_pass http://collaborator:3078/;
}
location /_transactor {
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
rewrite ^/_transactor(/.*)$ $1 break;
proxy_pass http://transactor:3333/;
}
location ~ ^/eyJ {
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_pass http://transactor:3333;
}
location /_rekoni {
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
rewrite ^/_rekoni(/.*)$ $1 break;
proxy_pass http://rekoni:4004/;
}
location /_stats {
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
rewrite ^/_stats(/.*)$ $1 break;
proxy_pass http://stats:4900/;
}
}`,
},
];
return {
domains,
envs,
mounts,
};
}

View File

@@ -0,0 +1,33 @@
version: "3.8"
services:
langflow:
image: langflowai/langflow:v1.1.1
ports:
- 7860
depends_on:
- postgres-langflow
environment:
- LANGFLOW_DATABASE_URL=postgresql://${DB_USERNAME}:${DB_PASSWORD}@postgres-langflow:5432/langflow
# This variable defines where the logs, file storage, monitor data and secret keys are stored.
volumes:
- langflow-data:/app/langflow
networks:
- dokploy-network
postgres-langflow:
image: postgres:16
environment:
POSTGRES_USER: ${DB_USERNAME}
POSTGRES_PASSWORD: ${DB_PASSWORD}
POSTGRES_DB: langflow
ports:
- 5432
volumes:
- langflow-postgres:/var/lib/postgresql/data
networks:
- dokploy-network
volumes:
langflow-postgres:
langflow-data:

View File

@@ -0,0 +1,28 @@
import {
type DomainSchema,
type Schema,
type Template,
generatePassword,
generateRandomDomain,
} from "../utils";
export function generate(schema: Schema): Template {
const mainDomain = generateRandomDomain(schema);
const dbPassword = generatePassword();
const dbUsername = "langflow";
const domains: DomainSchema[] = [
{
host: mainDomain,
port: 7860,
serviceName: "langflow",
},
];
const envs = [`DB_PASSWORD=${dbPassword}`, `DB_USERNAME=${dbUsername}`];
return {
domains,
envs,
};
}

View File

@@ -0,0 +1,42 @@
services:
app:
depends_on:
postgres:
condition: service_healthy
image: svhd/logto:1.22.0
entrypoint: ["sh", "-c", "npm run cli db seed -- --swe && npm start"]
ports:
- 3001
- 3002
networks:
- dokploy-network
environment:
TRUST_PROXY_HEADER: 1
DB_URL: postgres://logto:${LOGTO_POSTGRES_PASSWORD}@postgres:5432/logto
ENDPOINT: ${LOGTO_ENDPOINT}
ADMIN_ENDPOINT: ${LOGTO_ADMIN_ENDPOINT}
volumes:
- logto-connectors:/etc/logto/packages/core/connectors
postgres:
image: postgres:17-alpine
user: postgres
networks:
- dokploy-network
environment:
POSTGRES_USER: logto
POSTGRES_PASSWORD: ${LOGTO_POSTGRES_PASSWORD}
volumes:
- postgres-data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready"]
interval: 10s
timeout: 5s
retries: 5
networks:
dokploy-network:
external: true
volumes:
logto-connectors:
postgres-data:

View File

@@ -0,0 +1,37 @@
import {
type DomainSchema,
type Schema,
type Template,
generatePassword,
generateRandomDomain,
} from "../utils";
export function generate(schema: Schema): Template {
const mainDomain = generateRandomDomain(schema);
const adminDomain = generateRandomDomain(schema);
const postgresPassword = generatePassword();
const domains: DomainSchema[] = [
{
host: mainDomain,
port: 3001,
serviceName: "app",
},
{
host: adminDomain,
port: 3002,
serviceName: "app",
},
];
const envs = [
`LOGTO_ENDPOINT=http://${adminDomain}`,
`LOGTO_ADMIN_ENDPOINT=http://${adminDomain}`,
`LOGTO_POSTGRES_PASSWORD=${postgresPassword}`,
];
return {
domains,
envs,
};
}

View File

@@ -1,7 +1,7 @@
version: "3.8"
services:
n8n:
image: docker.n8n.io/n8nio/n8n:1.48.1
image: docker.n8n.io/n8nio/n8n:1.70.3
restart: always
environment:
- N8N_HOST=${N8N_HOST}

View File

@@ -0,0 +1,213 @@
## Common flags:
# demo-users
# email-verification
# log-emails
# log-invitation-tokens
# login-with-github
# login-with-gitlab
# login-with-google
# login-with-ldap
# login-with-oidc
# login-with-password
# prepl-server
# registration
# secure-session-cookies
# smtp
# smtp-debug
# telemetry
# webhooks
##
## You can read more about all available flags and other
## environment variables here:
## https://help.penpot.app/technical-guide/configuration/#advanced-configuration
#
# WARNING: if you're exposing Penpot to the internet, you should remove the flags
# 'disable-secure-session-cookies' and 'disable-email-verification'
volumes:
penpot_postgres_v15:
penpot_assets:
penpot_traefik:
# penpot_minio:
services:
penpot-frontend:
image: "penpotapp/frontend:2.3.2"
restart: always
ports:
- 8080
- 9001
volumes:
- penpot_assets:/opt/data/assets
depends_on:
- penpot-backend
- penpot-exporter
networks:
- dokploy-network
environment:
PENPOT_FLAGS: disable-email-verification enable-smtp enable-prepl-server disable-secure-session-cookies
penpot-backend:
image: "penpotapp/backend:2.3.2"
restart: always
volumes:
- penpot_assets:/opt/data/assets
depends_on:
- penpot-postgres
- penpot-redis
networks:
- dokploy-network
## Configuration envronment variables for the backend
## container.
environment:
PENPOT_PUBLIC_URI: http://${DOMAIN_NAME}
PENPOT_FLAGS: disable-email-verification enable-smtp enable-prepl-server disable-secure-session-cookies
## Penpot SECRET KEY. It serves as a master key from which other keys for subsystems
## (eg http sessions, or invitations) are derived.
##
## If you leave it commented, all created sessions and invitations will
## become invalid on container restart.
##
## If you going to uncomment this, we recommend to use a trully randomly generated
## 512 bits base64 encoded string here. You can generate one with:
##
## python3 -c "import secrets; print(secrets.token_urlsafe(64))"
# PENPOT_SECRET_KEY: my-insecure-key
## The PREPL host. Mainly used for external programatic access to penpot backend
## (example: admin). By default it will listen on `localhost` but if you are going to use
## the `admin`, you will need to uncomment this and set the host to `0.0.0.0`.
# PENPOT_PREPL_HOST: 0.0.0.0
## Database connection parameters. Don't touch them unless you are using custom
## postgresql connection parameters.
PENPOT_DATABASE_URI: postgresql://penpot-postgres/penpot
PENPOT_DATABASE_USERNAME: penpot
PENPOT_DATABASE_PASSWORD: penpot
## Redis is used for the websockets notifications. Don't touch unless the redis
## container has different parameters or different name.
PENPOT_REDIS_URI: redis://penpot-redis/0
## Default configuration for assets storage: using filesystem based with all files
## stored in a docker volume.
PENPOT_ASSETS_STORAGE_BACKEND: assets-fs
PENPOT_STORAGE_ASSETS_FS_DIRECTORY: /opt/data/assets
## Also can be configured to to use a S3 compatible storage
## service like MiniIO. Look below for minio service setup.
# AWS_ACCESS_KEY_ID: <KEY_ID>
# AWS_SECRET_ACCESS_KEY: <ACCESS_KEY>
# PENPOT_ASSETS_STORAGE_BACKEND: assets-s3
# PENPOT_STORAGE_ASSETS_S3_ENDPOINT: http://penpot-minio:9000
# PENPOT_STORAGE_ASSETS_S3_BUCKET: <BUKET_NAME>
## Telemetry. When enabled, a periodical process will send anonymous data about this
## instance. Telemetry data will enable us to learn how the application is used,
## based on real scenarios. If you want to help us, please leave it enabled. You can
## audit what data we send with the code available on github.
PENPOT_TELEMETRY_ENABLED: true
## Example SMTP/Email configuration. By default, emails are sent to the mailcatch
## service, but for production usage it is recommended to setup a real SMTP
## provider. Emails are used to confirm user registrations & invitations. Look below
## how the mailcatch service is configured.
PENPOT_SMTP_DEFAULT_FROM: no-reply@example.com
PENPOT_SMTP_DEFAULT_REPLY_TO: no-reply@example.com
PENPOT_SMTP_HOST: penpot-mailcatch
PENPOT_SMTP_PORT: 1025
PENPOT_SMTP_USERNAME:
PENPOT_SMTP_PASSWORD:
PENPOT_SMTP_TLS: false
PENPOT_SMTP_SSL: false
penpot-exporter:
image: "penpotapp/exporter:2.3.2"
restart: always
networks:
- dokploy-network
environment:
# Don't touch it; this uses an internal docker network to
# communicate with the frontend.
PENPOT_PUBLIC_URI: http://penpot-frontend
## Redis is used for the websockets notifications.
PENPOT_REDIS_URI: redis://penpot-redis/0
penpot-postgres:
image: "postgres:15"
restart: always
stop_signal: SIGINT
volumes:
- penpot_postgres_v15:/var/lib/postgresql/data
networks:
- dokploy-network
environment:
- POSTGRES_INITDB_ARGS=--data-checksums
- POSTGRES_DB=penpot
- POSTGRES_USER=penpot
- POSTGRES_PASSWORD=penpot
penpot-redis:
image: redis:7.2
restart: always
networks:
- dokploy-network
## A mailcatch service, used as temporal SMTP server. You can access via HTTP to the
## port 1080 for read all emails the penpot platform has sent. Should be only used as a
## temporal solution while no real SMTP provider is configured.
penpot-mailcatch:
image: sj26/mailcatcher:latest
restart: always
expose:
- '1025'
ports:
- 1080
networks:
- dokploy-network
## Example configuration of MiniIO (S3 compatible object storage service); If you don't
## have preference, then just use filesystem, this is here just for the completeness.
# minio:
# image: "minio/minio:latest"
# command: minio server /mnt/data --console-address ":9001"
# restart: always
#
# volumes:
# - "penpot_minio:/mnt/data"
#
# environment:
# - MINIO_ROOT_USER=minioadmin
# - MINIO_ROOT_PASSWORD=minioadmin
#
# ports:
# - 9000:9000
# - 9001:9001

View File

@@ -0,0 +1,27 @@
import {
type DomainSchema,
type Schema,
type Template,
generateBase64,
generatePassword,
generateRandomDomain,
} from "../utils";
export function generate(schema: Schema): Template {
const mainDomain = generateRandomDomain(schema);
const domains: DomainSchema[] = [
{
host: mainDomain,
port: 80,
serviceName: "penpot-frontend",
},
];
const envs = [`DOMAIN_NAME=${mainDomain}`];
return {
domains,
envs,
};
}

View File

@@ -155,7 +155,7 @@ export const templates: TemplateData[] = [
{
id: "n8n",
name: "n8n",
version: "1.48.1",
version: "1.70.3",
description:
"n8n is an open source low-code platform for automating workflows and integrations.",
logo: "n8n.png",
@@ -1016,8 +1016,8 @@ export const templates: TemplateData[] = [
},
tags: ["browser", "automation"],
load: () => import("./browserless/index").then((m) => m.generate),
},
{
},
{
id: "drawio",
name: "draw.io",
version: "24.7.17",
@@ -1031,8 +1031,8 @@ export const templates: TemplateData[] = [
},
tags: ["drawing", "diagrams"],
load: () => import("./drawio/index").then((m) => m.generate),
},
{
},
{
id: "kimai",
name: "Kimai",
version: "2.26.0",
@@ -1047,4 +1047,93 @@ export const templates: TemplateData[] = [
tags: ["invoice", "business", "finance"],
load: () => import("./kimai/index").then((m) => m.generate),
},
{
id: "logto",
name: "Logto",
version: "1.22.0",
description:
"Logto is an open-source Identity and Access Management (IAM) platform designed to streamline Customer Identity and Access Management (CIAM) and Workforce Identity Management.",
logo: "logto.png",
links: {
github: "https://github.com/logto-io/logto",
website: "https://logto.io/",
docs: "https://docs.logto.io/introduction",
},
tags: ["identity", "auth"],
load: () => import("./logto/index").then((m) => m.generate),
},
{
id: "penpot",
name: "Penpot",
version: "2.3.2",
description:
"Penpot is the web-based open-source design tool that bridges the gap between designers and developers.",
logo: "penpot.svg",
links: {
github: "https://github.com/penpot/penpot",
website: "https://penpot.app/",
docs: "https://docs.penpot.app/",
},
tags: ["desing", "collaboration"],
load: () => import("./penpot/index").then((m) => m.generate),
},
{
id: "huly",
name: "Huly",
version: "0.6.377",
description:
"Huly — All-in-One Project Management Platform (alternative to Linear, Jira, Slack, Notion, Motion)",
logo: "huly.svg",
links: {
github: "https://github.com/hcengineering/huly-selfhost",
website: "https://huly.io/",
docs: "https://docs.huly.io/",
},
tags: ["project-management", "community", "discussion"],
load: () => import("./huly/index").then((m) => m.generate),
},
{
id: "unsend",
name: "Unsend",
version: "v1.2.4",
description: "Open source alternative to Resend,Sendgrid, Postmark etc. ",
logo: "unsend.png", // we defined the name and the extension of the logo
links: {
github: "https://github.com/unsend-dev/unsend",
website: "https://unsend.dev/",
docs: "https://docs.unsend.dev/get-started/",
},
tags: ["e-mail", "marketing", "business"],
load: () => import("./unsend/index").then((m) => m.generate),
},
{
id: "langflow",
name: "Langflow",
version: "1.1.1",
description:
"Langflow is a low-code app builder for RAG and multi-agent AI applications. Its Python-based and agnostic to any model, API, or database. ",
logo: "langflow.svg",
links: {
github: "https://github.com/langflow-ai/langflow/tree/main",
website: "https://www.langflow.org/",
docs: "https://docs.langflow.org/",
},
tags: ["ai"],
load: () => import("./langflow/index").then((m) => m.generate),
},
{
id: "elastic-search",
name: "Elasticsearch",
version: "8.10.2",
description:
"Elasticsearch is an open-source search and analytics engine, used for full-text search and analytics on structured data such as text, web pages, images, and videos.",
logo: "elasticsearch.svg",
links: {
github: "https://github.com/elastic/elasticsearch",
website: "https://www.elastic.co/elasticsearch/",
docs: "https://docs.elastic.co/elasticsearch/",
},
tags: ["search", "analytics"],
load: () => import("./elastic-search/index").then((m) => m.generate),
},
];

View File

@@ -0,0 +1,78 @@
name: unsend-prod
services:
unsend-db-prod:
image: postgres:16
networks:
- dokploy-network
restart: always
environment:
- POSTGRES_USER=${POSTGRES_USER:?err}
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD:?err}
- POSTGRES_DB=${POSTGRES_DB:?err}
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER}"]
interval: 10s
timeout: 5s
retries: 5
# ports:
# - "5432:5432"
volumes:
- database:/var/lib/postgresql/data
unsend-redis-prod:
image: redis:7
networks:
- dokploy-network
restart: always
# ports:
# - "6379:6379"
volumes:
- cache:/data
command: ["redis-server", "--maxmemory-policy", "noeviction"]
unsend-storage-prod:
image: minio/minio:RELEASE.2024-11-07T00-52-20Z
networks:
- dokploy-network
ports:
- 9002
- 9001
volumes:
- storage:/data
environment:
MINIO_ROOT_USER: unsend
MINIO_ROOT_PASSWORD: password
entrypoint: sh
command: -c 'mkdir -p /data/unsend && minio server /data --console-address ":9001" --address ":9002"'
unsend:
image: unsend/unsend:v1.2.4
networks:
- dokploy-network
restart: always
ports:
- ${PORT:-3000}
environment:
- PORT=${PORT:-3000}
- DATABASE_URL=${DATABASE_URL:?err}
- NEXTAUTH_URL=${NEXTAUTH_URL:?err}
- NEXTAUTH_SECRET=${NEXTAUTH_SECRET:?err}
- AWS_ACCESS_KEY=${AWS_ACCESS_KEY:?err}
- AWS_SECRET_KEY=${AWS_SECRET_KEY:?err}
- AWS_DEFAULT_REGION=${AWS_DEFAULT_REGION:?err}
- GITHUB_ID=${GITHUB_ID:?err}
- GITHUB_SECRET=${GITHUB_SECRET:?err}
- REDIS_URL=${REDIS_URL:?err}
- NEXT_PUBLIC_IS_CLOUD=${NEXT_PUBLIC_IS_CLOUD:-false}
- API_RATE_LIMIT=${API_RATE_LIMIT:-1}
depends_on:
unsend-db-prod:
condition: service_healthy
unsend-redis-prod:
condition: service_started
volumes:
database:
cache:
storage:

View File

@@ -0,0 +1,44 @@
import {
generateHash,
generateRandomDomain,
generateBase64,
type Template,
type Schema,
type DomainSchema,
} from "../utils";
export function generate(schema: Schema): Template {
const mainDomain = generateRandomDomain(schema);
const secretBase = generateBase64(64);
const domains: DomainSchema[] = [
{
host: mainDomain,
port: 3000,
serviceName: "unsend",
},
];
const envs = [
"REDIS_URL=redis://unsend-redis-prod:6379",
"POSTGRES_USER=postgres",
"POSTGRES_PASSWORD=postgres",
"POSTGRES_DB=unsend",
"DATABASE_URL=postgresql://postgres:postgres@unsend-db-prod:5432/unsend",
"NEXTAUTH_URL=http://localhost:3000",
`NEXTAUTH_SECRET=${secretBase}`,
"GITHUB_ID='Fill'",
"GITHUB_SECRET='Fill'",
"AWS_DEFAULT_REGION=us-east-1",
"AWS_SECRET_KEY='Fill'",
"AWS_ACCESS_KEY='Fill'",
"DOCKER_OUTPUT=1",
"API_RATE_LIMIT=1",
"DISCORD_WEBHOOK_URL=",
];
return {
envs,
domains,
};
}

View File

@@ -92,6 +92,7 @@ export const apiUpdateAuth = createSchema.partial().extend({
email: z.string().nullable(),
password: z.string().nullable(),
image: z.string().optional(),
currentPassword: z.string().nullable(),
});
export const apiUpdateAuthByAdmin = createSchema.partial().extend({

View File

@@ -10,6 +10,7 @@ export const gitlab = pgTable("gitlab", {
.notNull()
.primaryKey()
.$defaultFn(() => nanoid()),
gitlabUrl: text("gitlabUrl").default("https://gitlab.com").notNull(),
applicationId: text("application_id"),
redirectUri: text("redirect_uri"),
secret: text("secret"),
@@ -39,6 +40,7 @@ export const apiCreateGitlab = createSchema.extend({
redirectUri: z.string().optional(),
authId: z.string().min(1),
name: z.string().min(1),
gitlabUrl: z.string().min(1),
});
export const apiFindOneGitlab = createSchema
@@ -67,4 +69,5 @@ export const apiUpdateGitlab = createSchema.extend({
redirectUri: z.string().optional(),
name: z.string().min(1),
gitlabId: z.string().min(1),
gitlabUrl: z.string().min(1),
});

Some files were not shown because too many files have changed in this diff Show More