foreman_remote_execution 4.1.0 → 4.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/js_ci.yml +29 -0
  3. data/.github/workflows/{ci.yml → ruby_ci.yml} +22 -50
  4. data/.prettierrc +4 -0
  5. data/.rubocop.yml +13 -49
  6. data/.rubocop_todo.yml +326 -102
  7. data/Gemfile +1 -4
  8. data/app/controllers/api/v2/job_invocations_controller.rb +21 -3
  9. data/app/controllers/foreman_remote_execution/concerns/api/v2/registration_controller_extensions.rb +26 -0
  10. data/app/controllers/job_templates_controller.rb +1 -1
  11. data/app/controllers/ui_job_wizard_controller.rb +18 -0
  12. data/app/lib/actions/remote_execution/run_host_job.rb +38 -1
  13. data/app/lib/actions/remote_execution/run_hosts_job.rb +9 -1
  14. data/app/models/concerns/foreman_remote_execution/host_extensions.rb +38 -14
  15. data/app/models/foreign_input_set.rb +1 -1
  16. data/app/models/host_status/execution_status.rb +7 -0
  17. data/app/models/job_invocation.rb +2 -1
  18. data/app/models/job_invocation_composer.rb +1 -1
  19. data/app/models/remote_execution_feature.rb +5 -2
  20. data/app/models/remote_execution_provider.rb +6 -1
  21. data/app/services/remote_execution_proxy_selector.rb +3 -0
  22. data/app/views/api/v2/job_invocations/main.json.rabl +1 -1
  23. data/app/views/api/v2/registration/_form.html.erb +12 -0
  24. data/app/views/template_invocations/_output_line_set.html.erb +1 -1
  25. data/app/views/template_invocations/show.html.erb +30 -23
  26. data/app/views/templates/ssh/package_action.erb +1 -0
  27. data/config/routes.rb +5 -0
  28. data/db/migrate/20200820122057_add_proxy_selector_override_to_remote_execution_feature.rb +5 -0
  29. data/foreman_remote_execution.gemspec +1 -2
  30. data/lib/foreman_remote_execution/engine.rb +21 -2
  31. data/lib/foreman_remote_execution/version.rb +1 -1
  32. data/package.json +6 -6
  33. data/test/functional/api/v2/job_invocations_controller_test.rb +42 -3
  34. data/test/functional/api/v2/registration_controller_test.rb +73 -0
  35. data/test/functional/ui_job_wizard_controller_test.rb +16 -0
  36. data/test/unit/actions/run_hosts_job_test.rb +1 -0
  37. data/webpack/JobWizard/JobWizard.js +32 -0
  38. data/webpack/JobWizard/index.js +32 -0
  39. data/webpack/Routes/routes.js +12 -0
  40. data/webpack/__mocks__/foremanReact/history.js +1 -0
  41. data/webpack/global_index.js +4 -0
  42. data/webpack/react_app/components/TargetingHosts/TargetingHosts.js +5 -1
  43. data/webpack/react_app/components/TargetingHosts/TargetingHostsPage.js +6 -2
  44. data/webpack/react_app/components/TargetingHosts/TargetingHostsPage.scss +0 -3
  45. data/webpack/react_app/components/TargetingHosts/__tests__/__snapshots__/TargetingHostsPage.test.js.snap +1 -1
  46. metadata +24 -23
@@ -98,6 +98,7 @@ handle_zypp_res_codes () {
98
98
  end
99
99
  -%>
100
100
  [ -x "$(command -v subscription-manager)" ] && subscription-manager refresh
101
+ export DEBIAN_FRONTEND=noninteractive
101
102
  apt-get -y update
102
103
  apt-get -o Dpkg::Options::="--force-confdef" -o Dpkg::Options::="--force-confold" -y <%= action %> <%= input("package") %>
103
104
  <% elsif package_manager == 'zypper' -%>
data/config/routes.rb CHANGED
@@ -43,6 +43,9 @@ Rails.application.routes.draw do
43
43
  get 'cockpit/host_ssh_params/:id', to: 'cockpit#host_ssh_params'
44
44
  end
45
45
  get 'cockpit/redirect', to: 'cockpit#redirect'
46
+ get 'ui_job_wizard/categories', to: 'ui_job_wizard#categories'
47
+
48
+ match '/experimental/job_wizard', to: 'react#index', :via => [:get]
46
49
 
47
50
  namespace :api, :defaults => {:format => 'json'} do
48
51
  scope '(:apiv)', :module => :v2, :defaults => {:apiv => 'v2'}, :apiv => /v1|v2/, :constraints => ApiConstraints.new(:version => 2, :default => true) do
@@ -55,6 +58,8 @@ Rails.application.routes.draw do
55
58
  post 'cancel'
56
59
  post 'rerun'
57
60
  get 'template_invocations', :to => 'template_invocations#template_invocations'
61
+ get 'outputs'
62
+ post 'outputs'
58
63
  end
59
64
  end
60
65
 
@@ -0,0 +1,5 @@
1
+ class AddProxySelectorOverrideToRemoteExecutionFeature < ActiveRecord::Migration[4.2]
2
+ def change
3
+ add_column :remote_execution_features, :proxy_selector_override, :string
4
+ end
5
+ end
@@ -26,9 +26,8 @@ Gem::Specification.new do |s|
26
26
  s.add_dependency 'deface'
27
27
  s.add_dependency 'dynflow', '>= 1.0.2', '< 2.0.0'
28
28
  s.add_dependency 'foreman_remote_execution_core'
29
- s.add_dependency 'foreman-tasks', '>= 0.15.1'
29
+ s.add_dependency 'foreman-tasks', '>= 4.0.0'
30
30
 
31
31
  s.add_development_dependency 'factory_bot_rails', '~> 4.8.0'
32
- s.add_development_dependency 'rubocop', '~> 0.80.0'
33
32
  s.add_development_dependency 'rdoc'
34
33
  end
@@ -53,6 +53,7 @@ module ForemanRemoteExecution
53
53
 
54
54
  initializer 'foreman_remote_execution.register_plugin', before: :finisher_hook do |_app|
55
55
  Foreman::Plugin.register :foreman_remote_execution do
56
+ register_global_js_file 'global'
56
57
  requires_foreman '>= 2.2'
57
58
 
58
59
  apipie_documented_controllers ["#{ForemanRemoteExecution::Engine.root}/app/controllers/api/v2/*.rb"]
@@ -68,7 +69,8 @@ module ForemanRemoteExecution
68
69
  permission :view_job_templates, { :job_templates => [:index, :show, :revision, :auto_complete_search, :auto_complete_job_category, :preview, :export],
69
70
  :'api/v2/job_templates' => [:index, :show, :revision, :export],
70
71
  :'api/v2/template_inputs' => [:index, :show],
71
- :'api/v2/foreign_input_sets' => [:index, :show]}, :resource_type => 'JobTemplate'
72
+ :'api/v2/foreign_input_sets' => [:index, :show],
73
+ :ui_job_wizard => [:categories]}, :resource_type => 'JobTemplate'
72
74
  permission :create_job_templates, { :job_templates => [:new, :create, :clone_template, :import],
73
75
  :'api/v2/job_templates' => [:create, :clone, :import] }, :resource_type => 'JobTemplate'
74
76
  permission :edit_job_templates, { :job_templates => [:edit, :update],
@@ -83,7 +85,7 @@ module ForemanRemoteExecution
83
85
  permission :create_job_invocations, { :job_invocations => [:new, :create, :refresh, :rerun, :preview_hosts],
84
86
  'api/v2/job_invocations' => [:create, :rerun] }, :resource_type => 'JobInvocation'
85
87
  permission :view_job_invocations, { :job_invocations => [:index, :chart, :show, :auto_complete_search], :template_invocations => [:show],
86
- 'api/v2/job_invocations' => [:index, :show, :output, :raw_output] }, :resource_type => 'JobInvocation'
88
+ 'api/v2/job_invocations' => [:index, :show, :output, :raw_output, :outputs] }, :resource_type => 'JobInvocation'
87
89
  permission :view_template_invocations, { :template_invocations => [:show],
88
90
  'api/v2/template_invocations' => [:template_invocations] }, :resource_type => 'TemplateInvocation'
89
91
  permission :create_template_invocations, {}, :resource_type => 'TemplateInvocation'
@@ -137,6 +139,13 @@ module ForemanRemoteExecution
137
139
  parent: :monitor_menu,
138
140
  after: :audits
139
141
 
142
+ menu :labs_menu, :job_wizard,
143
+ url_hash: { controller: 'job_wizard', action: :index },
144
+ caption: N_('Job wizard'),
145
+ parent: :lab_features_menu,
146
+ url: 'experimental/job_wizard',
147
+ after: :host_wizard
148
+
140
149
  register_custom_status HostStatus::ExecutionStatus
141
150
  # add dashboard widget
142
151
  # widget 'foreman_remote_execution_widget', name: N_('Foreman plugin template widget'), sizex: 4, sizey: 1
@@ -151,6 +160,14 @@ module ForemanRemoteExecution
151
160
  extend_rabl_template 'api/v2/subnets/show', 'api/v2/subnets/remote_execution_proxies'
152
161
  parameter_filter ::Subnet, :remote_execution_proxy_ids
153
162
  describe_host { overview_buttons_provider :host_overview_buttons }
163
+
164
+ # Extend Registration module
165
+ extend_allowed_registration_vars :remote_execution_interface
166
+ extend_page 'registration/_form' do |cx|
167
+ cx.add_pagelet :global_registration, name: N_('Remote Execution'), partial: 'api/v2/registration/form', priority: 100, id: 'remote_execution_interface'
168
+ end
169
+ ForemanTasks.dynflow.eager_load_actions!
170
+ extend_observable_events(::Dynflow::Action.descendants.select { |klass| klass <= ::Actions::ObservableAction }.map(&:namespaced_event_names))
154
171
  end
155
172
  end
156
173
 
@@ -207,6 +224,8 @@ module ForemanRemoteExecution
207
224
  ForemanRemoteExecution.register_rex_feature
208
225
 
209
226
  ::Api::V2::SubnetsController.include ::ForemanRemoteExecution::Concerns::Api::V2::SubnetsControllerExtensions
227
+ ::Api::V2::RegistrationController.prepend ::ForemanRemoteExecution::Concerns::Api::V2::RegistrationControllerExtensions
228
+ ::Api::V2::RegistrationController.include ::ForemanRemoteExecution::Concerns::Api::V2::RegistrationControllerExtensions::ApipieExtensions
210
229
  end
211
230
 
212
231
  initializer 'foreman_remote_execution.register_gettext', after: :load_config_initializers do |_app|
@@ -1,3 +1,3 @@
1
1
  module ForemanRemoteExecution
2
- VERSION = '4.1.0'.freeze
2
+ VERSION = '4.3.0'.freeze
3
3
  end
data/package.json CHANGED
@@ -21,16 +21,16 @@
21
21
  },
22
22
  "devDependencies": {
23
23
  "@babel/core": "^7.7.0",
24
- "@theforeman/builder": "^4.2.1",
25
- "@theforeman/eslint-plugin-foreman": "^4.2.1",
26
- "@theforeman/stories": "^4.2.1",
27
- "@theforeman/test": "^4.2.1",
28
- "@theforeman/vendor-dev": "^4.2.1",
24
+ "@theforeman/builder": "^4.14.0",
25
+ "@theforeman/eslint-plugin-foreman": "^4.14.0",
26
+ "@theforeman/stories": "^4.14.0",
27
+ "@theforeman/test": "^4.14.0",
28
+ "@theforeman/vendor-dev": "^4.14.0",
29
29
  "babel-eslint": "^10.0.0",
30
30
  "eslint": "^6.8.0",
31
31
  "prettier": "^1.19.1"
32
32
  },
33
33
  "peerDependencies": {
34
- "@theforeman/vendor": ">= 4.2.1"
34
+ "@theforeman/vendor": ">= 4.14.0"
35
35
  }
36
36
  }
@@ -137,6 +137,7 @@ module Api
137
137
 
138
138
  test 'should provide empty output for host which does not have a task yet' do
139
139
  JobInvocation.any_instance.expects(:sub_task_for_host).returns(nil)
140
+ JobInvocation.any_instance.expects(:finished?).returns(false)
140
141
  get :output, params: { :job_invocation_id => @invocation.id, :host_id => host.id }
141
142
  result = ActiveSupport::JSON.decode(@response.body)
142
143
  assert_equal result['refresh'], true
@@ -144,6 +145,15 @@ module Api
144
145
  assert_response :success
145
146
  end
146
147
 
148
+ test 'should provide empty output marked as done for host which does not have a task when the job is finished' do
149
+ JobInvocation.any_instance.expects(:sub_task_for_host).returns(nil)
150
+ get :output, params: { :job_invocation_id => @invocation.id, :host_id => host.id }
151
+ result = ActiveSupport::JSON.decode(@response.body)
152
+ assert_equal result['refresh'], false
153
+ assert_equal result['output'], []
154
+ assert_response :success
155
+ end
156
+
147
157
  test 'should fail with 404 for non-existing job invocation' do
148
158
  invocation_id = @invocation.id + 1
149
159
  assert_empty JobInvocation.where(:id => invocation_id)
@@ -160,6 +170,35 @@ module Api
160
170
  end
161
171
  end
162
172
 
173
+ describe '#outputs' do
174
+ test 'should provide outputs for hosts in the job' do
175
+ get :outputs, params: { :id => @invocation.id }
176
+ result = ActiveSupport::JSON.decode(@response.body)
177
+ host_output = result['outputs'].first
178
+ assert_equal host_output['host_id'], @invocation.targeting.host_ids.first
179
+ assert_equal host_output['refresh'], false
180
+ assert_equal host_output['output'], []
181
+ assert_response :success
182
+ end
183
+
184
+ test 'should provide outputs for selected hosts in the job' do
185
+ post :outputs, params: { :id => @invocation.id, :search_query => "id = #{@invocation.targeting.host_ids.first}" }, as: :json
186
+ result = ActiveSupport::JSON.decode(@response.body)
187
+ host_output = result['outputs'].first
188
+ assert_equal host_output['host_id'], @invocation.targeting.host_ids.first
189
+ assert_equal host_output['refresh'], false
190
+ assert_equal host_output['output'], []
191
+ assert_response :success
192
+ end
193
+
194
+ test 'should provide outputs for hosts in the job matching a search query' do
195
+ get :outputs, params: { :id => @invocation.id, :search_query => "name = definitely_not_in_the_job" }
196
+ result = ActiveSupport::JSON.decode(@response.body)
197
+ assert_equal result['outputs'], []
198
+ assert_response :success
199
+ end
200
+ end
201
+
163
202
  describe 'raw output' do
164
203
  let(:fake_output) do
165
204
  (1..5).map do |i|
@@ -172,7 +211,7 @@ module Api
172
211
  let(:host) { @invocation.targeting.hosts.first }
173
212
 
174
213
  test 'should provide raw output for a host' do
175
- JobInvocation.any_instance.expects(:task).returns(OpenStruct.new(:scheduled? => false))
214
+ JobInvocation.any_instance.expects(:task).times(3).returns(OpenStruct.new(:scheduled? => false, :pending? => false))
176
215
  JobInvocation.any_instance.expects(:sub_task_for_host).returns(fake_task)
177
216
  get :raw_output, params: { :job_invocation_id => @invocation.id, :host_id => host.id }
178
217
  result = ActiveSupport::JSON.decode(@response.body)
@@ -185,7 +224,7 @@ module Api
185
224
  start_time = Time.now
186
225
  JobInvocation.any_instance
187
226
  .expects(:task).twice
188
- .returns(OpenStruct.new(:scheduled? => true, :start_at => start_time))
227
+ .returns(OpenStruct.new(:scheduled? => true, :start_at => start_time, :pending? => true))
189
228
  JobInvocation.any_instance.expects(:sub_task_for_host).never
190
229
  get :raw_output, params: { :job_invocation_id => @invocation.id, :host_id => host.id }
191
230
  result = ActiveSupport::JSON.decode(@response.body)
@@ -197,7 +236,7 @@ module Api
197
236
  end
198
237
 
199
238
  test 'should provide raw output for host without task' do
200
- JobInvocation.any_instance.expects(:task).returns(OpenStruct.new(:scheduled? => false))
239
+ JobInvocation.any_instance.expects(:task).times(3).returns(OpenStruct.new(:scheduled? => false, :pending? => true))
201
240
  JobInvocation.any_instance.expects(:sub_task_for_host)
202
241
  get :raw_output, params: { :job_invocation_id => @invocation.id, :host_id => host.id }
203
242
  result = ActiveSupport::JSON.decode(@response.body)
@@ -0,0 +1,73 @@
1
+ require 'test_plugin_helper'
2
+
3
+ module Api
4
+ module V2
5
+ # Tests for the extra methods to play roles on a Host
6
+ class RegistrationControllerTest < ActionController::TestCase
7
+ describe 'host registration' do
8
+ let(:organization) { FactoryBot.create(:organization) }
9
+ let(:tax_location) { FactoryBot.create(:location) }
10
+ let(:template) do
11
+ FactoryBot.create(
12
+ :provisioning_template,
13
+ template_kind: template_kinds(:host_init_config),
14
+ template: 'template content <%= @host.name %>',
15
+ locations: [tax_location],
16
+ organizations: [organization]
17
+ )
18
+ end
19
+ let(:os) do
20
+ FactoryBot.create(
21
+ :operatingsystem,
22
+ :with_associations,
23
+ family: 'Redhat',
24
+ provisioning_templates: [
25
+ template,
26
+ ]
27
+ )
28
+ end
29
+
30
+ let(:host_params) do
31
+ { host: { name: 'centos-test.example.com',
32
+ managed: false, build: false,
33
+ organization_id: organization.id,
34
+ location_id: tax_location.id,
35
+ operatingsystem_id: os.id } }
36
+ end
37
+
38
+ describe 'remote_execution_interface' do
39
+ setup do
40
+ Setting[:default_host_init_config_template] = template.name
41
+ @host = Host.create(host_params[:host])
42
+ @interface0 = FactoryBot.create(:nic_managed, host: @host, identifier: 'dummy0', execution: false)
43
+ end
44
+
45
+ test 'with existing interface' do
46
+ params = host_params.merge(remote_execution_interface: @interface0.identifier)
47
+
48
+ post :host, params: params, session: set_session_user
49
+ assert_response :success
50
+ assert @interface0.reload.execution
51
+ end
52
+
53
+ test 'with not-existing interface' do
54
+ params = host_params.merge(remote_execution_interface: 'dummy999')
55
+
56
+ post :host, params: params, session: set_session_user
57
+ assert_response :not_found
58
+ end
59
+
60
+ test 'with multiple interfaces' do
61
+ interface1 = FactoryBot.create(:nic_managed, host: @host, identifier: 'dummy1', execution: false)
62
+ params = host_params.merge(remote_execution_interface: interface1.identifier)
63
+
64
+ post :host, params: params, session: set_session_user
65
+ assert_response :success
66
+ refute @interface0.reload.execution
67
+ assert interface1.reload.execution
68
+ end
69
+ end
70
+ end
71
+ end
72
+ end
73
+ end
@@ -0,0 +1,16 @@
1
+ require 'test_plugin_helper'
2
+
3
+ class UiJobWizardControllerTest < ActionController::TestCase
4
+ def setup
5
+ FactoryBot.create(:job_template, :job_category => 'cat1')
6
+ FactoryBot.create(:job_template, :job_category => 'cat2')
7
+ FactoryBot.create(:job_template, :job_category => 'cat2')
8
+ end
9
+
10
+ test 'should respond with categories' do
11
+ get :categories, :params => {}, :session => set_session_user
12
+ assert_response :success
13
+ res = JSON.parse @response.body
14
+ assert_equal ['cat1','cat2'], res['job_categories']
15
+ end
16
+ end
@@ -24,6 +24,7 @@ module ForemanRemoteExecution
24
24
  OpenStruct.new(:id => uuid).tap do |o|
25
25
  o.stubs(:add_missing_task_groups)
26
26
  o.stubs(:task_groups).returns([])
27
+ o.stubs(:pending?).returns(true)
27
28
  end
28
29
  end
29
30
  let(:action) do
@@ -0,0 +1,32 @@
1
+ import React from 'react';
2
+ import { Wizard } from '@patternfly/react-core';
3
+ import { translate as __ } from 'foremanReact/common/I18n';
4
+ import history from 'foremanReact/history';
5
+
6
+ export const JobWizard = () => {
7
+ const steps = [
8
+ {
9
+ name: __('Category and template'),
10
+ component: <p>Category and template</p>,
11
+ },
12
+ { name: __('Target hosts'), component: <p>TargetHosts </p> },
13
+ { name: __('Advanced fields'), component: <p> AdvancedFields </p> },
14
+ { name: __('Schedule'), component: <p>Schedule</p> },
15
+ {
16
+ name: __('Review details'),
17
+ component: <p>ReviewDetails</p>,
18
+ nextButtonText: 'Run',
19
+ },
20
+ ];
21
+ const title = __('Run Job');
22
+ return (
23
+ <Wizard
24
+ onClose={() => history.goBack()}
25
+ navAriaLabel={`${title} steps`}
26
+ steps={steps}
27
+ height="70vh"
28
+ />
29
+ );
30
+ };
31
+
32
+ export default JobWizard;
@@ -0,0 +1,32 @@
1
+ import React from 'react';
2
+ import { Title, Divider } from '@patternfly/react-core';
3
+ import { translate as __ } from 'foremanReact/common/I18n';
4
+ import PageLayout from 'foremanReact/routes/common/PageLayout/PageLayout';
5
+ import { JobWizard } from './JobWizard';
6
+
7
+ const JobWizardPage = () => {
8
+ const title = __('Run job');
9
+ const breadcrumbOptions = {
10
+ breadcrumbItems: [
11
+ { caption: __('Jobs'), url: `/jobs` },
12
+ { caption: title },
13
+ ],
14
+ };
15
+ return (
16
+ <PageLayout
17
+ header={title}
18
+ breadcrumbOptions={breadcrumbOptions}
19
+ searchable={false}
20
+ >
21
+ <React.Fragment>
22
+ <Title headingLevel="h2" size="2xl">
23
+ {title}
24
+ </Title>
25
+ <Divider component="div" />
26
+ <JobWizard />
27
+ </React.Fragment>
28
+ </PageLayout>
29
+ );
30
+ };
31
+
32
+ export default JobWizardPage;
@@ -0,0 +1,12 @@
1
+ import React from 'react';
2
+ import JobWizardPage from '../JobWizard';
3
+
4
+ const ForemanREXRoutes = [
5
+ {
6
+ path: '/experimental/job_wizard',
7
+ exact: true,
8
+ render: props => <JobWizardPage {...props} />,
9
+ },
10
+ ];
11
+
12
+ export default ForemanREXRoutes;
@@ -0,0 +1 @@
1
+ export default { goBack: () => null };
@@ -0,0 +1,4 @@
1
+ import { registerRoutes } from 'foremanReact/routes/RoutingService';
2
+ import routes from './Routes/routes';
3
+
4
+ registerRoutes('foreman_remote_execution', routes);
@@ -51,8 +51,12 @@ const TargetingHosts = ({ apiStatus, items }) => {
51
51
  };
52
52
 
53
53
  TargetingHosts.propTypes = {
54
- apiStatus: PropTypes.string.isRequired,
54
+ apiStatus: PropTypes.string,
55
55
  items: PropTypes.array.isRequired,
56
56
  };
57
57
 
58
+ TargetingHosts.defaultProps = {
59
+ apiStatus: null,
60
+ };
61
+
58
62
  export default TargetingHosts;
@@ -39,7 +39,7 @@ const TargetingHostsPage = ({
39
39
  <br />
40
40
  <TargetingHosts apiStatus={apiStatus} items={items} />
41
41
  <Pagination
42
- viewType="list"
42
+ viewType="table"
43
43
  itemCount={totalHosts}
44
44
  pagination={pagination}
45
45
  onChange={args => handlePagination(args)}
@@ -52,11 +52,15 @@ const TargetingHostsPage = ({
52
52
  TargetingHostsPage.propTypes = {
53
53
  handleSearch: PropTypes.func.isRequired,
54
54
  searchQuery: PropTypes.string.isRequired,
55
- apiStatus: PropTypes.string.isRequired,
55
+ apiStatus: PropTypes.string,
56
56
  items: PropTypes.array.isRequired,
57
57
  totalHosts: PropTypes.number.isRequired,
58
58
  pagination: PropTypes.object.isRequired,
59
59
  handlePagination: PropTypes.func.isRequired,
60
60
  };
61
61
 
62
+ TargetingHostsPage.defaultProps = {
63
+ apiStatus: null,
64
+ };
65
+
62
66
  export default TargetingHostsPage;