refactor(domains): make traefik domains generate in a single click

This commit is contained in:
Mauricio Siu 2024-08-15 01:02:11 -06:00
parent 29ca894a97
commit ecb919e109
15 changed files with 6321 additions and 211 deletions

View File

@ -27,13 +27,20 @@ import {
SelectValue, SelectValue,
} from "@/components/ui/select"; } from "@/components/ui/select";
import { Switch } from "@/components/ui/switch"; import { Switch } from "@/components/ui/switch";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { api } from "@/utils/api"; import { api } from "@/utils/api";
import { useEffect, useState } from "react"; 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"; 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 type z from "zod"; import type z from "zod";
type Domain = z.infer<typeof domain>; type Domain = z.infer<typeof domain>;
@ -60,10 +67,22 @@ export const AddDomain = ({
}, },
); );
const { data: application } = api.application.one.useQuery(
{
applicationId,
},
{
enabled: !!applicationId,
},
);
const { mutateAsync, isError, error, isLoading } = domainId const { mutateAsync, isError, error, isLoading } = domainId
? api.domain.update.useMutation() ? api.domain.update.useMutation()
: api.domain.create.useMutation(); : api.domain.create.useMutation();
const { mutateAsync: generateDomain, isLoading: isLoadingGenerate } =
api.domain.generateDomain.useMutation();
const form = useForm<Domain>({ const form = useForm<Domain>({
resolver: zodResolver(domain), resolver: zodResolver(domain),
}); });
@ -75,7 +94,6 @@ export const AddDomain = ({
/* Convert null to undefined */ /* Convert null to undefined */
path: data?.path || undefined, path: data?.path || undefined,
port: data?.port || undefined, port: data?.port || undefined,
serviceName: data?.serviceName || undefined,
}); });
} }
@ -143,9 +161,42 @@ export const AddDomain = ({
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel>Host</FormLabel> <FormLabel>Host</FormLabel>
<FormControl> <div className="flex max-lg:flex-wrap sm:flex-row gap-2">
<Input placeholder="api.dokploy.com" {...field} /> <FormControl>
</FormControl> <Input placeholder="api.dokploy.com" {...field} />
</FormControl>
<TooltipProvider delayDuration={0}>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="secondary"
type="button"
isLoading={isLoadingGenerate}
onClick={() => {
generateDomain({
appName: application?.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 /> <FormMessage />
</FormItem> </FormItem>

View File

@ -40,12 +40,12 @@ import {
} from "@/components/ui/tooltip"; } from "@/components/ui/tooltip";
import { domainCompose } from "@/server/db/validations/domain"; import { domainCompose } from "@/server/db/validations/domain";
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
import { DatabaseZap, RefreshCw } from "lucide-react"; import { DatabaseZap, Dices, RefreshCw } from "lucide-react";
import type z from "zod"; import type z from "zod";
type Domain = z.infer<typeof domainCompose>; type Domain = z.infer<typeof domainCompose>;
type CacheType = "fetch" | "cache"; export type CacheType = "fetch" | "cache";
interface Props { interface Props {
composeId: string; composeId: string;
@ -70,6 +70,15 @@ export const AddDomainCompose = ({
}, },
); );
const { data: compose } = api.compose.one.useQuery(
{
composeId,
},
{
enabled: !!composeId,
},
);
const { const {
data: services, data: services,
isFetching: isLoadingServices, isFetching: isLoadingServices,
@ -86,6 +95,9 @@ export const AddDomainCompose = ({
}, },
); );
const { mutateAsync: generateDomain, isLoading: isLoadingGenerate } =
api.domain.generateDomain.useMutation();
const { mutateAsync, isError, error, isLoading } = domainId const { mutateAsync, isError, error, isLoading } = domainId
? api.domain.update.useMutation() ? api.domain.update.useMutation()
: api.domain.create.useMutation(); : api.domain.create.useMutation();
@ -279,9 +291,42 @@ export const AddDomainCompose = ({
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel>Host</FormLabel> <FormLabel>Host</FormLabel>
<FormControl> <div className="flex max-lg:flex-wrap sm:flex-row gap-2">
<Input placeholder="api.dokploy.com" {...field} /> <FormControl>
</FormControl> <Input placeholder="api.dokploy.com" {...field} />
</FormControl>
<TooltipProvider delayDuration={0}>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="secondary"
type="button"
isLoading={isLoadingGenerate}
onClick={() => {
generateDomain({
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 /> <FormMessage />
</FormItem> </FormItem>

View File

@ -1,79 +0,0 @@
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { api } from "@/utils/api";
import { RefreshCcw } from "lucide-react";
import Link from "next/link";
import { GenerateTraefikMe } from "./generate-traefikme";
import { GenerateWildCard } from "./generate-wildcard";
interface Props {
applicationId: string;
}
export const GenerateDomain = ({ applicationId }: Props) => {
return (
<Dialog>
<DialogTrigger className="" asChild>
<Button variant="secondary">
Generate Domain
<RefreshCcw className="size-4 text-muted-foreground " />
</Button>
</DialogTrigger>
<DialogContent className="max-h-screen overflow-y-auto sm:max-w-2xl">
<DialogHeader>
<DialogTitle>Generate Domain</DialogTitle>
<DialogDescription>
Generate Domains for your applications
</DialogDescription>
</DialogHeader>
<div className="flex flex-col gap-4 w-full">
<ul className="flex flex-col gap-4">
<li className="flex flex-row items-center gap-4">
<div className="flex flex-col gap-2">
<div className="text-base font-bold">
1. Generate TraefikMe Domain
</div>
<div className="text-sm text-muted-foreground">
This option generates a free domain provided by{" "}
<Link
href="https://traefik.me"
className="text-primary"
target="_blank"
>
TraefikMe
</Link>
. We recommend using this for quick domain testing or if you
don't have a domain yet.
</div>
</div>
</li>
{/* <li className="flex flex-row items-center gap-4">
<div className="flex flex-col gap-2">
<div className="text-base font-bold">
2. Use Wildcard Domain
</div>
<div className="text-sm text-muted-foreground">
To use this option, you need to set up an 'A' record in your
domain provider. For example, create a record for
*.yourdomain.com.
</div>
</div>
</li> */}
</ul>
<div className="flex flex-row gap-4 w-full">
<GenerateTraefikMe applicationId={applicationId} />
{/* <GenerateWildCard applicationId={applicationId} /> */}
</div>
</div>
</DialogContent>
</Dialog>
);
};

View File

@ -1,69 +0,0 @@
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "@/components/ui/alert-dialog";
import { Button } from "@/components/ui/button";
import { api } from "@/utils/api";
import { RefreshCcw } from "lucide-react";
import React from "react";
import { toast } from "sonner";
interface Props {
applicationId: string;
}
export const GenerateTraefikMe = ({ applicationId }: Props) => {
const { mutateAsync, isLoading } = api.domain.generateDomain.useMutation();
const utils = api.useUtils();
return (
<AlertDialog>
<AlertDialogTrigger asChild>
<Button variant="secondary" isLoading={isLoading}>
Generate Domain
<RefreshCcw className="size-4 text-muted-foreground " />
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
Are you sure to generate a new domain?
</AlertDialogTitle>
<AlertDialogDescription>
This will generate a new domain and will be used to access to the
application
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={async () => {
await mutateAsync({
applicationId,
})
.then((data) => {
utils.domain.byApplicationId.invalidate({
applicationId: applicationId,
});
utils.application.readTraefikConfig.invalidate({
applicationId: applicationId,
});
toast.success("Generated Domain succesfully");
})
.catch(() => {
toast.error("Error to generate Domain");
});
}}
>
Confirm
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
};

View File

@ -26,6 +26,7 @@ export const ShowDomainsCompose = ({ composeId }: Props) => {
enabled: !!composeId, enabled: !!composeId,
}, },
); );
return ( return (
<div className="flex w-full flex-col gap-5 "> <div className="flex w-full flex-col gap-5 ">
<Card className="bg-background"> <Card className="bg-background">
@ -45,9 +46,6 @@ export const ShowDomainsCompose = ({ composeId }: Props) => {
</Button> </Button>
</AddDomainCompose> </AddDomainCompose>
)} )}
{/* {data && data?.length > 0 && (
<GenerateDomain composeId={composeId} />
)} */}
</div> </div>
</CardHeader> </CardHeader>
<CardContent className="flex w-full flex-row gap-4"> <CardContent className="flex w-full flex-row gap-4">
@ -64,8 +62,6 @@ export const ShowDomainsCompose = ({ composeId }: Props) => {
<GlobeIcon className="size-4" /> Add Domain <GlobeIcon className="size-4" /> Add Domain
</Button> </Button>
</AddDomainCompose> </AddDomainCompose>
{/* <GenerateDomain composeId={composeId} /> */}
</div> </div>
</div> </div>
) : ( ) : (

View File

@ -30,9 +30,9 @@ export const ShowProviderFormCompose = ({ composeId }: Props) => {
Select the source of your code Select the source of your code
</p> </p>
</div> </div>
<div className="hidden space-y-1 text-sm font-normal md:block"> {/* <div className="hidden space-y-1 text-sm font-normal md:block">
<GitBranch className="size-6 text-muted-foreground" /> <GitBranch className="size-6 text-muted-foreground" />
</div> </div> */}
<ShowConvertedCompose composeId={composeId} /> <ShowConvertedCompose composeId={composeId} />
</CardTitle> </CardTitle>
</CardHeader> </CardHeader>

View File

@ -0,0 +1,15 @@
DO $$ BEGIN
CREATE TYPE "public"."domainType" AS ENUM('compose', 'application');
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
--> statement-breakpoint
ALTER TABLE "domain" ALTER COLUMN "applicationId" DROP NOT NULL;--> statement-breakpoint
ALTER TABLE "domain" ADD COLUMN "serviceName" text;--> statement-breakpoint
ALTER TABLE "domain" ADD COLUMN "domainType" "domainType" DEFAULT 'application';--> statement-breakpoint
ALTER TABLE "domain" ADD COLUMN "composeId" text;--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "domain" ADD CONSTRAINT "domain_composeId_compose_composeId_fk" FOREIGN KEY ("composeId") REFERENCES "public"."compose"("composeId") ON DELETE cascade ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;

View File

@ -0,0 +1 @@
ALTER TABLE "domain" ALTER COLUMN "port" SET DEFAULT 3000;

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -218,6 +218,20 @@
"when": 1723608499147, "when": 1723608499147,
"tag": "0030_little_kabuki", "tag": "0030_little_kabuki",
"breakpoints": true "breakpoints": true
},
{
"idx": 31,
"version": "6",
"when": 1723701656243,
"tag": "0031_steep_vulture",
"breakpoints": true
},
{
"idx": 32,
"version": "6",
"when": 1723705257806,
"tag": "0032_flashy_shadow_king",
"breakpoints": true
} }
] ]
} }

View File

@ -1,6 +1,7 @@
import { createTRPCRouter, protectedProcedure } from "@/server/api/trpc"; import { createTRPCRouter, protectedProcedure } from "@/server/api/trpc";
import { import {
apiCreateDomain, apiCreateDomain,
apiCreateTraefikMeDomain,
apiFindCompose, apiFindCompose,
apiFindDomain, apiFindDomain,
apiFindDomainByApplication, apiFindDomainByApplication,
@ -16,7 +17,7 @@ import {
findDomainById, findDomainById,
findDomainsByApplicationId, findDomainsByApplicationId,
findDomainsByComposeId, findDomainsByComposeId,
generateDomain, generateTraefikMeDomain,
generateWildcard, generateWildcard,
removeDomainById, removeDomainById,
updateDomainById, updateDomainById,
@ -47,9 +48,9 @@ export const domainRouter = createTRPCRouter({
return await findDomainsByComposeId(input.composeId); return await findDomainsByComposeId(input.composeId);
}), }),
generateDomain: protectedProcedure generateDomain: protectedProcedure
.input(apiFindDomainByApplication) .input(apiCreateTraefikMeDomain)
.mutation(async ({ input }) => { .mutation(async ({ input }) => {
return generateDomain(input); return generateTraefikMeDomain(input.appName);
}), }),
generateWildcard: protectedProcedure generateWildcard: protectedProcedure
.input(apiFindDomainByApplication) .input(apiFindDomainByApplication)

View File

@ -37,48 +37,34 @@ export const createDomain = async (input: typeof apiCreateDomain._type) => {
}); });
}; };
export const generateDomain = async ( export const generateTraefikMeDomain = async (appName: string) => {
input: typeof apiFindDomainByApplication._type,
) => {
const application = await findApplicationById(input.applicationId);
const admin = await findAdmin(); const admin = await findAdmin();
const domain = await createDomain({ return generateRandomDomain({
applicationId: application.applicationId, serverIp: admin.serverIp || "",
host: generateRandomDomain({ projectName: appName,
serverIp: admin.serverIp || "",
projectName: application.appName,
}),
port: 3000,
certificateType: "none",
https: false,
path: "/",
}); });
return domain;
}; };
export const generateWildcard = async ( export const generateWildcard = async (
input: typeof apiFindDomainByApplication._type, input: typeof apiFindDomainByApplication._type,
) => { ) => {
const application = await findApplicationById(input.applicationId); // const application = await findApplicationById(input.applicationId);
const admin = await findAdmin(); // const admin = await findAdmin();
// if (!admin.host) {
if (!admin.host) { // throw new TRPCError({
throw new TRPCError({ // code: "BAD_REQUEST",
code: "BAD_REQUEST", // message: "We need a host to generate a wildcard domain",
message: "We need a host to generate a wildcard domain", // });
}); // }
} // const domain = await createDomain({
const domain = await createDomain({ // applicationId: application.applicationId,
applicationId: application.applicationId, // host: generateWildcardDomain(application.appName, admin.host || ""),
host: generateWildcardDomain(application.appName, admin.host || ""), // port: 3000,
port: 3000, // certificateType: "none",
certificateType: "none", // https: false,
https: false, // path: "/",
path: "/", // });
}); // return domain;
return domain;
}; };
export const generateWildcardDomain = ( export const generateWildcardDomain = (

View File

@ -10,6 +10,7 @@ import {
} from "drizzle-orm/pg-core"; } from "drizzle-orm/pg-core";
import { createInsertSchema } from "drizzle-zod"; import { createInsertSchema } from "drizzle-zod";
import { nanoid } from "nanoid"; import { nanoid } from "nanoid";
import { z } from "zod";
import { applications } from "./application"; import { applications } from "./application";
import { compose } from "./compose"; import { compose } from "./compose";
import { certificateType } from "./shared"; import { certificateType } from "./shared";
@ -23,7 +24,7 @@ export const domains = pgTable("domain", {
.$defaultFn(() => nanoid()), .$defaultFn(() => nanoid()),
host: text("host").notNull(), host: text("host").notNull(),
https: boolean("https").notNull().default(false), https: boolean("https").notNull().default(false),
port: integer("port").default(80), port: integer("port").default(3000),
path: text("path").default("/"), path: text("path").default("/"),
serviceName: text("serviceName"), serviceName: text("serviceName"),
domainType: domainType("domainType").default("application"), domainType: domainType("domainType").default("application"),
@ -76,6 +77,10 @@ export const apiFindDomainByApplication = createSchema.pick({
applicationId: true, applicationId: true,
}); });
export const apiCreateTraefikMeDomain = createSchema.pick({}).extend({
appName: z.string().min(1),
});
export const apiFindDomainByCompose = createSchema.pick({ export const apiFindDomainByCompose = createSchema.pick({
composeId: true, composeId: true,
}); });

View File

@ -61,9 +61,7 @@ export const addDomainToCompose = async (
const result = await loadDockerCompose(compose); const result = await loadDockerCompose(compose);
if (!result) { if (!result) {
throw new Error( return null;
"Error to load docker compose, the file is empty or not found",
);
} }
for (const domain of domains) { for (const domain of domains) {
@ -89,10 +87,14 @@ export const addDomainToCompose = async (
} }
if (Array.isArray(result.services[serviceName].labels)) { if (Array.isArray(result.services[serviceName].labels)) {
result.services[serviceName].labels.push( const haveTraefikEnableLabel = result.services[
"traefik.enable=true", serviceName
...httpLabels, ].labels.includes("traefik.enable=true");
);
if (!haveTraefikEnableLabel) {
result.services[serviceName].labels.push("traefik.enable=true");
}
result.services[serviceName].labels.push(...httpLabels);
} }
} }