"use client"; import { Button, buttonVariants } from "@app/components/ui/button"; import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from "@app/components/ui/form"; import { Input } from "@app/components/ui/input"; import { toast } from "@app/hooks/useToast"; import { zodResolver } from "@hookform/resolvers/zod"; import { useEffect, useState } from "react"; import { useForm } from "react-hook-form"; import { z } from "zod"; import { Credenza, CredenzaBody, CredenzaClose, CredenzaContent, CredenzaDescription, CredenzaFooter, CredenzaHeader, CredenzaTitle } from "@app/components/Credenza"; import { useParams, useRouter } from "next/navigation"; import { ListSitesResponse } from "@server/routers/site"; import { formatAxiosError } from "@app/lib/api"; import { CheckIcon } from "lucide-react"; import { Popover, PopoverContent, PopoverTrigger } from "@app/components/ui/popover"; import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@app/components/ui/command"; import { CaretSortIcon } from "@radix-ui/react-icons"; import CustomDomainInput from "./[resourceId]/CustomDomainInput"; import { AxiosResponse } from "axios"; import { Resource } from "@server/db/schema"; import { useOrgContext } from "@app/hooks/useOrgContext"; import { createApiClient } from "@app/lib/api"; import { useEnvContext } from "@app/hooks/useEnvContext"; import { cn } from "@app/lib/cn"; import { Switch } from "@app/components/ui/switch"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@app/components/ui/select"; import { subdomainSchema } from "@server/lib/schemas"; import Link from "next/link"; import { SquareArrowOutUpRight } from "lucide-react"; import CopyTextBox from "@app/components/CopyTextBox"; import { RadioGroup, RadioGroupItem } from "@app/components/ui/radio-group"; import { Label } from "@app/components/ui/label"; import { ListDomainsResponse } from "@server/routers/domain"; import LoaderPlaceholder from "@app/components/PlaceHolderLoader"; import { StrategySelect } from "@app/components/StrategySelect"; const createResourceFormSchema = z .object({ subdomain: z.string().optional(), domainId: z.string().min(1).optional(), name: z.string().min(1).max(255), siteId: z.number(), http: z.boolean(), protocol: z.string(), proxyPort: z.number().optional(), isBaseDomain: z.boolean().optional() }) .refine( (data) => { if (!data.http) { return z .number() .int() .min(1) .max(65535) .safeParse(data.proxyPort).success; } return true; }, { message: "Invalid port number", path: ["proxyPort"] } ) .refine( (data) => { if (data.http && !data.isBaseDomain) { return subdomainSchema.safeParse(data.subdomain).success; } return true; }, { message: "Invalid subdomain", path: ["subdomain"] } ); type CreateResourceFormValues = z.infer; type CreateResourceFormProps = { open: boolean; setOpen: (open: boolean) => void; }; export default function CreateResourceForm({ open, setOpen }: CreateResourceFormProps) { const [formKey, setFormKey] = useState(0); const api = createApiClient(useEnvContext()); const [loading, setLoading] = useState(false); const params = useParams(); const orgId = params.orgId; const router = useRouter(); const { org } = useOrgContext(); const { env } = useEnvContext(); const [sites, setSites] = useState([]); const [baseDomains, setBaseDomains] = useState< { domainId: string; baseDomain: string }[] >([]); const [showSnippets, setShowSnippets] = useState(false); const [resourceId, setResourceId] = useState(null); const [domainType, setDomainType] = useState<"subdomain" | "basedomain">( "subdomain" ); const [loadingPage, setLoadingPage] = useState(true); const form = useForm({ resolver: zodResolver(createResourceFormSchema), defaultValues: { subdomain: "", domainId: "", name: "", http: true, protocol: "tcp" } }); function reset() { form.reset(); setSites([]); setShowSnippets(false); setResourceId(null); } useEffect(() => { if (!open) { return; } reset(); const fetchSites = async () => { const res = await api .get>(`/org/${orgId}/sites/`) .catch((e) => { toast({ variant: "destructive", title: "Error fetching sites", description: formatAxiosError( e, "An error occurred when fetching the sites" ) }); }); if (res?.status === 200) { setSites(res.data.data.sites); if (res.data.data.sites.length > 0) { form.setValue("siteId", res.data.data.sites[0].siteId); } } }; const fetchDomains = async () => { const res = await api .get< AxiosResponse >(`/org/${orgId}/domains/`) .catch((e) => { toast({ variant: "destructive", title: "Error fetching domains", description: formatAxiosError( e, "An error occurred when fetching the domains" ) }); }); if (res?.status === 200) { const domains = res.data.data.domains; setBaseDomains(domains); if (domains.length) { form.setValue("domainId", domains[0].domainId); setFormKey((k) => k + 1); } } }; const load = async () => { setLoadingPage(true); await fetchSites(); await fetchDomains(); await new Promise((r) => setTimeout(r, 200)); setLoadingPage(false); }; load(); }, [open]); async function onSubmit(data: CreateResourceFormValues) { const res = await api .put>( `/org/${orgId}/site/${data.siteId}/resource/`, { name: data.name, subdomain: data.http ? data.subdomain : undefined, domainId: data.http ? data.domainId : undefined, http: data.http, protocol: data.protocol, proxyPort: data.http ? undefined : data.proxyPort, siteId: data.siteId, isBaseDomain: data.http ? data.isBaseDomain : undefined } ) .catch((e) => { toast({ variant: "destructive", title: "Error creating resource", description: formatAxiosError( e, "An error occurred when creating the resource" ) }); }); if (res && res.status === 201) { const id = res.data.data.resourceId; setResourceId(id); if (data.http) { goToResource(id); } else { setShowSnippets(true); router.refresh(); } } } function goToResource(id?: number) { // navigate to the resource page router.push(`/${orgId}/settings/resources/${id || resourceId}`); } const launchOptions = [ { id: "http", title: "HTTPS Resource", description: "Proxy requests to your app over HTTPS using a subdomain or base domain." }, { id: "raw", title: "Raw TCP/UDP Resource", description: "Proxy requests to your app over TCP/UDP using a port number." } ]; return ( <> { setOpen(val); setLoading(false); // reset all values form.reset(); }} > Create Resource Create a new resource to proxy requests to your app {loadingPage ? ( ) : (
{!showSnippets && (
( Name )} /> ( Site No site found. {sites.map( ( site ) => ( { form.setValue( "siteId", site.siteId ); }} > { site.name } ) )} This site will provide connectivity to the resource. )} /> {!env.flags.allowRawResources || (
Resource Type form.setValue( "http", value === "http" ) } /> You cannot change the type of resource after creation.
)} {form.watch("http") && env.flags .allowBaseDomainResources && ( ( Domain Type )} /> )} {form.watch("http") && ( <> {domainType === "subdomain" ? (
Subdomain
( )} />
( )} />
) : ( ( Base Domain )} /> )} )} {!form.watch("http") && ( <> ( Protocol )} /> ( Port Number field.onChange( e .target .value ? parseInt( e .target .value ) : null ) } /> The external port number to proxy requests. )} /> )} )} {showSnippets && (

Traefik: Add Entrypoints

Gerbil: Expose Ports in Docker Compose

Learn how to configure TCP/UDP resources
)}
)}
{!showSnippets && ( )} {showSnippets && ( )}
); }