Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions src/contexts/RoutesProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ export default function RoutesProvider({ children }: Readonly<Props>) {
: toUpdate.access_control_groups ??
route.access_control_groups ??
undefined,
skip_auto_apply: toUpdate.skip_auto_apply ?? route.skip_auto_apply ?? true,
},
`/${route.id}`,
)
Expand Down Expand Up @@ -94,6 +95,7 @@ export default function RoutesProvider({ children }: Readonly<Props>) {
masquerade: route.masquerade,
groups: route.groups || [],
access_control_groups: route?.access_control_groups || undefined,
skip_auto_apply: route.skip_auto_apply ?? true,
})
.then((route) => {
mutate("/routes");
Expand Down
1 change: 1 addition & 0 deletions src/interfaces/Route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ export interface Route {
groups: string[];
keep_route?: boolean;
access_control_groups?: string[];
skip_auto_apply?: boolean;
// Frontend only
peer_groups?: string[];
routesGroups?: string[];
Expand Down
4 changes: 2 additions & 2 deletions src/modules/exit-node/ExitNodeDropdownButton.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ type Props = {

export const ExitNodeDropdownButton = ({ peer }: Props) => {
const [modal, setModal] = useState(false);
const hasExitNodes = useHasExitNodes(peer);
const exitNodeInfo = useHasExitNodes(peer);
const { permission } = usePermissions();

return (
Expand All @@ -25,7 +25,7 @@ export const ExitNodeDropdownButton = ({ peer }: Props) => {
disabled={!permission.routes.create}
>
<div className={"flex gap-3 items-center w-full"}>
{hasExitNodes ? (
{exitNodeInfo.hasExitNode ? (
<>
<IconCirclePlus size={14} className={"shrink-0"} />
<div className={"flex justify-between items-center w-full"}>
Expand Down
28 changes: 16 additions & 12 deletions src/modules/exit-node/ExitNodePeerIndicator.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,18 +8,22 @@ type Props = {
peer: Peer;
};
export const ExitNodePeerIndicator = ({ peer }: Props) => {
const hasExitNode = useHasExitNodes(peer);
const exitNodeInfo = useHasExitNodes(peer);

return hasExitNode ? (
<FullTooltip
content={
<div className={"text-xs max-w-xs"}>
This peer is 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"} />
if (!exitNodeInfo.hasExitNode) {
return null;
}

const tooltipContent = exitNodeInfo.skipAutoApply === false
? "This peer is an auto-applied exit node. Traffic from the configured distribution groups will be routed through this peer."
: "This peer is an exit node. Traffic from the configured distribution groups will be routed through this peer.";

return (
<FullTooltip content={<div className={"text-xs max-w-xs"}>{tooltipContent}</div>}>
<IconDirectionSign
size={15}
className={`shrink-0 ${exitNodeInfo.skipAutoApply === false ? "text-green-400" : "text-yellow-400"}`}
/>
</FullTooltip>
) : null;
);
};
25 changes: 19 additions & 6 deletions src/modules/exit-node/useHasExitNodes.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,17 +3,30 @@ import { useLoggedInUser } from "@/contexts/UsersProvider";
import { Peer } from "@/interfaces/Peer";
import { Route } from "@/interfaces/Route";

export const useHasExitNodes = (peer?: Peer) => {
export interface ExitNodeInfo {
hasExitNode: boolean;
skipAutoApply?: boolean;
}

export const useHasExitNodes = (peer?: Peer): ExitNodeInfo => {
const { isOwnerOrAdmin } = useLoggedInUser();
const { data: routes } = useFetchApi<Route[]>(
`/routes`,
false,
true,
isOwnerOrAdmin,
);
return peer
? routes?.some(
(route) => route?.peer === peer.id && route?.network === "0.0.0.0/0",
) || false
: false;

if (!peer || !routes) {
return { hasExitNode: false };
}

const exitNodeRoute = routes.find(
(route) => route?.peer === peer.id && route?.network === "0.0.0.0/0",
);

return {
hasExitNode: !!exitNodeRoute,
skipAutoApply: exitNodeRoute?.skip_auto_apply,
};
};
4 changes: 2 additions & 2 deletions src/modules/peer/PeerNetworkRoutesSection.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ type Props = {

export const PeerNetworkRoutesSection = ({ peer }: Props) => {
const { peerRoutes, isLoading } = usePeerRoutes({ peer });
const hasExitNodes = useHasExitNodes(peer);
const exitNodeInfo = useHasExitNodes(peer);
const { ref: headingRef, portalTarget } =
usePortalElement<HTMLHeadingElement>();

Expand All @@ -34,7 +34,7 @@ export const PeerNetworkRoutesSection = ({ peer }: Props) => {
</div>
<div className={"inline-flex gap-4 justify-end"}>
<div className={"gap-4 flex"}>
<AddExitNodeButton peer={peer} firstTime={!hasExitNodes} />
<AddExitNodeButton peer={peer} firstTime={!exitNodeInfo.hasExitNode} />
<AddRouteDropdownButton />
</div>
</div>
Expand Down
53 changes: 53 additions & 0 deletions src/modules/routes/RouteAutoApplyCell.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import { ToggleSwitch } from "@components/ToggleSwitch";
import React, { useMemo } from "react";
import { useSWRConfig } from "swr";
import { usePermissions } from "@/contexts/PermissionsProvider";
import { useRoutes } from "@/contexts/RoutesProvider";
import { Route } from "@/interfaces/Route";

type Props = {
route: Route;
};

export default function RouteAutoApplyCell({ route }: Readonly<Props>) {
const { permission } = usePermissions();
const { updateRoute } = useRoutes();
const { mutate } = useSWRConfig();

const isExitNode = useMemo(() => route.network === "0.0.0.0/0", [route]);

const isChecked = useMemo(() => {
// Checked means Auto Apply is ON, which maps to skip_auto_apply === false
return route.skip_auto_apply === false;
}, [route]);

const update = async (checked: boolean) => {
// When toggled ON (checked === true), we want skip_auto_apply = false
const nextSkipAutoApply = !checked;
updateRoute(
route,
{ skip_auto_apply: nextSkipAutoApply },
() => {
mutate("/routes");
},
checked
? "Auto Apply was enabled for the route"
: "Auto Apply was disabled for the route",
);
};

if (!isExitNode) return null;

return (
<div className={"flex items-center"}>
<ToggleSwitch
checked={isChecked}
size={"small"}
onClick={() => update(!isChecked)}
disabled={!permission.routes.update}
/>
</div>
);
}


16 changes: 16 additions & 0 deletions src/modules/routes/RouteModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -227,6 +227,7 @@ export function RouteModalContent({
const [enabled, setEnabled] = useState<boolean>(true);
const [metric, setMetric] = useState("9999");
const [masquerade, setMasquerade] = useState<boolean>(true);
const [isForced, setIsForced] = useState<boolean>(true);

const isNonLinuxRoutingPeer = useMemo(() => {
if (!routingPeer) return false;
Expand Down Expand Up @@ -304,6 +305,7 @@ export function RouteModalContent({
masquerade: useSinglePeer && isNonLinuxRoutingPeer ? true : masquerade,
groups: groupIds,
access_control_groups: accessControlGroupIds || undefined,
skip_auto_apply: !isForced,
},
onSuccess,
);
Expand Down Expand Up @@ -718,6 +720,20 @@ export function RouteModalContent({
helpText={"Use this switch to enable or disable the route."}
/>

{exitNode && (
<FancyToggleSwitch
value={isForced}
onChange={setIsForced}
label={
<>
<IconDirectionSign size={15} />
Auto Apply Route
</>
}
helpText={"Automatically apply this exit node to your distribution groups. This requires NetBird client v0.55.0 or higher."}
/>
)}

{!exitNode && (
<RoutingPeerMasqueradeSwitch
value={masquerade}
Expand Down
15 changes: 15 additions & 0 deletions src/modules/routes/RouteTable.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { GroupedRoute, Route } from "@/interfaces/Route";
import RouteAccessControlGroups from "@/modules/routes/RouteAccessControlGroups";
import RouteActionCell from "@/modules/routes/RouteActionCell";
import RouteActiveCell from "@/modules/routes/RouteActiveCell";
import RouteAutoApplyCell from "@/modules/routes/RouteAutoApplyCell";
import RouteDistributionGroupsCell from "@/modules/routes/RouteDistributionGroupsCell";
import RouteMetricCell from "@/modules/routes/RouteMetricCell";
import RoutePeerCell from "@/modules/routes/RoutePeerCell";
Expand Down Expand Up @@ -77,6 +78,15 @@ export const RouteTableColumns: ColumnDef<Route>[] = [
},
cell: ({ row }) => <RouteAccessControlGroups route={row.original} />,
},
{
id: "skipAutoApply",
accessorKey: "skip_auto_apply",
header: ({ column }) => {
return <DataTableHeader column={column}>Auto Apply</DataTableHeader>;
},
cell: ({ row }) => <RouteAutoApplyCell route={row.original} />,
sortingFn: "basic",
},
{
id: "group_names",
accessorFn: (row) => {
Expand Down Expand Up @@ -104,6 +114,10 @@ export default function RouteTable({ row }: Props) {
desc: true,
},
]);

const hasAtLeastOneExitNode = useMemo(() => {
return row.routes?.some((route) => route.network === "0.0.0.0/0");
}, [row.routes]);

const data = useMemo(() => {
if (!row.routes) return [];
Expand Down Expand Up @@ -144,6 +158,7 @@ export default function RouteTable({ row }: Props) {
domains: false,
domain_search: false,
network: false,
skipAutoApply: !!hasAtLeastOneExitNode,
}}
setSorting={setSorting}
columns={RouteTableColumns}
Expand Down
18 changes: 18 additions & 0 deletions src/modules/routes/RouteUpdateModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from "@components/Tabs";
import { Textarea } from "@components/Textarea";
import { DomainsTooltip } from "@components/ui/DomainListBadge";
import { getOperatingSystem } from "@hooks/useOperatingSystem";
import { IconDirectionSign } from "@tabler/icons-react";
import { cn } from "@utils/helpers";
import { uniqBy } from "lodash";
import {
Expand Down Expand Up @@ -193,6 +194,7 @@ function RouteUpdateModalContent({ onSuccess, route, cell }: ModalProps) {
const [masquerade, setMasquerade] = useState<boolean>(
route?.masquerade ?? true,
);
const [isForced, setIsForced] = useState<boolean>(route?.skip_auto_apply === false);

// Refs to manage focus on tab change
const networkRangeRef = useRef<HTMLInputElement>(null);
Expand Down Expand Up @@ -257,6 +259,7 @@ function RouteUpdateModalContent({ onSuccess, route, cell }: ModalProps) {
masquerade: useSinglePeer && isNonLinuxRoutingPeer ? true : masquerade,
groups: groupIds,
access_control_groups: accessControlGroupIds || undefined,
skip_auto_apply: !isForced,
},
(r) => {
onSuccess && onSuccess(r);
Expand Down Expand Up @@ -456,6 +459,21 @@ function RouteUpdateModalContent({ onSuccess, route, cell }: ModalProps) {
}
helpText={"Use this switch to enable or disable the route."}
/>

{isExitNode && (
<FancyToggleSwitch
value={isForced}
onChange={setIsForced}
label={
<>
<IconDirectionSign size={15} />
Auto Apply Route
</>
}
helpText={"Automatically apply this exit node to your distribution groups. This requires NetBird client v0.55.0 or higher."}
/>
)}

{!isExitNode && (
<RoutingPeerMasqueradeSwitch
value={masquerade}
Expand Down