From c94fceb64fee497b05534c6bd363f219f877c8ad Mon Sep 17 00:00:00 2001 From: Joachim Schuler Date: Mon, 16 Dec 2024 10:26:48 -0500 Subject: [PATCH] MTV-1726: Edit VMs list in migration plan Signed-off-by: Joachim Schuler --- .../en/plugin__forklift-console-plugin.json | 4 + .../page/StandardPageWithExpansion.tsx | 149 ++++++++++++ .../Plans/utils/types/PlanEditAction.ts | 1 + .../SelectSourceProvider.tsx | 19 +- .../details/tabs/Mappings/PlanMappings.tsx | 144 ++++++++---- .../tabs/Mappings/PlanMappingsSection.tsx | 214 ++++++++++-------- .../MigrationVirtualMachinesList.tsx | 28 ++- .../Plan/PlanVirtualMachinesList.tsx | 25 +- .../components/PlanVMsEditButton.tsx | 38 ++++ .../tabs/VirtualMachines/components/index.ts | 1 + .../modals/PlanVMsEditModal.style.css | 3 + .../modals/PlanVMsEditModal.tsx | 147 ++++++++++++ .../tabs/VirtualMachines/modals/index.ts | 1 + .../details/utils/patchPlanMappingsData.ts | 9 +- .../modules/Plans/views/edit/PlanEditPage.tsx | 168 ++++++++++++++ .../ProvidersCreateVmMigrationContext.tsx | 6 +- .../ProvidersUpdateVmMigrationPage.tsx | 63 ++++++ .../migrate/components/PlansUpdateForm.tsx | 205 +++++++++++++++++ .../views/migrate/reducer/actions.ts | 11 + .../migrate/reducer/createInitialState.ts | 26 ++- .../views/migrate/reducer/helpers.ts | 21 +- .../views/migrate/reducer/reducer.ts | 21 +- .../modules/Providers/views/migrate/types.ts | 3 + .../views/migrate/useFetchEffects.ts | 13 +- .../views/migrate/useUpdateEffect.ts | 155 +++++++++++++ 25 files changed, 1281 insertions(+), 194 deletions(-) create mode 100644 packages/forklift-console-plugin/src/components/page/StandardPageWithExpansion.tsx create mode 100644 packages/forklift-console-plugin/src/modules/Plans/utils/types/PlanEditAction.ts create mode 100644 packages/forklift-console-plugin/src/modules/Plans/views/details/tabs/VirtualMachines/components/PlanVMsEditButton.tsx create mode 100644 packages/forklift-console-plugin/src/modules/Plans/views/details/tabs/VirtualMachines/modals/PlanVMsEditModal.style.css create mode 100644 packages/forklift-console-plugin/src/modules/Plans/views/details/tabs/VirtualMachines/modals/PlanVMsEditModal.tsx create mode 100644 packages/forklift-console-plugin/src/modules/Plans/views/edit/PlanEditPage.tsx create mode 100644 packages/forklift-console-plugin/src/modules/Providers/views/migrate/ProvidersUpdateVmMigrationPage.tsx create mode 100644 packages/forklift-console-plugin/src/modules/Providers/views/migrate/components/PlansUpdateForm.tsx create mode 100644 packages/forklift-console-plugin/src/modules/Providers/views/migrate/useUpdateEffect.ts diff --git a/packages/forklift-console-plugin/locales/en/plugin__forklift-console-plugin.json b/packages/forklift-console-plugin/locales/en/plugin__forklift-console-plugin.json index 5826254a1..7c5a8a82b 100644 --- a/packages/forklift-console-plugin/locales/en/plugin__forklift-console-plugin.json +++ b/packages/forklift-console-plugin/locales/en/plugin__forklift-console-plugin.json @@ -165,6 +165,7 @@ "Edit StorageMap": "Edit StorageMap", "Edit URL": "Edit URL", "Edit VDDK init image": "Edit VDDK init image", + "Edit virtual machines": "Edit virtual machines", "Empty": "Empty", "Endpoint": "Endpoint", "Endpoint type": "Endpoint type", @@ -483,6 +484,7 @@ "The chosen provider is no longer available.": "The chosen provider is no longer available.", "The current certificate does not match the certificate fetched from URL. Manually validate the fingerprint before proceeding.": "The current certificate does not match the certificate fetched from URL. Manually validate the fingerprint before proceeding.", "The edit mappings button is disabled if the plan started running and at least one virtual machine was migrated successfully or when the plan status does not enable editing.": "The edit mappings button is disabled if the plan started running and at least one virtual machine was migrated successfully or when the plan status does not enable editing.", + "The edit virtual machines button is disabled if the plan started running and at least one virtual machine was migrated successfully.": "The edit virtual machines button is disabled if the plan started running and at least one virtual machine was migrated successfully.", "The interval in minutes for precopy. Default value is 60.": "The interval in minutes for precopy. Default value is 60.", "The interval in seconds for snapshot pooling. Default value is 10.": "The interval in seconds for snapshot pooling. Default value is 10.", "The limit for CPU usage by the controller, specified in milliCPU. Default value is 500m.": "The limit for CPU usage by the controller, specified in milliCPU. Default value is 500m.", @@ -521,7 +523,9 @@ "Update credentials": "Update credentials", "Update hooks": "Update hooks", "Update mappings": "Update mappings", + "Update migration plan": "Update migration plan", "Update providers": "Update providers", + "Update virtual machines": "Update virtual machines", "Updated": "Updated", "Upload": "Upload", "URL": "URL", diff --git a/packages/forklift-console-plugin/src/components/page/StandardPageWithExpansion.tsx b/packages/forklift-console-plugin/src/components/page/StandardPageWithExpansion.tsx new file mode 100644 index 000000000..b4bbe1dfd --- /dev/null +++ b/packages/forklift-console-plugin/src/components/page/StandardPageWithExpansion.tsx @@ -0,0 +1,149 @@ +import React, { useState } from 'react'; + +import { RowProps, withTr } from '@kubev2v/common'; +import { Td, Th } from '@patternfly/react-table'; + +import StandardPage, { StandardPageProps } from './StandardPage'; + +export function withRowExpansion({ CellMapper, isExpanded, toggleExpandFor }) { + const Enhanced = (props: RowProps) => ( + <> + {isExpanded && ( + toggleExpandFor([props.resourceData]), + }} + /> + )} + + + ); + Enhanced.displayName = `${CellMapper.displayName || 'CellMapper'}WithExpansion`; + return Enhanced; +} + +export interface IdBasedExpansionProps { + /** + * @returns string that can be used as an unique identifier + */ + toId?: (item: T) => string; + + /** + * onExpand is called when expand changes + */ + onExpand?: (expandedIds: string[]) => void; + + /** + * Expanded ids + */ + expandedIds?: string[]; +} + +/** + * Adds ID based expansion to StandardPage component. + * Contract: + * 1. IDs provided with toId() function are unique and constant in time + */ +export function withIdBasedExpansion({ + toId, + onExpand, + expandedIds: initialExpandedIds, +}: IdBasedExpansionProps) { + const Enhanced = (props: StandardPageProps) => { + const [expandedIds, setExpandedIds] = useState(initialExpandedIds); + + const isExpanded = + onExpand || expandedIds ? (item: T) => expandedIds.includes(toId(item)) : undefined; + + const toggleExpandFor = (items: T[]) => { + const ids = items.map(toId); + const allExpanded = ids.every((id) => expandedIds?.includes(id)); + const newExpandedIds = [ + ...(expandedIds || []).filter((it) => !ids.includes(it)), + ...(allExpanded ? [] : ids), + ]; + + setExpandedIds(newExpandedIds); + if (onExpand) { + onExpand(newExpandedIds); + } + }; + + const { CellMapper, ExpandedComponent, ...rest } = props; + + const RowMapper = withTr( + withRowExpansion({ + CellMapper: CellMapper, + isExpanded, + toggleExpandFor, + }), + ExpandedComponent, + ); + + return ( + } + GlobalActionToolbarItems={props.GlobalActionToolbarItems} + /> + ); + }; + Enhanced.displayName = 'StandardPageWithExpansion'; + return Enhanced; +} + +/** + * Properties for the `StandardPageWithExpansion` component. + * These properties extend the base `StandardPageProps` and add additional ones related to expansion. + * + * @typedef {Object} StandardPageWithExpansionProps + * @property {Function} toId - A function that returns a unique identifier for each item. + * @property {Function} onExpand - A callback function that is triggered when row is expanded or un expanded. + * @property {string[]} expandedIds - An array of identifiers for the currently expanded items. + * @property {...StandardPageProps} - Other props that are passed through to the `StandardPage` component. + * + * @template T - The type of the items being displayed in the table. + */ +export interface StandardPageWithExpansionProps extends StandardPageProps { + toId?: (item: T) => string; + onExpand?: (expandedIds: string[]) => void; + expandedIds?: string[]; +} + +/** + * Renders a standard page with expansion capabilities. + * This component wraps the `StandardPage` component and adds support for row expansion. + * It uses the provided `toId`, `onExpand`, and `expandedIds` props to manage the expansion state. + * + * @param {Object} props - The properties passed to the component. + * @param {Function} props.toId - A function that returns a unique identifier for each item. + * @param {...StandardPageProps} props - Other props that are passed through to the `StandardPage` component. + * + * @template T - The type of the items being displayed in the table. + * + * @example + * item.id} + * // ...other props + * /> + */ +export function StandardPageWithExpansion(props: StandardPageWithExpansionProps) { + const { toId, onExpand, expandedIds, ...rest } = props; + + if (onExpand && (!toId || !expandedIds)) { + throw new Error('Missing required properties: toId, expandedIds'); + } + + const EnhancedStandardPage = withIdBasedExpansion({ + toId, + onExpand, + expandedIds, + }); + + return ; +} diff --git a/packages/forklift-console-plugin/src/modules/Plans/utils/types/PlanEditAction.ts b/packages/forklift-console-plugin/src/modules/Plans/utils/types/PlanEditAction.ts new file mode 100644 index 000000000..b9f1933d6 --- /dev/null +++ b/packages/forklift-console-plugin/src/modules/Plans/utils/types/PlanEditAction.ts @@ -0,0 +1 @@ +export type PlanEditAction = 'PLAN' | 'VMS'; diff --git a/packages/forklift-console-plugin/src/modules/Plans/views/create/steps/SelectSourceProvider/SelectSourceProvider.tsx b/packages/forklift-console-plugin/src/modules/Plans/views/create/steps/SelectSourceProvider/SelectSourceProvider.tsx index 10df4be05..a373443b3 100644 --- a/packages/forklift-console-plugin/src/modules/Plans/views/create/steps/SelectSourceProvider/SelectSourceProvider.tsx +++ b/packages/forklift-console-plugin/src/modules/Plans/views/create/steps/SelectSourceProvider/SelectSourceProvider.tsx @@ -15,7 +15,8 @@ export const SelectSourceProvider: React.FC<{ filterDispatch: React.Dispatch; providers: V1beta1Provider[]; selectedProvider: V1beta1Provider; -}> = ({ filterState, filterDispatch, providers, selectedProvider }) => { + hideProviderSection?: boolean; +}> = ({ filterState, filterDispatch, providers, selectedProvider, hideProviderSection }) => { const { t } = useForkliftTranslation(); // Get the ready providers (note: currently forklift does not allow filter be status.phase) @@ -33,13 +34,17 @@ export const SelectSourceProvider: React.FC<{ return ( <> - {t('Select source provider')} + {!hideProviderSection && ( + <> + {t('Select source provider')} - + + + )} {filterState.selectedProviderUID && ( <> diff --git a/packages/forklift-console-plugin/src/modules/Plans/views/details/tabs/Mappings/PlanMappings.tsx b/packages/forklift-console-plugin/src/modules/Plans/views/details/tabs/Mappings/PlanMappings.tsx index ba5b626ee..d134f6aa0 100644 --- a/packages/forklift-console-plugin/src/modules/Plans/views/details/tabs/Mappings/PlanMappings.tsx +++ b/packages/forklift-console-plugin/src/modules/Plans/views/details/tabs/Mappings/PlanMappings.tsx @@ -1,4 +1,4 @@ -import React from 'react'; +import React, { useEffect, useReducer } from 'react'; import { SectionHeading } from 'src/components/headers/SectionHeading'; import { useOpenShiftNetworks, useSourceNetworks } from 'src/modules/Providers/hooks/useNetworks'; import { useOpenShiftStorages, useSourceStorages } from 'src/modules/Providers/hooks/useStorages'; @@ -17,12 +17,23 @@ import { import { useK8sWatchResource } from '@openshift-console/dynamic-plugin-sdk'; import { Alert, PageSection } from '@patternfly/react-core'; -import { PlanMappingsSection } from './PlanMappingsSection'; +import { + PlanMappingsSection, + planMappingsSectionReducer, + PlanMappingsSectionState, +} from './PlanMappingsSection'; export type PlanMappingsInitSectionProps = { plan: V1beta1Plan; - loaded: boolean; - loadError: unknown; + loaded?: boolean; + loadError?: unknown; + planMappingsState: PlanMappingsSectionState; + planMappingsDispatch: React.Dispatch<{ + type: string; + payload?; + }>; + planNetworkMaps: V1beta1NetworkMap; + planStorageMaps: V1beta1StorageMap; }; export const PlanMappings: React.FC<{ name: string; namespace: string }> = ({ @@ -30,7 +41,6 @@ export const PlanMappings: React.FC<{ name: string; namespace: string }> = ({ namespace, }) => { const { t } = useForkliftTranslation(); - const [plan, loaded, loadError] = useK8sWatchResource({ groupVersionKind: PlanModelGroupVersionKind, namespaced: true, @@ -38,30 +48,6 @@ export const PlanMappings: React.FC<{ name: string; namespace: string }> = ({ namespace, }); - return ( - <> -
- - - - -
- - ); -}; - -const PlanMappingsInitSection: React.FC = (props) => { - const { t } = useForkliftTranslation(); - const { plan } = props; - - // Retrieve all k8s Providers - const [providers, providersLoaded, providersLoadError] = useK8sWatchResource({ - groupVersionKind: ProviderModelGroupVersionKind, - namespaced: true, - isList: true, - namespace: plan?.metadata?.namespace, - }); - // Retrieve all k8s Network Mappings const [networkMaps, networkMapsLoaded, networkMapsError] = useK8sWatchResource< V1beta1NetworkMap[] @@ -88,6 +74,93 @@ const PlanMappingsInitSection: React.FC = (props) const planStorageMaps = storageMaps ? storageMaps.find((storage) => storage?.metadata?.name === plan.spec.map?.storage?.name) : null; + + const initialState: PlanMappingsSectionState = { + edit: false, + dataChanged: false, + alertMessage: null, + updatedNetwork: planNetworkMaps?.spec?.map || [], + updatedStorage: planStorageMaps?.spec?.map || [], + planNetworkMaps: planNetworkMaps, + planStorageMaps: planStorageMaps, + }; + + const [state, dispatch] = useReducer(planMappingsSectionReducer, initialState); + + useEffect(() => { + if (planNetworkMaps && planStorageMaps) { + dispatch({ + type: 'SET_PLAN_MAPS', + payload: { planNetworkMaps, planStorageMaps }, + }); + } + }, [planNetworkMaps, planStorageMaps]); + + const checkResources = () => { + if (!networkMapsLoaded || !storageMapsLoaded) { + return ( +
+ {t('Data is loading, please wait.')} +
+ ); + } + + if (networkMapsError || storageMapsError) { + return ( +
+ + {t( + 'Something is wrong, the data was not loaded due to an error, please try to reload the page.', + )} + +
+ ); + } + + if (networkMaps.length == 0 || storageMaps.length == 0) + return ( +
+ {t('No Mapping found.')} +
+ ); + + return null; + }; + + return ( + <> +
+ + + {checkResources() ?? ( + + )} + +
+ + ); +}; + +export const PlanMappingsInitSection: React.FC = (props) => { + const { t } = useForkliftTranslation(); + const { plan, planMappingsState, planMappingsDispatch, planNetworkMaps, planStorageMaps } = props; + + // Retrieve all k8s Providers + const [providers, providersLoaded, providersLoadError] = useK8sWatchResource({ + groupVersionKind: ProviderModelGroupVersionKind, + namespaced: true, + isList: true, + namespace: plan?.metadata?.namespace, + }); + const sourceProvider: V1beta1Provider = providers ? providers.find((p) => p?.metadata?.name === plan?.spec?.provider?.source?.name) : null; @@ -106,8 +179,6 @@ const PlanMappingsInitSection: React.FC = (props) useOpenShiftStorages(targetProvider); if ( - !networkMapsLoaded || - !storageMapsLoaded || !providersLoaded || sourceNetworksLoading || targetNetworksLoading || @@ -122,8 +193,6 @@ const PlanMappingsInitSection: React.FC = (props) } if ( - networkMapsError || - storageMapsError || providersLoadError || sourceNetworksError || targetNetworksError || @@ -141,13 +210,6 @@ const PlanMappingsInitSection: React.FC = (props) ); } - if (networkMaps.length == 0 || storageMaps.length == 0) - return ( -
- {t('No Mapping found.')} -
- ); - // Warn when missing inventory data, missing inventory will make // some editing options missing. const alerts = []; @@ -176,6 +238,8 @@ const PlanMappingsInitSection: React.FC = (props) targetNetworks={targetNetworks} sourceStorages={sourceStorages} targetStorages={targetStorages} + planMappingsState={planMappingsState} + planMappingsDispatch={planMappingsDispatch} /> ); diff --git a/packages/forklift-console-plugin/src/modules/Plans/views/details/tabs/Mappings/PlanMappingsSection.tsx b/packages/forklift-console-plugin/src/modules/Plans/views/details/tabs/Mappings/PlanMappingsSection.tsx index 5bf78ae11..5904b3991 100644 --- a/packages/forklift-console-plugin/src/modules/Plans/views/details/tabs/Mappings/PlanMappingsSection.tsx +++ b/packages/forklift-console-plugin/src/modules/Plans/views/details/tabs/Mappings/PlanMappingsSection.tsx @@ -1,5 +1,6 @@ -import React, { ReactNode, useReducer, useState } from 'react'; +import React, { ReactNode, useState } from 'react'; import { isPlanEditable } from 'src/modules/Plans/utils'; +import { PlanEditAction } from 'src/modules/Plans/utils/types/PlanEditAction'; import { InventoryNetwork } from 'src/modules/Providers/hooks/useNetworks'; import { InventoryStorage } from 'src/modules/Providers/hooks/useStorages'; import { useForkliftTranslation } from 'src/utils/i18n'; @@ -56,12 +57,15 @@ import { * @property {V1beta1NetworkMapSpecMap[]} updatedNetwork - The new version of the Plan Network Maps being edited. * @property {V1beta1StorageMapSpecMap[]} updatedStorage - The new version of the Plan Storage Maps being edited. */ -interface PlanMappingsSectionState { +export interface PlanMappingsSectionState { edit: boolean; dataChanged: boolean; alertMessage: ReactNode; updatedNetwork: V1beta1NetworkMapSpecMap[]; updatedStorage: V1beta1StorageMapSpecMap[]; + planNetworkMaps: V1beta1NetworkMap; + planStorageMaps: V1beta1StorageMap; + editAction?: PlanEditAction; } export type PlanMappingsSectionProps = { @@ -72,8 +76,86 @@ export type PlanMappingsSectionProps = { targetNetworks: OpenShiftNetworkAttachmentDefinition[]; sourceStorages: InventoryStorage[]; targetStorages: OpenShiftStorageClass[]; + planMappingsState?: PlanMappingsSectionState; + planMappingsDispatch?: React.Dispatch<{ + type: string; + payload?; + }>; }; +export function planMappingsSectionReducer( + state: PlanMappingsSectionState, + action: { type: string; payload? }, +): PlanMappingsSectionState { + switch (action.type) { + case 'SET_PLAN_MAPS': { + const { planNetworkMaps, planStorageMaps } = action.payload; + return { + ...state, + planNetworkMaps, + planStorageMaps, + updatedNetwork: planNetworkMaps?.spec?.map, + updatedStorage: planStorageMaps?.spec?.map, + }; + } + case 'TOGGLE_EDIT': { + return { ...state, edit: !state.edit }; + } + case 'SET_CANCEL': { + const dataChanged = false; + + return { + ...state, + dataChanged, + alertMessage: null, + updatedNetwork: state.planNetworkMaps.spec.map, + updatedStorage: state.planStorageMaps.spec.map, + }; + } + case 'SET_ALERT_MESSAGE': { + return { ...state, alertMessage: action.payload }; + } + case 'ADD_NETWORK_MAPPING': + case 'DELETE_NETWORK_MAPPING': + case 'REPLACE_NETWORK_MAPPING': { + const updatedNetwork = action.payload.newState; + const dataChanged = hasPlanMappingsChanged( + state.planNetworkMaps.spec.map, + state.planStorageMaps.spec.map, + updatedNetwork, + state?.updatedStorage, + ); + + return { + ...state, + dataChanged, + alertMessage: null, + updatedNetwork, + }; + } + case 'ADD_STORAGE_MAPPING': + case 'DELETE_STORAGE_MAPPING': + case 'REPLACE_STORAGE_MAPPING': { + const updatedStorage = action.payload.newState; + const dataChanged = hasPlanMappingsChanged( + state.planNetworkMaps.spec.map, + state.planStorageMaps.spec.map, + state?.updatedNetwork, + updatedStorage, + ); + + return { + ...state, + dataChanged, + alertMessage: null, + updatedStorage, + }; + } + default: + return state; + } +} + export const PlanMappingsSection: React.FC = ({ plan, planNetworkMaps, @@ -82,84 +164,14 @@ export const PlanMappingsSection: React.FC = ({ targetNetworks, sourceStorages, targetStorages, + planMappingsState: state, + planMappingsDispatch: dispatch, }) => { const { t } = useForkliftTranslation(); - const initialState: PlanMappingsSectionState = { - edit: false, - dataChanged: false, - alertMessage: null, - updatedNetwork: planNetworkMaps?.spec?.map, - updatedStorage: planStorageMaps?.spec?.map, - }; - const [isLoading, setIsLoading] = useState(false); const [isAddNetworkMapAvailable, setIsAddNetworkMapAvailable] = useState(true); const [isAddStorageMapAvailable, setIsAddStorageMapAvailable] = useState(true); - const [state, dispatch] = useReducer(reducer, initialState); - - function reducer( - state: PlanMappingsSectionState, - action: { type: string; payload? }, - ): PlanMappingsSectionState { - switch (action.type) { - case 'TOGGLE_EDIT': { - return { ...state, edit: !state.edit }; - } - case 'SET_CANCEL': { - const dataChanged = false; - - return { - ...state, - dataChanged, - alertMessage: null, - updatedNetwork: planNetworkMaps?.spec?.map, - updatedStorage: planStorageMaps?.spec?.map, - }; - } - case 'SET_ALERT_MESSAGE': { - return { ...state, alertMessage: action.payload }; - } - case 'ADD_NETWORK_MAPPING': - case 'DELETE_NETWORK_MAPPING': - case 'REPLACE_NETWORK_MAPPING': { - const updatedNetwork = action.payload.newState; - const dataChanged = hasPlanMappingsChanged( - planNetworkMaps?.spec?.map, - planStorageMaps?.spec?.map, - updatedNetwork, - state?.updatedStorage, - ); - - return { - ...state, - dataChanged, - alertMessage: null, - updatedNetwork, - }; - } - case 'ADD_STORAGE_MAPPING': - case 'DELETE_STORAGE_MAPPING': - case 'REPLACE_STORAGE_MAPPING': { - const updatedStorage = action.payload.newState; - const dataChanged = hasPlanMappingsChanged( - planNetworkMaps?.spec?.map, - planStorageMaps?.spec?.map, - state?.updatedNetwork, - updatedStorage, - ); - - return { - ...state, - dataChanged, - alertMessage: null, - updatedStorage, - }; - } - default: - return state; - } - } // Toggles between view and edit modes function onToggleEdit() { @@ -648,31 +660,35 @@ export const PlanMappingsSection: React.FC = ({ return state.edit ? ( // Edit mode <> - - - - - - - - - - - {t( - 'Click the update mappings button to save your changes, button is disabled until a change is detected.', - )} - - - + {!state.editAction && ( + <> + + + + + + + + + + + {t( + 'Click the update mappings button to save your changes, button is disabled until a change is detected.', + )} + + + + + )} {state.alertMessage ? ( <> [ ]; const PageWithSelection = StandardPageWithSelection; +const PageWithExpansion = StandardPageWithExpansion; type PageWithSelectionProps = StandardPageWithSelectionProps; type PageGlobalActions = FC>[]; @@ -244,7 +252,9 @@ export const MigrationVirtualMachinesList: FC<{ obj: PlanData }> = ({ obj }) => })); const isExecuting = isPlanExecuting(plan); + const planSucceeded = isPlanSucceeded(plan); const isArchived = isPlanArchived(plan); + const disableEdit = hasSomeCompleteRunningVMs(plan) || !isPlanEditable(plan); // If plan executing and not archived (happens when archiving a running plan), allow to cancel vms, o/w remove from plan let actions: PageGlobalActions; @@ -254,10 +264,10 @@ export const MigrationVirtualMachinesList: FC<{ obj: PlanData }> = ({ obj }) => ), ]; - } else { - actions = [ - ({ selectedIds }) => , - ]; + } else if (!planSucceeded) { + actions = disableEdit + ? [({ selectedIds }) => ] + : [() => ]; } const canSelectWhenExecuting = (item: VMData) => @@ -288,5 +298,9 @@ export const MigrationVirtualMachinesList: FC<{ obj: PlanData }> = ({ obj }) => GlobalActionToolbarItems: actions, }; - return ; + return !planSucceeded && (isExecuting || disableEdit) ? ( + + ) : ( + + ); }; diff --git a/packages/forklift-console-plugin/src/modules/Plans/views/details/tabs/VirtualMachines/Plan/PlanVirtualMachinesList.tsx b/packages/forklift-console-plugin/src/modules/Plans/views/details/tabs/VirtualMachines/Plan/PlanVirtualMachinesList.tsx index 4be029bea..4cd25e85f 100644 --- a/packages/forklift-console-plugin/src/modules/Plans/views/details/tabs/VirtualMachines/Plan/PlanVirtualMachinesList.tsx +++ b/packages/forklift-console-plugin/src/modules/Plans/views/details/tabs/VirtualMachines/Plan/PlanVirtualMachinesList.tsx @@ -1,18 +1,16 @@ import React, { FC } from 'react'; -import { - GlobalActionWithSelection, - StandardPageWithSelection, -} from 'src/components/page/StandardPageWithSelection'; +import { StandardPage } from 'src/components/page/StandardPage'; import { useForkliftTranslation } from 'src/utils/i18n'; import { loadUserSettings, ResourceFieldFactory } from '@kubev2v/common'; +import { GlobalActionToolbarProps } from '@kubev2v/common'; import { V1beta1PlanSpecVms, V1beta1PlanStatusConditions, V1beta1PlanStatusMigrationVms, } from '@kubev2v/types'; -import { PlanVMsDeleteButton } from '../components'; +import { PlanVMsEditButton } from '../components'; import { PlanData, VMData } from '../types'; import { PlanVirtualMachinesRow } from './PlanVirtualMachinesRow'; @@ -41,8 +39,8 @@ const fieldsMetadataFactory: ResourceFieldFactory = (t) => [ }, ]; -const PageWithSelection = StandardPageWithSelection; -type PageGlobalActions = FC>[]; +const PageWithNoSelection = StandardPage; +type PageGlobalActions = FC>[]; export const PlanVirtualMachinesList: FC<{ obj: PlanData }> = ({ obj }) => { const { t } = useForkliftTranslation(); @@ -79,17 +77,11 @@ export const PlanVirtualMachinesList: FC<{ obj: PlanData }> = ({ obj }) => { })); const vmDataSource: [VMData[], boolean, unknown] = [vmData || [], true, undefined]; const vmDataToId = (item: VMData) => item?.specVM?.id; - const canSelect = (item: VMData) => - item?.statusVM?.started === undefined || item?.statusVM?.error !== undefined; - const onSelect = () => undefined; - const initialSelectedIds = []; - const actions: PageGlobalActions = [ - ({ selectedIds }) => , - ]; + const actions: PageGlobalActions = [() => ]; return ( - = ({ obj }) => { namespace={''} page={1} toId={vmDataToId} - canSelect={canSelect} - onSelect={onSelect} - selectedIds={initialSelectedIds} GlobalActionToolbarItems={actions} /> ); diff --git a/packages/forklift-console-plugin/src/modules/Plans/views/details/tabs/VirtualMachines/components/PlanVMsEditButton.tsx b/packages/forklift-console-plugin/src/modules/Plans/views/details/tabs/VirtualMachines/components/PlanVMsEditButton.tsx new file mode 100644 index 000000000..3ddf675a2 --- /dev/null +++ b/packages/forklift-console-plugin/src/modules/Plans/views/details/tabs/VirtualMachines/components/PlanVMsEditButton.tsx @@ -0,0 +1,38 @@ +import React, { FC } from 'react'; +import { isPlanEditable } from 'src/modules/Plans/utils'; +import { hasSomeCompleteRunningVMs } from 'src/modules/Plans/views/details/utils'; +import { useModal } from 'src/modules/Providers/modals'; +import { useForkliftTranslation } from 'src/utils/i18n'; + +import { V1beta1Plan } from '@kubev2v/types'; +import { Button, Tooltip } from '@patternfly/react-core'; + +import { PlanVMsEditModal } from '../modals'; + +export const PlanVMsEditButton: FC<{ + plan: V1beta1Plan; +}> = ({ plan }) => { + const { t } = useForkliftTranslation(); + const { showModal } = useModal(); + const disableEdit = hasSomeCompleteRunningVMs(plan) || !isPlanEditable(plan); + + const onClick = () => { + showModal(); + }; + + return disableEdit ? ( + + + + ) : ( + + ); +}; diff --git a/packages/forklift-console-plugin/src/modules/Plans/views/details/tabs/VirtualMachines/components/index.ts b/packages/forklift-console-plugin/src/modules/Plans/views/details/tabs/VirtualMachines/components/index.ts index db0547464..88a035dbf 100644 --- a/packages/forklift-console-plugin/src/modules/Plans/views/details/tabs/VirtualMachines/components/index.ts +++ b/packages/forklift-console-plugin/src/modules/Plans/views/details/tabs/VirtualMachines/components/index.ts @@ -4,4 +4,5 @@ export * from './MigrationVMsCancelButton'; export * from './NameCellRenderer'; export * from './PlanVMsCellProps'; export * from './PlanVMsDeleteButton'; +export * from './PlanVMsEditButton'; // @endindex diff --git a/packages/forklift-console-plugin/src/modules/Plans/views/details/tabs/VirtualMachines/modals/PlanVMsEditModal.style.css b/packages/forklift-console-plugin/src/modules/Plans/views/details/tabs/VirtualMachines/modals/PlanVMsEditModal.style.css new file mode 100644 index 000000000..e88ce2baf --- /dev/null +++ b/packages/forklift-console-plugin/src/modules/Plans/views/details/tabs/VirtualMachines/modals/PlanVMsEditModal.style.css @@ -0,0 +1,3 @@ +.forklift-edit-modal { + overflow: auto; +} diff --git a/packages/forklift-console-plugin/src/modules/Plans/views/details/tabs/VirtualMachines/modals/PlanVMsEditModal.tsx b/packages/forklift-console-plugin/src/modules/Plans/views/details/tabs/VirtualMachines/modals/PlanVMsEditModal.tsx new file mode 100644 index 000000000..279b1c8af --- /dev/null +++ b/packages/forklift-console-plugin/src/modules/Plans/views/details/tabs/VirtualMachines/modals/PlanVMsEditModal.tsx @@ -0,0 +1,147 @@ +import React from 'react'; +import { PlanEditAction } from 'src/modules/Plans/utils/types/PlanEditAction'; +import { PlanEditPage } from 'src/modules/Plans/views/edit/PlanEditPage'; +import { useModal } from 'src/modules/Providers/modals'; +import { useInventoryVms } from 'src/modules/Providers/views'; +import { useForkliftTranslation } from 'src/utils/i18n'; + +import { + NetworkMapModelGroupVersionKind, + ProviderModelGroupVersionKind, + StorageMapModelGroupVersionKind, + V1beta1NetworkMap, + V1beta1Plan, + V1beta1Provider, + V1beta1StorageMap, +} from '@kubev2v/types'; +import { useActiveNamespace } from '@openshift-console/dynamic-plugin-sdk'; +import { useK8sWatchResource } from '@openshift-console/dynamic-plugin-sdk'; +import { Modal, ModalVariant } from '@patternfly/react-core'; + +import './PlanVMsDeleteModal.style.css'; +import './PlanVMsEditModal.style.css'; + +export interface PlanVMsEditModalProps { + plan: V1beta1Plan; + editAction: PlanEditAction; +} + +export const PlanVMsEditModal: React.FC = ({ plan, editAction }) => { + const { toggleModal } = useModal(); + const [activeNamespace] = useActiveNamespace(); + const { t } = useForkliftTranslation(); + + // Retrieve k8s source provider + const [sourceProvider, sourceProviderLoaded, sourceProviderLoadError] = + useK8sWatchResource({ + groupVersionKind: ProviderModelGroupVersionKind, + namespaced: true, + name: plan?.spec?.provider?.source?.name, + namespace: plan?.spec?.provider?.source?.namespace, + }); + + // Retrieve k8s target provider + const [targetProvider, targetProviderLoaded, targetProviderLoadError] = + useK8sWatchResource({ + groupVersionKind: ProviderModelGroupVersionKind, + namespaced: true, + name: plan?.spec?.provider?.destination?.name, + namespace: plan?.spec?.provider?.destination?.namespace, + }); + + const [providers, providersLoaded, providersLoadError] = useK8sWatchResource({ + groupVersionKind: ProviderModelGroupVersionKind, + namespaced: true, + isList: true, + namespace: plan?.metadata?.namespace, + }); + + // Retrieve all k8s Network Mappings + const [networkMaps, networkMapsLoaded, networkMapsError] = useK8sWatchResource< + V1beta1NetworkMap[] + >({ + groupVersionKind: NetworkMapModelGroupVersionKind, + namespaced: true, + isList: true, + namespace: plan?.metadata?.namespace, + }); + + // Retrieve all k8s Storage Mappings + const [storageMaps, storageMapsLoaded, storageMapsError] = useK8sWatchResource< + V1beta1StorageMap[] + >({ + groupVersionKind: StorageMapModelGroupVersionKind, + namespaced: true, + isList: true, + namespace: plan?.metadata?.namespace, + }); + + const [vmData] = useInventoryVms( + { provider: sourceProvider }, + sourceProviderLoaded, + sourceProviderLoadError, + ); + const initialSelectedIds = plan.spec.vms.map((specVm) => specVm.id); + const selectedVMs = vmData.filter((vm) => initialSelectedIds.includes(vm.vm.id)); + + const planNetworkMaps = networkMaps + ? networkMaps.find((net) => net?.metadata?.name === plan?.spec?.map?.network?.name) + : null; + const planStorageMaps = storageMaps + ? storageMaps.find((storage) => storage?.metadata?.name === plan.spec.map?.storage?.name) + : null; + + const finishedLoading = + providersLoaded && + sourceProviderLoaded && + targetProviderLoaded && + networkMapsLoaded && + storageMapsLoaded && + vmData.length > 0; + const hasErrors = + providersLoadError || + sourceProviderLoadError || + targetProviderLoadError || + networkMapsError || + storageMapsError; + + return ( + + {hasErrors && ( +
+ + {t( + 'Something is wrong, the data was not loaded due to an error, please try to reload the page.', + )} + +
+ )} + {!hasErrors && finishedLoading ? ( + + ) : ( +
+ {t('Data is loading, please wait.')} +
+ )} +
+ ); +}; diff --git a/packages/forklift-console-plugin/src/modules/Plans/views/details/tabs/VirtualMachines/modals/index.ts b/packages/forklift-console-plugin/src/modules/Plans/views/details/tabs/VirtualMachines/modals/index.ts index d40f0e3ef..3af1d27c3 100644 --- a/packages/forklift-console-plugin/src/modules/Plans/views/details/tabs/VirtualMachines/modals/index.ts +++ b/packages/forklift-console-plugin/src/modules/Plans/views/details/tabs/VirtualMachines/modals/index.ts @@ -2,4 +2,5 @@ export * from './MigrationVMsCancelModal'; export * from './PipelineTasksModal'; export * from './PlanVMsDeleteModal'; +export * from './PlanVMsEditModal'; // @endindex diff --git a/packages/forklift-console-plugin/src/modules/Plans/views/details/utils/patchPlanMappingsData.ts b/packages/forklift-console-plugin/src/modules/Plans/views/details/utils/patchPlanMappingsData.ts index 7a254bfda..9c02fd2a1 100644 --- a/packages/forklift-console-plugin/src/modules/Plans/views/details/utils/patchPlanMappingsData.ts +++ b/packages/forklift-console-plugin/src/modules/Plans/views/details/utils/patchPlanMappingsData.ts @@ -23,7 +23,7 @@ export async function patchPlanMappingsData( updatedNetwork: V1beta1NetworkMapSpecMap[], updatedStorage: V1beta1StorageMapSpecMap[], ) { - await k8sPatch({ + const updatedNetworkMap = await k8sPatch({ model: NetworkMapModel, resource: planNetworkMaps, data: [ @@ -35,7 +35,7 @@ export async function patchPlanMappingsData( ], }); - await k8sPatch({ + const updatedStorageMap = await k8sPatch({ model: StorageMapModel, resource: planStorageMaps, data: [ @@ -46,6 +46,11 @@ export async function patchPlanMappingsData( }, ], }); + + return { + updatedNetworkMap, + updatedStorageMap, + }; } /** diff --git a/packages/forklift-console-plugin/src/modules/Plans/views/edit/PlanEditPage.tsx b/packages/forklift-console-plugin/src/modules/Plans/views/edit/PlanEditPage.tsx new file mode 100644 index 000000000..cebd185bd --- /dev/null +++ b/packages/forklift-console-plugin/src/modules/Plans/views/edit/PlanEditPage.tsx @@ -0,0 +1,168 @@ +import React, { useEffect, useReducer } from 'react'; +import { useHistory } from 'react-router'; +import { PlanEditAction } from 'src/modules/Plans/utils/types/PlanEditAction'; +import { + planMappingsSectionReducer, + PlanMappingsSectionState, +} from 'src/modules/Plans/views/details/tabs/Mappings/PlanMappingsSection'; +import { VmData } from 'src/modules/Providers/views/details/tabs/VirtualMachines/components/VMCellProps'; +import ProvidersUpdateVmMigrationPage from 'src/modules/Providers/views/migrate/ProvidersUpdateVmMigrationPage'; +import { startUpdate } from 'src/modules/Providers/views/migrate/reducer/actions'; +import { useFetchEffects } from 'src/modules/Providers/views/migrate/useFetchEffects'; +import { useUpdateEffect } from 'src/modules/Providers/views/migrate/useUpdateEffect'; +import { useForkliftTranslation } from 'src/utils/i18n'; + +import { V1beta1NetworkMap, V1beta1Plan, V1beta1Provider, V1beta1StorageMap } from '@kubev2v/types'; +import { PageSection, Title } from '@patternfly/react-core'; +import { Wizard } from '@patternfly/react-core/deprecated'; + +import { findProviderByID } from '../create/components'; +import { planCreatePageInitialState, planCreatePageReducer } from '../create/states'; +import { SelectSourceProvider } from '../create/steps'; + +import '../create/PlanCreatePage.style.css'; + +export const PlanEditPage: React.FC<{ + plan: V1beta1Plan; + providers: V1beta1Provider[]; + sourceProvider: V1beta1Provider; + targetProvider: V1beta1Provider; + namespace: string; + onClose?: () => void; + selectedVMs?: VmData[]; + editAction: PlanEditAction; + planNetworkMaps: V1beta1NetworkMap; + planStorageMaps: V1beta1StorageMap; +}> = ({ + plan, + providers, + sourceProvider, + targetProvider, + namespace, + onClose, + selectedVMs, + editAction, + planNetworkMaps, + planStorageMaps, +}) => { + const { t } = useForkliftTranslation(); + const history = useHistory(); + const startAtStep = 1; + + // Init Select source provider form state + const [filterState, filterDispatch] = useReducer(planCreatePageReducer, { + ...planCreatePageInitialState, + selectedProviderUID: sourceProvider.metadata.uid, + selectedVMs: selectedVMs, + }); + + const selectedProvider = + filterState.selectedProviderUID !== '' + ? findProviderByID(filterState.selectedProviderUID, providers) + : undefined; + + const [state, dispatch, emptyContext] = useFetchEffects({ + data: { + selectedVms: filterState.selectedVMs, + provider: selectedProvider, + targetProvider, + plan, + editAction, + }, + }); + + const initialPlanMappingsState: PlanMappingsSectionState = { + edit: true, + dataChanged: false, + alertMessage: null, + updatedNetwork: planNetworkMaps?.spec?.map || [], + updatedStorage: planStorageMaps?.spec?.map || [], + planNetworkMaps: planNetworkMaps, + planStorageMaps: planStorageMaps, + editAction, + }; + + const [planMappingsState, planMappingsDispatch] = useReducer( + planMappingsSectionReducer, + initialPlanMappingsState, + ); + + useEffect(() => { + if (planNetworkMaps && planStorageMaps) { + planMappingsDispatch({ + type: 'SET_PLAN_MAPS', + payload: { planNetworkMaps, planStorageMaps }, + }); + } + }, [planNetworkMaps, planStorageMaps]); + + useUpdateEffect(state, dispatch, planMappingsState); + + const steps = [ + { + id: 'step-1', + name: editAction === 'VMS' ? t('Select virtual machines') : t('Select source provider'), + component: ( + + ), + enableNext: filterState?.selectedVMs?.length > 0, + }, + { + id: 'step-2', + name: editAction === 'VMS' ? t('Update mappings') : t('Update migration plan'), + component: ( + + ), + enableNext: + !emptyContext && + !( + !!state?.flow?.apiError || + Object.values(state?.validation || []).some((validation) => validation === 'error') + ), + canJumpTo: filterState?.selectedVMs?.length > 0, + nextButtonText: + editAction === 'VMS' ? t('Update virtual machines') : t('Update migration plan'), + }, + ]; + + const goBack = () => history.goBack(); + const title = 'Plans wizard'; + return ( + <> + + + {editAction === 'VMS' ? 'Update virtual machines' : 'Update migration plan'} + + + + + dispatch(startUpdate())} + onClose={onClose || goBack} + startAtStep={startAtStep} + /> + + + ); +}; + +export default PlanEditPage; diff --git a/packages/forklift-console-plugin/src/modules/Providers/views/migrate/ProvidersCreateVmMigrationContext.tsx b/packages/forklift-console-plugin/src/modules/Providers/views/migrate/ProvidersCreateVmMigrationContext.tsx index c520e9a1e..e2679fdf0 100644 --- a/packages/forklift-console-plugin/src/modules/Providers/views/migrate/ProvidersCreateVmMigrationContext.tsx +++ b/packages/forklift-console-plugin/src/modules/Providers/views/migrate/ProvidersCreateVmMigrationContext.tsx @@ -8,14 +8,18 @@ import { useState, } from 'react'; import { produce } from 'immer'; +import { PlanEditAction } from 'src/modules/Plans/utils/types/PlanEditAction'; -import { V1beta1Provider } from '@kubev2v/types'; +import { V1beta1Plan, V1beta1Provider } from '@kubev2v/types'; import { VmData } from '../details'; export interface CreateVmMigrationContextData { selectedVms: VmData[]; provider?: V1beta1Provider; + targetProvider?: V1beta1Provider; + plan?: V1beta1Plan; + editAction?: PlanEditAction; } export interface CreateVmMigrationContextType { diff --git a/packages/forklift-console-plugin/src/modules/Providers/views/migrate/ProvidersUpdateVmMigrationPage.tsx b/packages/forklift-console-plugin/src/modules/Providers/views/migrate/ProvidersUpdateVmMigrationPage.tsx new file mode 100644 index 000000000..ccf5a7a19 --- /dev/null +++ b/packages/forklift-console-plugin/src/modules/Providers/views/migrate/ProvidersUpdateVmMigrationPage.tsx @@ -0,0 +1,63 @@ +import React from 'react'; +import { PlanMappingsSectionState } from 'src/modules/Plans/views/details/tabs/Mappings/PlanMappingsSection'; + +import { LoadingDots } from '@kubev2v/common'; +import { V1beta1NetworkMap, V1beta1StorageMap } from '@kubev2v/types'; +import { Alert } from '@patternfly/react-core'; + +import { PlansUpdateForm } from './components/PlansUpdateForm'; +import { CreateVmMigration, PageAction } from './reducer/actions'; +import { isDone } from './reducer/helpers'; +import { CreateVmMigrationPageState } from './types'; + +const ProvidersUpdateVmMigrationPage: React.FC<{ + state: CreateVmMigrationPageState; + dispatch: React.Dispatch>; + emptyContext: boolean; + planMappingsState: PlanMappingsSectionState; + planMappingsDispatch: React.Dispatch<{ + type: string; + payload?; + }>; + planNetworkMaps: V1beta1NetworkMap; + planStorageMaps: V1beta1StorageMap; +}> = ({ + state, + dispatch, + emptyContext, + planMappingsState, + planMappingsDispatch, + planNetworkMaps, + planStorageMaps, +}) => { + if (emptyContext) { + // display empty node and wait for redirect triggered from useEffect + // the redirect should be triggered right after the first render() + // so any "empty page" would only "blink" + return <>; + } + + if (!isDone(state.flow.initialLoading) && !state.flow.apiError) { + return ; + } + + const FormAlerts = state.flow.apiError && ( + + {state?.flow?.apiError?.message || state?.flow?.apiError?.toString()} + + ); + + return ( + + ); +}; + +export default ProvidersUpdateVmMigrationPage; diff --git a/packages/forklift-console-plugin/src/modules/Providers/views/migrate/components/PlansUpdateForm.tsx b/packages/forklift-console-plugin/src/modules/Providers/views/migrate/components/PlansUpdateForm.tsx new file mode 100644 index 000000000..80f3c918f --- /dev/null +++ b/packages/forklift-console-plugin/src/modules/Providers/views/migrate/components/PlansUpdateForm.tsx @@ -0,0 +1,205 @@ +import React, { ReactNode } from 'react'; +import { FilterableSelect } from 'src/components'; +import SectionHeading from 'src/components/headers/SectionHeading'; +import { PlanMappingsInitSection } from 'src/modules/Plans/views/details/tabs/Mappings/PlanMappings'; +import { PlanMappingsSectionState } from 'src/modules/Plans/views/details/tabs/Mappings/PlanMappingsSection'; +import { useForkliftTranslation } from 'src/utils/i18n'; + +import { FormGroupWithHelpText } from '@kubev2v/common'; +import { + ProviderModelGroupVersionKind, + V1beta1NetworkMap, + V1beta1StorageMap, +} from '@kubev2v/types'; +import { ResourceLink } from '@openshift-console/dynamic-plugin-sdk'; +import { + DescriptionList, + DescriptionListDescription, + DescriptionListGroup, + DescriptionListTerm, + Form, + FormSelect, + FormSelectOption, +} from '@patternfly/react-core'; + +import { DetailsItem, getIsTarget } from '../../../utils'; +import { PageAction, setPlanTargetNamespace, setPlanTargetProvider } from '../reducer/actions'; +import { CreateVmMigrationPageState } from '../types'; + +export type PlansUpdateFormProps = { + children?: ReactNode; + formAlerts?: ReactNode; + formActions?: ReactNode; + state: CreateVmMigrationPageState; + dispatch: (action: PageAction) => void; + planMappingsState: PlanMappingsSectionState; + planMappingsDispatch: React.Dispatch<{ + type: string; + payload?; + }>; + planNetworkMaps: V1beta1NetworkMap; + planStorageMaps: V1beta1StorageMap; +}; + +export const PlansUpdateForm = ({ + children, + state, + dispatch, + formAlerts, + formActions, + planMappingsState, + planMappingsDispatch, + planNetworkMaps, + planStorageMaps, +}: PlansUpdateFormProps) => { + const { t } = useForkliftTranslation(); + + const { + underConstruction: { plan }, + validation, + calculatedOnce: { namespacesUsedBySelectedVms }, + existingResources: { + providers: availableProviders, + targetNamespaces: availableTargetNamespaces, + }, + flow, + } = state; + + const onChangeTargetProvider: ( + value: string, + event: React.FormEvent, + ) => void = (value) => { + dispatch(setPlanTargetProvider(value)); + }; + + const mappingsSection = ( + + ); + + return ( + <> + {children} + + {flow.editAction !== 'VMS' && ( + <> + + {t('Plan name')} + +
{plan.metadata.name}
+
+
+ + + + + } + /> + + {t('Selected VMs')} + + {t('{{vmCount}} VMs selected ', { vmCount: plan.spec.vms?.length ?? 0 })} + + + + + +
+ + onChangeTargetProvider(v, e)} + id="targetProvider" + isDisabled={flow.editingDone} + > + {[ + , + ...availableProviders + .filter(getIsTarget) + .map((provider, index) => ( + + )), + ]} + + +
+ +
+ + ({ + itemId: ns?.name, + isDisabled: + namespacesUsedBySelectedVms.includes(ns?.name) && + plan.spec.provider?.destination?.name === plan.spec.provider.source.name, + children: ns?.name, + }))} + value={plan.spec.targetNamespace} + onSelect={(value) => dispatch(setPlanTargetNamespace(value as string))} + isDisabled={flow.editingDone} + isScrollable + canCreate + createNewOptionLabel={t('Create new namespace:')} + /> + +
+ + )} + {mappingsSection} +
+ {formAlerts} +
{formActions}
+ + ); +}; diff --git a/packages/forklift-console-plugin/src/modules/Providers/views/migrate/reducer/actions.ts b/packages/forklift-console-plugin/src/modules/Providers/views/migrate/reducer/actions.ts index a17ed70fa..1162f2ef6 100644 --- a/packages/forklift-console-plugin/src/modules/Providers/views/migrate/reducer/actions.ts +++ b/packages/forklift-console-plugin/src/modules/Providers/views/migrate/reducer/actions.ts @@ -43,6 +43,7 @@ export const SET_NICK_PROFILES = 'SET_NICK_PROFILES'; export const SET_DISKS = 'SET_DISKS'; export const SET_EXISTING_NET_MAPS = 'SET_EXISTING_NET_MAPS'; export const SET_EXISTING_STORAGE_MAPS = 'SET_EXISTING_STORAGE_MAPS'; +export const START_UPDATE = 'START_UPDATE'; export const START_CREATE = 'START_CREATE'; export const SET_API_ERROR = 'SET_API_ERROR'; export const REMOVE_ALERT = 'REMOVE_ALERT'; @@ -67,6 +68,7 @@ export type CreateVmMigration = | typeof SET_NICK_PROFILES | typeof SET_DISKS | typeof SET_EXISTING_NET_MAPS + | typeof START_UPDATE | typeof START_CREATE | typeof SET_API_ERROR | typeof SET_EXISTING_STORAGE_MAPS @@ -368,6 +370,11 @@ export const setDisks = ( payload: { disks, loading, error }, }); +export const startUpdate = (): PageAction => ({ + type: 'START_UPDATE', + payload: {}, +}); + export const startCreate = (): PageAction => ({ type: 'START_CREATE', payload: {}, @@ -389,11 +396,15 @@ export const initState = ( namespace: string, sourceProvider: V1beta1Provider, selectedVms: VmData[], + plan?: V1beta1Plan, + targetProvider?: V1beta1Provider, ): PageAction => ({ type: 'INIT', payload: { namespace, sourceProvider, + targetProvider, selectedVms, + plan, }, }); diff --git a/packages/forklift-console-plugin/src/modules/Providers/views/migrate/reducer/createInitialState.ts b/packages/forklift-console-plugin/src/modules/Providers/views/migrate/reducer/createInitialState.ts index 123f0c56a..dbba75dc9 100644 --- a/packages/forklift-console-plugin/src/modules/Providers/views/migrate/reducer/createInitialState.ts +++ b/packages/forklift-console-plugin/src/modules/Providers/views/migrate/reducer/createInitialState.ts @@ -1,6 +1,9 @@ +import { PlanEditAction } from 'src/modules/Plans/utils/types/PlanEditAction'; + import { ProviderModelGroupVersionKind as ProviderGVK, ProviderType, + V1beta1Plan, V1beta1Provider, } from '@kubev2v/types'; @@ -23,7 +26,10 @@ import { getObjectRef, resourceFieldsForType } from './helpers'; export type InitialStateParameters = { namespace: string; sourceProvider: V1beta1Provider; + targetProvider?: V1beta1Provider; selectedVms: VmData[]; + plan?: V1beta1Plan; + editAction?: PlanEditAction; }; export const createInitialState = ({ @@ -33,24 +39,26 @@ export const createInitialState = ({ apiVersion: `${ProviderGVK.group}/${ProviderGVK.version}`, kind: ProviderGVK.kind, }, + targetProvider, selectedVms = [], + plan = planTemplate, + editAction, }: InitialStateParameters): CreateVmMigrationPageState => { const hasVmNicWithEmptyProfile = hasNicWithEmptyProfile(sourceProvider, selectedVms); - return { underConstruction: { plan: { - ...planTemplate, + ...plan, metadata: { - ...planTemplate?.metadata, - name: '', + ...plan.metadata, + name: plan.metadata.name || '', namespace, }, spec: { - ...planTemplate?.spec, + ...plan?.spec, provider: { source: getObjectRef(sourceProvider), - destination: undefined, + destination: targetProvider ? getObjectRef(targetProvider) : undefined, }, targetNamespace: namespace, vms: selectedVms.map((data) => ({ @@ -71,7 +79,7 @@ export const createInitialState = ({ ...networkMapTemplate?.spec, provider: { source: getObjectRef(sourceProvider), - destination: undefined, + destination: targetProvider ? getObjectRef(targetProvider) : undefined, }, }, }, @@ -86,7 +94,7 @@ export const createInitialState = ({ ...storageMapTemplate?.spec, provider: { source: getObjectRef(sourceProvider), - destination: undefined, + destination: targetProvider ? getObjectRef(targetProvider) : undefined, }, }, }, @@ -109,6 +117,7 @@ export const createInitialState = ({ selectedVms, sourceProvider, namespace, + plan, }, validation: { planName: 'default', @@ -161,6 +170,7 @@ export const createInitialState = ({ [SET_DISKS]: !['ovirt', 'openstack'].includes(sourceProvider.spec?.type), [SET_NICK_PROFILES]: sourceProvider.spec?.type !== 'ovirt', }, + editAction, }, }; }; diff --git a/packages/forklift-console-plugin/src/modules/Providers/views/migrate/reducer/helpers.ts b/packages/forklift-console-plugin/src/modules/Providers/views/migrate/reducer/helpers.ts index 470d00d55..883228ef8 100644 --- a/packages/forklift-console-plugin/src/modules/Providers/views/migrate/reducer/helpers.ts +++ b/packages/forklift-console-plugin/src/modules/Providers/views/migrate/reducer/helpers.ts @@ -45,14 +45,23 @@ import { hasMultiplePodNetworkMappings } from './hasMultiplePodNetworkMappings'; export const validateUniqueName = (name: string, existingNames: string[]) => existingNames.every((existingName) => existingName !== name); -export const validatePlanName = (name: string, existingPlans: V1beta1Plan[]) => - validateK8sName(name) && - validateUniqueName( - name, - existingPlans.map((plan) => plan?.metadata?.name ?? ''), - ) +export const validatePlanName = ( + name: string, + existingPlans: V1beta1Plan[], + editingPlan?: boolean, +) => { + if (editingPlan) { + return validateK8sName(name) ? 'success' : 'error'; + } + + return validateK8sName(name) && + validateUniqueName( + name, + existingPlans.map((plan) => plan?.metadata?.name ?? ''), + ) ? 'success' : 'error'; +}; export const validateTargetNamespace = (namespace: string, alreadyInUseBySelectedVms: boolean) => !!namespace && validateK8sName(namespace) && !alreadyInUseBySelectedVms ? 'success' : 'error'; diff --git a/packages/forklift-console-plugin/src/modules/Providers/views/migrate/reducer/reducer.ts b/packages/forklift-console-plugin/src/modules/Providers/views/migrate/reducer/reducer.ts index a616f316f..71bb0b451 100644 --- a/packages/forklift-console-plugin/src/modules/Providers/views/migrate/reducer/reducer.ts +++ b/packages/forklift-console-plugin/src/modules/Providers/views/migrate/reducer/reducer.ts @@ -50,6 +50,7 @@ import { SET_TARGET_NAMESPACE, SET_TARGET_PROVIDER, START_CREATE, + START_UPDATE, } from './actions'; import { addMapping, deleteMapping, replaceMapping } from './changeMapping'; import { createInitialState, InitialStateParameters } from './createInitialState'; @@ -80,7 +81,11 @@ const handlers: { } = { [SET_NAME](draft, { payload: { name } }: PageAction) { draft.underConstruction.plan.metadata.name = name; - draft.validation.planName = validatePlanName(name, draft.existingResources.plans); + draft.validation.planName = validatePlanName( + name, + draft.existingResources.plans, + Boolean(draft.flow.editAction), + ); }, [SET_TARGET_NAMESPACE]( draft, @@ -135,6 +140,7 @@ const handlers: { draft.validation.planName = validatePlanName( draft.underConstruction.plan.metadata.name, existingPlans, + Boolean(draft.flow.editAction), ); }, [SET_AVAILABLE_TARGET_NAMESPACES]( @@ -289,6 +295,9 @@ const handlers: { // triggered from useEffect on any data change existingResources.storageMaps = existingStorageMaps; }, + [START_UPDATE]({ flow }) { + flow.editingDone = true; + }, [START_CREATE]({ flow, receivedAsParams: { sourceProvider }, @@ -472,10 +481,16 @@ const handlers: { [INIT]( draft, { - payload: { namespace, sourceProvider, selectedVms }, + payload: { namespace, sourceProvider, targetProvider, selectedVms, plan }, }: PageAction, ) { - const newDraft = createInitialState({ namespace, sourceProvider, selectedVms }); + const newDraft = createInitialState({ + namespace, + sourceProvider, + targetProvider, + selectedVms, + plan, + }); draft.underConstruction = newDraft.underConstruction; draft.calculatedOnce = newDraft.calculatedOnce; diff --git a/packages/forklift-console-plugin/src/modules/Providers/views/migrate/types.ts b/packages/forklift-console-plugin/src/modules/Providers/views/migrate/types.ts index 615b16c91..ef45a7dc9 100644 --- a/packages/forklift-console-plugin/src/modules/Providers/views/migrate/types.ts +++ b/packages/forklift-console-plugin/src/modules/Providers/views/migrate/types.ts @@ -1,4 +1,5 @@ import { FC } from 'react'; +import { PlanEditAction } from 'src/modules/Plans/utils/types/PlanEditAction'; import { ResourceFieldFactory, RowProps } from '@kubev2v/common'; import { @@ -87,6 +88,7 @@ export interface CreateVmMigrationPageState { selectedVms: VmData[]; sourceProvider: V1beta1Provider; namespace: string; + plan?: V1beta1Plan; }; // placeholder for helper data workArea: { @@ -96,6 +98,7 @@ export interface CreateVmMigrationPageState { editingDone: boolean; apiError?: Error; initialLoading: { [keys in CreateVmMigration]?: boolean }; + editAction?: PlanEditAction; }; } export interface MappingSource { diff --git a/packages/forklift-console-plugin/src/modules/Providers/views/migrate/useFetchEffects.ts b/packages/forklift-console-plugin/src/modules/Providers/views/migrate/useFetchEffects.ts index af3059a86..6940cdd62 100644 --- a/packages/forklift-console-plugin/src/modules/Providers/views/migrate/useFetchEffects.ts +++ b/packages/forklift-console-plugin/src/modules/Providers/views/migrate/useFetchEffects.ts @@ -44,7 +44,13 @@ import { CreateVmMigrationPageState } from './types'; export const useFetchEffects = ( createVmMigrationContext: CreateVmMigrationContextType, ): [CreateVmMigrationPageState, Dispatch>, boolean] => { - const { selectedVms, provider: sourceProvider } = createVmMigrationContext?.data || {}; + const { + selectedVms, + provider: sourceProvider, + targetProvider: tProvider, + plan, + editAction, + } = createVmMigrationContext?.data || {}; // error state - the page was entered directly without choosing the VMs const emptyContext = !selectedVms?.length || !sourceProvider; @@ -52,7 +58,7 @@ export const useFetchEffects = ( const [state, dispatch] = useImmerReducer( reducer, - { namespace, sourceProvider, selectedVms }, + { namespace, sourceProvider, targetProvider: tProvider, selectedVms, plan, editAction }, createInitialState, ); @@ -73,7 +79,8 @@ export const useFetchEffects = ( }; useEffect( - () => !editingDone && dispatch(initState(namespace, sourceProvider, selectedVms)), + () => + !editingDone && dispatch(initState(namespace, sourceProvider, selectedVms, plan, tProvider)), [selectedVms], ); diff --git a/packages/forklift-console-plugin/src/modules/Providers/views/migrate/useUpdateEffect.ts b/packages/forklift-console-plugin/src/modules/Providers/views/migrate/useUpdateEffect.ts new file mode 100644 index 000000000..76b69dd3d --- /dev/null +++ b/packages/forklift-console-plugin/src/modules/Providers/views/migrate/useUpdateEffect.ts @@ -0,0 +1,155 @@ +import { useEffect, useRef } from 'react'; +import { useHistory } from 'react-router'; +import { produce } from 'immer'; +import { PlanMappingsSectionState } from 'src/modules/Plans/views/details/tabs/Mappings/PlanMappingsSection'; +import { patchPlanMappingsData } from 'src/modules/Plans/views/details/utils/patchPlanMappingsData'; +import { deepCopy } from 'src/utils'; + +import { + NetworkMapModel, + PlanModel, + PlanModelRef, + StorageMapModel, + V1beta1NetworkMap, + V1beta1NetworkMapSpecMap, + V1beta1Plan, + V1beta1StorageMap, + V1beta1StorageMapSpecMap, +} from '@kubev2v/types'; +import { K8sModel, k8sPatch, k8sUpdate } from '@openshift-console/dynamic-plugin-sdk'; + +import { getResourceUrl } from '../../utils'; + +import { setAPiError } from './reducer/actions'; +import { getObjectRef } from './reducer/helpers'; +import { CreateVmMigrationPageState } from './types'; + +const updatePlan = async ( + plan: V1beta1Plan, + netMap: V1beta1NetworkMap, + storageMap: V1beta1StorageMap, +) => { + const updatedPlan = await k8sUpdate({ + model: PlanModel, + data: plan, + }); + const ownerReferences = [getObjectRef(updatedPlan)]; + return [ownerReferences, netMap, storageMap]; +}; + +const addOwnerRef = async (model: K8sModel, resource, ownerReferences) => { + const cleanOwnerReferences = ownerReferences.map((ref) => ({ + ...ref, + namespace: undefined, + })); + + return await k8sPatch({ + model, + resource, + data: [ + { + op: 'add', + path: '/metadata/ownerReferences', + value: cleanOwnerReferences, + }, + ], + }); +}; + +export const useUpdateEffect = ( + state: CreateVmMigrationPageState, + dispatch, + planMappingsState: PlanMappingsSectionState, +) => { + const history = useHistory(); + const mounted = useRef(true); + useEffect( + () => () => { + mounted.current = false; + }, + [], + ); + + useEffect(() => { + const { + flow, + underConstruction: { plan }, + } = state; + if (!flow.editingDone || !mounted.current) { + return; + } + + Promise.all([ + updateMappings( + planMappingsState.planNetworkMaps, + planMappingsState.planStorageMaps, + planMappingsState.updatedNetwork, + planMappingsState.updatedStorage, + ), + ]) + .then(([{ updatedNetworkMap, updatedStorageMap }]) => { + return updatePlan( + produce(plan, (draft) => { + draft.spec.map.network = getObjectRef(updatedNetworkMap); + draft.spec.map.storage = getObjectRef(updatedStorageMap); + }), + updatedNetworkMap, + updatedStorageMap, + ); + }) + .then(([ownerReferences, networkMap, storageMap]) => + Promise.all([ + addOwnerRef(StorageMapModel, storageMap, ownerReferences), + addOwnerRef(NetworkMapModel, networkMap, ownerReferences), + ]), + ) + .then( + () => + mounted.current && + history.push( + getResourceUrl({ + reference: PlanModelRef, + namespace: plan.metadata.namespace, + name: plan.metadata.name, + }), + ), + ) + .catch((error) => mounted.current && dispatch(setAPiError(error))); + }, [state.flow.editingDone]); +}; + +/** + * Updates the destination name and namespace in the network map entries. + * If the destination name contains a '/', it splits the name into two parts. + * The first part becomes the new namespace, and the second part becomes the new name. + * + * @param {NetworkMap} networkMap - The network map object to update. + * @returns {NetworkMap} The updated network map object. + */ +export function updateNetworkMapDestination(networkMap: V1beta1NetworkMap): V1beta1NetworkMap { + const networkMapCopy = deepCopy(networkMap); + + networkMapCopy.spec.map?.forEach((entry) => { + const parts = entry?.destination?.name?.split('/'); + if (parts?.length === 2) { + entry.destination.namespace = parts[0]; + entry.destination.name = parts[1]; + } + }); + return networkMapCopy; +} + +async function updateMappings( + planNetworkMaps: V1beta1NetworkMap, + planStorageMaps: V1beta1StorageMap, + updatedNetwork: V1beta1NetworkMapSpecMap[], + updatedStorage: V1beta1StorageMapSpecMap[], +) { + const { updatedNetworkMap, updatedStorageMap } = await patchPlanMappingsData( + planNetworkMaps, + planStorageMaps, + updatedNetwork, + updatedStorage, + ); + return { updatedNetworkMap, updatedStorageMap }; +}