diff --git a/.gitignore b/.gitignore index 4e788d919..7acbe51a2 100644 --- a/.gitignore +++ b/.gitignore @@ -52,6 +52,7 @@ yarn-error.log* # otros /.data /.main +.vscode *.lockb *.rdb diff --git a/__test__/traefik/traefik.test.ts b/__test__/traefik/traefik.test.ts new file mode 100644 index 000000000..ce35611f3 --- /dev/null +++ b/__test__/traefik/traefik.test.ts @@ -0,0 +1,187 @@ +import type { Domain } from "@/server/api/services/domain"; +import type { Redirect } from "@/server/api/services/redirect"; +import type { ApplicationNested } from "@/server/utils/builders"; +import { createRouterConfig } from "@/server/utils/traefik/domain"; +import { expect, test } from "vitest"; + +const baseApp: ApplicationNested = { + applicationId: "", + applicationStatus: "done", + appName: "", + autoDeploy: true, + branch: null, + buildArgs: null, + buildPath: "/", + buildType: "nixpacks", + command: null, + cpuLimit: null, + cpuReservation: null, + createdAt: "", + customGitBranch: "", + customGitBuildPath: "", + customGitSSHKeyId: null, + customGitUrl: "", + description: "", + dockerfile: null, + dockerImage: null, + dropBuildPath: null, + enabled: null, + env: null, + healthCheckSwarm: null, + labelsSwarm: null, + memoryLimit: null, + memoryReservation: null, + modeSwarm: null, + mounts: [], + name: "", + networkSwarm: null, + owner: null, + password: null, + placementSwarm: null, + ports: [], + projectId: "", + redirects: [], + refreshToken: "", + registry: null, + registryId: null, + replicas: 1, + repository: null, + restartPolicySwarm: null, + rollbackConfigSwarm: null, + security: [], + sourceType: "git", + subtitle: null, + title: null, + updateConfigSwarm: null, + username: null, +}; + +const baseDomain: Domain = { + applicationId: "", + certificateType: "none", + createdAt: "", + domainId: "", + host: "", + https: false, + path: null, + port: null, + uniqueConfigKey: 1, +}; + +const baseRedirect: Redirect = { + redirectId: "", + regex: "", + replacement: "", + permanent: false, + uniqueConfigKey: 1, + createdAt: "", + applicationId: "", +}; + +/** Middlewares */ + +test("Web entrypoint on http domain", async () => { + const router = await createRouterConfig( + baseApp, + { ...baseDomain, https: false }, + "web", + ); + + expect(router.middlewares).not.toContain("redirect-to-https"); +}); + +test("Web entrypoint on http domain with redirect", async () => { + const router = await createRouterConfig( + { + ...baseApp, + appName: "test", + redirects: [{ ...baseRedirect, uniqueConfigKey: 1 }], + }, + { ...baseDomain, https: false }, + "web", + ); + + expect(router.middlewares).not.toContain("redirect-to-https"); + expect(router.middlewares).toContain("redirect-test-1"); +}); + +test("Web entrypoint on http domain with multiple redirect", async () => { + const router = await createRouterConfig( + { + ...baseApp, + appName: "test", + redirects: [ + { ...baseRedirect, uniqueConfigKey: 1 }, + { ...baseRedirect, uniqueConfigKey: 2 }, + ], + }, + { ...baseDomain, https: false }, + "web", + ); + + expect(router.middlewares).not.toContain("redirect-to-https"); + expect(router.middlewares).toContain("redirect-test-1"); + expect(router.middlewares).toContain("redirect-test-2"); +}); + +test("Web entrypoint on https domain", async () => { + const router = await createRouterConfig( + baseApp, + { ...baseDomain, https: true }, + "web", + ); + + expect(router.middlewares).toContain("redirect-to-https"); +}); + +test("Web entrypoint on https domain with redirect", async () => { + const router = await createRouterConfig( + { + ...baseApp, + appName: "test", + redirects: [{ ...baseRedirect, uniqueConfigKey: 1 }], + }, + { ...baseDomain, https: true }, + "web", + ); + + expect(router.middlewares).toContain("redirect-to-https"); + expect(router.middlewares).not.toContain("redirect-test-1"); +}); + +test("Websecure entrypoint on https domain", async () => { + const router = await createRouterConfig( + baseApp, + { ...baseDomain, https: true }, + "websecure", + ); + + expect(router.middlewares).not.toContain("redirect-to-https"); +}); + +test("Websecure entrypoint on https domain with redirect", async () => { + const router = await createRouterConfig( + { + ...baseApp, + appName: "test", + redirects: [{ ...baseRedirect, uniqueConfigKey: 1 }], + }, + { ...baseDomain, https: true }, + "websecure", + ); + + expect(router.middlewares).not.toContain("redirect-to-https"); + expect(router.middlewares).toContain("redirect-test-1"); +}); + +/** Certificates */ + +test("CertificateType on websecure entrypoint", async () => { + const router = await createRouterConfig( + baseApp, + { ...baseDomain, certificateType: "letsencrypt" }, + "websecure", + ); + + expect(router.tls?.certResolver).toBe("letsencrypt"); +}); diff --git a/components/dashboard/application/domains/add-domain.tsx b/components/dashboard/application/domains/add-domain.tsx index 17adf2755..71e44f929 100644 --- a/components/dashboard/application/domains/add-domain.tsx +++ b/components/dashboard/application/domains/add-domain.tsx @@ -28,84 +28,103 @@ import { } from "@/components/ui/select"; import { Switch } from "@/components/ui/switch"; import { api } from "@/utils/api"; -import { zodResolver } from "@hookform/resolvers/zod"; -import { PlusIcon } from "lucide-react"; -import { useEffect } from "react"; +import { useEffect, useState } from "react"; import { useForm } from "react-hook-form"; import { toast } from "sonner"; -import { z } from "zod"; -// const hostnameRegex = /^[a-zA-Z0-9][a-zA-Z0-9\.-]*\.[a-zA-Z]{2,}$/; -// .regex(hostnameRegex -const addDomain = z.object({ - host: z.string().min(1, "Hostname is required"), - path: z.string().min(1), - port: z.number(), - https: z.boolean(), - certificateType: z.enum(["letsencrypt", "none"]), -}); +import { domain } from "@/server/db/validations"; +import { zodResolver } from "@hookform/resolvers/zod"; +import type z from "zod"; -type AddDomain = z.infer; +type Domain = z.infer; interface Props { applicationId: string; - children?: React.ReactNode; + domainId?: string; + children: React.ReactNode; } export const AddDomain = ({ applicationId, - children = , + domainId = "", + children, }: Props) => { + const [isOpen, setIsOpen] = useState(false); const utils = api.useUtils(); + const { data, refetch } = api.domain.one.useQuery( + { + domainId, + }, + { + enabled: !!domainId, + }, + ); - const { mutateAsync, isError, error } = api.domain.create.useMutation(); + const { mutateAsync, isError, error, isLoading } = domainId + ? api.domain.update.useMutation() + : api.domain.create.useMutation(); - const form = useForm({ - defaultValues: { - host: "", - https: false, - path: "/", - port: 3000, - certificateType: "none", - }, - resolver: zodResolver(addDomain), + const form = useForm({ + resolver: zodResolver(domain), }); useEffect(() => { - form.reset(); - }, [form, form.reset, form.formState.isSubmitSuccessful]); + if (data) { + form.reset({ + ...data, + /* Convert null to undefined */ + path: data?.path || undefined, + port: data?.port || undefined, + }); + } + + if (!domainId) { + form.reset({}); + } + }, [form, form.reset, data, isLoading]); + + const dictionary = { + success: domainId ? "Domain Updated" : "Domain Created", + error: domainId + ? "Error to update the domain" + : "Error to create 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: AddDomain) => { + const onSubmit = async (data: Domain) => { await mutateAsync({ + domainId, applicationId, - host: data.host, - https: data.https, - path: data.path, - port: data.port, - certificateType: data.certificateType, + ...data, }) .then(async () => { - toast.success("Domain Created"); + toast.success(dictionary.success); await utils.domain.byApplicationId.invalidate({ applicationId, }); await utils.application.readTraefikConfig.invalidate({ applicationId }); + + if (domainId) { + refetch(); + } + setIsOpen(false); }) .catch(() => { - toast.error("Error to create the domain"); + toast.error(dictionary.error); }); }; return ( - + - + {children} Domain - - In this section you can add custom domains - + {dictionary.dialogDescription} {isError && {error?.message}} @@ -169,33 +188,36 @@ export const AddDomain = ({ ); }} /> - ( - - Certificate - + + + + + + + + None + + Letsencrypt (Default) + + + + + + )} + /> + )} - - None - - Letsencrypt (Default) - - - - - - )} - /> Automatically provision SSL Certificate. + - Create + {dictionary.submit} diff --git a/components/dashboard/application/domains/show-domains.tsx b/components/dashboard/application/domains/show-domains.tsx index 5aed35243..d7724ce72 100644 --- a/components/dashboard/application/domains/show-domains.tsx +++ b/components/dashboard/application/domains/show-domains.tsx @@ -8,13 +8,11 @@ import { } from "@/components/ui/card"; import { Input } from "@/components/ui/input"; import { api } from "@/utils/api"; -import { ExternalLink, GlobeIcon, RefreshCcw } from "lucide-react"; +import { ExternalLink, GlobeIcon, PenBoxIcon } from "lucide-react"; import Link from "next/link"; -import React from "react"; import { AddDomain } from "./add-domain"; import { DeleteDomain } from "./delete-domain"; import { GenerateDomain } from "./generate-domain"; -import { UpdateDomain } from "./update-domain"; interface Props { applicationId: string; @@ -43,7 +41,9 @@ export const ShowDomains = ({ applicationId }: Props) => {
{data && data?.length > 0 && ( - Add Domain + )} {data && data?.length > 0 && ( @@ -61,7 +61,9 @@ export const ShowDomains = ({ applicationId }: Props) => {
- Add Domain + @@ -90,7 +92,14 @@ export const ShowDomains = ({ applicationId }: Props) => { {item.https ? "HTTPS" : "HTTP"}
- + + +
diff --git a/components/dashboard/application/domains/update-domain.tsx b/components/dashboard/application/domains/update-domain.tsx deleted file mode 100644 index 6614a4803..000000000 --- a/components/dashboard/application/domains/update-domain.tsx +++ /dev/null @@ -1,254 +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 } 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 { zodResolver } from "@hookform/resolvers/zod"; -import { PenBoxIcon } from "lucide-react"; -import { useEffect } from "react"; -import { useForm } from "react-hook-form"; -import { toast } from "sonner"; -import { z } from "zod"; - -const hostnameRegex = /^[a-zA-Z0-9][a-zA-Z0-9\.-]*\.[a-zA-Z]{2,}$/; - -const updateDomain = z.object({ - host: z.string().regex(hostnameRegex, { message: "Invalid hostname" }), - path: z.string().min(1), - port: z - .number() - .min(1, { message: "Port must be at least 1" }) - .max(65535, { message: "Port must be 65535 or below" }), - https: z.boolean(), - certificateType: z.enum(["letsencrypt", "none"]), -}); - -type UpdateDomain = z.infer; - -interface Props { - domainId: string; -} - -export const UpdateDomain = ({ domainId }: Props) => { - const utils = api.useUtils(); - const { data, refetch } = api.domain.one.useQuery( - { - domainId, - }, - { - enabled: !!domainId, - }, - ); - const { mutateAsync, isError, error } = api.domain.update.useMutation(); - - const form = useForm({ - defaultValues: { - host: "", - https: true, - path: "/", - port: 3000, - certificateType: "none", - }, - resolver: zodResolver(updateDomain), - }); - - useEffect(() => { - if (data) { - form.reset({ - host: data.host || "", - port: data.port || 3000, - path: data.path || "/", - https: data.https, - certificateType: data.certificateType, - }); - } - }, [form, form.reset, data]); - - const onSubmit = async (data: UpdateDomain) => { - await mutateAsync({ - domainId, - host: data.host, - https: data.https, - path: data.path, - port: data.port, - certificateType: data.certificateType, - }) - .then(async (data) => { - toast.success("Domain Updated"); - await refetch(); - await utils.domain.byApplicationId.invalidate({ - applicationId: data?.applicationId, - }); - await utils.application.readTraefikConfig.invalidate({ - applicationId: data?.applicationId, - }); - }) - .catch(() => { - toast.error("Error to update the domain"); - }); - }; - return ( - - - - - - - Domain - - In this section you can add custom domains - - - {isError && {error?.message}} - -
- -
-
- ( - - Host - - - - - - - )} - /> - - { - return ( - - Path - - - - - - ); - }} - /> - - { - return ( - - Container Port - - { - field.onChange(Number.parseInt(e.target.value)); - }} - /> - - - - ); - }} - /> - ( - - Certificate - - - - )} - /> - ( - -
- HTTPS - - Automatically provision SSL Certificate. - -
- - - -
- )} - /> -
-
-
- - - - - -
-
- ); -}; diff --git a/components/dashboard/application/environment/show.tsx b/components/dashboard/application/environment/show.tsx index 359142c53..9b38f1ced 100644 --- a/components/dashboard/application/environment/show.tsx +++ b/components/dashboard/application/environment/show.tsx @@ -1,30 +1,16 @@ -import { CodeEditor } from "@/components/shared/code-editor"; import { Button } from "@/components/ui/button"; -import { - Card, - CardContent, - CardDescription, - CardHeader, - CardTitle, -} from "@/components/ui/card"; -import { - Form, - FormControl, - FormField, - FormItem, - FormMessage, -} from "@/components/ui/form"; -import { Toggle } from "@/components/ui/toggle"; +import { Card, CardContent } from "@/components/ui/card"; +import { Form } from "@/components/ui/form"; +import { Secrets } from "@/components/ui/secrets"; import { api } from "@/utils/api"; import { zodResolver } from "@hookform/resolvers/zod"; -import { EyeIcon, EyeOffIcon } from "lucide-react"; -import React, { useEffect, useState } from "react"; import { useForm } from "react-hook-form"; import { toast } from "sonner"; import { z } from "zod"; const addEnvironmentSchema = z.object({ - environment: z.string(), + env: z.string(), + buildArgs: z.string(), }); type EnvironmentSchema = z.infer; @@ -34,7 +20,6 @@ interface Props { } export const ShowEnvironment = ({ applicationId }: Props) => { - const [isEnvVisible, setIsEnvVisible] = useState(true); const { mutateAsync, isLoading } = api.application.saveEnvironment.useMutation(); @@ -46,24 +31,19 @@ export const ShowEnvironment = ({ applicationId }: Props) => { enabled: !!applicationId, }, ); + const form = useForm({ defaultValues: { - environment: "", + env: data?.env || "", + buildArgs: data?.buildArgs || "", }, resolver: zodResolver(addEnvironmentSchema), }); - useEffect(() => { - if (data) { - form.reset({ - environment: data.env || "", - }); - } - }, [form.reset, data, form]); - const onSubmit = async (data: EnvironmentSchema) => { mutateAsync({ - env: data.environment, + env: data.env, + buildArgs: data.buildArgs, applicationId, }) .then(async () => { @@ -74,94 +54,50 @@ export const ShowEnvironment = ({ applicationId }: Props) => { toast.error("Error to add environment"); }); }; - useEffect(() => { - if (isEnvVisible) { - if (data?.env) { - const maskedLines = data.env - .split("\n") - .map((line) => "*".repeat(line.length)) - .join("\n"); - form.reset({ - environment: maskedLines, - }); - } else { - form.reset({ - environment: "", - }); - } - } else { - form.reset({ - environment: data?.env || "", - }); - } - }, [form.reset, data, form, isEnvVisible]); return ( -
- - -
- Environment Settings - - You can add environment variables to your resource. - -
- - - {isEnvVisible ? ( - - ) : ( - - )} - -
- -
- - ( - - - - - - - - )} - /> - -
- -
- - -
-
-
+
+ + + + {data?.buildType === "dockerfile" && ( + + Available only at build-time. See documentation  + + here + + . + + } + placeholder="NPM_TOKEN=xyz" + /> + )} + +
+ +
+
+
+
+ ); }; diff --git a/components/dashboard/application/general/generic/save-git-provider.tsx b/components/dashboard/application/general/generic/save-git-provider.tsx index 28828e7cd..58fd1973a 100644 --- a/components/dashboard/application/general/generic/save-git-provider.tsx +++ b/components/dashboard/application/general/generic/save-git-provider.tsx @@ -1,13 +1,4 @@ import { Button } from "@/components/ui/button"; -import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, - DialogTrigger, -} from "@/components/ui/dialog"; import { Form, FormControl, @@ -17,11 +8,20 @@ import { FormMessage, } from "@/components/ui/form"; import { Input } from "@/components/ui/input"; -import { Textarea } from "@/components/ui/textarea"; +import { + Select, + SelectContent, + SelectGroup, + SelectItem, + SelectLabel, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; import { api } from "@/utils/api"; import { zodResolver } from "@hookform/resolvers/zod"; -import copy from "copy-to-clipboard"; -import { CopyIcon, LockIcon } from "lucide-react"; +import { KeyRoundIcon, LockIcon } from "lucide-react"; +import { useRouter } from "next/router"; + import { useEffect } from "react"; import { useForm } from "react-hook-form"; import { toast } from "sonner"; @@ -33,6 +33,7 @@ const GitProviderSchema = z.object({ }), branch: z.string().min(1, "Branch required"), buildPath: z.string().min(1, "Build Path required"), + sshKey: z.string().optional(), }); type GitProvider = z.infer; @@ -43,19 +44,18 @@ interface Props { export const SaveGitProvider = ({ applicationId }: Props) => { const { data, refetch } = api.application.one.useQuery({ applicationId }); + const { data: sshKeys } = api.sshKey.all.useQuery(); + const router = useRouter(); const { mutateAsync, isLoading } = api.application.saveGitProdiver.useMutation(); - const { mutateAsync: generateSSHKey, isLoading: isGeneratingSSHKey } = - api.application.generateSSHKey.useMutation(); - const { mutateAsync: removeSSHKey, isLoading: isRemovingSSHKey } = - api.application.removeSSHKey.useMutation(); const form = useForm({ defaultValues: { branch: "", buildPath: "/", repositoryURL: "", + sshKey: undefined, }, resolver: zodResolver(GitProviderSchema), }); @@ -63,6 +63,7 @@ export const SaveGitProvider = ({ applicationId }: Props) => { useEffect(() => { if (data) { form.reset({ + sshKey: data.customGitSSHKeyId || undefined, branch: data.customGitBranch || "", buildPath: data.customGitBuildPath || "/", repositoryURL: data.customGitUrl || "", @@ -75,6 +76,7 @@ export const SaveGitProvider = ({ applicationId }: Props) => { customGitBranch: values.branch, customGitBuildPath: values.buildPath, customGitUrl: values.repositoryURL, + customGitSSHKeyId: values.sshKey === "none" ? null : values.sshKey, applicationId, }) .then(async () => { @@ -92,160 +94,103 @@ export const SaveGitProvider = ({ applicationId }: Props) => { onSubmit={form.handleSubmit(onSubmit)} className="flex flex-col gap-4" > -
-
- ( - - - Repository URL -
- - - ? - - - - Private Repository - - If your repository is private is necessary to - generate SSH Keys to add to your git provider. - - -
-
-