diff --git a/src/modules/reverse-proxy/targets/ReverseProxyTargetModal.tsx b/src/modules/reverse-proxy/targets/ReverseProxyTargetModal.tsx
index 5df5a2168..88ff09b27 100644
--- a/src/modules/reverse-proxy/targets/ReverseProxyTargetModal.tsx
+++ b/src/modules/reverse-proxy/targets/ReverseProxyTargetModal.tsx
@@ -1,5 +1,11 @@
"use client";
+import {
+ Accordion,
+ AccordionContent,
+ AccordionItem,
+ AccordionTrigger,
+} from "@components/Accordion";
import Button from "@components/Button";
import FancyToggleSwitch from "@components/FancyToggleSwitch";
import HelpText from "@components/HelpText";
@@ -7,22 +13,16 @@ import { Input } from "@components/Input";
import { Label } from "@components/Label";
import { Modal, ModalContent, ModalFooter } from "@components/modal/Modal";
import ModalHeader from "@components/modal/ModalHeader";
-import { PeerGroupSelector } from "@components/PeerGroupSelector";
import { SelectDropdown } from "@components/select/SelectDropdown";
-import { Tabs, TabsContent, TabsList, TabsTrigger } from "@components/Tabs";
-import useFetchApi from "@utils/api";
import {
AlertTriangle,
ClockFadingIcon,
ExternalLinkIcon,
PlusCircle,
Server,
- Settings,
ShieldXIcon,
- Text,
} from "lucide-react";
import { Callout } from "@components/Callout";
-import cidr from "ip-cidr";
import React, { useMemo, useRef, useState } from "react";
import { Network, NetworkResource } from "@/interfaces/Network";
import { Peer } from "@/interfaces/Peer";
@@ -32,6 +32,7 @@ import {
ReverseProxyTarget,
ReverseProxyTargetProtocol,
ReverseProxyTargetType,
+ ServiceMode,
ServiceTargetOptionsPathRewrite,
} from "@/interfaces/ReverseProxy";
import {
@@ -40,11 +41,18 @@ import {
} from "@/contexts/ReverseProxiesProvider";
import { cn } from "@utils/helpers";
import { HelpTooltip } from "@components/HelpTooltip";
-import InlineLink, { InlineButtonLink } from "@components/InlineLink";
-import SetupModal from "@/modules/setup-netbird-modal/SetupModal";
+import InlineLink from "@components/InlineLink";
import Paragraph from "@components/Paragraph";
import ReverseProxyTargetCustomHeaders from "@/modules/reverse-proxy/targets/ReverseProxyTargetCustomHeaders";
+import ReverseProxyTargetSelector, {
+ Target,
+} from "@/modules/reverse-proxy/targets/ReverseProxyTargetSelector";
import { useReverseProxyTargetOptions } from "@/modules/reverse-proxy/targets/useReverseProxyTargetOptions";
+import ReverseProxyAddressInput, {
+ CidrHelpText,
+ useReverseProxyAddress,
+} from "@/modules/reverse-proxy/targets/ReverseProxyAddressInput";
+import Separator from "@components/Separator";
/** Get initial host value based on target, resource, or peer */
function getInitialHost(
@@ -85,38 +93,33 @@ export default function ReverseProxyTargetModal({
}: Readonly
) {
const existingTargets = reverseProxy.targets || [];
const domain = reverseProxy.domain;
- // Fetch resources and peers for target selection
- const { data: resources } = useFetchApi(
- "/networks/resources",
- );
- const { data: peers } = useFetchApi("/peers");
- const [tab, setTab] = useState("details");
-
- const [targetType, setTargetType] = useState(
- currentTarget?.target_type ??
- (initialResource
- ? (initialResource.type as ReverseProxyTargetType) ??
- ReverseProxyTargetType.HOST
- : ReverseProxyTargetType.PEER),
- );
- const [targetPeerId, setTargetPeerId] = useState(
- currentTarget?.target_type === ReverseProxyTargetType.PEER
- ? currentTarget?.target_id
- : initialPeer?.id,
- );
- const [targetResourceId, setTargetResourceId] = useState(
- currentTarget && isResourceTargetType(currentTarget.target_type)
- ? currentTarget?.target_id
- : initialResource?.id,
+ const [target, setTarget] = useState(
+ currentTarget || initialResource || initialPeer
+ ? {
+ type:
+ currentTarget?.target_type ??
+ (initialResource
+ ? (initialResource.type as ReverseProxyTargetType) ??
+ ReverseProxyTargetType.HOST
+ : ReverseProxyTargetType.PEER),
+ peerId:
+ currentTarget?.target_type === ReverseProxyTargetType.PEER
+ ? currentTarget?.target_id
+ : initialPeer?.id,
+ resourceId:
+ currentTarget && isResourceTargetType(currentTarget.target_type)
+ ? currentTarget?.target_id
+ : initialResource?.id,
+ host: getInitialHost(currentTarget, initialResource, initialPeer),
+ }
+ : undefined,
);
+
const [targetProtocol, setTargetProtocol] =
useState(
currentTarget?.protocol ?? ReverseProxyTargetProtocol.HTTP,
);
- const [targetHost, setTargetHost] = useState(
- getInitialHost(currentTarget, initialResource, initialPeer),
- );
const [targetPort, setTargetPort] = useState(
currentTarget?.port ?? 0,
);
@@ -125,50 +128,9 @@ export default function ReverseProxyTargetModal({
const [options, setOption, { getTargetOptions, headers, errors }] =
useReverseProxyTargetOptions(currentTarget?.options);
const portInputRef = useRef(null);
- const [installModal, setInstallModal] = useState(false);
-
- // Get the current resource's address (from initialResource or selected resource)
- const currentResourceAddress = useMemo(() => {
- if (initialResource) return initialResource.address;
- if (targetResourceId) {
- const resource = resources?.find((r) => r.id === targetResourceId);
- return resource?.address || "";
- }
- return "";
- }, [initialResource, targetResourceId, resources]);
-
- // Parse the CIDR using ip-cidr library
- const cidrInfo = useMemo(() => {
- if (!currentResourceAddress) return null;
- if (!cidr.isValidCIDR(currentResourceAddress)) return null;
- try {
- return new cidr(currentResourceAddress);
- } catch {
- return null;
- }
- }, [currentResourceAddress]);
-
- // Get the CIDR mask (e.g., 24 for /24)
- const cidrMask = useMemo(() => {
- if (!cidrInfo) return null;
- const parts = currentResourceAddress.split("/");
- return parts.length === 2 ? parseInt(parts[1], 10) : 32;
- }, [cidrInfo, currentResourceAddress]);
-
- // Check if address is a CIDR range (has more than one address)
- const isCidrRange = useMemo(() => {
- return cidrMask !== null && cidrMask < 32;
- }, [cidrMask]);
- // Host should be editable if it's a CIDR range with multiple addresses
- const isHostEditable = isCidrRange;
-
- // Validate if current host is within CIDR range
- const isHostInCidrRange = useMemo(() => {
- if (!cidrInfo || !targetHost) return false;
- if (!cidr.isValidAddress(targetHost)) return false;
- return cidrInfo.contains(targetHost);
- }, [cidrInfo, targetHost]);
+ const { isCidrRange, isHostEditable, isValidCidrHost } =
+ useReverseProxyAddress(target);
// Normalize path for comparison (ensure it starts with / and handle empty as /)
const normalizePath = (path: string | undefined) => {
@@ -196,7 +158,6 @@ export default function ReverseProxyTargetModal({
const isValidPort =
targetPort === 0 || (targetPort >= 1 && targetPort <= 65535);
- const isValidCidrHost = !isCidrRange || (targetHost && isHostInCidrRange);
const canAddTarget = useMemo(() => {
// Don't allow if path is duplicate or port is invalid
@@ -209,11 +170,12 @@ export default function ReverseProxyTargetModal({
if (initialPeer) {
return true;
}
- if (targetType === ReverseProxyTargetType.PEER) {
- return !!targetPeerId;
+ if (!target) return false;
+ if (target.type === ReverseProxyTargetType.PEER) {
+ return !!target.peerId;
}
- if (isResourceTargetType(targetType)) {
- return !!targetResourceId && isValidCidrHost;
+ if (isResourceTargetType(target.type)) {
+ return !!target.resourceId && isValidCidrHost;
}
return false;
}, [
@@ -221,28 +183,30 @@ export default function ReverseProxyTargetModal({
isValidPort,
initialResource,
initialPeer,
- targetType,
- targetPeerId,
- targetResourceId,
+ target,
isValidCidrHost,
]);
- const hasTarget =
- initialResource || initialPeer || targetPeerId || targetResourceId;
+ const hasTarget = !!(initialResource || initialPeer || target);
const handleSave = () => {
- const resolvedType = initialPeer ? ReverseProxyTargetType.PEER : targetType;
+ if (!target) return;
+ const resolvedType = initialPeer
+ ? ReverseProxyTargetType.PEER
+ : target.type;
const resolvedIsResource =
isResourceTargetType(resolvedType) || !!initialResource;
const targetData: ReverseProxyTarget = {
target_type: resolvedType,
target_id:
resolvedType === ReverseProxyTargetType.PEER
- ? targetPeerId
- : targetResourceId,
+ ? target.peerId
+ : target.resourceId,
protocol: targetProtocol,
host:
- resolvedType === ReverseProxyTargetType.SUBNET ? targetHost : undefined,
+ resolvedType === ReverseProxyTargetType.SUBNET
+ ? target.host
+ : undefined,
port: targetPort,
path: targetPath || undefined,
enabled: currentTarget?.enabled ?? true,
@@ -264,397 +228,291 @@ export default function ReverseProxyTargetModal({
color="netbird"
/>
-
-
-
-
- Details
-
-
-
- Advanced Settings
-
-
+
+
+
+ {!initialResource && !initialPeer && (
+
{
+ setTarget(selection);
+ if (selection) {
+ setTimeout(() => portInputRef.current?.focus(), 0);
+ }
+ }}
+ />
+ )}
-
-
- {!initialResource && !initialPeer && (
-
-
- {initialNetwork ? (
- "Select Resource"
- ) : (
- <>
- Select{" "}
-
- A{" "}
-
- peer
+
+
Location (Optional)
+
+ Specify an optional path from where requests are routed to your
+ service.
+
+
+
+ {domain || "domain.example.com"}
+
+
{
+ let value = e.target.value;
+ if (value && !value.startsWith("/")) {
+ value = "/" + value;
+ }
+ setTargetPath(value);
+ if (!value || value === "/") {
+ setOption("path_rewrite", undefined);
+ }
+ }}
+ />
+
+ {isPathDuplicate && hasTarget && (
+
+ }
+ >
+ This location is already used by another target and cannot be
+ added.
Please use a different location.
+
+ )}
+ {targetPath &&
+ targetPath !== "/" &&
+ hasTarget &&
+ !isPathDuplicate && (
+
+ setOption(
+ "path_rewrite",
+ v
+ ? ("preserve" as ServiceTargetOptionsPathRewrite)
+ : undefined,
+ )
+ }
+ className={"mt-3.5"}
+ label={
+ <>
+ Preserve Full Path
+
+
+ When disabled, a request to e.g.,{" "}
+
+ {targetPath}/users
{" "}
- is a machine (e.g., laptop, server, container)
- running NetBird. Select a peer if your service
- runs directly on it.
-
- If you don't have a peer yet, you can{" "}
- setInstallModal(true)}
- >
- Install NetBird
-
- .
+ is forwarded as{" "}
+
+ /users
- >
- }
- interactive={true}
- >
- Peer
- {" "}
- or{" "}
-
- A{" "}
-
- resource
+ .
+
+
+ When enabled, a request to e.g.,{" "}
+
+ {targetPath}/users
{" "}
- is a destination (IP, subnet, or domain) that
- can't run NetBird directly. Resources are
- part of a network and are reached through a
- routing peer that forwards traffic to them.
-
- If you don't have resources yet, go to{" "}
-
- Networks
- {" "}
- to create some.
+ is forwarded as{" "}
+
+ {targetPath}/users
- >
- }
- interactive={true}
- >
- Resource
-
- >
- )}
-
-
-
- {initialNetwork
- ? "Select the resource from your network you want to expose."
- : "Select the peer where your service is running or select a resource to expose it."}
-
- {}}
- placeholder={
- initialNetwork
- ? "Select a resource..."
- : "Select a peer or resource..."
- }
- showPeers={!initialNetwork}
- showResources={true}
- showRoutes={false}
- hideAllGroup={true}
- hideGroupsTab={true}
- resourceIds={
- initialNetwork
- ? initialNetwork.resources ?? []
- : undefined
- }
- tabOrder={
- initialNetwork ? ["resources"] : ["peers", "resources"]
- }
- closeOnSelect={true}
- max={1}
- resource={
- isResourceTargetType(targetType) && targetResourceId
- ? { id: targetResourceId, type: "host" }
- : targetType === ReverseProxyTargetType.PEER &&
- targetPeerId
- ? { id: targetPeerId, type: "peer" }
- : undefined
- }
- onResourceChange={(res) => {
- if (res) {
- if (res.type === "peer") {
- setTargetType(ReverseProxyTargetType.PEER);
- setTargetPeerId(res.id);
- setTargetResourceId(undefined);
- const peer = peers?.find((p) => p.id === res.id);
- setTargetHost(peer?.ip || "localhost");
- } else {
- const selectedResource = resources?.find(
- (r) => r.id === res.id,
- );
- setTargetType(
- (selectedResource?.type as ReverseProxyTargetType) ??
- ReverseProxyTargetType.HOST,
- );
- setTargetResourceId(res.id);
- setTargetPeerId(undefined);
- const address = selectedResource?.address || "";
- // If CIDR range, pre-fill with base IP
- if (address.includes("/")) {
- setTargetHost(address.split("/")[0]);
- } else {
- setTargetHost(address);
- }
+ .
+
+
}
- setTimeout(() => portInputRef.current?.focus(), 0);
- } else {
- setTargetPeerId(undefined);
- setTargetResourceId(undefined);
- setTargetHost("");
- }
- }}
- />
-
+ />
+ >
+ }
+ helpText={
+
+ Keep the original full request path when forwarding.{" "}
+
+ When disabled the matched prefix path is stripped.
+
+ }
+ />
)}
+
-
-
Location (Optional)
-
- Specify an optional path from where requests are routed to
- your service.
-
-
-
- {domain || "domain.example.com"}
+
+
+
+
+ Protocol & Host / IP
+
+
+
+
+ {
+ const proto = v as ReverseProxyTargetProtocol;
+ setTargetProtocol(proto);
+ if (proto !== ReverseProxyTargetProtocol.HTTPS) {
+ setOption("skip_tls_verify", undefined);
+ }
+ }}
+ options={[
+ {
+ value: ReverseProxyTargetProtocol.HTTP,
+ label: "http://",
+ },
+ {
+ value: ReverseProxyTargetProtocol.HTTPS,
+ label: "https://",
+ },
+ ]}
+ className="!rounded-r-none !border-r-0"
+ disabled={!hasTarget}
+ />
+
+
+
+
+
+
+
+ Port
+
+
+
+ setTargetPort(parseInt(e.target.value) || 0)
+ }
+ placeholder={String(
+ defaultPortForProtocol(targetProtocol),
+ )}
+ min={0}
+ max={65535}
disabled={!hasTarget}
- onChange={(e) => {
- let value = e.target.value;
- if (value && !value.startsWith("/")) {
- value = "/" + value;
- }
- setTargetPath(value);
- if (!value || value === "/") {
- setOption("path_rewrite", undefined);
- }
- }}
+ autoFocus={!!initialResource && !isHostEditable}
/>
- {isPathDuplicate && hasTarget && (
-
- }
- >
- This location is already used by another target and cannot
- be added.
Please use a different location.
-
- )}
- {targetPath &&
- targetPath !== "/" &&
- hasTarget &&
- !isPathDuplicate && (
-
+
+
+ {targetProtocol === ReverseProxyTargetProtocol.HTTPS &&
+ hasTarget && (
+
+ setOption("skip_tls_verify", v || undefined)
+ }
+ label={
+ <>
+
+ Skip TLS Verification
+ >
+ }
+ helpText="Skip certificate verification when connecting to this target. Useful if your service already uses a self-signed certificate."
+ />
+ )}
+
+
+
+
+
+
+ Optional Settings
+
+
+
+
+
+
+ Request Timeout
+
+ Max time to wait for a response as duration string
+ (max 5m). Leave this field empty for no
+ timeout.
+
+
+
}
+ placeholder="e.g. 10s, 30s, 1m"
+ value={options.request_timeout ?? ""}
+ onChange={(e) =>
setOption(
- "path_rewrite",
- v
- ? ("preserve" as ServiceTargetOptionsPathRewrite)
- : undefined,
+ "request_timeout",
+ e.target.value || undefined,
)
}
- className={"mt-3.5"}
- label={
- <>
- Preserve Full Path
-
-
- When disabled, a request to e.g.,{" "}
-
- {targetPath}/users
- {" "}
- is forwarded as{" "}
-
- /users
-
- .
-
-
- When enabled, a request to e.g.,{" "}
-
- {targetPath}/users
- {" "}
- is forwarded as{" "}
-
- {targetPath}/users
-
- .
-
-
- }
- />
- >
- }
- helpText={
-
- Keep the original full request path when forwarding.{" "}
-
- When disabled the matched prefix path is stripped.
-
- }
+ maxWidthClass="w-[180px]"
+ errorTooltip={true}
+ error={errors.timeout}
/>
- )}
-
+
-
-
-
-
Protocol & Host / IP
- {cidrInfo && (
-
- Enter an IP address within {currentResourceAddress}
-
- )}
-
-
-
{
- const proto = v as ReverseProxyTargetProtocol;
- setTargetProtocol(proto);
- if (proto !== ReverseProxyTargetProtocol.HTTPS) {
- setOption("skip_tls_verify", undefined);
- }
- }}
- options={[
- {
- value: ReverseProxyTargetProtocol.HTTP,
- label: "http://",
- },
- {
- value: ReverseProxyTargetProtocol.HTTPS,
- label: "https://",
- },
- ]}
- className="!rounded-r-none !border-r-0"
- disabled={!hasTarget}
- />
+ {reverseProxy.mode === ServiceMode.UDP && (
+
+
+ Session Idle Timeout
+
+ How long a UDP session stays alive without traffic
+ (max 10m). Defaults to 30s when empty.
+
-
- {
- // Only allow valid IP characters for CIDR ranges
- const value = isHostEditable
- ? e.target.value.replace(/[^0-9.]/g, "")
- : e.target.value;
- setTargetHost(value);
- }}
- placeholder="e.g., 192.168.0.10"
- className="!rounded-l-none"
- disabled={!hasTarget}
- readOnly={
- hasTarget && !isHostEditable ? true : undefined
- }
- autoFocus={!!initialResource && isHostEditable}
- />
-
-
-
-
-
- Port
-
-
- {cidrInfo && (
-
- )}
-
}
+ placeholder="e.g. 30s, 2m, 5m"
+ value={options.session_idle_timeout ?? ""}
onChange={(e) =>
- setTargetPort(parseInt(e.target.value) || 0)
+ setOption(
+ "session_idle_timeout",
+ e.target.value || undefined,
+ )
}
- placeholder={String(
- defaultPortForProtocol(targetProtocol),
- )}
- min={0}
- max={65535}
- disabled={!hasTarget}
- autoFocus={!!initialResource && !isHostEditable}
+ maxWidthClass="w-[180px]"
+ errorTooltip={true}
+ error={errors.sessionIdleTimeout}
/>
-
-
- {targetProtocol === ReverseProxyTargetProtocol.HTTPS &&
- hasTarget && (
-
- setOption("skip_tls_verify", v || undefined)
- }
- label={
- <>
-
- Skip TLS Verification
- >
- }
- helpText="Skip certificate verification when connecting to this target. Useful if your service already uses a self-signed certificate."
- />
)}
-
-
-
-
-
-
-
- Request Timeout
-
- Max time to wait for a response as duration string (max
- 5m). Leave this field empty for no timeout.
-
+
-
}
- placeholder="e.g. 10s, 30s, 1m"
- value={options.request_timeout ?? ""}
- onChange={(e) =>
- setOption("request_timeout", e.target.value || undefined)
- }
- maxWidthClass="w-[180px]"
- errorTooltip={true}
- error={errors.timeout}
- />
-
-
-
-
-
-
+
+
+
+
@@ -670,69 +528,27 @@ export default function ReverseProxyTargetModal({
- {currentTarget ? (
- <>
-
onOpenChange(false)}
- >
- Cancel
-
-
- Save Changes
-
- >
- ) : (
- <>
- {tab === "details" && (
- <>
-
onOpenChange(false)}
- >
- Cancel
-
-
setTab("options")}
- disabled={!canAddTarget}
- >
- Continue
-
- >
- )}
- {tab === "options" && (
- <>
-
setTab("details")}
- >
- Back
-
-
-
- Add Target
-
- >
- )}
- >
- )}
+
onOpenChange(false)}>
+ Cancel
+
+
+ {currentTarget ? (
+ "Save Changes"
+ ) : (
+ <>
+
+ Add Target
+ >
+ )}
+
-
-
-
-
>
);
}
diff --git a/src/modules/reverse-proxy/targets/ReverseProxyTargetSelector.tsx b/src/modules/reverse-proxy/targets/ReverseProxyTargetSelector.tsx
new file mode 100644
index 000000000..da59fa480
--- /dev/null
+++ b/src/modules/reverse-proxy/targets/ReverseProxyTargetSelector.tsx
@@ -0,0 +1,151 @@
+"use client";
+
+import HelpText from "@components/HelpText";
+import { Label } from "@components/Label";
+import { Modal } from "@components/modal/Modal";
+import { PeerGroupSelector } from "@components/PeerGroupSelector";
+import React, { useState } from "react";
+import { Network } from "@/interfaces/Network";
+import { ReverseProxyTargetType } from "@/interfaces/ReverseProxy";
+import {
+ isResourceTargetType,
+ useReverseProxies,
+} from "@/contexts/ReverseProxiesProvider";
+import { HelpTooltip } from "@components/HelpTooltip";
+import InlineLink, { InlineButtonLink } from "@components/InlineLink";
+import SetupModal from "@/modules/setup-netbird-modal/SetupModal";
+
+export type Target = {
+ type: ReverseProxyTargetType;
+ peerId?: string;
+ resourceId?: string;
+ host: string;
+};
+
+type Props = {
+ value?: Target;
+ initialNetwork?: Network;
+ onChange: (value: Target | undefined) => void;
+};
+
+export default function ReverseProxyTargetSelector({
+ value,
+ initialNetwork,
+ onChange,
+}: Readonly
) {
+ const { resources, peers } = useReverseProxies();
+ const [installModal, setInstallModal] = useState(false);
+
+ return (
+
+
+ {initialNetwork ? (
+ "Select Resource"
+ ) : (
+ <>
+ Select{" "}
+
+ A peer is a
+ machine (e.g., laptop, server, container) running NetBird.
+ Select a peer if your service runs directly on it.
+
+ If you don't have a peer yet, you can{" "}
+ setInstallModal(true)}>
+ Install NetBird
+
+ .
+
+ >
+ }
+ interactive={true}
+ >
+ Peer
+ {" "}
+ or{" "}
+
+ A resource {" "}
+ is a destination (IP, subnet, or domain) that can't run
+ NetBird directly. Resources are part of a network and are
+ reached through a routing peer that forwards traffic to them.
+
+ If you don't have resources yet, go to{" "}
+ Networks to
+ create some.
+
+ >
+ }
+ interactive={true}
+ >
+ Resource
+
+ >
+ )}
+
+
+ {initialNetwork
+ ? "Select the resource from your network you want to expose."
+ : "Select the peer or resource where your service is running."}
+
+
{}}
+ placeholder={
+ initialNetwork
+ ? "Select a resource..."
+ : "Select a peer or resource..."
+ }
+ showPeers={!initialNetwork}
+ showResources={true}
+ showRoutes={false}
+ hideAllGroup={true}
+ hideGroupsTab={true}
+ resourceIds={
+ initialNetwork ? initialNetwork.resources ?? [] : undefined
+ }
+ tabOrder={initialNetwork ? ["resources"] : ["peers", "resources"]}
+ closeOnSelect={true}
+ max={1}
+ resource={
+ value?.type && isResourceTargetType(value.type) && value.resourceId
+ ? { id: value.resourceId, type: value.type }
+ : value?.type === ReverseProxyTargetType.PEER && value.peerId
+ ? { id: value.peerId, type: "peer" }
+ : undefined
+ }
+ onResourceChange={(res) => {
+ if (res) {
+ if (res.type === "peer") {
+ const peer = peers?.find((p) => p.id === res.id);
+ onChange({
+ type: ReverseProxyTargetType.PEER,
+ peerId: res.id,
+ host: peer?.ip || "localhost",
+ });
+ } else {
+ const selectedResource = resources?.find((r) => r.id === res.id);
+ const address = selectedResource?.address || "";
+ onChange({
+ type:
+ (selectedResource?.type as ReverseProxyTargetType) ??
+ ReverseProxyTargetType.HOST,
+ resourceId: res.id,
+ host: address.includes("/") ? address.split("/")[0] : address,
+ });
+ }
+ } else {
+ onChange(undefined);
+ }
+ }}
+ />
+
+
+
+
+ );
+}
diff --git a/src/modules/reverse-proxy/targets/ReverseProxyTargetsTable.tsx b/src/modules/reverse-proxy/targets/ReverseProxyTargetsTable.tsx
index e7dc5a0da..063cd6733 100644
--- a/src/modules/reverse-proxy/targets/ReverseProxyTargetsTable.tsx
+++ b/src/modules/reverse-proxy/targets/ReverseProxyTargetsTable.tsx
@@ -74,6 +74,7 @@ export default function ReverseProxyTargetsTable({ reverseProxy }: Props) {
className={"bg-nb-gray-960 py-2"}
inset={true}
text={"Targets"}
+ initialPageSize={reverseProxy?.targets?.length}
manualPagination={true}
sorting={sorting}
columnVisibility={{}}
diff --git a/src/modules/reverse-proxy/targets/flat/ReverseProxyFlatTargetActionCell.tsx b/src/modules/reverse-proxy/targets/flat/ReverseProxyFlatTargetActionCell.tsx
index cfdc14909..a7f8c625a 100644
--- a/src/modules/reverse-proxy/targets/flat/ReverseProxyFlatTargetActionCell.tsx
+++ b/src/modules/reverse-proxy/targets/flat/ReverseProxyFlatTargetActionCell.tsx
@@ -9,7 +9,7 @@ import { MoreVertical, Settings, SquarePenIcon, Trash2 } from "lucide-react";
import * as React from "react";
import { usePermissions } from "@/contexts/PermissionsProvider";
import { useReverseProxies } from "@/contexts/ReverseProxiesProvider";
-import { ReverseProxyFlatTarget } from "@/interfaces/ReverseProxy";
+import { isL4Mode, ReverseProxyFlatTarget } from "@/interfaces/ReverseProxy";
type Props = {
target: ReverseProxyFlatTarget;
@@ -40,7 +40,11 @@ export default function ReverseProxyFlatTargetActionCell({
{
e.stopPropagation();
- openTargetModal({ proxy: target.proxy, target: target });
+ if (isL4Mode(target.proxy.mode)) {
+ openModal({ proxy: target.proxy });
+ } else {
+ openTargetModal({ proxy: target.proxy, target: target });
+ }
}}
disabled={!permission?.services?.update}
>
@@ -57,9 +61,9 @@ export default function ReverseProxyFlatTargetActionCell({
}}
disabled={!permission?.services?.update}
>
-
+
- Settings
+ Advanced Settings
diff --git a/src/modules/reverse-proxy/targets/flat/ReverseProxyFlatTargetsTable.tsx b/src/modules/reverse-proxy/targets/flat/ReverseProxyFlatTargetsTable.tsx
index dd1ea28a2..3f6861cf5 100644
--- a/src/modules/reverse-proxy/targets/flat/ReverseProxyFlatTargetsTable.tsx
+++ b/src/modules/reverse-proxy/targets/flat/ReverseProxyFlatTargetsTable.tsx
@@ -44,14 +44,15 @@ const FlatTargetsTableColumns: ColumnDef
[] = [
: `/${target.path}`
: "";
const fullUrl = `${target.proxy.domain}${path}`;
- const disabled = target.enabled === false;
- const isEnabled = target.proxy.enabled && target.enabled !== false;
+ const disabled = !target.enabled;
+ const isEnabled = target.proxy.enabled && target.enabled;
return (
@@ -62,7 +63,7 @@ const FlatTargetsTableColumns: ColumnDef[] = [
accessorKey: "arrow",
header: "",
cell: ({ row }) => (
-
+
),
},
{
@@ -120,7 +121,13 @@ const FlatTargetsTableColumns: ColumnDef[] = [
{
id: "searchString",
accessorFn: (row) => {
- return [row.proxy.domain, row.destination, row.host, row.port, row.path].join("");
+ return [
+ row.proxy.domain,
+ row.destination,
+ row.host,
+ row.port,
+ row.path,
+ ].join("");
},
},
];
diff --git a/src/modules/reverse-proxy/targets/useReverseProxyTargetOptions.ts b/src/modules/reverse-proxy/targets/useReverseProxyTargetOptions.ts
index 79f797127..75dd70b1a 100644
--- a/src/modules/reverse-proxy/targets/useReverseProxyTargetOptions.ts
+++ b/src/modules/reverse-proxy/targets/useReverseProxyTargetOptions.ts
@@ -8,6 +8,7 @@ import {
// Go time.ParseDuration format: one or more {number}{unit} pairs
const DURATION_RE = /^(\d+(\.\d+)?(ns|us|µs|ms|s|m|h))+$/;
const MAX_TIMEOUT_MS = 5 * 60 * 1000; // 5m
+const MAX_SESSION_IDLE_TIMEOUT_MS = 10 * 60 * 1000; // 10m
function parseDurationMs(duration: string): number {
const units: Record = {
@@ -28,7 +29,7 @@ function parseDurationMs(duration: string): number {
return total;
}
-function validateTimeout(timeout: string): string | undefined {
+export function validateTimeout(timeout: string): string | undefined {
if (!timeout) return undefined;
if (!DURATION_RE.test(timeout))
return 'Invalid duration, use e.g., "10s", "30s", "1m"';
@@ -37,6 +38,15 @@ function validateTimeout(timeout: string): string | undefined {
return undefined;
}
+export function validateSessionIdleTimeout(timeout: string): string | undefined {
+ if (!timeout) return undefined;
+ if (!DURATION_RE.test(timeout))
+ return 'Invalid duration, use e.g., "30s", "2m", "5m"';
+ if (parseDurationMs(timeout) > MAX_SESSION_IDLE_TIMEOUT_MS)
+ return "Session idle timeout cannot exceed the maximum of 10m.";
+ return undefined;
+}
+
export function useReverseProxyTargetOptions(
initialOptions?: ServiceTargetOptions,
) {
@@ -67,7 +77,11 @@ export function useReverseProxyTargetOptions(
);
const timeoutError = validateTimeout(targetOptions.request_timeout ?? "");
- const hasOptionsErrors = !!timeoutError || hasHeaderErrors;
+ const sessionIdleTimeoutError = validateSessionIdleTimeout(
+ targetOptions.session_idle_timeout ?? "",
+ );
+ const hasOptionsErrors =
+ !!timeoutError || !!sessionIdleTimeoutError || hasHeaderErrors;
const getTargetOptions = useCallback((): ServiceTargetOptions | undefined => {
const customHeaders = headerEntriesToRecord(headerEntries);
@@ -94,6 +108,7 @@ export function useReverseProxyTargetOptions(
},
errors: {
timeout: timeoutError,
+ sessionIdleTimeout: sessionIdleTimeoutError,
options: hasOptionsErrors,
},
},
diff --git a/src/utils/helpers.ts b/src/utils/helpers.ts
index b212fa90f..fcddc9258 100644
--- a/src/utils/helpers.ts
+++ b/src/utils/helpers.ts
@@ -257,3 +257,16 @@ export const singularize = (
}
return count + " " + word;
};
+
+/**
+ * Converts milliseconds to human-readable duration (ms, s, m)
+ * @param ms Duration in milliseconds
+ * @returns Formatted string with appropriate unit
+ */
+export const formatDuration = (ms: number): string => {
+ if (!Number.isFinite(ms) || ms < 0) return "0ms";
+ if (ms < 1000) return `${ms}ms`;
+ if (ms < 60000) return `${(ms / 1000).toFixed(1)}s`;
+ if (ms < 3600000) return `${(ms / 60000).toFixed(1)}m`;
+ return `${(ms / 3600000).toFixed(1)}h`;
+};