foreman_remote_execution 4.7.0 → 4.8.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (52) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop_todo.yml +1 -0
  3. data/app/controllers/api/v2/job_invocations_controller.rb +7 -1
  4. data/app/lib/actions/remote_execution/run_host_job.rb +2 -1
  5. data/app/lib/actions/remote_execution/run_hosts_job.rb +57 -3
  6. data/app/mailers/rex_job_mailer.rb +15 -0
  7. data/app/models/job_invocation.rb +4 -0
  8. data/app/models/job_invocation_composer.rb +20 -12
  9. data/app/models/remote_execution_provider.rb +18 -2
  10. data/app/models/rex_mail_notification.rb +13 -0
  11. data/app/models/setting/remote_execution.rb +7 -1
  12. data/app/services/ui_notifications/remote_execution_jobs/base_job_finish.rb +2 -1
  13. data/app/views/dashboard/_latest-jobs.html.erb +21 -0
  14. data/app/views/rex_job_mailer/job_finished.html.erb +24 -0
  15. data/app/views/rex_job_mailer/job_finished.text.erb +9 -0
  16. data/app/views/template_invocations/show.html.erb +2 -1
  17. data/db/seeds.d/50-notification_blueprints.rb +14 -0
  18. data/db/seeds.d/95-mail_notifications.rb +24 -0
  19. data/foreman_remote_execution.gemspec +1 -1
  20. data/lib/foreman_remote_execution/engine.rb +1 -0
  21. data/lib/foreman_remote_execution/version.rb +1 -1
  22. data/package.json +6 -6
  23. data/test/functional/api/v2/job_invocations_controller_test.rb +10 -0
  24. data/test/unit/actions/run_hosts_job_test.rb +99 -4
  25. data/test/unit/job_invocation_report_template_test.rb +15 -12
  26. data/test/unit/remote_execution_provider_test.rb +46 -0
  27. data/webpack/JobWizard/JobWizard.js +53 -20
  28. data/webpack/JobWizard/JobWizard.scss +33 -4
  29. data/webpack/JobWizard/JobWizardConstants.js +17 -0
  30. data/webpack/JobWizard/__tests__/fixtures.js +8 -0
  31. data/webpack/JobWizard/__tests__/integration.test.js +3 -7
  32. data/webpack/JobWizard/steps/AdvancedFields/AdvancedFields.js +16 -5
  33. data/webpack/JobWizard/steps/AdvancedFields/Fields.js +48 -1
  34. data/webpack/JobWizard/steps/AdvancedFields/__tests__/AdvancedFields.test.js +29 -14
  35. data/webpack/JobWizard/steps/CategoryAndTemplate/CategoryAndTemplate.js +4 -2
  36. data/webpack/JobWizard/steps/CategoryAndTemplate/CategoryAndTemplate.test.js +3 -2
  37. data/webpack/JobWizard/steps/HostsAndInputs/SelectedChips.js +25 -0
  38. data/webpack/JobWizard/steps/HostsAndInputs/TemplateInputs.js +23 -0
  39. data/webpack/JobWizard/steps/HostsAndInputs/__tests__/SelectedChips.test.js +37 -0
  40. data/webpack/JobWizard/steps/HostsAndInputs/__tests__/TemplateInputs.test.js +50 -0
  41. data/webpack/JobWizard/steps/HostsAndInputs/index.js +66 -0
  42. data/webpack/JobWizard/steps/Schedule/ScheduleType.js +24 -21
  43. data/webpack/JobWizard/steps/Schedule/StartEndDates.js +36 -21
  44. data/webpack/JobWizard/steps/Schedule/__tests__/Schedule.test.js +155 -0
  45. data/webpack/JobWizard/steps/Schedule/__tests__/StartEndDates.test.js +9 -8
  46. data/webpack/JobWizard/steps/Schedule/index.js +89 -28
  47. data/webpack/JobWizard/steps/form/DateTimePicker.js +93 -0
  48. data/webpack/JobWizard/steps/form/Formatter.js +10 -9
  49. data/webpack/JobWizard/steps/form/NumberInput.js +2 -0
  50. data/webpack/JobWizard/steps/form/WizardTitle.js +14 -0
  51. data/webpack/react_app/components/RecentJobsCard/RecentJobsCard.js +2 -1
  52. metadata +18 -4
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 742978171c8d62de9fdf9e14b2be742235dc1cce90c0c522871d65a90ee1f612
4
- data.tar.gz: caae67492773457e26461ae6e39addec2cc1b105bf387a9cf0dbfc667ad25102
3
+ metadata.gz: c7565defbf0881174acb2a48e274aacb3d790d2ee8b9de3119307c2afabd7355
4
+ data.tar.gz: acffdb2df04d0c1caeff65513e1a63e5cf66276897206affd606c5136b7afba8
5
5
  SHA512:
6
- metadata.gz: e40d5600dbe09f3ea875179a3ac44a4046ffd8615efe5c88f364828e7bd21b24d2d2a7f5f8ea09954f2f00056bc6d867efc22f7b82b0189de766f225101ae896
7
- data.tar.gz: 6875df7248f14be5a915f7b3001cd6d4a50476f84824b0118747d4ba7b2993b977cf85cfe5f9cdde8706b2d2cb4fea20c1770988405adebf09683cc966ba9e70
6
+ metadata.gz: 7064c4e3984fd4ef3ef463fc10cb27e911d1f24ac0b74ccf5fb3b7682ba3d4c3418e130f5b6e0f1920ed83406dbf0787585ef1e1b83cfd0c0e412219c059309c
7
+ data.tar.gz: ab22827c2750d67893621267a43095c2b845937b6d72d5a665bf800ab97542a06cda1480187174943077c9365caac0076d67f765b6c43c4e01650dca1f7b9010
data/.rubocop_todo.yml CHANGED
@@ -220,6 +220,7 @@ Naming/FileName:
220
220
  - 'db/seeds.d/60-ssh_proxy_feature.rb'
221
221
  - 'db/seeds.d/70-job_templates.rb'
222
222
  - 'db/seeds.d/90-bookmarks.rb'
223
+ - 'db/seeds.d/95-mail_notifications.rb'
223
224
 
224
225
  # Offense count: 1
225
226
  # Configuration parameters: ForbiddenDelimiters.
@@ -41,12 +41,18 @@ module Api
41
41
  param :effective_user, String,
42
42
  :required => false,
43
43
  :desc => N_('What user should be used to run the script (using sudo-like mechanisms). Defaults to a template parameter or global setting.')
44
+ param :effective_user_password, String,
45
+ :required => false,
46
+ :desc => N_('Set password for effective user (using sudo-like mechanisms)')
44
47
  end
48
+ param :password, String, :required => false, :desc => N_('Set SSH password')
49
+ param :key_passphrase, String, :required => false, :desc => N_('Set SSH key passphrase')
45
50
 
46
51
  param :recurrence, Hash, :desc => N_('Create a recurring job') do
47
52
  param :cron_line, String, :required => false, :desc => N_('How often the job should occur, in the cron format')
48
53
  param :max_iteration, :number, :required => false, :desc => N_('Repeat a maximum of N times')
49
54
  param :end_time, DateTime, :required => false, :desc => N_('Perform no more executions after this time')
55
+ param :purpose, String, :required => false, :desc => N_('Designation of a special purpose')
50
56
  end
51
57
 
52
58
  param :scheduling, Hash, :desc => N_('Schedule the job to start at a later time') do
@@ -197,7 +203,7 @@ module Api
197
203
  end
198
204
 
199
205
  if job_invocation_params.key?(:ssh)
200
- job_invocation_params.merge!(job_invocation_params.delete(:ssh).permit(:effective_user))
206
+ job_invocation_params.merge!(job_invocation_params.delete(:ssh).permit(:effective_user, :effective_user_password))
201
207
  end
202
208
 
203
209
  job_invocation_params[:inputs] ||= {}
@@ -56,7 +56,8 @@ module Actions
56
56
  :secrets => secrets(host, job_invocation, provider),
57
57
  :use_batch_triggering => true,
58
58
  :use_concurrency_control => options[:use_concurrency_control],
59
- :first_execution => first_execution }
59
+ :first_execution => first_execution,
60
+ :alternative_names => provider.alternative_names(host) }
60
61
  action_options = provider.proxy_command_options(template_invocation, host)
61
62
  .merge(additional_options)
62
63
 
@@ -9,7 +9,9 @@ module Actions
9
9
  middleware.use Actions::Middleware::BindJobInvocation
10
10
  middleware.use Actions::Middleware::RecurringLogic
11
11
  middleware.use Actions::Middleware::WatchDelegatedProxySubTasks
12
- middleware.use Actions::Middleware::ProxyBatchTriggering
12
+
13
+ execution_plan_hooks.use :notify_on_success, :on => :success
14
+ execution_plan_hooks.use :notify_on_failure, :on => :failure
13
15
 
14
16
  class CheckOnProxyActions; end
15
17
 
@@ -32,6 +34,10 @@ module Actions
32
34
  set_up_concurrency_control job_invocation
33
35
  input.update(:job_category => job_invocation.job_category)
34
36
  plan_self(:job_invocation_id => job_invocation.id)
37
+ provider = job_invocation.pattern_template_invocations.first&.template&.provider
38
+ input[:proxy_batch_size] ||= provider&.proxy_batch_size || Setting['foreman_tasks_proxy_batch_size']
39
+ trigger_action = plan_action(Actions::TriggerProxyBatch, batch_size: proxy_batch_size, total_count: hosts.count)
40
+ input[:trigger_run_step_id] = trigger_action.run_step_id
35
41
  end
36
42
 
37
43
  def create_sub_plans
@@ -46,14 +52,47 @@ module Actions
46
52
  end
47
53
  end
48
54
 
55
+ def spawn_plans
56
+ super
57
+ ensure
58
+ trigger_remote_batch
59
+ end
60
+
61
+ def trigger_remote_batch
62
+ batches_ready = (output[:planned_count] - output[:remote_triggered_count]) / proxy_batch_size
63
+ return unless batches_ready > 0
64
+
65
+ plan_event(Actions::TriggerProxyBatch::TriggerNextBatch[batches_ready], nil, step_id: input[:trigger_run_step_id])
66
+ output[:remote_triggered_count] += proxy_batch_size * batches_ready
67
+ end
68
+
69
+ def on_planning_finished
70
+ plan_event(Actions::TriggerProxyBatch::TriggerLastBatch, nil, step_id: input[:trigger_run_step_id])
71
+ super
72
+ end
73
+
49
74
  def finalize
50
75
  job_invocation.password = job_invocation.key_passphrase = job_invocation.effective_user_password = nil
51
76
  job_invocation.save!
52
77
 
53
78
  Rails.logger.debug "cleaning cache for keys that begin with 'job_invocation_#{job_invocation.id}'"
54
79
  Rails.cache.delete_matched(/\A#{JobInvocation::CACHE_PREFIX}_#{job_invocation.id}/)
55
- # creating the success notification should be the very last thing this tasks do
80
+ end
81
+
82
+ def notify_on_success(_plan)
56
83
  job_invocation.build_notification.deliver!
84
+
85
+ if [RexMailNotification::SUCCEEDED_JOBS, RexMailNotification::ALL_JOBS].include?(mail_notification_preference&.interval)
86
+ RexJobMailer.job_finished(job_invocation, subject: _("REX job has succeeded - %s") % job_invocation.to_s).deliver_now
87
+ end
88
+ end
89
+
90
+ def notify_on_failure(_plan)
91
+ job_invocation.build_notification.deliver!
92
+
93
+ if [RexMailNotification::FAILED_JOBS, RexMailNotification::ALL_JOBS].include?(mail_notification_preference&.interval)
94
+ RexJobMailer.job_finished(job_invocation, subject: _("REX job has failed - %s") % job_invocation.to_s).deliver_now
95
+ end
57
96
  end
58
97
 
59
98
  def job_invocation
@@ -67,6 +106,7 @@ module Actions
67
106
 
68
107
  def initiate
69
108
  output[:host_count] = total_count
109
+ output[:remote_triggered_count] = 0
70
110
  super
71
111
  end
72
112
 
@@ -94,7 +134,11 @@ module Actions
94
134
  end
95
135
 
96
136
  def run(event = nil)
97
- super unless event == Dynflow::Action::Skip
137
+ if event == Dynflow::Action::Skip
138
+ plan_event(Dynflow::Action::Skip, nil, step_id: input[:trigger_run_step_id])
139
+ else
140
+ super
141
+ end
98
142
  end
99
143
 
100
144
  def humanized_input
@@ -104,6 +148,16 @@ module Actions
104
148
  def humanized_name
105
149
  '%s:' % _(super)
106
150
  end
151
+
152
+ def proxy_batch_size
153
+ input[:proxy_batch_size]
154
+ end
155
+
156
+ private
157
+
158
+ def mail_notification_preference
159
+ UserMailNotification.where(mail_notification_id: RexMailNotification.first, user_id: User.current.id).first
160
+ end
107
161
  end
108
162
  end
109
163
  end
@@ -0,0 +1,15 @@
1
+ class RexJobMailer < ApplicationMailer
2
+ add_template_helper(ApplicationHelper)
3
+
4
+ def job_finished(job, opts = {})
5
+ @job = job
6
+ @subject = opts[:subject] || _('REX job has finished - %s') % @job.to_s
7
+
8
+ if @job.user.nil?
9
+ Rails.logger.warn 'Job user no longer exist, skipping email notification'
10
+ return
11
+ end
12
+
13
+ mail(to: @job.user.mail, subject: @subject)
14
+ end
15
+ end
@@ -65,6 +65,8 @@ class JobInvocation < ApplicationRecord
65
65
  scoped_search :on => 'pattern_template_name', :rename => 'pattern_template_name', :operators => ['= '],
66
66
  :complete_value => false, :only_explicit => true, :ext_method => :search_by_pattern_template
67
67
 
68
+ scoped_search :relation => :recurring_logic, :on => 'purpose', :rename => 'recurring_logic.purpose'
69
+
68
70
  scoped_search :relation => :recurring_logic, :on => 'id', :rename => 'recurring_logic.id'
69
71
 
70
72
  scoped_search :relation => :recurring_logic, :on => 'id', :rename => 'recurring',
@@ -73,6 +75,8 @@ class JobInvocation < ApplicationRecord
73
75
 
74
76
  default_scope -> { order('job_invocations.id DESC') }
75
77
 
78
+ scope :latest_jobs, -> { unscoped.joins(:task).order('foreman_tasks_tasks.start_at DESC').authorized(:view_job_invocations).limit(5) }
79
+
76
80
  validates_lengths_from_database :only => [:description]
77
81
 
78
82
  attr_accessor :start_before, :description_format
@@ -121,7 +121,10 @@ class JobInvocationComposer
121
121
  :targeting => targeting_params,
122
122
  :triggering => triggering_params,
123
123
  :description_format => api_params[:description_format],
124
+ :password => api_params[:password],
124
125
  :remote_execution_feature_id => remote_execution_feature_id,
126
+ :effective_user_password => api_params[:effective_user_password],
127
+ :key_passphrase => api_params[:key_passphrase],
125
128
  :concurrency_control => concurrency_control_params,
126
129
  :execution_timeout_interval => api_params[:execution_timeout_interval] || template.execution_timeout_interval,
127
130
  :template_invocations => template_invocations_params }.with_indifferent_access
@@ -138,17 +141,11 @@ class JobInvocationComposer
138
141
  end
139
142
 
140
143
  def triggering_params
141
- raise ::Foreman::Exception, _('Cannot specify both recurrence and scheduling') if api_params[:recurrence].present? && api_params[:scheduling].present?
142
-
143
- if api_params[:recurrence].present?
144
- {
145
- :mode => :recurring,
146
- :cronline => api_params[:recurrence][:cron_line],
147
- :end_time => format_datetime(api_params[:recurrence][:end_time]),
148
- :input_type => :cronline,
149
- :max_iteration => api_params[:recurrence][:max_iteration],
150
- }
151
- elsif api_params[:scheduling].present?
144
+ if api_params[:recurrence].present? && api_params[:scheduling].present?
145
+ recurring_mode_params.merge :start_at_raw => format_datetime(api_params[:scheduling][:start_at])
146
+ elsif api_params[:recurrence].present? && api_params[:scheduling].empty?
147
+ recurring_mode_params
148
+ elsif api_params[:recurrence].empty? && api_params[:scheduling].present?
152
149
  {
153
150
  :mode => :future,
154
151
  :start_at_raw => format_datetime(api_params[:scheduling][:start_at]),
@@ -160,6 +157,17 @@ class JobInvocationComposer
160
157
  end
161
158
  end
162
159
 
160
+ def recurring_mode_params
161
+ {
162
+ :mode => :recurring,
163
+ :cronline => api_params[:recurrence][:cron_line],
164
+ :end_time => format_datetime(api_params[:recurrence][:end_time]),
165
+ :input_type => :cronline,
166
+ :max_iteration => api_params[:recurrence][:max_iteration],
167
+ :purpose => api_params[:recurrence][:purpose],
168
+ }
169
+ end
170
+
163
171
  def concurrency_control_params
164
172
  {
165
173
  :level => api_params.fetch(:concurrency_control, {})[:concurrency_level],
@@ -636,7 +644,7 @@ class JobInvocationComposer
636
644
  setting_value = Setting['remote_execution_form_job_template']
637
645
  return default_value unless setting_value
638
646
 
639
- form_template = JobTemplate.find_by :name => setting_value
647
+ form_template = JobTemplate.authorized(:view_job_templates).find_by :name => setting_value
640
648
  return default_value unless form_template
641
649
 
642
650
  if block_given?
@@ -80,7 +80,14 @@ class RemoteExecutionProvider
80
80
 
81
81
  def find_ip(host, interfaces)
82
82
  if host_setting(host, :remote_execution_connect_by_ip)
83
- interfaces.find { |i| i.ip.present? }.try(:ip)
83
+ ip4_address = interfaces.find { |i| i.ip.present? }.try(:ip)
84
+ ip6_address = interfaces.find { |i| i.ip6.present? }.try(:ip6)
85
+
86
+ if host_setting(host, :remote_execution_connect_by_ip_prefer_ipv6)
87
+ ip6_address || ip4_address
88
+ else
89
+ ip4_address || ip6_address
90
+ end
84
91
  end
85
92
  end
86
93
 
@@ -89,7 +96,8 @@ class RemoteExecutionProvider
89
96
  end
90
97
 
91
98
  def host_setting(host, setting)
92
- host.host_param(setting.to_s) || Setting[setting]
99
+ param_value = host.host_param(setting.to_s)
100
+ param_value.nil? ? Setting[setting] : param_value
93
101
  end
94
102
 
95
103
  def ssh_password(_host)
@@ -114,6 +122,10 @@ class RemoteExecutionProvider
114
122
  'Proxy::RemoteExecution::Ssh::Actions::RunScript'
115
123
  end
116
124
 
125
+ def proxy_batch_size
126
+ Setting['foreman_tasks_proxy_batch_size']
127
+ end
128
+
117
129
  # Return a specific proxy selector to use for running a given template
118
130
  # Returns either nil to use the default selector or an instance of a (sub)class of ::ForemanTasks::ProxySelector
119
131
  def required_proxy_selector_for(template)
@@ -123,5 +135,9 @@ class RemoteExecutionProvider
123
135
  ::DefaultProxyProxySelector.new
124
136
  end
125
137
  end
138
+
139
+ def alternative_names(host)
140
+ { :fqdn => find_fqdn(effective_interfaces(host)) }
141
+ end
126
142
  end
127
143
  end
@@ -0,0 +1,13 @@
1
+ class RexMailNotification < MailNotification
2
+ FAILED_JOBS = N_("Subscribe to my failed jobs")
3
+ SUCCEEDED_JOBS = N_("Subscribe to my succeeded jobs")
4
+ ALL_JOBS = N_("Subscribe to all my jobs")
5
+
6
+ def subscription_options
7
+ [
8
+ FAILED_JOBS,
9
+ SUCCEEDED_JOBS,
10
+ ALL_JOBS,
11
+ ]
12
+ end
13
+ end
@@ -39,9 +39,15 @@ class Setting::RemoteExecution < Setting
39
39
  self.set('remote_execution_connect_by_ip',
40
40
  N_('Should the ip addresses on host interfaces be preferred over the fqdn? '\
41
41
  'It is useful when DNS not resolving the fqdns properly. You may override this per host by setting a parameter called remote_execution_connect_by_ip. '\
42
- 'This setting only applies to IPv4. When the host has only an IPv6 address on the interface used for remote execution, hostname will be used even if this setting is set to true.'),
42
+ 'For dual-stacked hosts you should consider the remote_execution_connect_by_ip_prefer_ipv6 setting'),
43
43
  false,
44
44
  N_('Connect by IP')),
45
+ self.set('remote_execution_connect_by_ip_prefer_ipv6',
46
+ N_('When connecting using ip address, should the IPv6 addresses be preferred? '\
47
+ 'If no IPv6 address is set, it falls back to IPv4 automatically. You may override this per host by setting a parameter called remote_execution_connect_by_ip_prefer_ipv6. '\
48
+ 'By default and for compatibility, IPv4 will be preferred over IPv6 by default'),
49
+ false,
50
+ N_('Prefer IPv6 over IPv4')),
45
51
  self.set('remote_execution_ssh_password',
46
52
  N_('Default password to use for SSH. You may override per host by setting a parameter called remote_execution_ssh_password'),
47
53
  nil,
@@ -20,7 +20,8 @@ module UINotifications
20
20
  end
21
21
 
22
22
  def blueprint
23
- @blueprint ||= NotificationBlueprint.unscoped.find_by(:name => 'rex_job_succeeded')
23
+ blueprint = @subject.status == HostStatus::ExecutionStatus::ERROR ? 'rex_job_failed' : 'rex_job_succeeded'
24
+ @blueprint ||= NotificationBlueprint.unscoped.find_by(:name => blueprint)
24
25
  end
25
26
 
26
27
  def message
@@ -0,0 +1,21 @@
1
+ <h4 class="header">
2
+ <%= link_to _("Latest Jobs"), job_invocations_path(:order=>'start_at DESC') %>
3
+ </h4>
4
+ <% if JobInvocation.latest_jobs.any? %>
5
+ <table class="<%= table_css_classes('table-fixed') %>">
6
+ <tr>
7
+ <th class="col-md-5"><%= _("Name") %></th>
8
+ <th class="col-md-2"><%= _("State") %></th>
9
+ <th class="col-md-3"><%= _("Started") %></th>
10
+ </tr>
11
+ <% JobInvocation.latest_jobs.each do |invocation| %>
12
+ <tr>
13
+ <td class="ellipsis"><%= link_to_if_authorized invocation_description(invocation), hash_for_job_invocation_path(invocation).merge(:auth_object => invocation, :permission => :view_job_invocations, :authorizer => authorizer) %></td>
14
+ <td><%= link_to_invocation_task_if_authorized(invocation) %></td>
15
+ <td><%= time_in_words_span(invocation.start_at) %></td>
16
+ </tr>
17
+ <% end %>
18
+ </table>
19
+ <% else %>
20
+ <p class="ca"><%= _("No jobs available") %></p>
21
+ <% end %>
@@ -0,0 +1,24 @@
1
+ <p>
2
+ <%= _("A job '%{job_name}' has %{status} at %{time}") % {
3
+ :job_name => @job.to_s, :status => @job.status_label, :time => date_time_absolute_value(@job.task.ended_at)
4
+ } %>
5
+ </p>
6
+
7
+ <div class="dashboard">
8
+ <table>
9
+ <tr>
10
+ <td width="18%" class="hosts-rows"><b><%= _("Job result") %></b></td>
11
+ <td width="82%" class="hosts-rows"><%= @job.status_label %></td>
12
+ </tr>
13
+ <tr>
14
+ <td width="18%" class="hosts-rows"><b><%= _("Total hosts") %></b></td>
15
+ <td width="82%" class="hosts-rows"><%= @job.total_hosts_count %></td>
16
+ </tr>
17
+ <tr>
18
+ <td width="18%" class="hosts-rows"><b><%= _("Failed hosts") %></b></td>
19
+ <td width="82%" class="hosts-rows"><%= @job.failed_hosts.count %></td>
20
+ </tr>
21
+ </table>
22
+ </div>
23
+
24
+ <%= link_to 'More details', job_invocation_url(@job) %>
@@ -0,0 +1,9 @@
1
+ <%= _("A job '%{job_name}' has %{status} at %{time}") % {
2
+ :job_name => @job.to_s, :status => @job.status_label, :time => date_time_absolute_value(@job.task.ended_at)
3
+ } %>
4
+
5
+ <%= _('Job result') %>: <%= @job.status_label %>
6
+ <%= _('Total hosts') %>: <%= @job.total_hosts_count %>
7
+ <%= _('Failed hosts') %>: <%= @job.failed_hosts.count %>
8
+
9
+ <%= _('See more details at %s') % job_invocation_url(@job) %>
@@ -18,7 +18,8 @@ end
18
18
 
19
19
  <div id="title_action">
20
20
  <div class="btn-toolbar pull-right">
21
- <%= link_to(_('Back to Job'), job_invocation_path(@template_invocation.job_invocation), :class => 'btn btn-default') %>
21
+ <%= button_group(link_to(_('Back to Job'), job_invocation_path(@template_invocation.job_invocation), :class => 'btn btn-default'),
22
+ (link_to(_('Rerun'), rerun_job_invocation_path(@template_invocation.job_invocation, :host_ids => [ @host.id ]), :class => 'btn btn-default') if authorized_for(:permission => :create_job_invocations))) %>
22
23
  <%= button_group(link_to_function(_('Toggle command'), '$("div.preview").toggle()', :class => 'btn btn-default'),
23
24
  link_to_function(_('Toggle STDERR'), '$("div.line.stderr").toggle()', :class => 'btn btn-default'),
24
25
  link_to_function(_('Toggle STDOUT'), '$("div.line.stdout").toggle()', :class => 'btn btn-default'),
@@ -13,6 +13,20 @@ blueprints = [
13
13
  ],
14
14
  },
15
15
  },
16
+ {
17
+ group: N_('Jobs'),
18
+ name: 'rex_job_failed',
19
+ message: N_("A job '%{subject}' has failed"),
20
+ level: 'error',
21
+ actions:
22
+ {
23
+ links:
24
+ [
25
+ path_method: :job_invocation_path,
26
+ title: N_('Job Details'),
27
+ ],
28
+ },
29
+ },
16
30
  ]
17
31
 
18
32
  blueprints.each { |blueprint| UINotifications::Seed.new(blueprint).configure }