From 4575f16be44f7b8703e6c718144ba4f63da959e3 Mon Sep 17 00:00:00 2001 From: Mauricio Siu <47042324+Siumauricio@users.noreply.github.com> Date: Sun, 4 May 2025 12:07:09 -0600 Subject: [PATCH] Add DNS helper modal and refactor domain handling components - Introduced `DnsHelperModal` for guiding users on DNS configuration, including steps for adding A records and verifying configurations. - Refactored domain handling by consolidating domain management into `handle-domain.tsx`, replacing the previous `add-domain.tsx`. - Updated `ShowDomains` and related components to utilize the new domain handling structure, improving code organization and maintainability. - Enhanced user experience by integrating domain validation and service selection features in the domain management interface. --- .../domains/dns-helper-modal.tsx | 0 .../{add-domain.tsx => handle-domain.tsx} | 248 ++++++++- .../application/domains/show-domains.tsx | 92 +++- .../schedules/handle-schedules.tsx | 2 +- .../dashboard/compose/domains/add-domain.tsx | 508 ------------------ .../compose/domains/show-domains.tsx | 383 ------------- .../services/application/[applicationId].tsx | 2 +- .../services/compose/[composeId].tsx | 4 +- apps/dokploy/server/api/routers/domain.ts | 5 +- 9 files changed, 298 insertions(+), 946 deletions(-) rename apps/dokploy/components/dashboard/{compose => application}/domains/dns-helper-modal.tsx (100%) rename apps/dokploy/components/dashboard/application/domains/{add-domain.tsx => handle-domain.tsx} (60%) delete mode 100644 apps/dokploy/components/dashboard/compose/domains/add-domain.tsx delete mode 100644 apps/dokploy/components/dashboard/compose/domains/show-domains.tsx diff --git a/apps/dokploy/components/dashboard/compose/domains/dns-helper-modal.tsx b/apps/dokploy/components/dashboard/application/domains/dns-helper-modal.tsx similarity index 100% rename from apps/dokploy/components/dashboard/compose/domains/dns-helper-modal.tsx rename to apps/dokploy/components/dashboard/application/domains/dns-helper-modal.tsx diff --git a/apps/dokploy/components/dashboard/application/domains/add-domain.tsx b/apps/dokploy/components/dashboard/application/domains/handle-domain.tsx similarity index 60% rename from apps/dokploy/components/dashboard/application/domains/add-domain.tsx rename to apps/dokploy/components/dashboard/application/domains/handle-domain.tsx index e5e4d799..862c39b3 100644 --- a/apps/dokploy/components/dashboard/application/domains/add-domain.tsx +++ b/apps/dokploy/components/dashboard/application/domains/handle-domain.tsx @@ -38,26 +38,67 @@ import { useEffect, useState } from "react"; import { useForm } from "react-hook-form"; import { toast } from "sonner"; -import { domain } from "@/server/db/validations/domain"; import { zodResolver } from "@hookform/resolvers/zod"; -import { Dices } from "lucide-react"; +import { DatabaseZap, Dices, RefreshCw } from "lucide-react"; import Link from "next/link"; -import type z from "zod"; +import z from "zod"; + +export type CacheType = "fetch" | "cache"; + +export const domain = z + .object({ + host: z.string().min(1, { message: "Add a hostname" }), + path: z.string().min(1).optional(), + port: z + .number() + .min(1, { message: "Port must be at least 1" }) + .max(65535, { message: "Port must be 65535 or below" }) + .optional(), + https: z.boolean().optional(), + certificateType: z.enum(["letsencrypt", "none", "custom"]).optional(), + customCertResolver: z.string().optional(), + serviceName: z.string().optional(), + domainType: z.enum(["application", "compose", "preview"]).optional(), + }) + .superRefine((input, ctx) => { + if (input.https && !input.certificateType) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["certificateType"], + message: "Required", + }); + } + + if (input.certificateType === "custom" && !input.customCertResolver) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["customCertResolver"], + message: "Required", + }); + } + + if (input.domainType === "compose" && !input.serviceName) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["serviceName"], + message: "Required", + }); + } + }); type Domain = z.infer; interface Props { - applicationId: string; + id: string; + type: "application" | "compose"; domainId?: string; children: React.ReactNode; } -export const AddDomain = ({ - applicationId, - domainId = "", - children, -}: Props) => { +export const AddDomain = ({ id, type, domainId = "", children }: Props) => { const [isOpen, setIsOpen] = useState(false); + const [cacheType, setCacheType] = useState("cache"); + const utils = api.useUtils(); const { data, refetch } = api.domain.one.useQuery( { @@ -68,14 +109,24 @@ export const AddDomain = ({ }, ); - const { data: application } = api.application.one.useQuery( - { - applicationId, - }, - { - enabled: !!applicationId, - }, - ); + const { data: application } = + type === "application" + ? api.application.one.useQuery( + { + applicationId: id, + }, + { + enabled: !!id, + }, + ) + : api.compose.one.useQuery( + { + composeId: id, + }, + { + enabled: !!id, + }, + ); const { mutateAsync, isError, error, isLoading } = domainId ? api.domain.update.useMutation() @@ -89,6 +140,23 @@ export const AddDomain = ({ serverId: application?.serverId || "", }); + const { + data: services, + isFetching: isLoadingServices, + error: errorServices, + refetch: refetchServices, + } = api.compose.loadServices.useQuery( + { + composeId: id, + type: cacheType, + }, + { + retry: false, + refetchOnWindowFocus: false, + enabled: type === "compose" && !!id, + }, + ); + const form = useForm({ resolver: zodResolver(domain), defaultValues: { @@ -98,12 +166,15 @@ export const AddDomain = ({ https: false, certificateType: undefined, customCertResolver: undefined, + serviceName: undefined, + domainType: type, }, mode: "onChange", }); const certificateType = form.watch("certificateType"); const https = form.watch("https"); + const domainType = form.watch("domainType"); useEffect(() => { if (data) { @@ -114,6 +185,8 @@ export const AddDomain = ({ port: data?.port || undefined, certificateType: data?.certificateType || undefined, customCertResolver: data?.customCertResolver || undefined, + serviceName: data?.serviceName || undefined, + domainType: data?.domainType || type, }); } @@ -125,6 +198,7 @@ export const AddDomain = ({ https: false, certificateType: undefined, customCertResolver: undefined, + domainType: type, }); } }, [form, data, isLoading, domainId]); @@ -148,22 +222,37 @@ export const AddDomain = ({ const onSubmit = async (data: Domain) => { await mutateAsync({ domainId, - applicationId, + ...(data.domainType === "application" && { + applicationId: id, + }), + ...(data.domainType === "compose" && { + composeId: id, + }), ...data, }) .then(async () => { toast.success(dictionary.success); - await utils.domain.byApplicationId.invalidate({ - applicationId, - }); - await utils.application.readTraefikConfig.invalidate({ applicationId }); + + if (data.domainType === "application") { + await utils.domain.byApplicationId.invalidate({ + applicationId: id, + }); + await utils.application.readTraefikConfig.invalidate({ + applicationId: id, + }); + } else if (data.domainType === "compose") { + await utils.domain.byComposeId.invalidate({ + composeId: id, + }); + } if (domainId) { refetch(); } setIsOpen(false); }) - .catch(() => { + .catch((e) => { + console.log(e); toast.error(dictionary.error); }); }; @@ -187,6 +276,119 @@ export const AddDomain = ({ >
+
+ {domainType === "compose" && ( + <> + {errorServices && ( + + {errorServices?.message} + + )} + ( + + Service Name +
+ + + + + + + +

+ Fetch: Will clone the repository and load + the services +

+
+
+
+ + + + + + +

+ Cache: If you previously deployed this + compose, it will read the services from + the last deployment/fetch from the + repository +

+
+
+
+
+ + +
+ )} + /> + + )} +
{ - const { data: application } = api.application.one.useQuery( - { - applicationId, - }, - { - enabled: !!applicationId, - }, - ); +export const ShowDomains = ({ id, type }: Props) => { + const { data: application } = + type === "application" + ? api.application.one.useQuery( + { + applicationId: id, + }, + { + enabled: !!id, + }, + ) + : api.compose.one.useQuery( + { + composeId: id, + }, + { + enabled: !!id, + }, + ); const [validationStates, setValidationStates] = useState( {}, ); const { data: ip } = api.settings.getIp.useQuery(); - const { data, refetch } = api.domain.byApplicationId.useQuery( - { - applicationId, - }, - { - enabled: !!applicationId, - }, - ); + const { data, refetch } = + type === "application" + ? api.domain.byApplicationId.useQuery( + { + applicationId: id, + }, + { + enabled: !!id, + }, + ) + : api.domain.byComposeId.useQuery( + { + composeId: id, + }, + { + enabled: !!id, + }, + ); + const { mutateAsync: validateDomain } = api.domain.validateDomain.useMutation(); const { mutateAsync: deleteDomain, isLoading: isRemoving } = @@ -113,7 +143,7 @@ export const ShowDomains = ({ applicationId }: Props) => {
{data && data?.length > 0 && ( - + @@ -123,14 +153,14 @@ export const ShowDomains = ({ applicationId }: Props) => { {data?.length === 0 ? ( -
+
To access the application it is required to set at least 1 domain
- + @@ -138,19 +168,26 @@ export const ShowDomains = ({ applicationId }: Props) => {
) : ( -
+
{data?.map((item) => { const validationState = validationStates[item.host]; return (
{/* Service & Domain Info */}
+ {item.serviceName && ( + + + {item.serviceName} + + )} + { /> )} - - -

- Fetch: Will clone the repository and load the - services -

-
- - - - - - - - -

- Cache: If you previously deployed this - compose, it will read the services from the - last deployment/fetch from the repository -

-
-
-
-
- - - - )} - /> -
- - ( - - {!canGenerateTraefikMeDomains && - field.value.includes("traefik.me") && ( - - You need to set an IP address in your{" "} - - {compose?.serverId - ? "Remote Servers -> Server -> Edit Server -> Update IP Address" - : "Web Server -> Server -> Update Server IP"} - {" "} - to make your traefik.me domain work. - - )} - Host -
- - - - - - - - - -

Generate traefik.me domain

-
-
-
-
- - -
- )} - /> - - { - return ( - - Path - - - - - - ); - }} - /> - - { - return ( - - Container Port - - The port where your application is running inside the - container (e.g., 3000 for Node.js, 80 for Nginx, 8080 - for Java) - - - - - - - ); - }} - /> - - ( - -
- HTTPS - - Automatically provision SSL Certificate. - - -
- - - -
- )} - /> - - {https && ( - <> - ( - - Certificate Provider - - - - )} - /> - - {form.getValues().certificateType === "custom" && ( - ( - - Custom Certificate Resolver - - - - - - )} - /> - )} - - )} -
-
- - - - - - - - - ); -}; diff --git a/apps/dokploy/components/dashboard/compose/domains/show-domains.tsx b/apps/dokploy/components/dashboard/compose/domains/show-domains.tsx deleted file mode 100644 index 014f82a5..00000000 --- a/apps/dokploy/components/dashboard/compose/domains/show-domains.tsx +++ /dev/null @@ -1,383 +0,0 @@ -import { DialogAction } from "@/components/shared/dialog-action"; -import { Button } from "@/components/ui/button"; -import { - Card, - CardContent, - CardDescription, - CardHeader, - CardTitle, -} from "@/components/ui/card"; -import { - Tooltip, - TooltipContent, - TooltipProvider, - TooltipTrigger, -} from "@/components/ui/tooltip"; -import { api } from "@/utils/api"; -import { - ExternalLink, - GlobeIcon, - PenBoxIcon, - Trash2, - InfoIcon, - Server, - CheckCircle2, - XCircle, - Loader2, - RefreshCw, -} from "lucide-react"; -import Link from "next/link"; -import { toast } from "sonner"; -import { AddDomainCompose } from "./add-domain"; -import { Badge } from "@/components/ui/badge"; -import { DnsHelperModal } from "./dns-helper-modal"; -import { useState } from "react"; - -interface Props { - composeId: string; -} - -export type ValidationState = { - isLoading: boolean; - isValid?: boolean; - error?: string; - resolvedIp?: string; - message?: string; -}; - -export type ValidationStates = { - [key: string]: ValidationState; -}; - -export const ShowDomainsCompose = ({ composeId }: Props) => { - const [validationStates, setValidationStates] = useState( - {}, - ); - - const { data: ip } = api.settings.getIp.useQuery(); - - const { data, refetch } = api.domain.byComposeId.useQuery( - { - composeId, - }, - { - enabled: !!composeId, - }, - ); - - const { data: compose } = api.compose.one.useQuery( - { - composeId, - }, - { - enabled: !!composeId, - }, - ); - - const { mutateAsync: validateDomain } = - api.domain.validateDomain.useMutation(); - const { mutateAsync: deleteDomain, isLoading: isRemoving } = - api.domain.delete.useMutation(); - - const handleValidateDomain = async (host: string) => { - setValidationStates((prev) => ({ - ...prev, - [host]: { isLoading: true }, - })); - - try { - const result = await validateDomain({ - domain: host, - serverIp: - compose?.server?.ipAddress?.toString() || ip?.toString() || "", - }); - - setValidationStates((prev) => ({ - ...prev, - [host]: { - isLoading: false, - isValid: result.isValid, - error: result.error, - resolvedIp: result.resolvedIp, - message: result.error && result.isValid ? result.error : undefined, - }, - })); - } catch (err) { - const error = err as Error; - setValidationStates((prev) => ({ - ...prev, - [host]: { - isLoading: false, - isValid: false, - error: error.message || "Failed to validate domain", - }, - })); - } - }; - - return ( -
- - -
- Domains - - Domains are used to access to the application - -
- -
- {data && data?.length > 0 && ( - - - - )} -
-
- - {data?.length === 0 ? ( -
- - - To access to the application it is required to set at least 1 - domain - -
- - - -
-
- ) : ( -
- {data?.map((item) => { - const validationState = validationStates[item.host]; - return ( - - -
- {/* Service & Domain Info */} -
-
- - - {item.serviceName} - - - {item.host} - - -
-
- {!item.host.includes("traefik.me") && ( - - )} - - - - { - await deleteDomain({ - domainId: item.domainId, - }) - .then((_data) => { - refetch(); - toast.success( - "Domain deleted successfully", - ); - }) - .catch(() => { - toast.error("Error deleting domain"); - }); - }} - > - - -
-
- - {/* Domain Details */} -
- - - - - - Path: {item.path || "/"} - - - -

URL path for this service

-
-
-
- - - - - - - Port: {item.port} - - - -

Container port exposed

-
-
-
- - - - - - {item.https ? "HTTPS" : "HTTP"} - - - -

- {item.https - ? "Secure HTTPS connection" - : "Standard HTTP connection"} -

-
-
-
- - {item.certificateType && ( - - - - - Cert: {item.certificateType} - - - -

SSL Certificate Provider

-
-
-
- )} - - - - - - handleValidateDomain(item.host) - } - > - {validationState?.isLoading ? ( - <> - - Checking DNS... - - ) : validationState?.isValid ? ( - <> - - {validationState.message - ? "Behind Cloudflare" - : "DNS Valid"} - - ) : validationState?.error ? ( - <> - - {validationState.error} - - ) : ( - <> - - Validate DNS - - )} - - - - {validationState?.error && - !validationState.isValid ? ( -
-

- Error: -

-

{validationState.error}

-
- ) : validationState?.isValid ? ( -
-

- {validationState.message - ? "Info:" - : "Valid Configuration:"} -

-

- {validationState.message || - `Domain points to ${validationState.resolvedIp}`} -

-
- ) : ( - "Click to validate DNS configuration" - )} -
-
-
-
-
-
-
- ); - })} -
- )} -
-
-
- ); -}; diff --git a/apps/dokploy/pages/dashboard/project/[projectId]/services/application/[applicationId].tsx b/apps/dokploy/pages/dashboard/project/[projectId]/services/application/[applicationId].tsx index 17e35c63..3971d040 100644 --- a/apps/dokploy/pages/dashboard/project/[projectId]/services/application/[applicationId].tsx +++ b/apps/dokploy/pages/dashboard/project/[projectId]/services/application/[applicationId].tsx @@ -330,7 +330,7 @@ const Service = (
- +
diff --git a/apps/dokploy/pages/dashboard/project/[projectId]/services/compose/[composeId].tsx b/apps/dokploy/pages/dashboard/project/[projectId]/services/compose/[composeId].tsx index 6d86dd3d..5875c47e 100644 --- a/apps/dokploy/pages/dashboard/project/[projectId]/services/compose/[composeId].tsx +++ b/apps/dokploy/pages/dashboard/project/[projectId]/services/compose/[composeId].tsx @@ -1,11 +1,11 @@ import { ShowImport } from "@/components/dashboard/application/advanced/import/show-import"; import { ShowVolumes } from "@/components/dashboard/application/advanced/volumes/show-volumes"; +import { ShowDomains } from "@/components/dashboard/application/domains/show-domains"; import { ShowEnvironment } from "@/components/dashboard/application/environment/show-enviroment"; import { ShowSchedules } from "@/components/dashboard/application/schedules/show-schedules"; import { AddCommandCompose } from "@/components/dashboard/compose/advanced/add-command"; import { DeleteService } from "@/components/dashboard/compose/delete-service"; import { ShowDeploymentsCompose } from "@/components/dashboard/compose/deployments/show-deployments-compose"; -import { ShowDomainsCompose } from "@/components/dashboard/compose/domains/show-domains"; import { ShowGeneralCompose } from "@/components/dashboard/compose/general/show"; import { ShowDockerLogsCompose } from "@/components/dashboard/compose/logs/show"; import { ShowDockerLogsStack } from "@/components/dashboard/compose/logs/show-stack"; @@ -339,7 +339,7 @@ const Service = (
- +
diff --git a/apps/dokploy/server/api/routers/domain.ts b/apps/dokploy/server/api/routers/domain.ts index 2ade32ae..a585a2fc 100644 --- a/apps/dokploy/server/api/routers/domain.ts +++ b/apps/dokploy/server/api/routers/domain.ts @@ -57,7 +57,10 @@ export const domainRouter = createTRPCRouter({ } catch (error) { throw new TRPCError({ code: "BAD_REQUEST", - message: "Error creating the domain", + message: + error instanceof Error + ? error.message + : "Error creating the domain", cause: error, }); }