diff --git a/app/controllers/ansible_roles_controller.rb b/app/controllers/ansible_roles_controller.rb index b4764c9ce..61b5c246b 100644 --- a/app/controllers/ansible_roles_controller.rb +++ b/app/controllers/ansible_roles_controller.rb @@ -5,21 +5,6 @@ class AnsibleRolesController < ::ApplicationController include Foreman::Controller::AutoCompleteSearch include ForemanAnsible::Concerns::ImportControllerHelper include ::ForemanAnsible::AnsibleRolesDataPreparations - def index - @ansible_roles = resource_base.search_for(params[:search], - :order => params[:order]). - paginate(:page => params[:page], - :per_page => params[:per_page]) - end - - def destroy - if @ansible_role.destroy - process_success - else - process_error - end - end - def import changed = @importer.import! @rows = prepare_ansible_import_rows(changed, @variables_importer) diff --git a/app/controllers/ui_ansible_roles_controller.rb b/app/controllers/ui_ansible_roles_controller.rb index 364bedc97..751034bd8 100644 --- a/app/controllers/ui_ansible_roles_controller.rb +++ b/app/controllers/ui_ansible_roles_controller.rb @@ -7,6 +7,12 @@ def index @ui_ansible_roles = resource_scope_for_index(:permission => :view_ansible_roles) end + api :DELETE, '/ui_ansible_roles/:id', N_('Deletes Ansible role') + param :id, :identifier, :required => true + def destroy + AnsibleRole.find_by!(id: params[:id]).destroy + end + # restore original method from find_common to ignore resource nesting def resource_scope(**kwargs) @resource_scope ||= scope_for(resource_class, **kwargs) diff --git a/app/views/ansible_roles/index.html.erb b/app/views/ansible_roles/index.html.erb deleted file mode 100644 index dcb787662..000000000 --- a/app/views/ansible_roles/index.html.erb +++ /dev/null @@ -1,47 +0,0 @@ -<% title _("Ansible Roles") %> - -<% title_actions ansible_proxy_import(hash_for_import_ansible_roles_path), - documentation_button('Managing_Configurations_Ansible', type: 'docs', chapter: 'Importing_Ansible_Roles_and_Variables_ansible') %> - - - - - - - - - - - - - - <% @ansible_roles.each do |role| %> - - - - - - - - - <% end %> - -
<%= sort :name, :as => s_("Role|Name") %><%= _("Hostgroups") %><%= _("Hosts") %><%= _("Variables") %><%= sort :updated_at, :as => _("Imported at") %><%= _("Actions") %>
<%= role.name %><%= link_to role.hostgroups.count, hostgroups_path(:search => "ansible_role = #{role.name}") %><%= link_to role.hosts.count, hosts_path(:search => "ansible_role = #{role.name}")%><%= link_to(role.ansible_variables.count, ansible_variables_path(:search => "ansible_role = #{role}")) %><%= import_time role %> - <% - links = [ - link_to( - _('Variables'), - ansible_variables_path(:search => "ansible_role = #{role}") - ), - display_delete_if_authorized( - hash_for_ansible_role_path(:id => role). - merge(:auth_object => role, :authorizer => authorizer), - :data => { :confirm => _("Delete %s?") % role.name }, - :action => :delete - ) - ] - %> - <%= action_buttons(*links) %> -
- -<%= will_paginate_with_info @ansible_roles %> diff --git a/app/views/ansible_roles/welcome.html.erb b/app/views/ansible_roles/welcome.html.erb deleted file mode 100644 index 06b1d5813..000000000 --- a/app/views/ansible_roles/welcome.html.erb +++ /dev/null @@ -1,14 +0,0 @@ -<% content_for(:title, _("Ansible Roles")) %> -
-
- <%= icon_text("play", "", :kind => "fa") %> -
-

<%= _('Ansible Roles') %>

-

<%= _('No Ansible Roles were found in Foreman. If you want to assign roles to your hosts, - you have to import them first.').html_safe %> -

-

<%= link_to(_('Learn more about this in the documentation.'), documentation_url('Managing_Configurations_Ansible', type: 'docs', chapter: 'Importing_Ansible_Roles_and_Variables_ansible'), target: '_blank') %>

-
- <%= ansible_proxy_import(hash_for_import_ansible_roles_path) %> -
-
diff --git a/app/views/ui_ansible_roles/show.json.rabl b/app/views/ui_ansible_roles/show.json.rabl index a90699275..7be8f0b2a 100644 --- a/app/views/ui_ansible_roles/show.json.rabl +++ b/app/views/ui_ansible_roles/show.json.rabl @@ -1,3 +1,15 @@ object @ansible_role -extends "api/v2/ansible_roles/show" +attributes :id, :name, :updated_at +code :hostgroups_count do |role| + role.hostgroups.count +end +code :hosts_count do |role| + role.hosts.count +end +code :variables_count do |role| + role.ansible_variables.count +end +code :can_delete do + User.current.can?(:destroy_ansible_roles) +end diff --git a/config/routes.rb b/config/routes.rb index 1a0431e4f..46e9627c3 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -36,6 +36,7 @@ end end scope '/ansible' do + match '/ansible_roles' => 'react#index', :via => [:get] constraints(:id => %r{[^\/]+}) do resources :hosts, :only => [] do member do @@ -52,7 +53,7 @@ end end - resources :ansible_roles, :only => [:index, :destroy] do + resources :ansible_roles, :only => [:index] do collection do get :import post :confirm_import @@ -60,7 +61,7 @@ end end - resources :ui_ansible_roles, :only => [:index] + resources :ui_ansible_roles, :only => [:index, :destroy] resources :ansible_variables, :except => [:show] do resources :lookup_values, :only => [:index, :create, :update, :destroy] diff --git a/lib/foreman_ansible/register.rb b/lib/foreman_ansible/register.rb index 97e973527..4d904c091 100644 --- a/lib/foreman_ansible/register.rb +++ b/lib/foreman_ansible/register.rb @@ -102,8 +102,8 @@ :ui_ansible_roles => [:index] }, :resource_type => 'AnsibleRole' permission :destroy_ansible_roles, - { :ansible_roles => [:destroy], - :'api/v2/ansible_roles' => [:destroy, :obsolete] }, + { :'api/v2/ansible_roles' => [:destroy, :obsolete], + :ui_ansible_roles => [:destroy] }, :resource_type => 'AnsibleRole' permission :import_ansible_roles, { :ansible_roles => [:import, :confirm_import], diff --git a/test/functional/ansible_roles_controller_test.rb b/test/functional/ansible_roles_controller_test.rb index c39a665b7..f6073b9d3 100644 --- a/test/functional/ansible_roles_controller_test.rb +++ b/test/functional/ansible_roles_controller_test.rb @@ -2,19 +2,4 @@ require 'test_plugin_helper' # functional tests for AnsibleRolesController -class AnsibleRolesControllerTest < ActionController::TestCase - setup do - @role = FactoryBot.create(:ansible_role) - end - - basic_index_test - - test 'should destroy role' do - assert_difference('AnsibleRole.count', -1) do - delete :destroy, - :params => { :id => @role.id }, - :session => set_session_user - end - assert_redirected_to ansible_roles_url - end -end +class AnsibleRolesControllerTest < ActionController::TestCase; end diff --git a/test/functional/ui_ansible_roles_controller_test.rb b/test/functional/ui_ansible_roles_controller_test.rb index 37f4fa260..4e5922c6b 100644 --- a/test/functional/ui_ansible_roles_controller_test.rb +++ b/test/functional/ui_ansible_roles_controller_test.rb @@ -11,4 +11,11 @@ class UiAnsibleRolesControllerTest < ActionController::TestCase res = JSON.parse @response.body assert_equal res['total'], res['results'].size end + + test 'should destroy role' do + delete :destroy, + :params => { :id => @role.id }, + :session => set_session_user + assert_response :success + end end diff --git a/webpack/components/AnsibleRoles/AnsibleRolesPage.js b/webpack/components/AnsibleRoles/AnsibleRolesPage.js new file mode 100644 index 000000000..5ccdac923 --- /dev/null +++ b/webpack/components/AnsibleRoles/AnsibleRolesPage.js @@ -0,0 +1,78 @@ +import React from 'react'; +import { useAPI } from 'foremanReact/common/hooks/API/APIHooks'; +import TableIndexPage from 'foremanReact/components/PF4/TableIndexPage/TableIndexPage'; +import RelativeDateTime from 'foremanReact/components/common/dates/RelativeDateTime'; +import { translate as __ } from 'foremanReact/common/I18n'; +import { getDocsURL } from 'foremanReact/common/helpers'; +import { + hostgroupsLink, + hostsLink, + variablesLink, +} from './AnsibleRolesPageHelpers'; +import { AnsibleRolesImportButtonWrapper } from './components/AnsibleRolesImportButtonWrapper'; +import { showToast } from '../../toastHelper'; + +export const AnsibleRolesPage = () => { + const { + response: { results }, + status, + } = useAPI('get', '/api/v2/permissions/current_permissions'); + + if (status === 'ERROR') { + showToast({ + type: 'error', + message: __('There was an error requesting user permissions'), + }); + } + + return ( + + } + customHelpURL={getDocsURL( + 'Managing_Configurations_Ansible', + 'Importing_Ansible_Roles_and_Variables_ansible' + )} + columns={{ + name: { title: __('Name'), isSorted: true, weight: 0 }, + hostgroups_count: { + title: __('Hostgroups'), + weight: 1, + wrapper: ({ name, hostgroups_count: hostgroupsCount }) => + hostgroupsLink(name, hostgroupsCount, status, results), + }, + hosts_count: { + title: __('Hosts'), + weight: 2, + wrapper: ({ name, hosts_count: hostsCount }) => + hostsLink(name, hostsCount, status, results), + }, + variables_count: { + title: __('Variables'), + weight: 3, + wrapper: ({ name, variables_count: variablesCount }) => + variablesLink(name, variablesCount, status, results), + }, + updated_at: { + title: __('Imported at'), + isSorted: true, + weight: 4, + wrapper: ({ updated_at: updatedAt }) => ( + + ), + }, + }} + /> + ); +}; diff --git a/webpack/components/AnsibleRoles/AnsibleRolesPageHelpers.js b/webpack/components/AnsibleRoles/AnsibleRolesPageHelpers.js new file mode 100644 index 000000000..7e154d480 --- /dev/null +++ b/webpack/components/AnsibleRoles/AnsibleRolesPageHelpers.js @@ -0,0 +1,61 @@ +import React from 'react'; + +export const hostgroupsLink = ( + roleName, + hostgroupsCount, + apiStatus, + permissions +) => { + if ( + apiStatus === 'RESOLVED' && + permissions.some(perm => perm.name === 'view_hostgroups') + ) { + return link(hostgroupsUrl(roleName), hostgroupsCount); + } + return hostgroupsCount; +}; + +export const hostsLink = ( + roleName, + hostgroupsCount, + apiStatus, + permissions +) => { + if ( + apiStatus === 'RESOLVED' && + permissions.some(perm => perm.name === 'view_hosts') + ) { + return link(hostsUrl(roleName), hostgroupsCount); + } + return hostgroupsCount; +}; + +export const variablesLink = ( + roleName, + hostgroupsCount, + apiStatus, + permissions +) => { + if ( + apiStatus === 'RESOLVED' && + permissions.some(perm => perm.name === 'view_ansible_variables') + ) { + return link(variablesUrl(roleName), hostgroupsCount); + } + return hostgroupsCount; +}; + +const link = (url, displayText) => ( + + {displayText} + +); + +const hostgroupsUrl = roleName => `/hostgroups${searchString(roleName)}`; +const hostsUrl = roleName => `/new/hosts${searchString(roleName)}`; +const variablesUrl = roleName => `ansible_variables${searchString(roleName)}`; +const searchString = roleName => + `?search=${encodeURIComponent(`ansible_role = ${roleName}`)}`; + +export const importUrl = (proxyName, proxyId) => + `ansible_roles/import?proxy=${encodeURIComponent(`${proxyId}-${proxyName}`)}`; diff --git a/webpack/components/AnsibleRoles/components/AnsibleRolesImportButton.fixtures.js b/webpack/components/AnsibleRoles/components/AnsibleRolesImportButton.fixtures.js new file mode 100644 index 000000000..5f72caf57 --- /dev/null +++ b/webpack/components/AnsibleRoles/components/AnsibleRolesImportButton.fixtures.js @@ -0,0 +1,31 @@ +export const testSmartProxies = [ + { + created_at: '2024-06-21 14:49:35 +0200', + updated_at: '2024-06-21 14:49:35 +0200', + hosts_count: 0, + name: 'debuggable', + id: 2, + url: 'http://smart-proxy-ansible-capable.example.com:8080', + remote_execution_pubkey: null, + download_policy: 'on_demand', + supported_pulp_types: [], + lifecycle_environments: [], + features: [ + { + capabilities: ['ansible-runner', 'single'], + name: 'Dynflow', + id: 17, + }, + { + capabilities: ['vcs_clone'], + name: 'Ansible', + id: 20, + }, + { + capabilities: [], + name: 'Logs', + id: 13, + }, + ], + }, +]; diff --git a/webpack/components/AnsibleRoles/components/AnsibleRolesImportButton.js b/webpack/components/AnsibleRoles/components/AnsibleRolesImportButton.js new file mode 100644 index 000000000..ce298df12 --- /dev/null +++ b/webpack/components/AnsibleRoles/components/AnsibleRolesImportButton.js @@ -0,0 +1,72 @@ +import React from 'react'; +import { Dropdown, DropdownToggle, DropdownItem } from '@patternfly/react-core'; +import { translate as __ } from 'foremanReact/common/I18n'; +import { useAPI } from 'foremanReact/common/hooks/API/APIHooks'; +import { importUrl } from '../AnsibleRolesPageHelpers'; +import { showToast } from '../../../toastHelper'; + +export const AnsibleRolesImportButton = () => { + let dropdownItems; + + const { + response: { results }, + status, + } = useAPI('get', '/api/v2/smart_proxies', { + params: { search: 'feature=Ansible', per_page: 'all' }, + }); + + const [isOpen, setIsOpen] = React.useState(false); + const onToggle = isToggleOpen => { + setIsOpen(isToggleOpen); + }; + const onFocus = () => { + const element = document.getElementById('toggle-primary'); + element.focus(); + }; + const onSelect = () => { + setIsOpen(false); + onFocus(); + }; + + if (status === 'PENDING') { + dropdownItems = [ + + {__('Loading...')} + , + ]; + } else if (status === 'ERROR') { + showToast({ + type: 'error', + message: __('There was an error requesting Smart Proxies'), + }); + } else if (status === 'RESOLVED') { + dropdownItems = results.map(proxy => ( + + {proxy.name} + + )); + if (dropdownItems.length === 0) { + dropdownItems = [ + + {__('No Smart Proxies found')} + , + ]; + } + } + return ( + + {__('Import from')} + + } + isOpen={isOpen} + dropdownItems={dropdownItems} + /> + ); +}; diff --git a/webpack/components/AnsibleRoles/components/AnsibleRolesImportButton.test.js b/webpack/components/AnsibleRoles/components/AnsibleRolesImportButton.test.js new file mode 100644 index 000000000..2a6c2b3e8 --- /dev/null +++ b/webpack/components/AnsibleRoles/components/AnsibleRolesImportButton.test.js @@ -0,0 +1,77 @@ +import React from 'react'; +import { render } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import { useAPI } from 'foremanReact/common/hooks/API/APIHooks'; +import { AnsibleRolesImportButton } from './AnsibleRolesImportButton'; +import * as toastHelper from '../../../toastHelper'; +import { testSmartProxies } from './AnsibleRolesImportButton.fixtures'; + +jest.mock('foremanReact/common/hooks/API/APIHooks', () => ({ + useAPI: jest.fn(), +})); + +jest.spyOn(toastHelper, 'showToast'); + +describe('AnsibleRolesImportButton', () => { + it('should load', async () => { + useAPI.mockImplementation(() => ({ + status: 'PENDING', + response: { results: [] }, + })); + + const { queryByText, queryByRole } = render(); + + const dropdownButton = queryByRole('button', 'Import from'); + await dropdownButton.click(); + const loadingElement = queryByText('Loading...'); + + expect(loadingElement).toBeInTheDocument(); + }); + + it('should list Smart Proxies', async () => { + useAPI.mockImplementation(() => ({ + status: 'RESOLVED', + response: { results: testSmartProxies }, + })); + + const { queryByRole } = render(); + + const dropdownButton = queryByRole('button', 'Import from'); + await dropdownButton.click(); + const proxyList = queryByRole('menu'); + + expect(proxyList.querySelectorAll('li')).toHaveLength( + testSmartProxies.length + ); + }); + + it('should indicate that no supported Smart Proxies exist', async () => { + useAPI.mockImplementation(() => ({ + status: 'RESOLVED', + response: { results: [] }, + })); + + const { queryByText, queryByRole } = render(); + + const dropdownButton = queryByRole('button', 'Import from'); + await dropdownButton.click(); + const loadingElement = queryByText('No Smart Proxies found'); + + expect(loadingElement).toBeInTheDocument(); + }); + + it('should toast on API error', async () => { + useAPI.mockImplementation(() => ({ + status: 'ERROR', + response: { results: [] }, + })); + + render(); + + expect(toastHelper.showToast).toHaveBeenCalledTimes(1); + expect(toastHelper.showToast).toHaveBeenCalledWith({ + message: 'There was an error requesting Smart Proxies', + type: 'error', + }); + }); +}); diff --git a/webpack/components/AnsibleRoles/components/AnsibleRolesImportButtonWrapper.js b/webpack/components/AnsibleRoles/components/AnsibleRolesImportButtonWrapper.js new file mode 100644 index 000000000..c47e9d6a1 --- /dev/null +++ b/webpack/components/AnsibleRoles/components/AnsibleRolesImportButtonWrapper.js @@ -0,0 +1,26 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { AnsibleRolesImportButton } from './AnsibleRolesImportButton'; + +export const AnsibleRolesImportButtonWrapper = props => { + if (props.apiStatus === 'RESOLVED') { + if ( + ['import_ansible_roles', 'view_smart_proxies'].every(perm => + props.allPermissions.some(requestedPerm => perm === requestedPerm.name) + ) + ) { + return ; + } + } + return null; +}; + +AnsibleRolesImportButtonWrapper.propTypes = { + apiStatus: PropTypes.string, + allPermissions: PropTypes.array, +}; + +AnsibleRolesImportButtonWrapper.defaultProps = { + apiStatus: 'PENDING', + allPermissions: [], +}; diff --git a/webpack/components/AnsibleRoles/components/AnsibleRolesImportButtonWrapper.test.js b/webpack/components/AnsibleRoles/components/AnsibleRolesImportButtonWrapper.test.js new file mode 100644 index 000000000..0ee550cda --- /dev/null +++ b/webpack/components/AnsibleRoles/components/AnsibleRolesImportButtonWrapper.test.js @@ -0,0 +1,49 @@ +import React from 'react'; +import '@testing-library/jest-dom'; +import { render } from '@testing-library/react'; +import { useAPI } from 'foremanReact/common/hooks/API/APIHooks'; +import { AnsibleRolesImportButtonWrapper } from './AnsibleRolesImportButtonWrapper'; +import * as toastHelper from '../../../toastHelper'; + +jest.mock('foremanReact/common/hooks/API/APIHooks', () => ({ + useAPI: jest.fn(), +})); +jest.spyOn(toastHelper, 'showToast'); + +describe('AnsibleRolesImportButtonWrapper', () => { + it('should render the dropdown', () => { + useAPI.mockImplementation(() => ({ + status: 'RESOLVED', + response: { + results: [{ name: 'smart_proxy_1' }], + }, + })); + + const { queryByRole } = render( + + ); + + const dropdownButton = queryByRole('button', 'Import from'); + + expect(dropdownButton).toBeInTheDocument(); + }); + + it('should not render the dropdown if unpermitted', () => { + useAPI.mockImplementation(() => ({ + status: 'RESOLVED', + response: { results: [{ name: 'not_import_ansible_roles' }] }, + })); + + const { queryByRole } = render(); + + const dropdownButton = queryByRole('button', 'Import from'); + + expect(dropdownButton).not.toBeInTheDocument(); + }); +}); diff --git a/webpack/routes/AnsibleRoles/index.js b/webpack/routes/AnsibleRoles/index.js new file mode 100644 index 000000000..187913e82 --- /dev/null +++ b/webpack/routes/AnsibleRoles/index.js @@ -0,0 +1,11 @@ +import React from 'react'; + +import { AnsibleRolesPage } from '../../components/AnsibleRoles/AnsibleRolesPage'; + +const AnsibleRoles = () => ( + + + +); + +export default AnsibleRoles; diff --git a/webpack/routes/routes.js b/webpack/routes/routes.js index 6e996c06a..933734a60 100644 --- a/webpack/routes/routes.js +++ b/webpack/routes/routes.js @@ -1,5 +1,6 @@ import React from 'react'; import HostgroupJobs from './HostgroupJobs'; +import AnsibleRoles from './AnsibleRoles'; export default [ { @@ -7,4 +8,9 @@ export default [ render: props => , exact: true, }, + { + path: '/ansible/ansible_roles', + render: props => , + exact: true, + }, ];