"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, ResourceTargetSiteItem } 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"; import { maxSize } from "zod"; 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]); // 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 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 }) ); 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 siteColumn: ColumnDef = { accessorKey: "site", header: () => {t("site")}, cell: ({ row }) => { return ( ); }, size: 220, minSize: 180, maxSize: 280 }; const addressColumn: ColumnDef = { accessorKey: "address", header: () => {t("address")}, cell: ({ row }) => { return ( ); }, size: 350, minSize: 300, maxSize: 450 }; 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", cell: ({ row }) => (
), size: 100, minSize: 80, maxSize: 120 }; if (isAdvancedMode) { const cols = [ siteColumn, addressColumn, healthCheckColumn, enabledColumn, actionsColumn ]; if (isHttp) { cols.unshift(matchPathColumn); cols.splice(4, 0, rewritePathColumn, priorityColumn); } return cols; } else { return [ siteColumn, addressColumn, healthCheckColumn, enabledColumn, actionsColumn ]; } }, [ isAdvancedMode, isHttp, sites, updateTarget, getDockerStateForSite, refreshContainersForSite, openHealthCheckDialog, removeTarget, t ]); function addNewTarget() { const newTarget: LocalTarget = { targetId: -Date.now(), ip: "", mode: ((resource?.mode as LocalTarget["mode"]) ?? (isHttp ? "http" : "tcp")) as LocalTarget["mode"], 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"; const isSiteColumn = header.column .id === "site"; 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"; const isSiteColumn = cell.column .id === "site"; 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 ); } }} /> )} ); }