Add distribution groups to Network routes (#118)

Users can add distribution groups to network routes

Groups can be added to individual network routes or to all routes in the group

Adding a new group in the modal is restricted to individual network route operations
This commit is contained in:
Maycon Santos
2022-12-08 17:24:34 +01:00
committed by GitHub
parent 7fb4b0b145
commit c677eeaae4
8 changed files with 229 additions and 38 deletions

View File

@@ -353,7 +353,7 @@ const PeerUpdate = () => {
max={59}/>
</Form.Item>
<Form.Item
label="Possible domain name after saving"
label="New peer domain name preview"
tooltip="If the domain name already exists, we add an increment number suffix to it"
style={{margin: '1px'}}
>

View File

@@ -19,7 +19,7 @@ import {
Typography
} from "antd";
import {CloseOutlined, FlagFilled, QuestionCircleFilled} from "@ant-design/icons";
import {Route} from "../store/route/types";
import {Route, RouteToSave} from "../store/route/types";
import {Header} from "antd/es/layout/layout";
import {RuleObject} from "antd/lib/form";
import cidrRegex from 'cidr-regex';
@@ -31,6 +31,7 @@ import {
transformGroupedDataTable
} from '../utils/routes'
import {useGetAccessTokenSilently} from "../utils/token";
import {useGetGroupTagHelpers} from "../utils/groups";
const {Paragraph} = Typography;
@@ -38,6 +39,17 @@ interface FormRoute extends Route {
}
const RouteUpdate = () => {
const {
tagRender,
handleChangeTags,
dropDownRender,
optionRender,
tagGroups,
getExistingAndToCreateGroupsLists,
getGroupNamesFromIDs,
selectValidator
} = useGetGroupTagHelpers()
const {Option} = Select;
const {getAccessTokenSilently} = useGetAccessTokenSilently()
const dispatch = useDispatch()
const setupNewRouteVisible = useSelector((state: RootState) => state.route.setupNewRouteVisible)
@@ -46,7 +58,6 @@ const RouteUpdate = () => {
const route = useSelector((state: RootState) => state.route.route)
const routes = useSelector((state: RootState) => state.route.data)
const savedRoute = useSelector((state: RootState) => state.route.savedRoute)
// const [groupedDataTable, setGroupedDataTable] = useState([] as GroupedDataTable[]);
const [previousRouteKey, setPreviousRouteKey] = useState("")
const [editName, setEditName] = useState(false)
const [editDescription, setEditDescription] = useState(false)
@@ -63,11 +74,12 @@ const RouteUpdate = () => {
const defaultStatusMSG = "Status"
const [statusMSG, setStatusMSG] = useState(defaultStatusMSG)
const [peerNameToIP, peerIPToName] = initPeerMaps(peers);
const [newRoute, setNewRoute] = useState(false)
const optionsDisabledEnabled = [{label: 'Enabled', value: true}, {label: 'Disabled', value: false}]
useEffect(() => {
if (setupNewRouteHA) {
if (!newRoute ) {
setRoutingPeerMSG("Add additional routing peer")
setMasqueradeMSG("Update Masquerade")
setStatusMSG("Update Status")
@@ -77,7 +89,7 @@ const RouteUpdate = () => {
setStatusMSG(defaultStatusMSG)
setPreviousRouteKey("")
}
}, [setupNewRouteHA])
}, [newRoute])
useEffect(() => {
if (editName) inputNameRef.current!.focus({
@@ -96,9 +108,15 @@ const RouteUpdate = () => {
const fRoute = {
...route,
groups: getGroupNamesFromIDs(route.groups)
} as FormRoute
setFormRoute(fRoute)
setPreviousRouteKey(fRoute.network_id + fRoute.network)
if (!route.network_id) {
setNewRoute(true)
} else {
setNewRoute(false)
}
form.setFieldsValue(fRoute)
}, [route])
@@ -114,7 +132,7 @@ const RouteUpdate = () => {
}
})
const createRouteToSave = (inputRoute: FormRoute): Route => {
const createRouteToSave = (inputRoute: FormRoute): RouteToSave => {
let peerIDList = inputRoute.peer.split(routePeerSeparator)
let peerID: string
if (peerIDList[1]) {
@@ -123,6 +141,8 @@ const RouteUpdate = () => {
peerID = peerNameToIP[inputRoute.peer]
}
let [ existingGroups, groupsToCreate ] = getExistingAndToCreateGroupsLists(inputRoute.groups)
return {
id: inputRoute.id,
network: inputRoute.network,
@@ -131,8 +151,10 @@ const RouteUpdate = () => {
peer: peerID,
enabled: inputRoute.enabled,
masquerade: inputRoute.masquerade,
metric: inputRoute.metric
} as Route
metric: inputRoute.metric,
groups: existingGroups,
groupsToCreate: groupsToCreate,
} as RouteToSave
}
const handleFormSubmit = () => {
@@ -195,13 +217,14 @@ const RouteUpdate = () => {
setVisibleNewRoute(false)
setSetupNewRouteHA(false)
setPreviousRouteKey("")
setNewRoute(false)
}
const onChange = (data: any) => {
setFormRoute({...formRoute, ...data})
}
const dropDownRender = (menu: React.ReactElement) => (
const peerDropDownRender = (menu: React.ReactElement) => (
<>
{menu}
</>
@@ -227,13 +250,32 @@ const RouteUpdate = () => {
return Promise.resolve()
}
const peerValidator = (_: RuleObject, value: string) => {
if (value == "" && newRoute) {
return Promise.reject(new Error("Please select routing one peer"))
}
return Promise.resolve()
}
const selectPreValidator = (obj: RuleObject, value: string[]) => {
if (setupNewRouteHA && formRoute.peer == '') {
let [, newGroups ] = getExistingAndToCreateGroupsLists(value)
if (newGroups.length > 0) {
return Promise.reject(new Error("You can't add new Groups from the group update view, please remove:\"" + newGroups +"\""))
}
}
return selectValidator(obj, value)
}
return (
<>
{route &&
<Drawer
headerStyle={{display: "none"}}
forceRender={true}
visible={setupNewRouteVisible}
open={setupNewRouteVisible}
bodyStyle={{paddingBottom: 80}}
onClose={onCancel}
autoFocus={true}
@@ -241,11 +283,11 @@ const RouteUpdate = () => {
<Space style={{display: 'flex', justifyContent: 'end'}}>
<Button onClick={onCancel} disabled={savedRoute.loading}>Cancel</Button>
<Button type="primary" disabled={savedRoute.loading}
onClick={handleFormSubmit}>{`${formRoute.network_id ? 'Save' : 'Create'}`}</Button>
onClick={handleFormSubmit}>{`${newRoute ? 'Create' : 'Save'}`}</Button>
</Space>
}
>
<Form layout="vertical" hideRequiredMark form={form} onValuesChange={onChange}>
<Form layout="vertical" form={form} requiredMark={false} onValuesChange={onChange}>
<Row gutter={16}>
<Col span={24}>
<Header style={{margin: "-32px -24px 20px -24px", padding: "24px 24px 0 24px"}}>
@@ -278,7 +320,7 @@ const RouteUpdate = () => {
}]}
>
<Input placeholder="e.g. aws-eu-central-1-vpc" ref={inputNameRef}
disabled={!setupNewRouteHA}
disabled={!setupNewRouteHA && !newRoute}
onPressEnter={() => toggleEditName(false)}
onBlur={() => toggleEditName(false)} autoComplete="off"
maxLength={40}/>
@@ -294,7 +336,7 @@ const RouteUpdate = () => {
style={{marginTop: 24}}
>
<Input placeholder="Add description..." ref={inputDescriptionRef}
disabled={!setupNewRouteHA}
disabled={!setupNewRouteHA && !newRoute}
onPressEnter={() => toggleEditDescription(false)}
onBlur={() => toggleEditDescription(false)}
autoComplete="off" maxLength={200}/>
@@ -321,7 +363,7 @@ const RouteUpdate = () => {
tooltip="Use CIDR notation. e.g. 192.168.10.0/24 or 172.16.0.0/16"
rules={[{validator: networkRangeValidator}]}
>
<Input placeholder="e.g. 172.16.0.0/16" disabled={!setupNewRouteHA}
<Input placeholder="e.g. 172.16.0.0/16" disabled={!setupNewRouteHA && !newRoute}
autoComplete="off" minLength={9} maxLength={43}/>
</Form.Item>
</Col>
@@ -343,12 +385,13 @@ const RouteUpdate = () => {
name="peer"
label={routingPeerMSG}
tooltip="Assign a peer as a routing peer for the Network CIDR"
rules={[{validator:peerValidator}]}
>
<Select
showSearch
style={{width: '100%'}}
placeholder="Select Peer"
dropdownRender={dropDownRender}
dropdownRender={peerDropDownRender}
options={options}
allowClear={true}
/>
@@ -360,7 +403,7 @@ const RouteUpdate = () => {
label={masqueradeMSG}
tooltip={masqueradeDisabledMSG}
>
<Switch size={"small"} disabled={!setupNewRouteHA} checked={formRoute.masquerade}/>
<Switch size={"small"} disabled={!setupNewRouteHA && !newRoute} checked={formRoute.masquerade}/>
</Form.Item>
</Col>
<Col span={24}>
@@ -372,6 +415,28 @@ const RouteUpdate = () => {
<InputNumber min={1} max={9999} autoComplete="off"/>
</Form.Item>
</Col>
<Col span={24}>
<Form.Item
name="groups"
label="Distribution groups"
tooltip="NetBird will advertise this route to peers that belong to the following groups"
rules={[{validator: selectPreValidator}]}
>
<Select mode="tags"
style={{width: '100%'}}
placeholder="Associate groups with the network route"
tagRender={tagRender}
onChange={handleChangeTags}
dropdownRender={dropDownRender}
>
{
tagGroups.map(m =>
<Option key={m}>{optionRender(m)}</Option>
)
}
</Select>
</Form.Item>
</Col>
<Col span={24}>
<Row wrap={false} gutter={12}>
<Col flex="none">

View File

@@ -1,5 +1,5 @@
import { ActionType, createAction, createAsyncAction } from 'typesafe-actions';
import {Route} from './types';
import {Route, RouteToSave} from './types';
import {ApiError, CreateResponse, DeleteResponse, RequestPayload} from '../../services/api-client/types';
const actions = {
@@ -13,7 +13,7 @@ const actions = {
'SAVE_ROUTE_REQUEST',
'SAVE_ROUTE_SUCCESS',
'SAVE_ROUTE_FAILURE',
)<RequestPayload<Route>, CreateResponse<Route | null>, CreateResponse<Route | null>>(),
)<RequestPayload<RouteToSave>, CreateResponse<Route | null>, CreateResponse<Route | null>>(),
setSavedRoute: createAction('SET_CREATE_ROUTE')<CreateResponse<Route | null>>(),
resetSavedRoute: createAction('RESET_CREATE_ROUTE')<null>(),

View File

@@ -3,6 +3,9 @@ import {ApiError, ApiResponse, CreateResponse, DeleteResponse} from '../../servi
import {Route} from './types'
import service from './service';
import actions from './actions';
import serviceGroup from "../group/service";
import {Group} from "../group/types";
import {actions as groupActions} from "../group";
export function* getRoutes(action: ReturnType<typeof actions.getRoutes.request>): Generator {
try {
@@ -40,6 +43,21 @@ export function* saveRoute(action: ReturnType<typeof actions.saveRoute.request>)
const routeToSave = action.payload.payload
let groupsToCreate = routeToSave.groupsToCreate
if (!groupsToCreate) {
groupsToCreate = []
}
// first, create groups that were newly added by user
const responsesGroup = yield all(groupsToCreate.map(g => call(serviceGroup.createGroup, {
getAccessTokenSilently: action.payload.getAccessTokenSilently,
payload: {name: g}
})
))
const resGroups = (responsesGroup as ApiResponse<Group>[]).filter(r => r.statusCode === 200).map(g => (g.body as Group)).map(g => g.id)
const newGroups = [...routeToSave.groups, ...resGroups]
const payloadToSave = {
getAccessTokenSilently: action.payload.getAccessTokenSilently,
payload: {
@@ -50,7 +68,8 @@ export function* saveRoute(action: ReturnType<typeof actions.saveRoute.request>)
metric: routeToSave.metric,
network: routeToSave.network,
network_id: routeToSave.network_id,
peer: routeToSave.peer
peer: routeToSave.peer,
groups: newGroups
} as Route
}
@@ -72,8 +91,19 @@ export function* saveRoute(action: ReturnType<typeof actions.saveRoute.request>)
data: response.body
} as CreateResponse<Route | null>));
yield put(groupActions.getGroups.request({
getAccessTokenSilently: action.payload.getAccessTokenSilently,
payload: null
}));
yield put(actions.getRoutes.request({ getAccessTokenSilently: action.payload.getAccessTokenSilently, payload: null }));
} catch (err) {
yield put(groupActions.getGroups.request({
getAccessTokenSilently: action.payload.getAccessTokenSilently,
payload: null
}));
yield put(actions.saveRoute.failure({
loading: false,
success: false,

View File

@@ -1,3 +1,4 @@
export interface Route {
id?: string
description: string
@@ -8,4 +9,10 @@ export interface Route {
network_type?: string
metric?: number
masquerade: boolean
groups: string[]
}
export interface RouteToSave extends Route
{
groupsToCreate: string[]
}

View File

@@ -34,6 +34,7 @@ export interface GroupedDataTable {
description: string
routesCount: number
groupedRoutes: RouteDataTable[]
routesGroups: string[]
}
export const transformDataTable = (d:Route[],peerIPToName:PeerIPToName):RouteDataTable[] => {
@@ -52,10 +53,12 @@ export const transformGroupedDataTable = (routes:Route[],peerIPToName:PeerIPToNa
}))
let groupedRoutes:GroupedDataTable[] = []
keySet.forEach((p) => {
let hasEnabled = false
let lastRoute:Route
let listedRoutes:Route[] = []
let groupList:string[] = []
routes.forEach((r) => {
if ( p === r.network_id + r.network ) {
lastRoute = r
@@ -63,8 +66,10 @@ export const transformGroupedDataTable = (routes:Route[],peerIPToName:PeerIPToNa
hasEnabled = true
}
listedRoutes.push(r)
groupList = groupList.concat(r.groups)
}
})
groupList = groupList.filter((value,index,arrary) => arrary.indexOf(value) === index)
let groupDataTableRoutes = transformDataTable(listedRoutes,peerIPToName)
groupedRoutes.push({
key: p.toString(),
@@ -75,6 +80,7 @@ export const transformGroupedDataTable = (routes:Route[],peerIPToName:PeerIPToNa
enabled: hasEnabled,
routesCount: groupDataTableRoutes.length,
groupedRoutes: groupDataTableRoutes,
routesGroups: groupList,
})
})
return groupedRoutes

View File

@@ -301,7 +301,7 @@ export const DNS = () => {
let errorMsg = "Failed to update nameserver group"
switch (savedNSGroup.error.statusCode) {
case 403:
errorMsg = "Failed to update user. You might not have enough permissions."
errorMsg = "Failed to update nameserver group. You might not have enough permissions."
break
default:
errorMsg = savedNSGroup.error.data.message ? savedNSGroup.error.data.message : errorMsg

View File

@@ -9,7 +9,7 @@ import {
Input,
Menu,
message,
Modal,
Modal, Popover,
Radio,
RadioChangeEvent,
Row,
@@ -24,7 +24,7 @@ import {
import {Container} from "../components/Container";
import {useDispatch, useSelector} from "react-redux";
import {RootState} from "typesafe-actions";
import {Route} from "../store/route/types";
import {Route, RouteToSave} from "../store/route/types";
import {actions as routeActions} from "../store/route";
import {actions as peerActions} from "../store/peer";
import {filter, sortBy} from "lodash";
@@ -42,6 +42,10 @@ import {
transformGroupedDataTable
} from '../utils/routes'
import {useGetAccessTokenSilently} from "../utils/token";
import {Group} from "../store/group/types";
import {TooltipPlacement} from "antd/es/tooltip";
import {actions as groupActions} from "../store/group";
import {useGetGroupTagHelpers} from "../utils/groups";
const {Title, Paragraph, Text} = Typography;
const {Column} = Table;
@@ -50,7 +54,11 @@ const {confirm} = Modal;
export const Routes = () => {
const {getAccessTokenSilently} = useGetAccessTokenSilently()
const dispatch = useDispatch()
const {
getGroupNamesFromIDs,
} = useGetGroupTagHelpers()
const groups = useSelector((state: RootState) => state.group.data)
const routes = useSelector((state: RootState) => state.route.data);
const failed = useSelector((state: RootState) => state.route.failed);
const loading = useSelector((state: RootState) => state.route.loading);
@@ -58,6 +66,7 @@ export const Routes = () => {
const savedRoute = useSelector((state: RootState) => state.route.savedRoute);
const peers = useSelector((state: RootState) => state.peer.data)
const loadingPeer = useSelector((state: RootState) => state.peer.loading);
const setupNewRouteVisible = useSelector((state: RootState) => state.route.setupNewRouteVisible)
const [showTutorial, setShowTutorial] = useState(true)
const [textToSearch, setTextToSearch] = useState('');
const [optionAllEnable, setOptionAllEnable] = useState('enabled');
@@ -67,6 +76,7 @@ export const Routes = () => {
const [routeToAction, setRouteToAction] = useState(null as RouteDataTable | null);
const [groupedDataTable, setGroupedDataTable] = useState([] as GroupedDataTable[]);
const [expandRowsOnClick, setExpandRowsOnClick] = useState(true)
const [groupPopupVisible, setGroupPopupVisible] = useState(false as boolean | undefined)
const [peerNameToIP, peerIPToName] = initPeerMaps(peers);
@@ -83,10 +93,6 @@ export const Routes = () => {
key: "view",
label: (<Button type="text" block onClick={() => onClickViewRoute()}>View</Button>)
},
// {
// key: "delete",
// label: (<Button type="text" block onClick={() => showConfirmDeactivate()}>Deactivate</Button>)
// },
{
key: "delete",
label: (<Button type="text" block onClick={() => showConfirmDelete()}>Delete</Button>)
@@ -104,12 +110,15 @@ export const Routes = () => {
useEffect(() => {
dispatch(peerActions.getPeers.request({getAccessTokenSilently: getAccessTokenSilently, payload: null}));
dispatch(groupActions.getGroups.request({getAccessTokenSilently: getAccessTokenSilently, payload: null}));
}, [])
const filterGroupedDataTable = (routes: GroupedDataTable[]): GroupedDataTable[] => {
const t = textToSearch.toLowerCase().trim()
let f: GroupedDataTable[] = filter(routes, (f) =>
(f.network_id.toLowerCase().includes(t) || f.network.toLowerCase().includes(t) || f.description.toLowerCase().includes(t) || t === "")
(f.network_id.toLowerCase().includes(t) || f.network.toLowerCase().includes(t) ||
f.description.toLowerCase().includes(t) || t === "" ||
getGroupNamesFromIDs(f.routesGroups).find(u => u.toLowerCase().trim().includes(t)) )
) as GroupedDataTable[]
if (optionAllEnable !== "all") {
f = filter(f, (f) => f.enabled)
@@ -151,10 +160,19 @@ export const Routes = () => {
dispatch(routeActions.setSavedRoute({...savedRoute, success: false}))
dispatch(routeActions.resetSavedRoute(null))
} else if (savedRoute.error) {
let errorMsg = "Failed to update network route"
switch (savedRoute.error.statusCode) {
case 403:
errorMsg = "Failed to update network route. You might not have enough permissions."
break
default:
errorMsg = savedRoute.error.data.message ? savedRoute.error.data.message : errorMsg
break
}
message.error({
content: savedRoute.error.data ? savedRoute.error.data : savedRoute.error.message,
content: errorMsg,
key: saveKey,
duration: 2,
duration: 5,
style: styleNotification
});
dispatch(routeActions.setSavedRoute({...savedRoute, error: null}))
@@ -224,7 +242,6 @@ export const Routes = () => {
const onClickAddNewRoute = () => {
dispatch(routeActions.setSetupNewRouteHA(true));
dispatch(routeActions.setSetupNewRouteVisible(true));
dispatch(routeActions.setRoute({
network: '',
@@ -264,7 +281,8 @@ export const Routes = () => {
peer: route.peer ? peerToPeerIP(route.peer, peerNameToIP[route.peer]) : '',
metric: route.metric ? route.metric : 9999,
masquerade: route.masquerade,
enabled: route.enabled
enabled: route.enabled,
groups: route.groups
} as Route))
dispatch(routeActions.setSetupNewRouteVisible(true));
}
@@ -293,17 +311,77 @@ export const Routes = () => {
});
}
const onPopoverVisibleChange = () => {
if (setupNewRouteVisible) {
setGroupPopupVisible(false)
} else {
setGroupPopupVisible(undefined)
}
}
function handleSwitchMasquerade(routeGroup: GroupedDataTable, checked: boolean) {
routeGroup.groupedRoutes.forEach((record) => {
const route = {
...record,
peer: peerNameToIP[record.peer],
masquerade: checked,
} as Route
groupsToCreate: []
} as RouteToSave
dispatch(routeActions.saveRoute.request({getAccessTokenSilently: getAccessTokenSilently, payload: route}));
})
}
const renderPopoverGroups = (label: string, rowGroups: string[] | null, userToAction: RouteDataTable) => {
let groupsMap = new Map<string, Group>();
groups.forEach(g => {
groupsMap.set(g.id!, g)
})
let displayGroups: Group[] = []
if (rowGroups) {
displayGroups = rowGroups.filter(g => groupsMap.get(g)).map(g => groupsMap.get(g)!)
}
let btn = <Button type="link" onClick={() => setRouteAndView(userToAction)}>{displayGroups.length}</Button>
if (!displayGroups || displayGroups!.length < 1) {
return btn
}
const content = displayGroups?.map((g, i) => {
const _g = g as Group
const peersCount = ` - ${_g.peers_count || 0} ${(!_g.peers_count || parseInt(_g.peers_count) !== 1) ? 'peers' : 'peer'} `
return (
<div key={i}>
<Tag
color="blue"
style={{marginRight: 3}}
>
<strong>{_g.name}</strong>
</Tag>
<span style={{fontSize: ".85em"}}>{peersCount}</span>
</div>
)
})
const mainContent = (<Space direction="vertical">{content}</Space>)
let popoverPlacement = "top"
if (content && content.length > 5) {
popoverPlacement = "rightTop"
}
return (
<Popover placement={popoverPlacement as TooltipPlacement}
key={userToAction.id}
onOpenChange={onPopoverVisibleChange}
open={groupPopupVisible}
content={mainContent}
title={null}>
{btn}
</Popover>
)
}
const expandedRowRender = (record: GroupedDataTable) => {
return <Table
@@ -327,7 +405,12 @@ export const Routes = () => {
onFilter={(value: string | number | boolean, record) => (record as any).metric.includes(value)}
sorter={(a, b) => ((a as any).metric - ((b as any).metric))}
/>
<Column title="Status" dataIndex="enabled" align="center"
<Column title="Groups" dataIndex="groupsCount" align="center"
render={(text, record: RouteDataTable) => {
return renderPopoverGroups(text, record.groups, record)
}}
/>
<Column title="Routing peer status" dataIndex="enabled" align="center"
render={(text: Boolean) => {
return text ? <Tag color="green">enabled</Tag> : <Tag color="red">disabled</Tag>
}}
@@ -336,7 +419,7 @@ export const Routes = () => {
render={(text, record) => {
if (deletedRoute.loading || savedRoute.loading) return <></>
return <Dropdown.Button type="text" overlay={actionsMenu} trigger={["click"]}
onVisibleChange={visible => {
onOpenChange={visible => {
if (visible) setRouteToAction(record as RouteDataTable)
}}></Dropdown.Button>
}}
@@ -438,7 +521,7 @@ export const Routes = () => {
sorter={(a, b) => ((a as any).network.localeCompare((b as any).network))}
// defaultSortOrder='ascend'
/>
<Column title="Status" dataIndex="enabled" align="center"
<Column title="Route status" dataIndex="enabled" align="center"
render={(text: Boolean) => {
return text ? <Tag color="green">enabled</Tag> :
<Tag color="red">disabled</Tag>
@@ -463,8 +546,8 @@ export const Routes = () => {
if (count > 1) {
tag = <Tag color="green">on</Tag>
}
return <div>{tag}<Divider type="vertical"/><Button type="link"
onClick={() => setRouteAndView(record)}>Configure</Button>
return <div>{tag}<Divider type="vertical"/>
<Button type="link" onClick={() => setRouteAndView(record)}>Configure</Button>
</div>
}}
/>