Răsfoiți Sursa

Special handling for Coriolis Regions export

When exporting an endpoint which has Coriolis Regions mapped, export
their names instead of their IDs.

When importing an endpoint try to match the regions by name.

If there are 1 or more regions that couldn't be found by name, give the
user the options to select a new Coriolis Regions mapping.
Sergiu Miclea 5 ani în urmă
părinte
comite
f938a22188

+ 1 - 1
src/@types/NotificationItem.ts

@@ -12,7 +12,7 @@ You should have received a copy of the GNU Affero General Public License
 along with this program.  If not, see <http://www.gnu.org/licenses/>.
 */
 
-export type AlertInfoLevel = 'success' | 'error' | 'info'
+export type AlertInfoLevel = 'success' | 'error' | 'info' | 'warning'
 
 export type AlertInfoOptions = {
   action?: {

+ 51 - 7
src/components/organisms/ChooseProvider/ChooseProvider.tsx

@@ -32,6 +32,7 @@ import type { Endpoint, MultiValidationItem } from '../../../@types/Endpoint'
 
 import MultipleUploadedEndpoints from './MultipleUploadedEndpoints'
 import { ProviderTypes } from '../../../@types/Providers'
+import { Region } from '../../../@types/Region'
 
 const Wrapper = styled.div<any>`
   display: flex;
@@ -90,6 +91,7 @@ const LoadingText = styled.div<any>`
 `
 type Props = {
   providers: ProviderTypes[],
+  regions: Region[]
   onCancelClick: () => void,
   onProviderClick: (provider: ProviderTypes) => void,
   onUploadEndpoint: (endpoint: Endpoint) => void,
@@ -104,12 +106,14 @@ type Props = {
 type State = {
   highlightDropzone: boolean,
   multipleUploadedEndpoints: (Endpoint | string)[],
+  invalidRegionsEndpointIds: { id: string, regions: string[] }[],
 }
 @observer
 class ChooseProvider extends React.Component<Props, State> {
-  state = {
+  state: State = {
     highlightDropzone: false,
     multipleUploadedEndpoints: [],
+    invalidRegionsEndpointIds: [],
   }
 
   fileInput: HTMLElement | null | undefined
@@ -178,19 +182,34 @@ class ChooseProvider extends React.Component<Props, State> {
     this.dragDropListeners = []
   }
 
-  parseEndpoint(content: string): Endpoint {
+  parseEndpoint(content: string, skipAlert?: boolean): { endpoint: Endpoint, unidentRegions: string[] } {
     const endpoint: Endpoint = JSON.parse(content)
     if (!endpoint.name || !endpoint.type || !this.props.providers.find(p => p === endpoint.type)) {
       throw new Error()
     }
     delete (endpoint as any).id
-    return endpoint
+    const unidentRegions: string[] = []
+
+    if (endpoint.mapped_regions?.length) {
+      endpoint.mapped_regions = endpoint.mapped_regions.map(nameId => {
+        const region = this.props.regions.find(r => r.id === nameId || r.name === nameId)
+        if (region) {
+          return region.id
+        }
+        unidentRegions.push(nameId)
+        return null
+      }).filter((item: string | null): item is string => Boolean(item))
+      if (unidentRegions.length && !skipAlert) {
+        notificationStore.alert(`${unidentRegions.length} Coriolis Region${unidentRegions.length > 1 ? 's' : ''} couldn't be mapped`, 'warning')
+      }
+    }
+    return { endpoint, unidentRegions }
   }
 
   processOneFileContent(content: string) {
     this.props.onResetValidation()
     try {
-      const endpoint = this.parseEndpoint(content)
+      const { endpoint } = this.parseEndpoint(content)
       this.chooseEndpoint(endpoint)
     } catch (err) {
       notificationStore.alert('Invalid .endpoint file', 'error')
@@ -200,9 +219,11 @@ class ChooseProvider extends React.Component<Props, State> {
   processMultipleFilesContents(filesContents: FileContent[]) {
     this.props.onResetValidation()
     const uniqueNames: { [prop: string]: number } = {}
+    const invalidRegionsEndpointIds: {id: string, regions: string[]}[] = []
+
     const endpoints = filesContents.map(fileContent => {
       try {
-        const endpoint = this.parseEndpoint(fileContent.content)
+        const { endpoint, unidentRegions } = this.parseEndpoint(fileContent.content, true)
         const key = `${endpoint.type}${endpoint.name}`
         if (uniqueNames[key] === undefined) {
           uniqueNames[key] = 0
@@ -210,6 +231,9 @@ class ChooseProvider extends React.Component<Props, State> {
           uniqueNames[key] += 1
           endpoint.name = `${endpoint.name} (${uniqueNames[key]})`
         }
+        if (unidentRegions.length) {
+          invalidRegionsEndpointIds.push({ id: `${endpoint.type}${endpoint.name}`, regions: unidentRegions })
+        }
         return endpoint
       } catch (err) {
         return fileContent.name
@@ -239,7 +263,10 @@ class ChooseProvider extends React.Component<Props, State> {
       return a.type.localeCompare(b.type)
     })
 
-    this.setState({ multipleUploadedEndpoints: endpoints })
+    this.setState({
+      multipleUploadedEndpoints: endpoints,
+      invalidRegionsEndpointIds,
+    })
   }
 
   chooseEndpoint(endpoint: Endpoint) {
@@ -273,6 +300,20 @@ class ChooseProvider extends React.Component<Props, State> {
     })
   }
 
+  handleRegionsChange(endpoint: Endpoint, newRegions: string[]) {
+    this.setState(prevState => ({
+      multipleUploadedEndpoints: prevState.multipleUploadedEndpoints.map(stateEndpoint => {
+        if (typeof stateEndpoint !== 'string' && `${stateEndpoint.type}${stateEndpoint.name}` === `${endpoint.type}${endpoint.name}`) {
+          return {
+            ...stateEndpoint,
+            mapped_regions: newRegions,
+          }
+        }
+        return stateEndpoint
+      }),
+    }))
+  }
+
   renderMultipleUploadedEndpoints() {
     return (
       <MultipleUploadedEndpoints
@@ -281,8 +322,11 @@ class ChooseProvider extends React.Component<Props, State> {
         onRemove={(e, isAdded) => { this.handleRemoveUploadedEndpoint(e, isAdded) }}
         validating={this.props.multiValidating}
         multiValidation={this.props.multiValidation}
+        invalidRegionsEndpointIds={this.state.invalidRegionsEndpointIds}
+        regions={this.props.regions}
+        onRegionsChange={(endpoint, newRegions) => { this.handleRegionsChange(endpoint, newRegions) }}
         onValidateClick={() => {
-          this.props.onValidateMultipleEndpoints(this.state.multipleUploadedEndpoints.filter(e => typeof e !== 'string'))
+          this.props.onValidateMultipleEndpoints(this.state.multipleUploadedEndpoints.filter(e => typeof e !== 'string') as Endpoint[])
         }}
         onDone={this.props.onCancelClick}
       />

+ 53 - 16
src/components/organisms/ChooseProvider/MultipleUploadedEndpoints.tsx

@@ -27,18 +27,21 @@ import deleteImage from './images/delete.svg'
 import deleteHoverImage from './images/delete-hover.svg'
 import DomUtils from '../../../utils/DomUtils'
 import notificationStore from '../../../stores/NotificationStore'
+import DropdownLink from '../../molecules/DropdownLink/DropdownLink'
+import { Region } from '../../../@types/Region'
 
-const Wrapper = styled.div<any>`
+const Wrapper = styled.div`
+  width: 100%;
   min-height: 0;
 `
-const Buttons = styled.div<any>`
+const Buttons = styled.div`
   display: flex;
   justify-content: space-between;
   margin-top: 32px;
   flex-shrink: 0;
   padding: 0 32px;
 `
-const DeleteButton = styled.div<any>`
+const DeleteButton = styled.div`
   width: 16px;
   height: 16px;
   background: url('${deleteImage}') center no-repeat;
@@ -48,7 +51,7 @@ const DeleteButton = styled.div<any>`
     background: url('${deleteHoverImage}') center no-repeat;
   }
 `
-const Content = styled.div<any>`
+const Content = styled.div`
   overflow: auto;
   display: flex;
   flex-direction: column;
@@ -57,39 +60,46 @@ const Content = styled.div<any>`
   max-height: 384px;
   text-align: left;
 `
-const InvalidEndpoint = styled.div<any>`
+const InvalidEndpoint = styled.div`
   margin-bottom: 8px;
 `
-const EndpointItem = styled.div<any>`
+const EndpointItem = styled.div`
   display: flex;
   align-items: center;
   margin-bottom: 8px;
 `
-const EndpointLogoWrapper = styled.div<any>`
+const EndpointLogoWrapper = styled.div`
   min-width: 110px;
 `
-const EndpointData = styled.div<any>`
+const EndpointData = styled.div`
   display: flex;
   align-items: center;
   justify-content: space-between;
   flex-grow: 1;
   overflow: hidden;
 `
-const EndpointName = styled.div<any>`
+const EndpointName = styled.div`
   overflow: hidden;
   text-overflow: ellipsis;
 `
-const EndpointOptions = styled.div<any>`
+const EndpointOptions = styled.div`
   display: flex;
   align-items: center;
 `
-const EndpointStatus = styled.div<any>`
-  margin: 0 8px;
+const EndpointStatus = styled.div`
+  display: flex;
+  margin-right: 8px;
+  > div {
+    margin-left:  8px;
+  }
 `
 type Props = {
   endpoints: (Endpoint | string)[],
+  regions: Region[],
+  invalidRegionsEndpointIds: { id: string, regions: string[] }[]
   multiValidation: MultiValidationItem[],
   validating: boolean,
+  onRegionsChange: (endpoint: Endpoint, newRegions: string[]) => void
   onBackClick: () => void,
   onRemove: (endpoint: Endpoint, isAdded: boolean) => void,
   onValidateClick: () => void,
@@ -172,11 +182,38 @@ class MultipleUploadedEndpoints extends React.Component<Props, State> {
   }
 
   renderStatus(endpoint: Endpoint) {
-    const validationItem = this.props.multiValidation.find(v => v.endpoint.name === endpoint.name
-      && v.endpoint.type === endpoint.type)
-
+    const validationItem = this.props.multiValidation.find(v => v.endpoint.name === endpoint.name && v.endpoint.type === endpoint.type)
     if (!validationItem) {
-      return null
+      const invalidRegions = this.props.invalidRegionsEndpointIds.find(e => e.id === `${endpoint.type}${endpoint.name}`)?.regions
+      if (!invalidRegions?.length) {
+        return null
+      }
+      return (
+        <>
+          <DropdownLink
+            width="200px"
+            listWidth="120px"
+            getLabel={() => 'Coriolis Regions'}
+            multipleSelection
+            selectedItems={endpoint.mapped_regions}
+            items={this.props.regions.map(r => ({
+              label: r.name,
+              value: r.id,
+            }))}
+            onChange={item => {
+              if (endpoint.mapped_regions.find(r => r === item.value)) {
+                this.props.onRegionsChange(endpoint, endpoint.mapped_regions.filter(r => r !== item.value))
+              } else {
+                this.props.onRegionsChange(endpoint, [...endpoint.mapped_regions, item.value])
+              }
+            }}
+          />
+          <StatusIcon
+            status="INFO"
+            data-tip={`${invalidRegions.length} Coriolis Region${invalidRegions.length > 1 ? 's' : ''} couldn't be mapped for this endpoint. Use the Coriolis Regions dropdown to view and update the current mapping.`}
+          />
+        </>
+      )
     }
 
     if (validationItem.validating) {

+ 1 - 2
src/components/organisms/EndpointDetailsContent/EndpointDetailsContent.tsx

@@ -197,8 +197,7 @@ class EndpointDetailsContent extends React.Component<Props> {
   renderRegions() {
     return (
       <span>
-        {this.props.item?.mapped_regions
-          .map(regionId => this.props.regions.find(r => r.id === regionId)?.name).join(', ') || '-'}
+        {this.props.item?.mapped_regions.map(regionId => this.props.regions.find(r => r.id === regionId)?.name).join(', ') || '-'}
       </span>
     )
   }

+ 3 - 0
src/components/organisms/Notifications/style.ts

@@ -84,6 +84,9 @@ const NotificationsStyle = css`
   .notification-error .notification-title {
     background-image: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTZweCIgaGVpZ2h0PSIxNnB4IiB2aWV3Qm94PSIwIDAgMTYgMTYiIHZlcnNpb249IjEuMSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIiB4bWxuczp4bGluaz0iaHR0cDovL3d3dy53My5vcmcvMTk5OS94bGluayI+DQogICAgPGcgaWQ9IlN5bWJvbHMiIHN0cm9rZT0ibm9uZSIgc3Ryb2tlLXdpZHRoPSIxIiBmaWxsPSJub25lIiBmaWxsLXJ1bGU9ImV2ZW5vZGQiPg0KICAgICAgICA8ZyBpZD0iSWNvbi1FcnJvci1XaGl0ZSI+DQogICAgICAgICAgICA8Y2lyY2xlIGlkPSJPdmFsLTIiIGZpbGw9IiNGRkZGRkYiIGN4PSI4IiBjeT0iOCIgcj0iOCI+PC9jaXJjbGU+DQogICAgICAgICAgICA8cGF0aCBkPSJNMTEuNDI4NTcxNCw0LjU3MTQyODU3IEw0LjU3MTQyODU3LDExLjQyODU3MTQiIGlkPSJMaW5lIiBzdHJva2U9IiNGQTE2NjEiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCI+PC9wYXRoPg0KICAgICAgICAgICAgPHBhdGggZD0iTTExLjQyODU3MTQsMTEuNDI4NTcxNCBMNC41NzE0Mjg1Nyw0LjU3MTQyODU3IiBpZD0iTGluZS1Db3B5IiBzdHJva2U9IiNGQTE2NjEiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCI+PC9wYXRoPg0KICAgICAgICA8L2c+DQogICAgPC9nPg0KPC9zdmc+');
   }
+  .notification-warning .notification-title {
+    background-image: url("data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTZweCIgaGVpZ2h0PSIxNnB4IiB2aWV3Qm94PSIwIDAgMTYgMTYiIHZlcnNpb249IjEuMSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIiB4bWxuczp4bGluaz0iaHR0cDovL3d3dy53My5vcmcvMTk5OS94bGluayI+CiAgICA8ZyBpZD0iQ29yaW9saXMiIHN0cm9rZT0ibm9uZSIgc3Ryb2tlLXdpZHRoPSIxIiBmaWxsPSJub25lIiBmaWxsLXJ1bGU9ImV2ZW5vZGQiPgogICAgICAgIDxnIGlkPSIyMDItUmVwbGljYS1FeGVjdXRpb25zIiB0cmFuc2Zvcm09InRyYW5zbGF0ZSgtMzkyLjAwMDAwMCwgLTg3MS4wMDAwMDApIj4KICAgICAgICAgICAgPGcgaWQ9Ikdyb3VwLTIiIHRyYW5zZm9ybT0idHJhbnNsYXRlKDM2MC4wMDAwMDAsIDI0MC4wMDAwMDApIj4KICAgICAgICAgICAgICAgIDxnIGlkPSJHcm91cC0zIiB0cmFuc2Zvcm09InRyYW5zbGF0ZSgwLjAwMDAwMCwgNzkuMDAwMDAwKSI+CiAgICAgICAgICAgICAgICAgICAgPGcgaWQ9Ikljb24vV2FybmluZyIgdHJhbnNmb3JtPSJ0cmFuc2xhdGUoMzIuMDAwMDAwLCA1NTIuMDAwMDAwKSI+CiAgICAgICAgICAgICAgICAgICAgICAgIDxjaXJjbGUgZmlsbD0iI0ZEQzAyRiIgZmlsbC1ydWxlPSJldmVub2RkIiBjeD0iOCIgY3k9IjgiIHI9IjgiPjwvY2lyY2xlPgogICAgICAgICAgICAgICAgICAgICAgICA8cGF0aCBkPSJNOCw4IEw4LDQiIGlkPSJMaW5lLUNvcHkiIHN0cm9rZT0iI0ZGRkZGRiIgc3Ryb2tlLXdpZHRoPSIyIiBzdHJva2UtbGluZWNhcD0icm91bmQiPjwvcGF0aD4KICAgICAgICAgICAgICAgICAgICAgICAgPHBhdGggZD0iTTgsMTIuNSBDOC42OTAzNTU5NCwxMi41IDkuMjUsMTEuOTQwMzU1OSA5LjI1LDExLjI1IEM5LjI1LDEwLjU1OTY0NDEgOC42OTAzNTU5NCwxMCA4LDEwIEM3LjMwOTY0NDA2LDEwIDYuNzUsMTAuNTU5NjQ0MSA2Ljc1LDExLjI1IEM2Ljc1LDExLjk0MDM1NTkgNy4zMDk2NDQwNiwxMi41IDgsMTIuNSBaIiBpZD0iT3ZhbC0zIiBmaWxsPSIjRkZGRkZGIiBmaWxsLXJ1bGU9ImV2ZW5vZGQiPjwvcGF0aD4KICAgICAgICAgICAgICAgICAgICA8L2c+CiAgICAgICAgICAgICAgICA8L2c+CiAgICAgICAgICAgIDwvZz4KICAgICAgICA8L2c+CiAgICA8L2c+Cjwvc3ZnPg==");
+  }
 `
 
 export default NotificationsStyle

+ 4 - 1
src/components/organisms/PageHeader/PageHeader.tsx

@@ -42,6 +42,7 @@ import { ProviderTypes } from '../../../@types/Providers'
 import MinionEndpointModal from '../MinionEndpointModal/MinionEndpointModal'
 import MinionPoolModal from '../MinionPoolModal'
 import ObjectUtils from '../../../utils/ObjectUtils'
+import regionStore from '../../../stores/RegionStore'
 
 const Wrapper = styled.div<any>`
   display: flex;
@@ -156,6 +157,7 @@ class PageHeader extends React.Component<Props, State> {
     switch (item) {
       case 'endpoint':
         providerStore.loadProviders()
+        regionStore.getRegions()
         if (this.props.onModalOpen) {
           this.props.onModalOpen()
         }
@@ -369,7 +371,8 @@ class PageHeader extends React.Component<Props, State> {
           <ChooseProvider
             onCancelClick={() => { this.handleCloseChooseProviderModal() }}
             providers={providerStore.providerNames}
-            loading={providerStore.providersLoading}
+            loading={providerStore.providersLoading || regionStore.loading}
+            regions={regionStore.regions}
             onProviderClick={providerName => { this.handleProviderClick(providerName) }}
             onUploadEndpoint={endpoint => { this.handleUploadEndpoint(endpoint) }}
             multiValidating={this.state.multiValidating}

+ 4 - 1
src/components/pages/EndpointsPage/EndpointsPage.tsx

@@ -41,6 +41,7 @@ import LabelDictionary from '../../../utils/LabelDictionary'
 import configLoader from '../../../utils/Config'
 import Palette from '../../styleUtils/Palette'
 import { ProviderTypes } from '../../../@types/Providers'
+import regionStore from '../../../stores/RegionStore'
 
 const Wrapper = styled.div<any>``
 
@@ -158,6 +159,7 @@ class EndpointsPage extends React.Component<{ history: any }, State> {
 
   handleEmptyListButtonClick() {
     providerStore.loadProviders()
+    regionStore.getRegions()
     this.setState({ showChooseProviderModal: true })
   }
 
@@ -336,8 +338,9 @@ class EndpointsPage extends React.Component<{ history: any }, State> {
         >
           <ChooseProvider
             onCancelClick={() => { this.handleCloseChooseProviderModal() }}
+            regions={regionStore.regions}
             providers={providerStore.providerNames}
-            loading={providerStore.providersLoading}
+            loading={providerStore.providersLoading || regionStore.loading}
             onUploadEndpoint={endpoint => { this.handleUploadEndpoint(endpoint) }}
             onProviderClick={providerName => { this.handleProviderClick(providerName) }}
             multiValidating={this.state.multiValidating}

+ 5 - 2
src/stores/EndpointStore.ts

@@ -24,6 +24,7 @@ import notificationStore from './NotificationStore'
 import EndpointSource from '../sources/EndpointSource'
 
 import DomUtils from '../utils/DomUtils'
+import regionStore from './RegionStore'
 
 const updateEndpoint = (endpoint: Endpoint, endpoints: Endpoint[]) => endpoints.map(e => {
   if (e.id === endpoint.id) {
@@ -163,8 +164,9 @@ class EndpointStore {
 
   @action async exportToJson(endpoint: Endpoint): Promise<void> {
     const connectionInfo = await EndpointSource.getConnectionInfo(endpoint)
-    const newEndpoint = endpoint
+    const newEndpoint = JSON.parse(JSON.stringify(endpoint))
     newEndpoint.connection_info = connectionInfo
+    await regionStore.setEndpointRegionExport(newEndpoint)
     DomUtils.download(JSON.stringify(newEndpoint), `${newEndpoint.name}.endpoint`)
   }
 
@@ -173,8 +175,9 @@ class EndpointStore {
 
     await Promise.all(endpoints.map(async endpoint => {
       const connectionInfo = await EndpointSource.getConnectionInfo(endpoint)
-      const newEndpoint = endpoint
+      const newEndpoint = JSON.parse(JSON.stringify(endpoint))
       newEndpoint.connection_info = connectionInfo
+      await regionStore.setEndpointRegionExport(newEndpoint)
       zip.file(`${newEndpoint.name}.endpoint`, JSON.stringify(newEndpoint))
     }))
 

+ 2 - 2
src/stores/NotificationStore.ts

@@ -14,7 +14,7 @@ along with this program.  If not, see <http://www.gnu.org/licenses/>.
 
 import { observable, action } from 'mobx'
 
-import type { AlertInfo, NotificationItemData } from '../@types/NotificationItem'
+import type { AlertInfo, AlertInfoLevel, NotificationItemData } from '../@types/NotificationItem'
 import NotificationSource from '../sources/NotificationSource'
 
 class NotificationStore {
@@ -26,7 +26,7 @@ class NotificationStore {
 
   visibleErrors: string[] = []
 
-  @action alert(message: string, level?: AlertInfo['level'], options?: AlertInfo['options']) {
+  @action alert(message: string, level?: AlertInfoLevel, options?: AlertInfo['options']) {
     if (this.visibleErrors.find(e => e === message)) {
       return
     }

+ 10 - 0
src/stores/RegionStore.ts

@@ -13,6 +13,7 @@ along with this program.  If not, see <http://www.gnu.org/licenses/>.
 */
 
 import { observable, action, runInAction } from 'mobx'
+import { Endpoint } from '../@types/Endpoint'
 import { Region } from '../@types/Region'
 import regionSource from '../sources/RegionSource'
 
@@ -28,12 +29,21 @@ class RegionStore {
       runInAction(() => {
         this.regions = regions
       })
+      return regions
     } finally {
       runInAction(() => {
         this.loading = false
       })
     }
   }
+
+  async setEndpointRegionExport(endpoint: Endpoint) {
+    if (!endpoint.mapped_regions?.length) {
+      return
+    }
+    const regions = this.regions.length ? this.regions : await this.getRegions()
+    endpoint.mapped_regions = endpoint.mapped_regions.map(id => regions.find(r => r.id === id)?.name || id)
+  }
 }
 
 export default new RegionStore()