diff --git a/app/javascript/react/screens/App/Settings/Settings.js b/app/javascript/react/screens/App/Settings/Settings.js index 63be6ed6c6..f6b3a94b4a 100644 --- a/app/javascript/react/screens/App/Settings/Settings.js +++ b/app/javascript/react/screens/App/Settings/Settings.js @@ -22,7 +22,7 @@ const Settings = props => { ) : (
- redirectTo(key)}> + redirectTo(key)} unmountOnExit> diff --git a/app/javascript/react/screens/App/Settings/Settings.scss b/app/javascript/react/screens/App/Settings/Settings.scss index c26302c5aa..89c2afa851 100644 --- a/app/javascript/react/screens/App/Settings/Settings.scss +++ b/app/javascript/react/screens/App/Settings/Settings.scss @@ -5,3 +5,12 @@ .conversion-hosts-list .list-view-pf-main-info { padding: 10px 0; } + +.conversion-hosts-list .spinner.spinner-inline { + margin-right: 10px; +} + +.conversion-hosts-list-actions { + min-width: 150px; + text-align: right; +} diff --git a/app/javascript/react/screens/App/Settings/SettingsActions.js b/app/javascript/react/screens/App/Settings/SettingsActions.js index 5ae2fc829a..b60b2b3a01 100644 --- a/app/javascript/react/screens/App/Settings/SettingsActions.js +++ b/app/javascript/react/screens/App/Settings/SettingsActions.js @@ -7,6 +7,7 @@ import { V2V_FETCH_SETTINGS, V2V_PATCH_SETTINGS, FETCH_V2V_CONVERSION_HOSTS, + FETCH_V2V_CONVERSION_HOST_TASKS, SHOW_V2V_CONVERSION_HOST_WIZARD, HIDE_V2V_CONVERSION_HOST_WIZARD, V2V_CONVERSION_HOST_WIZARD_EXITED, @@ -69,6 +70,17 @@ export const fetchConversionHostsAction = url => { return _getConversionHostsActionCreator(uri.toString()); }; +const _getConversionHostTasksActionCreator = url => dispatch => + dispatch({ + type: FETCH_V2V_CONVERSION_HOST_TASKS, + payload: API.get(url) + }); + +export const fetchConversionHostTasksAction = url => { + const uri = new URI(url); + return _getConversionHostTasksActionCreator(uri.toString()); +}; + export const showConversionHostWizardAction = () => dispatch => dispatch({ type: SHOW_V2V_CONVERSION_HOST_WIZARD }); export const hideConversionHostWizardAction = () => dispatch => dispatch({ type: HIDE_V2V_CONVERSION_HOST_WIZARD }); diff --git a/app/javascript/react/screens/App/Settings/SettingsConstants.js b/app/javascript/react/screens/App/Settings/SettingsConstants.js index 0a4a13823c..74182e96cc 100644 --- a/app/javascript/react/screens/App/Settings/SettingsConstants.js +++ b/app/javascript/react/screens/App/Settings/SettingsConstants.js @@ -2,6 +2,7 @@ export const V2V_FETCH_SERVERS = 'V2V_FETCH_SERVERS'; export const V2V_FETCH_SETTINGS = 'V2V_FETCH_SETTINGS'; export const V2V_PATCH_SETTINGS = 'V2V_PATCH_SETTINGS'; export const FETCH_V2V_CONVERSION_HOSTS = 'FETCH_V2V_CONVERSION_HOSTS'; +export const FETCH_V2V_CONVERSION_HOST_TASKS = 'FETCH_V2V_CONVERSION_HOST_TASKS'; export const SHOW_V2V_CONVERSION_HOST_WIZARD = 'SHOW_V2V_CONVERSION_HOST_WIZARD'; export const HIDE_V2V_CONVERSION_HOST_WIZARD = 'HIDE_V2V_CONVERSION_HOST_WIZARD'; export const V2V_CONVERSION_HOST_WIZARD_EXITED = 'V2V_CONVERSION_HOST_WIZARD_EXITED'; diff --git a/app/javascript/react/screens/App/Settings/SettingsReducer.js b/app/javascript/react/screens/App/Settings/SettingsReducer.js index 1d5d9c7a49..514a4630c0 100644 --- a/app/javascript/react/screens/App/Settings/SettingsReducer.js +++ b/app/javascript/react/screens/App/Settings/SettingsReducer.js @@ -5,6 +5,7 @@ import { V2V_FETCH_SETTINGS, V2V_PATCH_SETTINGS, FETCH_V2V_CONVERSION_HOSTS, + FETCH_V2V_CONVERSION_HOST_TASKS, SHOW_V2V_CONVERSION_HOST_WIZARD, HIDE_V2V_CONVERSION_HOST_WIZARD, V2V_CONVERSION_HOST_WIZARD_EXITED, @@ -15,15 +16,23 @@ import { DELETE_V2V_CONVERSION_HOST } from './SettingsConstants'; -import { getFormValuesFromApiSettings } from './helpers'; +import { + getFormValuesFromApiSettings, + parseConversionHostTasksMetadata, + indexConversionHostTasksByResource +} from './helpers'; export const initialState = Immutable({ conversionHosts: [], + conversionHostTasks: [], + conversionHostTasksByResource: {}, conversionHostToDelete: null, + conversionHostDeleteModalVisible: false, conversionHostWizardMounted: false, conversionHostWizardVisible: false, errorDeleteConversionHost: false, errorFetchingConversionHosts: null, + errorFetchingConversionHostTasks: null, errorFetchingServers: null, errorFetchingSettings: null, errorPostingConversionHosts: null, @@ -32,18 +41,19 @@ export const initialState = Immutable({ fetchingSettingsRejected: false, isDeletingConversionHost: false, isFetchingConversionHosts: false, + isFetchingConversionHostTasks: false, isFetchingServers: false, isFetchingSettings: false, - isRejectedConversionHost: false, - isRejectedConversionHosts: false, isPostingConversionHosts: false, + isRejectedDeletingConversionHost: false, + isRejectedFetchingConversionHosts: false, + isRejectedFetchingConversionHostTasks: false, isRejectedPostingConversionHosts: false, isSavingSettings: false, postConversionHostsResults: [], savedSettings: {}, savingSettingsRejected: false, - servers: [], - showConversionHostDeleteModal: false + servers: [] }); export default (state = initialState, action) => { @@ -102,22 +112,41 @@ export default (state = initialState, action) => { case `${FETCH_V2V_CONVERSION_HOSTS}_PENDING`: return state .set('isFetchingConversionHosts', true) - .set('isRejectedConversionHosts', false) + .set('isRejectedFetchingConversionHosts', false) .set('errorFetchingConversionHosts', null); case `${FETCH_V2V_CONVERSION_HOSTS}_FULFILLED`: return state .set('conversionHosts', action.payload.data.resources) .set('isFetchingConversionHosts', false) - .set('isRejectedConversionHosts', false) - .set('showConversionHostDeleteModal', false) + .set('isRejectedFetchingConversionHosts', false) .set('errorFetchingConversionHosts', null); case `${FETCH_V2V_CONVERSION_HOSTS}_REJECTED`: return state .set('isFetchingConversionHosts', false) - .set('isRejectedConversionHosts', true) - .set('showConversionHostDeleteModal', false) + .set('isRejectedFetchingConversionHosts', true) .set('errorFetchingConversionHosts', action.payload); + case `${FETCH_V2V_CONVERSION_HOST_TASKS}_PENDING`: + return state + .set('isFetchingConversionHostTasks', true) + .set('isRejectedFetchingConversionHostTasks', false) + .set('errorFetchingConversionHostTasks', null); + case `${FETCH_V2V_CONVERSION_HOST_TASKS}_FULFILLED`: { + const tasksWithMetadata = parseConversionHostTasksMetadata(action.payload.data.resources); + const tasksByResource = indexConversionHostTasksByResource(tasksWithMetadata); + return state + .set('conversionHostTasks', tasksWithMetadata) + .set('conversionHostTasksByResource', tasksByResource) + .set('isFetchingConversionHostTasks', false) + .set('isRejectedFetchingConversionHostTasks', false) + .set('errorFetchingConversionHostTasks', null); + } + case `${FETCH_V2V_CONVERSION_HOST_TASKS}_REJECTED`: + return state + .set('isFetchingConversionHostTasks', false) + .set('isRejectedFetchingConversionHostTasks', true) + .set('errorFetchingConversionHostTasks', action.payload); + case SHOW_V2V_CONVERSION_HOST_WIZARD: return state.set('conversionHostWizardMounted', true).set('conversionHostWizardVisible', true); case HIDE_V2V_CONVERSION_HOST_WIZARD: @@ -144,9 +173,9 @@ export default (state = initialState, action) => { case SET_V2V_CONVERSION_HOST_TO_DELETE: return state.set('conversionHostToDelete', action.payload); case SHOW_V2V_CONVERSION_HOST_DELETE_MODAL: - return state.set('showConversionHostDeleteModal', true); + return state.set('conversionHostDeleteModalVisible', true); case HIDE_V2V_CONVERSION_HOST_DELETE_MODAL: - return state.set('showConversionHostDeleteModal', false); + return state.set('conversionHostDeleteModalVisible', false); case `${DELETE_V2V_CONVERSION_HOST}_PENDING`: return state.set('isDeletingConversionHost', action.payload); @@ -154,13 +183,15 @@ export default (state = initialState, action) => { return state .set('deleteConversionHostResponse', action.payload.data) .set('isDeletingConversionHost', null) - .set('isRejectedConversionHost', false) - .set('errorDeleteConversionHost', null); + .set('isRejectedDeletingConversionHost', false) + .set('errorDeleteConversionHost', null) + .set('conversionHostDeleteModalVisible', false); case `${DELETE_V2V_CONVERSION_HOST}_REJECTED`: return state .set('errorDeleteConversionHost', action.payload) - .set('isRejectedConversionHost', true) - .set('isDeletingConversionHost', null); + .set('isRejectedDeletingConversionHost', true) + .set('isDeletingConversionHost', null) + .set('conversionHostDeleteModalVisible', false); default: return state; diff --git a/app/javascript/react/screens/App/Settings/__tests__/SettingsReducer.test.js b/app/javascript/react/screens/App/Settings/__tests__/SettingsReducer.test.js index 091424cee7..5eb21f842c 100644 --- a/app/javascript/react/screens/App/Settings/__tests__/SettingsReducer.test.js +++ b/app/javascript/react/screens/App/Settings/__tests__/SettingsReducer.test.js @@ -115,7 +115,7 @@ describe('fetching conversion hosts', () => { const action = { type: `${FETCH_V2V_CONVERSION_HOSTS}_PENDING` }; - const prevState = initialState.set('isRejectedConversionHosts', true); + const prevState = initialState.set('isRejectedFetchingConversionHosts', true); const state = settingsReducer(prevState, action); expect(state).toMatchSnapshot(); }); @@ -135,7 +135,9 @@ describe('fetching conversion hosts', () => { type: `${FETCH_V2V_CONVERSION_HOSTS}_FULFILLED`, payload: { data: { resources: [{ mock: 'conversionHost' }] } } }; - const prevState = initialState.set('isRejectedConversionHosts', true).set('isFetchingConversionHosts', true); + const prevState = initialState + .set('isRejectedFetchingConversionHosts', true) + .set('isFetchingConversionHosts', true); const state = settingsReducer(prevState, action); expect(state).toMatchSnapshot(); }); diff --git a/app/javascript/react/screens/App/Settings/__tests__/__snapshots__/Settings.test.js.snap b/app/javascript/react/screens/App/Settings/__tests__/__snapshots__/Settings.test.js.snap index 85a708fca1..bacfdc1636 100644 --- a/app/javascript/react/screens/App/Settings/__tests__/__snapshots__/Settings.test.js.snap +++ b/app/javascript/react/screens/App/Settings/__tests__/__snapshots__/Settings.test.js.snap @@ -32,6 +32,7 @@ exports[`Settings component renders correctly 1`] = ` activeKey="/settings" id="settings-tabs" onSelect={[Function]} + unmountOnExit={true} > ({ max_concurrent_tasks_per_host: payload.transformation.limits.max_concurrent_tasks_per_host }); @@ -9,3 +11,78 @@ export const getApiSettingsFromFormValues = values => ({ } } }); + +export const parseConversionHostTasksMetadata = tasks => { + // Example task name: "Configuring a conversion_host: operation=enable resource=(name: ims-conversion-host type: ManageIQ::Providers::Openstack::CloudManager::Vm id: 42000000000113)" + const taskNameRegex = /operation=(\w+)\s+resource=\(name:\s(.+)\stype:\s+([\w:]+)\s+id:\s(.+)\)/; + if (!tasks) return []; + return tasks.map(task => { + const result = taskNameRegex.exec(task.name); + if (!result) return task; + const [, operation, resourceName, resourceType, resourceId] = result; + return { + ...task, + meta: { + isTask: true, // To distinguish when part of combinedListItems + operation, + resourceName, + resourceType, + resourceId, + unparsedTaskName: task.name + }, + name: resourceName // For sorting and filtering + }; + }); +}; + +export const indexConversionHostTasksByResource = tasksWithMetadata => { + const tasksByResource = {}; + tasksWithMetadata.forEach(task => { + if (!task.meta) return; + const { resourceType: type, resourceId: id, operation } = task.meta; + if (!tasksByResource[type]) tasksByResource[type] = {}; + if (!tasksByResource[type][id]) tasksByResource[type][id] = {}; + if (!tasksByResource[type][id][operation]) tasksByResource[type][id][operation] = []; + tasksByResource[type][id][operation].push(task); + }); + return tasksByResource; +}; + +const getActiveConversionHostEnableTasks = (tasksWithMetadata, conversionHosts) => { + // Start with enable tasks that are either unfinished or finished with errors, and don't match any enabled hosts. + const tasks = tasksWithMetadata.filter( + task => + task.meta.operation === 'enable' && + (task.state !== FINISHED || task.status === ERROR) && + conversionHosts.every( + ch => ch.resource.type !== task.meta.resourceType || ch.resource.id !== task.meta.resourceId + ) + ); + // Filter to only the latest task for each resource (filter out old failures if a new task exists) + return tasks.filter((task, index) => + tasks.every( + (otherTask, otherIndex) => + otherIndex === index || + otherTask.meta.resourceType !== task.meta.resourceType || + otherTask.meta.resourceId !== task.meta.resourceId || + otherTask.updated_on <= task.updated_on + ) + ); +}; + +const attachTasksToConversionHosts = (conversionHosts, tasksByResource) => + conversionHosts.filter(conversionHost => !!conversionHost.resource).map(conversionHost => { + const { type, id } = conversionHost.resource; + return { + ...conversionHost, + meta: { + tasksByOperation: (tasksByResource[type] && tasksByResource[type][id]) || {} + } + }; + }); + +export const getCombinedConversionHostListItems = (conversionHosts, tasksWithMetadata, tasksByResource) => { + const activeEnableTasks = getActiveConversionHostEnableTasks(tasksWithMetadata, conversionHosts); + const conversionHostsWithTasks = attachTasksToConversionHosts(conversionHosts, tasksByResource); + return [...activeEnableTasks, ...conversionHostsWithTasks]; +}; diff --git a/app/javascript/react/screens/App/Settings/screens/ConversionHostsSettings/ConversionHostsSettings.js b/app/javascript/react/screens/App/Settings/screens/ConversionHostsSettings/ConversionHostsSettings.js index 3a91e316d8..9cf2b3fe2b 100644 --- a/app/javascript/react/screens/App/Settings/screens/ConversionHostsSettings/ConversionHostsSettings.js +++ b/app/javascript/react/screens/App/Settings/screens/ConversionHostsSettings/ConversionHostsSettings.js @@ -8,22 +8,68 @@ import ConversionHostWizard from './components/ConversionHostWizard'; import { FETCH_V2V_PROVIDERS_URL } from '../../../../../../redux/common/providers/providersConstants'; class ConversionHostsSettings extends React.Component { + pollingInterval = null; + state = { hasMadeInitialFetch: false }; + componentDidMount() { - const { fetchProvidersAction, fetchProvidersUrl, fetchConversionHostsAction, fetchConversionHostsUrl } = this.props; + const { fetchProvidersAction, fetchProvidersUrl } = this.props; fetchProvidersAction(fetchProvidersUrl); - fetchConversionHostsAction(fetchConversionHostsUrl); + this.startPolling(); + } + + componentWillUnmount() { + this.stopPolling(); } + componentDidUpdate(prevProps) { + // When a modal closes, reset the polling interval to see results immediately + if (this.pollingInterval && this.hasSomeModalOpen(prevProps) && !this.hasSomeModalOpen()) { + this.startPolling(); + } + } + + hasSomeModalOpen = (props = this.props) => + props.conversionHostWizardMounted || props.conversionHostDeleteModalVisible; + + startPolling = () => { + this.stopPolling(); // Allow startPolling to be called more than once to reset the interval + this.fetchConversionHostsAndTasks().then(() => { + this.setState({ hasMadeInitialFetch: true }); + this.pollingInterval = setInterval(this.fetchConversionHostsAndTasks, 15000); + }); + }; + + stopPolling = () => { + if (this.pollingInterval) { + clearInterval(this.pollingInterval); + this.pollingInterval = null; + } + }; + + fetchConversionHostsAndTasks = () => { + const { + fetchConversionHostsAction, + fetchConversionHostsUrl, + fetchConversionHostTasksAction, + fetchConversionHostTasksUrl + } = this.props; + if (this.hasSomeModalOpen()) return Promise.resolve(); + return Promise.all([ + fetchConversionHostsAction(fetchConversionHostsUrl), + fetchConversionHostTasksAction(fetchConversionHostTasksUrl) + ]); + }; + render() { const { isFetchingProviders, hasSufficientProviders, - isFetchingConversionHosts, - conversionHosts, + combinedListItems, setHostToDeleteAction, showConversionHostDeleteModalAction, - showConversionHostDeleteModal, + conversionHostDeleteModalVisible, conversionHostToDelete, + isDeletingConversionHost, showConversionHostWizardAction, conversionHostWizardMounted, hideConversionHostDeleteModalAction, @@ -33,8 +79,10 @@ class ConversionHostsSettings extends React.Component { fetchConversionHostsUrl } = this.props; + const { hasMadeInitialFetch } = this.state; + return ( - + {!hasSufficientProviders ? (
+
+

{__('Configured Conversion Hosts')}

+
- {conversionHosts.length === 0 ? ( + {combinedListItems.length === 0 ? ( ) : ( )} @@ -94,21 +146,25 @@ ConversionHostsSettings.propTypes = { hasSufficientProviders: PropTypes.bool, fetchConversionHostsUrl: PropTypes.string, fetchConversionHostsAction: PropTypes.func, - isFetchingConversionHosts: PropTypes.bool, - conversionHosts: PropTypes.arrayOf(PropTypes.object), + fetchConversionHostTasksAction: PropTypes.func, + fetchConversionHostTasksUrl: PropTypes.string, + combinedListItems: PropTypes.arrayOf(PropTypes.object), showConversionHostWizardAction: PropTypes.func, conversionHostWizardMounted: PropTypes.bool, setHostToDeleteAction: PropTypes.func, showConversionHostDeleteModalAction: PropTypes.func, - showConversionHostDeleteModal: PropTypes.bool, + conversionHostDeleteModalVisible: PropTypes.bool, conversionHostToDelete: PropTypes.object, + isDeletingConversionHost: PropTypes.bool, hideConversionHostDeleteModalAction: PropTypes.func }; ConversionHostsSettings.defaultProps = { deleteConversionHostActionUrl: '/api/conversion_hosts', fetchProvidersUrl: FETCH_V2V_PROVIDERS_URL, - fetchConversionHostsUrl: '/api/conversion_hosts?attributes=resource&expand=resources' + fetchConversionHostsUrl: '/api/conversion_hosts?attributes=resource&expand=resources', + fetchConversionHostTasksUrl: + '/api/tasks?expand=resources&attributes=id,name,state,status,message,started_on,updated_on,pct_complete&filter[]=name="%25Configuring a conversion_host%25"&sort_by=updated_on&sort_order=descending' }; export default ConversionHostsSettings; diff --git a/app/javascript/react/screens/App/Settings/screens/ConversionHostsSettings/ConversionHostsSettingsConstants.js b/app/javascript/react/screens/App/Settings/screens/ConversionHostsSettings/ConversionHostsSettingsConstants.js new file mode 100644 index 0000000000..85a0a80d16 --- /dev/null +++ b/app/javascript/react/screens/App/Settings/screens/ConversionHostsSettings/ConversionHostsSettingsConstants.js @@ -0,0 +1,10 @@ +// task.status +export const ERROR = 'Error'; +export const OK = 'Ok'; + +// task.state +export const FINISHED = 'Finished'; + +// task.meta.operation +export const ENABLE = 'enable'; +export const DISABLE = 'disable'; diff --git a/app/javascript/react/screens/App/Settings/screens/ConversionHostsSettings/__tests__/ConversionHostsSettings.test.js b/app/javascript/react/screens/App/Settings/screens/ConversionHostsSettings/__tests__/ConversionHostsSettings.test.js index 155785f6b2..4478265505 100644 --- a/app/javascript/react/screens/App/Settings/screens/ConversionHostsSettings/__tests__/ConversionHostsSettings.test.js +++ b/app/javascript/react/screens/App/Settings/screens/ConversionHostsSettings/__tests__/ConversionHostsSettings.test.js @@ -8,15 +8,21 @@ import ConversionHostsList from '../components/ConversionHostsList'; describe('ConversionHostsSettings component', () => { const getBaseProps = () => ({ + deleteConversionHostAction: jest.fn(), fetchProvidersAction: jest.fn(), - fetchConversionHostsAction: jest.fn(), - conversionHosts: [], - hasSufficientProviders: true, isFetchingProviders: false, - isFetchingConversionHosts: false, + hasSufficientProviders: true, + fetchConversionHostsAction: jest.fn(), + fetchConversionHostTasksAction: jest.fn(), + combinedListItems: [], showConversionHostWizardAction: jest.fn(), - hideConversionHostWizardAction: jest.fn(), - conversionHostWizardVisible: false + conversionHostWizardMounted: false, + setHostToDeleteAction: jest.fn(), + showConversionHostDeleteModalAction: jest.fn(), + conversionHostDeleteModalVisible: false, + conversionHostToDelete: null, + isDeletingConversionHost: false, + hideConversionHostDeleteModalAction: jest.fn() }); it('renders a spinner when fetching conversion hosts', () => { @@ -44,7 +50,7 @@ describe('ConversionHostsSettings component', () => { }); it('renders the list view when conversion hosts are present', () => { - const component = shallow(); + const component = shallow(); expect(component.find(ConversionHostsList)).toHaveLength(1); expect(component.find(ShowWizardEmptyState)).toHaveLength(0); expect(component.find(ConversionHostsEmptyState)).toHaveLength(0); diff --git a/app/javascript/react/screens/App/Settings/screens/ConversionHostsSettings/components/ConversionHostRemoveButton.js b/app/javascript/react/screens/App/Settings/screens/ConversionHostsSettings/components/ConversionHostRemoveButton.js index f67d0dfaf5..37faae6384 100644 --- a/app/javascript/react/screens/App/Settings/screens/ConversionHostsSettings/components/ConversionHostRemoveButton.js +++ b/app/javascript/react/screens/App/Settings/screens/ConversionHostsSettings/components/ConversionHostRemoveButton.js @@ -2,7 +2,7 @@ import React from 'react'; import PropTypes from 'prop-types'; import { Button } from 'patternfly-react'; -const ConversionHostRemoveButton = ({ host, setHostToDeleteAction, showConversionHostDeleteModalAction }) => ( +const ConversionHostRemoveButton = ({ host, setHostToDeleteAction, showConversionHostDeleteModalAction, ...props }) => ( diff --git a/app/javascript/react/screens/App/Settings/screens/ConversionHostsSettings/components/ConversionHostWizard/ConversionHostWizardHostsStep/ConversionHostWizardHostsStep.js b/app/javascript/react/screens/App/Settings/screens/ConversionHostsSettings/components/ConversionHostWizard/ConversionHostWizardHostsStep/ConversionHostWizardHostsStep.js index acd92ac9a5..590236df84 100644 --- a/app/javascript/react/screens/App/Settings/screens/ConversionHostsSettings/components/ConversionHostWizard/ConversionHostWizardHostsStep/ConversionHostWizardHostsStep.js +++ b/app/javascript/react/screens/App/Settings/screens/ConversionHostsSettings/components/ConversionHostWizard/ConversionHostWizardHostsStep/ConversionHostWizardHostsStep.js @@ -6,8 +6,14 @@ import { Form } from 'patternfly-react'; import { RHV, OPENSTACK } from '../../../../../../../../../common/constants'; import TypeAheadSelectField from '../../../../../../common/forms/TypeAheadSelectField'; import { stepIDs } from '../ConversionHostWizardConstants'; +import { FINISHED } from '../../../ConversionHostsSettingsConstants'; -const ConversionHostWizardHostsStep = ({ selectedProviderType, selectedCluster }) => { +const ConversionHostWizardHostsStep = ({ + selectedProviderType, + selectedCluster, + conversionHosts, + conversionHostTasksByResource +}) => { let hostOptions = []; let emptyLabel = ''; if (selectedProviderType === RHV) { @@ -18,6 +24,16 @@ const ConversionHostWizardHostsStep = ({ selectedProviderType, selectedCluster } hostOptions = selectedCluster.vms; emptyLabel = __('No hosts available for the selected project.'); } + const filteredHostOptions = hostOptions.filter(host => { + // Don't allow selection of hosts already configured as conversion hosts + if (conversionHosts.some(ch => ch.resource.type === host.type && ch.resource.id === host.id)) return false; + // Don't allow selection of hosts in progress of being configured as conversion hosts + const tasks = conversionHostTasksByResource; + const matchingEnableTasks = + tasks && tasks[host.type] && tasks[host.type][host.id] && tasks[host.type][host.id].enable; + const enableInProgress = matchingEnableTasks && matchingEnableTasks.some(task => task.state !== FINISHED); + return !enableInProgress; + }); return (
@@ -28,7 +44,7 @@ const ConversionHostWizardHostsStep = ({ selectedProviderType, selectedCluster } controlId="host-selection" multiple clearButton - options={hostOptions} + options={filteredHostOptions} labelKey="name" placeholder={__('Select one or more hosts...')} emptyLabel={hostOptions.length === 0 ? emptyLabel : __('No matches found.')} @@ -43,7 +59,11 @@ const ConversionHostWizardHostsStep = ({ selectedProviderType, selectedCluster } ConversionHostWizardHostsStep.propTypes = { selectedProviderType: PropTypes.string, - selectedCluster: PropTypes.object + selectedCluster: PropTypes.object, + conversionHosts: PropTypes.arrayOf(PropTypes.object), + conversionHostTasksByResource: PropTypes.objectOf( + PropTypes.objectOf(PropTypes.objectOf(PropTypes.arrayOf(PropTypes.object))) + ) }; export default reduxForm({ diff --git a/app/javascript/react/screens/App/Settings/screens/ConversionHostsSettings/components/ConversionHostWizard/ConversionHostWizardHostsStep/index.js b/app/javascript/react/screens/App/Settings/screens/ConversionHostsSettings/components/ConversionHostWizard/ConversionHostWizardHostsStep/index.js index a42cc4eeac..5547e73b92 100644 --- a/app/javascript/react/screens/App/Settings/screens/ConversionHostsSettings/components/ConversionHostWizard/ConversionHostWizardHostsStep/index.js +++ b/app/javascript/react/screens/App/Settings/screens/ConversionHostsSettings/components/ConversionHostWizard/ConversionHostWizardHostsStep/index.js @@ -3,13 +3,19 @@ import ConversionHostWizardHostsStep from './ConversionHostWizardHostsStep'; import { stepIDs } from '../ConversionHostWizardConstants'; -const mapStateToProps = ({ form, targetResources: { targetClusters } }) => { +const mapStateToProps = ({ + form, + targetResources: { targetClusters }, + settings: { conversionHosts, conversionHostTasksByResource } +}) => { const locationStepForm = form[stepIDs.locationStep]; const locationStepValues = locationStepForm && locationStepForm.values; const selectedClusterId = locationStepValues && locationStepValues.cluster; return { selectedProviderType: locationStepValues && locationStepValues.providerType, - selectedCluster: targetClusters.find(cluster => cluster.id === selectedClusterId) + selectedCluster: targetClusters.find(cluster => cluster.id === selectedClusterId), + conversionHosts, + conversionHostTasksByResource }; }; diff --git a/app/javascript/react/screens/App/Settings/screens/ConversionHostsSettings/components/ConversionHostsList.js b/app/javascript/react/screens/App/Settings/screens/ConversionHostsSettings/components/ConversionHostsList.js index 7d45a49576..ddd3a79172 100644 --- a/app/javascript/react/screens/App/Settings/screens/ConversionHostsSettings/components/ConversionHostsList.js +++ b/app/javascript/react/screens/App/Settings/screens/ConversionHostsSettings/components/ConversionHostsList.js @@ -1,28 +1,26 @@ import React from 'react'; import PropTypes from 'prop-types'; -import { DropdownKebab, Grid, ListView, MenuItem, Toolbar } from 'patternfly-react'; +import { Grid, ListView, Toolbar } from 'patternfly-react'; import ListViewToolbar from '../../../../common/ListViewToolbar/ListViewToolbar'; -import ConversionHostRemoveButton from './ConversionHostRemoveButton'; +import ConversionHostsListItem from './ConversionHostsListItem'; import DeleteConversionHostConfirmationModal from './DeleteConversionHostConfirmationModal'; -import StopPropagationOnClick from '../../../../common/StopPropagationOnClick'; const ConversionHostsList = ({ - conversionHosts, + combinedListItems, conversionHostToDelete, deleteConversionHostAction, deleteConversionHostActionUrl, - fetchConversionHostsAction, - fetchConversionHostsUrl, hideConversionHostDeleteModalAction, setHostToDeleteAction, - showConversionHostDeleteModal, - showConversionHostDeleteModalAction + conversionHostDeleteModalVisible, + showConversionHostDeleteModalAction, + isDeletingConversionHost }) => ( {({ filteredSortedPaginatedListItems, @@ -41,27 +39,19 @@ const ConversionHostsList = ({
- {filteredSortedPaginatedListItems.items.map((host, n) => ( - - - - - {__('Download Log')} - - -
- } - /> - ))} + {filteredSortedPaginatedListItems.items.map(listItem => { + const { isTask } = listItem.meta; + const itemKey = `conversion-host-${isTask ? 'task-' : ''}${listItem.id}`; + return ( + + ); + })} {renderPaginationRow(filteredSortedPaginatedListItems)}
@@ -73,25 +63,23 @@ const ConversionHostsList = ({ conversionHostToDelete={conversionHostToDelete} deleteConversionHostAction={deleteConversionHostAction} deleteConversionHostActionUrl={deleteConversionHostActionUrl} - fetchConversionHostsAction={fetchConversionHostsAction} - fetchConversionHostsUrl={fetchConversionHostsUrl} hideConversionHostDeleteModalAction={hideConversionHostDeleteModalAction} - showConversionHostDeleteModal={showConversionHostDeleteModal} + conversionHostDeleteModalVisible={conversionHostDeleteModalVisible} + isDeletingConversionHost={isDeletingConversionHost} /> ); ConversionHostsList.propTypes = { - conversionHosts: PropTypes.arrayOf(PropTypes.object), + combinedListItems: PropTypes.arrayOf(PropTypes.object), conversionHostToDelete: PropTypes.object, deleteConversionHostAction: PropTypes.func, deleteConversionHostActionUrl: PropTypes.string, - fetchConversionHostsAction: PropTypes.func, - fetchConversionHostsUrl: PropTypes.string, hideConversionHostDeleteModalAction: PropTypes.func, setHostToDeleteAction: PropTypes.func, - showConversionHostDeleteModal: PropTypes.bool, - showConversionHostDeleteModalAction: PropTypes.func + conversionHostDeleteModalVisible: PropTypes.bool, + showConversionHostDeleteModalAction: PropTypes.func, + isDeletingConversionHost: PropTypes.bool }; ConversionHostsList.sortFields = [ diff --git a/app/javascript/react/screens/App/Settings/screens/ConversionHostsSettings/components/ConversionHostsListItem.js b/app/javascript/react/screens/App/Settings/screens/ConversionHostsSettings/components/ConversionHostsListItem.js new file mode 100644 index 0000000000..c1eba6c85f --- /dev/null +++ b/app/javascript/react/screens/App/Settings/screens/ConversionHostsSettings/components/ConversionHostsListItem.js @@ -0,0 +1,126 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import Immutable from 'seamless-immutable'; +import { DropdownKebab, ListView, MenuItem, Button, Spinner, OverlayTrigger, Popover, Icon } from 'patternfly-react'; +import ConversionHostRemoveButton from './ConversionHostRemoveButton'; +import StopPropagationOnClick from '../../../../common/StopPropagationOnClick'; +import { FINISHED, ERROR, ENABLE, DISABLE } from '../ConversionHostsSettingsConstants'; + +const downloadLogSupported = false; // TODO remove me when the Download Log action works +const retryFailedTaskSupported = false; // TODO remove me when the Retry button works +const removeFailedTaskSupported = false; // TODO remove me when the Remove button works + +const ConversionHostsListItem = ({ listItem, isTask, setHostToDeleteAction, showConversionHostDeleteModalAction }) => { + let mostRecentTask = listItem; + if (!isTask) { + const { enable, disable } = listItem.meta.tasksByOperation; + const mostRecentFirst = (a, b) => (a.updated_on > b.updated_on ? -1 : a.updated_on < b.updated_on ? 1 : 0); + const lastEnableTask = enable && Immutable.asMutable(enable).sort(mostRecentFirst)[0]; + const lastDisableTask = disable && Immutable.asMutable(disable).sort(mostRecentFirst)[0]; + mostRecentTask = + lastEnableTask && (!lastDisableTask || lastDisableTask.updated_on < lastEnableTask.updated_on) + ? lastEnableTask + : lastDisableTask; + } + + let statusIcon; + let statusMessage; + if (mostRecentTask && mostRecentTask.status === ERROR) { + statusIcon = ; + statusMessage = mostRecentTask.meta.operation === ENABLE ? __('Configuration Failed') : __('Removal Failed'); + } else if (mostRecentTask && mostRecentTask.state !== FINISHED) { + statusIcon = ; + statusMessage = mostRecentTask.meta.operation === ENABLE ? __('Configuring...') : __('Removing...'); + } else { + statusIcon = ; + statusMessage = __('Configured'); + } + + const taskInfoPopover = ( + + {mostRecentTask ? ( + + {__('State:')} {mostRecentTask.state} +
+ {__('Message:')} {mostRecentTask.message} +
+ ) : ( + __('No configuration task information available') + )} + + } + > + +
+ ); + + let actionButtons; + if (isTask) { + const retryButton = mostRecentTask.status === ERROR ? : null; + const removeButton = ; + actionButtons = ( + + {retryFailedTaskSupported && retryButton} + {mostRecentTask.state !== FINISHED || removeFailedTaskSupported ? ( + removeButton + ) : ( +
+ )} + {/* TODO remove the above spacer div when there are buttons here */} + + ); + } else { + actionButtons = ( + + ); + } + + const kebabMenu = mostRecentTask ? ( + + + {__('Download Log') /* TODO */} + + + ) : null; + + return ( + + {statusIcon} + {statusMessage} + {taskInfoPopover} + + ]} + stacked + actions={ +
+ {actionButtons} + {downloadLogSupported && kebabMenu} +
+ } + /> + ); +}; + +ConversionHostsListItem.propTypes = { + listItem: PropTypes.object, + isTask: PropTypes.bool, + setHostToDeleteAction: PropTypes.func, + showConversionHostDeleteModalAction: PropTypes.func +}; + +export default ConversionHostsListItem; diff --git a/app/javascript/react/screens/App/Settings/screens/ConversionHostsSettings/components/DeleteConversionHostConfirmationModal.js b/app/javascript/react/screens/App/Settings/screens/ConversionHostsSettings/components/DeleteConversionHostConfirmationModal.js index 7d720c8b78..7657be24ce 100644 --- a/app/javascript/react/screens/App/Settings/screens/ConversionHostsSettings/components/DeleteConversionHostConfirmationModal.js +++ b/app/javascript/react/screens/App/Settings/screens/ConversionHostsSettings/components/DeleteConversionHostConfirmationModal.js @@ -2,23 +2,18 @@ import React from 'react'; import PropTypes from 'prop-types'; import { Button, Modal, Icon } from 'patternfly-react'; -const onClickHandler = (host, deleteHostAction, deleteHostActionUrl, fetchHostsAction, fetchHostsUrl) => { - deleteHostAction(deleteHostActionUrl, host).then(() => fetchHostsAction(fetchHostsUrl)); -}; - const DeleteConversionHostConfirmationModal = ({ conversionHostToDelete, deleteConversionHostAction, deleteConversionHostActionUrl, - fetchConversionHostsAction, - fetchConversionHostsUrl, hideConversionHostDeleteModalAction, - showConversionHostDeleteModal + conversionHostDeleteModalVisible, + isDeletingConversionHost }) => ( - + - {__('Delete Conversion Host')} + {__('Remove Conversion Host')}
@@ -27,7 +22,7 @@ const DeleteConversionHostConfirmationModal = ({

{conversionHostToDelete && - sprintf(__('Are you sure you want to delete conversion host %s ?'), conversionHostToDelete.name)} + sprintf(__('Are you sure you want to remove conversion host %s ?'), conversionHostToDelete.name)}

@@ -37,17 +32,12 @@ const DeleteConversionHostConfirmationModal = ({ @@ -57,10 +47,9 @@ DeleteConversionHostConfirmationModal.propTypes = { conversionHostToDelete: PropTypes.object, deleteConversionHostAction: PropTypes.func, deleteConversionHostActionUrl: PropTypes.string, - fetchConversionHostsAction: PropTypes.func, - fetchConversionHostsUrl: PropTypes.string, hideConversionHostDeleteModalAction: PropTypes.func, - showConversionHostDeleteModal: PropTypes.bool + conversionHostDeleteModalVisible: PropTypes.bool, + isDeletingConversionHost: PropTypes.bool }; export default DeleteConversionHostConfirmationModal; diff --git a/app/javascript/react/screens/App/Settings/screens/ConversionHostsSettings/index.js b/app/javascript/react/screens/App/Settings/screens/ConversionHostsSettings/index.js index 1cd19e2219..6f5489aca2 100644 --- a/app/javascript/react/screens/App/Settings/screens/ConversionHostsSettings/index.js +++ b/app/javascript/react/screens/App/Settings/screens/ConversionHostsSettings/index.js @@ -1,12 +1,45 @@ import { connect } from 'react-redux'; import ConversionHostsSettings from './ConversionHostsSettings'; -import * as SettingsActions from '../../SettingsActions'; -import * as ProvidersActions from '../../../../../../redux/common/providers/providersActions'; +import { fetchProvidersAction } from '../../../../../../redux/common/providers/providersActions'; +import { + fetchConversionHostsAction, + fetchConversionHostTasksAction, + showConversionHostWizardAction, + setHostToDeleteAction, + deleteConversionHostAction, + showConversionHostDeleteModalAction, + hideConversionHostDeleteModalAction +} from '../../SettingsActions'; -const mapStateToProps = ({ settings, providers }, ownProps) => ({ - ...settings, - ...providers, +import { getCombinedConversionHostListItems } from '../../helpers'; + +const mapStateToProps = ( + { + providers: { isFetchingProviders, hasSufficientProviders }, + settings: { + conversionHosts, + conversionHostTasks, + conversionHostTasksByResource, + conversionHostWizardMounted, + conversionHostDeleteModalVisible, + conversionHostToDelete, + isDeletingConversionHost + } + }, + ownProps +) => ({ + isFetchingProviders, + hasSufficientProviders, + combinedListItems: getCombinedConversionHostListItems( + conversionHosts, + conversionHostTasks, + conversionHostTasksByResource + ), + conversionHostWizardMounted, + conversionHostDeleteModalVisible, + conversionHostToDelete, + isDeletingConversionHost, ...ownProps.data }); @@ -14,6 +47,15 @@ const mergeProps = (stateProps, dispatchProps, ownProps) => Object.assign(stateP export default connect( mapStateToProps, - { ...SettingsActions, ...ProvidersActions }, + { + fetchProvidersAction, + fetchConversionHostsAction, + fetchConversionHostTasksAction, + showConversionHostWizardAction, + setHostToDeleteAction, + deleteConversionHostAction, + showConversionHostDeleteModalAction, + hideConversionHostDeleteModalAction + }, mergeProps )(ConversionHostsSettings);