"use client"; import CopyTextBox from "@app/components/CopyTextBox"; import DomainPicker from "@app/components/DomainPicker"; import HealthCheckDialog from "@app/components/HealthCheckDialog"; import { PathMatchDisplay, PathMatchModal, PathRewriteDisplay, PathRewriteModal } from "@app/components/PathMatchRenameModal"; import { SettingsContainer, SettingsSection, SettingsSectionBody, SettingsSectionDescription, SettingsSectionForm, SettingsSectionHeader, SettingsSectionTitle } from "@app/components/Settings"; import HeaderTitle from "@app/components/SettingsSectionTitle"; import { StrategySelect } from "@app/components/StrategySelect"; import { ResourceTargetAddressItem } from "@app/components/resource-target-address-item"; import { Button } 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 { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@app/components/ui/select"; import { Switch } from "@app/components/ui/switch"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@app/components/ui/table"; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@app/components/ui/tooltip"; import { Alert, AlertDescription, AlertTitle } from "@app/components/ui/alert"; import { useEnvContext } from "@app/hooks/useEnvContext"; import { toast } from "@app/hooks/useToast"; import { createApiClient, formatAxiosError } from "@app/lib/api"; import { DockerManager, DockerState } from "@app/lib/docker"; import { orgQueries } from "@app/lib/queries"; import { finalizeSubdomainSanitize } from "@app/lib/subdomain-utils"; import { zodResolver } from "@hookform/resolvers/zod"; import { build } from "@server/build"; import { Resource } from "@server/db"; import { isTargetValid } from "@server/lib/validators"; import { ListTargetsResponse } from "@server/routers/target"; import { ListRemoteExitNodesResponse } from "@server/routers/remoteExitNode/types"; import { ArrayElement } from "@server/types/ArrayElement"; import { useQuery } from "@tanstack/react-query"; import { ColumnDef, flexRender, getCoreRowModel, getFilteredRowModel, getPaginationRowModel, getSortedRowModel, useReactTable } from "@tanstack/react-table"; import { AxiosResponse } from "axios"; import { CircleCheck, CircleX, Info, InfoIcon, Plus, Settings, SquareArrowOutUpRight } from "lucide-react"; import { useTranslations } from "next-intl"; import Link from "next/link"; import { useParams, useRouter } from "next/navigation"; import { toASCII } from "punycode"; import { useEffect, useMemo, useState, useCallback } from "react"; import { Controller, useForm } from "react-hook-form"; import { z } from "zod"; 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 // Exception: stripPrefix can have an empty rewritePath (to just strip the prefix) if (data.rewritePathType && !data.rewritePath) { // Allow empty rewritePath for stripPrefix type if (data.rewritePathType !== "stripPrefix") { return false; } } return true; }, { error: "Invalid rewrite path configuration" } ); type ResourceType = "http" | "raw"; interface ResourceTypeOption { id: ResourceType; title: string; description: string; disabled?: boolean; } export 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 { data: sites = [], isLoading: loadingPage } = useQuery( orgQueries.sites({ orgId: orgId as string }) ); const [remoteExitNodes, setRemoteExitNodes] = useState< ListRemoteExitNodesResponse["remoteExitNodes"] >([]); const [loadingExitNodes, setLoadingExitNodes] = useState(build === "saas"); const [createLoading, setCreateLoading] = useState(false); const [showSnippets, setShowSnippets] = useState(false); const [niceId, setNiceId] = useState(""); // Target management state const [targets, setTargets] = useState([]); const [dockerStates, setDockerStates] = useState>( new Map() ); const [selectedTargetForHealthCheck, setSelectedTargetForHealthCheck] = useState(null); const [healthCheckDialogOpen, setHealthCheckDialogOpen] = useState(false); useEffect(() => { if (build !== "saas") return; const fetchExitNodes = async () => { try { const res = await api.get< AxiosResponse >(`/org/${orgId}/remote-exit-nodes`); if (res && res.status === 200) { setRemoteExitNodes(res.data.data.remoteExitNodes); } } catch (e) { console.error("Failed to fetch remote exit nodes:", e); } finally { setLoadingExitNodes(false); } }; fetchExitNodes(); }, [orgId]); 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, siteName: sites.length > 0 ? sites[0].name : "", 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, hcTlsServerName: 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 ? [] : build === "saas" && remoteExitNodes.length === 0 ? [] : [ { id: "raw" as ResourceType, title: t("resourceRaw"), description: build == "saas" ? t("resourceRawDescriptionCloud") : t("resourceRawDescription") } ]) ]; // In saas mode with no exit nodes, force HTTP const showTypeSelector = build !== "saas" || (!loadingExitNodes && remoteExitNodes.length > 0); 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 = useCallback( 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; }); }, [api] ); const getDockerStateForSite = useCallback( (siteId: number): DockerState => { return ( dockerStates.get(siteId) || { isEnabled: false, isAvailable: false, containers: [] } ); }, [dockerStates] ); const removeTarget = useCallback((targetId: number) => { setTargets((prevTargets) => { return prevTargets.filter((target) => target.targetId !== targetId); }); }, []); const updateTarget = useCallback( (targetId: number, data: Partial) => { setTargets((prevTargets) => { const site = sites.find((site) => site.siteId === data.siteId); return prevTargets.map((target) => target.targetId === targetId ? { ...target, ...data, updated: true, siteType: site ? site.type : target.siteType } : target ); }); }, [sites] ); 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, hcTlsServerName: target.hcTlsServerName }; // 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: formatAxiosError( e, t("resourceErrorCreateMessageDescription") ) }); } setCreateLoading(false); } useEffect(() => { // Initialize Docker for newt sites for (const site of sites) { if (site.type === "newt") { initializeDockerForSite(site.siteId); } } // If there's at least one site, set it as the default in the form if (sites.length > 0) { addTargetForm.setValue("siteId", sites[0].siteId); } }, [sites]); function TargetHealthCheck(targetId: number, config: any) { setTargets( targets.map((target) => target.targetId === targetId ? { ...target, ...config, updated: true } : target ) ); } const openHealthCheckDialog = useCallback((target: LocalTarget) => { console.log(target); setSelectedTargetForHealthCheck(target); setHealthCheckDialogOpen(true); }, []); const isHttp = baseForm.watch("http"); const columns = useMemo((): ColumnDef[] => { 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.path === null && config.pathMatchType === null ? { ...config, rewritePath: null, rewritePathType: null } : config ) } trigger={ } /> ) : ( updateTarget(row.original.targetId, config.path === null && config.pathMatchType === null ? { ...config, rewritePath: null, rewritePathType: null } : config ) } trigger={ } /> )}
); }, size: 200, minSize: 180, maxSize: 200 }; const addressColumn: ColumnDef = { accessorKey: "address", header: () => {t("address")}, cell: ({ row }) => ( ), 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 ]; } }, [ isAdvancedMode, isHttp, sites, updateTarget, getDockerStateForSite, refreshContainersForSite, openHealthCheckDialog, removeTarget, t ]); const table = useReactTable({ data: targets, columns, getCoreRowModel: getCoreRowModel(), getPaginationRowModel: getPaginationRowModel(), getSortedRowModel: getSortedRowModel(), getFilteredRowModel: getFilteredRowModel(), getRowId: (row) => String(row.targetId), state: { pagination: { pageIndex: 0, pageSize: 1000 } } }); return ( <>
{!loadingPage && (
{!showSnippets ? ( {t("resourceInfo")} {showTypeSelector && resourceTypes.length > 1 && ( <>
{t("type")}
{ baseForm.setValue( "http", value === "http" ); // Update method default when switching resource type addTargetForm.setValue( "method", value === "http" ? "http" : null ); }} cols={2} /> )}
{ if (e.key === "Enter") { e.preventDefault(); // block default enter refresh } }} className="space-y-4" id="base-resource-form" > ( {t("name")} {t( "resourceNameDescription" )} )} />
{baseForm.watch("http") ? ( {t("resourceHTTPSSettings")} {t( "resourceHTTPSSettingsDescription" )} = 1 } onDomainChange={(res) => { if (!res) return; 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 grid gap-4 grid-cols-1 md:grid-cols-2 items-start" id="tcp-udp-settings-form" > ( {t("protocol")} )} /> ( {t( "resourcePortNumber" )} field.onChange( e .target .value ? parseInt( e .target .value ) : undefined ) } /> {t( "resourcePortNumberDescription" )} )} />
)} {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("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")}
)}
)} ); }