Add DnsHelperModal component for DNS configuration guidance and integrate it into ShowDomainsCompose. Enhance domain validation functionality with server IP checks and improve UI with tooltips for better user experience.

This commit is contained in:
Mauricio Siu 2025-04-30 01:08:08 -06:00
parent cfae5f7e6c
commit 77b1ec4733
4 changed files with 492 additions and 71 deletions

View File

@ -0,0 +1,109 @@
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { AlertBlock } from "@/components/shared/alert-block";
import { Copy, HelpCircle, Server } from "lucide-react";
import { toast } from "sonner";
interface Props {
domain: {
host: string;
https: boolean;
path?: string;
};
serverIp?: string;
}
export const DnsHelperModal = ({ domain, serverIp }: Props) => {
const copyToClipboard = (text: string) => {
navigator.clipboard.writeText(text);
toast.success("Copied to clipboard!");
};
return (
<Dialog>
<DialogTrigger>
<Button variant="ghost" size="icon" className="group">
<HelpCircle className="size-4" />
</Button>
</DialogTrigger>
<DialogContent className="max-h-screen overflow-y-auto sm:max-w-2xl">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Server className="size-5" />
DNS Configuration Guide
</DialogTitle>
<DialogDescription>
Follow these steps to configure your DNS records for {domain.host}
</DialogDescription>
</DialogHeader>
<div className="flex flex-col gap-4">
<AlertBlock type="info">
To make your domain accessible, you need to configure your DNS
records with your domain provider (e.g., Cloudflare, GoDaddy,
NameCheap).
</AlertBlock>
<div className="flex flex-col gap-6">
<div className="rounded-lg border p-4">
<h3 className="font-medium mb-2">1. Add A Record</h3>
<div className="flex flex-col gap-3">
<p className="text-sm text-muted-foreground">
Create an A record that points your domain to the server's IP
address:
</p>
<div className="flex flex-col gap-2">
<div className="flex items-center justify-between gap-2 bg-muted p-3 rounded-md">
<div>
<p className="text-sm font-medium">Type: A</p>
<p className="text-sm">
Name: @ or {domain.host.split(".")[0]}
</p>
<p className="text-sm">
Value: {serverIp || "Your server IP"}
</p>
</div>
<Button
variant="ghost"
size="icon"
onClick={() => copyToClipboard(serverIp || "")}
disabled={!serverIp}
>
<Copy className="size-4" />
</Button>
</div>
</div>
</div>
</div>
<div className="rounded-lg border p-4">
<h3 className="font-medium mb-2">2. Verify Configuration</h3>
<div className="flex flex-col gap-3">
<p className="text-sm text-muted-foreground">
After configuring your DNS records:
</p>
<ul className="list-disc list-inside space-y-1 text-sm">
<li>Wait for DNS propagation (usually 15-30 minutes)</li>
<li>
Test your domain by visiting:{" "}
{domain.https ? "https://" : "http://"}
{domain.host}
{domain.path || "/"}
</li>
<li>Use a DNS lookup tool to verify your records</li>
</ul>
</div>
</div>
</div>
</div>
</DialogContent>
</Dialog>
);
};

View File

@ -7,17 +7,54 @@ import {
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 } from "lucide-react";
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;
}
type ValidationState = {
isLoading: boolean;
isValid?: boolean;
error?: string;
resolvedIp?: string;
};
type ValidationStates = {
[key: string]: ValidationState;
};
export const ShowDomainsCompose = ({ composeId }: Props) => {
const [validationStates, setValidationStates] = useState<ValidationStates>(
{},
);
const { data: ip } = api.settings.getIp.useQuery();
const { data, refetch } = api.domain.byComposeId.useQuery(
{
composeId,
@ -27,11 +64,57 @@ export const ShowDomainsCompose = ({ composeId }: Props) => {
},
);
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,
},
}));
} catch (err) {
const error = err as Error;
setValidationStates((prev) => ({
...prev,
[host]: {
isLoading: false,
isValid: false,
error: error.message || "Failed to validate domain",
},
}));
}
};
return (
<div className="flex w-full flex-col gap-5 ">
<div className="flex w-full flex-col gap-5">
<Card className="bg-background">
<CardHeader className="flex flex-row items-center flex-wrap gap-4 justify-between">
<div className="flex flex-col gap-1">
@ -45,59 +128,69 @@ export const ShowDomainsCompose = ({ composeId }: Props) => {
{data && data?.length > 0 && (
<AddDomainCompose composeId={composeId}>
<Button>
<GlobeIcon className="size-4" /> Add Domain
<GlobeIcon className="size-4 mr-2" /> Add Domain
</Button>
</AddDomainCompose>
)}
</div>
</CardHeader>
<CardContent className="flex w-full flex-row gap-4">
<CardContent>
{data?.length === 0 ? (
<div className="flex w-full flex-col items-center justify-center gap-3">
<div className="flex w-full flex-col items-center justify-center gap-3 py-8">
<GlobeIcon className="size-8 text-muted-foreground" />
<span className="text-base text-muted-foreground">
<span className="text-base text-muted-foreground text-center">
To access to the application it is required to set at least 1
domain
</span>
<div className="flex flex-row gap-4 flex-wrap">
<AddDomainCompose composeId={composeId}>
<Button>
<GlobeIcon className="size-4" /> Add Domain
<GlobeIcon className="size-4 mr-2" /> Add Domain
</Button>
</AddDomainCompose>
</div>
</div>
) : (
<div className="flex w-full flex-col gap-4">
<div className="grid grid-cols-1 gap-4 xl:grid-cols-2">
{data?.map((item) => {
const validationState = validationStates[item.host];
return (
<div
<Card
key={item.domainId}
className="flex w-full items-center justify-between gap-4 border p-4 md:px-6 rounded-lg flex-wrap"
className="relative overflow-hidden border bg-card transition-all hover:shadow-md bg-transparent"
>
<div className="md:basis-1/2 flex gap-6 w-full items-center">
<span className="opacity-50 text-center font-medium text-sm whitespace-nowrap">
<CardContent className="p-6">
<div className="flex flex-col gap-4">
{/* Service & Domain Info */}
<div className="flex items-start justify-between">
<div className="flex flex-col gap-2">
<Badge variant="outline" className="w-fit">
<Server className="size-3 mr-1" />
{item.serviceName}
</span>
</Badge>
<Link
className="flex gap-2 items-center hover:underline transition-all w-full max-w-[calc(100%-4rem)]"
className="flex items-center gap-2 text-base font-medium hover:underline"
target="_blank"
href={`${item.https ? "https" : "http"}://${item.host}${item.path}`}
>
<span className="truncate text-sm">{item.host}</span>
<ExternalLink className="size-4 min-w-4" />
{item.host}
<ExternalLink className="size-4" />
</Link>
</div>
<div className="flex gap-8">
<div className="flex gap-8 opacity-50 items-center h-10 text-center text-sm font-medium">
<span>{item.path}</span>
<span>{item.port}</span>
<span>{item.https ? "HTTPS" : "HTTP"}</span>
</div>
<div className="flex gap-2">
{!item.host.includes("traefik.me") && (
<DnsHelperModal
domain={{
host: item.host,
https: item.https,
path: item.path || undefined,
}}
serverIp={
compose?.server?.ipAddress?.toString() ||
ip?.toString()
}
/>
)}
<AddDomainCompose
composeId={composeId}
domainId={item.domainId}
@ -105,7 +198,7 @@ export const ShowDomainsCompose = ({ composeId }: Props) => {
<Button
variant="ghost"
size="icon"
className="group hover:bg-blue-500/10 "
className="group hover:bg-blue-500/10"
>
<PenBoxIcon className="size-3.5 text-primary group-hover:text-blue-500" />
</Button>
@ -120,7 +213,9 @@ export const ShowDomainsCompose = ({ composeId }: Props) => {
})
.then((_data) => {
refetch();
toast.success("Domain deleted successfully");
toast.success(
"Domain deleted successfully",
);
})
.catch(() => {
toast.error("Error deleting domain");
@ -138,7 +233,128 @@ export const ShowDomainsCompose = ({ composeId }: Props) => {
</DialogAction>
</div>
</div>
{/* Domain Details */}
<div className="flex flex-wrap gap-3">
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Badge variant="secondary">
<InfoIcon className="size-3 mr-1" />
Path: {item.path || "/"}
</Badge>
</TooltipTrigger>
<TooltipContent>
<p>URL path for this service</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Badge variant="secondary">
<InfoIcon className="size-3 mr-1" />
Port: {item.port}
</Badge>
</TooltipTrigger>
<TooltipContent>
<p>Container port exposed</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Badge
variant={item.https ? "outline" : "secondary"}
>
{item.https ? "HTTPS" : "HTTP"}
</Badge>
</TooltipTrigger>
<TooltipContent>
<p>
{item.https
? "Secure HTTPS connection"
: "Standard HTTP connection"}
</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
{item.certificateType && (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Badge variant="outline">
Cert: {item.certificateType}
</Badge>
</TooltipTrigger>
<TooltipContent>
<p>SSL Certificate Provider</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Badge
variant="outline"
className={
validationState?.isValid
? "bg-green-500/10 text-green-500 cursor-pointer"
: validationState?.error
? "bg-red-500/10 text-red-500 cursor-pointer"
: "bg-yellow-500/10 text-yellow-500 cursor-pointer"
}
onClick={() =>
handleValidateDomain(item.host)
}
>
{validationState?.isLoading ? (
<>
<Loader2 className="size-3 mr-1 animate-spin" />
Checking DNS...
</>
) : validationState?.isValid ? (
<>
<CheckCircle2 className="size-3 mr-1" />
{"DNS Valid"}
</>
) : validationState?.error ? (
<>
<XCircle className="size-3 mr-1" />
{validationState.error}
</>
) : (
<>
<RefreshCw className="size-3 mr-1" />
Validate DNS
</>
)}
</Badge>
</TooltipTrigger>
<TooltipContent className="max-w-xs">
{validationState?.error ? (
<div className="flex flex-col gap-1">
<p className="font-medium text-red-500">
Error:
</p>
<p>{validationState.error}</p>
</div>
) : (
"Click to validate DNS configuration"
)}
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
</div>
</CardContent>
</Card>
);
})}
</div>

View File

@ -21,6 +21,7 @@ import {
removeDomain,
removeDomainById,
updateDomainById,
validateDomain,
} from "@dokploy/server";
import { TRPCError } from "@trpc/server";
import { z } from "zod";
@ -224,4 +225,15 @@ export const domainRouter = createTRPCRouter({
return result;
}),
validateDomain: protectedProcedure
.input(
z.object({
domain: z.string(),
serverIp: z.string().optional(),
}),
)
.mutation(async ({ input }) => {
return validateDomain(input.domain, input.serverIp);
}),
});

View File

@ -7,6 +7,8 @@ import { type apiCreateDomain, domains } from "../db/schema";
import { findUserById } from "./admin";
import { findApplicationById } from "./application";
import { findServerById } from "./server";
import dns from "node:dns";
import { promisify } from "node:util";
export type Domain = typeof domains.$inferSelect;
@ -137,3 +139,85 @@ export const removeDomainById = async (domainId: string) => {
export const getDomainHost = (domain: Domain) => {
return `${domain.https ? "https" : "http"}://${domain.host}`;
};
const resolveDns = promisify(dns.resolve4);
// Cloudflare IP ranges (simplified - these are some common ones)
const CLOUDFLARE_IPS = [
"172.67.",
"104.21.",
"104.16.",
"104.17.",
"104.18.",
"104.19.",
"104.20.",
"104.22.",
"104.23.",
"104.24.",
"104.25.",
"104.26.",
"104.27.",
"104.28.",
];
const isCloudflareIp = (ip: string) => {
return CLOUDFLARE_IPS.some((range) => ip.startsWith(range));
};
export const validateDomain = async (
domain: string,
expectedIp?: string,
): Promise<{
isValid: boolean;
resolvedIp?: string;
error?: string;
isCloudflare?: boolean;
}> => {
try {
// Remove protocol and path if present
const cleanDomain = domain.replace(/^https?:\/\//, "").split("/")[0];
// Resolve the domain to get its IP
const ips = await resolveDns(cleanDomain || "");
const resolvedIp = ips[0];
// Check if it's a Cloudflare IP
const behindCloudflare = ips.some((ip) => isCloudflareIp(ip));
// If behind Cloudflare, we consider it valid but inform the user
if (behindCloudflare) {
return {
isValid: true,
resolvedIp,
isCloudflare: true,
error:
"Domain is behind Cloudflare - actual IP is masked by Cloudflare proxy",
};
}
// If we have an expected IP, validate against it
if (expectedIp) {
return {
isValid: resolvedIp === expectedIp,
resolvedIp,
error:
resolvedIp !== expectedIp
? `Domain resolves to ${resolvedIp} but should point to ${expectedIp}`
: undefined,
};
}
// If no expected IP, just return the resolved IP
return {
isValid: true,
resolvedIp,
};
} catch (error) {
return {
isValid: false,
error:
error instanceof Error ? error.message : "Failed to resolve domain",
};
}
};