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') %>
-
-
-
-
- <%= sort :name, :as => s_("Role|Name") %> |
- <%= _("Hostgroups") %> |
- <%= _("Hosts") %> |
- <%= _("Variables") %> |
- <%= sort :updated_at, :as => _("Imported at") %> |
- <%= _("Actions") %> |
-
-
-
- <% @ansible_roles.each do |role| %>
-
- <%= 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) %>
- |
-
- <% end %>
-
-
-
-<%= 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,
+ },
];