diff --git a/apps/dokploy/components/dashboard/application/deployments/show-deployment.tsx b/apps/dokploy/components/dashboard/application/deployments/show-deployment.tsx index 380b22d9..c67d1ba6 100644 --- a/apps/dokploy/components/dashboard/application/deployments/show-deployment.tsx +++ b/apps/dokploy/components/dashboard/application/deployments/show-deployment.tsx @@ -1,3 +1,4 @@ +import { Badge } from "@/components/ui/badge"; import { Dialog, DialogContent, @@ -5,11 +6,10 @@ import { DialogHeader, DialogTitle, } from "@/components/ui/dialog"; +import { Loader2 } from "lucide-react"; import { useEffect, useRef, useState } from "react"; import { TerminalLine } from "../../docker/logs/terminal-line"; -import { LogLine, parseLogs } from "../../docker/logs/utils"; -import { Badge } from "@/components/ui/badge"; -import { Loader2 } from "lucide-react"; +import { type LogLine, parseLogs } from "../../docker/logs/utils"; interface Props { logPath: string | null; @@ -24,21 +24,20 @@ export const ShowDeployment = ({ logPath, open, onClose, serverId }: Props) => { const [autoScroll, setAutoScroll] = useState(true); const scrollRef = useRef(null); - const scrollToBottom = () => { if (autoScroll && scrollRef.current) { - scrollRef.current.scrollTop = scrollRef.current.scrollHeight; + scrollRef.current.scrollTop = scrollRef.current.scrollHeight; } - }; - + }; + const handleScroll = () => { if (!scrollRef.current) return; - + const { scrollTop, scrollHeight, clientHeight } = scrollRef.current; const isAtBottom = Math.abs(scrollHeight - scrollTop - clientHeight) < 10; setAutoScroll(isAtBottom); - }; - + }; + useEffect(() => { if (!open || !logPath) return; @@ -69,7 +68,6 @@ export const ShowDeployment = ({ logPath, open, onClose, serverId }: Props) => { }; }, [logPath, open]); - useEffect(() => { const logs = parseLogs(data); setFilteredLogs(logs); @@ -77,12 +75,11 @@ export const ShowDeployment = ({ logPath, open, onClose, serverId }: Props) => { useEffect(() => { scrollToBottom(); - - if (autoScroll && scrollRef.current) { - scrollRef.current.scrollTop = scrollRef.current.scrollHeight; - } - }, [filteredLogs, autoScroll]); + if (autoScroll && scrollRef.current) { + scrollRef.current.scrollTop = scrollRef.current.scrollHeight; + } + }, [filteredLogs, autoScroll]); return ( { Deployment - See all the details of this deployment | {filteredLogs.length} lines + See all the details of this deployment |{" "} + + {filteredLogs.length} lines + -
{ - filteredLogs.length > 0 ? filteredLogs.map((log: LogLine, index: number) => ( - - )) : - ( -
- -
- )} + > + {" "} + {filteredLogs.length > 0 ? ( + filteredLogs.map((log: LogLine, index: number) => ( + + )) + ) : ( +
+ +
+ )}
diff --git a/apps/dokploy/components/dashboard/application/preview-deployments/show-preview-builds.tsx b/apps/dokploy/components/dashboard/application/preview-deployments/show-preview-builds.tsx index 4eb2107f..154510c3 100644 --- a/apps/dokploy/components/dashboard/application/preview-deployments/show-preview-builds.tsx +++ b/apps/dokploy/components/dashboard/application/preview-deployments/show-preview-builds.tsx @@ -26,7 +26,9 @@ export const ShowPreviewBuilds = ({ deployments, serverId }: Props) => { return ( - + diff --git a/apps/dokploy/components/dashboard/application/preview-deployments/show-preview-deployments.tsx b/apps/dokploy/components/dashboard/application/preview-deployments/show-preview-deployments.tsx index 45451e78..c2d712c3 100644 --- a/apps/dokploy/components/dashboard/application/preview-deployments/show-preview-deployments.tsx +++ b/apps/dokploy/components/dashboard/application/preview-deployments/show-preview-deployments.tsx @@ -1,5 +1,7 @@ import { DateTooltip } from "@/components/shared/date-tooltip"; +import { DialogAction } from "@/components/shared/dialog-action"; import { StatusTooltip } from "@/components/shared/status-tooltip"; +import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { Card, @@ -8,30 +10,33 @@ import { CardHeader, CardTitle, } from "@/components/ui/card"; -import { Switch } from "@/components/ui/switch"; +import { Separator } from "@/components/ui/separator"; import { api } from "@/utils/api"; -import { Pencil, RocketIcon } from "lucide-react"; -import React, { useEffect, useState } from "react"; -import { toast } from "sonner"; -import { ShowDeployment } from "../deployments/show-deployment"; +import { + Clock, + GitBranch, + GitPullRequest, + Pencil, + RocketIcon, +} from "lucide-react"; import Link from "next/link"; +import React from "react"; +import { toast } from "sonner"; import { ShowModalLogs } from "../../settings/web-server/show-modal-logs"; -import { DialogAction } from "@/components/shared/dialog-action"; import { AddPreviewDomain } from "./add-preview-domain"; -import { GithubIcon } from "@/components/icons/data-tools-icons"; -import { ShowPreviewSettings } from "./show-preview-settings"; import { ShowPreviewBuilds } from "./show-preview-builds"; +import { ShowPreviewSettings } from "./show-preview-settings"; interface Props { applicationId: string; } export const ShowPreviewDeployments = ({ applicationId }: Props) => { - const [activeLog, setActiveLog] = useState(null); const { data } = api.application.one.useQuery({ applicationId }); const { mutateAsync: deletePreviewDeployment, isLoading } = api.previewDeployment.delete.useMutation(); + const { data: previewDeployments, refetch: refetchPreviewDeployments } = api.previewDeployment.all.useQuery( { applicationId }, @@ -39,10 +44,19 @@ export const ShowPreviewDeployments = ({ applicationId }: Props) => { enabled: !!applicationId, }, ); - // const [url, setUrl] = React.useState(""); - // useEffect(() => { - // setUrl(document.location.origin); - // }, []); + + const handleDeletePreviewDeployment = async (previewDeploymentId: string) => { + deletePreviewDeployment({ + previewDeploymentId: previewDeploymentId, + }) + .then(() => { + refetchPreviewDeployments(); + toast.success("Preview deployment deleted"); + }) + .catch((error) => { + toast.error(error.message); + }); + }; return ( @@ -65,7 +79,7 @@ export const ShowPreviewDeployments = ({ applicationId }: Props) => { each pull request you create. - {data?.previewDeployments?.length === 0 ? ( + {!previewDeployments?.length ? (
@@ -74,125 +88,136 @@ export const ShowPreviewDeployments = ({ applicationId }: Props) => {
) : (
- {previewDeployments?.map((previewDeployment) => { - const { deployments, domain } = previewDeployment; + {previewDeployments.map((previewDeployment) => ( +
+
+ + {previewDeployment.pullRequestTitle} + + + + {previewDeployment.previewStatus + ?.replace("running", "Running") + .replace("done", "Done") + .replace("error", "Error") + .replace("idle", "Idle") || "Idle"} + +
- return ( -
-
-
- {deployments?.length === 0 ? ( -
- - No deployments found - -
- ) : ( -
- - {previewDeployment?.pullRequestTitle} - - -
- )} -
- {previewDeployment?.pullRequestTitle && ( -
- - Title: {previewDeployment?.pullRequestTitle} - -
- )} +
+
+ + {previewDeployment.domain?.host} + - {previewDeployment?.pullRequestURL && ( -
- - - Pull Request URL - -
- )} -
-
- Domain -
- - {domain?.host} - - - - -
-
+ + + +
+ +
+
+ + Branch: + + {previewDeployment.branch} +
+
+ + Deployed: + + + +
+
-
- {previewDeployment?.createdAt && ( -
- -
- )} - + - +

+ Pull Request +

+
+ + - - - - { - deletePreviewDeployment({ - previewDeploymentId: - previewDeployment.previewDeploymentId, - }) - .then(() => { - refetchPreviewDeployments(); - toast.success("Preview deployment deleted"); - }) - .catch((error) => { - toast.error(error.message); - }); - }} - > - - + {previewDeployment.pullRequestTitle} +
- ); - })} + +
+
+ + + + + + + + handleDeletePreviewDeployment( + previewDeployment.previewDeploymentId, + ) + } + > + + +
+
+
+ ))}
)} diff --git a/apps/dokploy/components/dashboard/application/preview-deployments/show-preview-settings.tsx b/apps/dokploy/components/dashboard/application/preview-deployments/show-preview-settings.tsx index 6e56bbdd..0e4231eb 100644 --- a/apps/dokploy/components/dashboard/application/preview-deployments/show-preview-settings.tsx +++ b/apps/dokploy/components/dashboard/application/preview-deployments/show-preview-settings.tsx @@ -1,5 +1,3 @@ -import { api } from "@/utils/api"; -import { useEffect, useState } from "react"; import { Button } from "@/components/ui/button"; import { Dialog, @@ -20,12 +18,7 @@ import { FormMessage, } from "@/components/ui/form"; import { Input, NumberInput } from "@/components/ui/input"; -import { zodResolver } from "@hookform/resolvers/zod"; -import { useForm } from "react-hook-form"; -import { z } from "zod"; import { Secrets } from "@/components/ui/secrets"; -import { toast } from "sonner"; -import { Switch } from "@/components/ui/switch"; import { Select, SelectContent, @@ -33,6 +26,13 @@ import { SelectTrigger, SelectValue, } from "@/components/ui/select"; +import { Switch } from "@/components/ui/switch"; +import { api } from "@/utils/api"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { useEffect, useState } from "react"; +import { useForm } from "react-hook-form"; +import { toast } from "sonner"; +import { z } from "zod"; const schema = z.object({ env: z.string(), diff --git a/apps/dokploy/components/dashboard/compose/advanced/add-command.tsx b/apps/dokploy/components/dashboard/compose/advanced/add-command.tsx index 44ce15c0..6d1b455f 100644 --- a/apps/dokploy/components/dashboard/compose/advanced/add-command.tsx +++ b/apps/dokploy/components/dashboard/compose/advanced/add-command.tsx @@ -1,3 +1,4 @@ +import { AlertBlock } from "@/components/shared/alert-block"; import { Button } from "@/components/ui/button"; import { Card, @@ -91,7 +92,7 @@ export const AddCommandCompose = ({ composeId }: Props) => {
Run Command - Append a custom command to the compose file + Override a custom command to the compose file
@@ -101,6 +102,12 @@ export const AddCommandCompose = ({ composeId }: Props) => { onSubmit={form.handleSubmit(onSubmit)} className="grid w-full gap-4" > + + Modifying the default command may affect deployment stability, + impacting logs and monitoring. Proceed carefully and test + thoroughly. By default, the command starts with{" "} + docker. +
; @@ -51,6 +54,7 @@ export const DeleteCompose = ({ composeId }: Props) => { const form = useForm({ defaultValues: { projectName: "", + deleteVolumes: false, }, resolver: zodResolver(deleteComposeSchema), }); @@ -58,7 +62,8 @@ export const DeleteCompose = ({ composeId }: Props) => { const onSubmit = async (formData: DeleteCompose) => { const expectedName = `${data?.name}/${data?.appName}`; if (formData.projectName === expectedName) { - await mutateAsync({ composeId }) + const { deleteVolumes } = formData; + await mutateAsync({ composeId, deleteVolumes }) .then((result) => { push(`/dashboard/project/${result?.projectId}`); toast.success("Compose deleted successfully"); @@ -133,6 +138,27 @@ export const DeleteCompose = ({ composeId }: Props) => { )} /> + ( + +
+ + + + + + Delete volumes associated with this compose + +
+ +
+ )} + />
diff --git a/apps/dokploy/components/dashboard/compose/deployments/show-deployment-compose.tsx b/apps/dokploy/components/dashboard/compose/deployments/show-deployment-compose.tsx index 4a45fb20..d683d4ab 100644 --- a/apps/dokploy/components/dashboard/compose/deployments/show-deployment-compose.tsx +++ b/apps/dokploy/components/dashboard/compose/deployments/show-deployment-compose.tsx @@ -1,3 +1,4 @@ +import { Badge } from "@/components/ui/badge"; import { Dialog, DialogContent, @@ -5,12 +6,10 @@ import { DialogHeader, DialogTitle, } from "@/components/ui/dialog"; +import { Loader2 } from "lucide-react"; import { useEffect, useRef, useState } from "react"; import { TerminalLine } from "../../docker/logs/terminal-line"; -import { LogLine, parseLogs } from "../../docker/logs/utils"; -import { Badge } from "@/components/ui/badge"; -import { Loader2 } from "lucide-react"; - +import { type LogLine, parseLogs } from "../../docker/logs/utils"; interface Props { logPath: string | null; @@ -32,19 +31,18 @@ export const ShowDeploymentCompose = ({ const scrollToBottom = () => { if (autoScroll && scrollRef.current) { - scrollRef.current.scrollTop = scrollRef.current.scrollHeight; + scrollRef.current.scrollTop = scrollRef.current.scrollHeight; } - }; - + }; + const handleScroll = () => { if (!scrollRef.current) return; - + const { scrollTop, scrollHeight, clientHeight } = scrollRef.current; const isAtBottom = Math.abs(scrollHeight - scrollTop - clientHeight) < 10; setAutoScroll(isAtBottom); - }; - - + }; + useEffect(() => { if (!open || !logPath) return; @@ -76,7 +74,6 @@ export const ShowDeploymentCompose = ({ }; }, [logPath, open]); - useEffect(() => { const logs = parseLogs(data); setFilteredLogs(logs); @@ -84,11 +81,11 @@ export const ShowDeploymentCompose = ({ useEffect(() => { scrollToBottom(); - + if (autoScroll && scrollRef.current) { - scrollRef.current.scrollTop = scrollRef.current.scrollHeight; + scrollRef.current.scrollTop = scrollRef.current.scrollHeight; } - }, [filteredLogs, autoScroll]); + }, [filteredLogs, autoScroll]); return ( Deployment - See all the details of this deployment | {filteredLogs.length} lines + See all the details of this deployment |{" "} + + {filteredLogs.length} lines + -
- - - { - filteredLogs.length > 0 ? filteredLogs.map((log: LogLine, index: number) => ( - - )) : - ( + {filteredLogs.length > 0 ? ( + filteredLogs.map((log: LogLine, index: number) => ( + + )) + ) : (
- ) - } + )}
diff --git a/apps/dokploy/components/dashboard/compose/general/deploy-compose.tsx b/apps/dokploy/components/dashboard/compose/general/deploy-compose.tsx index c02a7802..25b59cc7 100644 --- a/apps/dokploy/components/dashboard/compose/general/deploy-compose.tsx +++ b/apps/dokploy/components/dashboard/compose/general/deploy-compose.tsx @@ -53,7 +53,7 @@ export const DeployCompose = ({ composeId }: Props) => { }) .then(async () => { router.push( - `/dashboard/project/${data?.project.projectId}/services/compose/${composeId}?tab=deployments` + `/dashboard/project/${data?.project.projectId}/services/compose/${composeId}?tab=deployments`, ); }) .catch(() => { diff --git a/apps/dokploy/components/dashboard/docker/logs/terminal-line.tsx b/apps/dokploy/components/dashboard/docker/logs/terminal-line.tsx index 2f247e25..c25acc67 100644 --- a/apps/dokploy/components/dashboard/docker/logs/terminal-line.tsx +++ b/apps/dokploy/components/dashboard/docker/logs/terminal-line.tsx @@ -7,9 +7,10 @@ import { TooltipTrigger, } from "@/components/ui/tooltip"; import { cn } from "@/lib/utils"; +import { FancyAnsi } from "fancy-ansi"; import { escapeRegExp } from "lodash"; import React from "react"; -import { type LogLine, getLogType, parseAnsi } from "./utils"; +import { type LogLine, getLogType } from "./utils"; interface LogLineProps { log: LogLine; @@ -17,6 +18,8 @@ interface LogLineProps { searchTerm?: string; } +const fancyAnsi = new FancyAnsi(); + export function TerminalLine({ log, noTimestamp, searchTerm }: LogLineProps) { const { timestamp, message, rawTimestamp } = log; const { type, variant, color } = getLogType(message); @@ -34,37 +37,42 @@ export function TerminalLine({ log, noTimestamp, searchTerm }: LogLineProps) { const highlightMessage = (text: string, term: string) => { if (!term) { - const segments = parseAnsi(text); - return segments.map((segment, index) => ( - - {segment.text} - - )); + return ( + + ); } - // For search, we need to handle both ANSI and search highlighting - const segments = parseAnsi(text); - return segments.map((segment, index) => { - const parts = segment.text.split( - new RegExp(`(${escapeRegExp(term)})`, "gi"), - ); - return ( - - {parts.map((part, partIndex) => - part.toLowerCase() === term.toLowerCase() ? ( - - {part} - - ) : ( - part - ), - )} - - ); - }); + const htmlContent = fancyAnsi.toHtml(text); + const modifiedContent = htmlContent.replace( + /]*)>([^<]*)<\/span>/g, + (match, attrs, content) => { + const searchRegex = new RegExp(`(${escapeRegExp(term)})`, "gi"); + if (!content.match(searchRegex)) return match; + + const segments = content.split(searchRegex); + const wrappedSegments = segments + .map((segment: string) => + segment.toLowerCase() === term.toLowerCase() + ? `${segment}` + : segment, + ) + .join(""); + + return `${wrappedSegments}`; + }, + ); + + return ( + + ); }; const tooltip = (color: string, timestamp: string | null) => { diff --git a/apps/dokploy/components/dashboard/docker/logs/utils.ts b/apps/dokploy/components/dashboard/docker/logs/utils.ts index 48219428..cf0b30bb 100644 --- a/apps/dokploy/components/dashboard/docker/logs/utils.ts +++ b/apps/dokploy/components/dashboard/docker/logs/utils.ts @@ -12,47 +12,6 @@ interface LogStyle { variant: LogVariant; color: string; } -interface AnsiSegment { - text: string; - className: string; -} - -const ansiToTailwind: Record = { - // Reset - 0: "", - // Regular colors - 30: "text-black dark:text-gray-900", - 31: "text-red-600 dark:text-red-500", - 32: "text-green-600 dark:text-green-500", - 33: "text-yellow-600 dark:text-yellow-500", - 34: "text-blue-600 dark:text-blue-500", - 35: "text-purple-600 dark:text-purple-500", - 36: "text-cyan-600 dark:text-cyan-500", - 37: "text-gray-600 dark:text-gray-400", - // Bright colors - 90: "text-gray-500 dark:text-gray-600", - 91: "text-red-500 dark:text-red-600", - 92: "text-green-500 dark:text-green-600", - 93: "text-yellow-500 dark:text-yellow-600", - 94: "text-blue-500 dark:text-blue-600", - 95: "text-purple-500 dark:text-purple-600", - 96: "text-cyan-500 dark:text-cyan-600", - 97: "text-white dark:text-gray-300", - // Background colors - 40: "bg-black", - 41: "bg-red-600", - 42: "bg-green-600", - 43: "bg-yellow-600", - 44: "bg-blue-600", - 45: "bg-purple-600", - 46: "bg-cyan-600", - 47: "bg-white", - // Formatting - 1: "font-bold", - 2: "opacity-75", - 3: "italic", - 4: "underline", -}; const LOG_STYLES: Record = { error: { @@ -191,56 +150,3 @@ export const getLogType = (message: string): LogStyle => { return LOG_STYLES.info; }; - -export function parseAnsi(text: string) { - const segments: { text: string; className: string }[] = []; - let currentIndex = 0; - let currentClasses: string[] = []; - - while (currentIndex < text.length) { - const escStart = text.indexOf("\x1b[", currentIndex); - - // No more escape sequences found - if (escStart === -1) { - if (currentIndex < text.length) { - segments.push({ - text: text.slice(currentIndex), - className: currentClasses.join(" "), - }); - } - break; - } - - // Add text before escape sequence - if (escStart > currentIndex) { - segments.push({ - text: text.slice(currentIndex, escStart), - className: currentClasses.join(" "), - }); - } - - const escEnd = text.indexOf("m", escStart); - if (escEnd === -1) break; - - // Handle multiple codes in one sequence (e.g., \x1b[1;31m) - const codesStr = text.slice(escStart + 2, escEnd); - const codes = codesStr.split(";").map((c) => Number.parseInt(c, 10)); - - if (codes.includes(0)) { - // Reset all formatting - currentClasses = []; - } else { - // Add new classes for each code - for (const code of codes) { - const className = ansiToTailwind[code]; - if (className && !currentClasses.includes(className)) { - currentClasses.push(className); - } - } - } - - currentIndex = escEnd + 1; - } - - return segments; -} \ No newline at end of file diff --git a/apps/dokploy/components/dashboard/docker/terminal/docker-terminal-modal.tsx b/apps/dokploy/components/dashboard/docker/terminal/docker-terminal-modal.tsx index c3dba4f5..90aa2b40 100644 --- a/apps/dokploy/components/dashboard/docker/terminal/docker-terminal-modal.tsx +++ b/apps/dokploy/components/dashboard/docker/terminal/docker-terminal-modal.tsx @@ -59,7 +59,10 @@ export const DockerTerminalModal = ({ {children} - + event.preventDefault()} + > Docker Terminal @@ -73,7 +76,7 @@ export const DockerTerminalModal = ({ serverId={serverId || ""} /> - + event.preventDefault()}> Are you sure you want to close the terminal? diff --git a/apps/dokploy/components/dashboard/projects/show.tsx b/apps/dokploy/components/dashboard/projects/show.tsx index d05bbba2..d4d9ac55 100644 --- a/apps/dokploy/components/dashboard/projects/show.tsx +++ b/apps/dokploy/components/dashboard/projects/show.tsx @@ -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,257 +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 && ( -
- - - No projects added yet. Click on Create project. - -
- )} -
- {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 && ( +
+ + + No projects added yet. Click on Create project. + +
+ )} +
+ {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] ? ( - - - {"applicationId" in item[0] ? "Applications" : "Compose"} - - {item.map((a) => ( - - - - - {a.name} - - - {a.domains.map((domain) => ( - - - {domain.host} - - - - ))} - - - ))} - - ) : null; + const renderDomainsDropdown = ( + item: typeof project.compose | typeof project.applications, + ) => + item[0] ? ( + + + {"applicationId" in item[0] ? "Applications" : "Compose"} + + {item.map((a) => ( + + + + + {a.name} + + + {a.domains.map((domain) => ( + + + {domain.host} + + + + ))} + + + ))} + + ) : null; - return ( -
- - - {flattedDomains.length > 1 ? ( - - - - - e.stopPropagation()} - > - {renderDomainsDropdown(project.applications)} - {renderDomainsDropdown(project.compose)} - - - ) : flattedDomains[0] ? ( - - ) : null} + return ( +
+ + + {flattedDomains.length > 1 ? ( + + + + + e.stopPropagation()} + > + {renderDomainsDropdown(project.applications)} + {renderDomainsDropdown(project.compose)} + + + ) : flattedDomains[0] ? ( + + ) : null} - - - -
- - - {project.name} - -
+ + + +
+ + + {project.name} + +
- - {project.description} - -
-
- - - - - - - Actions - -
e.stopPropagation()}> - -
-
e.stopPropagation()}> - -
+ + {project.description} + + +
+ + + + + + + Actions + +
e.stopPropagation()}> + +
+
e.stopPropagation()}> + +
-
e.stopPropagation()}> - {(auth?.rol === "admin" || - user?.canDeleteProjects) && ( - - - e.preventDefault()} - > - - Delete - - - - - - Are you sure to delete this project? - - {!emptyServices ? ( -
- - - You have active services, please - delete them first - -
- ) : ( - - This action cannot be undone - - )} -
- - - Cancel - - { - 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 - - -
-
- )} -
-
-
-
- - - -
- - Created - - - {totalServices}{" "} - {totalServices === 1 ? "service" : "services"} - -
-
- - -
- ); - })} -
- - ); +
e.stopPropagation()}> + {(auth?.rol === "admin" || + user?.canDeleteProjects) && ( + + + e.preventDefault()} + > + + Delete + + + + + + Are you sure to delete this project? + + {!emptyServices ? ( +
+ + + You have active services, please + delete them first + +
+ ) : ( + + This action cannot be undone + + )} +
+ + + Cancel + + { + 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 + + +
+
+ )} +
+ + +
+ + + +
+ + Created + + + {totalServices}{" "} + {totalServices === 1 ? "service" : "services"} + +
+
+ + +
+ ); + })} +
+ + ); }; diff --git a/apps/dokploy/components/dashboard/search-command.tsx b/apps/dokploy/components/dashboard/search-command.tsx index 8afea672..4d3c75f9 100644 --- a/apps/dokploy/components/dashboard/search-command.tsx +++ b/apps/dokploy/components/dashboard/search-command.tsx @@ -1,189 +1,189 @@ "use client"; -import React from "react"; import { - Command, - CommandEmpty, - CommandList, - CommandGroup, - CommandInput, - CommandItem, - CommandDialog, - CommandSeparator, + MariadbIcon, + MongodbIcon, + MysqlIcon, + PostgresqlIcon, + RedisIcon, +} from "@/components/icons/data-tools-icons"; +import { Badge } from "@/components/ui/badge"; +import { + Command, + CommandDialog, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, + CommandSeparator, } from "@/components/ui/command"; -import { useRouter } from "next/router"; import { - extractServices, - type Services, + type Services, + extractServices, } from "@/pages/dashboard/project/[projectId]"; +import { api } from "@/utils/api"; 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 { useRouter } from "next/router"; +import React from "react"; import { StatusTooltip } from "../shared/status-tooltip"; type Project = Awaited>; export const SearchCommand = () => { - const router = useRouter(); - const [open, setOpen] = React.useState(false); - const [search, setSearch] = React.useState(""); + 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(); + 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); - } - }; + 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); - }, []); + document.addEventListener("keydown", down); + return () => document.removeEventListener("keydown", down); + }, []); - return ( -
- - - - - No projects added yet. Click on Create project. - - - - {data?.map((project) => ( - { - router.push(`/dashboard/project/${project.projectId}`); - setOpen(false); - }} - > - - {project.name} - - ))} - - - - - - {data?.map((project) => { - const applications: Services[] = extractServices(project); - return applications.map((application) => ( - { - router.push( - `/dashboard/project/${project.projectId}/services/${application.type}/${application.id}` - ); - setOpen(false); - }} - > - {application.type === "postgres" && ( - - )} - {application.type === "redis" && ( - - )} - {application.type === "mariadb" && ( - - )} - {application.type === "mongo" && ( - - )} - {application.type === "mysql" && ( - - )} - {application.type === "application" && ( - - )} - {application.type === "compose" && ( - - )} - - {project.name} / {application.name}{" "} -
{application.id}
-
-
- -
-
- )); - })} -
-
- - -
-
-
- ); + return ( +
+ + + + + No projects added yet. Click on Create project. + + + + {data?.map((project) => ( + { + router.push(`/dashboard/project/${project.projectId}`); + setOpen(false); + }} + > + + {project.name} + + ))} + + + + + + {data?.map((project) => { + const applications: Services[] = extractServices(project); + return applications.map((application) => ( + { + router.push( + `/dashboard/project/${project.projectId}/services/${application.type}/${application.id}`, + ); + setOpen(false); + }} + > + {application.type === "postgres" && ( + + )} + {application.type === "redis" && ( + + )} + {application.type === "mariadb" && ( + + )} + {application.type === "mongo" && ( + + )} + {application.type === "mysql" && ( + + )} + {application.type === "application" && ( + + )} + {application.type === "compose" && ( + + )} + + {project.name} / {application.name}{" "} +
{application.id}
+
+
+ +
+
+ )); + })} +
+
+ + +
+
+
+ ); }; diff --git a/apps/dokploy/components/dashboard/settings/notifications/show-notifications.tsx b/apps/dokploy/components/dashboard/settings/notifications/show-notifications.tsx index 1883d538..8a45bbf3 100644 --- a/apps/dokploy/components/dashboard/settings/notifications/show-notifications.tsx +++ b/apps/dokploy/components/dashboard/settings/notifications/show-notifications.tsx @@ -44,7 +44,7 @@ export const ShowNotifications = () => { {data?.map((notification, index) => (
{notification.notificationType === "slack" && ( @@ -68,7 +68,7 @@ export const ShowNotifications = () => {
)}
- + {notification.name} diff --git a/apps/dokploy/components/dashboard/settings/notifications/update-notification.tsx b/apps/dokploy/components/dashboard/settings/notifications/update-notification.tsx index 45791917..e9909b2b 100644 --- a/apps/dokploy/components/dashboard/settings/notifications/update-notification.tsx +++ b/apps/dokploy/components/dashboard/settings/notifications/update-notification.tsx @@ -220,7 +220,11 @@ export const UpdateNotification = ({ notificationId }: Props) => { return ( - diff --git a/apps/dokploy/components/dashboard/settings/profile/profile-form.tsx b/apps/dokploy/components/dashboard/settings/profile/profile-form.tsx index 1141397f..191c1936 100644 --- a/apps/dokploy/components/dashboard/settings/profile/profile-form.tsx +++ b/apps/dokploy/components/dashboard/settings/profile/profile-form.tsx @@ -1,3 +1,4 @@ +import { AlertBlock } from "@/components/shared/alert-block"; import { Button } from "@/components/ui/button"; import { Card, @@ -26,7 +27,6 @@ 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(), diff --git a/apps/dokploy/components/dashboard/settings/profile/remove-self-account.tsx b/apps/dokploy/components/dashboard/settings/profile/remove-self-account.tsx index f4f4680b..3fc55452 100644 --- a/apps/dokploy/components/dashboard/settings/profile/remove-self-account.tsx +++ b/apps/dokploy/components/dashboard/settings/profile/remove-self-account.tsx @@ -1,3 +1,5 @@ +import { AlertBlock } from "@/components/shared/alert-block"; +import { DialogAction } from "@/components/shared/dialog-action"; import { Button } from "@/components/ui/button"; import { Card, @@ -18,13 +20,11 @@ import { Input } from "@/components/ui/input"; import { api } from "@/utils/api"; import { zodResolver } from "@hookform/resolvers/zod"; import { useTranslation } from "next-i18next"; +import { useRouter } from "next/router"; 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, { diff --git a/apps/dokploy/components/dashboard/settings/servers/actions/show-traefik-actions.tsx b/apps/dokploy/components/dashboard/settings/servers/actions/show-traefik-actions.tsx index 4385dc6a..72854f93 100644 --- a/apps/dokploy/components/dashboard/settings/servers/actions/show-traefik-actions.tsx +++ b/apps/dokploy/components/dashboard/settings/servers/actions/show-traefik-actions.tsx @@ -25,6 +25,7 @@ import { toast } from "sonner"; import { cn } from "@/lib/utils"; import { useTranslation } from "next-i18next"; import { EditTraefikEnv } from "../../web-server/edit-traefik-env"; +import { ManageTraefikPorts } from "../../web-server/manage-traefik-ports"; import { ShowModalLogs } from "../../web-server/show-modal-logs"; interface Props { @@ -128,6 +129,14 @@ export const ShowTraefikActions = ({ serverId }: Props) => { Enter the terminal */} + + e.preventDefault()} + className="cursor-pointer" + > + {t("settings.server.webServer.traefik.managePorts")} + + diff --git a/apps/dokploy/components/dashboard/settings/servers/edit-script.tsx b/apps/dokploy/components/dashboard/settings/servers/edit-script.tsx index faaecb1f..0a22220e 100644 --- a/apps/dokploy/components/dashboard/settings/servers/edit-script.tsx +++ b/apps/dokploy/components/dashboard/settings/servers/edit-script.tsx @@ -108,7 +108,8 @@ export const EditScript = ({ serverId }: Props) => { - We recommend not modifying this script unless you know what you are doing. + We recommend not modifying this script unless you know what you are + doing.
diff --git a/apps/dokploy/components/dashboard/settings/servers/setup-server.tsx b/apps/dokploy/components/dashboard/settings/servers/setup-server.tsx index 7c181459..252ca16c 100644 --- a/apps/dokploy/components/dashboard/settings/servers/setup-server.tsx +++ b/apps/dokploy/components/dashboard/settings/servers/setup-server.tsx @@ -34,8 +34,8 @@ 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"; import { SecurityAudit } from "./security-audit"; +import { ValidateServer } from "./validate-server"; interface Props { serverId: string; diff --git a/apps/dokploy/components/dashboard/settings/servers/show-servers.tsx b/apps/dokploy/components/dashboard/settings/servers/show-servers.tsx index a174cd9c..d45a3b77 100644 --- a/apps/dokploy/components/dashboard/settings/servers/show-servers.tsx +++ b/apps/dokploy/components/dashboard/settings/servers/show-servers.tsx @@ -23,15 +23,16 @@ import { api } from "@/utils/api"; import { format } from "date-fns"; import { KeyIcon, MoreHorizontal, ServerIcon } from "lucide-react"; import Link from "next/link"; +import { useRouter } from "next/router"; import { toast } from "sonner"; import { TerminalModal } from "../web-server/terminal-modal"; import { ShowServerActions } from "./actions/show-server-actions"; import { AddServer } from "./add-server"; import { SetupServer } from "./setup-server"; import { ShowDockerContainersModal } from "./show-docker-containers-modal"; +import { ShowSwarmOverviewModal } from "./show-swarm-overview-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 = () => { @@ -259,6 +260,9 @@ export const ShowServers = () => { + )} diff --git a/apps/dokploy/components/dashboard/settings/servers/show-swarm-overview-modal.tsx b/apps/dokploy/components/dashboard/settings/servers/show-swarm-overview-modal.tsx new file mode 100644 index 00000000..f8acd207 --- /dev/null +++ b/apps/dokploy/components/dashboard/settings/servers/show-swarm-overview-modal.tsx @@ -0,0 +1,51 @@ +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; +import { DropdownMenuItem } from "@/components/ui/dropdown-menu"; +import { ContainerIcon } from "lucide-react"; +import { useState } from "react"; +import SwarmMonitorCard from "../../swarm/monitoring-card"; + +interface Props { + serverId: string; +} + +export const ShowSwarmOverviewModal = ({ serverId }: Props) => { + const [isOpen, setIsOpen] = useState(false); + + return ( + + + e.preventDefault()} + > + Show Swarm Overview + + + + +
+ + + Swarm Overview + +

+ See all details of your swarm node +

+
+
+ +
+
+ +
+
+
+
+ ); +}; diff --git a/apps/dokploy/components/dashboard/settings/servers/welcome-stripe/create-ssh-key.tsx b/apps/dokploy/components/dashboard/settings/servers/welcome-stripe/create-ssh-key.tsx index 740f7960..37f8e017 100644 --- a/apps/dokploy/components/dashboard/settings/servers/welcome-stripe/create-ssh-key.tsx +++ b/apps/dokploy/components/dashboard/settings/servers/welcome-stripe/create-ssh-key.tsx @@ -1,12 +1,12 @@ +import { CodeEditor } from "@/components/shared/code-editor"; 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 { ExternalLinkIcon, Loader2 } from "lucide-react"; import { CopyIcon } from "lucide-react"; +import Link from "next/link"; 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(); diff --git a/apps/dokploy/components/dashboard/settings/servers/welcome-stripe/setup.tsx b/apps/dokploy/components/dashboard/settings/servers/welcome-stripe/setup.tsx index 39179de8..be7a8e48 100644 --- a/apps/dokploy/components/dashboard/settings/servers/welcome-stripe/setup.tsx +++ b/apps/dokploy/components/dashboard/settings/servers/welcome-stripe/setup.tsx @@ -5,26 +5,26 @@ import { StatusTooltip } from "@/components/shared/status-tooltip"; import { Button } from "@/components/ui/button"; import { Card, + CardContent, + CardDescription, 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, + SelectTrigger, + SelectValue, } from "@/components/ui/select"; +import { api } from "@/utils/api"; +import { RocketIcon } from "lucide-react"; +import { useState } from "react"; +import { toast } from "sonner"; +import { EditScript } from "../edit-script"; export const Setup = () => { const { data: servers } = api.server.all.useQuery(); diff --git a/apps/dokploy/components/dashboard/settings/servers/welcome-stripe/verify.tsx b/apps/dokploy/components/dashboard/settings/servers/welcome-stripe/verify.tsx index 3d6cfa7d..fe8c36c2 100644 --- a/apps/dokploy/components/dashboard/settings/servers/welcome-stripe/verify.tsx +++ b/apps/dokploy/components/dashboard/settings/servers/welcome-stripe/verify.tsx @@ -1,27 +1,27 @@ import { Button } from "@/components/ui/button"; import { Card, + CardContent, + CardDescription, 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 { api } from "@/utils/api"; +import { Loader2, PcCase, RefreshCw } from "lucide-react"; +import { useState } from "react"; +import { AlertBlock } from "@/components/shared/alert-block"; import { Select, - SelectTrigger, - SelectValue, SelectContent, SelectGroup, SelectItem, SelectLabel, + SelectTrigger, + SelectValue, } 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(); diff --git a/apps/dokploy/components/dashboard/settings/servers/welcome-stripe/welcome-suscription.tsx b/apps/dokploy/components/dashboard/settings/servers/welcome-stripe/welcome-suscription.tsx index e9de0523..bab93047 100644 --- a/apps/dokploy/components/dashboard/settings/servers/welcome-stripe/welcome-suscription.tsx +++ b/apps/dokploy/components/dashboard/settings/servers/welcome-stripe/welcome-suscription.tsx @@ -1,3 +1,5 @@ +import { GithubIcon } from "@/components/icons/data-tools-icons"; +import { AlertBlock } from "@/components/shared/alert-block"; import { Button } from "@/components/ui/button"; import { Dialog, @@ -7,21 +9,19 @@ import { DialogHeader, DialogTitle, } from "@/components/ui/dialog"; +import { Separator } from "@/components/ui/separator"; +import { defineStepper } from "@stepperize/react"; import { BookIcon, Puzzle } from "lucide-react"; +import { Code2, Database, GitMerge, Globe, Plug, Users } from "lucide-react"; +import Link from "next/link"; 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 ConfettiExplosion from "react-confetti-explosion"; import { CreateServer } from "./create-server"; import { CreateSSHKey } from "./create-ssh-key"; import { Setup } from "./setup"; import { Verify } from "./verify"; -import { Database, Globe, GitMerge, 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"; export const { useStepper, steps, Scoped } = defineStepper( { diff --git a/apps/dokploy/components/dashboard/settings/web-server/docker-terminal-modal.tsx b/apps/dokploy/components/dashboard/settings/web-server/docker-terminal-modal.tsx index f81be0ad..fa9f1a41 100644 --- a/apps/dokploy/components/dashboard/settings/web-server/docker-terminal-modal.tsx +++ b/apps/dokploy/components/dashboard/settings/web-server/docker-terminal-modal.tsx @@ -80,7 +80,10 @@ export const DockerTerminalModal = ({ children, appName, serverId }: Props) => { return ( {children} - + event.preventDefault()} + > Docker Terminal @@ -119,7 +122,7 @@ export const DockerTerminalModal = ({ children, appName, serverId }: Props) => { containerId={containerId || "select-a-container"} /> - + event.preventDefault()}> Are you sure you want to close the terminal? diff --git a/apps/dokploy/components/dashboard/settings/web-server/manage-traefik-ports.tsx b/apps/dokploy/components/dashboard/settings/web-server/manage-traefik-ports.tsx new file mode 100644 index 00000000..180b2fcb --- /dev/null +++ b/apps/dokploy/components/dashboard/settings/web-server/manage-traefik-ports.tsx @@ -0,0 +1,230 @@ +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Label } from "@/components/ui/label"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { api } from "@/utils/api"; +import { useTranslation } from "next-i18next"; +import type React from "react"; +import { useEffect, useState } from "react"; +import { toast } from "sonner"; + +/** + * Props for the ManageTraefikPorts component + * @interface Props + * @property {React.ReactNode} children - The trigger element that opens the ports management modal + * @property {string} [serverId] - Optional ID of the server whose ports are being managed + */ +interface Props { + children: React.ReactNode; + serverId?: string; +} + +/** + * Represents a port mapping configuration for Traefik + * @interface AdditionalPort + * @property {number} targetPort - The internal port that the service is listening on + * @property {number} publishedPort - The external port that will be exposed + * @property {"ingress" | "host"} publishMode - The Docker Swarm publish mode: + * - "host": Publishes the port directly on the host + * - "ingress": Publishes the port through the Swarm routing mesh + */ +interface AdditionalPort { + targetPort: number; + publishedPort: number; + publishMode: "ingress" | "host"; +} + +/** + * ManageTraefikPorts is a component that provides a modal interface for managing + * additional port mappings for Traefik in a Docker Swarm environment. + * + * Features: + * - Add, remove, and edit port mappings + * - Configure target port, published port, and publish mode for each mapping + * - Persist port configurations through API calls + * + * @component + * @example + * ```tsx + * + * + * + * ``` + */ +export const ManageTraefikPorts = ({ children, serverId }: Props) => { + const { t } = useTranslation("settings"); + const [open, setOpen] = useState(false); + const [additionalPorts, setAdditionalPorts] = useState([]); + + const { data: currentPorts, refetch: refetchPorts } = + api.settings.getTraefikPorts.useQuery({ + serverId, + }); + + const { mutateAsync: updatePorts, isLoading } = + api.settings.updateTraefikPorts.useMutation({ + onSuccess: () => { + refetchPorts(); + }, + }); + + useEffect(() => { + if (currentPorts) { + setAdditionalPorts(currentPorts); + } + }, [currentPorts]); + + const handleAddPort = () => { + setAdditionalPorts([ + ...additionalPorts, + { targetPort: 0, publishedPort: 0, publishMode: "host" }, + ]); + }; + + const handleUpdatePorts = async () => { + try { + await updatePorts({ + serverId, + additionalPorts, + }); + toast.success(t("settings.server.webServer.traefik.portsUpdated")); + setOpen(false); + } catch (error) { + toast.error(t("settings.server.webServer.traefik.portsUpdateError")); + } + }; + + return ( + <> +
setOpen(true)}>{children}
+ + + + + {t("settings.server.webServer.traefik.managePorts")} + + + {t("settings.server.webServer.traefik.managePortsDescription")} + + +
+ {additionalPorts.map((port, index) => ( +
+
+ + { + const newPorts = [...additionalPorts]; + + if (newPorts[index]) { + newPorts[index].targetPort = Number.parseInt( + e.target.value, + ); + } + + setAdditionalPorts(newPorts); + }} + className="w-full rounded border p-2" + /> +
+
+ + { + const newPorts = [...additionalPorts]; + if (newPorts[index]) { + newPorts[index].publishedPort = Number.parseInt( + e.target.value, + ); + } + setAdditionalPorts(newPorts); + }} + className="w-full rounded border p-2" + /> +
+
+ + +
+
+ +
+
+ ))} +
+ + +
+
+
+
+ + ); +}; diff --git a/apps/dokploy/components/dashboard/settings/web-server/toggle-auto-check-updates.tsx b/apps/dokploy/components/dashboard/settings/web-server/toggle-auto-check-updates.tsx new file mode 100644 index 00000000..fb3776b1 --- /dev/null +++ b/apps/dokploy/components/dashboard/settings/web-server/toggle-auto-check-updates.tsx @@ -0,0 +1,28 @@ +import { Label } from "@/components/ui/label"; +import { Switch } from "@/components/ui/switch"; +import { useState } from "react"; + +export const ToggleAutoCheckUpdates = ({ disabled }: { disabled: boolean }) => { + const [enabled, setEnabled] = useState( + localStorage.getItem("enableAutoCheckUpdates") === "true", + ); + + const handleToggle = (checked: boolean) => { + setEnabled(checked); + localStorage.setItem("enableAutoCheckUpdates", String(checked)); + }; + + return ( +
+ + +
+ ); +}; diff --git a/apps/dokploy/components/dashboard/settings/web-server/update-server.tsx b/apps/dokploy/components/dashboard/settings/web-server/update-server.tsx index 48a61c7a..6c8475d3 100644 --- a/apps/dokploy/components/dashboard/settings/web-server/update-server.tsx +++ b/apps/dokploy/components/dashboard/settings/web-server/update-server.tsx @@ -3,91 +3,224 @@ import { Button } from "@/components/ui/button"; import { Dialog, DialogContent, - DialogDescription, - DialogHeader, DialogTitle, DialogTrigger, } from "@/components/ui/dialog"; import { api } from "@/utils/api"; -import { RefreshCcw } from "lucide-react"; +import { + Bug, + Download, + Info, + RefreshCcw, + Server, + ServerCrash, + Sparkles, + Stars, +} from "lucide-react"; import Link from "next/link"; import { useState } from "react"; import { toast } from "sonner"; +import { ToggleAutoCheckUpdates } from "./toggle-auto-check-updates"; import { UpdateWebServer } from "./update-webserver"; export const UpdateServer = () => { - const [isUpdateAvailable, setIsUpdateAvailable] = useState( - null, - ); - const { mutateAsync: checkAndUpdateImage, isLoading } = - api.settings.checkAndUpdateImage.useMutation(); + const [hasCheckedUpdate, setHasCheckedUpdate] = useState(false); + const [isUpdateAvailable, setIsUpdateAvailable] = useState(false); + const { mutateAsync: getUpdateData, isLoading } = + api.settings.getUpdateData.useMutation(); + const { data: dokployVersion } = api.settings.getDokployVersion.useQuery(); + const { data: releaseTag } = api.settings.getReleaseTag.useQuery(); const [isOpen, setIsOpen] = useState(false); + const [latestVersion, setLatestVersion] = useState(""); + + const handleCheckUpdates = async () => { + try { + const updateData = await getUpdateData(); + const versionToUpdate = updateData.latestVersion || ""; + setHasCheckedUpdate(true); + setIsUpdateAvailable(updateData.updateAvailable); + setLatestVersion(versionToUpdate); + + if (updateData.updateAvailable) { + toast.success(versionToUpdate, { + description: "New version available!", + }); + } else { + toast.info("No updates available"); + } + } catch (error) { + console.error("Error checking for updates:", error); + setHasCheckedUpdate(true); + setIsUpdateAvailable(false); + toast.error( + "An error occurred while checking for updates, please try again.", + ); + } + }; return ( - - - - Web Server Update - - Check new releases and update your dokploy - - + +
+ + Web Server Update + + {dokployVersion && ( +
+ + + {dokployVersion} | {releaseTag} + +
+ )} +
-
- - We suggest to update your dokploy to the latest version only if you: - -
    -
  • Want to try the latest features
  • -
  • Some bug that is blocking to use some features
  • -
- - We recommend checking the latest version for any breaking changes - before updating. Go to{" "} - - Dokploy Releases - {" "} - to check the latest version. - + {/* Initial state */} + {!hasCheckedUpdate && ( +
+

+ Check for new releases and update Dokploy. +
+
+ We recommend checking for updates regularly to ensure you have the + latest features and security improvements. +

+
+ )} -
- {isUpdateAvailable === false && ( -
- - - You are using the latest version + {/* Update available state */} + {isUpdateAvailable && latestVersion && ( +
+
+
+ + + + + + + New version available:
- )} + + {latestVersion} + +
+ +
+

+ A new version of the server software is available. Consider + updating if you: +

+
    +
  • + + + Want to access the latest features and improvements + +
  • +
  • + + + Are experiencing issues that may be resolved in the new + version + +
  • +
+
+
+ )} + + {/* Up to date state */} + {hasCheckedUpdate && !isUpdateAvailable && !isLoading && ( +
+
+
+ +
+
+

+ You are using the latest version +

+

+ Your server is up to date with all the latest features and + security improvements. +

+
+
+
+ )} + + {hasCheckedUpdate && isLoading && ( +
+
+
+ +
+
+

Checking for updates...

+

+ Please wait while we pull the latest version information from + Docker Hub. +

+
+
+
+ )} + + {isUpdateAvailable && ( +
+
+ +
+ We recommend reviewing the{" "} + + release notes + {" "} + for any breaking changes before updating. +
+
+
+ )} + +
+ +
+ +
+
+ {isUpdateAvailable ? ( ) : ( )}
@@ -96,3 +229,5 @@ export const UpdateServer = () => {
); }; + +export default UpdateServer; diff --git a/apps/dokploy/components/dashboard/settings/web-server/update-webserver.tsx b/apps/dokploy/components/dashboard/settings/web-server/update-webserver.tsx index 47d38310..c1e5de70 100644 --- a/apps/dokploy/components/dashboard/settings/web-server/update-webserver.tsx +++ b/apps/dokploy/components/dashboard/settings/web-server/update-webserver.tsx @@ -11,24 +11,53 @@ import { } from "@/components/ui/alert-dialog"; import { Button } from "@/components/ui/button"; import { api } from "@/utils/api"; +import { HardDriveDownload } from "lucide-react"; import { toast } from "sonner"; -export const UpdateWebServer = () => { +interface Props { + isNavbar?: boolean; +} + +export const UpdateWebServer = ({ isNavbar }: Props) => { const { mutateAsync: updateServer, isLoading } = api.settings.updateServer.useMutation(); + + const buttonLabel = isNavbar ? "Update available" : "Update Server"; + + const handleConfirm = async () => { + try { + await updateServer(); + toast.success( + "The server has been updated. The page will be reloaded to reflect the changes...", + ); + setTimeout(() => { + // Allow seeing the toast before reloading + window.location.reload(); + }, 2000); + } catch (error) { + console.error("Error updating server:", error); + toast.error( + "An error occurred while updating the server, please try again.", + ); + } + }; + return ( @@ -36,19 +65,12 @@ export const UpdateWebServer = () => { Are you absolutely sure? This action cannot be undone. This will update the web server to the - new version. + new version. The page will be reloaded once the update is finished. Cancel - { - await updateServer(); - toast.success("Please reload the browser to see the changes"); - }} - > - Confirm - + Confirm diff --git a/apps/dokploy/components/dashboard/swarm/applications/columns.tsx b/apps/dokploy/components/dashboard/swarm/applications/columns.tsx new file mode 100644 index 00000000..1961cd99 --- /dev/null +++ b/apps/dokploy/components/dashboard/swarm/applications/columns.tsx @@ -0,0 +1,218 @@ +import type { ColumnDef } from "@tanstack/react-table"; +import { ArrowUpDown, MoreHorizontal } from "lucide-react"; +import * as React from "react"; + +import { Button } from "@/components/ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuLabel, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; + +import { Badge } from "@/components/ui/badge"; +import { ShowNodeConfig } from "../details/show-node-config"; +// import { ShowContainerConfig } from "../config/show-container-config"; +// import { ShowDockerModalLogs } from "../logs/show-docker-modal-logs"; +// import { DockerTerminalModal } from "../terminal/docker-terminal-modal"; +// import type { Container } from "./show-containers"; + +export interface ApplicationList { + ID: string; + Image: string; + Mode: string; + Name: string; + Ports: string; + Replicas: string; + CurrentState: string; + DesiredState: string; + Error: string; + Node: string; +} + +export const columns: ColumnDef[] = [ + { + accessorKey: "ID", + accessorFn: (row) => row.ID, + header: ({ column }) => { + return ( + + ); + }, + cell: ({ row }) => { + return
{row.getValue("ID")}
; + }, + }, + { + accessorKey: "Name", + accessorFn: (row) => row.Name, + header: ({ column }) => { + return ( + + ); + }, + cell: ({ row }) => { + return
{row.getValue("Name")}
; + }, + }, + { + accessorKey: "Image", + accessorFn: (row) => row.Image, + header: ({ column }) => { + return ( + + ); + }, + cell: ({ row }) => { + return
{row.getValue("Image")}
; + }, + }, + { + accessorKey: "Mode", + accessorFn: (row) => row.Mode, + header: ({ column }) => { + return ( + + ); + }, + cell: ({ row }) => { + return
{row.getValue("Mode")}
; + }, + }, + { + accessorKey: "CurrentState", + accessorFn: (row) => row.CurrentState, + header: ({ column }) => { + return ( + + ); + }, + cell: ({ row }) => { + const value = row.getValue("CurrentState") as string; + const valueStart = value.startsWith("Running") + ? "Running" + : value.startsWith("Shutdown") + ? "Shutdown" + : value; + return ( +
+ + {value} + +
+ ); + }, + }, + { + accessorKey: "DesiredState", + accessorFn: (row) => row.DesiredState, + header: ({ column }) => { + return ( + + ); + }, + cell: ({ row }) => { + return
{row.getValue("DesiredState")}
; + }, + }, + + { + accessorKey: "Replicas", + accessorFn: (row) => row.Replicas, + header: ({ column }) => { + return ( + + ); + }, + cell: ({ row }) => { + return
{row.getValue("Replicas")}
; + }, + }, + + { + accessorKey: "Ports", + accessorFn: (row) => row.Ports, + header: ({ column }) => { + return ( + + ); + }, + cell: ({ row }) => { + return
{row.getValue("Ports")}
; + }, + }, + { + accessorKey: "Errors", + accessorFn: (row) => row.Error, + header: ({ column }) => { + return ( + + ); + }, + cell: ({ row }) => { + return
{row.getValue("Errors")}
; + }, + }, +]; diff --git a/apps/dokploy/components/dashboard/swarm/applications/data-table.tsx b/apps/dokploy/components/dashboard/swarm/applications/data-table.tsx new file mode 100644 index 00000000..03915c19 --- /dev/null +++ b/apps/dokploy/components/dashboard/swarm/applications/data-table.tsx @@ -0,0 +1,197 @@ +"use client"; + +import { + type ColumnDef, + type ColumnFiltersState, + type SortingState, + type VisibilityState, + flexRender, + getCoreRowModel, + getFilteredRowModel, + getPaginationRowModel, + getSortedRowModel, + useReactTable, +} from "@tanstack/react-table"; + +import { Button } from "@/components/ui/button"; +import { + DropdownMenu, + DropdownMenuCheckboxItem, + DropdownMenuContent, +} from "@/components/ui/dropdown-menu"; +import { Input } from "@/components/ui/input"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { DropdownMenuTrigger } from "@radix-ui/react-dropdown-menu"; +import { ChevronDown } from "lucide-react"; +import React from "react"; + +interface DataTableProps { + columns: ColumnDef[]; + data: TData[]; +} + +export function DataTable({ + columns, + data, +}: DataTableProps) { + const [sorting, setSorting] = React.useState([]); + const [columnFilters, setColumnFilters] = React.useState( + [], + ); + const [columnVisibility, setColumnVisibility] = + React.useState({}); + const [rowSelection, setRowSelection] = React.useState({}); + const [pagination, setPagination] = React.useState({ + pageIndex: 0, //initial page index + pageSize: 8, //default page size + }); + + const table = useReactTable({ + data, + columns, + onSortingChange: setSorting, + onColumnFiltersChange: setColumnFilters, + getCoreRowModel: getCoreRowModel(), + getPaginationRowModel: getPaginationRowModel(), + getSortedRowModel: getSortedRowModel(), + getFilteredRowModel: getFilteredRowModel(), + onColumnVisibilityChange: setColumnVisibility, + onRowSelectionChange: setRowSelection, + state: { + sorting, + columnFilters, + columnVisibility, + rowSelection, + }, + }); + + return ( +
+
+
+ + table.getColumn("Name")?.setFilterValue(event.target.value) + } + className="md:max-w-sm" + /> + + + + + + {table + .getAllColumns() + .filter((column) => column.getCanHide()) + .map((column) => { + return ( + + column.toggleVisibility(!!value) + } + > + {column.id} + + ); + })} + + +
+ + + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => { + return ( + + {header.isPlaceholder + ? null + : flexRender( + header.column.columnDef.header, + header.getContext(), + )} + + ); + })} + + ))} + + + {table?.getRowModel()?.rows?.length ? ( + table.getRowModel().rows.map((row) => ( + + {row.getVisibleCells().map((cell) => ( + + {flexRender( + cell.column.columnDef.cell, + cell.getContext(), + )} + + ))} + + )) + ) : ( + + + No results. + {/* {isLoading ? ( +
+ + Loading... + +
+ ) : ( + <>No results. + )} */} +
+
+ )} +
+
+ + {data && data?.length > 0 && ( +
+
+ + +
+
+ )} +
+
+ ); +} diff --git a/apps/dokploy/components/dashboard/swarm/applications/show-applications.tsx b/apps/dokploy/components/dashboard/swarm/applications/show-applications.tsx new file mode 100644 index 00000000..132cb008 --- /dev/null +++ b/apps/dokploy/components/dashboard/swarm/applications/show-applications.tsx @@ -0,0 +1,116 @@ +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; +import { api } from "@/utils/api"; +import { Layers, Loader2 } from "lucide-react"; +import React from "react"; +import { columns } from "./columns"; +import { DataTable } from "./data-table"; + +interface Props { + serverId?: string; +} + +interface ApplicationList { + ID: string; + Image: string; + Mode: string; + Name: string; + Ports: string; + Replicas: string; + CurrentState: string; + DesiredState: string; + Error: string; + Node: string; +} + +export const ShowNodeApplications = ({ serverId }: Props) => { + const { data: NodeApps, isLoading: NodeAppsLoading } = + api.swarm.getNodeApps.useQuery({ serverId }); + + let applicationList = ""; + + if (NodeApps && NodeApps.length > 0) { + applicationList = NodeApps.map((app) => app.Name).join(" "); + } + + const { data: NodeAppDetails, isLoading: NodeAppDetailsLoading } = + api.swarm.getAppInfos.useQuery({ appName: applicationList, serverId }); + + if (NodeAppsLoading || NodeAppDetailsLoading) { + return ( + + + + + + ); + } + + if (!NodeApps || !NodeAppDetails) { + return ( + + No data found + + ); + } + + const combinedData: ApplicationList[] = NodeApps.flatMap((app) => { + const appDetails = + NodeAppDetails?.filter((detail) => + detail.Name.startsWith(`${app.Name}.`), + ) || []; + + if (appDetails.length === 0) { + return [ + { + ...app, + CurrentState: "N/A", + DesiredState: "N/A", + Error: "", + Node: "N/A", + Ports: app.Ports, + }, + ]; + } + + return appDetails.map((detail) => ({ + ...app, + CurrentState: detail.CurrentState, + DesiredState: detail.DesiredState, + Error: detail.Error, + Node: detail.Node, + Ports: detail.Ports || app.Ports, + })); + }); + + return ( + + + + + + + Node Applications + + See in detail the applications running on this node + + +
+ +
+
+
+ ); +}; diff --git a/apps/dokploy/components/dashboard/swarm/details/details-card.tsx b/apps/dokploy/components/dashboard/swarm/details/details-card.tsx new file mode 100644 index 00000000..a499f898 --- /dev/null +++ b/apps/dokploy/components/dashboard/swarm/details/details-card.tsx @@ -0,0 +1,140 @@ +import { Badge } from "@/components/ui/badge"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { api } from "@/utils/api"; +import { + AlertCircle, + CheckCircle, + HelpCircle, + Loader2, + LoaderIcon, +} from "lucide-react"; +import { ShowNodeApplications } from "../applications/show-applications"; +import { ShowNodeConfig } from "./show-node-config"; + +export interface SwarmList { + ID: string; + Hostname: string; + Availability: string; + EngineVersion: string; + Status: string; + ManagerStatus: string; + TLSStatus: string; +} + +interface Props { + node: SwarmList; + serverId?: string; +} + +export function NodeCard({ node, serverId }: Props) { + const { data, isLoading } = api.swarm.getNodeInfo.useQuery({ + nodeId: node.ID, + serverId, + }); + + const getStatusIcon = (status: string) => { + switch (status) { + case "Ready": + return ; + case "Down": + return ; + default: + return ; + } + }; + + if (isLoading) { + return ( + + + + + {getStatusIcon(node.Status)} + {node.Hostname} + + + {node.ManagerStatus || "Worker"} + + + + +
+ +
+
+
+ ); + } + + return ( + + + + + {getStatusIcon(node.Status)} + {node.Hostname} + + + {node.ManagerStatus || "Worker"} + + + + +
+
+ Status: + {node.Status} +
+
+ IP Address: + {isLoading ? ( + + ) : ( + {data?.Status?.Addr} + )} +
+
+ Availability: + {node.Availability} +
+
+ Engine Version: + {node.EngineVersion} +
+
+ CPU: + {isLoading ? ( + + ) : ( + + {(data?.Description?.Resources?.NanoCPUs / 1e9).toFixed(2)} GHz + + )} +
+
+ Memory: + {isLoading ? ( + + ) : ( + + {( + data?.Description?.Resources?.MemoryBytes / + 1024 ** 3 + ).toFixed(2)}{" "} + GB + + )} +
+
+ TLS Status: + {node.TLSStatus} +
+
+
+ + +
+
+
+ ); +} diff --git a/apps/dokploy/components/dashboard/swarm/details/show-node-config.tsx b/apps/dokploy/components/dashboard/swarm/details/show-node-config.tsx new file mode 100644 index 00000000..a41c5a49 --- /dev/null +++ b/apps/dokploy/components/dashboard/swarm/details/show-node-config.tsx @@ -0,0 +1,56 @@ +import { CodeEditor } from "@/components/shared/code-editor"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; +import { api } from "@/utils/api"; +import { Settings } from "lucide-react"; + +interface Props { + nodeId: string; + serverId?: string; +} + +export const ShowNodeConfig = ({ nodeId, serverId }: Props) => { + const { data, isLoading } = api.swarm.getNodeInfo.useQuery({ + nodeId, + serverId, + }); + return ( + + + + + + + Node Config + + See in detail the metadata of this node + + +
+ +
+							{/* {JSON.stringify(data, null, 2)} */}
+							
+						
+
+
+
+
+ ); +}; diff --git a/apps/dokploy/components/dashboard/swarm/monitoring-card.tsx b/apps/dokploy/components/dashboard/swarm/monitoring-card.tsx new file mode 100644 index 00000000..e2453dd9 --- /dev/null +++ b/apps/dokploy/components/dashboard/swarm/monitoring-card.tsx @@ -0,0 +1,188 @@ +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@/components/ui/tooltip"; +import { api } from "@/utils/api"; +import { + AlertCircle, + CheckCircle, + HelpCircle, + Loader2, + Server, +} from "lucide-react"; +import { NodeCard } from "./details/details-card"; + +interface Props { + serverId?: string; +} + +export default function SwarmMonitorCard({ serverId }: Props) { + const { data: nodes, isLoading } = api.swarm.getNodes.useQuery({ + serverId, + }); + + if (isLoading) { + return ( +
+
+
+ +
+
+
+ ); + } + + if (!nodes) { + return ( +
+
+
+ Failed to load data +
+
+
+ ); + } + + const totalNodes = nodes.length; + const activeNodesCount = nodes.filter( + (node) => node.Status === "Ready", + ).length; + const managerNodesCount = nodes.filter( + (node) => + node.ManagerStatus === "Leader" || node.ManagerStatus === "Reachable", + ).length; + + const activeNodes = nodes.filter((node) => node.Status === "Ready"); + const managerNodes = nodes.filter( + (node) => + node.ManagerStatus === "Leader" || node.ManagerStatus === "Reachable", + ); + + const getStatusIcon = (status: string) => { + switch (status) { + case "Ready": + return ; + case "Down": + return ; + case "Disconnected": + return ; + default: + return ; + } + }; + + return ( +
+
+

Docker Swarm Overview

+ {!serverId && ( + + )} +
+ + + + + Monitor + + + +
+
+ Total Nodes: + {totalNodes} +
+
+ Active Nodes: + + + + + {activeNodesCount} / {totalNodes} + + + +
+ {activeNodes.map((node) => ( +
+ {getStatusIcon(node.Status)} + {node.Hostname} +
+ ))} +
+
+
+
+
+
+ Manager Nodes: + + + + + {managerNodesCount} / {totalNodes} + + + +
+ {managerNodes.map((node) => ( +
+ {getStatusIcon(node.Status)} + {node.Hostname} +
+ ))} +
+
+
+
+
+
+
+

Node Status:

+
    + {nodes.map((node) => ( +
  • + + {getStatusIcon(node.Status)} + {node.Hostname} + + + {node.ManagerStatus || "Worker"} + +
  • + ))} +
+
+
+
+
+ {nodes.map((node) => ( + + ))} +
+
+ ); +} diff --git a/apps/dokploy/components/layouts/navbar.tsx b/apps/dokploy/components/layouts/navbar.tsx index cead4683..0e52d701 100644 --- a/apps/dokploy/components/layouts/navbar.tsx +++ b/apps/dokploy/components/layouts/navbar.tsx @@ -12,11 +12,16 @@ import { api } from "@/utils/api"; import { HeartIcon } from "lucide-react"; import Link from "next/link"; import { useRouter } from "next/router"; +import { useEffect, useRef, useState } from "react"; +import { UpdateWebServer } from "../dashboard/settings/web-server/update-webserver"; import { Logo } from "../shared/logo"; import { Avatar, AvatarFallback, AvatarImage } from "../ui/avatar"; import { buttonVariants } from "../ui/button"; +const AUTO_CHECK_UPDATES_INTERVAL_MINUTES = 7; + export const Navbar = () => { + const [isUpdateAvailable, setIsUpdateAvailable] = useState(false); const router = useRouter(); const { data } = api.auth.get.useQuery(); const { data: isCloud } = api.settings.isCloud.useQuery(); @@ -29,6 +34,59 @@ export const Navbar = () => { }, ); const { mutateAsync } = api.auth.logout.useMutation(); + const { mutateAsync: getUpdateData } = + api.settings.getUpdateData.useMutation(); + + const checkUpdatesIntervalRef = useRef(null); + + useEffect(() => { + // Handling of automatic check for server updates + if (isCloud) { + return; + } + + if (!localStorage.getItem("enableAutoCheckUpdates")) { + // Enable auto update checking by default if user didn't change it + localStorage.setItem("enableAutoCheckUpdates", "true"); + } + + const clearUpdatesInterval = () => { + if (checkUpdatesIntervalRef.current) { + clearInterval(checkUpdatesIntervalRef.current); + } + }; + + const checkUpdates = async () => { + try { + if (localStorage.getItem("enableAutoCheckUpdates") !== "true") { + return; + } + + const { updateAvailable } = await getUpdateData(); + + if (updateAvailable) { + // Stop interval when update is available + clearUpdatesInterval(); + setIsUpdateAvailable(true); + } + } catch (error) { + console.error("Error auto-checking for updates:", error); + } + }; + + checkUpdatesIntervalRef.current = setInterval( + checkUpdates, + AUTO_CHECK_UPDATES_INTERVAL_MINUTES * 60000, + ); + + // Also check for updates on initial page load + checkUpdates(); + + return () => { + clearUpdatesInterval(); + }; + }, []); + return (
+ {isUpdateAvailable && ( +
+ +
+ )} { const elements: TabInfo[] = [ @@ -60,6 +61,15 @@ const getTabMaps = (isCloud: boolean) => { }, type: "docker", }, + { + label: "Swarm", + description: "Manage your docker swarm and Servers", + index: "/dashboard/swarm", + isShow: ({ rol, user }) => { + return Boolean(rol === "admin" || user?.canAccessToDocker); + }, + type: "swarm", + }, { label: "Requests", description: "Manage your requests", diff --git a/apps/dokploy/package.json b/apps/dokploy/package.json index 9e3eb7f7..a16dedac 100644 --- a/apps/dokploy/package.json +++ b/apps/dokploy/package.json @@ -1,6 +1,6 @@ { "name": "dokploy", - "version": "v0.15.0", + "version": "v0.15.1", "private": true, "license": "Apache-2.0", "type": "module", @@ -35,8 +35,6 @@ "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", @@ -64,6 +62,7 @@ "@radix-ui/react-tabs": "^1.0.4", "@radix-ui/react-toggle": "^1.0.3", "@radix-ui/react-tooltip": "^1.0.7", + "@stepperize/react": "4.0.1", "@stripe/stripe-js": "4.8.0", "@tanstack/react-query": "^4.36.1", "@tanstack/react-table": "^8.16.0", @@ -87,6 +86,7 @@ "dotenv": "16.4.5", "drizzle-orm": "^0.30.8", "drizzle-zod": "0.5.1", + "fancy-ansi": "^0.1.3", "i18next": "^23.16.4", "input-otp": "^1.2.4", "js-cookie": "^3.0.5", @@ -104,6 +104,7 @@ "postgres": "3.4.4", "public-ip": "6.0.2", "react": "18.2.0", + "react-confetti-explosion": "2.1.2", "react-dom": "18.2.0", "react-hook-form": "^7.49.3", "react-i18next": "^15.1.0", diff --git a/apps/dokploy/pages/api/deploy/github.ts b/apps/dokploy/pages/api/deploy/github.ts index 0e0a6c82..45923762 100644 --- a/apps/dokploy/pages/api/deploy/github.ts +++ b/apps/dokploy/pages/api/deploy/github.ts @@ -3,19 +3,19 @@ import { applications, compose, github } from "@/server/db/schema"; import type { DeploymentJob } from "@/server/queues/queue-types"; import { myQueue } from "@/server/queues/queueSetup"; import { deploy } from "@/server/utils/deploy"; +import { generateRandomDomain } from "@/templates/utils"; import { - createPreviewDeployment, type Domain, + IS_CLOUD, + createPreviewDeployment, findPreviewDeploymentByApplicationId, findPreviewDeploymentsByPullRequestId, - IS_CLOUD, removePreviewDeployment, } from "@dokploy/server"; import { Webhooks } from "@octokit/webhooks"; import { and, eq } from "drizzle-orm"; import type { NextApiRequest, NextApiResponse } from "next"; import { extractCommitMessage, extractHash } from "./[refreshToken]"; -import { generateRandomDomain } from "@/templates/utils"; export default async function handler( req: NextApiRequest, diff --git a/apps/dokploy/pages/dashboard/swarm.tsx b/apps/dokploy/pages/dashboard/swarm.tsx new file mode 100644 index 00000000..15a7d793 --- /dev/null +++ b/apps/dokploy/pages/dashboard/swarm.tsx @@ -0,0 +1,86 @@ +import SwarmMonitorCard from "@/components/dashboard/swarm/monitoring-card"; +import { DashboardLayout } from "@/components/layouts/dashboard-layout"; +import { appRouter } from "@/server/api/root"; +import { IS_CLOUD, validateRequest } from "@dokploy/server"; +import { createServerSideHelpers } from "@trpc/react-query/server"; +import type { GetServerSidePropsContext } from "next"; +import type { ReactElement } from "react"; +import superjson from "superjson"; + +const Dashboard = () => { + return ( + <> +
+ +
+ + ); +}; + +export default Dashboard; + +Dashboard.getLayout = (page: ReactElement) => { + return {page}; +}; +export async function getServerSideProps( + ctx: GetServerSidePropsContext<{ serviceId: string }>, +) { + if (IS_CLOUD) { + return { + redirect: { + permanent: true, + destination: "/dashboard/projects", + }, + }; + } + const { user, session } = await validateRequest(ctx.req, ctx.res); + if (!user) { + return { + redirect: { + permanent: true, + destination: "/", + }, + }; + } + const { req, res } = ctx; + + const helpers = createServerSideHelpers({ + router: appRouter, + ctx: { + req: req as any, + res: res as any, + db: null as any, + session: session, + user: user, + }, + transformer: superjson, + }); + try { + await helpers.project.all.prefetch(); + const auth = await helpers.auth.get.fetch(); + + if (auth.rol === "user") { + const user = await helpers.user.byAuthId.fetch({ + authId: auth.id, + }); + + if (!user.canAccessToDocker) { + return { + redirect: { + permanent: true, + destination: "/", + }, + }; + } + } + return { + props: { + trpcState: helpers.dehydrate(), + }, + }; + } catch (error) { + return { + props: {}, + }; + } +} diff --git a/apps/dokploy/public/locales/en/settings.json b/apps/dokploy/public/locales/en/settings.json index 2103ecc0..1ce54692 100644 --- a/apps/dokploy/public/locales/en/settings.json +++ b/apps/dokploy/public/locales/en/settings.json @@ -18,6 +18,14 @@ "settings.server.webServer.server.label": "Server", "settings.server.webServer.traefik.label": "Traefik", "settings.server.webServer.traefik.modifyEnv": "Modify Env", + "settings.server.webServer.traefik.managePorts": "Additional Ports", + "settings.server.webServer.traefik.managePortsDescription": "Add or remove additional ports for Traefik", + "settings.server.webServer.traefik.targetPort": "Target Port", + "settings.server.webServer.traefik.publishedPort": "Published Port", + "settings.server.webServer.traefik.addPort": "Add Port", + "settings.server.webServer.traefik.portsUpdated": "Ports updated successfully", + "settings.server.webServer.traefik.portsUpdateError": "Failed to update ports", + "settings.server.webServer.traefik.publishMode": "Publish Mode", "settings.server.webServer.storage.label": "Space", "settings.server.webServer.storage.cleanUnusedImages": "Clean unused images", "settings.server.webServer.storage.cleanUnusedVolumes": "Clean unused volumes", diff --git a/apps/dokploy/public/locales/it/common.json b/apps/dokploy/public/locales/it/common.json index 9e26dfee..0967ef42 100644 --- a/apps/dokploy/public/locales/it/common.json +++ b/apps/dokploy/public/locales/it/common.json @@ -1 +1 @@ -{} \ No newline at end of file +{} diff --git a/apps/dokploy/public/locales/it/settings.json b/apps/dokploy/public/locales/it/settings.json index f4c2cc06..6280e44e 100644 --- a/apps/dokploy/public/locales/it/settings.json +++ b/apps/dokploy/public/locales/it/settings.json @@ -1,44 +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" -} \ No newline at end of file +{ + "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" +} diff --git a/apps/dokploy/server/api/root.ts b/apps/dokploy/server/api/root.ts index 85eb9763..68f5e4e0 100644 --- a/apps/dokploy/server/api/root.ts +++ b/apps/dokploy/server/api/root.ts @@ -21,6 +21,7 @@ import { mysqlRouter } from "./routers/mysql"; import { notificationRouter } from "./routers/notification"; import { portRouter } from "./routers/port"; import { postgresRouter } from "./routers/postgres"; +import { previewDeploymentRouter } from "./routers/preview-deployment"; import { projectRouter } from "./routers/project"; import { redirectsRouter } from "./routers/redirects"; import { redisRouter } from "./routers/redis"; @@ -30,8 +31,8 @@ import { serverRouter } from "./routers/server"; import { settingsRouter } from "./routers/settings"; import { sshRouter } from "./routers/ssh-key"; import { stripeRouter } from "./routers/stripe"; +import { swarmRouter } from "./routers/swarm"; import { userRouter } from "./routers/user"; -import { previewDeploymentRouter } from "./routers/preview-deployment"; /** * This is the primary router for your server. @@ -73,6 +74,7 @@ export const appRouter = createTRPCRouter({ github: githubRouter, server: serverRouter, stripe: stripeRouter, + swarm: swarmRouter, }); // export type definition of API diff --git a/apps/dokploy/server/api/routers/compose.ts b/apps/dokploy/server/api/routers/compose.ts index 6d04e815..5f53752b 100644 --- a/apps/dokploy/server/api/routers/compose.ts +++ b/apps/dokploy/server/api/routers/compose.ts @@ -3,6 +3,7 @@ import { db } from "@/server/db"; import { apiCreateCompose, apiCreateComposeByTemplate, + apiDeleteCompose, apiFetchServices, apiFindCompose, apiRandomizeCompose, @@ -117,7 +118,7 @@ export const composeRouter = createTRPCRouter({ return updateCompose(input.composeId, input); }), delete: protectedProcedure - .input(apiFindCompose) + .input(apiDeleteCompose) .mutation(async ({ input, ctx }) => { if (ctx.user.rol === "user") { await checkServiceAccess(ctx.user.authId, input.composeId, "delete"); @@ -138,7 +139,7 @@ export const composeRouter = createTRPCRouter({ .returning(); const cleanupOperations = [ - async () => await removeCompose(composeResult), + async () => await removeCompose(composeResult, input.deleteVolumes), async () => await removeDeploymentsByComposeId(composeResult), async () => await removeComposeDirectory(composeResult.appName), ]; diff --git a/apps/dokploy/server/api/routers/settings.ts b/apps/dokploy/server/api/routers/settings.ts index e6469224..8bec9b52 100644 --- a/apps/dokploy/server/api/routers/settings.ts +++ b/apps/dokploy/server/api/routers/settings.ts @@ -12,6 +12,7 @@ import { } from "@/server/db/schema"; import { removeJob, schedule } from "@/server/utils/backup"; import { + DEFAULT_UPDATE_DATA, IS_CLOUD, canAccessToTraefikFiles, cleanStoppedContainers, @@ -25,6 +26,8 @@ import { findAdminById, findServerById, getDokployImage, + getDokployImageTag, + getUpdateData, initializeTraefik, logRotationManager, parseRawConfig, @@ -342,17 +345,20 @@ export const settingsRouter = createTRPCRouter({ writeConfig("middlewares", input.traefikConfig); return true; }), - - checkAndUpdateImage: adminProcedure.mutation(async () => { + getUpdateData: adminProcedure.mutation(async () => { if (IS_CLOUD) { - return true; + return DEFAULT_UPDATE_DATA; } - return await pullLatestRelease(); + + return await getUpdateData(); }), updateServer: adminProcedure.mutation(async () => { if (IS_CLOUD) { return true; } + + await pullLatestRelease(); + await spawnAsync("docker", [ "service", "update", @@ -361,12 +367,16 @@ export const settingsRouter = createTRPCRouter({ getDokployImage(), "dokploy", ]); + return true; }), getDokployVersion: adminProcedure.query(() => { return packageInfo.version; }), + getReleaseTag: adminProcedure.query(() => { + return getDokployImageTag(); + }), readDirectories: protectedProcedure .input(apiServerSchema) .query(async ({ ctx, input }) => { @@ -706,6 +716,83 @@ export const settingsRouter = createTRPCRouter({ throw new Error("Failed to check GPU status"); } }), + updateTraefikPorts: adminProcedure + .input( + z.object({ + serverId: z.string().optional(), + additionalPorts: z.array( + z.object({ + targetPort: z.number(), + publishedPort: z.number(), + publishMode: z.enum(["ingress", "host"]).default("host"), + }), + ), + }), + ) + .mutation(async ({ input }) => { + try { + if (IS_CLOUD && !input.serverId) { + throw new TRPCError({ + code: "UNAUTHORIZED", + message: "Please set a serverId to update Traefik ports", + }); + } + await initializeTraefik({ + serverId: input.serverId, + additionalPorts: input.additionalPorts, + }); + return true; + } catch (error) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: + error instanceof Error + ? error.message + : "Error to update Traefik ports", + cause: error, + }); + } + }), + getTraefikPorts: adminProcedure + .input(apiServerSchema) + .query(async ({ input }) => { + const command = `docker service inspect --format='{{json .Endpoint.Ports}}' dokploy-traefik`; + + try { + let stdout = ""; + if (input?.serverId) { + const result = await execAsyncRemote(input.serverId, command); + stdout = result.stdout; + } else if (!IS_CLOUD) { + const result = await execAsync(command); + stdout = result.stdout; + } + + const ports: { + Protocol: string; + TargetPort: number; + PublishedPort: number; + PublishMode: string; + }[] = JSON.parse(stdout.trim()); + + // Filter out the default ports (80, 443, and optionally 8080) + const additionalPorts = ports + .filter((port) => ![80, 443, 8080].includes(port.PublishedPort)) + .map((port) => ({ + targetPort: port.TargetPort, + publishedPort: port.PublishedPort, + publishMode: port.PublishMode.toLowerCase() as "host" | "ingress", + })); + + return additionalPorts; + } catch (error) { + throw new TRPCError({ + code: "INTERNAL_SERVER_ERROR", + message: "Failed to get Traefik ports", + cause: error, + }); + } + }), }); // { // "Parallelism": 1, diff --git a/apps/dokploy/server/api/routers/swarm.ts b/apps/dokploy/server/api/routers/swarm.ts new file mode 100644 index 00000000..c5a2d4c8 --- /dev/null +++ b/apps/dokploy/server/api/routers/swarm.ts @@ -0,0 +1,44 @@ +import { + getApplicationInfo, + getNodeApplications, + getNodeInfo, + getSwarmNodes, +} from "@dokploy/server"; +import { z } from "zod"; +import { createTRPCRouter, protectedProcedure } from "../trpc"; + +export const swarmRouter = createTRPCRouter({ + getNodes: protectedProcedure + .input( + z.object({ + serverId: z.string().optional(), + }), + ) + .query(async ({ input }) => { + return await getSwarmNodes(input.serverId); + }), + getNodeInfo: protectedProcedure + .input(z.object({ nodeId: z.string(), serverId: z.string().optional() })) + .query(async ({ input }) => { + return await getNodeInfo(input.nodeId, input.serverId); + }), + getNodeApps: protectedProcedure + .input( + z.object({ + serverId: z.string().optional(), + }), + ) + .query(async ({ input }) => { + return getNodeApplications(input.serverId); + }), + getAppInfos: protectedProcedure + .input( + z.object({ + appName: z.string(), + serverId: z.string().optional(), + }), + ) + .query(async ({ input }) => { + return await getApplicationInfo(input.appName, input.serverId); + }), +}); diff --git a/apps/dokploy/tailwind.config.ts b/apps/dokploy/tailwind.config.ts index c4fa88ec..45b529af 100644 --- a/apps/dokploy/tailwind.config.ts +++ b/apps/dokploy/tailwind.config.ts @@ -87,7 +87,7 @@ const config = { }, }, }, - plugins: [require("tailwindcss-animate")], + plugins: [require("tailwindcss-animate"), require("fancy-ansi/plugin")], } satisfies Config; export default config; diff --git a/apps/dokploy/templates/onedev/index.ts b/apps/dokploy/templates/onedev/index.ts index 5dad1728..8017c351 100644 --- a/apps/dokploy/templates/onedev/index.ts +++ b/apps/dokploy/templates/onedev/index.ts @@ -1,22 +1,22 @@ import { - type DomainSchema, - type Schema, - type Template, - generateRandomDomain, + type DomainSchema, + type Schema, + type Template, + generateRandomDomain, } from "../utils"; export function generate(schema: Schema): Template { - const randomDomain = generateRandomDomain(schema); + const randomDomain = generateRandomDomain(schema); - const domains: DomainSchema[] = [ - { - host: randomDomain, - port: 6610, - serviceName: "onedev", - }, - ]; + const domains: DomainSchema[] = [ + { + host: randomDomain, + port: 6610, + serviceName: "onedev", + }, + ]; - return { - domains, - }; + return { + domains, + }; } diff --git a/apps/dokploy/templates/unsend/index.ts b/apps/dokploy/templates/unsend/index.ts index a383b771..1c4c9c71 100644 --- a/apps/dokploy/templates/unsend/index.ts +++ b/apps/dokploy/templates/unsend/index.ts @@ -1,44 +1,44 @@ import { - generateHash, - generateRandomDomain, - generateBase64, - type Template, - type Schema, - type DomainSchema, + type DomainSchema, + type Schema, + type Template, + generateBase64, + generateHash, + generateRandomDomain, } from "../utils"; export function generate(schema: Schema): Template { - const mainDomain = generateRandomDomain(schema); - const secretBase = generateBase64(64); + const mainDomain = generateRandomDomain(schema); + const secretBase = generateBase64(64); - const domains: DomainSchema[] = [ - { - host: mainDomain, - port: 3000, - serviceName: "unsend", - }, - ]; + 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=", - ]; + 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, - }; + return { + envs, + domains, + }; } diff --git a/packages/server/src/db/schema/application.ts b/packages/server/src/db/schema/application.ts index d9b1a5df..0f6aaed3 100644 --- a/packages/server/src/db/schema/application.ts +++ b/packages/server/src/db/schema/application.ts @@ -17,6 +17,7 @@ import { github } from "./github"; import { gitlab } from "./gitlab"; import { mounts } from "./mount"; import { ports } from "./port"; +import { previewDeployments } from "./preview-deployments"; import { projects } from "./project"; import { redirects } from "./redirects"; import { registry } from "./registry"; @@ -25,7 +26,6 @@ import { server } from "./server"; import { applicationStatus, certificateType } from "./shared"; import { sshKeys } from "./ssh-key"; import { generateAppName } from "./utils"; -import { previewDeployments } from "./preview-deployments"; export const sourceType = pgEnum("sourceType", [ "docker", diff --git a/packages/server/src/db/schema/compose.ts b/packages/server/src/db/schema/compose.ts index 02bac781..e0c4863b 100644 --- a/packages/server/src/db/schema/compose.ts +++ b/packages/server/src/db/schema/compose.ts @@ -155,6 +155,11 @@ export const apiFindCompose = z.object({ composeId: z.string().min(1), }); +export const apiDeleteCompose = z.object({ + composeId: z.string().min(1), + deleteVolumes: z.boolean(), +}); + export const apiFetchServices = z.object({ composeId: z.string().min(1), type: z.enum(["fetch", "cache"]).optional().default("cache"), diff --git a/packages/server/src/db/schema/deployment.ts b/packages/server/src/db/schema/deployment.ts index f79b48ee..ccaf6466 100644 --- a/packages/server/src/db/schema/deployment.ts +++ b/packages/server/src/db/schema/deployment.ts @@ -11,8 +11,8 @@ import { nanoid } from "nanoid"; import { z } from "zod"; import { applications } from "./application"; import { compose } from "./compose"; -import { server } from "./server"; import { previewDeployments } from "./preview-deployments"; +import { server } from "./server"; export const deploymentStatus = pgEnum("deploymentStatus", [ "running", diff --git a/packages/server/src/db/schema/domain.ts b/packages/server/src/db/schema/domain.ts index f115ce66..5c34d455 100644 --- a/packages/server/src/db/schema/domain.ts +++ b/packages/server/src/db/schema/domain.ts @@ -14,8 +14,8 @@ import { z } from "zod"; import { domain } from "../validations/domain"; import { applications } from "./application"; import { compose } from "./compose"; -import { certificateType } from "./shared"; import { previewDeployments } from "./preview-deployments"; +import { certificateType } from "./shared"; export const domainType = pgEnum("domainType", [ "compose", diff --git a/packages/server/src/db/schema/index.ts b/packages/server/src/db/schema/index.ts index f07a1870..9c7a079c 100644 --- a/packages/server/src/db/schema/index.ts +++ b/packages/server/src/db/schema/index.ts @@ -29,4 +29,4 @@ export * from "./github"; export * from "./gitlab"; export * from "./server"; export * from "./utils"; -export * from "./preview-deployments"; \ No newline at end of file +export * from "./preview-deployments"; diff --git a/packages/server/src/db/schema/preview-deployments.ts b/packages/server/src/db/schema/preview-deployments.ts index 5d0671e8..3bdab2c2 100644 --- a/packages/server/src/db/schema/preview-deployments.ts +++ b/packages/server/src/db/schema/preview-deployments.ts @@ -1,13 +1,13 @@ import { relations } from "drizzle-orm"; import { pgTable, text } from "drizzle-orm/pg-core"; -import { nanoid } from "nanoid"; -import { applications } from "./application"; -import { domains } from "./domain"; -import { deployments } from "./deployment"; import { createInsertSchema } from "drizzle-zod"; +import { nanoid } from "nanoid"; import { z } from "zod"; -import { generateAppName } from "./utils"; +import { applications } from "./application"; +import { deployments } from "./deployment"; +import { domains } from "./domain"; import { applicationStatus } from "./shared"; +import { generateAppName } from "./utils"; export const previewDeployments = pgTable("preview_deployments", { previewDeploymentId: text("previewDeploymentId") diff --git a/packages/server/src/db/schema/utils.ts b/packages/server/src/db/schema/utils.ts index 59ebf4b7..43332c8a 100644 --- a/packages/server/src/db/schema/utils.ts +++ b/packages/server/src/db/schema/utils.ts @@ -1,3 +1,4 @@ +import { generatePassword } from "@dokploy/server/templates/utils"; import { faker } from "@faker-js/faker"; import { customAlphabet } from "nanoid"; @@ -13,3 +14,17 @@ export const generateAppName = (type: string) => { const nanoidPart = customNanoid(); return `${type}-${randomFakerElement}-${nanoidPart}`; }; + +export const cleanAppName = (appName?: string) => { + if (!appName) { + return appName?.toLowerCase(); + } + return appName.trim().replace(/ /g, "-").toLowerCase(); +}; + +export const buildAppName = (type: string, baseAppName?: string) => { + if (baseAppName) { + return `${cleanAppName(baseAppName)}-${generatePassword(6)}`; + } + return generateAppName(type); +}; diff --git a/packages/server/src/services/application.ts b/packages/server/src/services/application.ts index fef1457c..b8ecb88b 100644 --- a/packages/server/src/services/application.ts +++ b/packages/server/src/services/application.ts @@ -3,10 +3,10 @@ import { db } from "@dokploy/server/db"; import { type apiCreateApplication, applications, + buildAppName, + cleanAppName, } from "@dokploy/server/db/schema"; -import { generateAppName } from "@dokploy/server/db/schema"; import { getAdvancedStats } from "@dokploy/server/monitoring/utilts"; -import { generatePassword } from "@dokploy/server/templates/utils"; import { buildApplication, getBuildCommand, @@ -46,34 +46,31 @@ import { createDeploymentPreview, updateDeploymentStatus, } from "./deployment"; -import { validUniqueServerAppName } from "./project"; -import { - findPreviewDeploymentById, - updatePreviewDeployment, -} from "./preview-deployment"; +import { type Domain, getDomainHost } from "./domain"; import { createPreviewDeploymentComment, getIssueComment, issueCommentExists, updateIssueComment, } from "./github"; -import { type Domain, getDomainHost } from "./domain"; +import { + findPreviewDeploymentById, + updatePreviewDeployment, +} from "./preview-deployment"; +import { validUniqueServerAppName } from "./project"; export type Application = typeof applications.$inferSelect; export const createApplication = async ( input: typeof apiCreateApplication._type, ) => { - input.appName = - `${input.appName}-${generatePassword(6)}` || generateAppName("app"); - if (input.appName) { - const valid = await validUniqueServerAppName(input.appName); + const appName = buildAppName("app", input.appName); - if (!valid) { - throw new TRPCError({ - code: "CONFLICT", - message: "Application with this 'AppName' already exists", - }); - } + const valid = await validUniqueServerAppName(appName); + if (!valid) { + throw new TRPCError({ + code: "CONFLICT", + message: "Application with this 'AppName' already exists", + }); } return await db.transaction(async (tx) => { @@ -81,6 +78,7 @@ export const createApplication = async ( .insert(applications) .values({ ...input, + appName, }) .returning() .then((value) => value[0]); @@ -140,10 +138,11 @@ export const updateApplication = async ( applicationId: string, applicationData: Partial, ) => { + const { appName, ...rest } = applicationData; const application = await db .update(applications) .set({ - ...applicationData, + ...rest, }) .where(eq(applications.applicationId, applicationId)) .returning(); diff --git a/packages/server/src/services/compose.ts b/packages/server/src/services/compose.ts index 5ae0d774..ff660351 100644 --- a/packages/server/src/services/compose.ts +++ b/packages/server/src/services/compose.ts @@ -2,7 +2,7 @@ import { join } from "node:path"; import { paths } from "@dokploy/server/constants"; import { db } from "@dokploy/server/db"; import { type apiCreateCompose, compose } from "@dokploy/server/db/schema"; -import { generateAppName } from "@dokploy/server/db/schema"; +import { buildAppName, cleanAppName } from "@dokploy/server/db/schema"; import { generatePassword } from "@dokploy/server/templates/utils"; import { buildCompose, @@ -52,17 +52,14 @@ import { validUniqueServerAppName } from "./project"; export type Compose = typeof compose.$inferSelect; export const createCompose = async (input: typeof apiCreateCompose._type) => { - input.appName = - `${input.appName}-${generatePassword(6)}` || generateAppName("compose"); - if (input.appName) { - const valid = await validUniqueServerAppName(input.appName); + const appName = buildAppName("compose", input.appName); - if (!valid) { - throw new TRPCError({ - code: "CONFLICT", - message: "Service with this 'AppName' already exists", - }); - } + const valid = await validUniqueServerAppName(appName); + if (!valid) { + throw new TRPCError({ + code: "CONFLICT", + message: "Service with this 'AppName' already exists", + }); } const newDestination = await db @@ -70,6 +67,7 @@ export const createCompose = async (input: typeof apiCreateCompose._type) => { .values({ ...input, composeFile: "", + appName, }) .returning() .then((value) => value[0]); @@ -87,8 +85,9 @@ export const createCompose = async (input: typeof apiCreateCompose._type) => { export const createComposeByTemplate = async ( input: typeof compose.$inferInsert, ) => { - if (input.appName) { - const valid = await validUniqueServerAppName(input.appName); + const appName = cleanAppName(input.appName); + if (appName) { + const valid = await validUniqueServerAppName(appName); if (!valid) { throw new TRPCError({ @@ -101,6 +100,7 @@ export const createComposeByTemplate = async ( .insert(compose) .values({ ...input, + appName, }) .returning() .then((value) => value[0]); @@ -184,10 +184,11 @@ export const updateCompose = async ( composeId: string, composeData: Partial, ) => { + const { appName, ...rest } = composeData; const composeResult = await db .update(compose) .set({ - ...composeData, + ...rest, }) .where(eq(compose.composeId, composeId)) .returning(); @@ -205,7 +206,9 @@ export const deployCompose = async ({ descriptionLog: string; }) => { const compose = await findComposeById(composeId); - const buildLink = `${await getDokployUrl()}/dashboard/project/${compose.projectId}/services/compose/${compose.composeId}?tab=deployments`; + const buildLink = `${await getDokployUrl()}/dashboard/project/${ + compose.projectId + }/services/compose/${compose.composeId}?tab=deployments`; const deployment = await createDeploymentCompose({ composeId: composeId, title: titleLog, @@ -307,7 +310,9 @@ export const deployRemoteCompose = async ({ descriptionLog: string; }) => { const compose = await findComposeById(composeId); - const buildLink = `${await getDokployUrl()}/dashboard/project/${compose.projectId}/services/compose/${compose.composeId}?tab=deployments`; + const buildLink = `${await getDokployUrl()}/dashboard/project/${ + compose.projectId + }/services/compose/${compose.composeId}?tab=deployments`; const deployment = await createDeploymentCompose({ composeId: composeId, title: titleLog, @@ -436,13 +441,17 @@ export const rebuildRemoteCompose = async ({ return true; }; -export const removeCompose = async (compose: Compose) => { +export const removeCompose = async ( + compose: Compose, + deleteVolumes: boolean, +) => { try { const { COMPOSE_PATH } = paths(!!compose.serverId); const projectPath = join(COMPOSE_PATH, compose.appName); if (compose.composeType === "stack") { const command = `cd ${projectPath} && docker stack rm ${compose.appName} && rm -rf ${projectPath}`; + if (compose.serverId) { await execAsyncRemote(compose.serverId, command); } else { @@ -452,7 +461,13 @@ export const removeCompose = async (compose: Compose) => { cwd: projectPath, }); } else { - const command = `cd ${projectPath} && docker compose -p ${compose.appName} down && rm -rf ${projectPath}`; + let command: string; + if (deleteVolumes) { + command = `cd ${projectPath} && docker compose -p ${compose.appName} down --volumes && rm -rf ${projectPath}`; + } else { + command = `cd ${projectPath} && docker compose -p ${compose.appName} down && rm -rf ${projectPath}`; + } + if (compose.serverId) { await execAsyncRemote(compose.serverId, command); } else { @@ -476,7 +491,11 @@ export const startCompose = async (composeId: string) => { if (compose.serverId) { await execAsyncRemote( compose.serverId, - `cd ${join(COMPOSE_PATH, compose.appName, "code")} && docker compose -p ${compose.appName} up -d`, + `cd ${join( + COMPOSE_PATH, + compose.appName, + "code", + )} && docker compose -p ${compose.appName} up -d`, ); } else { await execAsync(`docker compose -p ${compose.appName} up -d`, { @@ -506,7 +525,9 @@ export const stopCompose = async (composeId: string) => { if (compose.serverId) { await execAsyncRemote( compose.serverId, - `cd ${join(COMPOSE_PATH, compose.appName)} && docker compose -p ${compose.appName} stop`, + `cd ${join(COMPOSE_PATH, compose.appName)} && docker compose -p ${ + compose.appName + } stop`, ); } else { await execAsync(`docker compose -p ${compose.appName} stop`, { diff --git a/packages/server/src/services/deployment.ts b/packages/server/src/services/deployment.ts index b18b132d..41adf238 100644 --- a/packages/server/src/services/deployment.ts +++ b/packages/server/src/services/deployment.ts @@ -23,8 +23,8 @@ import { type Server, findServerById } from "./server"; import { execAsyncRemote } from "@dokploy/server/utils/process/execAsync"; import { - findPreviewDeploymentById, type PreviewDeployment, + findPreviewDeploymentById, updatePreviewDeployment, } from "./preview-deployment"; diff --git a/packages/server/src/services/docker.ts b/packages/server/src/services/docker.ts index 6ac61354..60262ba1 100644 --- a/packages/server/src/services/docker.ts +++ b/packages/server/src/services/docker.ts @@ -224,3 +224,124 @@ export const containerRestart = async (containerId: string) => { return config; } catch (error) {} }; + +export const getSwarmNodes = async (serverId?: string) => { + try { + let stdout = ""; + let stderr = ""; + const command = "docker node ls --format '{{json .}}'"; + + if (serverId) { + const result = await execAsyncRemote(serverId, command); + stdout = result.stdout; + stderr = result.stderr; + } else { + const result = await execAsync(command); + stdout = result.stdout; + stderr = result.stderr; + } + + if (stderr) { + console.error(`Error: ${stderr}`); + return; + } + + const nodes = JSON.parse(stdout); + + const nodesArray = stdout + .trim() + .split("\n") + .map((line) => JSON.parse(line)); + return nodesArray; + } catch (error) {} +}; + +export const getNodeInfo = async (nodeId: string, serverId?: string) => { + try { + const command = `docker node inspect ${nodeId} --format '{{json .}}'`; + let stdout = ""; + let stderr = ""; + if (serverId) { + const result = await execAsyncRemote(serverId, command); + stdout = result.stdout; + stderr = result.stderr; + } else { + const result = await execAsync(command); + stdout = result.stdout; + stderr = result.stderr; + } + + if (stderr) { + console.error(`Error: ${stderr}`); + return; + } + + const nodeInfo = JSON.parse(stdout); + + return nodeInfo; + } catch (error) {} +}; + +export const getNodeApplications = async (serverId?: string) => { + try { + let stdout = ""; + let stderr = ""; + const command = `docker service ls --format '{{json .}}'`; + + if (serverId) { + const result = await execAsyncRemote(serverId, command); + stdout = result.stdout; + stderr = result.stderr; + } else { + const result = await execAsync(command); + + stdout = result.stdout; + stderr = result.stderr; + } + + if (stderr) { + console.error(`Error: ${stderr}`); + return; + } + + const appArray = stdout + .trim() + .split("\n") + .map((line) => JSON.parse(line)); + + return appArray; + } catch (error) {} +}; + +export const getApplicationInfo = async ( + appName: string, + serverId?: string, +) => { + try { + let stdout = ""; + let stderr = ""; + const command = `docker service ps ${appName} --format '{{json .}}'`; + + if (serverId) { + const result = await execAsyncRemote(serverId, command); + stdout = result.stdout; + stderr = result.stderr; + } else { + const result = await execAsync(command); + stdout = result.stdout; + stderr = result.stderr; + } + + if (stderr) { + console.error(`Error: ${stderr}`); + return; + } + + const appArray = stdout + .trim() + .split("\n") + .map((line) => JSON.parse(line)); + + return appArray; + } catch (error) {} +}; diff --git a/packages/server/src/services/mariadb.ts b/packages/server/src/services/mariadb.ts index 645b5c65..39ef5910 100644 --- a/packages/server/src/services/mariadb.ts +++ b/packages/server/src/services/mariadb.ts @@ -4,7 +4,7 @@ import { backups, mariadb, } from "@dokploy/server/db/schema"; -import { generateAppName } from "@dokploy/server/db/schema"; +import { buildAppName, cleanAppName } from "@dokploy/server/db/schema"; import { generatePassword } from "@dokploy/server/templates/utils"; import { buildMariadb } from "@dokploy/server/utils/databases/mariadb"; import { pullImage } from "@dokploy/server/utils/docker/utils"; @@ -17,17 +17,14 @@ import { execAsyncRemote } from "@dokploy/server/utils/process/execAsync"; export type Mariadb = typeof mariadb.$inferSelect; export const createMariadb = async (input: typeof apiCreateMariaDB._type) => { - input.appName = - `${input.appName}-${generatePassword(6)}` || generateAppName("mariadb"); - if (input.appName) { - const valid = await validUniqueServerAppName(input.appName); + const appName = buildAppName("mariadb", input.appName); - if (!valid) { - throw new TRPCError({ - code: "CONFLICT", - message: "Service with this 'AppName' already exists", - }); - } + const valid = await validUniqueServerAppName(input.appName); + if (!valid) { + throw new TRPCError({ + code: "CONFLICT", + message: "Service with this 'AppName' already exists", + }); } const newMariadb = await db @@ -40,6 +37,7 @@ export const createMariadb = async (input: typeof apiCreateMariaDB._type) => { databaseRootPassword: input.databaseRootPassword ? input.databaseRootPassword : generatePassword(), + appName, }) .returning() .then((value) => value[0]); @@ -82,10 +80,11 @@ export const updateMariadbById = async ( mariadbId: string, mariadbData: Partial, ) => { + const { appName, ...rest } = mariadbData; const result = await db .update(mariadb) .set({ - ...mariadbData, + ...rest, }) .where(eq(mariadb.mariadbId, mariadbId)) .returning(); diff --git a/packages/server/src/services/mongo.ts b/packages/server/src/services/mongo.ts index b87ec4da..f8d5e4d6 100644 --- a/packages/server/src/services/mongo.ts +++ b/packages/server/src/services/mongo.ts @@ -1,6 +1,6 @@ import { db } from "@dokploy/server/db"; import { type apiCreateMongo, backups, mongo } from "@dokploy/server/db/schema"; -import { generateAppName } from "@dokploy/server/db/schema"; +import { buildAppName, cleanAppName } from "@dokploy/server/db/schema"; import { generatePassword } from "@dokploy/server/templates/utils"; import { buildMongo } from "@dokploy/server/utils/databases/mongo"; import { pullImage } from "@dokploy/server/utils/docker/utils"; @@ -13,17 +13,14 @@ import { execAsyncRemote } from "@dokploy/server/utils/process/execAsync"; export type Mongo = typeof mongo.$inferSelect; export const createMongo = async (input: typeof apiCreateMongo._type) => { - input.appName = - `${input.appName}-${generatePassword(6)}` || generateAppName("mongo"); - if (input.appName) { - const valid = await validUniqueServerAppName(input.appName); + const appName = buildAppName("mongo", input.appName); - if (!valid) { - throw new TRPCError({ - code: "CONFLICT", - message: "Service with this 'AppName' already exists", - }); - } + const valid = await validUniqueServerAppName(appName); + if (!valid) { + throw new TRPCError({ + code: "CONFLICT", + message: "Service with this 'AppName' already exists", + }); } const newMongo = await db @@ -33,6 +30,7 @@ export const createMongo = async (input: typeof apiCreateMongo._type) => { databasePassword: input.databasePassword ? input.databasePassword : generatePassword(), + appName, }) .returning() .then((value) => value[0]); @@ -74,10 +72,11 @@ export const updateMongoById = async ( mongoId: string, mongoData: Partial, ) => { + const { appName, ...rest } = mongoData; const result = await db .update(mongo) .set({ - ...mongoData, + ...rest, }) .where(eq(mongo.mongoId, mongoId)) .returning(); diff --git a/packages/server/src/services/mysql.ts b/packages/server/src/services/mysql.ts index ee9df820..e2c8bb6f 100644 --- a/packages/server/src/services/mysql.ts +++ b/packages/server/src/services/mysql.ts @@ -1,6 +1,6 @@ import { db } from "@dokploy/server/db"; import { type apiCreateMySql, backups, mysql } from "@dokploy/server/db/schema"; -import { generateAppName } from "@dokploy/server/db/schema"; +import { buildAppName, cleanAppName } from "@dokploy/server/db/schema"; import { generatePassword } from "@dokploy/server/templates/utils"; import { buildMysql } from "@dokploy/server/utils/databases/mysql"; import { pullImage } from "@dokploy/server/utils/docker/utils"; @@ -13,18 +13,14 @@ import { execAsyncRemote } from "@dokploy/server/utils/process/execAsync"; export type MySql = typeof mysql.$inferSelect; export const createMysql = async (input: typeof apiCreateMySql._type) => { - input.appName = - `${input.appName}-${generatePassword(6)}` || generateAppName("mysql"); + const appName = buildAppName("mysql", input.appName); - if (input.appName) { - const valid = await validUniqueServerAppName(input.appName); - - if (!valid) { - throw new TRPCError({ - code: "CONFLICT", - message: "Service with this 'AppName' already exists", - }); - } + const valid = await validUniqueServerAppName(appName); + if (!valid) { + throw new TRPCError({ + code: "CONFLICT", + message: "Service with this 'AppName' already exists", + }); } const newMysql = await db @@ -37,6 +33,7 @@ export const createMysql = async (input: typeof apiCreateMySql._type) => { databaseRootPassword: input.databaseRootPassword ? input.databaseRootPassword : generatePassword(), + appName, }) .returning() .then((value) => value[0]); @@ -79,10 +76,11 @@ export const updateMySqlById = async ( mysqlId: string, mysqlData: Partial, ) => { + const { appName, ...rest } = mysqlData; const result = await db .update(mysql) .set({ - ...mysqlData, + ...rest, }) .where(eq(mysql.mysqlId, mysqlId)) .returning(); diff --git a/packages/server/src/services/postgres.ts b/packages/server/src/services/postgres.ts index c94ddbbe..87286e67 100644 --- a/packages/server/src/services/postgres.ts +++ b/packages/server/src/services/postgres.ts @@ -4,7 +4,7 @@ import { backups, postgres, } from "@dokploy/server/db/schema"; -import { generateAppName } from "@dokploy/server/db/schema"; +import { buildAppName, cleanAppName } from "@dokploy/server/db/schema"; import { generatePassword } from "@dokploy/server/templates/utils"; import { buildPostgres } from "@dokploy/server/utils/databases/postgres"; import { pullImage } from "@dokploy/server/utils/docker/utils"; @@ -17,17 +17,14 @@ import { execAsyncRemote } from "@dokploy/server/utils/process/execAsync"; export type Postgres = typeof postgres.$inferSelect; export const createPostgres = async (input: typeof apiCreatePostgres._type) => { - input.appName = - `${input.appName}-${generatePassword(6)}` || generateAppName("postgres"); - if (input.appName) { - const valid = await validUniqueServerAppName(input.appName); + const appName = buildAppName("postgres", input.appName); - if (!valid) { - throw new TRPCError({ - code: "CONFLICT", - message: "Service with this 'AppName' already exists", - }); - } + const valid = await validUniqueServerAppName(appName); + if (!valid) { + throw new TRPCError({ + code: "CONFLICT", + message: "Service with this 'AppName' already exists", + }); } const newPostgres = await db @@ -37,6 +34,7 @@ export const createPostgres = async (input: typeof apiCreatePostgres._type) => { databasePassword: input.databasePassword ? input.databasePassword : generatePassword(), + appName, }) .returning() .then((value) => value[0]); @@ -96,10 +94,11 @@ export const updatePostgresById = async ( postgresId: string, postgresData: Partial, ) => { + const { appName, ...rest } = postgresData; const result = await db .update(postgres) .set({ - ...postgresData, + ...rest, }) .where(eq(postgres.postgresId, postgresId)) .returning(); diff --git a/packages/server/src/services/preview-deployment.ts b/packages/server/src/services/preview-deployment.ts index e52f4553..06bf8fb5 100644 --- a/packages/server/src/services/preview-deployment.ts +++ b/packages/server/src/services/preview-deployment.ts @@ -7,20 +7,20 @@ import { import { TRPCError } from "@trpc/server"; import { and, desc, eq } from "drizzle-orm"; import { slugify } from "../setup/server-setup"; -import { findApplicationById } from "./application"; -import { createDomain } from "./domain"; import { generatePassword, generateRandomDomain } from "../templates/utils"; +import { removeService } from "../utils/docker/utils"; +import { removeDirectoryCode } from "../utils/filesystem/directory"; +import { authGithub } from "../utils/providers/github"; +import { removeTraefikConfig } from "../utils/traefik/application"; import { manageDomain } from "../utils/traefik/domain"; +import { findAdminById } from "./admin"; +import { findApplicationById } from "./application"; import { removeDeployments, removeDeploymentsByPreviewDeploymentId, } from "./deployment"; -import { removeDirectoryCode } from "../utils/filesystem/directory"; -import { removeTraefikConfig } from "../utils/traefik/application"; -import { removeService } from "../utils/docker/utils"; -import { authGithub } from "../utils/providers/github"; -import { getIssueComment, type Github } from "./github"; -import { findAdminById } from "./admin"; +import { createDomain } from "./domain"; +import { type Github, getIssueComment } from "./github"; export type PreviewDeployment = typeof previewDeployments.$inferSelect; diff --git a/packages/server/src/services/redis.ts b/packages/server/src/services/redis.ts index 7809de28..5b958081 100644 --- a/packages/server/src/services/redis.ts +++ b/packages/server/src/services/redis.ts @@ -1,6 +1,6 @@ import { db } from "@dokploy/server/db"; import { type apiCreateRedis, redis } from "@dokploy/server/db/schema"; -import { generateAppName } from "@dokploy/server/db/schema"; +import { buildAppName, cleanAppName } from "@dokploy/server/db/schema"; import { generatePassword } from "@dokploy/server/templates/utils"; import { buildRedis } from "@dokploy/server/utils/databases/redis"; import { pullImage } from "@dokploy/server/utils/docker/utils"; @@ -14,17 +14,14 @@ export type Redis = typeof redis.$inferSelect; // https://github.com/drizzle-team/drizzle-orm/discussions/1483#discussioncomment-7523881 export const createRedis = async (input: typeof apiCreateRedis._type) => { - input.appName = - `${input.appName}-${generatePassword(6)}` || generateAppName("redis"); - if (input.appName) { - const valid = await validUniqueServerAppName(input.appName); + const appName = buildAppName("redis", input.appName); - if (!valid) { - throw new TRPCError({ - code: "CONFLICT", - message: "Service with this 'AppName' already exists", - }); - } + const valid = await validUniqueServerAppName(appName); + if (!valid) { + throw new TRPCError({ + code: "CONFLICT", + message: "Service with this 'AppName' already exists", + }); } const newRedis = await db @@ -34,6 +31,7 @@ export const createRedis = async (input: typeof apiCreateRedis._type) => { databasePassword: input.databasePassword ? input.databasePassword : generatePassword(), + appName, }) .returning() .then((value) => value[0]); @@ -70,10 +68,11 @@ export const updateRedisById = async ( redisId: string, redisData: Partial, ) => { + const { appName, ...rest } = redisData; const result = await db .update(redis) .set({ - ...redisData, + ...rest, }) .where(eq(redis.redisId, redisId)) .returning(); diff --git a/packages/server/src/services/settings.ts b/packages/server/src/services/settings.ts index 8261843a..37f7b2ee 100644 --- a/packages/server/src/services/settings.ts +++ b/packages/server/src/services/settings.ts @@ -1,41 +1,108 @@ import { readdirSync } from "node:fs"; import { join } from "node:path"; import { docker } from "@dokploy/server/constants"; -import { getServiceContainer } from "@dokploy/server/utils/docker/utils"; -import { execAsyncRemote } from "@dokploy/server/utils/process/execAsync"; +import { + execAsync, + execAsyncRemote, +} from "@dokploy/server/utils/process/execAsync"; // import packageInfo from "../../../package.json"; -const updateIsAvailable = async () => { - try { - const service = await getServiceContainer("dokploy"); +export interface IUpdateData { + latestVersion: string | null; + updateAvailable: boolean; +} - const localImage = await docker.getImage(getDokployImage()).inspect(); - return localImage.Id !== service?.ImageID; - } catch (error) { - return false; - } +export const DEFAULT_UPDATE_DATA: IUpdateData = { + latestVersion: null, + updateAvailable: false, +}; + +/** Returns current Dokploy docker image tag or `latest` by default. */ +export const getDokployImageTag = () => { + return process.env.RELEASE_TAG || "latest"; }; export const getDokployImage = () => { - return `dokploy/dokploy:${process.env.RELEASE_TAG || "latest"}`; + return `dokploy/dokploy:${getDokployImageTag()}`; }; export const pullLatestRelease = async () => { - try { - const stream = await docker.pull(getDokployImage(), {}); - await new Promise((resolve, reject) => { - docker.modem.followProgress(stream, (err, res) => - err ? reject(err) : resolve(res), - ); - }); - const newUpdateIsAvailable = await updateIsAvailable(); - return newUpdateIsAvailable; - } catch (error) {} - - return false; + const stream = await docker.pull(getDokployImage()); + await new Promise((resolve, reject) => { + docker.modem.followProgress(stream, (err, res) => + err ? reject(err) : resolve(res), + ); + }); }; -export const getDokployVersion = () => { - // return packageInfo.version; + +/** Returns Dokploy docker service image digest */ +export const getServiceImageDigest = async () => { + const { stdout } = await execAsync( + "docker service inspect dokploy --format '{{.Spec.TaskTemplate.ContainerSpec.Image}}'", + ); + + const currentDigest = stdout.trim().split("@")[1]; + + if (!currentDigest) { + throw new Error("Could not get current service image digest"); + } + + return currentDigest; +}; + +/** Returns latest version number and information whether server update is available by comparing current image's digest against digest for provided image tag via Docker hub API. */ +export const getUpdateData = async (): Promise => { + let currentDigest: string; + try { + currentDigest = await getServiceImageDigest(); + } catch { + // Docker service might not exist locally + // You can run the # Installation command for docker service create mentioned in the below docs to test it locally: + // https://docs.dokploy.com/docs/core/manual-installation + return DEFAULT_UPDATE_DATA; + } + + const baseUrl = "https://hub.docker.com/v2/repositories/dokploy/dokploy/tags"; + let url: string | null = `${baseUrl}?page_size=100`; + let allResults: { digest: string; name: string }[] = []; + while (url) { + const response = await fetch(url, { + method: "GET", + headers: { "Content-Type": "application/json" }, + }); + + const data = (await response.json()) as { + next: string | null; + results: { digest: string; name: string }[]; + }; + + allResults = allResults.concat(data.results); + url = data?.next; + } + + const imageTag = getDokployImageTag(); + const searchedDigest = allResults.find((t) => t.name === imageTag)?.digest; + + if (!searchedDigest) { + return DEFAULT_UPDATE_DATA; + } + + if (imageTag === "latest") { + const versionedTag = allResults.find( + (t) => t.digest === searchedDigest && t.name.startsWith("v"), + ); + + if (!versionedTag) { + return DEFAULT_UPDATE_DATA; + } + + const { name: latestVersion, digest } = versionedTag; + const updateAvailable = digest !== currentDigest; + + return { latestVersion, updateAvailable }; + } + const updateAvailable = searchedDigest !== currentDigest; + return { latestVersion: imageTag, updateAvailable }; }; interface TreeDataItem { diff --git a/packages/server/src/setup/traefik-setup.ts b/packages/server/src/setup/traefik-setup.ts index 82832027..5e994dd2 100644 --- a/packages/server/src/setup/traefik-setup.ts +++ b/packages/server/src/setup/traefik-setup.ts @@ -16,12 +16,18 @@ interface TraefikOptions { enableDashboard?: boolean; env?: string[]; serverId?: string; + additionalPorts?: { + targetPort: number; + publishedPort: number; + publishMode?: "ingress" | "host"; + }[]; } export const initializeTraefik = async ({ enableDashboard = false, env, serverId, + additionalPorts = [], }: TraefikOptions = {}) => { const { MAIN_TRAEFIK_PATH, DYNAMIC_TRAEFIK_PATH } = paths(!!serverId); const imageName = "traefik:v3.1.2"; @@ -84,6 +90,11 @@ export const initializeTraefik = async ({ }, ] : []), + ...additionalPorts.map((port) => ({ + TargetPort: port.targetPort, + PublishedPort: port.publishedPort, + PublishMode: port.publishMode || ("host" as const), + })), ], }, }; diff --git a/packages/server/src/utils/backups/index.ts b/packages/server/src/utils/backups/index.ts index 797feb38..6fec7a31 100644 --- a/packages/server/src/utils/backups/index.ts +++ b/packages/server/src/utils/backups/index.ts @@ -7,12 +7,12 @@ import { cleanUpSystemPrune, cleanUpUnusedImages, } from "../docker/utils"; +import { sendDatabaseBackupNotifications } from "../notifications/database-backup"; +import { sendDockerCleanupNotifications } from "../notifications/docker-cleanup"; import { runMariadbBackup } from "./mariadb"; import { runMongoBackup } from "./mongo"; import { runMySqlBackup } from "./mysql"; import { runPostgresBackup } from "./postgres"; -import { sendDockerCleanupNotifications } from "../notifications/docker-cleanup"; -import { sendDatabaseBackupNotifications } from "../notifications/database-backup"; export const initCronJobs = async () => { console.log("Setting up cron jobs...."); diff --git a/packages/server/src/utils/builders/compose.ts b/packages/server/src/utils/builders/compose.ts index 3e64ed35..40f8d6d5 100644 --- a/packages/server/src/utils/builders/compose.ts +++ b/packages/server/src/utils/builders/compose.ts @@ -48,6 +48,7 @@ Compose Type: ${composeType} ✅`; writeStream.write(`\n${logBox}\n`); const projectPath = join(COMPOSE_PATH, compose.appName, "code"); + await spawnAsync( "docker", [...command.split(" ")], @@ -144,6 +145,10 @@ const sanitizeCommand = (command: string) => { export const createCommand = (compose: ComposeNested) => { const { composeType, appName, sourceType } = compose; + if (compose.command) { + return `${sanitizeCommand(compose.command)}`; + } + const path = sourceType === "raw" ? "docker-compose.yml" : compose.composePath; let command = ""; @@ -154,12 +159,6 @@ export const createCommand = (compose: ComposeNested) => { command = `stack deploy -c ${path} ${appName} --prune`; } - const customCommand = sanitizeCommand(compose.command); - - if (customCommand) { - command = `${command} ${customCommand}`; - } - return command; }; diff --git a/packages/server/src/utils/builders/index.ts b/packages/server/src/utils/builders/index.ts index ce10413a..df85d98f 100644 --- a/packages/server/src/utils/builders/index.ts +++ b/packages/server/src/utils/builders/index.ts @@ -2,6 +2,7 @@ import { createWriteStream } from "node:fs"; import { join } from "node:path"; import type { InferResultType } from "@dokploy/server/types/with"; import type { CreateServiceOptions } from "dockerode"; +import { nanoid } from "nanoid"; import { uploadImage, uploadImageRemoteCommand } from "../cluster/upload"; import { calculateResources, @@ -17,7 +18,6 @@ import { buildHeroku, getHerokuCommand } from "./heroku"; import { buildNixpacks, getNixpacksCommand } from "./nixpacks"; import { buildPaketo, getPaketoCommand } from "./paketo"; import { buildStatic, getStaticCommand } from "./static"; -import { nanoid } from "nanoid"; // NIXPACKS codeDirectory = where is the path of the code directory // HEROKU codeDirectory = where is the path of the code directory diff --git a/packages/server/src/utils/docker/utils.ts b/packages/server/src/utils/docker/utils.ts index 216ee867..a14602e9 100644 --- a/packages/server/src/utils/docker/utils.ts +++ b/packages/server/src/utils/docker/utils.ts @@ -238,9 +238,11 @@ export const startServiceRemote = async (serverId: string, appName: string) => { export const removeService = async ( appName: string, serverId?: string | null, + deleteVolumes = false, ) => { try { const command = `docker service rm ${appName}`; + if (serverId) { await execAsyncRemote(serverId, command); } else { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 62ba2f56..df2dc8e0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -250,6 +250,9 @@ importers: drizzle-zod: specifier: 0.5.1 version: 0.5.1(drizzle-orm@0.30.10(@types/react@18.3.5)(postgres@3.4.4)(react@18.2.0))(zod@3.23.8) + fancy-ansi: + specifier: ^0.1.3 + version: 0.1.3 i18next: specifier: ^23.16.4 version: 23.16.5 @@ -874,9 +877,11 @@ packages: '@esbuild-kit/core-utils@3.3.2': resolution: {integrity: sha512-sPRAnw9CdSsRmEtnsl2WXWdyquogVpB3yZ3dgwJfe8zrOzTsV7cJvmwrKVa+0ma5BoiGJ+BoqkMvawbayKUsqQ==} + deprecated: 'Merged into tsx: https://tsx.is' '@esbuild-kit/esm-loader@2.6.5': resolution: {integrity: sha512-FxEMIkJKnodyA1OaCUoEvbYRkoZlLZ4d/eXFu9Fh8CbBBgP5EmZxrfTRyN0qpXZ4vOvqnE5YdRdcrmUUXuU+dA==} + deprecated: 'Merged into tsx: https://tsx.is' '@esbuild/aix-ppc64@0.19.12': resolution: {integrity: sha512-bmoCYyWdEL3wDQIVbcyzRyeKLgk2WtWLTWz1ZIAZF/EGbNOwSA6ew3PftJ1PqMiOOGu0OyFMzG53L0zqIpPeNA==} @@ -4401,6 +4406,9 @@ packages: resolution: {integrity: sha512-ErCHMCae19vR8vQGe50xIsVomy19rg6gFu3+r3jkEO46suLMWBksvVyoGgQV+jOfl84ZSOSlmv6Gxa89PmTGmA==} engines: {node: '>=6'} + escape-html@1.0.3: + resolution: {integrity: sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==} + escape-string-regexp@1.0.5: resolution: {integrity: sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==} engines: {node: '>=0.8.0'} @@ -4460,6 +4468,9 @@ packages: ext@1.7.0: resolution: {integrity: sha512-6hxeJYaL110a9b5TEJSj0gojyHQAmA2ch5Os+ySCiA1QGdS697XWY1pzsrSjqA9LDEEgdB/KypIlR59RcLuHYw==} + fancy-ansi@0.1.3: + resolution: {integrity: sha512-tRQVTo5jjdSIiydqgzIIEZpKddzSsfGLsSVt6vWdjVm7fbvDTiQkyoPu6Z3dIPlAM4OZk0jP5jmTCX4G8WGgBw==} + fast-copy@3.0.2: resolution: {integrity: sha512-dl0O9Vhju8IrcLndv2eU4ldt1ftXMqqfgN4H1cpmGV7P6jeB9FwpN9a2c8DPGE1Ys88rNUJVYDHq73CGAGOPfQ==} @@ -10735,6 +10746,8 @@ snapshots: escalade@3.1.2: {} + escape-html@1.0.3: {} + escape-string-regexp@1.0.5: {} escape-string-regexp@5.0.0: {} @@ -10795,6 +10808,10 @@ snapshots: dependencies: type: 2.7.3 + fancy-ansi@0.1.3: + dependencies: + escape-html: 1.0.3 + fast-copy@3.0.2: {} fast-deep-equal@2.0.1: {}