diff --git a/jest.config.ts b/jest.config.ts index 91e27f93a..f9964c07a 100644 --- a/jest.config.ts +++ b/jest.config.ts @@ -13,6 +13,7 @@ const config: Config.InitialOptions = { moduleNameMapper: { '\\.(css|less|scss|svg)$': '/src/__mocks__/dummy.ts', '@console/*': '/src/__mocks__/dummy.ts', + '@openshift-console/*': '/src/__mocks__/dummy.ts', 'react-i18next': '/src/__mocks__/react-i18next.ts', ...pathsToModuleNameMapper(compilerOptions.paths, { prefix: '/', diff --git a/locales/en/plugin__forklift-console-plugin.json b/locales/en/plugin__forklift-console-plugin.json index 9230e6cbd..664f49987 100644 --- a/locales/en/plugin__forklift-console-plugin.json +++ b/locales/en/plugin__forklift-console-plugin.json @@ -1,22 +1,52 @@ { + "{{type}} provider {{name}} will no longer be selectable as a migration source.": "", + "{{type}} provider {{name}} will no longer be selectable as a migration target.": "", + "Actions": "", + "Add Provider": "", "Cancel": "", + "Cannot remove provider": "", "Clear all filters": "", + "Clusters": "", + "Delete": "", + "Delete Provider": "", + "Edit Provider": "", + "Endpoint": "", + "False": "", + "Filter by endpoint": "", "Filter by name": "", "Filter by namespace": "", + "Hosts": "", + "KubeVirt": "", "Loading": "", "Manage Columns": "", "Mappings for Import": "", "Name": "", "Namespace": "", + "Networks": "", + "No information": "", "No results found": "", "No results match the filter criteria. Clear all filters and try again.": "", + "oVirt": "", + "Permanently delete provider?": "", "Plans for Import": "", + "Providers": "", "Providers for Import": "", + "Ready": "", "Reorder": "", "Restore default colums": "", "Save": "", + "Select a default migration network for the provider. This network will be used for migrating data to all namespaces to which it is attached.": "", "Select Filter": "", + "Select migration network": "", "Selected columns will be displayed in the table.": "", + "Storage": "", "Table column management": "", - "Unable to retrieve data": "" + "The host provider cannot be edited": "", + "This provider cannot be edited because it has running migrations": "", + "True": "", + "Type": "", + "Unable to retrieve data": "", + "Unknown": "", + "VMs": "", + "VMware": "" } diff --git a/pkg/web/src/app/Providers/HostsPage.tsx b/pkg/web/src/app/Providers/HostsPage.tsx index 233560bda..7e560308a 100644 --- a/pkg/web/src/app/Providers/HostsPage.tsx +++ b/pkg/web/src/app/Providers/HostsPage.tsx @@ -11,7 +11,7 @@ import { } from '@patternfly/react-core'; import spacing from '@patternfly/react-styles/css/utilities/Spacing/spacing'; import { Breadcrumb, BreadcrumbItem } from '@patternfly/react-core'; -import { Link, useRouteMatch } from 'react-router-dom'; +import { useRouteMatch } from 'react-router-dom'; import { VMwareProviderHostsTable } from './components/VMwareProviderHostsTable'; import PlusCircleIcon from '@patternfly/react-icons/dist/esm/icons/plus-circle-icon'; import { useHostsQuery, useInventoryProvidersQuery } from '@app/queries'; @@ -45,9 +45,7 @@ export const HostsPage: React.FunctionComponent = () => { Providers - - {PROVIDER_TYPE_NAMES.vsphere} - + {PROVIDER_TYPE_NAMES.vsphere} {match?.params.providerName} Hosts diff --git a/pkg/web/src/app/Providers/components/ProvidersTable/ProviderActionsDropdown.tsx b/pkg/web/src/app/Providers/components/ProvidersTable/ProviderActionsDropdown.tsx index 5fc79c1d9..a9bb2090f 100644 --- a/pkg/web/src/app/Providers/components/ProvidersTable/ProviderActionsDropdown.tsx +++ b/pkg/web/src/app/Providers/components/ProvidersTable/ProviderActionsDropdown.tsx @@ -1,7 +1,12 @@ import * as React from 'react'; import { Dropdown, KebabToggle, DropdownItem, DropdownPosition } from '@patternfly/react-core'; import { useDeleteProviderMutation } from '@app/queries'; -import { ICorrelatedProvider, InventoryProvider } from '@app/queries/types'; +import { + ICorrelatedProvider, + INameNamespaceRef, + InventoryProvider, + IPlan, +} from '@app/queries/types'; import { PATH_PREFIX, ProviderType, PROVIDER_TYPE_NAMES } from '@app/common/constants'; import { ConfirmModal } from '@app/common/components/ConfirmModal'; import { EditProviderContext } from '@app/Providers/ProvidersPage'; @@ -11,6 +16,22 @@ import { isSameResource } from '@app/queries/helpers'; import { useHistory } from 'react-router-dom'; import { useClusterProvidersQuery } from '@app/queries'; +export const hasRunningMigration = ({ + plans = [], + providerMetadata, +}: { + plans: IPlan[]; + providerMetadata: INameNamespaceRef; +}): boolean => + !!plans + .filter((plan) => hasCondition(plan.status?.conditions || [], 'Executing')) + .find((runningPlan) => { + const { source, destination } = runningPlan.spec.provider; + return ( + isSameResource(providerMetadata, source) || isSameResource(providerMetadata, destination) + ); + }); + interface IProviderActionsDropdownProps { provider: ICorrelatedProvider; providerType: ProviderType; @@ -37,15 +58,15 @@ export const ProviderActionsDropdown: React.FunctionComponent hasCondition(plan.status?.conditions || [], 'Executing')) - .find((runningPlan) => { - const { source, destination } = runningPlan.spec.provider; - return ( - isSameResource(provider.metadata, source) || isSameResource(provider.metadata, destination) - ); - }); - const isEditDeleteDisabled = !provider.spec.url || hasRunningMigration; + const isEditDeleteDisabled = + !provider.spec.url || hasRunningMigration({ plans, providerMetadata: provider.metadata }); + + const disabledEditTooltip = !provider.spec.url + ? 'The host provider cannot be edited' + : 'This provider cannot be edited because it has running migrations'; + const disabledDeleteTooltip = !provider.spec.url + ? 'The host provider cannot be removed' + : 'This provider cannot be removed because it has running migrations'; return ( <> @@ -58,13 +79,7 @@ export const ProviderActionsDropdown: React.FunctionComponent + function GenericActions({ actions }: ActionService) { + const { t } = useTranslation(); + const [isActionMenuOpen, setIsActionMenuOpen] = useState(false); + const isPlain = variant === 'kebab'; + const toggle = + variant === 'kebab' ? ( + + ) : ( + {t('Actions')} + ); + return ( + <> + setIsActionMenuOpen(!isActionMenuOpen)} + toggle={toggle} + isOpen={isActionMenuOpen} + isPlain={isPlain} + dropdownItems={actions.map(({ id, label, cta, disabled, disabledTooltip }) => ( + undefined} + isAriaDisabled={disabled} + tooltip={disabledTooltip} + > + {label} + + ))} + /> + + ); + }; diff --git a/src/components/Filter/helpers.ts b/src/components/Filter/helpers.ts index 5a15b3b21..44f7ac1d5 100644 --- a/src/components/Filter/helpers.ts +++ b/src/components/Filter/helpers.ts @@ -7,3 +7,6 @@ export const toFieldFilter = ({ toLabel: toFieldLabel, filter: filterDef, }: Field): FieldFilter => ({ fieldId, toFieldLabel, filterDef }); + +export const fromI18nEnum = (i18nEnum: { [k: string]: (t: (k: string) => string) => string }) => + Object.entries(i18nEnum).map(([type, toLabel]) => ({ id: type, toLabel })); diff --git a/src/components/types.ts b/src/components/types.ts index bc14e90cf..406100e03 100644 --- a/src/components/types.ts +++ b/src/components/types.ts @@ -16,3 +16,6 @@ export interface Field { // eslint-disable-next-line @typescript-eslint/no-explicit-any comparator?: (a: any, b: any, locale: string) => number; } + +export const K8sConditionStatusValues = ['True', 'False', 'Unknown'] as const; +export type K8sConditionStatus = typeof K8sConditionStatusValues[number]; diff --git a/src/modules/Providers/ProviderRow.tsx b/src/modules/Providers/ProviderRow.tsx new file mode 100644 index 000000000..5b6194c44 --- /dev/null +++ b/src/modules/Providers/ProviderRow.tsx @@ -0,0 +1,115 @@ +import React, { JSXElementConstructor } from 'react'; +import { Link } from 'react-router-dom'; +import { RowProps } from 'src/components/TableView'; +import * as C from 'src/utils/constants'; +import { CONDITIONS, PROVIDERS } from 'src/utils/enums'; +import { useTranslation } from 'src/utils/i18n'; + +import { PATH_PREFIX } from '@app/common/constants'; +import { StatusIcon } from '@migtools/lib-ui'; +import { ResourceLink } from '@openshift-console/dynamic-plugin-sdk'; +import { Button, Popover } from '@patternfly/react-core'; +import { DatabaseIcon, NetworkIcon, OutlinedHddIcon } from '@patternfly/react-icons'; +import { Td, Tr } from '@patternfly/react-table'; + +import { MergedProvider } from './data'; +import { ProviderActions } from './providerActions'; + +interface CellProps { + value: string; + entity: MergedProvider; + t?: (k: string) => string; +} + +const StatusCell = ({ value, entity: { conditions }, t }: CellProps) => { + const existingConditions = Object.values(conditions).filter(Boolean); + const toState = (value) => { + switch (value) { + case 'True': + return 'Ok'; + case 'False': + return 'Error'; + default: + return 'Unknown'; + } + }; + + const label = CONDITIONS[value]?.(t) ?? t('Unknown'); + return ( + + {existingConditions.length > 0 + ? existingConditions.map(({ message, status }) => { + return ; + }) + : t('No information')} + + } + > + + + ); +}; + +const TextCell = ({ value }: { value: string }) => <>{value ?? ''}; + +const TextWithIcon = ({ value, Icon }: { value: string; Icon: JSXElementConstructor }) => ( + <> + {value && ( + <> + + + )} + +); + +const ProviderLink = ({ value, entity }: CellProps) => ( + +); + +const HostCell = ({ value, entity: { ready, name, type } }: CellProps) => ( + <> + {ready === 'True' && value && type === 'vsphere' ? ( + + + + ) : ( + + )} + +); + +const cellCreator: Record JSX.Element> = { + [C.NAME]: ProviderLink, + [C.READY]: StatusCell, + [C.URL]: TextCell, + [C.TYPE]: ({ value, t }: CellProps) => , + [C.NAMESPACE]: ({ value }: CellProps) => , + [C.ACTIONS]: ProviderActions, + [C.NETWORK_COUNT]: ({ value }: CellProps) => , + [C.STORAGE_COUNT]: ({ value }: CellProps) => , + [C.HOST_COUNT]: HostCell, +}; + +const ProviderRow = ({ columns, entity }: RowProps) => { + const { t } = useTranslation(); + return ( + + {columns.map(({ id, toLabel }) => ( + + {cellCreator?.[id]?.({ + value: entity[id], + entity, + t, + }) ?? } + + ))} + + ); +}; + +export default ProviderRow; diff --git a/src/modules/Providers/ProvidersPage.tsx b/src/modules/Providers/ProvidersPage.tsx new file mode 100644 index 000000000..40c39d5b9 --- /dev/null +++ b/src/modules/Providers/ProvidersPage.tsx @@ -0,0 +1,167 @@ +import React from 'react'; +import { fromI18nEnum } from '_/components/Filter/helpers'; +import withQueryClient from '_/components/QueryClientHoc'; +import { StandardPage } from 'src/components/StandardPage'; +import { Field } from 'src/components/types'; +import * as C from 'src/utils/constants'; +import { CONDITIONS, PROVIDERS } from 'src/utils/enums'; +import { useTranslation } from 'src/utils/i18n'; +import { ResourceConsolePageProps } from 'src/utils/types'; + +import { AddEditProviderModal } from '@app/Providers/components/AddEditProviderModal'; +import { EditProviderContext } from '@app/Providers/ProvidersPage'; +import { useModal } from '@openshift-console/dynamic-plugin-sdk'; +import { Button } from '@patternfly/react-core'; + +import { MergedProvider, useProvidersWithInventory } from './data'; +import ProviderRow from './ProviderRow'; + +const fieldsMetadata: Field[] = [ + { + id: C.NAME, + toLabel: (t) => t('Name'), + isVisible: true, + isIdentity: true, // Name is sufficient ID when Namespace is pre-selected + filter: { + type: 'freetext', + toPlaceholderLabel: (t) => t('Filter by name'), + }, + sortable: true, + }, + { + id: C.NAMESPACE, + toLabel: (t) => t('Namespace'), + isVisible: true, + isIdentity: true, + filter: { + type: 'freetext', + toPlaceholderLabel: (t) => t('Filter by namespace'), + }, + sortable: true, + }, + { + id: C.READY, + toLabel: (t) => t('Ready'), + isVisible: true, + filter: { + type: 'enum', + primary: true, + toPlaceholderLabel: (t) => t('Ready'), + values: fromI18nEnum(CONDITIONS), + }, + sortable: true, + }, + { + id: C.URL, + toLabel: (t) => t('Endpoint'), + isVisible: true, + filter: { + type: 'freetext', + toPlaceholderLabel: (t) => t('Filter by endpoint'), + }, + sortable: true, + }, + { + id: C.TYPE, + toLabel: (t) => t('Type'), + isVisible: true, + filter: { + type: 'enum', + primary: true, + toPlaceholderLabel: (t) => t('Type'), + values: fromI18nEnum(PROVIDERS), + }, + sortable: true, + }, + { + id: C.VM_COUNT, + toLabel: (t) => t('VMs'), + isVisible: true, + sortable: true, + }, + { + id: C.NETWORK_COUNT, + toLabel: (t) => t('Networks'), + isVisible: true, + sortable: true, + }, + { + id: C.CLUSTER_COUNT, + toLabel: (t) => t('Clusters'), + isVisible: false, + sortable: true, + }, + { + id: C.HOST_COUNT, + toLabel: (t) => t('Hosts'), + isVisible: true, + sortable: true, + }, + { + id: C.STORAGE_COUNT, + toLabel: (t) => t('Storage'), + isVisible: false, + sortable: true, + }, + { + id: C.ACTIONS, + toLabel: () => '', + isVisible: true, + sortable: false, + }, +]; + +export const ProvidersPage = ({ namespace, kind }: ResourceConsolePageProps) => { + const { t } = useTranslation(); + + const dataSource = useProvidersWithInventory({ + kind, + namespace, + }); + + // data hook triggers frequent re-renders although data remains the same: + // both the content content and object reference + return ; +}; + +const Page = ({ + dataSource, + namespace, + title, +}: { + dataSource: [MergedProvider[], boolean, boolean]; + namespace: string; + title: string; +}) => ( + + addButton={} + dataSource={dataSource} + RowMapper={ProviderRow} + fieldsMetadata={fieldsMetadata} + namespace={namespace} + title={title} + /> +); + +const PageMemo = React.memo(Page); + +const AddProviderButton = () => { + const { t } = useTranslation(); + const launchModal = useModal(); + + return ( + + ); +}; + +const AddProviderModal = ({ closeModal }: { closeModal: () => void }) => { + return ( + undefined, plans: [] }}> + + + ); +}; + +export default ProvidersPage; diff --git a/src/modules/Providers/ProvidersWrapper.tsx b/src/modules/Providers/ProvidersWrapper.tsx index 28cbed6f9..4db6b9f46 100644 --- a/src/modules/Providers/ProvidersWrapper.tsx +++ b/src/modules/Providers/ProvidersWrapper.tsx @@ -1,6 +1,6 @@ import withQueryClient from 'src/components/QueryClientHoc'; -import { ProvidersPage } from '@app/Providers/ProvidersPage'; +import ProvidersPage from './ProvidersPage'; const Page = withQueryClient(ProvidersPage); diff --git a/src/modules/Providers/UseMergedProviders.ts b/src/modules/Providers/UseMergedProviders.ts new file mode 100644 index 000000000..36ae14381 --- /dev/null +++ b/src/modules/Providers/UseMergedProviders.ts @@ -0,0 +1,3 @@ +import { useMergedProviderActions } from './providerActions'; + +export default useMergedProviderActions; diff --git a/src/modules/Providers/__tests__/mergeData.test.ts b/src/modules/Providers/__tests__/mergeData.test.ts new file mode 100644 index 000000000..728acbf87 --- /dev/null +++ b/src/modules/Providers/__tests__/mergeData.test.ts @@ -0,0 +1,313 @@ +import { ProviderResource } from '_/utils/types'; + +import { + MOCK_CLUSTER_PROVIDERS, + MOCK_INVENTORY_PROVIDERS, +} from '@app/queries/mocks/providers.mock'; +import { ObjectMetadata } from '@openshift-console/dynamic-plugin-sdk'; + +import { groupPairs, mergeData, toSupportedConditions } from '../data'; + +describe('extracting conditions', () => { + test('empty input', () => { + expect(toSupportedConditions([])).toEqual({}); + }); + it('extracts supported condition', () => { + expect( + toSupportedConditions([ + { + type: 'URLNotValid', + status: 'True', + category: 'Critical', + message: 'Not responding', + lastTransitionTime: '2020-08-21T18:36:41.468Z', + reason: '', + }, + ]), + ).toEqual({ URLNotValid: { status: 'True', message: 'Not responding' } }); + }); + it('maps unknown status to Unknown', () => { + expect( + toSupportedConditions([ + { + type: 'URLNotValid', + status: undefined, + message: 'Not responding', + }, + ]), + ).toEqual({ URLNotValid: { status: 'Unknown', message: 'Not responding' } }); + }); + it('extracts also unsupported conditions (typing is used to cherry-pick the supported via desctructuring', () => { + expect( + toSupportedConditions([ + { + type: 'FooBar', + status: 'False', + message: 'BarFoo', + }, + ]), + ).toEqual({ FooBar: { status: 'False', message: 'BarFoo' } }); + }); +}); + +describe('grouping pairs', () => { + test('empty input', () => { + expect(groupPairs([], { openshift: [], ovirt: [], vsphere: [] })).toHaveLength(0); + }); + it('skipps items without pairs', () => { + expect( + groupPairs([MOCK_INVENTORY_PROVIDERS.openshift[0].object as ProviderResource], { + openshift: [MOCK_INVENTORY_PROVIDERS.openshift[1]], + ovirt: [], + vsphere: [], + }), + ).toHaveLength(0); + }); + it('skipps items without UID', () => { + const provider = MOCK_INVENTORY_PROVIDERS.openshift[0]; + const k8sNoUid: ProviderResource = { + ...provider.object, + metadata: { ...(provider.object.metadata as ObjectMetadata), uid: undefined }, + }; + expect( + groupPairs([k8sNoUid], { + openshift: [{ ...provider, uid: undefined }], + ovirt: [], + vsphere: [], + }), + ).toHaveLength(0); + }); + it('groups into pairs', () => { + const provider = MOCK_INVENTORY_PROVIDERS.openshift[0]; + expect( + groupPairs([provider.object as ProviderResource], { + openshift: [provider], + ovirt: [], + vsphere: [], + }), + ).toEqual([[provider.object, provider]]); + }); +}); + +describe('merging k8s Provider with inventory Provider', () => { + test('empty input', () => { + expect(mergeData([])).toHaveLength(0); + }); + + test('standard mock data', () => { + const merged = mergeData( + groupPairs(MOCK_CLUSTER_PROVIDERS as ProviderResource[], MOCK_INVENTORY_PROVIDERS), + ); + expect(merged).toEqual(MERGED_MOCK_DATA); + }); +}); + +const MERGED_MOCK_DATA = [ + { + apiVersion: '12345', + clusterCount: 2, + conditions: { + connected: { message: 'Connection test, succeeded.', status: 'True' }, + inventory: { message: 'The inventory has been loaded.', status: 'True' }, + ready: { message: 'The provider is ready.', status: 'True' }, + validated: { message: 'Validation has been completed.', status: 'True' }, + }, + defaultTransferNetwork: undefined, + hostCount: 2, + kind: 'forklift.konveyor.io~v1beta1~Provider', + name: 'vcenter-1', + namespace: 'openshift-migration', + networkCount: 8, + ready: 'True', + secretName: 'boston', + storageCount: 3, + type: 'vsphere', + uid: 'mock-uid-vcenter-1', + url: 'https://vcenter.v2v.bos.redhat.com/sdk', + vmCount: 41, + }, + { + apiVersion: '12345', + clusterCount: 2, + conditions: { + connected: undefined, + inventory: undefined, + ready: undefined, + validated: undefined, + }, + defaultTransferNetwork: undefined, + hostCount: 2, + kind: 'forklift.konveyor.io~v1beta1~Provider', + name: 'vcenter-2', + namespace: 'openshift-migration', + networkCount: 8, + ready: 'Unknown', + secretName: 'boston', + storageCount: 3, + type: 'vsphere', + uid: 'mock-uid-vcenter-2', + url: 'https://vcenter.v2v.bos.redhat.com/sdk', + vmCount: 41, + }, + { + apiVersion: '12345', + clusterCount: 2, + conditions: { + connected: { message: 'Connection test, succeeded.', status: 'True' }, + inventory: undefined, + ready: undefined, + validated: { message: 'Validation has been completed.', status: 'True' }, + }, + defaultTransferNetwork: undefined, + hostCount: 2, + kind: 'forklift.konveyor.io~v1beta1~Provider', + name: 'vcenter-3', + namespace: 'openshift-migration', + networkCount: 8, + ready: 'Unknown', + secretName: 'boston', + storageCount: 3, + type: 'vsphere', + uid: 'mock-uid-vcenter-3', + url: 'https://vcenter.v2v.bos.redhat.com/sdk', + vmCount: 41, + }, + { + apiVersion: 'forklift.konveyor.io/v1beta1', + clusterCount: 2, + conditions: { + connected: undefined, + inventory: { message: 'The inventory has been loaded.', status: 'True' }, + ready: { message: 'The provider is ready.', status: 'True' }, + validated: { message: 'Validation has been completed.', status: 'True' }, + }, + defaultTransferNetwork: undefined, + hostCount: 4, + kind: 'forklift.konveyor.io~v1beta1~Provider', + name: 'rhv-1', + namespace: 'konveyor-forklift', + networkCount: 15, + ready: 'True', + secretName: 'rhv', + storageCount: 9, + type: 'ovirt', + uid: 'mock-uid-rhv-1', + url: 'https://rhvm.v2v.bos.redhat.com/ovirt-engine/api', + vmCount: 36, + }, + { + apiVersion: 'forklift.konveyor.io/v1beta1', + clusterCount: 2, + conditions: { + connected: undefined, + inventory: { message: 'The inventory has been loaded.', status: 'True' }, + ready: { message: 'The provider is ready.', status: 'True' }, + validated: { message: 'Validation has been completed.', status: 'True' }, + }, + defaultTransferNetwork: undefined, + hostCount: 4, + kind: 'forklift.konveyor.io~v1beta1~Provider', + name: 'rhv-2', + namespace: 'konveyor-forklift', + networkCount: 15, + ready: 'True', + secretName: 'rhv', + storageCount: 9, + type: 'ovirt', + uid: 'mock-uid-rhv-2', + url: 'https://rhvm.v2v.bos.redhat.com/ovirt-engine/api', + vmCount: 36, + }, + { + apiVersion: 'forklift.konveyor.io/v1beta1', + clusterCount: 2, + conditions: { + connected: undefined, + inventory: { message: 'The inventory has been loaded.', status: 'True' }, + ready: { message: 'The provider is ready.', status: 'True' }, + validated: { message: 'Validation has been completed.', status: 'True' }, + }, + defaultTransferNetwork: undefined, + hostCount: 4, + kind: 'forklift.konveyor.io~v1beta1~Provider', + name: 'rhv-3', + namespace: 'konveyor-forklift', + networkCount: 15, + ready: 'True', + secretName: 'rhv', + storageCount: 9, + type: 'ovirt', + uid: 'mock-uid-rhv-3', + url: 'https://rhvm.v2v.bos.redhat.com/ovirt-engine/api', + vmCount: 36, + }, + { + apiVersion: '12345', + clusterCount: undefined, + conditions: { + connected: { message: 'Connection test, succeeded.', status: 'True' }, + inventory: { message: 'The inventory has been loaded.', status: 'True' }, + ready: { message: 'The provider is ready.', status: 'True' }, + validated: { message: 'Validation has been completed.', status: 'True' }, + }, + defaultTransferNetwork: 'ocp-network-3', + hostCount: undefined, + kind: 'forklift.konveyor.io~v1beta1~Provider', + name: 'ocpv-1', + namespace: 'openshift-migration', + networkCount: 8, + ready: 'True', + secretName: 'boston', + storageCount: undefined, + type: 'openshift', + uid: 'mock-uid-ocpv-1', + url: 'https://my_OCPv_url', + vmCount: 26, + }, + { + apiVersion: '12345', + clusterCount: undefined, + conditions: { + connected: undefined, + inventory: undefined, + ready: undefined, + validated: undefined, + }, + defaultTransferNetwork: 'ocp-network-3', + hostCount: undefined, + kind: 'forklift.konveyor.io~v1beta1~Provider', + name: 'ocpv-2', + namespace: 'openshift-migration', + networkCount: 8, + ready: 'Unknown', + secretName: 'boston', + storageCount: undefined, + type: 'openshift', + uid: 'mock-uid-ocpv-2', + url: 'https://my_OCPv_url', + vmCount: 26, + }, + { + apiVersion: '12345', + clusterCount: undefined, + conditions: { + connected: { message: 'Connection test, succeeded.', status: 'True' }, + inventory: { message: 'The inventory has been loaded.', status: 'True' }, + ready: { message: 'The provider is ready.', status: 'True' }, + validated: { message: 'Validation has been completed.', status: 'True' }, + }, + defaultTransferNetwork: 'ocp-network-3', + hostCount: undefined, + kind: 'forklift.konveyor.io~v1beta1~Provider', + name: 'ocpv-3', + namespace: 'openshift-migration', + networkCount: 8, + ready: 'True', + secretName: 'boston', + storageCount: undefined, + type: 'openshift', + uid: 'mock-uid-ocpv-3', + url: 'https://my_OCPv_url', + vmCount: 26, + }, +]; diff --git a/src/modules/Providers/data.ts b/src/modules/Providers/data.ts new file mode 100644 index 000000000..6fcfec184 --- /dev/null +++ b/src/modules/Providers/data.ts @@ -0,0 +1,160 @@ +import { useMemo } from 'react'; +import * as C from 'src/utils/constants'; +import { useProviders } from 'src/utils/fetch'; +import { Condition, ProviderResource } from 'src/utils/types'; + +import { useInventoryProvidersQuery } from '@app/queries'; +import { + IOpenShiftProvider, + IProvidersByType, + IRHVProvider, + IVMwareProvider, +} from '@app/queries/types'; + +const conditionState = (state: string) => + state === 'True' || state === 'False' ? state : 'Unknown'; + +interface SupportedCondition { + status: string; + message: string; +} +interface SupportedConditions { + Ready?: SupportedCondition; + Validated?: SupportedCondition; + ConnectionTested?: SupportedCondition; + InventoryCreated?: SupportedCondition; +} + +interface FlattenedProvider { + [C.KIND]: string; + [C.NAME]: string; + [C.NAMESPACE]: string; + [C.URL]: string; + [C.TYPE]: string; + [C.UID]: string; + [C.SECRET_NAME]: string; + [C.API_VERSION]: string; + [C.DEFAULT_TRANSFER_NETWORK]: string; +} + +interface FlattenedConditions { + [C.READY]: string; + conditions: { + [C.READY]: SupportedCondition; + [C.CONNECTED]: SupportedCondition; + [C.VALIDATED]: SupportedCondition; + [C.INVENTORY]: SupportedCondition; + }; +} + +interface MergedInventory { + clusterCount: number; + hostCount: number; + vmCount: number; + networkCount: number; + storageCount: number; +} + +export type MergedProvider = FlattenedProvider & MergedInventory & FlattenedConditions; + +export const groupPairs = ( + resources: ProviderResource[], + inventory: IProvidersByType, +): [ProviderResource, IVMwareProvider & IRHVProvider & IOpenShiftProvider][] => { + const uid2inventory: { [key: string]: IVMwareProvider & IRHVProvider & IOpenShiftProvider } = + Object.fromEntries( + Object.values(inventory) + .flat() + .map((inventory) => [inventory?.uid, inventory]) + .filter(([uid, inventory]) => uid && inventory), + ); + + return resources + .map((resource): [string, ProviderResource] => [resource?.metadata?.uid, resource]) + .filter(([uid, resource]) => uid && resource) + .map( + ([uid, resource]): [ + ProviderResource, + IVMwareProvider & IRHVProvider & IOpenShiftProvider, + ] => [resource, uid2inventory[uid]], + ) + .filter(([resource, inventory]) => resource && inventory); +}; + +export const mergeData = ( + pairs: [ProviderResource, IVMwareProvider & IRHVProvider & IOpenShiftProvider][], +) => + pairs + .map( + ([resource, inventory]): [ + ProviderResource, + IVMwareProvider & IRHVProvider & IOpenShiftProvider, + SupportedConditions, + ] => [resource, inventory, toSupportedConditions(resource.status?.conditions ?? [])], + ) + .map( + ([ + { + metadata: { name = '', namespace = '', uid = '', annotations = [] } = {}, + spec: { url = '', type = '', secret: { name: secretName = '' } = {} } = {}, + kind, + apiVersion, + }, + { clusterCount, hostCount, vmCount, networkCount, datastoreCount, storageDomainCount }, + { Ready, Validated, ConnectionTested, InventoryCreated }, + ]): MergedProvider => ({ + name, + namespace, + url, + type, + uid, + kind, + secretName, + apiVersion, + defaultTransferNetwork: annotations?.[C.DEFAULT_TRANSFER_NETWORK_ANNOTATION], + clusterCount, + hostCount, + vmCount, + networkCount, + storageCount: storageDomainCount ?? datastoreCount, + ready: Ready?.status ?? 'Unknown', + conditions: { + ready: Ready, + inventory: InventoryCreated, + validated: Validated, + connected: ConnectionTested, + }, + }), + ); + +export const toSupportedConditions = (conditions: Condition[]) => + conditions.reduce( + (acc: SupportedConditions, { type, status, message }) => ({ + ...acc, + [type]: { status: conditionState(status), message }, + }), + {}, + ); + +export const useProvidersWithInventory = ({ + kind, + namespace, + name = undefined, +}): [MergedProvider[], boolean, boolean] => { + const [resources, loaded, error] = useProviders({ kind, namespace, name }); + const { data, isSuccess, isError } = useInventoryProvidersQuery(); + const providersWithInventory = useMemo( + () => (resources && data ? mergeData(groupPairs(resources, data)) : []), + [resources, data], + ); + + const totalSuccess = loaded && isSuccess; + const totalError = error || isError; + // extra memo to keep the tuple reference stable + // the tuple is used as data source and passed as prop + // which triggres unnecessary re-renders + return useMemo( + () => [providersWithInventory, totalSuccess, totalError], + [providersWithInventory, totalSuccess, totalError], + ); +}; diff --git a/src/modules/Providers/dynamic-plugin.ts b/src/modules/Providers/dynamic-plugin.ts index 6e77721f8..316149791 100644 --- a/src/modules/Providers/dynamic-plugin.ts +++ b/src/modules/Providers/dynamic-plugin.ts @@ -1,15 +1,21 @@ import { EncodedExtension } from '@openshift/dynamic-plugin-sdk'; -import { HrefNavItem, RoutePage } from '@openshift-console/dynamic-plugin-sdk'; +import { + ActionProvider, + ResourceListPage, + ResourceNSNavItem, + RoutePage, +} from '@openshift-console/dynamic-plugin-sdk'; import type { ConsolePluginMetadata } from '@openshift-console/dynamic-plugin-sdk-webpack/lib/schema/plugin-package'; export const exposedModules: ConsolePluginMetadata['exposedModules'] = { ProvidersPage: './modules/Providers/ProvidersWrapper', HostsPage: './modules/Providers/HostsPageWrapper', + useMergedProviders: './modules/Providers/UseMergedProviders', }; export const extensions: EncodedExtension[] = [ { - type: 'console.navigation/href', + type: 'console.navigation/resource-ns', properties: { id: 'providers', insertAfter: 'importSeparator', @@ -17,20 +23,31 @@ export const extensions: EncodedExtension[] = [ section: 'virtualization', // t('plugin__forklift-console-plugin~Providers for Import') name: '%plugin__forklift-console-plugin~Providers for Import%', - href: '/mtv/providers', + model: { + group: 'forklift.konveyor.io', + kind: 'Provider', + version: 'v1beta1', + }, + dataAttributes: { + 'data-quickstart-id': 'qs-nav-providers', + 'data-test-id': 'providers-nav-item', + }, }, - } as EncodedExtension, + } as EncodedExtension, { - type: 'console.page/route', + type: 'console.page/resource/list', properties: { component: { $codeRef: 'ProvidersPage', }, - path: ['/mtv/providers', '/mtv/providers/:providerType'], - exact: true, + model: { + group: 'forklift.konveyor.io', + kind: 'Provider', + version: 'v1beta1', + }, }, - } as EncodedExtension, + } as EncodedExtension, { type: 'console.page/route', @@ -42,4 +59,14 @@ export const extensions: EncodedExtension[] = [ exact: false, }, } as EncodedExtension, + + { + type: 'console.action/provider', + properties: { + contextId: 'forklift-merged-provider', + provider: { + $codeRef: 'useMergedProviders', + }, + }, + } as EncodedExtension, ]; diff --git a/src/modules/Providers/index.ts b/src/modules/Providers/index.ts new file mode 100644 index 000000000..9ecea0580 --- /dev/null +++ b/src/modules/Providers/index.ts @@ -0,0 +1,2 @@ +export * from './providerActions'; +export * from './ProvidersPage'; diff --git a/src/modules/Providers/providerActions.tsx b/src/modules/Providers/providerActions.tsx new file mode 100644 index 000000000..34f25b281 --- /dev/null +++ b/src/modules/Providers/providerActions.tsx @@ -0,0 +1,212 @@ +import React, { useMemo, useState } from 'react'; +import { createActions } from 'src/components/ActionServiceDropdown'; +import withQueryClient from 'src/components/QueryClientHoc'; +import { useTranslation } from 'src/utils/i18n'; + +import { ConfirmModal } from '@app/common/components/ConfirmModal'; +import { SelectOpenShiftNetworkModal } from '@app/common/components/SelectOpenShiftNetworkModal'; +import { ProviderType } from '@app/common/constants'; +import { AddEditProviderModal } from '@app/Providers/components/AddEditProviderModal'; +import { hasRunningMigration } from '@app/Providers/components/ProvidersTable/ProviderActionsDropdown'; +import { EditProviderContext } from '@app/Providers/ProvidersPage'; +import { + useDeleteProviderMutation, + useOCPMigrationNetworkMutation, + usePlansQuery, +} from '@app/queries'; +import { IOpenShiftProvider, IPlan, IProviderObject } from '@app/queries/types'; +import { ActionServiceProvider, useModal } from '@openshift-console/dynamic-plugin-sdk'; + +import { type MergedProvider } from './data'; + +export const useMergedProviderActions = (entity: MergedProvider) => { + const { t } = useTranslation(); + const launchModal = useModal(); + const plansQuery = usePlansQuery(); + const editingDisabled = + !entity.url || + hasRunningMigration({ + plans: plansQuery?.data?.items, + providerMetadata: { + name: entity.name, + namespace: entity.namespace, + }, + }); + const disabledTooltip = !entity.url + ? t('The host provider cannot be edited') + : t('This provider cannot be edited because it has running migrations'); + + const actions = useMemo( + () => + [ + { + id: 'edit', + cta: () => + launchModal(withQueryClient(EditModal), { + entity, + plans: plansQuery?.data?.items, + }), + label: t('Edit Provider'), + disabled: editingDisabled, + disabledTooltip: editingDisabled ? disabledTooltip : '', + }, + { + id: 'delete', + cta: () => launchModal(withQueryClient(DeleteModal), { entity }), + label: t('Delete Provider'), + disabled: editingDisabled, + disabledTooltip: editingDisabled ? disabledTooltip : '', + }, + entity.type === 'openshift' && { + id: 'selectNetwork', + cta: () => launchModal(withQueryClient(SelectNetworkForOpenshift), { entity }), + label: t('Select migration network'), + }, + ].filter(Boolean), + [t, editingDisabled, disabledTooltip], + ); + + return [actions, true, undefined]; +}; + +const EditModal = ({ + entity, + closeModal, + plans = [], +}: { + closeModal: () => void; + entity: MergedProvider; + plans: IPlan[]; +}) => { + return ( + undefined, plans }}> + + + ); +}; + +const SelectNetworkForOpenshift = ({ + entity, + closeModal, +}: { + closeModal: () => void; + entity: MergedProvider; +}) => { + const { t } = useTranslation(); + const migrationNetworkMutation = useOCPMigrationNetworkMutation(closeModal); + const inventory = toIOpenShiftProvider(entity, toIProviderObject(entity)); + return ( + { + migrationNetworkMutation.reset(); + closeModal(); + }} + onSubmit={(network) => + migrationNetworkMutation.mutate({ + provider: inventory, + network, + }) + } + mutationResult={migrationNetworkMutation} + /> + ); +}; + +const DeleteModal = ({ + entity, + closeModal, +}: { + closeModal: () => void; + entity: MergedProvider; +}) => { + const { t } = useTranslation(); + const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(true); + + const toggleDeleteModal = () => setIsDeleteModalOpen(!isDeleteModalOpen); + const deleteProviderMutation = useDeleteProviderMutation( + entity.type as ProviderType, + toggleDeleteModal, + ); + const isTarget = (type: ProviderType) => type !== 'openshift'; + + return ( + { + toggleDeleteModal(); + closeModal(); + }} + mutateFn={() => deleteProviderMutation.mutate(toIProviderObject(entity))} + mutateResult={deleteProviderMutation} + title={t('Permanently delete provider?')} + body={ + isTarget(entity.type as ProviderType) + ? t('{{type}} provider {{name}} will no longer be selectable as a migration target.', { + type: entity.type, + name: entity.name, + }) + : t('{{type}} provider {{name}} will no longer be selectable as a migration source.', { + type: entity.type, + name: entity.name, + }) + } + confirmButtonText={t('Delete')} + errorText={t('Cannot remove provider')} + cancelButtonText={t('Cancel')} + /> + ); +}; +export interface ProviderActionsProps { + entity: MergedProvider; + variant?: 'kebab' | 'dropdown'; +} + +export const ProviderActions = ({ entity, variant = 'kebab' }: ProviderActionsProps) => { + const ActionsComponent = useMemo(() => createActions(variant), [variant]); + return ( + + {ActionsComponent} + + ); +}; + +const toIProviderObject = ({ + name, + namespace, + type, + url, + secretName, + kind, + apiVersion, +}: MergedProvider): IProviderObject => ({ + metadata: { + name, + namespace, + }, + spec: { type: type as ProviderType, url, secret: { name: secretName, namespace } }, + kind, + apiVersion, +}); + +const toIOpenShiftProvider = ( + { name, namespace, networkCount, selfLink = 'foo', type, uid, vmCount }, + object, +): IOpenShiftProvider => ({ + object, + name, + namespace, + networkCount, + selfLink, + type, + uid, + vmCount, +}); diff --git a/src/utils/constants.ts b/src/utils/constants.ts index 9125e9645..9d8c2c445 100644 --- a/src/utils/constants.ts +++ b/src/utils/constants.ts @@ -1,3 +1,20 @@ +export const ACTIONS = 'actions'; export const NAME = 'name'; -export const UID = 'uid'; +export const READY = 'ready'; +export const CONNECTED = 'connected'; +export const INVENTORY = 'inventory'; +export const VALIDATED = 'validated'; +export const TYPE = 'type'; +export const URL = 'url'; export const NAMESPACE = 'namespace'; +export const KIND = 'kind'; +export const UID = 'uid'; +export const CLUSTER_COUNT = 'clusterCount'; +export const HOST_COUNT = 'hostCount'; +export const VM_COUNT = 'vmCount'; +export const NETWORK_COUNT = 'networkCount'; +export const STORAGE_COUNT = 'storageCount'; +export const SECRET_NAME = 'secretName'; +export const API_VERSION = 'apiVersion'; +export const DEFAULT_TRANSFER_NETWORK = 'defaultTransferNetwork'; +export const DEFAULT_TRANSFER_NETWORK_ANNOTATION = 'forklift.konveyor.io/defaultTransferNetwork'; diff --git a/src/utils/enums.ts b/src/utils/enums.ts new file mode 100644 index 000000000..722730342 --- /dev/null +++ b/src/utils/enums.ts @@ -0,0 +1,15 @@ +import { K8sConditionStatus } from '_/components/types'; + +import { ProviderType } from '@app/common/constants'; + +export const PROVIDERS: Record string) => string> = { + vsphere: (t) => t('VMware'), + ovirt: (t) => t('oVirt'), + openshift: (t) => t('KubeVirt'), +}; + +export const CONDITIONS: Record string) => string> = { + True: (t) => t('True'), + False: (t) => t('False'), + Unknown: (t) => t('Unknown'), +}; diff --git a/src/utils/fetch.ts b/src/utils/fetch.ts new file mode 100644 index 000000000..9c1c07c3d --- /dev/null +++ b/src/utils/fetch.ts @@ -0,0 +1,40 @@ +import { useMemo } from 'react'; +import { ProviderResource } from 'src/utils/types'; + +import { MOCK_CLUSTER_PROVIDERS } from '@app/queries/mocks/providers.mock'; +import { + useK8sWatchResource, + WatchK8sResource, + WatchK8sResult, +} from '@openshift-console/dynamic-plugin-sdk'; + +const IS_MOCK = process.env.DATA_SOURCE === 'mock'; + +function useRealK8sWatchResource({ + kind, + namespace, + name, +}: WatchK8sResource): WatchK8sResult { + return useK8sWatchResource({ + kind, + isList: true, + namespaced: true, + namespace, + name, + }); +} + +const useMockProviders = ({ name }: WatchK8sResource): WatchK8sResult => { + const mockData: ProviderResource[] = useMemo( + () => + !name + ? (MOCK_CLUSTER_PROVIDERS as ProviderResource[]) + : (MOCK_CLUSTER_PROVIDERS?.filter( + (provider) => provider?.metadata?.name === name, + ) as ProviderResource[]), + [name], + ); + return [mockData, true, false]; +}; + +export const useProviders = IS_MOCK ? useMockProviders : useRealK8sWatchResource; diff --git a/src/utils/types.ts b/src/utils/types.ts index 89ccece69..63a7d1937 100644 --- a/src/utils/types.ts +++ b/src/utils/types.ts @@ -30,3 +30,8 @@ export type ProviderResource = { conditions?: Condition[]; }; } & K8sResourceCommon; + +export type ResourceConsolePageProps = { + kind: string; + namespace: string; +};