"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 { 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, CircleCheck, CircleX, Info, MoveRight, Plus, Settings, 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 "@app/components/DomainsTable"; import { finalizeSubdomainSanitize } from "@app/lib/subdomain-utils"; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@app/components/ui/tooltip"; import { PathMatchDisplay, PathMatchModal, PathRewriteDisplay, PathRewriteModal } from "@app/components/PathMatchRenameModal"; import { Badge } from "@app/components/ui/badge"; import HealthCheckDialog from "@app/components/HealthCheckDialog"; import { SwitchInput } from "@app/components/SwitchInput"; 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.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.int().positive(), path: z.string().optional().nullable(), pathMatchType: z .enum(["exact", "prefix", "regex"]) .optional() .nullable(), rewritePath: z.string().optional().nullable(), rewritePathType: z .enum(["exact", "prefix", "regex", "stripPrefix"]) .optional() .nullable(), priority: z.int().min(1).max(1000).optional() }) .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; }, { error: "Invalid path configuration" } ) .refine( (data) => { // If rewritePath is provided, rewritePathType must be provided if (data.rewritePath && !data.rewritePathType) { return false; } // If rewritePathType is provided, rewritePath must be provided if (data.rewritePathType && !data.rewritePath) { return false; } return true; }, { error: "Invalid rewrite 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 [niceId, setNiceId] = useState(""); // Target management state const [targets, setTargets] = useState([]); const [targetsToRemove, setTargetsToRemove] = useState([]); const [dockerStates, setDockerStates] = useState>( new Map() ); const [selectedTargetForHealthCheck, setSelectedTargetForHealthCheck] = useState(null); const [healthCheckDialogOpen, setHealthCheckDialogOpen] = useState(false); const [isAdvancedMode, setIsAdvancedMode] = useState(() => { if (typeof window !== "undefined") { const saved = localStorage.getItem("create-advanced-mode"); return saved === "true"; } return false; }); // Save advanced mode preference to localStorage useEffect(() => { if (typeof window !== "undefined") { localStorage.setItem( "create-advanced-mode", isAdvancedMode.toString() ); } }, [isAdvancedMode]); function addNewTarget() { const isHttp = baseForm.watch("http"); const newTarget: LocalTarget = { targetId: -Date.now(), // Use negative timestamp as temporary ID ip: "", method: isHttp ? "http" : null, port: 0, siteId: sites.length > 0 ? sites[0].siteId : 0, path: isHttp ? null : null, pathMatchType: isHttp ? null : null, rewritePath: isHttp ? null : null, rewritePathType: isHttp ? null : null, priority: isHttp ? 100 : 100, enabled: true, resourceId: 0, hcEnabled: false, hcPath: null, hcMethod: null, hcInterval: null, hcTimeout: null, hcHeaders: null, hcScheme: null, hcHostname: null, hcPort: null, hcFollowRedirects: null, hcHealth: "unknown", hcStatus: null, hcMode: null, hcUnhealthyInterval: null, siteType: sites.length > 0 ? sites[0].type : null, new: true, updated: false }; setTargets((prev) => [...prev, newTarget]); } 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, rewritePath: null, rewritePathType: null, priority: baseForm.watch("http") ? 100 : undefined } as z.infer }); // Helper function to check if all targets have required fields using schema validation const areAllTargetsValid = () => { if (targets.length === 0) return true; // No targets is valid return targets.every((target) => { try { const isHttp = baseForm.watch("http"); const targetData: any = { ip: target.ip, method: target.method, port: target.port, siteId: target.siteId, path: target.path, pathMatchType: target.pathMatchType, rewritePath: target.rewritePath, rewritePathType: target.rewritePathType }; // Only include priority for HTTP resources if (isHttp) { targetData.priority = target.priority; } addTargetSchema.parse(targetData); return true; } catch { return false; } }); }; 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) { const site = sites.find((site) => site.siteId === data.siteId); const isHttp = baseForm.watch("http"); const newTarget: LocalTarget = { ...data, path: isHttp ? (data.path || null) : null, pathMatchType: isHttp ? (data.pathMatchType || null) : null, rewritePath: isHttp ? (data.rewritePath || null) : null, rewritePathType: isHttp ? (data.rewritePathType || null) : null, siteType: site?.type || null, enabled: true, targetId: new Date().getTime(), new: true, resourceId: 0, // Will be set when resource is created priority: isHttp ? (data.priority || 100) : 100, // Default priority hcEnabled: false, hcPath: null, hcMethod: null, hcInterval: null, hcTimeout: null, hcHeaders: null, hcScheme: null, hcHostname: null, hcPort: null, hcFollowRedirects: null, hcHealth: "unknown", hcStatus: null, hcMode: null, hcUnhealthyInterval: null }; setTargets([...targets, newTarget]); addTargetForm.reset({ ip: "", method: baseForm.watch("http") ? "http" : null, port: "" as any as number, path: null, pathMatchType: null, rewritePath: null, rewritePathType: null, priority: isHttp ? 100 : undefined }); } 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 ? site.type : target.siteType } : 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; setNiceId(niceId); // Create targets if any exist if (targets.length > 0) { try { for (const target of targets) { const data: any = { ip: target.ip, port: target.port, method: target.method, enabled: target.enabled, siteId: target.siteId, hcEnabled: target.hcEnabled, hcPath: target.hcPath || null, hcMethod: target.hcMethod || null, hcInterval: target.hcInterval || null, hcTimeout: target.hcTimeout || null, hcHeaders: target.hcHeaders || null, hcScheme: target.hcScheme || null, hcHostname: target.hcHostname || null, hcPort: target.hcPort || null, hcFollowRedirects: target.hcFollowRedirects || null, hcStatus: target.hcStatus || null, hcUnhealthyInterval: target.hcUnhealthyInterval || null, hcMode: target.hcMode || null }; // Only include path-related fields for HTTP resources if (isHttp) { data.path = target.path; data.pathMatchType = target.pathMatchType; data.rewritePath = target.rewritePath; data.rewritePathType = target.rewritePathType; data.priority = target.priority; } 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/proxy/${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/proxy/${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(); }, []); function TargetHealthCheck(targetId: number, config: any) { setTargets( targets.map((target) => target.targetId === targetId ? { ...target, ...config, updated: true } : target ) ); } const openHealthCheckDialog = (target: LocalTarget) => { console.log(target); setSelectedTargetForHealthCheck(target); setHealthCheckDialogOpen(true); }; const getColumns = (): ColumnDef[] => { const baseColumns: ColumnDef[] = []; const isHttp = baseForm.watch("http"); const priorityColumn: ColumnDef = { id: "priority", header: () => (
{t("priority")}

{t("priorityDescription")}

), cell: ({ row }) => { return (
{ const value = parseInt(e.target.value, 10); if (value >= 1 && value <= 1000) { updateTarget(row.original.targetId, { ...row.original, priority: value }); } }} />
); }, size: 120, minSize: 100, maxSize: 150 }; const healthCheckColumn: ColumnDef = { accessorKey: "healthCheck", header: () => ({t("healthCheck")}), cell: ({ row }) => { const status = row.original.hcHealth || "unknown"; const isEnabled = row.original.hcEnabled; const getStatusColor = (status: string) => { switch (status) { case "healthy": return "green"; case "unhealthy": return "red"; case "unknown": default: return "secondary"; } }; const getStatusText = (status: string) => { switch (status) { case "healthy": return t("healthCheckHealthy"); case "unhealthy": return t("healthCheckUnhealthy"); case "unknown": default: return t("healthCheckUnknown"); } }; const getStatusIcon = (status: string) => { switch (status) { case "healthy": return ; case "unhealthy": return ; case "unknown": default: return null; } }; return (
{row.original.siteType === "newt" ? ( ) : ( - )}
); }, size: 200, minSize: 180, maxSize: 250 }; const matchPathColumn: ColumnDef = { accessorKey: "path", header: () => ({t("matchPath")}), cell: ({ row }) => { const hasPathMatch = !!( row.original.path || row.original.pathMatchType ); return (
{hasPathMatch ? ( updateTarget(row.original.targetId, config) } trigger={ } /> ) : ( updateTarget(row.original.targetId, config) } trigger={ } /> )}
); }, size: 200, minSize: 180, maxSize: 200 }; const addressColumn: ColumnDef = { accessorKey: "address", header: () => ({t("address")}), 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, ...(port && { port: port }) }); }; return (
{selectedSite && selectedSite.type === "newt" && (() => { const dockerState = getDockerStateForSite( selectedSite.siteId ); return ( refreshContainersForSite( selectedSite.siteId ) } /> ); })()} {t("siteNotFound")} {sites.map((site) => ( updateTarget( row.original .targetId, { siteId: site.siteId } ) } > {site.name} ))} {isHttp && ( )} {isHttp && (
{"://"}
)} { 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 }); } }} />
{":"}
{ const value = parseInt(e.target.value, 10); if (!isNaN(value) && value > 0) { updateTarget(row.original.targetId, { ...row.original, port: value }); } else { updateTarget(row.original.targetId, { ...row.original, port: 0 }); } }} />
); }, size: 400, minSize: 350, maxSize: 500 }; const rewritePathColumn: ColumnDef = { accessorKey: "rewritePath", header: () => ({t("rewritePath")}), cell: ({ row }) => { const hasRewritePath = !!( row.original.rewritePath || row.original.rewritePathType ); const noPathMatch = !row.original.path && !row.original.pathMatchType; return (
{hasRewritePath && !noPathMatch ? ( updateTarget(row.original.targetId, config) } trigger={ } /> ) : ( updateTarget(row.original.targetId, config) } trigger={ } disabled={noPathMatch} /> )}
); }, size: 200, minSize: 180, maxSize: 200 }; const enabledColumn: ColumnDef = { accessorKey: "enabled", header: () => ({t("enabled")}), cell: ({ row }) => (
updateTarget(row.original.targetId, { ...row.original, enabled: val }) } />
), size: 100, minSize: 80, maxSize: 120 }; const actionsColumn: ColumnDef = { id: "actions", header: () => ({t("actions")}), cell: ({ row }) => (
), size: 100, minSize: 80, maxSize: 120 }; if (isAdvancedMode) { const columns = [ addressColumn, healthCheckColumn, enabledColumn, actionsColumn ]; // Only include path-related columns for HTTP resources if (isHttp) { columns.unshift(matchPathColumn); columns.splice(3, 0, rewritePathColumn, priorityColumn); } return columns; } else { return [ addressColumn, healthCheckColumn, enabledColumn, actionsColumn ]; } }; const columns = getColumns(); 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")} {targets.length > 0 ? ( <>
{table .getHeaderGroups() .map( ( headerGroup ) => ( {headerGroup.headers.map( ( header ) => { const isActionsColumn = header.column.id === "actions"; return ( {header.isPlaceholder ? null : flexRender( header .column .columnDef .header, header.getContext() )} ); } )} ) )} {table.getRowModel() .rows?.length ? ( table .getRowModel() .rows.map( (row) => ( {row .getVisibleCells() .map( ( cell ) => { const isActionsColumn = cell.column.id === "actions"; return ( {flexRender( cell .column .columnDef .cell, cell.getContext() )} ); } )} ) ) ) : ( {t( "targetNoOne" )} )} {/* */} {/* {t('targetNoOneDescription')} */} {/* */}
) : (

{t("targetNoOne")}

)}
{selectedTargetForHealthCheck && ( { if (selectedTargetForHealthCheck) { console.log(config); TargetHealthCheck( selectedTargetForHealthCheck.targetId, config ); } }} /> )}
) : ( {t("resourceConfig")} {t("resourceConfigDescription")}

{t("resourceAddEntrypoints")}

{t( "resourceAddEntrypointsEditFile" )}

{t("resourceExposePorts")}

{t( "resourceExposePortsEditFile" )}

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