From f12451b8f97d6324788eaba442021b2db257a7f7 Mon Sep 17 00:00:00 2001 From: Owen Date: Tue, 26 May 2026 16:33:54 -0700 Subject: [PATCH] Consolidate target components --- .../proxy/ProxyResourceTargetsForm.tsx | 1030 +++++++++++++++++ .../resources/proxy/[niceId]/http/page.tsx | 964 +-------------- .../settings/resources/proxy/create/page.tsx | 834 +------------ 3 files changed, 1046 insertions(+), 1782 deletions(-) create mode 100644 src/app/[orgId]/settings/resources/proxy/ProxyResourceTargetsForm.tsx diff --git a/src/app/[orgId]/settings/resources/proxy/ProxyResourceTargetsForm.tsx b/src/app/[orgId]/settings/resources/proxy/ProxyResourceTargetsForm.tsx new file mode 100644 index 000000000..7e0e86066 --- /dev/null +++ b/src/app/[orgId]/settings/resources/proxy/ProxyResourceTargetsForm.tsx @@ -0,0 +1,1030 @@ +"use client"; + +import HealthCheckCredenza from "@/components/HealthCheckCredenza"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Switch } from "@/components/ui/switch"; +import { + PathMatchDisplay, + PathMatchModal, + PathRewriteDisplay, + PathRewriteModal +} from "@app/components/PathMatchRenameModal"; +import { ResourceTargetAddressItem } from "@app/components/resource-target-address-item"; +import { + SettingsSection, + SettingsSectionBody, + SettingsSectionDescription, + SettingsSectionHeader, + SettingsSectionTitle +} from "@app/components/Settings"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow +} from "@app/components/ui/table"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger +} from "@app/components/ui/tooltip"; +import type { ResourceContextType } from "@app/contexts/resourceContext"; +import { useEnvContext } from "@app/hooks/useEnvContext"; +import { toast } from "@app/hooks/useToast"; +import { createApiClient } from "@app/lib/api"; +import { formatAxiosError } from "@app/lib/api/formatAxiosError"; +import { DockerManager, DockerState } from "@app/lib/docker"; +import { orgQueries, resourceQueries } from "@app/lib/queries"; +import { build } from "@server/build"; +import { type GetResourceResponse } from "@server/routers/resource"; +import { CreateTargetResponse } from "@server/routers/target"; +import { ListTargetsResponse } from "@server/routers/target/listTargets"; +import { ArrayElement } from "@server/types/ArrayElement"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { + ColumnDef, + flexRender, + getCoreRowModel, + getFilteredRowModel, + getPaginationRowModel, + getSortedRowModel, + useReactTable +} from "@tanstack/react-table"; +import { AxiosResponse } from "axios"; +import { ExternalLink, Info, Plus } from "lucide-react"; +import { useTranslations } from "next-intl"; +import { useRouter } from "next/navigation"; +import { + useActionState, + useCallback, + useEffect, + useMemo, + useState +} from "react"; + +export type LocalTarget = Omit< + ArrayElement & { + new?: boolean; + updated?: boolean; + siteType: string | null; + }, + "protocol" +>; + +interface ProxyResourceTargetsFormProps { + orgId: string; + isHttp: boolean; + initialTargets?: LocalTarget[]; + /** Edit mode: when provided, shows a save button and polls for health status */ + resource?: GetResourceResponse; + updateResource?: ResourceContextType["updateResource"]; + /** Create mode: called whenever the targets list changes */ + onChange?: (targets: LocalTarget[]) => void; +} + +export function ProxyResourceTargetsForm({ + orgId, + isHttp, + initialTargets = [], + resource, + updateResource, + onChange +}: ProxyResourceTargetsFormProps) { + const t = useTranslations(); + const api = createApiClient(useEnvContext()); + + const [targets, setTargets] = useState(initialTargets); + const [targetsToRemove, setTargetsToRemove] = useState([]); + + // Notify parent of changes (create mode) + useEffect(() => { + onChange?.(targets); + }, [targets]); // eslint-disable-line react-hooks/exhaustive-deps + + // Poll health status only in edit mode + const { data: polledTargets } = useQuery({ + ...resourceQueries.resourceTargets({ + resourceId: resource?.resourceId ?? 0 + }), + refetchInterval: 10_000, + enabled: !!resource + }); + + useEffect(() => { + if (!polledTargets) return; + setTargets((prev) => + prev.map((t) => { + const fresh = polledTargets.find( + (p) => p.targetId === t.targetId + ); + if (!fresh) return t; + return { + ...t, + hcHealth: fresh.hcHealth, + hcEnabled: t.updated ? t.hcEnabled : fresh.hcEnabled + }; + }) + ); + }, [polledTargets]); + + const [dockerStates, setDockerStates] = useState>( + new Map() + ); + const [healthCheckDialogOpen, setHealthCheckDialogOpen] = useState(false); + const [selectedTargetForHealthCheck, setSelectedTargetForHealthCheck] = + useState(null); + + const [bgDestination, setBgDestination] = useState(""); + const [bgDestinationPort, setBgDestinationPort] = useState(""); + const [bgSiteId, setBgSiteId] = useState(null); + const [bgTargetId, setBgTargetId] = useState(null); + + const initializeDockerForSite = async (siteId: number) => { + if (dockerStates.has(siteId)) { + return; + } + 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 [isAdvancedMode, setIsAdvancedMode] = useState(() => { + if (typeof window !== "undefined") { + const saved = localStorage.getItem("proxy-advanced-mode"); + return saved === "true"; + } + return false; + }); + + const removeTarget = useCallback((targetId: number) => { + setTargets((prevTargets) => { + const targetToRemove = prevTargets.find( + (target) => target.targetId === targetId + ); + if (targetToRemove && !targetToRemove.new) { + setTargetsToRemove((prev) => [...prev, targetId]); + } + return prevTargets.filter((target) => target.targetId !== targetId); + }); + }, []); + + const { data: sites = [] } = useQuery( + orgQueries.sites({ + orgId + }) + ); + + // Browser-gateway targets (edit mode only) + const { data: bgTargetsResponse } = useQuery({ + queryKey: ["browserGatewayTargets", resource?.resourceId, orgId], + queryFn: async () => { + const res = await api.get( + `/org/${orgId}/resource/${resource!.resourceId}/browser-gateway-targets` + ); + return res.data.data as { + targets: Array<{ + browserGatewayTargetId: number; + resourceId: number; + siteId: number; + type: string; + destination: string; + destinationPort: number; + }>; + }; + }, + enabled: !!resource + }); + + useEffect(() => { + if (!bgTargetsResponse?.targets?.length) return; + const bgt = bgTargetsResponse.targets[0]; + setBgDestination(bgt.destination); + setBgDestinationPort(String(bgt.destinationPort)); + setBgSiteId(bgt.siteId); + setBgTargetId(bgt.browserGatewayTargetId); + }, [bgTargetsResponse]); + + useEffect(() => { + if (sites.length > 0 && bgSiteId === null) { + setBgSiteId(sites[0].siteId); + } + }, [sites, bgSiteId]); + + const updateTarget = useCallback( + (targetId: number, data: Partial) => { + setTargets((prevTargets) => { + return prevTargets.map((target) => + target.targetId === targetId + ? { + ...target, + ...data, + updated: true + } + : target + ); + }); + }, + [sites] + ); + + const openHealthCheckDialog = useCallback((target: LocalTarget) => { + setSelectedTargetForHealthCheck(target); + setHealthCheckDialogOpen(true); + }, []); + + const columns = useMemo((): ColumnDef[] => { + const priorityColumn: ColumnDef = { + id: "priority", + header: () => ( +
+ {t("priority")} + + + + + + +

{t("priorityDescription")}

+
+
+
+
+ ), + cell: ({ row }) => { + return ( +
+ e.currentTarget.focus()} + defaultValue={row.original.priority || 100} + className="w-full max-w-20" + onBlur={(e) => { + 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 getStatusText = (status: string) => { + switch (status) { + case "healthy": + return t("healthCheckHealthy"); + case "unhealthy": + return t("healthCheckUnhealthy"); + case "unknown": + default: + return t("healthCheckUnknown"); + } + }; + + 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 }) => { + return ( + + ); + }, + 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 cols = [ + addressColumn, + healthCheckColumn, + enabledColumn, + actionsColumn + ]; + + if (isHttp) { + cols.unshift(matchPathColumn); + cols.splice(3, 0, rewritePathColumn, priorityColumn); + } + + return cols; + } else { + return [ + addressColumn, + healthCheckColumn, + enabledColumn, + actionsColumn + ]; + } + }, [ + isAdvancedMode, + isHttp, + sites, + updateTarget, + getDockerStateForSite, + refreshContainersForSite, + openHealthCheckDialog, + removeTarget, + t + ]); + + function addNewTarget() { + const newTarget: LocalTarget = { + targetId: -Date.now(), + ip: "", + method: isHttp ? "http" : null, + port: 0, + siteId: sites.length > 0 ? sites[0].siteId : 0, + siteName: sites.length > 0 ? sites[0].name : "", + path: null, + pathMatchType: null, + rewritePath: null, + rewritePathType: null, + priority: 100, + enabled: true, + resourceId: resource?.resourceId ?? 0, + hcEnabled: false, + hcPath: null, + hcMethod: null, + hcInterval: null, + hcTimeout: null, + hcHeaders: null, + hcFollowRedirects: null, + hcScheme: null, + hcHostname: null, + hcPort: null, + hcHealth: "unknown", + hcStatus: null, + hcMode: null, + hcUnhealthyInterval: null, + hcTlsServerName: null, + hcHealthyThreshold: null, + hcUnhealthyThreshold: null, + siteType: sites.length > 0 ? sites[0].type : null, + new: true, + updated: false + }; + + setTargets((prev) => [...prev, newTarget]); + } + + function updateTargetHealthCheck(targetId: number, config: any) { + setTargets( + targets.map((target) => + target.targetId === targetId + ? { + ...target, + ...config, + updated: true + } + : target + ) + ); + } + + 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 + } + } + }); + + const router = useRouter(); + const queryClient = useQueryClient(); + + useEffect(() => { + const newtSites = sites.filter((site) => site.type === "newt"); + for (const site of newtSites) { + initializeDockerForSite(site.siteId); + } + }, [sites]); + + useEffect(() => { + if (typeof window !== "undefined") { + localStorage.setItem( + "proxy-advanced-mode", + isAdvancedMode.toString() + ); + } + }, [isAdvancedMode]); + + const [, formAction, isSubmitting] = useActionState(saveTargets, null); + + async function saveTargets() { + if (!resource) return; + + const targetsWithInvalidFields = targets.filter( + (target) => + !target.ip || + target.ip.trim() === "" || + !target.port || + target.port <= 0 || + isNaN(target.port) + ); + if (targetsWithInvalidFields.length > 0) { + toast({ + variant: "destructive", + title: t("targetErrorInvalidIp"), + description: t("targetErrorInvalidIpDescription") + }); + return; + } + + try { + await Promise.all( + targetsToRemove.map((targetId) => + api.delete(`/target/${targetId}`) + ) + ); + + 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, + hcScheme: target.hcScheme || null, + hcHostname: target.hcHostname || null, + hcPort: target.hcPort || null, + hcInterval: target.hcInterval || null, + hcTimeout: target.hcTimeout || null, + hcHeaders: target.hcHeaders || null, + hcFollowRedirects: target.hcFollowRedirects || null, + hcMethod: target.hcMethod || null, + hcStatus: target.hcStatus || null, + hcUnhealthyInterval: target.hcUnhealthyInterval || null, + hcMode: target.hcMode || null, + hcTlsServerName: target.hcTlsServerName, + hcHealthyThreshold: target.hcHealthyThreshold || null, + hcUnhealthyThreshold: target.hcUnhealthyThreshold || null + }; + + if (isHttp) { + data.path = target.path; + data.pathMatchType = target.pathMatchType; + data.rewritePath = target.rewritePath; + data.rewritePathType = target.rewritePathType; + data.priority = target.priority; + } + + if (target.new) { + const res = await api.put< + AxiosResponse + >(`/resource/${resource.resourceId}/target`, data); + target.targetId = res.data.data.targetId; + target.new = false; + } else if (target.updated) { + await api.post(`/target/${target.targetId}`, data); + target.updated = false; + } + } + + toast({ + title: + targets.length === 0 + ? t("targetTargetsCleared") + : t("settingsUpdated"), + description: + targets.length === 0 + ? t("targetTargetsClearedDescription") + : t("settingsUpdatedDescription") + }); + + setTargetsToRemove([]); + router.refresh(); + await queryClient.invalidateQueries( + resourceQueries.resourceTargets({ + resourceId: resource.resourceId + }) + ); + } catch (err) { + console.error(err); + toast({ + variant: "destructive", + title: t("settingsErrorUpdate"), + description: formatAxiosError( + err, + t("settingsErrorUpdateDescription") + ) + }); + } + } + + return ( + <> + + + {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")} +

+ +
+ )} + {build === "saas" && + targets.length > 1 && + new Set(targets.map((t) => t.siteId)).size > 1 && ( +

+ {t("proxyMultiSiteRoundRobinNodeHelp")}{" "} + + {t("learnMore")} + + + . +

+ )} +
+ + {/* Save button — only shown in edit mode */} + {resource && ( +
+ +
+ )} +
+ + {selectedTargetForHealthCheck && ( + { + if (selectedTargetForHealthCheck) { + updateTargetHealthCheck( + selectedTargetForHealthCheck.targetId, + config + ); + } + }} + /> + )} + + ); +} diff --git a/src/app/[orgId]/settings/resources/proxy/[niceId]/http/page.tsx b/src/app/[orgId]/settings/resources/proxy/[niceId]/http/page.tsx index 221bc8014..3da903b7e 100644 --- a/src/app/[orgId]/settings/resources/proxy/[niceId]/http/page.tsx +++ b/src/app/[orgId]/settings/resources/proxy/[niceId]/http/page.tsx @@ -69,7 +69,11 @@ import type { ListSitesResponse } from "@server/routers/site"; import { CreateTargetResponse } from "@server/routers/target"; import { ListTargetsResponse } from "@server/routers/target/listTargets"; import { ArrayElement } from "@server/types/ArrayElement"; -import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { useQuery } from "@tanstack/react-query"; +import { + LocalTarget, + ProxyResourceTargetsForm +} from "@app/app/[orgId]/settings/resources/proxy/ProxyResourceTargetsForm"; import { ColumnDef, flexRender, @@ -106,15 +110,6 @@ const targetsSettingsSchema = z.object({ stickySession: z.boolean() }); -type LocalTarget = Omit< - ArrayElement & { - new?: boolean; - updated?: boolean; - siteType: string | null; - }, - "protocol" ->; - export default function ReverseProxyTargetsPage(props: { params: Promise<{ resourceId: number; orgId: string }>; }) { @@ -135,6 +130,7 @@ export default function ReverseProxyTargetsPage(props: { (initialTargets); - const [targetsToRemove, setTargetsToRemove] = useState([]); - - const { data: polledTargets } = useQuery({ - ...resourceQueries.resourceTargets({ - resourceId: resource.resourceId - }), - refetchInterval: 10_000 - }); - - useEffect(() => { - if (!polledTargets) return; - setTargets((prev) => - prev.map((t) => { - const fresh = polledTargets.find( - (p) => p.targetId === t.targetId - ); - if (!fresh) return t; - return { - ...t, - hcHealth: fresh.hcHealth, - hcEnabled: t.updated ? t.hcEnabled : fresh.hcEnabled - }; - }) - ); - }, [polledTargets]); - const [dockerStates, setDockerStates] = useState>( - new Map() - ); - const [healthCheckDialogOpen, setHealthCheckDialogOpen] = useState(false); - const [selectedTargetForHealthCheck, setSelectedTargetForHealthCheck] = - useState(null); - - const [bgDestination, setBgDestination] = useState(""); - const [bgDestinationPort, setBgDestinationPort] = useState(""); - const [bgSiteId, setBgSiteId] = useState(null); - const [bgTargetId, setBgTargetId] = useState(null); - - 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 [isAdvancedMode, setIsAdvancedMode] = useState(() => { - if (typeof window !== "undefined") { - const saved = localStorage.getItem("proxy-advanced-mode"); - return saved === "true"; - } - return false; - }); - - const isHttp = resource.http; - - const removeTarget = useCallback((targetId: number) => { - setTargets((prevTargets) => { - const targetToRemove = prevTargets.find( - (target) => target.targetId === targetId - ); - if (targetToRemove && !targetToRemove.new) { - setTargetsToRemove((prev) => [...prev, targetId]); - } - return prevTargets.filter((target) => target.targetId !== targetId); - }); - }, []); - - const { data: sites = [] } = useQuery( - orgQueries.sites({ - orgId - }) - ); - - const { data: bgTargetsResponse } = useQuery({ - queryKey: ["browserGatewayTargets", resource.resourceId, orgId], - queryFn: async () => { - const res = await api.get( - `/org/${orgId}/resource/${resource.resourceId}/browser-gateway-targets` - ); - return res.data.data as { - targets: Array<{ - browserGatewayTargetId: number; - resourceId: number; - siteId: number; - type: string; - destination: string; - destinationPort: number; - }>; - }; - } - }); - - useEffect(() => { - if (!bgTargetsResponse?.targets?.length) return; - const bgt = bgTargetsResponse.targets[0]; - setBgDestination(bgt.destination); - setBgDestinationPort(String(bgt.destinationPort)); - setBgSiteId(bgt.siteId); - setBgTargetId(bgt.browserGatewayTargetId); - }, [bgTargetsResponse]); - - useEffect(() => { - if (sites.length > 0 && bgSiteId === null) { - setBgSiteId(sites[0].siteId); - } - }, [sites, bgSiteId]); - - const updateTarget = useCallback( - (targetId: number, data: Partial) => { - setTargets((prevTargets) => { - return prevTargets.map((target) => - target.targetId === targetId - ? { - ...target, - ...data, - updated: true - } - : target - ); - }); - }, - [sites] - ); - - const openHealthCheckDialog = useCallback((target: LocalTarget) => { - setSelectedTargetForHealthCheck(target); - setHealthCheckDialogOpen(true); - }, []); - - const columns = useMemo((): ColumnDef[] => { - const priorityColumn: ColumnDef = { - id: "priority", - header: () => ( -
- {t("priority")} - - - - - - -

{t("priorityDescription")}

-
-
-
-
- ), - cell: ({ row }) => { - return ( -
- e.currentTarget.focus()} - defaultValue={row.original.priority || 100} - className="w-full max-w-20" - onBlur={(e) => { - 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 getStatusText = (status: string) => { - switch (status) { - case "healthy": - return t("healthCheckHealthy"); - case "unhealthy": - return t("healthCheckUnhealthy"); - case "unknown": - default: - return t("healthCheckUnknown"); - } - }; - - 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 }) => { - return ( - - ); - }, - 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 - ]); - - function addNewTarget() { - const isHttp = resource.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: resource.resourceId, - hcEnabled: false, - hcPath: null, - hcMethod: null, - hcInterval: null, - hcTimeout: null, - hcHeaders: null, - hcFollowRedirects: null, - hcScheme: null, - hcHostname: null, - hcPort: null, - hcHealth: "unknown", - hcStatus: null, - hcMode: null, - hcUnhealthyInterval: null, - hcTlsServerName: null, - hcHealthyThreshold: null, - hcUnhealthyThreshold: null, - siteType: sites.length > 0 ? sites[0].type : null, - new: true, - updated: false - }; - - setTargets((prev) => [...prev, newTarget]); - } - - function updateTargetHealthCheck(targetId: number, config: any) { - setTargets( - targets.map((target) => - target.targetId === targetId - ? { - ...target, - ...config, - updated: true - } - : target - ) - ); - } - - 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 - } - } - }); - - const router = useRouter(); - - const queryClient = useQueryClient(); - - useEffect(() => { - const newtSites = sites.filter((site) => site.type === "newt"); - for (const site of newtSites) { - initializeDockerForSite(site.siteId); - } - }, [sites]); - - // Save advanced mode preference to localStorage - useEffect(() => { - if (typeof window !== "undefined") { - localStorage.setItem( - "proxy-advanced-mode", - isAdvancedMode.toString() - ); - } - }, [isAdvancedMode]); - - const [, formAction, isSubmitting] = useActionState(saveTargets, null); - - async function saveTargets() { - // Validate that no targets have blank IPs or invalid ports - const targetsWithInvalidFields = targets.filter( - (target) => - !target.ip || - target.ip.trim() === "" || - !target.port || - target.port <= 0 || - isNaN(target.port) - ); - console.log(targetsWithInvalidFields); - if (targetsWithInvalidFields.length > 0) { - toast({ - variant: "destructive", - title: t("targetErrorInvalidIp"), - description: t("targetErrorInvalidIpDescription") - }); - return; - } - - try { - await Promise.all( - targetsToRemove.map((targetId) => - api.delete(`/target/${targetId}`) - ) - ); - - // Save targets - 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, - hcScheme: target.hcScheme || null, - hcHostname: target.hcHostname || null, - hcPort: target.hcPort || null, - hcInterval: target.hcInterval || null, - hcTimeout: target.hcTimeout || null, - hcHeaders: target.hcHeaders || null, - hcFollowRedirects: target.hcFollowRedirects || null, - hcMethod: target.hcMethod || null, - hcStatus: target.hcStatus || null, - hcUnhealthyInterval: target.hcUnhealthyInterval || null, - hcMode: target.hcMode || null, - hcTlsServerName: target.hcTlsServerName, - hcHealthyThreshold: target.hcHealthyThreshold || null, - hcUnhealthyThreshold: target.hcUnhealthyThreshold || null - }; - - // Only include path-related fields for HTTP resources - if (resource.http) { - data.path = target.path; - data.pathMatchType = target.pathMatchType; - data.rewritePath = target.rewritePath; - data.rewritePathType = target.rewritePathType; - data.priority = target.priority; - } - - if (target.new) { - const res = await api.put< - AxiosResponse - >(`/resource/${resource.resourceId}/target`, data); - target.targetId = res.data.data.targetId; - target.new = false; - } else if (target.updated) { - await api.post(`/target/${target.targetId}`, data); - target.updated = false; - } - } - - toast({ - title: - targets.length === 0 - ? t("targetTargetsCleared") - : t("settingsUpdated"), - description: - targets.length === 0 - ? t("targetTargetsClearedDescription") - : t("settingsUpdatedDescription") - }); - - setTargetsToRemove([]); - router.refresh(); - await queryClient.invalidateQueries( - resourceQueries.resourceTargets({ - resourceId: resource.resourceId - }) - ); - } catch (err) { - console.error(err); - toast({ - variant: "destructive", - title: t("settingsErrorUpdate"), - description: formatAxiosError( - err, - t("settingsErrorUpdateDescription") - ) - }); - } - } - - return ( - <> - - - {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")} -

- -
- )} - {build === "saas" && - targets.length > 1 && - new Set(targets.map((t) => t.siteId)).size > 1 && ( -

- {t("proxyMultiSiteRoundRobinNodeHelp")}{" "} - - {t("learnMore")} - - - . -

- )} -
- -
- -
-
- - {selectedTargetForHealthCheck && ( - { - if (selectedTargetForHealthCheck) { - updateTargetHealthCheck( - selectedTargetForHealthCheck.targetId, - config - ); - } - }} - /> - )} - - ); -} - function ProxyResourceHttpForm({ resource, updateResource diff --git a/src/app/[orgId]/settings/resources/proxy/create/page.tsx b/src/app/[orgId]/settings/resources/proxy/create/page.tsx index 8edc5a7b7..c3a059314 100644 --- a/src/app/[orgId]/settings/resources/proxy/create/page.tsx +++ b/src/app/[orgId]/settings/resources/proxy/create/page.tsx @@ -86,6 +86,10 @@ 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 { + LocalTarget, + ProxyResourceTargetsForm +} from "@app/app/[orgId]/settings/resources/proxy/ProxyResourceTargetsForm"; import { ColumnDef, flexRender, @@ -206,15 +210,6 @@ const addTargetSchema = z type NewResourceType = "http" | "ssh" | "rdp" | "vnc" | "tcp" | "udp"; -export type LocalTarget = Omit< - ArrayElement & { - new?: boolean; - updated?: boolean; - siteType: string | null; - }, - "protocol" ->; - export default function Page() { const { env } = useEnvContext(); const api = createApiClient({ env }); @@ -238,15 +233,8 @@ export default function Page() { // Resource type state const [resourceType, setResourceType] = useState("http"); - // Target management state + // Target management state (managed by ProxyResourceTargetsForm; mirrored here for onSubmit) const [targets, setTargets] = useState([]); - const [dockerStates, setDockerStates] = useState>( - new Map() - ); - - const [selectedTargetForHealthCheck, setSelectedTargetForHealthCheck] = - useState(null); - const [healthCheckDialogOpen, setHealthCheckDialogOpen] = useState(false); // SSH-specific state const [sshServerMode, setSshServerMode] = useState<"standard" | "native">( @@ -306,23 +294,6 @@ export default function Page() { fetchExitNodes(); }, [orgId]); - const [isAdvancedMode, setIsAdvancedMode] = useState(() => { - if (typeof window !== "undefined") { - const saved = localStorage.getItem("create-advanced-mode"); - return saved === "true"; - } - return false; - }); - - useEffect(() => { - if (typeof window !== "undefined") { - localStorage.setItem( - "create-advanced-mode", - isAdvancedMode.toString() - ); - } - }, [isAdvancedMode]); - // Derived flags const isHttpResource = resourceType !== "tcp" && resourceType !== "udp"; const isNative = sshServerMode === "native"; @@ -334,48 +305,6 @@ export default function Page() { pamMode === "push" && standardDaemonLocation === "remote"; - function addNewTarget() { - const isHttp = resourceType === "http"; - - const newTarget: LocalTarget = { - targetId: -Date.now(), - ip: "", - method: isHttp ? "http" : null, - port: 0, - siteId: sites.length > 0 ? sites[0].siteId : 0, - siteName: sites.length > 0 ? sites[0].name : "", - path: null, - pathMatchType: null, - rewritePath: null, - rewritePathType: 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, - hcHealthyThreshold: null, - hcUnhealthyThreshold: null, - siteType: sites.length > 0 ? sites[0].type : null, - new: true, - updated: false - }; - - setTargets((prev) => [...prev, newTarget]); - } - // Whether raw (TCP/UDP) resources are available const rawResourcesAllowed = env.flags.allowRawResources && @@ -417,20 +346,6 @@ export default function Page() { } }); - const addTargetForm = useForm({ - resolver: zodResolver(addTargetSchema), - defaultValues: { - ip: "", - method: "http", - port: "" as any as number, - path: null, - pathMatchType: null, - rewritePath: null, - rewritePathType: null, - priority: 100 - } as z.infer - }); - // Sync form http field with resourceType useEffect(() => { baseForm.setValue("http", isHttpResource); @@ -470,72 +385,6 @@ export default function Page() { }); }; - const initializeDockerForSite = async (siteId: number) => { - if (dockerStates.has(siteId)) { - return; - } - - 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); @@ -787,402 +636,6 @@ export default function Page() { setCreateLoading(false); } - useEffect(() => { - for (const site of sites) { - if (site.type === "newt") { - initializeDockerForSite(site.siteId); - } - } - - 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 = resourceType === "http"; - - const columns = useMemo((): ColumnDef[] => { - const priorityColumn: ColumnDef = { - id: "priority", - header: () => ( -
- {t("priority")} - - - - - - -

- {t("priorityDescription")} -

-
-
-
-
- ), - cell: ({ row }) => { - return ( -
- { - const val = parseInt(e.target.value); - if (!isNaN(val) && val >= 1 && val <= 1000) { - updateTarget(row.original.targetId, { - priority: val - }); - } - }} - /> -
- ); - }, - size: 120, - minSize: 100, - maxSize: 150 - }; - - const healthCheckColumn: ColumnDef = { - accessorKey: "healthCheck", - header: () => {t("healthCheck")}, - cell: ({ row }) => { - const status = row.original.hcHealth || "unknown"; - - const getStatusText = (status: string) => { - switch (status) { - case "healthy": - return t("healthy"); - case "unhealthy": - return t("unhealthy"); - default: - return t("unknown"); - } - }; - - return ( -
- -
- ); - }, - 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 cols = [ - addressColumn, - healthCheckColumn, - enabledColumn, - actionsColumn - ]; - - if (isHttp) { - cols.splice( - 1, - 0, - matchPathColumn, - rewritePathColumn, - priorityColumn - ); - } - - return cols; - } 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 - } - } - }); - // SSH strategy options const sshModeOptions: StrategyOption<"standard" | "native">[] = [ { @@ -1732,207 +1185,11 @@ export default function Page() { {(resourceType === "http" || resourceType === "tcp" || resourceType === "udp") && ( - - - - {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")} -

- -
- )} - {build === "saas" && - targets.length > 1 && - new Set( - targets.map((t) => t.siteId) - ).size > 1 && ( -

- {t( - "proxyMultiSiteRoundRobinNodeHelp" - )}{" "} - - {t("learnMore")} - - - . -

- )} -
-
+ )}
@@ -1977,77 +1234,6 @@ export default function Page() { {t("resourceCreate")}
- {selectedTargetForHealthCheck && ( - { - if (selectedTargetForHealthCheck) { - console.log(config); - TargetHealthCheck( - selectedTargetForHealthCheck.targetId, - config - ); - } - }} - /> - )}
) : (