"use client"; import { SettingsContainer, SettingsSection, SettingsSectionBody, SettingsSectionDescription, SettingsSectionForm, SettingsSectionHeader, SettingsSectionTitle } from "@app/components/Settings"; import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from "@app/components/ui/form"; import HeaderTitle from "@app/components/SettingsSectionTitle"; import { z } from "zod"; import { useEffect, useState } from "react"; import { Controller, useForm } from "react-hook-form"; import { zodResolver } from "@hookform/resolvers/zod"; import { Input } from "@app/components/ui/input"; import { Button } from "@app/components/ui/button"; import { Checkbox } from "@app/components/ui/checkbox"; import { useParams, useRouter } from "next/navigation"; import { ListSitesResponse } from "@server/routers/site"; import { formatAxiosError } from "@app/lib/api"; import { createApiClient } from "@app/lib/api"; import { useEnvContext } from "@app/hooks/useEnvContext"; import { toast } from "@app/hooks/useToast"; import { AxiosResponse } from "axios"; import { Resource } from "@server/db"; import { StrategySelect } from "@app/components/StrategySelect"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@app/components/ui/select"; import { ListDomainsResponse } from "@server/routers/domain"; import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@app/components/ui/command"; import { Popover, PopoverContent, PopoverTrigger } from "@app/components/ui/popover"; import { CaretSortIcon, CheckIcon } from "@radix-ui/react-icons"; import { cn } from "@app/lib/cn"; import { ArrowRight, MoveRight, SquareArrowOutUpRight } from "lucide-react"; import CopyTextBox from "@app/components/CopyTextBox"; import Link from "next/link"; import { useTranslations } from "next-intl"; import DomainPicker from "@app/components/DomainPicker"; import { build } from "@server/build"; import { ContainersSelector } from "@app/components/ContainersSelector"; import { ColumnDef, getFilteredRowModel, getSortedRowModel, getPaginationRowModel, getCoreRowModel, useReactTable, flexRender, Row } from "@tanstack/react-table"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@app/components/ui/table"; import { Switch } from "@app/components/ui/switch"; import { ArrayElement } from "@server/types/ArrayElement"; import { isTargetValid } from "@server/lib/validators"; import { ListTargetsResponse } from "@server/routers/target"; import { DockerManager, DockerState } from "@app/lib/docker"; import { parseHostTarget } from "@app/lib/parseHostTarget"; import { toASCII, toUnicode } from 'punycode'; import { DomainRow } from "../../../../../components/DomainsTable"; import { finalizeSubdomainSanitize } from "@app/lib/subdomain-utils"; const baseResourceFormSchema = z.object({ name: z.string().min(1).max(255), http: z.boolean() }); const httpResourceFormSchema = z.object({ domainId: z.string().nonempty(), subdomain: z.string().optional() }); const tcpUdpResourceFormSchema = z.object({ protocol: z.string(), proxyPort: z.number().int().min(1).max(65535) // enableProxy: z.boolean().default(false) }); const addTargetSchema = z.object({ ip: z.string().refine(isTargetValid), method: z.string().nullable(), port: z.coerce.number().int().positive(), siteId: z.number().int().positive(), path: z.string().optional().nullable(), pathMatchType: z.enum(["exact", "prefix", "regex"]).optional().nullable() }).refine( (data) => { // If path is provided, pathMatchType must be provided if (data.path && !data.pathMatchType) { return false; } // If pathMatchType is provided, path must be provided if (data.pathMatchType && !data.path) { return false; } // Validate path based on pathMatchType if (data.path && data.pathMatchType) { switch (data.pathMatchType) { case "exact": case "prefix": // Path should start with / return data.path.startsWith("/"); case "regex": // Validate regex try { new RegExp(data.path); return true; } catch { return false; } } } return true; }, { message: "Invalid path configuration" } ); type BaseResourceFormValues = z.infer; type HttpResourceFormValues = z.infer; type TcpUdpResourceFormValues = z.infer; type ResourceType = "http" | "raw"; interface ResourceTypeOption { id: ResourceType; title: string; description: string; disabled?: boolean; } type LocalTarget = Omit< ArrayElement & { new?: boolean; updated?: boolean; siteType: string | null; }, "protocol" >; export default function Page() { const { env } = useEnvContext(); const api = createApiClient({ env }); const { orgId } = useParams(); const router = useRouter(); const t = useTranslations(); const [loadingPage, setLoadingPage] = useState(true); const [sites, setSites] = useState([]); const [baseDomains, setBaseDomains] = useState< { domainId: string; baseDomain: string }[] >([]); const [createLoading, setCreateLoading] = useState(false); const [showSnippets, setShowSnippets] = useState(false); const [resourceId, setResourceId] = useState(null); // Target management state const [targets, setTargets] = useState([]); const [targetsToRemove, setTargetsToRemove] = useState([]); const [dockerStates, setDockerStates] = useState>(new Map()); const resourceTypes: ReadonlyArray = [ { id: "http", title: t("resourceHTTP"), description: t("resourceHTTPDescription") }, ...(!env.flags.allowRawResources ? [] : [ { id: "raw" as ResourceType, title: t("resourceRaw"), description: t("resourceRawDescription") } ]) ]; const baseForm = useForm({ resolver: zodResolver(baseResourceFormSchema), defaultValues: { name: "", http: true } }); const httpForm = useForm({ resolver: zodResolver(httpResourceFormSchema), defaultValues: {} }); const tcpUdpForm = useForm({ resolver: zodResolver(tcpUdpResourceFormSchema), defaultValues: { protocol: "tcp", proxyPort: undefined // enableProxy: false } }); const addTargetForm = useForm({ resolver: zodResolver(addTargetSchema), defaultValues: { ip: "", method: baseForm.watch("http") ? "http" : null, port: "" as any as number, path: null, pathMatchType: null } as z.infer }); const watchedIp = addTargetForm.watch("ip"); const watchedPort = addTargetForm.watch("port"); const watchedSiteId = addTargetForm.watch("siteId"); const handleContainerSelect = (hostname: string, port?: number) => { addTargetForm.setValue("ip", hostname); if (port) { addTargetForm.setValue("port", port); } }; const initializeDockerForSite = async (siteId: number) => { if (dockerStates.has(siteId)) { return; // Already initialized } const dockerManager = new DockerManager(api, siteId); const dockerState = await dockerManager.initializeDocker(); setDockerStates(prev => new Map(prev.set(siteId, dockerState))); }; const refreshContainersForSite = async (siteId: number) => { const dockerManager = new DockerManager(api, siteId); const containers = await dockerManager.fetchContainers(); setDockerStates(prev => { const newMap = new Map(prev); const existingState = newMap.get(siteId); if (existingState) { newMap.set(siteId, { ...existingState, containers }); } return newMap; }); }; const getDockerStateForSite = (siteId: number): DockerState => { return dockerStates.get(siteId) || { isEnabled: false, isAvailable: false, containers: [] }; }; async function addTarget(data: z.infer) { // Check if target with same IP, port and method already exists const isDuplicate = targets.some( (target) => target.ip === data.ip && target.port === data.port && target.method === data.method && target.siteId === data.siteId ); if (isDuplicate) { toast({ variant: "destructive", title: t("targetErrorDuplicate"), description: t("targetErrorDuplicateDescription") }); return; } const site = sites.find((site) => site.siteId === data.siteId); const newTarget: LocalTarget = { ...data, path: data.path || null, pathMatchType: data.pathMatchType || null, siteType: site?.type || null, enabled: true, targetId: new Date().getTime(), new: true, resourceId: 0 // Will be set when resource is created }; setTargets([...targets, newTarget]); addTargetForm.reset({ ip: "", method: baseForm.watch("http") ? "http" : null, port: "" as any as number, path: null, pathMatchType: null }); } const removeTarget = (targetId: number) => { setTargets([ ...targets.filter((target) => target.targetId !== targetId) ]); if (!targets.find((target) => target.targetId === targetId)?.new) { setTargetsToRemove([...targetsToRemove, targetId]); } }; async function updateTarget(targetId: number, data: Partial) { const site = sites.find((site) => site.siteId === data.siteId); setTargets( targets.map((target) => target.targetId === targetId ? { ...target, ...data, updated: true, siteType: site?.type || null } : target ) ); } async function onSubmit() { setCreateLoading(true); const baseData = baseForm.getValues(); const isHttp = baseData.http; try { const payload = { name: baseData.name, http: baseData.http }; let sanitizedSubdomain: string | undefined; if (isHttp) { const httpData = httpForm.getValues(); sanitizedSubdomain = httpData.subdomain ? finalizeSubdomainSanitize(httpData.subdomain) : undefined; Object.assign(payload, { subdomain: sanitizedSubdomain ? toASCII(sanitizedSubdomain) : undefined, domainId: httpData.domainId, protocol: "tcp" }); } else { const tcpUdpData = tcpUdpForm.getValues(); Object.assign(payload, { protocol: tcpUdpData.protocol, proxyPort: tcpUdpData.proxyPort // enableProxy: tcpUdpData.enableProxy }); } const res = await api .put< AxiosResponse >(`/org/${orgId}/resource/`, payload) .catch((e) => { toast({ variant: "destructive", title: t("resourceErrorCreate"), description: formatAxiosError( e, t("resourceErrorCreateDescription") ) }); }); if (res && res.status === 201) { const id = res.data.data.resourceId; const niceId = res.data.data.niceId; setResourceId(id); // Create targets if any exist if (targets.length > 0) { try { for (const target of targets) { const data = { ip: target.ip, port: target.port, method: target.method, enabled: target.enabled, siteId: target.siteId, path: target.path, pathMatchType: target.pathMatchType }; await api.put(`/resource/${id}/target`, data); } } catch (targetError) { console.error("Error creating targets:", targetError); toast({ variant: "destructive", title: t("targetErrorCreate"), description: formatAxiosError( targetError, t("targetErrorCreateDescription") ) }); } } if (isHttp) { router.push(`/${orgId}/settings/resources/${niceId}`); } else { const tcpUdpData = tcpUdpForm.getValues(); // Only show config snippets if enableProxy is explicitly true // if (tcpUdpData.enableProxy === true) { setShowSnippets(true); router.refresh(); // } else { // // If enableProxy is false or undefined, go directly to resource page // router.push(`/${orgId}/settings/resources/${id}`); // } } } } catch (e) { console.error(t("resourceErrorCreateMessage"), e); toast({ variant: "destructive", title: t("resourceErrorCreate"), description: t("resourceErrorCreateMessageDescription") }); } setCreateLoading(false); } useEffect(() => { const load = async () => { setLoadingPage(true); const fetchSites = async () => { const res = await api .get< AxiosResponse >(`/org/${orgId}/sites/`) .catch((e) => { toast({ variant: "destructive", title: t("sitesErrorFetch"), description: formatAxiosError( e, t("sitesErrorFetchDescription") ) }); }); if (res?.status === 200) { setSites(res.data.data.sites); // Initialize Docker for newt sites for (const site of res.data.data.sites) { if (site.type === "newt") { initializeDockerForSite(site.siteId); } } // If there's only one site, set it as the default in the form if (res.data.data.sites.length) { addTargetForm.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: t("domainsErrorFetch"), description: formatAxiosError( e, t("domainsErrorFetchDescription") ) }); }); if (res?.status === 200) { const rawDomains = res.data.data.domains as DomainRow[]; const domains = rawDomains.map((domain) => ({ ...domain, baseDomain: toUnicode(domain.baseDomain), })); setBaseDomains(domains); // if (domains.length) { // httpForm.setValue("domainId", domains[0].domainId); // } } }; await fetchSites(); await fetchDomains(); setLoadingPage(false); }; load(); }, []); const columns: ColumnDef[] = [ { accessorKey: "path", header: t("matchPath"), cell: ({ row }) => { const [showPathInput, setShowPathInput] = useState( !!(row.original.path || row.original.pathMatchType) ); if (!showPathInput) { return ( ); } return (
{ const value = e.target.value.trim(); if (!value) { setShowPathInput(false); updateTarget(row.original.targetId, { ...row.original, path: null, pathMatchType: null }); } else { updateTarget(row.original.targetId, { ...row.original, path: value }); } }} />
); } }, { accessorKey: "siteId", header: t("site"), cell: ({ row }) => { const selectedSite = sites.find( (site) => site.siteId === row.original.siteId ); const handleContainerSelectForTarget = ( hostname: string, port?: number ) => { updateTarget(row.original.targetId, { ...row.original, ip: hostname }); if (port) { updateTarget(row.original.targetId, { ...row.original, port: port }); } }; return (
{t("siteNotFound")} {sites.map((site) => ( { updateTarget( row.original .targetId, { siteId: site.siteId } ); }} > {site.name} ))} {selectedSite && selectedSite.type === "newt" && (() => { const dockerState = getDockerStateForSite(selectedSite.siteId); return ( refreshContainersForSite(selectedSite.siteId)} /> ); })()}
); } }, ...(baseForm.watch("http") ? [ { accessorKey: "method", header: t("method"), cell: ({ row }: { row: Row }) => ( ) } ] : []), { accessorKey: "ip", header: t("targetAddr"), cell: ({ row }) => ( { const input = e.target.value.trim(); const hasProtocol = /^(https?|h2c):\/\//.test(input); const hasPort = /:\d+(?:\/|$)/.test(input); if (hasProtocol || hasPort) { const parsed = parseHostTarget(input); if (parsed) { updateTarget(row.original.targetId, { ...row.original, method: hasProtocol ? parsed.protocol : row.original.method, ip: parsed.host, port: hasPort ? parsed.port : row.original.port }); } else { updateTarget(row.original.targetId, { ...row.original, ip: input }); } } else { updateTarget(row.original.targetId, { ...row.original, ip: input }); } }} /> ) }, { accessorKey: "port", header: t("targetPort"), cell: ({ row }) => ( updateTarget(row.original.targetId, { ...row.original, port: parseInt(e.target.value, 10) }) } /> ) }, { accessorKey: "enabled", header: t("enabled"), cell: ({ row }) => ( updateTarget(row.original.targetId, { ...row.original, enabled: val }) } /> ) }, { id: "actions", cell: ({ row }) => ( <>
) } ]; const table = useReactTable({ data: targets, columns, getCoreRowModel: getCoreRowModel(), getPaginationRowModel: getPaginationRowModel(), getSortedRowModel: getSortedRowModel(), getFilteredRowModel: getFilteredRowModel(), state: { pagination: { pageIndex: 0, pageSize: 1000 } } }); return ( <>
{!loadingPage && (
{!showSnippets ? ( {t("resourceInfo")}
{ if (e.key === "Enter") { e.preventDefault(); // block default enter refresh } }} className="space-y-4" id="base-resource-form" > ( {t("name")} {t( "resourceNameDescription" )} )} />
{resourceTypes.length > 1 && ( {t("resourceType")} {t("resourceTypeDescription")} { baseForm.setValue( "http", value === "http" ); // Update method default when switching resource type addTargetForm.setValue( "method", value === "http" ? "http" : null ); }} cols={2} /> )} {baseForm.watch("http") ? ( {t("resourceHTTPSSettings")} {t( "resourceHTTPSSettingsDescription" )} { httpForm.setValue( "subdomain", res.subdomain ); httpForm.setValue( "domainId", res.domainId ); console.log( "Domain changed:", res ); }} /> ) : ( {t("resourceRawSettings")} {t( "resourceRawSettingsDescription" )}
{ if (e.key === "Enter") { e.preventDefault(); // block default enter refresh } }} className="space-y-4" id="tcp-udp-settings-form" > ( {t( "protocol" )} )} /> ( {t( "resourcePortNumber" )} field.onChange( e .target .value ? parseInt( e .target .value ) : undefined ) } /> {t( "resourcePortNumberDescription" )} )} /> {/* {build == "oss" && ( (
{t( "resourceEnableProxy" )} {t( "resourceEnableProxyDescription" )}
)} /> )} */}
)} {t("targets")} {t("targetsDescription")}
( {t("site")}
{t( "siteNotFound" )} {sites.map( ( site ) => ( { addTargetForm.setValue( "siteId", site.siteId ); }} > { site.name } ) )} {field.value && (() => { const selectedSite = sites.find( ( site ) => site.siteId === field.value ); return selectedSite && selectedSite.type === "newt" ? (() => { const dockerState = getDockerStateForSite(selectedSite.siteId); return ( refreshContainersForSite(selectedSite.siteId)} /> ); })() : null; })()}
)} /> {baseForm.watch("http") && ( ( {t( "method" )} )} /> )} ( {t("targetAddr")} { const input = e.target.value.trim(); const hasProtocol = /^(https?|h2c):\/\//.test(input); const hasPort = /:\d+(?:\/|$)/.test(input); if (hasProtocol || hasPort) { const parsed = parseHostTarget(input); if (parsed) { if (hasProtocol || !addTargetForm.getValues("method")) { addTargetForm.setValue("method", parsed.protocol); } addTargetForm.setValue("ip", parsed.host); if (hasPort || !addTargetForm.getValues("port")) { addTargetForm.setValue("port", parsed.port); } } } else { field.onBlur(); } }} /> )} /> ( {t( "targetPort" )} )} />
{targets.length > 0 ? ( <>
{t("targetsList")}
{table .getHeaderGroups() .map( ( headerGroup ) => ( {headerGroup.headers.map( ( header ) => ( {header.isPlaceholder ? null : flexRender( header .column .columnDef .header, header.getContext() )} ) )} ) )} {table.getRowModel() .rows?.length ? ( table .getRowModel() .rows.map( (row) => ( {row .getVisibleCells() .map( ( cell ) => ( {flexRender( cell .column .columnDef .cell, cell.getContext() )} ) )} ) ) ) : ( {t( "targetNoOne" )} )}
) : (

{t("targetNoOne")}

)}
) : ( {t("resourceConfig")} {t("resourceConfigDescription")}

{t("resourceAddEntrypoints")}

{t("resourceAddEntrypointsEditFile")}

{t("resourceExposePorts")}

{t("resourceExposePortsEditFile")}

{t("resourceLearnRaw")}
)}
)} ); }