diff --git a/packages/manager/.changeset/pr-9608-upcoming-features-1693406491148.md b/packages/manager/.changeset/pr-9608-upcoming-features-1693406491148.md new file mode 100644 index 00000000000..b78c2ad200b --- /dev/null +++ b/packages/manager/.changeset/pr-9608-upcoming-features-1693406491148.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Upcoming Features +--- + +Created 'Add Nodebalancer' to Firewall drawer ([#9608](https://github.com/linode/manager/pull/9608)) diff --git a/packages/manager/src/features/Firewalls/FirewallDetail/Devices/AddDeviceDrawer.tsx b/packages/manager/src/features/Firewalls/FirewallDetail/Devices/AddDeviceDrawer.tsx deleted file mode 100644 index fa35fc730ad..00000000000 --- a/packages/manager/src/features/Firewalls/FirewallDetail/Devices/AddDeviceDrawer.tsx +++ /dev/null @@ -1,161 +0,0 @@ -import { useTheme } from '@mui/material/styles'; -import * as React from 'react'; -import { useParams } from 'react-router-dom'; - -import { ActionsPanel } from 'src/components/ActionsPanel/ActionsPanel'; -import { Drawer } from 'src/components/Drawer'; -import { Link } from 'src/components/Link'; -import { Notice } from 'src/components/Notice/Notice'; -import { SupportLink } from 'src/components/SupportLink'; -import { LinodeSelect } from 'src/features/Linodes/LinodeSelect/LinodeSelect'; -import { - useAddFirewallDeviceMutation, - useAllFirewallDevicesQuery, - useFirewallQuery, -} from 'src/queries/firewalls'; -import { useGrants, useProfile } from 'src/queries/profile'; -import { getAPIErrorOrDefault } from 'src/utilities/errorUtils'; -import { getEntityIdsByPermission } from 'src/utilities/grants'; - -import { READ_ONLY_LINODES_HIDDEN_MESSAGE } from '../../FirewallLanding/CreateFirewallDrawer'; - -interface Props { - onClose: () => void; - open: boolean; -} - -export const AddDeviceDrawer = (props: Props) => { - const { onClose, open } = props; - - const { id } = useParams<{ id: string }>(); - - const { data: grants } = useGrants(); - const { data: profile } = useProfile(); - const isRestrictedUser = Boolean(profile?.restricted); - - const { data: firewall } = useFirewallQuery(Number(id)); - const { - data: currentDevices, - isLoading: currentDevicesLoading, - } = useAllFirewallDevicesQuery(Number(id)); - - const currentLinodeIds = - currentDevices - ?.filter((device) => device.entity.type === 'linode') - .map((device) => device.entity.id) ?? []; - - const { - error, - isLoading, - mutateAsync: addDevice, - } = useAddFirewallDeviceMutation(Number(id)); - const theme = useTheme(); - - const [selectedLinodeIds, setSelectedLinodeIds] = React.useState( - [] - ); - - const handleSubmit = async () => { - await Promise.all( - selectedLinodeIds.map((id) => addDevice({ id, type: 'linode' })) - ); - onClose(); - setSelectedLinodeIds([]); - }; - - // @todo title and error messaging will update to "Device" once NodeBalancers are allowed - const errorMessage = error - ? getAPIErrorOrDefault(error, 'Error adding Linode')[0].reason - : undefined; - - // @todo update regex once error messaging updates - const errorNotice = (errorMsg: string) => { - // match something like: Linode (ID ) - const linode = /linode (.+?) \(id ([^()]*)\)/i.exec(errorMsg); - const openTicket = errorMsg.match(/open a support ticket\./i); - if (openTicket) { - errorMsg = errorMsg.replace(/open a support ticket\./i, ''); - } - if (linode) { - const [, label, id] = linode; - const labelIndex = errorMsg.indexOf(label); - errorMsg = errorMsg.replace(/\(id ([^()]*)\)/i, ''); - return ( - - {errorMsg.substring(0, labelIndex)} - {label} - {errorMsg.substring(labelIndex + label.length)} - {openTicket ? ( - <> - . - - ) : null} - - ); - } else { - return ; - } - }; - - // If a user is restricted, they can not add a read-only Linode to a firewall. - const readOnlyLinodeIds = isRestrictedUser - ? getEntityIdsByPermission(grants, 'linode', 'read_only') - : []; - - const linodeSelectGuidance = - readOnlyLinodeIds.length > 0 ? READ_ONLY_LINODES_HIDDEN_MESSAGE : undefined; - - return ( - -
) => { - e.preventDefault(); - handleSubmit(); - }} - > - {errorMessage ? errorNotice(errorMessage) : null} - - setSelectedLinodeIds(linodes.map((linode) => linode.id)) - } - optionsFilter={(linode) => - ![...readOnlyLinodeIds, ...currentLinodeIds].includes(linode.id) - } - disabled={currentDevicesLoading} - loading={currentDevicesLoading} - multiple - noOptionsMessage="No Linodes available to add" - value={selectedLinodeIds} - /> - - -
- ); -}; diff --git a/packages/manager/src/features/Firewalls/FirewallDetail/Devices/AddLinodeDrawer.tsx b/packages/manager/src/features/Firewalls/FirewallDetail/Devices/AddLinodeDrawer.tsx new file mode 100644 index 00000000000..c1b3a826d5a --- /dev/null +++ b/packages/manager/src/features/Firewalls/FirewallDetail/Devices/AddLinodeDrawer.tsx @@ -0,0 +1,211 @@ +import { Linode } from '@linode/api-v4'; +import { useSnackbar } from 'notistack'; +import * as React from 'react'; +import { useParams } from 'react-router-dom'; + +import { ActionsPanel } from 'src/components/ActionsPanel/ActionsPanel'; +import { Autocomplete } from 'src/components/Autocomplete/Autocomplete'; +import { Drawer } from 'src/components/Drawer'; +import { Link } from 'src/components/Link'; +import { Notice } from 'src/components/Notice/Notice'; +import { SupportLink } from 'src/components/SupportLink'; +import { + useAddFirewallDeviceMutation, + useAllFirewallDevicesQuery, + useFirewallQuery, +} from 'src/queries/firewalls'; +import { useAllLinodesQuery } from 'src/queries/linodes/linodes'; +import { useGrants, useProfile } from 'src/queries/profile'; +import { getAPIErrorOrDefault } from 'src/utilities/errorUtils'; +import { getEntityIdsByPermission } from 'src/utilities/grants'; + +interface Props { + helperText: string; + onClose: () => void; + open: boolean; +} + +export const AddLinodeDrawer = (props: Props) => { + const { helperText, onClose, open } = props; + + const { id } = useParams<{ id: string }>(); + + const { enqueueSnackbar } = useSnackbar(); + + const { data: grants } = useGrants(); + const { data: profile } = useProfile(); + const isRestrictedUser = Boolean(profile?.restricted); + + const { data: firewall } = useFirewallQuery(Number(id)); + const { + data: currentDevices, + isLoading: currentDevicesLoading, + } = useAllFirewallDevicesQuery(Number(id)); + + const { isLoading, mutateAsync: addDevice } = useAddFirewallDeviceMutation( + Number(id) + ); + + const [selectedLinodes, setSelectedLinodes] = React.useState([]); + + const [localError, setLocalError] = React.useState( + undefined + ); + + const handleSubmit = async () => { + let firstError: string | undefined = undefined; + const failedLinodes: Linode[] = []; + + const results = await Promise.allSettled( + selectedLinodes.map((linode) => + addDevice({ id: linode.id, type: 'linode' }) + ) + ); + + results.forEach((result, index) => { + const label = selectedLinodes[index].label; + const id = selectedLinodes[index].id; + if (result.status === 'fulfilled') { + enqueueSnackbar(`${label} added successfully.`, { variant: 'success' }); + } else { + failedLinodes?.push(selectedLinodes[index]); + const errorReason = getAPIErrorOrDefault( + result.reason, + `Failed to add Linode ${label} (ID ${id}).` + )[0].reason; + + if (!firstError) { + firstError = errorReason; + } + + enqueueSnackbar(`Failed to add ${label}.`, { variant: 'error' }); + } + }); + + setLocalError(firstError); + setSelectedLinodes(failedLinodes); + + if (!firstError) { + onClose(); + } + }; + + const errorNotice = () => { + let errorMsg = localError || ''; + // match something like: Linode (ID ) + + const linode = /Linode (.+?) \(ID ([^\)]+)\)/i.exec(errorMsg); + const openTicket = errorMsg.match(/open a support ticket\./i); + + if (openTicket) { + errorMsg = errorMsg.replace(/open a support ticket\./i, ''); + } + + if (linode) { + const [, label, id] = linode; + + // Break the errorMsg into two parts: before and after the linode pattern + const startMsg = errorMsg.substring( + 0, + errorMsg.indexOf(`Linode ${label}`) + ); + const endMsg = errorMsg.substring( + errorMsg.indexOf(`(ID ${id})`) + `(ID ${id})`.length + ); + + return ( + + {startMsg} + {label} + {endMsg} + {openTicket ? ( + <> + . + + ) : null} + + ); + } else { + return ; + } + }; + + const currentLinodeIds = + currentDevices + ?.filter((device) => device.entity.type === 'linode') + .map((device) => device.entity.id) ?? []; + + // If a user is restricted, they can not add a read-only Linode to a firewall. + const readOnlyLinodeIds = isRestrictedUser + ? getEntityIdsByPermission(grants, 'linode', 'read_only') + : []; + + const optionsFilter = (linode: Linode) => { + return ![...currentLinodeIds, ...readOnlyLinodeIds].includes(linode.id); + }; + + const { + data, + error: linodeError, + isLoading: linodeIsLoading, + } = useAllLinodesQuery(); + + React.useEffect(() => { + if (linodeError) { + setLocalError('Could not load Linode Data'); + } + }, [linodeError]); + + const linodes = data?.filter(optionsFilter); + + return ( + { + setSelectedLinodes([]); + setLocalError(undefined); + onClose(); + }} + open={open} + title={`Add Linode to Firewall: ${firewall?.label}`} + > +
) => { + e.preventDefault(); + handleSubmit(); + }} + > + {localError ? errorNotice() : null} + setSelectedLinodes(linodes)} + options={linodes || []} + value={selectedLinodes} + /> + + +
+ ); +}; diff --git a/packages/manager/src/features/Firewalls/FirewallDetail/Devices/AddNodebalancerDrawer.tsx b/packages/manager/src/features/Firewalls/FirewallDetail/Devices/AddNodebalancerDrawer.tsx new file mode 100644 index 00000000000..e895d5a073a --- /dev/null +++ b/packages/manager/src/features/Firewalls/FirewallDetail/Devices/AddNodebalancerDrawer.tsx @@ -0,0 +1,223 @@ +import { NodeBalancer } from '@linode/api-v4'; +import { useSnackbar } from 'notistack'; +import * as React from 'react'; +import { useParams } from 'react-router-dom'; + +import { ActionsPanel } from 'src/components/ActionsPanel/ActionsPanel'; +import { Autocomplete } from 'src/components/Autocomplete/Autocomplete'; +import { Drawer } from 'src/components/Drawer'; +import { Link } from 'src/components/Link'; +import { Notice } from 'src/components/Notice/Notice'; +import { SupportLink } from 'src/components/SupportLink'; +import { + useAddFirewallDeviceMutation, + useAllFirewallDevicesQuery, + useFirewallQuery, +} from 'src/queries/firewalls'; +import { useAllNodeBalancersQuery } from 'src/queries/nodebalancers'; +import { useGrants, useProfile } from 'src/queries/profile'; +import { getAPIErrorOrDefault } from 'src/utilities/errorUtils'; +import { getEntityIdsByPermission } from 'src/utilities/grants'; + +interface Props { + helperText: string; + onClose: () => void; + open: boolean; +} + +export const AddNodebalancerDrawer = (props: Props) => { + const { helperText, onClose, open } = props; + + const { enqueueSnackbar } = useSnackbar(); + + const { id } = useParams<{ id: string }>(); + + const { data: grants } = useGrants(); + const { data: profile } = useProfile(); + const isRestrictedUser = Boolean(profile?.restricted); + + const { data: firewall } = useFirewallQuery(Number(id)); + const { + data: currentDevices, + isLoading: currentDevicesLoading, + } = useAllFirewallDevicesQuery(Number(id)); + + const { isLoading, mutateAsync: addDevice } = useAddFirewallDeviceMutation( + Number(id) + ); + + const [selectedNodebalancers, setSelectedNodebalancers] = React.useState< + NodeBalancer[] + >([]); + + const [localError, setLocalError] = React.useState( + undefined + ); + + const handleSubmit = async () => { + let firstError: string | undefined = undefined; + const failedNodebalancers: NodeBalancer[] = []; + + const results = await Promise.allSettled( + selectedNodebalancers.map((nodebalancer) => + addDevice({ id: nodebalancer.id, type: 'nodebalancer' }) + ) + ); + + results.forEach((result, index) => { + const label = selectedNodebalancers[index].label; + const id = selectedNodebalancers[index].id; + if (result.status === 'fulfilled') { + enqueueSnackbar(`${label} added successfully.`, { variant: 'success' }); + } else { + failedNodebalancers?.push(selectedNodebalancers[index]); + const errorReason = getAPIErrorOrDefault( + result.reason, + `Failed to add NodeBalancer ${label} (ID ${id}).` + )[0].reason; + + if (!firstError) { + firstError = errorReason; + } + + enqueueSnackbar(`Failed to add ${label}.`, { variant: 'error' }); + } + }); + + setLocalError(firstError); + setSelectedNodebalancers(failedNodebalancers); + + if (!firstError) { + onClose(); + } + }; + + const errorNotice = () => { + let errorMsg = localError || ''; + // match something like: NodeBalancer (ID ) + + const nodebalancer = /NodeBalancer (.+?) \(ID ([^\)]+)\)/i.exec(errorMsg); + const openTicket = errorMsg.match(/open a support ticket\./i); + + if (openTicket) { + errorMsg = errorMsg.replace(/open a support ticket\./i, ''); + } + + if (nodebalancer) { + const [, label, id] = nodebalancer; + + // Break the errorMsg into two parts: before and after the nodebalancer pattern + const startMsg = errorMsg.substring( + 0, + errorMsg.indexOf(`NodeBalancer ${label}`) + ); + const endMsg = errorMsg.substring( + errorMsg.indexOf(`(ID ${id})`) + `(ID ${id})`.length + ); + + return ( + + {startMsg} + {label} + {endMsg} + {openTicket ? ( + <> + . + + ) : null} + + ); + } else { + return ; + } + }; + + const currentNodebalancerIds = + currentDevices + ?.filter((device) => device.entity.type === 'nodebalancer') + .map((device) => device.entity.id) ?? []; + + // If a user is restricted, they can not add a read-only Nodebalancer to a firewall. + const readOnlyNodebalancerIds = isRestrictedUser + ? getEntityIdsByPermission(grants, 'nodebalancer', 'read_only') + : []; + + const optionsFilter = (nodebalancer: NodeBalancer) => { + return ![...currentNodebalancerIds, ...readOnlyNodebalancerIds].includes( + nodebalancer.id + ); + }; + + const { + data, + error: nodebalancerError, + isLoading: nodebalancerIsLoading, + } = useAllNodeBalancersQuery(); + + React.useEffect(() => { + if (nodebalancerError) { + setLocalError('Could not load NodeBalancer Data'); + } + }, [nodebalancerError]); + + const nodebalancers = data?.filter(optionsFilter); + + return ( + { + setSelectedNodebalancers([]); + setLocalError(undefined); + onClose(); + }} + open={open} + title={`Add Nodebalancer to Firewall: ${firewall?.label}`} + > + + Only the Firewall's inbound rules apply to NodeBalancers. Any existing + outbound rules won't be applied. + {/* @todo add documentation link */} + Learn more. + +
) => { + e.preventDefault(); + handleSubmit(); + }} + > + {localError ? errorNotice() : null} + + setSelectedNodebalancers(nodebalancers) + } + disabled={currentDevicesLoading || nodebalancerIsLoading} + helperText={helperText} + label="NodeBalancers" + loading={currentDevicesLoading || nodebalancerIsLoading} + multiple + noOptionsText="No NodeBalancers available to add" + options={nodebalancers || []} + value={selectedNodebalancers} + /> + + +
+ ); +}; diff --git a/packages/manager/src/features/Firewalls/FirewallDetail/Devices/FirewallDeviceLanding.tsx b/packages/manager/src/features/Firewalls/FirewallDetail/Devices/FirewallDeviceLanding.tsx index 06c0de707c9..0de3e087c9f 100644 --- a/packages/manager/src/features/Firewalls/FirewallDetail/Devices/FirewallDeviceLanding.tsx +++ b/packages/manager/src/features/Firewalls/FirewallDetail/Devices/FirewallDeviceLanding.tsx @@ -3,11 +3,13 @@ import { styled } from '@mui/material/styles'; import * as React from 'react'; import { Button } from 'src/components/Button/Button'; +import { Link } from 'src/components/Link'; import { Notice } from 'src/components/Notice/Notice'; import { Typography } from 'src/components/Typography'; import { useAllFirewallDevicesQuery } from 'src/queries/firewalls'; -import { AddDeviceDrawer } from './AddDeviceDrawer'; +import { AddLinodeDrawer } from './AddLinodeDrawer'; +import { AddNodebalancerDrawer } from './AddNodebalancerDrawer'; import { FirewallDevicesTable } from './FirewallDevicesTable'; import { RemoveDeviceDialog } from './RemoveDeviceDialog'; @@ -20,11 +22,14 @@ export interface FirewallDeviceLandingProps { type: FirewallDeviceEntityType; } -const formattedTypes = { +export const formattedTypes = { linode: 'Linode', nodebalancer: 'NodeBalancer', }; +const helperText = + 'Assign one or more devices to this firewall. You can add devices later if you want to customize your rules first.'; + export const FirewallDeviceLanding = React.memo( (props: FirewallDeviceLandingProps) => { const { disabled, firewallID, firewallLabel, type } = props; @@ -69,11 +74,15 @@ export const FirewallDeviceLanding = React.memo( /> ) : null} - + The following {formattedType}s have been assigned to this Firewall. A {formattedType} can only be assigned to a single Firewall. + + Learn about how Firewall rules apply to {formattedType}s. + + {/* @todo add documentation link */} @@ -97,7 +106,19 @@ export const FirewallDeviceLanding = React.memo( error={error ?? undefined} loading={isLoading} /> - + {type === 'linode' ? ( + + ) : ( + + )} { - const { deviceEntityID, deviceID, deviceLabel } = props; + const { deviceEntityID, deviceLabel } = props; return ( - + {deviceLabel} diff --git a/packages/manager/src/features/Firewalls/FirewallDetail/Devices/RemoveDeviceDialog.tsx b/packages/manager/src/features/Firewalls/FirewallDetail/Devices/RemoveDeviceDialog.tsx index 78db05a90a8..2e83dc1ba81 100644 --- a/packages/manager/src/features/Firewalls/FirewallDetail/Devices/RemoveDeviceDialog.tsx +++ b/packages/manager/src/features/Firewalls/FirewallDetail/Devices/RemoveDeviceDialog.tsx @@ -70,13 +70,11 @@ export const RemoveDeviceDialog = React.memo((props: Props) => { actions={ + `Only ${formattedTypes[deviceType]}s you have permission to modify are shown.`; export interface CreateFirewallDrawerProps { onClose: () => void; @@ -128,7 +135,7 @@ export const CreateFirewallDrawer = React.memo( const linodeSelectGuidance = readOnlyLinodeIds.length > 0 - ? READ_ONLY_LINODES_HIDDEN_MESSAGE + ? READ_ONLY_DEVICES_HIDDEN_MESSAGE('linode') : undefined; const firewallHelperText = `Assign one or more Linodes to this firewall. You can add Linodes later if you want to customize your rules first. ${ diff --git a/packages/manager/src/features/Linodes/LinodeSelect/LinodeSelect.tsx b/packages/manager/src/features/Linodes/LinodeSelect/LinodeSelect.tsx index 3dff27db52d..4f6906b63e7 100644 --- a/packages/manager/src/features/Linodes/LinodeSelect/LinodeSelect.tsx +++ b/packages/manager/src/features/Linodes/LinodeSelect/LinodeSelect.tsx @@ -12,7 +12,7 @@ import { import { Box } from 'src/components/Box'; import { TextField } from 'src/components/TextField'; import { useAllLinodesQuery } from 'src/queries/linodes/linodes'; -import { mapIdsToLinodes } from 'src/utilities/mapIdsToLinodes'; +import { mapIdsToDevices } from 'src/utilities/mapIdsToDevices'; interface LinodeSelectProps { /** Whether to display the clear icon. Defaults to `true`. */ @@ -174,7 +174,7 @@ export const LinodeSelect = ( ? multiple && Array.isArray(value) ? linodes?.filter(value) ?? null : linodes?.find(value) ?? null - : mapIdsToLinodes(value, linodes) + : mapIdsToDevices(value, linodes) } ChipProps={{ deleteIcon: }} PopperComponent={CustomPopper} diff --git a/packages/manager/src/utilities/mapIdsToLinodes.test.ts b/packages/manager/src/utilities/mapIdsToDevices.test.ts similarity index 57% rename from packages/manager/src/utilities/mapIdsToLinodes.test.ts rename to packages/manager/src/utilities/mapIdsToDevices.test.ts index 5ba41fa2b42..3167250b42a 100644 --- a/packages/manager/src/utilities/mapIdsToLinodes.test.ts +++ b/packages/manager/src/utilities/mapIdsToDevices.test.ts @@ -1,22 +1,22 @@ import { linodeFactory } from 'src/factories'; -import { mapIdsToLinodes } from './mapIdsToLinodes'; +import { mapIdsToDevices } from './mapIdsToDevices'; describe('mapIdsToLinodes', () => { const linodes = linodeFactory.buildList(5); it('works with a single id', () => { - expect(mapIdsToLinodes(1, linodes)).toBe(linodes[1]); + expect(mapIdsToDevices(1, linodes)).toBe(linodes[1]); }); it('works with a multiple ids', () => { - expect(mapIdsToLinodes([0, 1, 2], linodes)).toEqual([ + expect(mapIdsToDevices([0, 1, 2], linodes)).toEqual([ linodes[0], linodes[1], linodes[2], ]); }); it('omits missing ids', () => { - expect(mapIdsToLinodes(99, linodes)).toBe(null); - expect(mapIdsToLinodes([0, 99, 2], linodes)).toEqual([ + expect(mapIdsToDevices(99, linodes)).toBe(null); + expect(mapIdsToDevices([0, 99, 2], linodes)).toEqual([ linodes[0], linodes[2], ]); diff --git a/packages/manager/src/utilities/mapIdsToDevices.ts b/packages/manager/src/utilities/mapIdsToDevices.ts new file mode 100644 index 00000000000..326016995e5 --- /dev/null +++ b/packages/manager/src/utilities/mapIdsToDevices.ts @@ -0,0 +1,22 @@ +import { Linode, NodeBalancer } from '@linode/api-v4'; + +import { isNotNullOrUndefined } from './nullOrUndefined'; + +type Device = Linode | NodeBalancer; + +export const mapIdsToDevices = ( + ids: null | number | number[], + devices: T[] = [] +): T | T[] | null => { + const deviceMap = new Map( + // Even though the types extend Device. type insertion is still required here + devices.map((device) => [device.id, device]) + ); + if (Array.isArray(ids)) { + return ids.map((id) => deviceMap.get(id)).filter(isNotNullOrUndefined); + } else if (ids !== null) { + return deviceMap.get(ids) ?? null; + } else { + return null; + } +}; diff --git a/packages/manager/src/utilities/mapIdsToLinodes.ts b/packages/manager/src/utilities/mapIdsToLinodes.ts deleted file mode 100644 index 37bec20d2ff..00000000000 --- a/packages/manager/src/utilities/mapIdsToLinodes.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { Linode } from '@linode/api-v4'; - -import { isNotNullOrUndefined } from './nullOrUndefined'; - -export const mapIdsToLinodes = ( - ids: null | number | number[], - linodes: Linode[] = [] -): Linode | Linode[] | null => { - const linodeMap = new Map(linodes.map((linode) => [linode.id, linode])); - if (Array.isArray(ids)) { - return ids.map((id) => linodeMap.get(id)).filter(isNotNullOrUndefined); - } else if (ids !== null) { - return linodeMap.get(ids) ?? null; - } else { - return null; - } -};