foreman_remote_execution 17.0.0 → 17.2.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 (42) hide show
  1. checksums.yaml +4 -4
  2. data/app/controllers/api/v2/job_invocations_controller.rb +24 -2
  3. data/app/controllers/job_invocations_controller.rb +1 -28
  4. data/app/models/remote_execution_provider.rb +11 -0
  5. data/app/models/script_execution_provider.rb +8 -5
  6. data/app/views/api/v2/job_invocations/hosts.json.rabl +8 -0
  7. data/config/routes.rb +0 -1
  8. data/lib/foreman_remote_execution/plugin.rb +11 -2
  9. data/lib/foreman_remote_execution/version.rb +1 -1
  10. data/test/functional/api/v2/job_invocations_controller_test.rb +46 -7
  11. data/test/unit/remote_execution_provider_test.rb +56 -0
  12. data/webpack/JobInvocationDetail/CheckboxesActions.js +1 -0
  13. data/webpack/JobInvocationDetail/DropdownFilter.js +1 -0
  14. data/webpack/JobInvocationDetail/JobInvocationActions.js +10 -4
  15. data/webpack/JobInvocationDetail/JobInvocationConstants.js +3 -2
  16. data/webpack/JobInvocationDetail/JobInvocationDetail.scss +5 -3
  17. data/webpack/JobInvocationDetail/JobInvocationHostTable.js +24 -12
  18. data/webpack/JobInvocationDetail/JobInvocationSelectors.js +0 -5
  19. data/webpack/JobInvocationDetail/JobInvocationSystemStatusChart.js +5 -2
  20. data/webpack/JobInvocationDetail/TemplateInvocation.js +7 -1
  21. data/webpack/JobInvocationDetail/TemplateInvocationComponents/OutputCodeBlock.js +2 -1
  22. data/webpack/JobInvocationDetail/TemplateInvocationComponents/TemplateActionButtons.js +17 -6
  23. data/webpack/JobInvocationDetail/TemplateInvocationComponents/index.scss +0 -1
  24. data/webpack/JobInvocationDetail/__tests__/JobInvocationHostTablePolling.test.js +33 -0
  25. data/webpack/JobInvocationDetail/__tests__/JobInvocationPolling.test.js +17 -12
  26. data/webpack/JobInvocationDetail/index.js +14 -1
  27. data/webpack/JobWizard/JobWizard.js +5 -1
  28. data/webpack/JobWizard/JobWizard.scss +21 -0
  29. data/webpack/JobWizard/JobWizardConstants.js +10 -1
  30. data/webpack/JobWizard/steps/Schedule/RepeatHour.js +4 -2
  31. data/webpack/JobWizard/steps/Schedule/RepeatWeek.js +4 -2
  32. data/webpack/JobWizard/steps/Schedule/ScheduleRecurring.js +2 -1
  33. data/webpack/JobWizard/steps/Schedule/ScheduleType.js +4 -1
  34. data/webpack/JobWizard/steps/form/DateTimePicker.js +12 -6
  35. data/webpack/JobWizard/steps/form/FormHelpers.js +5 -4
  36. data/webpack/react_app/components/FeaturesDropdown/index.scss +2 -0
  37. data/webpack/react_app/components/TargetingHosts/TargetingHostsLabelsRow.scss +6 -2
  38. data/webpack/react_app/components/TargetingHosts/__tests__/__snapshots__/TargetingHostsPage.test.js.snap +1 -1
  39. data/webpack/react_app/components/TargetingHosts/index.js +2 -1
  40. data/webpack/react_app/redux/actions/jobInvocations/index.js +3 -2
  41. metadata +2 -3
  42. data/webpack/__mocks__/foremanReact/constants.js +0 -25
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: d0c5f9e975897ee24ccf2c91fafe81e998742ec7453ca0e7cf3aea4846962b23
4
- data.tar.gz: 20f9ca73d78bd41ce758b3ac39e1e880be6630230eb64d735850a88ae7b9cbec
3
+ metadata.gz: e240991349f9cb6bc60f979202f8de2bde03d3d8100c13e701469aa2f8e29a97
4
+ data.tar.gz: f9936d90d62ce33221969367e5625cdf750c36922855142f460f46b18a8bc925
5
5
  SHA512:
6
- metadata.gz: 57609a9b217ab4e7e5e60536a6a4de788b26d46d09f68330ca385fbd4df59d65cabc33581026ba15795aacbfc96b1fe6a97b735e006dfcf0a51188032ea525b9
7
- data.tar.gz: 23d742ef78d0f9dc5cc205d541c74a8c3a3472618c4e67a6cb1c5c94c552a2ea6aa601ce38b498f9fae2403227f0efdf44055fefe8cc90df1de86e3c7b6fc96f
6
+ metadata.gz: eaf96768e4b112d387e660cca80d6b9dd360dd1145cf40b5540ea428bdf4bf3482e47591a05d2e2f0bcc05e36808e8163346258f323cc7f7f60917db3b930686
7
+ data.tar.gz: 56dbf3ac2a8cb8f90c8ae719fc5213c2fbf40588812bf98552fa8bdd967b6d1150632543dfb6992df09050d74813dd3eeb367cc857c3709a921a404b3772c823
@@ -117,12 +117,14 @@ module Api
117
117
  param_group :search_and_pagination, ::Api::V2::BaseController
118
118
  add_scoped_search_description_for(JobInvocation)
119
119
  param :id, :identifier, :required => true
120
+ param :include_permissions, :bool, :required => false, :desc => N_('Include per-host task and permission data in the response')
120
121
  def hosts
121
122
  set_hosts_and_template_invocations
122
123
  @total = @hosts.size
123
124
  @hosts = @hosts.search_for(params[:search], :order => params[:order]).paginate(:page => params[:page], :per_page => params[:per_page])
124
125
  @subtotal = @hosts.total_entries
125
126
  set_statuses_and_smart_proxies
127
+ set_tasks_and_permissions if Foreman::Cast.to_bool(params[:include_permissions])
126
128
  if params[:awaiting]
127
129
  @hosts = @hosts.select { |host| @host_statuses[host.id] == 'N/A' }
128
130
  end
@@ -321,15 +323,35 @@ module Api
321
323
  template_invocations = @template_invocations.where(host_id: @hosts.select(:id))
322
324
  .includes(:run_host_job_task).to_a
323
325
  hosts = @hosts.to_a
324
- template_invocations_by_host_id = template_invocations.index_by(&:host_id)
326
+ @template_invocations_by_host_id = template_invocations.index_by(&:host_id)
325
327
  @host_statuses = hosts.to_h do |host|
326
- template_invocation = template_invocations_by_host_id[host.id]
328
+ template_invocation = @template_invocations_by_host_id[host.id]
327
329
  task = template_invocation.try(:run_host_job_task)
328
330
  [host.id, template_invocation_status(task, @job_invocation.task)]
329
331
  end
330
332
  @smart_proxy_id = template_invocations.to_h { |ti| [ti.host_id, ti.smart_proxy_id] }
331
333
  @smart_proxy_name = template_invocations.to_h { |ti| [ti.host_id, ti.smart_proxy_name] }
332
334
  end
335
+
336
+ def set_tasks_and_permissions
337
+ can_cancel = authorized_for(:permission => :cancel_job_invocations, :auth_object => @job_invocation)
338
+ can_create = authorized_for(controller: :job_invocations, action: :create)
339
+ can_execute_on_infra = User.current.can?(:execute_jobs_on_infrastructure_hosts)
340
+
341
+ hosts = @hosts.to_a
342
+ @task_by_host = hosts.to_h do |host|
343
+ task = @template_invocations_by_host_id[host.id].try(:run_host_job_task)
344
+ [host.id, task ? { :id => task.id, :cancellable => task.try(:cancellable?) || false } : nil]
345
+ end
346
+ @permissions_by_host = hosts.to_h do |host|
347
+ task = @template_invocations_by_host_id[host.id].try(:run_host_job_task)
348
+ [host.id, {
349
+ :view_foreman_tasks => authorized_for(:permission => :view_foreman_tasks, :auth_object => task),
350
+ :cancel_job_invocations => can_cancel,
351
+ :execute_jobs => can_create && (!host.infrastructure_host? || can_execute_on_infra),
352
+ }]
353
+ end
354
+ end
333
355
  end
334
356
  end
335
357
  end
@@ -149,33 +149,6 @@ class JobInvocationsController < ApplicationController
149
149
  render :json => {:job_invocations => job_invocations}
150
150
  end
151
151
 
152
- def list_jobs_hosts
153
- @job_invocation = resource_base.find(params[:id])
154
- hosts = @job_invocation.targeting.hosts.authorized(:view_hosts, Host)
155
- hosts = hosts.search_for(params[:search])
156
- template_invocations_task_by_hosts = {}
157
- hosts.each do |host|
158
- template_invocation = @job_invocation.template_invocations.find { |template_inv| template_inv.host_id == host.id }
159
- next unless template_invocation
160
- template_invocation_task = template_invocation.run_host_job_task
161
- template_invocations_task_by_hosts[host.id] =
162
- {
163
- :host_name => host.name,
164
- :id => host.id,
165
- :task => template_invocation_task.attributes.merge({cancellable: template_invocation_task.cancellable? }),
166
- :permissions => {
167
- :view_foreman_tasks => authorized_for(:permission => :view_foreman_tasks, :auth_object => template_invocation_task),
168
- :cancel_job_invocations => authorized_for(:permission => :cancel_job_invocations, :auth_object => @job_invocation),
169
- :execute_jobs => authorized_for(controller: :job_invocations, action: :create) && (!host.infrastructure_host? || User.current.can?(:execute_jobs_on_infrastructure_hosts)),
170
- },
171
- }
172
- end
173
-
174
- render json: {
175
- :template_invocations_task_by_hosts => template_invocations_task_by_hosts,
176
- }
177
- end
178
-
179
152
  private
180
153
 
181
154
  def action_permission
@@ -186,7 +159,7 @@ class JobInvocationsController < ApplicationController
186
159
  'create'
187
160
  when 'cancel'
188
161
  'cancel'
189
- when 'chart', 'preview_job_invocations_per_host', 'list_jobs_hosts'
162
+ when 'chart', 'preview_job_invocations_per_host'
190
163
  'view'
191
164
  else
192
165
  super
@@ -76,6 +76,17 @@ class RemoteExecutionProvider
76
76
  [true, 'true', 'True', 'TRUE', '1'].include?(setting)
77
77
  end
78
78
 
79
+ def remote_working_dir(host)
80
+ dir = host_setting(host, :remote_execution_remote_working_dir).to_s.strip
81
+ unless Pathname.new(dir).absolute?
82
+ raise ::Foreman::Exception.new(
83
+ N_('Remote working directory "%{current_value}" is not an absolute path'),
84
+ current_value: dir
85
+ )
86
+ end
87
+ dir
88
+ end
89
+
79
90
  def effective_user_password(host)
80
91
  host_setting(host, :remote_execution_effective_user_password)
81
92
  end
@@ -1,11 +1,14 @@
1
1
  class ScriptExecutionProvider < RemoteExecutionProvider
2
2
  class << self
3
3
  def proxy_command_options(template_invocation, host)
4
- super.merge(:ssh_user => ssh_user(host, template_invocation.job_invocation),
5
- :effective_user => effective_user(template_invocation),
6
- :effective_user_method => effective_user_method(host),
7
- :cleanup_working_dirs => cleanup_working_dirs?(host),
8
- :ssh_port => ssh_port(host))
4
+ super.merge(
5
+ ssh_user: ssh_user(host, template_invocation.job_invocation),
6
+ effective_user: effective_user(template_invocation),
7
+ effective_user_method: effective_user_method(host),
8
+ cleanup_working_dirs: cleanup_working_dirs?(host),
9
+ remote_working_dir: remote_working_dir(host),
10
+ ssh_port: ssh_port(host)
11
+ )
9
12
  end
10
13
 
11
14
  def humanized_name
@@ -13,3 +13,11 @@ end
13
13
  node :smart_proxy_name do |host|
14
14
  @smart_proxy_name[host.id]
15
15
  end
16
+
17
+ node(:task, :if => ->(_host) { @task_by_host }) do |host|
18
+ @task_by_host[host.id]
19
+ end
20
+
21
+ node(:permissions, :if => ->(_host) { @permissions_by_host }) do |host|
22
+ @permissions_by_host[host.id]
23
+ end
data/config/routes.rb CHANGED
@@ -35,7 +35,6 @@ Rails.application.routes.draw do
35
35
  get 'auto_complete_search'
36
36
  end
37
37
  member do
38
- get 'hosts', to: 'job_invocations#list_jobs_hosts'
39
38
  post 'cancel'
40
39
  end
41
40
  end
@@ -86,6 +86,15 @@ Foreman::Plugin.register :foreman_remote_execution do
86
86
  description: N_('When enabled, working directories will be removed after task completion. You may override this per host by setting a parameter called remote_execution_cleanup_working_dirs.'),
87
87
  default: true,
88
88
  full_name: N_('Cleanup working directories')
89
+ setting 'remote_execution_remote_working_dir',
90
+ type: :string,
91
+ description: N_('Directory on the host where remote execution jobs in push mode place and run their scripts. '\
92
+ 'You can override this setting per host with the `remote_execution_remote_working_dir` host parameter.'),
93
+ default: '/var/tmp',
94
+ full_name: N_('Remote working directory')
95
+ validates 'remote_execution_remote_working_dir',
96
+ ->(value) { Pathname.new(value.to_s.strip).absolute? },
97
+ message: N_('must be an absolute path')
89
98
  setting 'remote_execution_cockpit_url',
90
99
  type: :string,
91
100
  description: N_('Where to find the Cockpit instance for the Web Console button. By default, no button is shown.'),
@@ -134,9 +143,9 @@ Foreman::Plugin.register :foreman_remote_execution do
134
143
  permission :lock_job_templates, { :job_templates => [:lock, :unlock] }, :resource_type => 'JobTemplate'
135
144
  permission :create_job_invocations, { :job_invocations => [:new, :create, :legacy_create, :refresh, :rerun, :preview_hosts],
136
145
  'api/v2/job_invocations' => [:create, :rerun] }, :resource_type => 'JobInvocation'
137
- permission :view_job_invocations, { :job_invocations => [:index, :chart, :show, :auto_complete_search, :preview_job_invocations_per_host, :list_jobs_hosts], :template_invocations => [:show, :show_template_invocation_by_host],
146
+ permission :view_job_invocations, { :job_invocations => [:index, :chart, :show, :auto_complete_search, :preview_job_invocations_per_host], :template_invocations => [:show, :show_template_invocation_by_host],
138
147
  'api/v2/job_invocations' => [:index, :show, :output, :raw_output, :outputs, :hosts] }, :resource_type => 'JobInvocation'
139
- permission :view_template_invocations, { :template_invocations => [:show, :template_invocation_preview, :show_template_invocation_by_host], :job_invocations => [:list_jobs_hosts],
148
+ permission :view_template_invocations, { :template_invocations => [:show, :template_invocation_preview, :show_template_invocation_by_host],
140
149
  'api/v2/template_invocations' => [:template_invocations], :ui_job_wizard => [:job_invocation] }, :resource_type => 'TemplateInvocation'
141
150
  permission :create_template_invocations, {}, :resource_type => 'TemplateInvocation'
142
151
  permission :execute_jobs_on_infrastructure_hosts, {}, :resource_type => 'JobInvocation'
@@ -1,3 +1,3 @@
1
1
  module ForemanRemoteExecution
2
- VERSION = '17.0.0'.freeze
2
+ VERSION = '17.2.0'.freeze
3
3
  end
@@ -284,20 +284,59 @@ module Api
284
284
  end
285
285
 
286
286
  describe '#hosts' do
287
- test 'should compute job_status for the paginated subset' do
288
- invocation = FactoryBot.create(:job_invocation, :with_template, :with_task)
289
- 2.times { invocation.template_invocations << FactoryBot.create(:template_invocation, :with_task, :with_host, :job_invocation => invocation) }
290
- invocation.job_category = invocation.pattern_template_invocations.first.template.job_category
291
- invocation.targeting.hosts = invocation.template_invocations.map(&:host)
292
- invocation.save!
287
+ setup do
288
+ @hosts_invocation = FactoryBot.create(:job_invocation, :with_template, :with_task)
289
+ 2.times { @hosts_invocation.template_invocations << FactoryBot.create(:template_invocation, :with_task, :with_host, :job_invocation => @hosts_invocation) }
290
+ @hosts_invocation.job_category = @hosts_invocation.pattern_template_invocations.first.template.job_category
291
+ @hosts_invocation.targeting.hosts = @hosts_invocation.template_invocations.map(&:host)
292
+ @hosts_invocation.save!
293
+ end
293
294
 
294
- get :hosts, params: { :id => invocation.id, :page => 2, :per_page => 1 }
295
+ test 'should compute job_status for the paginated subset' do
296
+ get :hosts, params: { :id => @hosts_invocation.id, :page => 2, :per_page => 1 }
295
297
  assert_response :success
296
298
  result = ActiveSupport::JSON.decode(@response.body)
297
299
  assert_equal 3, result['total']
298
300
  assert_equal 1, result['results'].size
299
301
  assert_equal(['success'], result['results'].map { |r| r['job_status'] })
300
302
  end
303
+
304
+ test 'should not include task and permissions without include_permissions' do
305
+ get :hosts, params: { :id => @hosts_invocation.id }
306
+ assert_response :success
307
+ result = ActiveSupport::JSON.decode(@response.body)
308
+ result['results'].each do |host|
309
+ assert_not host.key?('task'), 'Expected task to be absent'
310
+ assert_not host.key?('permissions'), 'Expected permissions to be absent'
311
+ end
312
+ end
313
+
314
+ test 'should include task and permissions with include_permissions=true' do
315
+ get :hosts, params: { :id => @hosts_invocation.id, :include_permissions => true }
316
+ assert_response :success
317
+ result = ActiveSupport::JSON.decode(@response.body)
318
+ result['results'].each do |host|
319
+ assert host.key?('task'), 'Expected task to be present'
320
+ assert host.key?('permissions'), 'Expected permissions to be present'
321
+ permissions = host['permissions']
322
+ assert permissions.key?('view_foreman_tasks')
323
+ assert permissions.key?('cancel_job_invocations')
324
+ assert permissions.key?('execute_jobs')
325
+ end
326
+ end
327
+
328
+ test 'should handle hosts with nil task gracefully when include_permissions=true' do
329
+ invocation = FactoryBot.create(:job_invocation, :with_template, :with_task, :with_unplanned_host)
330
+ invocation.job_category = invocation.pattern_template_invocations.first.template.job_category
331
+ invocation.save!
332
+
333
+ get :hosts, params: { :id => invocation.id, :include_permissions => true }
334
+ assert_response :success
335
+ result = ActiveSupport::JSON.decode(@response.body)
336
+ result['results'].each do |host|
337
+ assert host.key?('permissions'), 'Expected permissions to be present'
338
+ end
339
+ end
301
340
  end
302
341
 
303
342
  describe 'raw output' do
@@ -202,6 +202,62 @@ class RemoteExecutionProviderTest < ActiveSupport::TestCase
202
202
  end
203
203
  end
204
204
 
205
+ describe 'remote working directory' do
206
+ it 'defaults to the value of the global setting' do
207
+ assert_equal '/var/tmp', proxy_options[:remote_working_dir]
208
+ end
209
+
210
+ it 'updates the value via settings' do
211
+ Setting[:remote_execution_remote_working_dir] = '/opt/rex'
212
+ assert_equal '/opt/rex', proxy_options[:remote_working_dir]
213
+ end
214
+
215
+ it 'takes the value from host parameters over the global setting' do
216
+ Setting[:remote_execution_remote_working_dir] = '/opt/rex'
217
+ host.host_parameters << FactoryBot.build(:host_parameter, name: 'remote_execution_remote_working_dir', value: '/opt/host')
218
+ host.clear_host_parameters_cache!
219
+ assert_equal '/opt/host', proxy_options[:remote_working_dir]
220
+ end
221
+
222
+ it 'takes the value inherited from the host group over the global setting' do
223
+ Setting[:remote_execution_remote_working_dir] = '/opt/rex'
224
+ hostgroup = FactoryBot.create(:hostgroup)
225
+ hostgroup.group_parameters << FactoryBot.build(:hostgroup_parameter, name: 'remote_execution_remote_working_dir', value: '/opt/hostgroup')
226
+ host.update!(hostgroup: hostgroup)
227
+ host.clear_host_parameters_cache!
228
+ assert_equal '/opt/hostgroup', proxy_options[:remote_working_dir]
229
+ end
230
+
231
+ it 'strips surrounding whitespace' do
232
+ Setting[:remote_execution_remote_working_dir] = ' /opt/rex '
233
+ assert_equal '/opt/rex', proxy_options[:remote_working_dir]
234
+ end
235
+
236
+ it 'rejects a setting value that is not an absolute path' do
237
+ assert_raises(ActiveRecord::RecordInvalid) do
238
+ Setting[:remote_execution_remote_working_dir] = 'opt/rex'
239
+ end
240
+ end
241
+
242
+ it 'rejects an empty setting value' do
243
+ assert_raises(ActiveRecord::RecordInvalid) do
244
+ Setting[:remote_execution_remote_working_dir] = ''
245
+ end
246
+ end
247
+
248
+ it 'raises when the host parameter is not an absolute path' do
249
+ host.host_parameters << FactoryBot.build(:host_parameter, name: 'remote_execution_remote_working_dir', value: 'opt/host')
250
+ host.clear_host_parameters_cache!
251
+ assert_raises(::Foreman::Exception) { proxy_options }
252
+ end
253
+
254
+ it 'raises when the host parameter is empty' do
255
+ host.host_parameters << FactoryBot.build(:host_parameter, name: 'remote_execution_remote_working_dir', value: '')
256
+ host.clear_host_parameters_cache!
257
+ assert_raises(::Foreman::Exception) { proxy_options }
258
+ end
259
+ end
260
+
205
261
  describe '#find_ip_or_hostname' do
206
262
  let(:host) do
207
263
  FactoryBot.create(:host) do |host|
@@ -86,6 +86,7 @@ const ActionsKebab = ({
86
86
  id="toggle-kebab"
87
87
  isExpanded={isDropdownOpen}
88
88
  onClick={() => setIsDropdownOpen(prev => !prev)}
89
+ ouiaId="actions-kebab-toggle"
89
90
  ref={toggleRef}
90
91
  variant="plain"
91
92
  >
@@ -22,6 +22,7 @@ const DropdownFilter = ({ dropdownFilter, setDropdownFilter }) => {
22
22
  ref={toggleRef}
23
23
  onClick={() => setIsOpen(!isOpen)}
24
24
  isExpanded={isOpen}
25
+ ouiaId="host-status-select-toggle"
25
26
  style={{
26
27
  width: '200px',
27
28
  }}
@@ -7,10 +7,9 @@ import {
7
7
  CHANGE_ENABLED_RECURRING_LOGIC,
8
8
  JOB_INVOCATION_KEY,
9
9
  STATUS,
10
+ AUTO_REFRESH_INTERVAL_MS,
10
11
  } from './JobInvocationConstants';
11
12
 
12
- const POLL_INTERVAL = 5000;
13
-
14
13
  export const isJobFinished = statusLabel =>
15
14
  statusLabel === STATUS.FAILED ||
16
15
  statusLabel === STATUS.SUCCEEDED ||
@@ -26,20 +25,27 @@ const createPollState = cancel => ({ timeoutId: null, cancel });
26
25
 
27
26
  export const getJobInvocation = (url, pollTimeoutRef) => dispatch => {
28
27
  let cancelled = false;
28
+ let isFirstCall = true;
29
29
 
30
30
  const poll = () => {
31
31
  if (cancelled) return;
32
+ const params = { include_hosts: false };
33
+ if (isFirstCall) {
34
+ params.include_permissions = true;
35
+ isFirstCall = false;
36
+ }
37
+
32
38
  dispatch(
33
39
  APIActions.get({
34
40
  key: JOB_INVOCATION_KEY,
35
- params: { include_permissions: true, include_hosts: false },
41
+ params,
36
42
  url,
37
43
  handleSuccess: ({ data }) => {
38
44
  if (cancelled) return;
39
45
  // eslint-disable-next-line camelcase
40
46
  pollTimeoutRef.current.timeoutId = isJobFinished(data?.status_label)
41
47
  ? null
42
- : setTimeout(poll, POLL_INTERVAL);
48
+ : setTimeout(poll, AUTO_REFRESH_INTERVAL_MS);
43
49
  },
44
50
  handleError: () => {
45
51
  if (cancelled) return;
@@ -15,14 +15,15 @@ export const GET_REPORT_TEMPLATE_INPUTS = 'GET_REPORT_TEMPLATE_INPUTS';
15
15
  export const JOB_INVOCATION_HOSTS = 'JOB_INVOCATION_HOSTS';
16
16
  export const GET_TEMPLATE_INVOCATION = 'GET_TEMPLATE_INVOCATION';
17
17
  export const DIRECT_OPEN_HOST_LIMIT = 3;
18
- export const ALL_JOB_HOSTS = 'ALL_JOB_HOSTS';
19
18
  export const AWAITING_STATUS_FILTER = '(job_invocation.result = N/A)';
20
19
 
21
20
  export const AUTO_REFRESH_INTERVAL_MS = 5000;
21
+ export const DEFAULT_CHART_LEGEND_WIDTH = 270;
22
+ export const CLIPBOARD_COPIED_EXIT_DELAY_MS = 1500;
23
+ export const CLIPBOARD_DEFAULT_EXIT_DELAY_MS = 600;
22
24
 
23
25
  export const showTemplateInvocationUrl = (hostID, jobID) =>
24
26
  `/show_template_invocation_by_host/${hostID}/job_invocation/${jobID}`;
25
- export const LIST_TEMPLATE_INVOCATIONS = 'LIST_TEMPLATE_INVOCATIONS';
26
27
 
27
28
  export const templateInvocationPageUrl = (hostID, jobID) =>
28
29
  `/job_invocations_detail/${jobID}/host_invocation/${hostID}`;
@@ -1,5 +1,6 @@
1
1
  .job-invocation-detail-flex {
2
2
  $chart_size: 105px;
3
+
3
4
  padding-top: 0px;
4
5
  padding-left: 10px;
5
6
  display: flex;
@@ -92,16 +93,17 @@ section.job-additional-info {
92
93
  max-height: 25em;
93
94
  }
94
95
  }
96
+
95
97
  div.invocation-output {
96
98
  display: block;
97
99
  padding: 9.5px;
98
100
  margin: 0 0 10px;
99
101
  font-size: 12px;
100
102
  word-break: break-all;
101
- word-wrap: break-word;
103
+ overflow-wrap: break-word;
102
104
  color: rgba(255, 255, 255, 1);
103
105
  background-color: rgba(47, 47, 47, 1);
104
- border: 1px solid #000000;
106
+ border: 1px solid #000;
105
107
  border-radius: 0px;
106
108
  font-family: Menlo, Monaco, Consolas, monospace;
107
109
 
@@ -127,7 +129,7 @@ section.job-additional-info {
127
129
  }
128
130
 
129
131
  a {
130
- color: #ffffff;
132
+ color: #fff;
131
133
  }
132
134
 
133
135
  a.scroll-link{
@@ -39,7 +39,6 @@ import { CheckboxesActions } from './CheckboxesActions';
39
39
  import DropdownFilter from './DropdownFilter';
40
40
  import Columns, {
41
41
  JOB_INVOCATION_HOSTS,
42
- LIST_TEMPLATE_INVOCATIONS,
43
42
  STATUS_UPPERCASE,
44
43
  AWAITING_STATUS_FILTER,
45
44
  AUTO_REFRESH_INTERVAL_MS,
@@ -163,11 +162,24 @@ const JobInvocationHostTable = ({
163
162
  [initialFilter, urlSearchQuery]
164
163
  );
165
164
 
165
+ const [hostPermissions, setHostPermissions] = useState({});
166
+
166
167
  const updateHostsState = useCallback(data => {
167
168
  const ids = data.data.results.map(i => i.id);
168
169
  setApiResponse(data.data);
169
170
  setAllHostsIds(ids);
170
171
  setStatus(STATUS_UPPERCASE.RESOLVED);
172
+
173
+ const resultsWithPermissions = data.data.results.filter(r => r.permissions);
174
+ if (resultsWithPermissions.length > 0) {
175
+ setHostPermissions(prev => {
176
+ const updated = { ...prev };
177
+ resultsWithPermissions.forEach(r => {
178
+ updated[r.id] = { task: r.task, permissions: r.permissions };
179
+ });
180
+ return updated;
181
+ });
182
+ }
171
183
  }, []);
172
184
 
173
185
  // Call hosts data with params
@@ -229,7 +241,10 @@ const JobInvocationHostTable = ({
229
241
  finalParams.search = filterSearch;
230
242
  }
231
243
 
232
- currentPollParams.current = finalParams;
244
+ finalParams.include_permissions = true;
245
+
246
+ currentPollParams.current = { ...finalParams };
247
+ delete currentPollParams.current.include_permissions;
233
248
  clearTimeout(pollTimeoutId.current);
234
249
  pollTimeoutId.current = null;
235
250
 
@@ -258,20 +273,12 @@ const JobInvocationHostTable = ({
258
273
  const initializedRef = useRef(false);
259
274
  useEffect(() => {
260
275
  if (!initializedRef.current) {
261
- dispatch(
262
- APIActions.get({
263
- key: LIST_TEMPLATE_INVOCATIONS,
264
- url: `/job_invocations/${id}/hosts`,
265
- params: {},
266
- })
267
- );
268
-
269
276
  if (initialFilter === '') {
270
277
  onFilterUpdate('all_statuses');
271
278
  }
272
279
  initializedRef.current = true;
273
280
  }
274
- }, [dispatch, id, initialFilter, onFilterUpdate]);
281
+ }, [initialFilter, onFilterUpdate]);
275
282
 
276
283
  useEffect(() => {
277
284
  const filterChanged = initialFilter !== prevFilter.current;
@@ -506,7 +513,12 @@ const JobInvocationHostTable = ({
506
513
  <Td key={k}>{columns[k].wrapper(result)}</Td>
507
514
  ))}
508
515
  <Td isActionCell>
509
- <RowActions hostID={result.id} jobID={id} />
516
+ <RowActions
517
+ hostID={result.id}
518
+ jobID={id}
519
+ task={hostPermissions[result.id]?.task}
520
+ permissions={hostPermissions[result.id]?.permissions}
521
+ />
510
522
  </Td>
511
523
  </Tr>
512
524
  <Tr
@@ -6,7 +6,6 @@ import {
6
6
  import {
7
7
  JOB_INVOCATION_KEY,
8
8
  GET_TEMPLATE_INVOCATION,
9
- LIST_TEMPLATE_INVOCATIONS,
10
9
  } from './JobInvocationConstants';
11
10
 
12
11
  export const selectItems = state =>
@@ -20,7 +19,3 @@ export const selectTemplateInvocation = hostID => state =>
20
19
 
21
20
  export const selectTemplateInvocationStatus = hostID => state =>
22
21
  selectAPIStatus(state, `${GET_TEMPLATE_INVOCATION}_${hostID}`);
23
-
24
- export const selectTemplateInvocationList = state =>
25
- selectAPIResponse(state, LIST_TEMPLATE_INVOCATIONS)
26
- ?.template_invocations_task_by_hosts;
@@ -23,7 +23,10 @@ import {
23
23
  global_palette_blue_300 as inProgressColor,
24
24
  global_palette_green_500 as successedColor,
25
25
  } from '@patternfly/react-tokens';
26
- import { STATUS_TITLES } from './JobInvocationConstants';
26
+ import {
27
+ STATUS_TITLES,
28
+ DEFAULT_CHART_LEGEND_WIDTH,
29
+ } from './JobInvocationConstants';
27
30
  import './JobInvocationDetail.scss';
28
31
 
29
32
  const JobInvocationSystemStatusChart = ({
@@ -52,7 +55,7 @@ const JobInvocationSystemStatusChart = ({
52
55
  return '0';
53
56
  };
54
57
  const chartSize = 105;
55
- const [legendWidth, setLegendWidth] = useState(270);
58
+ const [legendWidth, setLegendWidth] = useState(DEFAULT_CHART_LEGEND_WIDTH);
56
59
 
57
60
  // Calculates chart legend width based on its content
58
61
  useEffect(() => {
@@ -12,6 +12,8 @@ import {
12
12
  templateInvocationPageUrl,
13
13
  GET_TEMPLATE_INVOCATION,
14
14
  AUTO_REFRESH_INTERVAL_MS,
15
+ CLIPBOARD_COPIED_EXIT_DELAY_MS,
16
+ CLIPBOARD_DEFAULT_EXIT_DELAY_MS,
15
17
  } from './JobInvocationConstants';
16
18
  import {
17
19
  selectTemplateInvocationStatus,
@@ -42,7 +44,11 @@ const CopyToClipboard = ({ fullOutput }) => {
42
44
  textId="code-content"
43
45
  aria-label="Copy to clipboard"
44
46
  onClick={e => onClick(e, fullOutput)}
45
- exitDelay={copied ? 1500 : 600}
47
+ exitDelay={
48
+ copied
49
+ ? CLIPBOARD_COPIED_EXIT_DELAY_MS
50
+ : CLIPBOARD_DEFAULT_EXIT_DELAY_MS
51
+ }
46
52
  maxWidth="110px"
47
53
  variant="plain"
48
54
  onTooltipHidden={() => setCopied(false)}
@@ -7,6 +7,7 @@ import React, {
7
7
  import PropTypes from 'prop-types';
8
8
  import { Button } from '@patternfly/react-core';
9
9
  import { translate as __ } from 'foremanReact/common/I18n';
10
+ import { MS_PER_SECOND } from 'foremanReact/constants';
10
11
 
11
12
  export const OutputCodeBlock = ({ code, showOutputType, scrollElement }) => {
12
13
  let lineCounter = 0;
@@ -122,7 +123,7 @@ export const OutputCodeBlock = ({ code, showOutputType, scrollElement }) => {
122
123
  <div key={index} className={`line ${line.output_type}`}>
123
124
  <span
124
125
  className="counter"
125
- title={new Date(line.timestamp * 1000).toISOString()}
126
+ title={new Date(line.timestamp * MS_PER_SECOND).toISOString()}
126
127
  >
127
128
  {lineCounter.toString().padStart(4, '\u00A0')}:{' '}
128
129
  </span>
@@ -1,12 +1,11 @@
1
1
  import React from 'react';
2
2
  import PropTypes from 'prop-types';
3
- import { useDispatch, useSelector } from 'react-redux';
3
+ import { useDispatch } from 'react-redux';
4
4
  import { Flex, FlexItem, Button } from '@patternfly/react-core';
5
5
  import { ActionsColumn } from '@patternfly/react-table';
6
6
  import { APIActions } from 'foremanReact/redux/API';
7
7
  import { addToast } from 'foremanReact/components/ToastsList';
8
8
  import { translate as __ } from 'foremanReact/common/I18n';
9
- import { selectTemplateInvocationList } from '../JobInvocationSelectors';
10
9
  import './index.scss';
11
10
 
12
11
  const actions = ({
@@ -81,11 +80,9 @@ const actions = ({
81
80
  },
82
81
  });
83
82
 
84
- export const RowActions = ({ hostID, jobID }) => {
83
+ export const RowActions = ({ hostID, jobID, task, permissions }) => {
85
84
  const dispatch = useDispatch();
86
- const response = useSelector(selectTemplateInvocationList)?.[hostID];
87
- if (!response?.permissions) return null;
88
- const { task, permissions } = response;
85
+ if (!permissions) return null;
89
86
  const { id: taskID, cancellable: taskCancellable } = task || {};
90
87
  const getActions = actions({
91
88
  taskID,
@@ -216,4 +213,18 @@ TemplateActionButtons.defaultProps = {
216
213
  RowActions.propTypes = {
217
214
  hostID: PropTypes.oneOfType([PropTypes.string, PropTypes.number]).isRequired,
218
215
  jobID: PropTypes.oneOfType([PropTypes.string, PropTypes.number]).isRequired,
216
+ task: PropTypes.shape({
217
+ id: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
218
+ cancellable: PropTypes.bool,
219
+ }),
220
+ permissions: PropTypes.shape({
221
+ view_foreman_tasks: PropTypes.bool,
222
+ cancel_job_invocations: PropTypes.bool,
223
+ execute_jobs: PropTypes.bool,
224
+ }),
225
+ };
226
+
227
+ RowActions.defaultProps = {
228
+ task: null,
229
+ permissions: null,
219
230
  };