Skip to content

Commit ea5da2c

Browse files
committed
Add skip_auto_apply feature to exit nodes and update related components
1 parent 9b1f920 commit ea5da2c

File tree

10 files changed

+144
-22
lines changed

10 files changed

+144
-22
lines changed

src/contexts/RoutesProvider.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,7 @@ export default function RoutesProvider({ children }: Readonly<Props>) {
6161
: toUpdate.access_control_groups ??
6262
route.access_control_groups ??
6363
undefined,
64+
skip_auto_apply: toUpdate.skip_auto_apply ?? route.skip_auto_apply ?? true,
6465
},
6566
`/${route.id}`,
6667
)
@@ -94,6 +95,7 @@ export default function RoutesProvider({ children }: Readonly<Props>) {
9495
masquerade: route.masquerade,
9596
groups: route.groups || [],
9697
access_control_groups: route?.access_control_groups || undefined,
98+
skip_auto_apply: route.skip_auto_apply ?? true,
9799
})
98100
.then((route) => {
99101
mutate("/routes");

src/interfaces/Route.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ export interface Route {
1212
groups: string[];
1313
keep_route?: boolean;
1414
access_control_groups?: string[];
15+
skip_auto_apply?: boolean;
1516
// Frontend only
1617
peer_groups?: string[];
1718
routesGroups?: string[];

src/modules/exit-node/ExitNodeDropdownButton.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ type Props = {
1515

1616
export const ExitNodeDropdownButton = ({ peer }: Props) => {
1717
const [modal, setModal] = useState(false);
18-
const hasExitNodes = useHasExitNodes(peer);
18+
const exitNodeInfo = useHasExitNodes(peer);
1919
const { permission } = usePermissions();
2020

2121
return (
@@ -25,7 +25,7 @@ export const ExitNodeDropdownButton = ({ peer }: Props) => {
2525
disabled={!permission.routes.create}
2626
>
2727
<div className={"flex gap-3 items-center w-full"}>
28-
{hasExitNodes ? (
28+
{exitNodeInfo.hasExitNode ? (
2929
<>
3030
<IconCirclePlus size={14} className={"shrink-0"} />
3131
<div className={"flex justify-between items-center w-full"}>

src/modules/exit-node/ExitNodePeerIndicator.tsx

Lines changed: 16 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -8,18 +8,22 @@ type Props = {
88
peer: Peer;
99
};
1010
export const ExitNodePeerIndicator = ({ peer }: Props) => {
11-
const hasExitNode = useHasExitNodes(peer);
11+
const exitNodeInfo = useHasExitNodes(peer);
1212

13-
return hasExitNode ? (
14-
<FullTooltip
15-
content={
16-
<div className={"text-xs max-w-xs"}>
17-
This peer is an exit node. Traffic from the configured distribution
18-
groups will be routed through this peer.
19-
</div>
20-
}
21-
>
22-
<IconDirectionSign size={15} className={"text-yellow-400 shrink-0"} />
13+
if (!exitNodeInfo.hasExitNode) {
14+
return null;
15+
}
16+
17+
const tooltipContent = exitNodeInfo.skipAutoApply === false
18+
? "This peer is an auto-applied exit node. Traffic from the configured distribution groups will be routed through this peer."
19+
: "This peer is an exit node. Traffic from the configured distribution groups will be routed through this peer.";
20+
21+
return (
22+
<FullTooltip content={<div className={"text-xs max-w-xs"}>{tooltipContent}</div>}>
23+
<IconDirectionSign
24+
size={15}
25+
className={`shrink-0 ${exitNodeInfo.skipAutoApply === false ? "text-green-400" : "text-yellow-400"}`}
26+
/>
2327
</FullTooltip>
24-
) : null;
28+
);
2529
};

src/modules/exit-node/useHasExitNodes.tsx

Lines changed: 19 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,17 +3,30 @@ import { useLoggedInUser } from "@/contexts/UsersProvider";
33
import { Peer } from "@/interfaces/Peer";
44
import { Route } from "@/interfaces/Route";
55

6-
export const useHasExitNodes = (peer?: Peer) => {
6+
export interface ExitNodeInfo {
7+
hasExitNode: boolean;
8+
skipAutoApply?: boolean;
9+
}
10+
11+
export const useHasExitNodes = (peer?: Peer): ExitNodeInfo => {
712
const { isOwnerOrAdmin } = useLoggedInUser();
813
const { data: routes } = useFetchApi<Route[]>(
914
`/routes`,
1015
false,
1116
true,
1217
isOwnerOrAdmin,
1318
);
14-
return peer
15-
? routes?.some(
16-
(route) => route?.peer === peer.id && route?.network === "0.0.0.0/0",
17-
) || false
18-
: false;
19+
20+
if (!peer || !routes) {
21+
return { hasExitNode: false };
22+
}
23+
24+
const exitNodeRoute = routes.find(
25+
(route) => route?.peer === peer.id && route?.network === "0.0.0.0/0",
26+
);
27+
28+
return {
29+
hasExitNode: !!exitNodeRoute,
30+
skipAutoApply: exitNodeRoute?.skip_auto_apply,
31+
};
1932
};

src/modules/peer/PeerNetworkRoutesSection.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ type Props = {
1717

1818
export const PeerNetworkRoutesSection = ({ peer }: Props) => {
1919
const { peerRoutes, isLoading } = usePeerRoutes({ peer });
20-
const hasExitNodes = useHasExitNodes(peer);
20+
const exitNodeInfo = useHasExitNodes(peer);
2121
const { ref: headingRef, portalTarget } =
2222
usePortalElement<HTMLHeadingElement>();
2323

@@ -34,7 +34,7 @@ export const PeerNetworkRoutesSection = ({ peer }: Props) => {
3434
</div>
3535
<div className={"inline-flex gap-4 justify-end"}>
3636
<div className={"gap-4 flex"}>
37-
<AddExitNodeButton peer={peer} firstTime={!hasExitNodes} />
37+
<AddExitNodeButton peer={peer} firstTime={!exitNodeInfo.hasExitNode} />
3838
<AddRouteDropdownButton />
3939
</div>
4040
</div>
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
import { ToggleSwitch } from "@components/ToggleSwitch";
2+
import React, { useMemo } from "react";
3+
import { useSWRConfig } from "swr";
4+
import { usePermissions } from "@/contexts/PermissionsProvider";
5+
import { useRoutes } from "@/contexts/RoutesProvider";
6+
import { Route } from "@/interfaces/Route";
7+
8+
type Props = {
9+
route: Route;
10+
};
11+
12+
export default function RouteAutoApplyCell({ route }: Readonly<Props>) {
13+
const { permission } = usePermissions();
14+
const { updateRoute } = useRoutes();
15+
const { mutate } = useSWRConfig();
16+
17+
const isExitNode = useMemo(() => route.network === "0.0.0.0/0", [route]);
18+
19+
const isChecked = useMemo(() => {
20+
// Checked means Auto Apply is ON, which maps to skip_auto_apply === false
21+
return route.skip_auto_apply === false;
22+
}, [route]);
23+
24+
const update = async (checked: boolean) => {
25+
// When toggled ON (checked === true), we want skip_auto_apply = false
26+
const nextSkipAutoApply = !checked;
27+
updateRoute(
28+
route,
29+
{ skip_auto_apply: nextSkipAutoApply },
30+
() => {
31+
mutate("/routes");
32+
},
33+
checked
34+
? "Auto Apply was enabled for the route"
35+
: "Auto Apply was disabled for the route",
36+
);
37+
};
38+
39+
if (!isExitNode) return null;
40+
41+
return (
42+
<div className={"flex items-center"}>
43+
<ToggleSwitch
44+
checked={isChecked}
45+
size={"small"}
46+
onClick={() => update(!isChecked)}
47+
disabled={!permission.routes.update}
48+
/>
49+
</div>
50+
);
51+
}
52+
53+

src/modules/routes/RouteModal.tsx

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -227,6 +227,7 @@ export function RouteModalContent({
227227
const [enabled, setEnabled] = useState<boolean>(true);
228228
const [metric, setMetric] = useState("9999");
229229
const [masquerade, setMasquerade] = useState<boolean>(true);
230+
const [isForced, setIsForced] = useState<boolean>(true);
230231

231232
const isNonLinuxRoutingPeer = useMemo(() => {
232233
if (!routingPeer) return false;
@@ -304,6 +305,7 @@ export function RouteModalContent({
304305
masquerade: useSinglePeer && isNonLinuxRoutingPeer ? true : masquerade,
305306
groups: groupIds,
306307
access_control_groups: accessControlGroupIds || undefined,
308+
skip_auto_apply: !isForced,
307309
},
308310
onSuccess,
309311
);
@@ -718,6 +720,20 @@ export function RouteModalContent({
718720
helpText={"Use this switch to enable or disable the route."}
719721
/>
720722

723+
{exitNode && (
724+
<FancyToggleSwitch
725+
value={isForced}
726+
onChange={setIsForced}
727+
label={
728+
<>
729+
<IconDirectionSign size={15} />
730+
Auto Apply Route
731+
</>
732+
}
733+
helpText={"Automatically apply this exit node to your distribution groups. This requires NetBird client v0.55.0 or higher."}
734+
/>
735+
)}
736+
721737
{!exitNode && (
722738
<RoutingPeerMasqueradeSwitch
723739
value={masquerade}

src/modules/routes/RouteTable.tsx

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { GroupedRoute, Route } from "@/interfaces/Route";
77
import RouteAccessControlGroups from "@/modules/routes/RouteAccessControlGroups";
88
import RouteActionCell from "@/modules/routes/RouteActionCell";
99
import RouteActiveCell from "@/modules/routes/RouteActiveCell";
10+
import RouteAutoApplyCell from "@/modules/routes/RouteAutoApplyCell";
1011
import RouteDistributionGroupsCell from "@/modules/routes/RouteDistributionGroupsCell";
1112
import RouteMetricCell from "@/modules/routes/RouteMetricCell";
1213
import RoutePeerCell from "@/modules/routes/RoutePeerCell";
@@ -77,6 +78,15 @@ export const RouteTableColumns: ColumnDef<Route>[] = [
7778
},
7879
cell: ({ row }) => <RouteAccessControlGroups route={row.original} />,
7980
},
81+
{
82+
id: "skipAutoApply",
83+
accessorKey: "skip_auto_apply",
84+
header: ({ column }) => {
85+
return <DataTableHeader column={column}>Auto Apply</DataTableHeader>;
86+
},
87+
cell: ({ row }) => <RouteAutoApplyCell route={row.original} />,
88+
sortingFn: "basic",
89+
},
8090
{
8191
id: "group_names",
8292
accessorFn: (row) => {
@@ -104,6 +114,10 @@ export default function RouteTable({ row }: Props) {
104114
desc: true,
105115
},
106116
]);
117+
118+
const hasAtLeastOneExitNode = useMemo(() => {
119+
return row.routes?.some((route) => route.network === "0.0.0.0/0");
120+
}, [row.routes]);
107121

108122
const data = useMemo(() => {
109123
if (!row.routes) return [];
@@ -144,6 +158,7 @@ export default function RouteTable({ row }: Props) {
144158
domains: false,
145159
domain_search: false,
146160
network: false,
161+
skipAutoApply: !!hasAtLeastOneExitNode,
147162
}}
148163
setSorting={setSorting}
149164
columns={RouteTableColumns}

src/modules/routes/RouteUpdateModal.tsx

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from "@components/Tabs";
2020
import { Textarea } from "@components/Textarea";
2121
import { DomainsTooltip } from "@components/ui/DomainListBadge";
2222
import { getOperatingSystem } from "@hooks/useOperatingSystem";
23+
import { IconDirectionSign } from "@tabler/icons-react";
2324
import { cn } from "@utils/helpers";
2425
import { uniqBy } from "lodash";
2526
import {
@@ -193,6 +194,7 @@ function RouteUpdateModalContent({ onSuccess, route, cell }: ModalProps) {
193194
const [masquerade, setMasquerade] = useState<boolean>(
194195
route?.masquerade ?? true,
195196
);
197+
const [isForced, setIsForced] = useState<boolean>(route?.skip_auto_apply === false);
196198

197199
// Refs to manage focus on tab change
198200
const networkRangeRef = useRef<HTMLInputElement>(null);
@@ -257,6 +259,7 @@ function RouteUpdateModalContent({ onSuccess, route, cell }: ModalProps) {
257259
masquerade: useSinglePeer && isNonLinuxRoutingPeer ? true : masquerade,
258260
groups: groupIds,
259261
access_control_groups: accessControlGroupIds || undefined,
262+
skip_auto_apply: !isForced,
260263
},
261264
(r) => {
262265
onSuccess && onSuccess(r);
@@ -456,6 +459,21 @@ function RouteUpdateModalContent({ onSuccess, route, cell }: ModalProps) {
456459
}
457460
helpText={"Use this switch to enable or disable the route."}
458461
/>
462+
463+
{isExitNode && (
464+
<FancyToggleSwitch
465+
value={isForced}
466+
onChange={setIsForced}
467+
label={
468+
<>
469+
<IconDirectionSign size={15} />
470+
Auto Apply Route
471+
</>
472+
}
473+
helpText={"Automatically apply this exit node to your distribution groups. This requires NetBird client v0.55.0 or higher."}
474+
/>
475+
)}
476+
459477
{!isExitNode && (
460478
<RoutingPeerMasqueradeSwitch
461479
value={masquerade}

0 commit comments

Comments
 (0)