mirror of
https://github.com/Dokploy/dokploy
synced 2025-06-26 18:27:59 +00:00
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:
@@ -38,26 +38,67 @@ import { useEffect, useState } from "react";
|
|||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
|
||||||
import { domain } from "@/server/db/validations/domain";
|
|
||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
import { Dices } from "lucide-react";
|
import { DatabaseZap, Dices, RefreshCw } from "lucide-react";
|
||||||
import Link from "next/link";
|
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>;
|
type Domain = z.infer<typeof domain>;
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
applicationId: string;
|
id: string;
|
||||||
|
type: "application" | "compose";
|
||||||
domainId?: string;
|
domainId?: string;
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const AddDomain = ({
|
export const AddDomain = ({ id, type, domainId = "", children }: Props) => {
|
||||||
applicationId,
|
|
||||||
domainId = "",
|
|
||||||
children,
|
|
||||||
}: Props) => {
|
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
const [cacheType, setCacheType] = useState<CacheType>("cache");
|
||||||
|
|
||||||
const utils = api.useUtils();
|
const utils = api.useUtils();
|
||||||
const { data, refetch } = api.domain.one.useQuery(
|
const { data, refetch } = api.domain.one.useQuery(
|
||||||
{
|
{
|
||||||
@@ -68,14 +109,24 @@ export const AddDomain = ({
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
const { data: application } = api.application.one.useQuery(
|
const { data: application } =
|
||||||
{
|
type === "application"
|
||||||
applicationId,
|
? api.application.one.useQuery(
|
||||||
},
|
{
|
||||||
{
|
applicationId: id,
|
||||||
enabled: !!applicationId,
|
},
|
||||||
},
|
{
|
||||||
);
|
enabled: !!id,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
: api.compose.one.useQuery(
|
||||||
|
{
|
||||||
|
composeId: id,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
enabled: !!id,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
const { mutateAsync, isError, error, isLoading } = domainId
|
const { mutateAsync, isError, error, isLoading } = domainId
|
||||||
? api.domain.update.useMutation()
|
? api.domain.update.useMutation()
|
||||||
@@ -89,6 +140,23 @@ export const AddDomain = ({
|
|||||||
serverId: application?.serverId || "",
|
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>({
|
const form = useForm<Domain>({
|
||||||
resolver: zodResolver(domain),
|
resolver: zodResolver(domain),
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
@@ -98,12 +166,15 @@ export const AddDomain = ({
|
|||||||
https: false,
|
https: false,
|
||||||
certificateType: undefined,
|
certificateType: undefined,
|
||||||
customCertResolver: undefined,
|
customCertResolver: undefined,
|
||||||
|
serviceName: undefined,
|
||||||
|
domainType: type,
|
||||||
},
|
},
|
||||||
mode: "onChange",
|
mode: "onChange",
|
||||||
});
|
});
|
||||||
|
|
||||||
const certificateType = form.watch("certificateType");
|
const certificateType = form.watch("certificateType");
|
||||||
const https = form.watch("https");
|
const https = form.watch("https");
|
||||||
|
const domainType = form.watch("domainType");
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (data) {
|
if (data) {
|
||||||
@@ -114,6 +185,8 @@ export const AddDomain = ({
|
|||||||
port: data?.port || undefined,
|
port: data?.port || undefined,
|
||||||
certificateType: data?.certificateType || undefined,
|
certificateType: data?.certificateType || undefined,
|
||||||
customCertResolver: data?.customCertResolver || undefined,
|
customCertResolver: data?.customCertResolver || undefined,
|
||||||
|
serviceName: data?.serviceName || undefined,
|
||||||
|
domainType: data?.domainType || type,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -125,6 +198,7 @@ export const AddDomain = ({
|
|||||||
https: false,
|
https: false,
|
||||||
certificateType: undefined,
|
certificateType: undefined,
|
||||||
customCertResolver: undefined,
|
customCertResolver: undefined,
|
||||||
|
domainType: type,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}, [form, data, isLoading, domainId]);
|
}, [form, data, isLoading, domainId]);
|
||||||
@@ -148,22 +222,37 @@ export const AddDomain = ({
|
|||||||
const onSubmit = async (data: Domain) => {
|
const onSubmit = async (data: Domain) => {
|
||||||
await mutateAsync({
|
await mutateAsync({
|
||||||
domainId,
|
domainId,
|
||||||
applicationId,
|
...(data.domainType === "application" && {
|
||||||
|
applicationId: id,
|
||||||
|
}),
|
||||||
|
...(data.domainType === "compose" && {
|
||||||
|
composeId: id,
|
||||||
|
}),
|
||||||
...data,
|
...data,
|
||||||
})
|
})
|
||||||
.then(async () => {
|
.then(async () => {
|
||||||
toast.success(dictionary.success);
|
toast.success(dictionary.success);
|
||||||
await utils.domain.byApplicationId.invalidate({
|
|
||||||
applicationId,
|
if (data.domainType === "application") {
|
||||||
});
|
await utils.domain.byApplicationId.invalidate({
|
||||||
await utils.application.readTraefikConfig.invalidate({ applicationId });
|
applicationId: id,
|
||||||
|
});
|
||||||
|
await utils.application.readTraefikConfig.invalidate({
|
||||||
|
applicationId: id,
|
||||||
|
});
|
||||||
|
} else if (data.domainType === "compose") {
|
||||||
|
await utils.domain.byComposeId.invalidate({
|
||||||
|
composeId: id,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
if (domainId) {
|
if (domainId) {
|
||||||
refetch();
|
refetch();
|
||||||
}
|
}
|
||||||
setIsOpen(false);
|
setIsOpen(false);
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch((e) => {
|
||||||
|
console.log(e);
|
||||||
toast.error(dictionary.error);
|
toast.error(dictionary.error);
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
@@ -187,6 +276,119 @@ export const AddDomain = ({
|
|||||||
>
|
>
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
<div className="flex flex-col gap-2">
|
<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
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="host"
|
name="host"
|
||||||
@@ -16,12 +16,13 @@ import {
|
|||||||
Loader2,
|
Loader2,
|
||||||
PenBoxIcon,
|
PenBoxIcon,
|
||||||
RefreshCw,
|
RefreshCw,
|
||||||
|
Server,
|
||||||
Trash2,
|
Trash2,
|
||||||
XCircle,
|
XCircle,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { AddDomain } from "./add-domain";
|
import { AddDomain } from "./handle-domain";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import {
|
import {
|
||||||
Tooltip,
|
Tooltip,
|
||||||
@@ -29,36 +30,65 @@ import {
|
|||||||
TooltipProvider,
|
TooltipProvider,
|
||||||
TooltipTrigger,
|
TooltipTrigger,
|
||||||
} from "@/components/ui/tooltip";
|
} from "@/components/ui/tooltip";
|
||||||
import type { ValidationStates } from "../../compose/domains/show-domains";
|
import { DnsHelperModal } from "./dns-helper-modal";
|
||||||
import { DnsHelperModal } from "../../compose/domains/dns-helper-modal";
|
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
|
||||||
|
export type ValidationState = {
|
||||||
|
isLoading: boolean;
|
||||||
|
isValid?: boolean;
|
||||||
|
error?: string;
|
||||||
|
resolvedIp?: string;
|
||||||
|
message?: string;
|
||||||
|
};
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
applicationId: string;
|
id: string;
|
||||||
|
type: "application" | "compose";
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ShowDomains = ({ applicationId }: Props) => {
|
export const ShowDomains = ({ id, type }: Props) => {
|
||||||
const { data: application } = api.application.one.useQuery(
|
const { data: application } =
|
||||||
{
|
type === "application"
|
||||||
applicationId,
|
? api.application.one.useQuery(
|
||||||
},
|
{
|
||||||
{
|
applicationId: id,
|
||||||
enabled: !!applicationId,
|
},
|
||||||
},
|
{
|
||||||
);
|
enabled: !!id,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
: api.compose.one.useQuery(
|
||||||
|
{
|
||||||
|
composeId: id,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
enabled: !!id,
|
||||||
|
},
|
||||||
|
);
|
||||||
const [validationStates, setValidationStates] = useState<ValidationStates>(
|
const [validationStates, setValidationStates] = useState<ValidationStates>(
|
||||||
{},
|
{},
|
||||||
);
|
);
|
||||||
const { data: ip } = api.settings.getIp.useQuery();
|
const { data: ip } = api.settings.getIp.useQuery();
|
||||||
|
|
||||||
const { data, refetch } = api.domain.byApplicationId.useQuery(
|
const { data, refetch } =
|
||||||
{
|
type === "application"
|
||||||
applicationId,
|
? api.domain.byApplicationId.useQuery(
|
||||||
},
|
{
|
||||||
{
|
applicationId: id,
|
||||||
enabled: !!applicationId,
|
},
|
||||||
},
|
{
|
||||||
);
|
enabled: !!id,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
: api.domain.byComposeId.useQuery(
|
||||||
|
{
|
||||||
|
composeId: id,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
enabled: !!id,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
const { mutateAsync: validateDomain } =
|
const { mutateAsync: validateDomain } =
|
||||||
api.domain.validateDomain.useMutation();
|
api.domain.validateDomain.useMutation();
|
||||||
const { mutateAsync: deleteDomain, isLoading: isRemoving } =
|
const { mutateAsync: deleteDomain, isLoading: isRemoving } =
|
||||||
@@ -113,7 +143,7 @@ export const ShowDomains = ({ applicationId }: Props) => {
|
|||||||
|
|
||||||
<div className="flex flex-row gap-4 flex-wrap">
|
<div className="flex flex-row gap-4 flex-wrap">
|
||||||
{data && data?.length > 0 && (
|
{data && data?.length > 0 && (
|
||||||
<AddDomain applicationId={applicationId}>
|
<AddDomain id={id} type={type}>
|
||||||
<Button>
|
<Button>
|
||||||
<GlobeIcon className="size-4" /> Add Domain
|
<GlobeIcon className="size-4" /> Add Domain
|
||||||
</Button>
|
</Button>
|
||||||
@@ -123,14 +153,14 @@ export const ShowDomains = ({ applicationId }: Props) => {
|
|||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="flex w-full flex-row gap-4">
|
<CardContent className="flex w-full flex-row gap-4">
|
||||||
{data?.length === 0 ? (
|
{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" />
|
<GlobeIcon className="size-8 text-muted-foreground" />
|
||||||
<span className="text-base text-muted-foreground">
|
<span className="text-base text-muted-foreground">
|
||||||
To access the application it is required to set at least 1
|
To access the application it is required to set at least 1
|
||||||
domain
|
domain
|
||||||
</span>
|
</span>
|
||||||
<div className="flex flex-row gap-4 flex-wrap">
|
<div className="flex flex-row gap-4 flex-wrap">
|
||||||
<AddDomain applicationId={applicationId}>
|
<AddDomain id={id} type={type}>
|
||||||
<Button>
|
<Button>
|
||||||
<GlobeIcon className="size-4" /> Add Domain
|
<GlobeIcon className="size-4" /> Add Domain
|
||||||
</Button>
|
</Button>
|
||||||
@@ -138,19 +168,26 @@ export const ShowDomains = ({ applicationId }: Props) => {
|
|||||||
</div>
|
</div>
|
||||||
</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) => {
|
{data?.map((item) => {
|
||||||
const validationState = validationStates[item.host];
|
const validationState = validationStates[item.host];
|
||||||
return (
|
return (
|
||||||
<Card
|
<Card
|
||||||
key={item.domainId}
|
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">
|
<CardContent className="p-6">
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
{/* Service & Domain Info */}
|
{/* Service & Domain Info */}
|
||||||
<div className="flex items-start justify-between">
|
<div className="flex items-start justify-between">
|
||||||
<div className="flex flex-col gap-2">
|
<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
|
<Link
|
||||||
className="flex items-center gap-2 text-base font-medium hover:underline"
|
className="flex items-center gap-2 text-base font-medium hover:underline"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
@@ -175,7 +212,8 @@ export const ShowDomains = ({ applicationId }: Props) => {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<AddDomain
|
<AddDomain
|
||||||
applicationId={applicationId}
|
id={id}
|
||||||
|
type={type}
|
||||||
domainId={item.domainId}
|
domainId={item.domainId}
|
||||||
>
|
>
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@@ -43,10 +43,10 @@ import {
|
|||||||
DialogTrigger,
|
DialogTrigger,
|
||||||
} from "@/components/ui/dialog";
|
} from "@/components/ui/dialog";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import type { CacheType } from "../../compose/domains/add-domain";
|
|
||||||
import { AlertBlock } from "@/components/shared/alert-block";
|
import { AlertBlock } from "@/components/shared/alert-block";
|
||||||
import { CodeEditor } from "@/components/shared/code-editor";
|
import { CodeEditor } from "@/components/shared/code-editor";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
import type { CacheType } from "../domains/handle-domain";
|
||||||
|
|
||||||
export const commonCronExpressions = [
|
export const commonCronExpressions = [
|
||||||
{ label: "Every minute", value: "* * * * *" },
|
{ label: "Every minute", value: "* * * * *" },
|
||||||
|
|||||||
@@ -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>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -330,7 +330,7 @@ const Service = (
|
|||||||
</TabsContent>
|
</TabsContent>
|
||||||
<TabsContent value="domains" className="w-full">
|
<TabsContent value="domains" className="w-full">
|
||||||
<div className="flex flex-col gap-4 pt-2.5">
|
<div className="flex flex-col gap-4 pt-2.5">
|
||||||
<ShowDomains applicationId={applicationId} />
|
<ShowDomains id={applicationId} type="application" />
|
||||||
</div>
|
</div>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
<TabsContent value="advanced">
|
<TabsContent value="advanced">
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
import { ShowImport } from "@/components/dashboard/application/advanced/import/show-import";
|
import { ShowImport } from "@/components/dashboard/application/advanced/import/show-import";
|
||||||
import { ShowVolumes } from "@/components/dashboard/application/advanced/volumes/show-volumes";
|
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 { ShowEnvironment } from "@/components/dashboard/application/environment/show-enviroment";
|
||||||
import { ShowSchedules } from "@/components/dashboard/application/schedules/show-schedules";
|
import { ShowSchedules } from "@/components/dashboard/application/schedules/show-schedules";
|
||||||
import { AddCommandCompose } from "@/components/dashboard/compose/advanced/add-command";
|
import { AddCommandCompose } from "@/components/dashboard/compose/advanced/add-command";
|
||||||
import { DeleteService } from "@/components/dashboard/compose/delete-service";
|
import { DeleteService } from "@/components/dashboard/compose/delete-service";
|
||||||
import { ShowDeploymentsCompose } from "@/components/dashboard/compose/deployments/show-deployments-compose";
|
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 { ShowGeneralCompose } from "@/components/dashboard/compose/general/show";
|
||||||
import { ShowDockerLogsCompose } from "@/components/dashboard/compose/logs/show";
|
import { ShowDockerLogsCompose } from "@/components/dashboard/compose/logs/show";
|
||||||
import { ShowDockerLogsStack } from "@/components/dashboard/compose/logs/show-stack";
|
import { ShowDockerLogsStack } from "@/components/dashboard/compose/logs/show-stack";
|
||||||
@@ -339,7 +339,7 @@ const Service = (
|
|||||||
|
|
||||||
<TabsContent value="domains">
|
<TabsContent value="domains">
|
||||||
<div className="flex flex-col gap-4 pt-2.5">
|
<div className="flex flex-col gap-4 pt-2.5">
|
||||||
<ShowDomainsCompose composeId={composeId} />
|
<ShowDomains id={composeId} type="compose" />
|
||||||
</div>
|
</div>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
<TabsContent value="advanced">
|
<TabsContent value="advanced">
|
||||||
|
|||||||
@@ -57,7 +57,10 @@ export const domainRouter = createTRPCRouter({
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
throw new TRPCError({
|
throw new TRPCError({
|
||||||
code: "BAD_REQUEST",
|
code: "BAD_REQUEST",
|
||||||
message: "Error creating the domain",
|
message:
|
||||||
|
error instanceof Error
|
||||||
|
? error.message
|
||||||
|
: "Error creating the domain",
|
||||||
cause: error,
|
cause: error,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user