Add Exit Nodes (#374)

* Add exit node feature

* Fix spelling

* Hide masquerade for exit nodes

* Add exit node information to peers list

* Change exit node button, add indicator to peers table

* Add steps to route modal

* Add hook to check if peer has exit nodes

* Hide exit node indicator for regular users

* Add documentation links
This commit is contained in:
Eduard Gert
2024-04-17 13:11:38 +02:00
committed by GitHub
parent fc3da50346
commit 2272a1d2a4
19 changed files with 535 additions and 169 deletions

View File

@@ -57,6 +57,8 @@ import { getOperatingSystem } from "@/hooks/useOperatingSystem";
import { OperatingSystem } from "@/interfaces/OperatingSystem";
import type { Peer } from "@/interfaces/Peer";
import PageContainer from "@/layouts/PageContainer";
import { AddExitNodeButton } from "@/modules/exit-node/AddExitNodeButton";
import { useHasExitNodes } from "@/modules/exit-node/useHasExitNodes";
import useGroupHelper from "@/modules/groups/useGroupHelper";
import AddRouteDropdownButton from "@/modules/peer/AddRouteDropdownButton";
import PeerRoutesTable from "@/modules/peer/PeerRoutesTable";
@@ -127,6 +129,7 @@ function PeerOverview() {
};
const { isUser } = useLoggedInUser();
const hasExitNodes = useHasExitNodes(peer);
return (
<PageContainer>
@@ -342,7 +345,8 @@ function PeerOverview() {
</Paragraph>
</div>
<div className={"inline-flex gap-4 justify-end"}>
<div>
<div className={"gap-4 flex"}>
<AddExitNodeButton peer={peer} firstTime={!hasExitNodes} />
<AddRouteDropdownButton />
</div>
</div>

View File

@@ -121,7 +121,7 @@ export function PeerSelector({
<PopoverTrigger asChild>
<button
className={cn(
"min-h-[42px] w-full relative items-center group",
"min-h-[46px] w-full relative items-center group",
"border border-neutral-200 dark:border-nb-gray-700 justify-between py-2 px-3",
"rounded-md bg-white text-sm dark:bg-nb-gray-900/40 flex dark:text-neutral-400/70 text-neutral-500 cursor-pointer enabled:hover:dark:bg-nb-gray-900/50",
"disabled:opacity-40 disabled:cursor-default",

View File

@@ -15,6 +15,7 @@ const iconVariant = cva(
green: "bg-green-950 border-green-500 text-green-500",
purple: "bg-purple-950 border-purple-500 text-purple-500",
indigo: "bg-indigo-950 border-indigo-500 text-indigo-500",
yellow: "bg-yellow-950 border-yellow-400 text-yellow-400",
},
size: {
small: "w-8 h-8",

View File

@@ -9,6 +9,7 @@ interface Props extends IconVariant {
description: string | React.ReactNode;
className?: string;
margin?: string;
truncate?: boolean;
}
export default function ModalHeader({
icon,
@@ -17,14 +18,19 @@ export default function ModalHeader({
color = "netbird",
className = "pb-6 px-8",
margin = "mt-0",
truncate = false,
}: Props) {
return (
<div className={className}>
<div className={"flex items-start gap-5 pr-10"}>
<div className={cn(className, "min-w-0")}>
<div className={"flex items-start gap-5 pr-10 min-w-0"}>
{icon && <SquareIcon color={color} icon={icon} />}
<div>
<div className={"min-w-0"}>
<h2 className={"text-lg my-0 leading-[1.5]"}>{title}</h2>
<Paragraph className={cn("text-sm", margin)}>{description}</Paragraph>
<Paragraph
className={cn("text-sm", margin, truncate && "!block truncate")}
>
{description}
</Paragraph>
</div>
</div>
</div>

View File

@@ -24,14 +24,21 @@ export default function TextWithTooltip({
<FullTooltip
disabled={charCount <= maxChars || hideTooltip}
interactive={false}
className={"truncate w-full"}
className={"truncate w-full min-w-0"}
content={
<div className={"max-w-xs break-all whitespace-normal"}>{text}</div>
<div className={"max-w-xs break-all whitespace-normal text-xs"}>
{text}
</div>
}
>
<span className={cn(className, "truncate")}>
{charCount > maxChars ? text && `${text.slice(0, maxChars)}...` : text}
</span>
<div
className={"w-full min-w-0 inline-block"}
style={{
maxWidth: `${maxChars - 2}ch`,
}}
>
<div className={cn(className, "truncate")}>{text}</div>
</div>
</FullTooltip>
);
}

View File

@@ -10,6 +10,7 @@ type Props = {
leftSection?: React.ReactNode;
text?: string | React.ReactNode;
className?: string;
additionalInfo?: React.ReactNode;
};
export default function ActiveInactiveRow({
active,
@@ -18,11 +19,12 @@ export default function ActiveInactiveRow({
leftSection,
inactiveDot = "gray",
className,
additionalInfo,
}: Props) {
return (
<div
className={cn(
"flex gap-3 dark:text-neutral-300 text-neutral-500 min-w-[250px] max-w-[250px]",
"gap-3 dark:text-neutral-300 text-neutral-500 min-w-0",
className,
)}
>
@@ -34,9 +36,12 @@ export default function ActiveInactiveRow({
inactiveDot={inactiveDot}
className={"mt-1 shrink-0"}
/>
<div className={"flex flex-col"}>
<div className={" font-medium"}>
<div className={"flex flex-col min-w-0"}>
<div
className={"font-medium flex gap-2 items-center justify-center"}
>
<TextWithTooltip text={text as string} maxChars={25} />
{additionalInfo}
</div>
{children}
</div>

View File

@@ -0,0 +1,46 @@
import Button from "@components/Button";
import { Modal } from "@components/modal/Modal";
import { IconCirclePlus, IconDirectionSign } from "@tabler/icons-react";
import * as React from "react";
import { useState } from "react";
import { Peer } from "@/interfaces/Peer";
import { ExitNodeHelpTooltip } from "@/modules/exit-node/ExitNodeHelpTooltip";
import { RouteModalContent } from "@/modules/routes/RouteModal";
type Props = {
peer?: Peer;
firstTime?: boolean;
};
export const AddExitNodeButton = ({ peer, firstTime = false }: Props) => {
const [modal, setModal] = useState(false);
return (
<>
<ExitNodeHelpTooltip>
<Button variant={"secondary"} onClick={() => setModal(true)}>
{!firstTime ? (
<>
<IconCirclePlus size={16} />
Add Exit Node
</>
) : (
<>
<IconDirectionSign size={16} className={"text-yellow-400"} />
Setup Exit Node
</>
)}
</Button>
</ExitNodeHelpTooltip>
<Modal open={modal} onOpenChange={setModal}>
{modal && (
<RouteModalContent
onSuccess={() => setModal(false)}
peer={peer}
isFirstExitNode={firstTime}
exitNode={true}
/>
)}
</Modal>
</>
);
};

View File

@@ -0,0 +1,40 @@
import { DropdownMenuItem } from "@components/DropdownMenu";
import { Modal } from "@components/modal/Modal";
import { getOperatingSystem } from "@hooks/useOperatingSystem";
import { IconDirectionSign } from "@tabler/icons-react";
import * as React from "react";
import { useState } from "react";
import { OperatingSystem } from "@/interfaces/OperatingSystem";
import { Peer } from "@/interfaces/Peer";
import { RouteModalContent } from "@/modules/routes/RouteModal";
type Props = {
peer: Peer;
};
export const ExitNodeDropdownButton = ({ peer }: Props) => {
const [modal, setModal] = useState(false);
const isLinux = getOperatingSystem(peer.os) === OperatingSystem.LINUX;
return isLinux ? (
<>
<DropdownMenuItem onClick={() => setModal(true)}>
<div className={"flex gap-3 items-center w-full"}>
<IconDirectionSign size={14} className={"shrink-0"} />
<div className={"flex justify-between items-center w-full"}>
Add Exit Node
</div>
</div>
</DropdownMenuItem>
<Modal open={modal} onOpenChange={setModal}>
{modal && (
<RouteModalContent
onSuccess={() => setModal(false)}
peer={peer}
exitNode={true}
/>
)}
</Modal>
</>
) : null;
};

View File

@@ -0,0 +1,41 @@
import FullTooltip from "@components/FullTooltip";
import InlineLink from "@components/InlineLink";
import { ExternalLinkIcon } from "lucide-react";
import * as React from "react";
type Props = {
children: React.ReactNode;
hoverButton?: boolean;
};
export const ExitNodeHelpTooltip = ({
children,
hoverButton = false,
}: Props) => {
return (
<FullTooltip
hoverButton={hoverButton}
content={
<div className={"text-xs max-w-xs"}>
An exit node is a network route that routes all your internet traffic
through one of your peers.
<div className={"mt-2"}>
Learn more about{" "}
<InlineLink
href={
"https://docs.netbird.io/how-to/configuring-default-routes-for-internet-traffic"
}
target={"_blank"}
className={"mr-1"}
>
Exit Nodes
<ExternalLinkIcon size={10} />
</InlineLink>
in our documentation.
</div>
</div>
}
>
{children}
</FullTooltip>
);
};

View File

@@ -0,0 +1,25 @@
import FullTooltip from "@components/FullTooltip";
import { IconDirectionSign } from "@tabler/icons-react";
import * as React from "react";
import { Peer } from "@/interfaces/Peer";
import { useHasExitNodes } from "@/modules/exit-node/useHasExitNodes";
type Props = {
peer: Peer;
};
export const ExitNodePeerIndicator = ({ peer }: Props) => {
const hasExitNode = useHasExitNodes(peer);
return hasExitNode ? (
<FullTooltip
content={
<div className={"text-xs max-w-xs"}>
This peer has an exit node. Traffic from the configured distribution
groups will be routed through this peer.
</div>
}
>
<IconDirectionSign size={15} className={"text-yellow-400 shrink-0"} />
</FullTooltip>
) : null;
};

View File

@@ -0,0 +1,20 @@
import useFetchApi from "@utils/api";
import { useLoggedInUser } from "@/contexts/UsersProvider";
import { Peer } from "@/interfaces/Peer";
import { Route } from "@/interfaces/Route";
export const useHasExitNodes = (peer?: Peer) => {
const { isOwnerOrAdmin } = useLoggedInUser();
const { data: routes } = useFetchApi<Route[]>(
`/routes`,
false,
true,
isOwnerOrAdmin,
);
return peer
? routes?.some(
(route) =>
route?.peer === peer.id && route?.network.includes("0.0.0.0"),
) || false
: false;
};

View File

@@ -1,10 +1,29 @@
import { IconDirectionSign } from "@tabler/icons-react";
import { InfoIcon } from "lucide-react";
import * as React from "react";
import { Route } from "@/interfaces/Route";
import { ExitNodeHelpTooltip } from "@/modules/exit-node/ExitNodeHelpTooltip";
type Props = {
route: Route;
};
export default function PeerRouteNetworkCell({ route }: Props) {
return (
const isExitNode = route?.network === "0.0.0.0/0";
return isExitNode ? (
<ExitNodeHelpTooltip>
<div className={"flex gap-2 items-center dark:text-nb-gray-300 group"}>
<IconDirectionSign size={16} className={"text-yellow-400"} />
Exit Node{" "}
<InfoIcon
size={14}
className={
"text-nb-gray-500 group-hover:text-nb-gray-400 transition-all"
}
/>
</div>
</ExitNodeHelpTooltip>
) : (
<div className={"font-mono dark:text-nb-gray-300 flex max-w-[10px]"}>
{route.network}
</div>

View File

@@ -14,6 +14,7 @@ import PeerRouteActiveCell from "@/modules/peer/PeerRouteActiveCell";
import PeerRouteNameCell from "@/modules/peer/PeerRouteNameCell";
import PeerRouteNetworkCell from "@/modules/peer/PeerRouteNetworkCell";
import usePeerRoutes from "@/modules/peer/usePeerRoutes";
import RouteDistributionGroupsCell from "@/modules/routes/RouteDistributionGroupsCell";
type Props = {
peer: Peer;
@@ -35,6 +36,16 @@ export const RouteTableColumns: ColumnDef<Route>[] = [
},
cell: ({ row }) => <PeerRouteNetworkCell route={row.original} />,
},
{
id: "groups",
accessorFn: (r) => r.groups?.length,
header: ({ column }) => {
return (
<DataTableHeader column={column}>Distribution Groups</DataTableHeader>
);
},
cell: ({ row }) => <RouteDistributionGroupsCell route={row.original} />,
},
{
id: "enabled",
accessorKey: "enabled",

View File

@@ -19,6 +19,7 @@ import { useRouter } from "next/navigation";
import React from "react";
import { useSWRConfig } from "swr";
import { usePeer } from "@/contexts/PeerProvider";
import { ExitNodeDropdownButton } from "@/modules/exit-node/ExitNodeDropdownButton";
export default function PeerActionCell() {
const { peer, deletePeer, update, openSSHDialog } = usePeer();
@@ -125,6 +126,9 @@ export default function PeerActionCell() {
</div>
</div>
</DropdownMenuItem>
<ExitNodeDropdownButton peer={peer} />
<DropdownMenuSeparator />
<DropdownMenuItem onClick={deletePeer} variant={"danger"}>

View File

@@ -1,9 +1,10 @@
import { useRouter } from "next/navigation";
import * as React from "react";
import { useMemo } from "react";
import { useUsers } from "@/contexts/UsersProvider";
import { useLoggedInUser, useUsers } from "@/contexts/UsersProvider";
import { Peer } from "@/interfaces/Peer";
import ActiveInactiveRow from "@/modules/common-table-rows/ActiveInactiveRow";
import { ExitNodePeerIndicator } from "@/modules/exit-node/ExitNodePeerIndicator";
type Props = {
peer: Peer;
@@ -11,22 +12,33 @@ type Props = {
export default function PeerNameCell({ peer }: Props) {
const { users } = useUsers();
const router = useRouter();
const { isOwnerOrAdmin } = useLoggedInUser();
const userOfPeer = useMemo(() => {
return users?.find((user) => user.id === peer.user_id);
}, [users, peer.user_id]);
return (
<div
className={
"flex items-center min-w-[250px] max-w-[250px] gap-2 dark:text-neutral-300 text-neutral-500 hover:text-neutral-100 transition-all hover:bg-nb-gray-800/60 py-2 px-3 rounded-md cursor-pointer"
}
data-testid="peer-name-cell"
onClick={() => router.push("/peer?id=" + peer.id)}
>
<ActiveInactiveRow active={peer.connected} text={peer.name}>
<div className={"text-nb-gray-400 font-light"}>{userOfPeer?.email}</div>
</ActiveInactiveRow>
<div>
<div
className={
"flex items-center max-w-[300px] gap-2 dark:text-neutral-300 text-neutral-500 hover:text-neutral-100 transition-all hover:bg-nb-gray-800/60 py-2 px-3 rounded-md cursor-pointer"
}
data-testid="peer-name-cell"
onClick={() => router.push("/peer?id=" + peer.id)}
>
<ActiveInactiveRow
active={peer.connected}
text={peer.name}
additionalInfo={
isOwnerOrAdmin && <ExitNodePeerIndicator peer={peer} />
}
>
<div className={"text-nb-gray-400 font-light truncate"}>
{userOfPeer?.email}
</div>
</ActiveInactiveRow>
</div>
</div>
);
}

View File

@@ -1,8 +1,28 @@
import { IconDirectionSign } from "@tabler/icons-react";
import { InfoIcon } from "lucide-react";
import * as React from "react";
import { ExitNodeHelpTooltip } from "@/modules/exit-node/ExitNodeHelpTooltip";
type Props = {
network: string;
};
export default function GroupedRouteNetworkRangeCell({ network }: Props) {
return (
const isExitNode = network === "0.0.0.0/0";
return isExitNode ? (
<ExitNodeHelpTooltip>
<div className={"flex gap-2 items-center dark:text-nb-gray-300 group"}>
<IconDirectionSign size={16} className={"text-yellow-400"} />
Exit Node{" "}
<InfoIcon
size={14}
className={
"text-nb-gray-500 group-hover:text-nb-gray-400 transition-all"
}
/>
</div>
</ExitNodeHelpTooltip>
) : (
<div className={"font-mono dark:text-nb-gray-300 flex max-w-[10px]"}>
{network}
</div>

View File

@@ -17,6 +17,7 @@ import NetworkRoutesIcon from "@/assets/icons/NetworkRoutesIcon";
import GroupRouteProvider from "@/contexts/GroupRouteProvider";
import { useLocalStorage } from "@/hooks/useLocalStorage";
import { GroupedRoute, Route } from "@/interfaces/Route";
import { AddExitNodeButton } from "@/modules/exit-node/AddExitNodeButton";
import GroupedRouteActionCell from "@/modules/route-group/GroupedRouteActionCell";
import GroupedRouteHighAvailabilityCell from "@/modules/route-group/GroupedRouteHighAvailabilityCell";
import GroupedRouteNameCell from "@/modules/route-group/GroupedRouteNameCell";
@@ -157,12 +158,15 @@ export default function NetworkRoutesTable({
"It looks like you don't have any routes. Access LANs and VPC by adding a network route."
}
button={
<RouteModal>
<Button variant={"primary"} className={""}>
<PlusCircle size={16} />
Add Route
</Button>
</RouteModal>
<div className={"gap-x-4 flex items-center justify-center"}>
<AddExitNodeButton />
<RouteModal>
<Button variant={"primary"} className={""}>
<PlusCircle size={16} />
Add Route
</Button>
</RouteModal>
</div>
}
learnMore={
<>
@@ -183,12 +187,15 @@ export default function NetworkRoutesTable({
rightSide={() => (
<>
{routes && routes?.length > 0 && (
<RouteModal>
<Button variant={"primary"} className={"ml-auto"}>
<PlusCircle size={16} />
Add Route
</Button>
</RouteModal>
<div className={"gap-x-4 ml-auto flex"}>
<AddExitNodeButton />
<RouteModal>
<Button variant={"primary"} className={""}>
<PlusCircle size={16} />
Add Route
</Button>
</RouteModal>
</div>
)}
</>
)}

View File

@@ -20,6 +20,7 @@ import { PeerSelector } from "@components/PeerSelector";
import { SegmentedTabs } from "@components/SegmentedTabs";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@components/Tabs";
import { Textarea } from "@components/Textarea";
import { IconDirectionSign } from "@tabler/icons-react";
import { cn } from "@utils/helpers";
import cidr from "ip-cidr";
import { uniqBy } from "lodash";
@@ -63,19 +64,36 @@ export default function RouteModal({ children }: Props) {
type ModalProps = {
onSuccess?: (route: Route) => void;
peer?: Peer;
exitNode?: boolean;
isFirstExitNode?: boolean;
};
export function RouteModalContent({ onSuccess, peer }: ModalProps) {
export function RouteModalContent({
onSuccess,
peer,
exitNode,
isFirstExitNode = false,
}: ModalProps) {
const { createRoute } = useRoutes();
const [tab, setTab] = useState("network");
// General
const [networkIdentifier, setNetworkIdentifier] = useState("");
/**
* Network Identifier, Description & Network Range
*/
const [networkIdentifier, setNetworkIdentifier] = useState(
exitNode
? peer
? `Exit Node (${
peer.name.length > 25
? peer.name.substring(0, 25) + "..."
: peer.name
})`
: "Exit Node"
: "",
);
const [description, setDescription] = useState("");
// Network
const [networkRange, setNetworkRange] = useState("");
const [networkRange, setNetworkRange] = useState(exitNode ? "0.0.0.0/0" : "");
const [routingPeer, setRoutingPeer] = useState<Peer | undefined>(peer);
const [
routingPeerGroups,
setRoutingPeerGroups,
@@ -84,29 +102,23 @@ export function RouteModalContent({ onSuccess, peer }: ModalProps) {
initial: [],
});
/**
* Distribution Groups
*/
const [groups, setGroups, { getGroupsToUpdate }] = useGroupHelper({
initial: [],
});
// Additional Settings
/**
* Additional Settings
*/
const [enabled, setEnabled] = useState<boolean>(true);
const [metric, setMetric] = useState("9999");
const [masquerade, setMasquerade] = useState<boolean>(true);
// Validate CIDR
const cidrError = useMemo(() => {
if (networkRange == "") return "";
const validCIDR = cidr.isValidAddress(networkRange);
if (!validCIDR) return "Please enter a valid CIDR, e.g., 192.168.1.0/24";
}, [networkRange]);
// Refs to manage focus on tab change
const networkRangeRef = useRef<HTMLInputElement>(null);
const nameRef = useRef<HTMLInputElement>(null);
const [peerTab, setPeerTab] = useState("routing-peer");
// Create route
// TODO Refactor to avoid duplicate code
/**
* Create Route
*/
const createRouteHandler = async () => {
const g1 = getAllRoutingGroupsToUpdate();
const g2 = getGroupsToUpdate();
@@ -147,36 +159,71 @@ export function RouteModalContent({ onSuccess, peer }: ModalProps) {
);
};
// Is button disabled
const isDisabled = useMemo(() => {
return (
networkIdentifier == "" ||
/**
* Refs to manage input focus on tab change
*/
const networkRangeRef = useRef<HTMLInputElement>(null);
const nameRef = useRef<HTMLInputElement>(null);
const [peerTab, setPeerTab] = useState("routing-peer");
/**
* Validate CIDR Range
*/
const cidrError = useMemo(() => {
if (networkRange == "") return "";
const validCIDR = cidr.isValidAddress(networkRange);
if (!validCIDR) return "Please enter a valid CIDR, e.g., 192.168.1.0/24";
}, [networkRange]);
/**
* Allow to create route only when all fields are filled
*/
const isNetworkEntered = useMemo(() => {
return !(
(cidrError && cidrError.length > 1) ||
(peerTab === "peer-group" && routingPeerGroups.length == 0) ||
(peerTab === "routing-peer" && !routingPeer) ||
groups.length == 0
);
}, [
networkIdentifier,
cidrError,
peerTab,
routingPeerGroups.length,
routingPeer,
groups,
]);
}, [cidrError, peerTab, routingPeerGroups.length, routingPeer, groups]);
const [tab, setTab] = useState("network");
const isNameEntered = useMemo(() => {
return !(networkIdentifier == "");
}, [networkIdentifier]);
const canCreateOrSave = useMemo(() => {
return isNetworkEntered && isNameEntered;
}, [isNetworkEntered, isNameEntered]);
return (
<ModalContent maxWidthClass={"max-w-xl"}>
<ModalHeader
icon={<NetworkRoutesIcon className={"fill-netbird"} />}
title={"Create New Route"}
description={"Access LANs and VPC by adding a network route."}
color={"netbird"}
icon={
exitNode ? (
<IconDirectionSign size={20} />
) : (
<NetworkRoutesIcon className={"fill-netbird"} />
)
}
title={
exitNode
? isFirstExitNode
? "Setup Exit Node"
: "Add Exit Node"
: "Create New Route"
}
truncate={!!peer}
description={
exitNode
? peer
? `Route all traffic through the peer '${peer.name}'`
: "Route all internet traffic through a peer"
: "Access LANs and VPC by adding a network route."
}
color={exitNode ? "yellow" : "netbird"}
/>
<Tabs defaultValue={tab} onValueChange={(v) => setTab(v)}>
<Tabs defaultValue={tab} onValueChange={(v) => setTab(v)} value={tab}>
<TabsList justify={"start"} className={"px-8"}>
<TabsTrigger
value={"network"}
@@ -192,6 +239,7 @@ export function RouteModalContent({ onSuccess, peer }: ModalProps) {
</TabsTrigger>
<TabsTrigger
value={"general"}
disabled={!isNetworkEntered}
onClick={() => nameRef.current?.focus()}
>
<Text
@@ -202,7 +250,7 @@ export function RouteModalContent({ onSuccess, peer }: ModalProps) {
/>
Name & Description
</TabsTrigger>
<TabsTrigger value={"settings"}>
<TabsTrigger value={"settings"} disabled={!canCreateOrSave}>
<Settings2
size={16}
className={
@@ -212,6 +260,78 @@ export function RouteModalContent({ onSuccess, peer }: ModalProps) {
Additional Settings
</TabsTrigger>
</TabsList>
<TabsContent value={"network"} className={"pb-8"}>
<div className={"px-8 flex-col flex gap-6"}>
<div className={cn(exitNode && "hidden")}>
<Label>Network Range</Label>
<HelpText>Add a private IP address range</HelpText>
<Input
ref={networkRangeRef}
customPrefix={<NetworkIcon size={16} />}
placeholder={"e.g., 172.16.0.0/16"}
value={networkRange}
className={"font-mono !text-[13px]"}
error={cidrError}
onChange={(e) => setNetworkRange(e.target.value)}
/>
</div>
{exitNode && peer ? (
<></>
) : (
<SegmentedTabs value={peerTab} onChange={setPeerTab}>
<SegmentedTabs.List>
<SegmentedTabs.Trigger value={"routing-peer"}>
<MonitorSmartphoneIcon size={16} />
Routing Peer
</SegmentedTabs.Trigger>
<SegmentedTabs.Trigger value={"peer-group"} disabled={!!peer}>
<FolderGit2 size={16} />
Peer Group
</SegmentedTabs.Trigger>
</SegmentedTabs.List>
<SegmentedTabs.Content value={"routing-peer"}>
<div>
<HelpText>
Assign a single peer as a routing peer for the
{exitNode ? " Exit Node" : " Network CIDR"}
</HelpText>
<PeerSelector
onChange={setRoutingPeer}
value={routingPeer}
disabled={!!peer}
/>
</div>
</SegmentedTabs.Content>
<SegmentedTabs.Content value={"peer-group"}>
<div>
<HelpText>
Assign peer group with Linux machines to be used as
routing peers.
</HelpText>
<PeerGroupSelector
max={1}
onChange={setRoutingPeerGroups}
values={routingPeerGroups}
/>
</div>
</SegmentedTabs.Content>
</SegmentedTabs>
)}
<div>
<Label>Distribution Groups</Label>
<HelpText>
{exitNode
? peer
? `Route all internet traffic through this peer for the following groups`
: `Route all internet traffic through the peer(s) for the following groups`
: "Advertise this route to peers that belong to the following groups"}
</HelpText>
<PeerGroupSelector onChange={setGroups} values={groups} />
</div>
</div>
</TabsContent>
<TabsContent value={"general"} className={"px-8 pb-6"}>
<div className={"flex flex-col gap-6"}>
<div>
@@ -244,69 +364,6 @@ export function RouteModalContent({ onSuccess, peer }: ModalProps) {
</div>
</div>
</TabsContent>
<TabsContent value={"network"} className={"pb-8"}>
<div className={"px-8 flex-col flex gap-6"}>
<div>
<Label>Network Range</Label>
<HelpText>Add a private IP address range</HelpText>
<Input
ref={networkRangeRef}
customPrefix={<NetworkIcon size={16} />}
placeholder={"e.g., 172.16.0.0/16"}
value={networkRange}
className={"font-mono !text-[13px]"}
error={cidrError}
onChange={(e) => setNetworkRange(e.target.value)}
/>
</div>
<SegmentedTabs value={peerTab} onChange={setPeerTab}>
<SegmentedTabs.List>
<SegmentedTabs.Trigger value={"routing-peer"}>
<MonitorSmartphoneIcon size={16} />
Routing Peer
</SegmentedTabs.Trigger>
<SegmentedTabs.Trigger value={"peer-group"} disabled={!!peer}>
<FolderGit2 size={16} />
Peer Group
</SegmentedTabs.Trigger>
</SegmentedTabs.List>
<SegmentedTabs.Content value={"routing-peer"}>
<div>
<HelpText>
Assign a single peer as a routing peer for the Network CIDR.
</HelpText>
<PeerSelector
onChange={setRoutingPeer}
value={routingPeer}
disabled={!!peer}
/>
</div>
</SegmentedTabs.Content>
<SegmentedTabs.Content value={"peer-group"}>
<div>
<HelpText>
Assign peer group with Linux machines to be used as routing
peers.
</HelpText>
<PeerGroupSelector
max={1}
onChange={setRoutingPeerGroups}
values={routingPeerGroups}
/>
</div>
</SegmentedTabs.Content>
</SegmentedTabs>
<div>
<Label>Distribution Groups</Label>
<HelpText>
Advertise this route to peers that belong to the following
groups
</HelpText>
<PeerGroupSelector onChange={setGroups} values={groups} />
</div>
</div>
</TabsContent>
<TabsContent value={"settings"} className={"pb-4"}>
<div className={"px-8 flex flex-col gap-6"}>
<FancyToggleSwitch
@@ -320,19 +377,22 @@ export function RouteModalContent({ onSuccess, peer }: ModalProps) {
}
helpText={"Use this switch to enable or disable the route."}
/>
<FancyToggleSwitch
value={masquerade}
onChange={setMasquerade}
label={
<>
<VenetianMask size={15} />
Masquerade
</>
}
helpText={
"Allow access to your private networks without configuring routes on your local routers or other devices."
}
/>
{!exitNode && (
<FancyToggleSwitch
value={masquerade}
onChange={setMasquerade}
label={
<>
<VenetianMask size={15} />
Masquerade
</>
}
helpText={
"Allow access to your private networks without configuring routes on your local routers or other devices."
}
/>
)}
<div className={cn("flex justify-between")}>
<div>
<Label>Metrics</Label>
@@ -366,28 +426,64 @@ export function RouteModalContent({ onSuccess, peer }: ModalProps) {
Learn more about
<InlineLink
href={
"https://docs.netbird.io/how-to/routing-traffic-to-private-networks"
exitNode
? "https://docs.netbird.io/how-to/configuring-default-routes-for-internet-traffic"
: "https://docs.netbird.io/how-to/routing-traffic-to-private-networks"
}
target={"_blank"}
>
Network Routes
{exitNode ? "Exit Nodes" : "Network Routes"}
<ExternalLinkIcon size={12} />
</InlineLink>
</Paragraph>
</div>
<div className={"flex gap-3 w-full justify-end"}>
<ModalClose asChild={true}>
<Button variant={"secondary"}>Cancel</Button>
</ModalClose>
{tab == "network" && (
<ModalClose asChild={true}>
<Button variant={"secondary"}>Cancel</Button>
</ModalClose>
)}
<Button
variant={"primary"}
disabled={isDisabled}
onClick={createRouteHandler}
>
<PlusCircle size={16} />
Add Route
</Button>
{tab == "general" && (
<Button variant={"secondary"} onClick={() => setTab("network")}>
Back
</Button>
)}
{tab == "settings" && (
<Button variant={"secondary"} onClick={() => setTab("general")}>
Back
</Button>
)}
{tab == "network" && (
<Button
variant={"primary"}
onClick={() => setTab("general")}
disabled={!isNetworkEntered}
>
Continue
</Button>
)}
{tab == "general" && (
<Button
variant={"primary"}
onClick={() => setTab("settings")}
disabled={!canCreateOrSave}
>
Continue
</Button>
)}
{tab == "settings" && (
<Button
variant={"primary"}
disabled={!canCreateOrSave}
onClick={createRouteHandler}
>
<PlusCircle size={16} />
{exitNode ? "Add Exit Node" : "Add Route"}
</Button>
)}
</div>
</ModalFooter>
</ModalContent>

View File

@@ -93,6 +93,7 @@ export default function useFetchApi<T>(
url: string,
ignoreError = false,
revalidate = true,
allowFetch = true,
) {
const { fetch } = useNetBirdFetch(ignoreError);
const handleErrors = useApiErrorHandling(ignoreError);
@@ -100,6 +101,7 @@ export default function useFetchApi<T>(
const { data, error, isLoading, isValidating, mutate } = useSWR(
url,
async (url) => {
if (!allowFetch) return;
return apiRequest<T>(fetch, "GET", url).catch((err) =>
handleErrors(err as ErrorResponse),
);