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.
This commit is contained in:
Mauricio Siu 2025-05-04 12:07:09 -06:00
parent d6f5f6e6cb
commit 4575f16be4
9 changed files with 298 additions and 946 deletions

View File

@ -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<typeof domain>;
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<CacheType>("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<Domain>({
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 = ({
>
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-2">
<div className="flex flex-row items-end w-full gap-4">
{domainType === "compose" && (
<>
{errorServices && (
<AlertBlock
type="warning"
className="[overflow-wrap:anywhere]"
>
{errorServices?.message}
</AlertBlock>
)}
<FormField
control={form.control}
name="serviceName"
render={({ field }) => (
<FormItem className="w-full">
<FormLabel>Service Name</FormLabel>
<div className="flex gap-2">
<Select
onValueChange={field.onChange}
defaultValue={field.value || ""}
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select a service name" />
</SelectTrigger>
</FormControl>
<SelectContent>
{services?.map((service, index) => (
<SelectItem
value={service}
key={`${service}-${index}`}
>
{service}
</SelectItem>
))}
<SelectItem value="none" disabled>
Empty
</SelectItem>
</SelectContent>
</Select>
<TooltipProvider delayDuration={0}>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="secondary"
type="button"
isLoading={isLoadingServices}
onClick={() => {
if (cacheType === "fetch") {
refetchServices();
} else {
setCacheType("fetch");
}
}}
>
<RefreshCw className="size-4 text-muted-foreground" />
</Button>
</TooltipTrigger>
<TooltipContent
side="left"
sideOffset={5}
className="max-w-[10rem]"
>
<p>
Fetch: Will clone the repository and load
the services
</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
<TooltipProvider delayDuration={0}>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="secondary"
type="button"
isLoading={isLoadingServices}
onClick={() => {
if (cacheType === "cache") {
refetchServices();
} else {
setCacheType("cache");
}
}}
>
<DatabaseZap className="size-4 text-muted-foreground" />
</Button>
</TooltipTrigger>
<TooltipContent
side="left"
sideOffset={5}
className="max-w-[10rem]"
>
<p>
Cache: If you previously deployed this
compose, it will read the services from
the last deployment/fetch from the
repository
</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
<FormMessage />
</FormItem>
)}
/>
</>
)}
</div>
<FormField
control={form.control}
name="host"

View File

@ -16,12 +16,13 @@ import {
Loader2,
PenBoxIcon,
RefreshCw,
Server,
Trash2,
XCircle,
} from "lucide-react";
import Link from "next/link";
import { toast } from "sonner";
import { AddDomain } from "./add-domain";
import { AddDomain } from "./handle-domain";
import { useState } from "react";
import {
Tooltip,
@ -29,36 +30,65 @@ import {
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import type { ValidationStates } from "../../compose/domains/show-domains";
import { DnsHelperModal } from "../../compose/domains/dns-helper-modal";
import { DnsHelperModal } from "./dns-helper-modal";
import { Badge } from "@/components/ui/badge";
export type ValidationState = {
isLoading: boolean;
isValid?: boolean;
error?: string;
resolvedIp?: string;
message?: string;
};
interface Props {
applicationId: string;
id: string;
type: "application" | "compose";
}
export const ShowDomains = ({ applicationId }: Props) => {
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<ValidationStates>(
{},
);
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) => {
<div className="flex flex-row gap-4 flex-wrap">
{data && data?.length > 0 && (
<AddDomain applicationId={applicationId}>
<AddDomain id={id} type={type}>
<Button>
<GlobeIcon className="size-4" /> Add Domain
</Button>
@ -123,14 +153,14 @@ export const ShowDomains = ({ applicationId }: Props) => {
</CardHeader>
<CardContent className="flex w-full flex-row gap-4">
{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 min-h-[40vh]">
<GlobeIcon className="size-8 text-muted-foreground" />
<span className="text-base text-muted-foreground">
To access the application it is required to set at least 1
domain
</span>
<div className="flex flex-row gap-4 flex-wrap">
<AddDomain applicationId={applicationId}>
<AddDomain id={id} type={type}>
<Button>
<GlobeIcon className="size-4" /> Add Domain
</Button>
@ -138,19 +168,26 @@ export const ShowDomains = ({ applicationId }: Props) => {
</div>
</div>
) : (
<div className="grid grid-cols-1 gap-4 xl:grid-cols-2 w-full">
<div className="grid grid-cols-1 gap-4 xl:grid-cols-2 w-full min-h-[40vh]">
{data?.map((item) => {
const validationState = validationStates[item.host];
return (
<Card
key={item.domainId}
className="relative overflow-hidden w-full border bg-card transition-all hover:shadow-md bg-transparent"
className="relative overflow-hidden w-full border bg-card transition-all hover:shadow-md bg-transparent h-fit"
>
<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">
{item.serviceName && (
<Badge variant="outline" className="w-fit">
<Server className="size-3 mr-1" />
{item.serviceName}
</Badge>
)}
<Link
className="flex items-center gap-2 text-base font-medium hover:underline"
target="_blank"
@ -175,7 +212,8 @@ export const ShowDomains = ({ applicationId }: Props) => {
/>
)}
<AddDomain
applicationId={applicationId}
id={id}
type={type}
domainId={item.domainId}
>
<Button

View File

@ -43,10 +43,10 @@ import {
DialogTrigger,
} from "@/components/ui/dialog";
import { toast } from "sonner";
import type { CacheType } from "../../compose/domains/add-domain";
import { AlertBlock } from "@/components/shared/alert-block";
import { CodeEditor } from "@/components/shared/code-editor";
import { cn } from "@/lib/utils";
import type { CacheType } from "../domains/handle-domain";
export const commonCronExpressions = [
{ label: "Every minute", value: "* * * * *" },

View File

@ -1,508 +0,0 @@
import { AlertBlock } from "@/components/shared/alert-block";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input, NumberInput } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import { api } from "@/utils/api";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { domainCompose } from "@/server/db/validations/domain";
import { zodResolver } from "@hookform/resolvers/zod";
import { DatabaseZap, Dices, RefreshCw } from "lucide-react";
import Link from "next/link";
import type z from "zod";
type Domain = z.infer<typeof domainCompose>;
export type CacheType = "fetch" | "cache";
interface Props {
composeId: string;
domainId?: string;
children: React.ReactNode;
}
export const AddDomainCompose = ({
composeId,
domainId = "",
children,
}: Props) => {
const [isOpen, setIsOpen] = useState(false);
const [cacheType, setCacheType] = useState<CacheType>("cache");
const utils = api.useUtils();
const { data, refetch } = api.domain.one.useQuery(
{
domainId,
},
{
enabled: !!domainId,
},
);
const { data: compose } = api.compose.one.useQuery(
{
composeId,
},
{
enabled: !!composeId,
},
);
const {
data: services,
isFetching: isLoadingServices,
error: errorServices,
refetch: refetchServices,
} = api.compose.loadServices.useQuery(
{
composeId,
type: cacheType,
},
{
retry: false,
refetchOnWindowFocus: false,
},
);
const { mutateAsync: generateDomain, isLoading: isLoadingGenerate } =
api.domain.generateDomain.useMutation();
const { mutateAsync, isError, error, isLoading } = domainId
? api.domain.update.useMutation()
: api.domain.create.useMutation();
const { data: canGenerateTraefikMeDomains } =
api.domain.canGenerateTraefikMeDomains.useQuery({
serverId: compose?.serverId || "",
});
const form = useForm<Domain>({
resolver: zodResolver(domainCompose),
defaultValues: {
host: "",
path: undefined,
port: undefined,
https: false,
certificateType: undefined,
customCertResolver: undefined,
serviceName: "",
},
});
const https = form.watch("https");
useEffect(() => {
if (data) {
form.reset({
...data,
/* Convert null to undefined */
path: data?.path || undefined,
port: data?.port || undefined,
serviceName: data?.serviceName || undefined,
certificateType: data?.certificateType || undefined,
customCertResolver: data?.customCertResolver || undefined,
});
}
if (!domainId) {
form.reset({
host: "",
path: undefined,
port: undefined,
https: false,
certificateType: undefined,
customCertResolver: undefined,
serviceName: "",
});
}
}, [form, form.reset, data, isLoading]);
const dictionary = {
success: domainId ? "Domain Updated" : "Domain Created",
error: domainId ? "Error updating the domain" : "Error creating the domain",
submit: domainId ? "Update" : "Create",
dialogDescription: domainId
? "In this section you can edit a domain"
: "In this section you can add domains",
};
const onSubmit = async (data: Domain) => {
await mutateAsync({
domainId,
composeId,
domainType: "compose",
...data,
})
.then(async () => {
await utils.domain.byComposeId.invalidate({
composeId,
});
toast.success(dictionary.success);
if (domainId) {
refetch();
}
setIsOpen(false);
})
.catch(() => {
toast.error(dictionary.error);
});
};
return (
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogTrigger className="" asChild>
{children}
</DialogTrigger>
<DialogContent className="max-h-screen overflow-y-auto sm:max-w-2xl">
<DialogHeader>
<DialogTitle>Domain</DialogTitle>
<DialogDescription>{dictionary.dialogDescription}</DialogDescription>
</DialogHeader>
<div className="flex flex-col gap-4">
<AlertBlock type="info">
Deploy is required to apply changes after creating or updating a
domain.
</AlertBlock>
{isError && <AlertBlock type="error">{error?.message}</AlertBlock>}
</div>
<Form {...form}>
<form
id="hook-form"
onSubmit={form.handleSubmit(onSubmit)}
className="grid w-full gap-8 "
>
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-2">
{errorServices && (
<AlertBlock
type="warning"
className="[overflow-wrap:anywhere]"
>
{errorServices?.message}
</AlertBlock>
)}
<div className="flex flex-row items-end w-full gap-4">
<FormField
control={form.control}
name="serviceName"
render={({ field }) => (
<FormItem className="w-full">
<FormLabel>Service Name</FormLabel>
<div className="flex gap-2">
<Select
onValueChange={field.onChange}
defaultValue={field.value || ""}
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select a service name" />
</SelectTrigger>
</FormControl>
<SelectContent>
{services?.map((service, index) => (
<SelectItem
value={service}
key={`${service}-${index}`}
>
{service}
</SelectItem>
))}
<SelectItem value="none" disabled>
Empty
</SelectItem>
</SelectContent>
</Select>
<TooltipProvider delayDuration={0}>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="secondary"
type="button"
isLoading={isLoadingServices}
onClick={() => {
if (cacheType === "fetch") {
refetchServices();
} else {
setCacheType("fetch");
}
}}
>
<RefreshCw className="size-4 text-muted-foreground" />
</Button>
</TooltipTrigger>
<TooltipContent
side="left"
sideOffset={5}
className="max-w-[10rem]"
>
<p>
Fetch: Will clone the repository and load the
services
</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
<TooltipProvider delayDuration={0}>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="secondary"
type="button"
isLoading={isLoadingServices}
onClick={() => {
if (cacheType === "cache") {
refetchServices();
} else {
setCacheType("cache");
}
}}
>
<DatabaseZap className="size-4 text-muted-foreground" />
</Button>
</TooltipTrigger>
<TooltipContent
side="left"
sideOffset={5}
className="max-w-[10rem]"
>
<p>
Cache: If you previously deployed this
compose, it will read the services from the
last deployment/fetch from the repository
</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
<FormMessage />
</FormItem>
)}
/>
</div>
<FormField
control={form.control}
name="host"
render={({ field }) => (
<FormItem>
{!canGenerateTraefikMeDomains &&
field.value.includes("traefik.me") && (
<AlertBlock type="warning">
You need to set an IP address in your{" "}
<Link
href="/dashboard/settings/server"
className="text-primary"
>
{compose?.serverId
? "Remote Servers -> Server -> Edit Server -> Update IP Address"
: "Web Server -> Server -> Update Server IP"}
</Link>{" "}
to make your traefik.me domain work.
</AlertBlock>
)}
<FormLabel>Host</FormLabel>
<div className="flex gap-2">
<FormControl>
<Input placeholder="api.dokploy.com" {...field} />
</FormControl>
<TooltipProvider delayDuration={0}>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="secondary"
type="button"
isLoading={isLoadingGenerate}
onClick={() => {
generateDomain({
serverId: compose?.serverId || "",
appName: compose?.appName || "",
})
.then((domain) => {
field.onChange(domain);
})
.catch((err) => {
toast.error(err.message);
});
}}
>
<Dices className="size-4 text-muted-foreground" />
</Button>
</TooltipTrigger>
<TooltipContent
side="left"
sideOffset={5}
className="max-w-[10rem]"
>
<p>Generate traefik.me domain</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="path"
render={({ field }) => {
return (
<FormItem>
<FormLabel>Path</FormLabel>
<FormControl>
<Input placeholder={"/"} {...field} />
</FormControl>
<FormMessage />
</FormItem>
);
}}
/>
<FormField
control={form.control}
name="port"
render={({ field }) => {
return (
<FormItem>
<FormLabel>Container Port</FormLabel>
<FormDescription>
The port where your application is running inside the
container (e.g., 3000 for Node.js, 80 for Nginx, 8080
for Java)
</FormDescription>
<FormControl>
<NumberInput placeholder={"3000"} {...field} />
</FormControl>
<FormMessage />
</FormItem>
);
}}
/>
<FormField
control={form.control}
name="https"
render={({ field }) => (
<FormItem className="flex flex-row items-center justify-between p-3 mt-4 border rounded-lg shadow-sm">
<div className="space-y-0.5">
<FormLabel>HTTPS</FormLabel>
<FormDescription>
Automatically provision SSL Certificate.
</FormDescription>
<FormMessage />
</div>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
{https && (
<>
<FormField
control={form.control}
name="certificateType"
render={({ field }) => (
<FormItem className="col-span-2">
<FormLabel>Certificate Provider</FormLabel>
<Select
onValueChange={field.onChange}
defaultValue={field.value || ""}
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select a certificate provider" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="none">None</SelectItem>
<SelectItem value={"letsencrypt"}>
Let's Encrypt
</SelectItem>
<SelectItem value={"custom"}>Custom</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
{form.getValues().certificateType === "custom" && (
<FormField
control={form.control}
name="customCertResolver"
render={({ field }) => (
<FormItem className="col-span-2">
<FormLabel>Custom Certificate Resolver</FormLabel>
<FormControl>
<Input
placeholder="Enter your custom certificate resolver"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
)}
</>
)}
</div>
</div>
</form>
<DialogFooter>
<Button
isLoading={form.formState.isSubmitting}
form="hook-form"
type="submit"
>
{dictionary.submit}
</Button>
</DialogFooter>
</Form>
</DialogContent>
</Dialog>
);
};

View File

@ -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<ValidationStates>(
{},
);
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 (
<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">
<CardTitle className="text-xl">Domains</CardTitle>
<CardDescription>
Domains are used to access to the application
</CardDescription>
</div>
<div className="flex flex-row gap-4 flex-wrap">
{data && data?.length > 0 && (
<AddDomainCompose composeId={composeId}>
<Button>
<GlobeIcon className="size-4 mr-2" /> Add Domain
</Button>
</AddDomainCompose>
)}
</div>
</CardHeader>
<CardContent>
{data?.length === 0 ? (
<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 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 mr-2" /> Add Domain
</Button>
</AddDomainCompose>
</div>
</div>
) : (
<div className="grid grid-cols-1 gap-4 xl:grid-cols-2">
{data?.map((item) => {
const validationState = validationStates[item.host];
return (
<Card
key={item.domainId}
className="relative overflow-hidden border bg-card transition-all hover:shadow-md bg-transparent"
>
<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}
</Badge>
<Link
className="flex items-center gap-2 text-base font-medium hover:underline"
target="_blank"
href={`${item.https ? "https" : "http"}://${item.host}${item.path}`}
>
{item.host}
<ExternalLink className="size-4" />
</Link>
</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}
>
<Button
variant="ghost"
size="icon"
className="group hover:bg-blue-500/10"
>
<PenBoxIcon className="size-3.5 text-primary group-hover:text-blue-500" />
</Button>
</AddDomainCompose>
<DialogAction
title="Delete Domain"
description="Are you sure you want to delete this domain?"
type="destructive"
onClick={async () => {
await deleteDomain({
domainId: item.domainId,
})
.then((_data) => {
refetch();
toast.success(
"Domain deleted successfully",
);
})
.catch(() => {
toast.error("Error deleting domain");
});
}}
>
<Button
variant="ghost"
size="icon"
className="group hover:bg-red-500/10"
isLoading={isRemoving}
>
<Trash2 className="size-4 text-primary group-hover:text-red-500" />
</Button>
</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" />
{validationState.message
? "Behind Cloudflare"
: "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 &&
!validationState.isValid ? (
<div className="flex flex-col gap-1">
<p className="font-medium text-red-500">
Error:
</p>
<p>{validationState.error}</p>
</div>
) : validationState?.isValid ? (
<div className="flex flex-col gap-1">
<p className="font-medium text-green-500">
{validationState.message
? "Info:"
: "Valid Configuration:"}
</p>
<p>
{validationState.message ||
`Domain points to ${validationState.resolvedIp}`}
</p>
</div>
) : (
"Click to validate DNS configuration"
)}
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
</div>
</CardContent>
</Card>
);
})}
</div>
)}
</CardContent>
</Card>
</div>
);
};

View File

@ -330,7 +330,7 @@ const Service = (
</TabsContent>
<TabsContent value="domains" className="w-full">
<div className="flex flex-col gap-4 pt-2.5">
<ShowDomains applicationId={applicationId} />
<ShowDomains id={applicationId} type="application" />
</div>
</TabsContent>
<TabsContent value="advanced">

View File

@ -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 = (
<TabsContent value="domains">
<div className="flex flex-col gap-4 pt-2.5">
<ShowDomainsCompose composeId={composeId} />
<ShowDomains id={composeId} type="compose" />
</div>
</TabsContent>
<TabsContent value="advanced">

View File

@ -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,
});
}