mirror of
https://github.com/netbirdio/dashboard.git
synced 2026-01-26 01:21:04 +00:00
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:
@@ -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>
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
46
src/modules/exit-node/AddExitNodeButton.tsx
Normal file
46
src/modules/exit-node/AddExitNodeButton.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
40
src/modules/exit-node/ExitNodeDropdownButton.tsx
Normal file
40
src/modules/exit-node/ExitNodeDropdownButton.tsx
Normal 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;
|
||||
};
|
||||
41
src/modules/exit-node/ExitNodeHelpTooltip.tsx
Normal file
41
src/modules/exit-node/ExitNodeHelpTooltip.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
25
src/modules/exit-node/ExitNodePeerIndicator.tsx
Normal file
25
src/modules/exit-node/ExitNodePeerIndicator.tsx
Normal 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;
|
||||
};
|
||||
20
src/modules/exit-node/useHasExitNodes.tsx
Normal file
20
src/modules/exit-node/useHasExitNodes.tsx
Normal 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;
|
||||
};
|
||||
@@ -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>
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"}>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user