diff --git a/messages/en-US.json b/messages/en-US.json index 9435b5d74..62493e43f 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -468,7 +468,10 @@ "createdAt": "Created At", "proxyErrorInvalidHeader": "Invalid custom Host Header value. Use domain name format, or save empty to unset custom Host Header.", "proxyErrorTls": "Invalid TLS Server Name. Use domain name format, or save empty to remove the TLS Server Name.", - "proxyEnableSSL": "Enable SSL (https)", + "proxyEnableSSL": "Enable SSL", + "proxyEnableSSLDescription": "Enable SSL/TLS encryption for secure HTTPS connections to your targets.", + "target": "Target", + "configureTarget": "Configure Targets", "targetErrorFetch": "Failed to fetch targets", "targetErrorFetchDescription": "An error occurred while fetching targets", "siteErrorFetch": "Failed to fetch resource", @@ -495,7 +498,7 @@ "targetTlsSettings": "Secure Connection Configuration", "targetTlsSettingsDescription": "Configure SSL/TLS settings for your resource", "targetTlsSettingsAdvanced": "Advanced TLS Settings", - "targetTlsSni": "TLS Server Name (SNI)", + "targetTlsSni": "TLS Server Name", "targetTlsSniDescription": "The TLS Server Name to use for SNI. Leave empty to use the default.", "targetTlsSubmit": "Save Settings", "targets": "Targets Configuration", @@ -504,9 +507,21 @@ "targetStickySessionsDescription": "Keep connections on the same backend target for their entire session.", "methodSelect": "Select method", "targetSubmit": "Add Target", - "targetNoOne": "No targets. Add a target using the form.", + "targetNoOne": "This resource doesn't have any targets. Add a target to configure where to send requests to your backend.", "targetNoOneDescription": "Adding more than one target above will enable load balancing.", "targetsSubmit": "Save Targets", + "addTarget": "Add Target", + "targetErrorInvalidIp": "Invalid IP address", + "targetErrorInvalidIpDescription": "Please enter a valid IP address or hostname", + "targetErrorInvalidPort": "Invalid port", + "targetErrorInvalidPortDescription": "Please enter a valid port number", + "targetErrorNoSite": "No site selected", + "targetErrorNoSiteDescription": "Please select a site for the target", + "targetCreated": "Target created", + "targetCreatedDescription": "Target has been created successfully", + "targetErrorCreate": "Failed to create target", + "targetErrorCreateDescription": "An error occurred while creating the target", + "save": "Save", "proxyAdditional": "Additional Proxy Settings", "proxyAdditionalDescription": "Configure how your resource handles proxy settings", "proxyCustomHeader": "Custom Host Header", @@ -1410,6 +1425,7 @@ "externalProxyEnabled": "External Proxy Enabled", "addNewTarget": "Add New Target", "targetsList": "Targets List", + "advancedMode": "Advanced Mode", "targetErrorDuplicateTargetFound": "Duplicate target found", "healthCheckHealthy": "Healthy", "healthCheckUnhealthy": "Unhealthy", diff --git a/server/lib/traefik/getTraefikConfig.ts b/server/lib/traefik/getTraefikConfig.ts index 7e1ce562a..934e7466b 100644 --- a/server/lib/traefik/getTraefikConfig.ts +++ b/server/lib/traefik/getTraefikConfig.ts @@ -126,7 +126,7 @@ export async function getTraefikConfig( resourcesMap.set(key, { resourceId: row.resourceId, - name: resourceName, + name: resourceName, fullDomain: row.fullDomain, ssl: row.ssl, http: row.http, @@ -297,8 +297,6 @@ export async function getTraefikConfig( // Single middleware routerMiddlewares.push(rewriteMiddlewareName); } - - logger.debug(`Created path rewrite middleware ${rewriteMiddlewareName}: ${resource.pathMatchType}(${resource.path}) -> ${resource.rewritePathType}(${resource.rewritePath})`); } catch (error) { logger.error(`Failed to create path rewrite middleware for resource ${resource.resourceId}: ${error}`); } diff --git a/server/routers/resource/createResource.ts b/server/routers/resource/createResource.ts index f4fe352e1..2a4e67a76 100644 --- a/server/routers/resource/createResource.ts +++ b/server/routers/resource/createResource.ts @@ -37,7 +37,8 @@ const createHttpResourceSchema = z subdomain: z.string().nullable().optional(), http: z.boolean(), protocol: z.enum(["tcp", "udp"]), - domainId: z.string() + domainId: z.string(), + stickySession: z.boolean().optional(), }) .strict() .refine( @@ -191,6 +192,7 @@ async function createHttpResource( const { name, domainId } = parsedBody.data; const subdomain = parsedBody.data.subdomain; + const stickySession=parsedBody.data.stickySession; // Validate domain and construct full domain const domainResult = await validateAndConstructDomain( @@ -254,7 +256,8 @@ async function createHttpResource( subdomain: finalSubdomain, http: true, protocol: "tcp", - ssl: true + ssl: true, + stickySession: stickySession }) .returning(); diff --git a/src/app/[orgId]/settings/resources/[niceId]/proxy/page.tsx b/src/app/[orgId]/settings/resources/[niceId]/proxy/page.tsx index 302d16d24..92e02aa21 100644 --- a/src/app/[orgId]/settings/resources/[niceId]/proxy/page.tsx +++ b/src/app/[orgId]/settings/resources/[niceId]/proxy/page.tsx @@ -109,14 +109,22 @@ import { PathRewriteModal } from "@app/components/PathMatchRenameModal"; import { Badge } from "@app/components/ui/badge"; -import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@app/components/ui/tooltip"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger +} from "@app/components/ui/tooltip"; const addTargetSchema = z .object({ ip: z.string().refine(isTargetValid), method: z.string().nullable(), port: z.coerce.number().int().positive(), - siteId: z.number().int().positive(), + siteId: z + .number() + .int() + .positive({ message: "You must select a site for a target." }), path: z.string().optional().nullable(), pathMatchType: z .enum(["exact", "prefix", "regex"]) @@ -250,6 +258,13 @@ export default function ReverseProxyTargets(props: { const [pageLoading, setPageLoading] = useState(true); const [isAdvancedOpen, setIsAdvancedOpen] = useState(false); + const [isAdvancedMode, setIsAdvancedMode] = useState(() => { + if (typeof window !== "undefined") { + const saved = localStorage.getItem("proxy-advanced-mode"); + return saved === "true"; + } + return false; + }); const [healthCheckDialogOpen, setHealthCheckDialogOpen] = useState(false); const [selectedTargetForHealthCheck, setSelectedTargetForHealthCheck] = useState(null); @@ -297,31 +312,6 @@ export default function ReverseProxyTargets(props: { type TlsSettingsValues = z.infer; type TargetsSettingsValues = z.infer; - const addTargetForm = useForm({ - resolver: zodResolver(addTargetSchema), - defaultValues: { - ip: "", - method: resource.http ? "http" : null, - port: "" as any as number, - path: null, - pathMatchType: null, - rewritePath: null, - rewritePathType: null, - priority: 100 - } as z.infer - }); - - const watchedIp = addTargetForm.watch("ip"); - const watchedPort = addTargetForm.watch("port"); - const watchedSiteId = addTargetForm.watch("siteId"); - - const handleContainerSelect = (hostname: string, port?: number) => { - addTargetForm.setValue("ip", hostname); - if (port) { - addTargetForm.setValue("port", port); - } - }; - const tlsSettingsForm = useForm({ resolver: zodResolver(tlsSettingsSchema), defaultValues: { @@ -398,13 +388,7 @@ export default function ReverseProxyTargets(props: { 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 - ); - } + // Sites loaded successfully } }; fetchSites(); @@ -433,6 +417,158 @@ export default function ReverseProxyTargets(props: { // fetchSite(); }, []); + // Save advanced mode preference to localStorage + useEffect(() => { + if (typeof window !== "undefined") { + localStorage.setItem( + "proxy-advanced-mode", + isAdvancedMode.toString() + ); + } + }, [isAdvancedMode]); + + function addNewTarget() { + const newTarget: LocalTarget = { + targetId: -Date.now(), // Use negative timestamp as temporary ID + ip: "", + method: resource.http ? "http" : null, + port: 0, + siteId: sites.length > 0 ? sites[0].siteId : 0, + path: null, + pathMatchType: null, + rewritePath: null, + rewritePathType: null, + priority: 100, + enabled: true, + resourceId: resource.resourceId, + 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]); + } + + async function saveNewTarget(target: LocalTarget) { + // Validate the target + if (!isTargetValid(target.ip)) { + toast({ + variant: "destructive", + title: t("targetErrorInvalidIp"), + description: t("targetErrorInvalidIpDescription") + }); + return; + } + + if (!target.port || target.port <= 0) { + toast({ + variant: "destructive", + title: t("targetErrorInvalidPort"), + description: t("targetErrorInvalidPortDescription") + }); + return; + } + + if (!target.siteId) { + toast({ + variant: "destructive", + title: t("targetErrorNoSite"), + description: t("targetErrorNoSiteDescription") + }); + return; + } + + // Check if target with same IP, port and method already exists + const isDuplicate = targets.some( + (t) => + t.targetId !== target.targetId && + t.ip === target.ip && + t.port === target.port && + t.method === target.method && + t.siteId === target.siteId + ); + + if (isDuplicate) { + toast({ + variant: "destructive", + title: t("targetErrorDuplicate"), + description: t("targetErrorDuplicateDescription") + }); + return; + } + + try { + setTargetsLoading(true); + + const response = await api.post< + AxiosResponse + >(`/target`, { + resourceId: resource.resourceId, + siteId: target.siteId, + ip: target.ip, + method: target.method, + port: target.port, + path: target.path, + pathMatchType: target.pathMatchType, + rewritePath: target.rewritePath, + rewritePathType: target.rewritePathType, + priority: target.priority, + enabled: target.enabled, + hcEnabled: target.hcEnabled, + hcPath: target.hcPath, + hcInterval: target.hcInterval, + hcTimeout: target.hcTimeout + }); + + if (response.status === 200) { + // Update the target with the new ID and remove the new flag + setTargets((prev) => + prev.map((t) => + t.targetId === target.targetId + ? { + ...t, + targetId: response.data.data.targetId, + new: false, + updated: false + } + : t + ) + ); + + toast({ + title: t("targetCreated"), + description: t("targetCreatedDescription") + }); + } + } catch (err) { + console.error(err); + toast({ + variant: "destructive", + title: t("targetErrorCreate"), + description: formatAxiosError( + err, + t("targetErrorCreateDescription") + ) + }); + } finally { + setTargetsLoading(false); + } + } + async function addTarget(data: z.infer) { // Check if target with same IP, port and method already exists const isDuplicate = targets.some( @@ -509,16 +645,6 @@ export default function ReverseProxyTargets(props: { }; setTargets([...targets, newTarget]); - addTargetForm.reset({ - ip: "", - method: resource.http ? "http" : null, - port: "" as any as number, - path: null, - pathMatchType: null, - rewritePath: null, - rewritePathType: null, - priority: 100, - }); } const removeTarget = (targetId: number) => { @@ -568,6 +694,24 @@ export default function ReverseProxyTargets(props: { }; async function saveAllSettings() { + // 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) + ); + if (targetsWithInvalidFields.length > 0) { + toast({ + variant: "destructive", + title: t("targetErrorInvalidIp"), + description: t("targetErrorInvalidIpDescription") + }); + return; + } + try { setTargetsLoading(true); setHttpsTlsLoading(true); @@ -668,8 +812,10 @@ export default function ReverseProxyTargets(props: { } } - const columns: ColumnDef[] = [ - { + const getColumns = (): ColumnDef[] => { + const baseColumns: ColumnDef[] = []; + + const priorityColumn: ColumnDef = { id: "priority", header: () => (
@@ -680,7 +826,12 @@ export default function ReverseProxyTargets(props: { -

Higher priority routes are evaluated first. Priority = 100 means automatic ordering (system decides). Use another number to enforce manual priority.

+

+ Higher priority routes are evaluated first. + Priority = 100 means automatic ordering + (system decides). Use another number to + enforce manual priority. +

@@ -688,13 +839,13 @@ export default function ReverseProxyTargets(props: { ), cell: ({ row }) => { return ( -
+
{ const value = parseInt(e.target.value, 10); if (value >= 1 && value <= 1000) { @@ -707,371 +858,13 @@ export default function ReverseProxyTargets(props: { />
); - } - }, - { - 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={ - - } - /> - ); - } - }, - { - accessorKey: "siteId", - header: t("site"), - cell: ({ row }) => { - const selectedSite = sites.find( - (site) => site.siteId === row.original.siteId - ); - - const handleContainerSelectForTarget = ( - hostname: string, - port?: number - ) => { - updateTarget(row.original.targetId, { - ...row.original, - ip: hostname - }); - if (port) { - updateTarget(row.original.targetId, { - ...row.original, - port: port - }); - } - }; - - return ( -
- - - - - - - - - - {t("siteNotFound")} - - - {sites.map((site) => ( - { - updateTarget( - row.original - .targetId, - { - siteId: site.siteId - } - ); - }} - > - - {site.name} - - ))} - - - - - - {selectedSite && - selectedSite.type === "newt" && - (() => { - const dockerState = getDockerStateForSite( - selectedSite.siteId - ); - return ( - - refreshContainersForSite( - selectedSite.siteId - ) - } - /> - ); - })()} -
- ); - } - }, - ...(resource.http - ? [ - { - accessorKey: "method", - header: t("method"), - cell: ({ row }: { row: Row }) => ( - - ) - } - ] - : []), - { - accessorKey: "ip", - header: t("targetAddr"), - cell: ({ row }) => ( - { - const input = e.target.value.trim(); - const hasProtocol = /^(https?|h2c):\/\//.test(input); - const hasPort = /:\d+(?:\/|$)/.test(input); - - if (hasProtocol || hasPort) { - const parsed = parseHostTarget(input); - if (parsed) { - updateTarget(row.original.targetId, { - ...row.original, - method: hasProtocol - ? parsed.protocol - : row.original.method, - ip: parsed.host, - port: hasPort - ? parsed.port - : row.original.port - }); - } else { - updateTarget(row.original.targetId, { - ...row.original, - ip: input - }); - } - } else { - updateTarget(row.original.targetId, { - ...row.original, - ip: input - }); - } - }} - /> - ) - }, - { - accessorKey: "port", - header: t("targetPort"), - cell: ({ row }) => ( - - updateTarget(row.original.targetId, { - ...row.original, - port: parseInt(e.target.value, 10) - }) - } - /> - ) - }, - { - accessorKey: "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: 120, + minSize: 100, + maxSize: 150 + }; - // { - // accessorKey: "protocol", - // header: t('targetProtocol'), - // cell: ({ row }) => ( - // - // ), - // }, - { + const healthCheckColumn: ColumnDef = { accessorKey: "healthCheck", header: t("healthCheck"), cell: ({ row }) => { @@ -1115,74 +908,439 @@ export default function ReverseProxyTargets(props: { }; 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 + }); + if (port) { + updateTarget(row.original.targetId, { + ...row.original, + port: port + }); + } + }; + + return ( +
+
+ + + + + + + + + + {t("siteNotFound")} + + + {sites.map((site) => ( + + updateTarget( + row.original + .targetId, + { + siteId: site.siteId + } + ) + } + > + + {site.name} + + ))} + + + + + + {selectedSite && + selectedSite.type === "newt" && + (() => { + const dockerState = getDockerStateForSite( + selectedSite.siteId + ); + return ( + + refreshContainersForSite( + selectedSite.siteId + ) + } + /> + ); + })()} + + + +
+ {"://"} +
+ + { + 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 + }); } - className="h-6 w-6 p-0" - > - - + }} + /> +
+ {":"}
+ { + 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={ + + } + /> ) : ( - - {t("healthCheckNotAvailable")} - + + 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 - }) - } - /> - ) - }, - { +
+ + 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) { + return [ + matchPathColumn, + addressColumn, + rewritePathColumn, + priorityColumn, + healthCheckColumn, + enabledColumn, + actionsColumn + ]; + } else { + return [ + addressColumn, + healthCheckColumn, + enabledColumn, + actionsColumn + ]; } - ]; + }; + + const columns = getColumns(); const table = useReactTable({ data: targets, @@ -1213,352 +1371,9 @@ export default function ReverseProxyTargets(props: { -
-
- -
- ( - - - {t("site")} - -
- - - - - - - - - - - - {t( - "siteNotFound" - )} - - - {sites.map( - ( - site - ) => ( - { - addTargetForm.setValue( - "siteId", - site.siteId - ); - }} - > - - { - site.name - } - - ) - )} - - - - - - - {field.value && - (() => { - const selectedSite = - sites.find( - (site) => - site.siteId === - field.value - ); - return selectedSite && - selectedSite.type === - "newt" - ? (() => { - const dockerState = - getDockerStateForSite( - selectedSite.siteId - ); - return ( - - refreshContainersForSite( - selectedSite.siteId - ) - } - /> - ); - })() - : null; - })()} -
- -
- )} - /> - - {resource.http && ( - ( - - - {t("method")} - - - - - - - )} - /> - )} - - ( - - - {t("targetAddr")} - - - { - const input = - e.target.value.trim(); - const hasProtocol = - /^(https?|h2c):\/\//.test( - input - ); - const hasPort = - /:\d+(?:\/|$)/.test( - input - ); - - if ( - hasProtocol || - hasPort - ) { - const parsed = - parseHostTarget( - input - ); - if (parsed) { - if ( - hasProtocol || - !addTargetForm.getValues( - "method" - ) - ) { - addTargetForm.setValue( - "method", - parsed.protocol - ); - } - addTargetForm.setValue( - "ip", - parsed.host - ); - if ( - hasPort || - !addTargetForm.getValues( - "port" - ) - ) { - addTargetForm.setValue( - "port", - parsed.port - ); - } - } - } else { - field.onBlur(); - } - }} - /> - - - - )} - /> - ( - - - {t("targetPort")} - - - - - - - )} - /> - -
-
- -
- {targets.length > 0 ? ( <> -
- {t("targetsList")} -
- -
- - ( - - - { - field.onChange( - val - ); - }} - /> - - - )} - /> - - -
-
+
{table @@ -1626,12 +1441,40 @@ export default function ReverseProxyTargets(props: { {/* */}
+
+
+ +
+ + +
+
+
) : ( -
-

+

+

{t("targetNoOne")}

+
)} @@ -1669,6 +1512,9 @@ export default function ReverseProxyTargets(props: { label={t( "proxyEnableSSL" )} + description={t( + "proxyEnableSSLDescription" + )} defaultChecked={ field.value } @@ -1709,6 +1555,46 @@ export default function ReverseProxyTargets(props: { + +
+ + ( + + + { + field.onChange(val); + }} + /> + + + )} + /> + + +
+
( - + {t("customHeaders")} diff --git a/src/app/[orgId]/settings/resources/create/page.tsx b/src/app/[orgId]/settings/resources/create/page.tsx index 55a7a7bec..1036538d3 100644 --- a/src/app/[orgId]/settings/resources/create/page.tsx +++ b/src/app/[orgId]/settings/resources/create/page.tsx @@ -58,7 +58,7 @@ import { } from "@app/components/ui/popover"; import { CaretSortIcon, CheckIcon } from "@radix-ui/react-icons"; import { cn } from "@app/lib/cn"; -import { ArrowRight, Info, MoveRight, Plus, SquareArrowOutUpRight } from "lucide-react"; +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"; @@ -94,6 +94,9 @@ import { DomainRow } from "../../../../../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({ @@ -112,6 +115,11 @@ const tcpUdpResourceFormSchema = z.object({ // enableProxy: z.boolean().default(false) }); +const targetsSettingsSchema = z.object({ + stickySession: z.boolean() +}); + + const addTargetSchema = z.object({ ip: z.string().refine(isTargetValid), method: z.string().nullable(), @@ -215,6 +223,10 @@ export default function Page() { const [targetsToRemove, setTargetsToRemove] = useState([]); const [dockerStates, setDockerStates] = useState>(new Map()); + const [selectedTargetForHealthCheck, setSelectedTargetForHealthCheck] = + useState(null); + const [healthCheckDialogOpen, setHealthCheckDialogOpen] = useState(false); + const resourceTypes: ReadonlyArray = [ { id: "http", @@ -268,6 +280,13 @@ export default function Page() { } as z.infer }); + const targetsSettingsForm = useForm({ + resolver: zodResolver(targetsSettingsSchema), + defaultValues: { + stickySession: false + } + }); + const watchedIp = addTargetForm.watch("ip"); const watchedPort = addTargetForm.watch("port"); const watchedSiteId = addTargetForm.watch("siteId"); @@ -405,11 +424,13 @@ export default function Page() { const baseData = baseForm.getValues(); const isHttp = baseData.http; + const stickySessionData = targetsSettingsForm.getValues() try { const payload = { name: baseData.name, - http: baseData.http + http: baseData.http, + stickySession: stickySessionData.stickySession }; let sanitizedSubdomain: string | undefined; @@ -603,7 +624,42 @@ export default function Page() { 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 columns: ColumnDef[] = [ + { + accessorKey: "enabled", + header: t("enabled"), + cell: ({ row }) => ( + + updateTarget(row.original.targetId, { + ...row.original, + enabled: val + }) + } + /> + ) + }, { id: "priority", header: () => ( @@ -644,6 +700,82 @@ export default function Page() { ); } }, + { + 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" ? ( + +
+ + ) : ( + + {t("healthCheckNotAvailable")} + + )} + + ); + } + }, { accessorKey: "path", header: t("matchPath"), @@ -672,55 +804,37 @@ export default function Page() { } /> - - - {/* */} +
) : ( - updateTarget(row.original.targetId, config)} - trigger={ - - } - /> +
+ updateTarget(row.original.targetId, config)} + trigger={ + + } + /> + +
); - }, + } }, { - accessorKey: "siteId", - header: t("site"), + accessorKey: "address", + header: t("address"), cell: ({ row }) => { const selectedSite = sites.find( (site) => site.siteId === row.original.siteId ); - const handleContainerSelectForTarget = ( - hostname: string, - port?: number - ) => { + const handleContainerSelectForTarget = (hostname: string, port?: number) => { updateTarget(row.original.targetId, { ...row.original, ip: hostname @@ -734,180 +848,174 @@ export default function Page() { }; return ( -
- - - - - - - - - - {t("siteNotFound")} - - - {sites.map((site) => ( - { - updateTarget( - row.original - .targetId, - { - siteId: site.siteId - } - ); - }} - > - - {site.name} - - ))} - - - - - - {selectedSite && selectedSite.type === "newt" && (() => { - const dockerState = getDockerStateForSite(selectedSite.siteId); - return ( - refreshContainersForSite(selectedSite.siteId)} - /> - ); - })()} +
+ + + + + + + {t("siteNotFound")} + + {sites.map((site) => ( + + updateTarget(row.original.targetId, { siteId: site.siteId }) + } + > + + {site.name} + + ))} + + + + + + {selectedSite && + selectedSite.type === "newt" && + (() => { + const dockerState = getDockerStateForSite( + selectedSite.siteId + ); + return ( + + refreshContainersForSite( + selectedSite.siteId + ) + } + /> + ); + })()} + + + +
+ {"://"} +
+ + { + 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 + }); + } + }} + /> +
+ {":"} +
+ + updateTarget(row.original.targetId, { + ...row.original, + port: parseInt(e.target.value, 10) + }) + } + /> + +
); } }, - ...(baseForm.watch("http") - ? [ - { - accessorKey: "method", - header: t("method"), - cell: ({ row }: { row: Row }) => ( - - ) - } - ] - : []), - { - accessorKey: "ip", - header: t("targetAddr"), - cell: ({ row }) => ( - { - const input = e.target.value.trim(); - const hasProtocol = /^(https?|h2c):\/\//.test(input); - const hasPort = /:\d+(?:\/|$)/.test(input); - - if (hasProtocol || hasPort) { - const parsed = parseHostTarget(input); - if (parsed) { - updateTarget(row.original.targetId, { - ...row.original, - method: hasProtocol ? parsed.protocol : row.original.method, - ip: parsed.host, - port: hasPort ? parsed.port : row.original.port - }); - } else { - updateTarget(row.original.targetId, { - ...row.original, - ip: input - }); - } - } else { - updateTarget(row.original.targetId, { - ...row.original, - ip: input - }); - } - }} - /> - ) - }, - { - accessorKey: "port", - header: t("targetPort"), - cell: ({ row }) => ( - - updateTarget(row.original.targetId, { - ...row.original, - port: parseInt(e.target.value, 10) - }) - } - /> - ) - }, { accessorKey: "rewritePath", header: t("rewritePath"), cell: ({ row }) => { - const hasRewritePath = !!(row.original.rewritePath || row.original.rewritePathType); - const noPathMatch = !row.original.path && !row.original.pathMatchType; + const hasRewritePath = !!( + row.original.rewritePath || row.original.rewritePathType + ); + const noPathMatch = + !row.original.path && !row.original.pathMatchType; return hasRewritePath && !noPathMatch ? (
- {/* */} updateTarget(row.original.targetId, config)} + onChange={(config) => + updateTarget(row.original.targetId, config) + } trigger={ } /> -
) : ( updateTarget(row.original.targetId, config)} + onChange={(config) => + updateTarget(row.original.targetId, config) + } trigger={