foreman_remote_execution 4.1.0 → 4.3.0

Sign up to get free protection for your applications and to get access to all the features.
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
data/Gemfile CHANGED
@@ -2,7 +2,4 @@ source 'http://rubygems.org'
2
2
 
3
3
  gemspec :name => 'foreman_remote_execution'
4
4
 
5
- gem 'rubocop', '~> 0.80.0'
6
- gem 'rubocop-minitest'
7
- gem 'rubocop-performance'
8
- gem 'rubocop-rails'
5
+ gem 'theforeman-rubocop', '~> 0.1.0.pre'
@@ -6,7 +6,7 @@ module Api
6
6
 
7
7
  before_action :find_optional_nested_object, :only => %w{output raw_output}
8
8
  before_action :find_host, :only => %w{output raw_output}
9
- before_action :find_resource, :only => %w{show update destroy clone cancel rerun}
9
+ before_action :find_resource, :only => %w{show update destroy clone cancel rerun outputs}
10
10
 
11
11
  wrap_parameters JobInvocation, :include => (JobInvocation.attribute_names + [:ssh])
12
12
 
@@ -137,6 +137,24 @@ module Api
137
137
  end
138
138
  end
139
139
 
140
+ api :GET, '/job_invocations/:id/outputs', N_('Get outputs of hosts in a job')
141
+ param :id, :identifier, :required => true
142
+ param :search_query, :identifier, :required => false
143
+ param :since, String, :required => false
144
+ param :raw, String, :required => false
145
+ def outputs
146
+ hosts = @job_invocation.targeting.hosts.authorized(:view_hosts, Host)
147
+ hosts = hosts.search_for(params['search_query']) if params['search_query']
148
+ raw = ActiveRecord::Type::Boolean.new.cast params['raw']
149
+ default_value = raw ? '' : []
150
+ outputs = hosts.map do |host|
151
+ host_output(@job_invocation, host, :default => default_value, :since => params['since'], :raw => raw)
152
+ .merge(host_id: host.id)
153
+ end
154
+
155
+ render :json => { :outputs => outputs }
156
+ end
157
+
140
158
  private
141
159
 
142
160
  def allowed_nested_id
@@ -145,7 +163,7 @@ module Api
145
163
 
146
164
  def action_permission
147
165
  case params[:action]
148
- when 'output', 'raw_output'
166
+ when 'output', 'raw_output', 'outputs'
149
167
  :view
150
168
  when 'cancel'
151
169
  :cancel
@@ -196,7 +214,7 @@ module Api
196
214
  end
197
215
 
198
216
  def host_output(job_invocation, host, default: nil, since: nil, raw: false)
199
- refresh = true
217
+ refresh = !job_invocation.finished?
200
218
 
201
219
  if (task = job_invocation.sub_task_for_host(host))
202
220
  refresh = task.pending?
@@ -0,0 +1,26 @@
1
+ module ForemanRemoteExecution
2
+ module Concerns
3
+ module Api::V2::RegistrationControllerExtensions
4
+ module ApipieExtensions
5
+ extend Apipie::DSL::Concern
6
+
7
+ update_api(:global, :host) do
8
+ param :remote_execution_interface, String, desc: N_("Identifier of the Host interface for Remote execution")
9
+ end
10
+ end
11
+
12
+ extend ActiveSupport::Concern
13
+
14
+ def host_setup_extension
15
+ remote_execution_interface
16
+ super
17
+ end
18
+
19
+ def remote_execution_interface
20
+ return unless params['remote_execution_interface'].present?
21
+
22
+ @host.set_execution_interface(params['remote_execution_interface'])
23
+ end
24
+ end
25
+ end
26
+ end
@@ -36,7 +36,7 @@ class JobTemplatesController < ::TemplatesController
36
36
 
37
37
  @template = JobTemplate.import_raw(contents, :update => Foreman::Cast.to_bool(params[:imported_template][:overwrite]))
38
38
  if @template&.save
39
- flash[:notice] = _('Job template imported successfully.')
39
+ flash[:success] = _('Job template imported successfully.')
40
40
  redirect_to job_templates_path(:search => "name = \"#{@template.name}\"")
41
41
  else
42
42
  @template ||= JobTemplate.import_raw(contents, :build_new => true)
@@ -0,0 +1,18 @@
1
+ class UiJobWizardController < ::Api::V2::BaseController
2
+ def categories
3
+ job_categories = resource_scope
4
+ .search_for("job_category ~ \"#{params[:search]}\"")
5
+ .select(:job_category).distinct
6
+ .reorder(:job_category)
7
+ .pluck(:job_category)
8
+ render :json => {:job_categories =>job_categories}
9
+ end
10
+
11
+ def resource_class
12
+ JobTemplate
13
+ end
14
+
15
+ def action_permission
16
+ :view_job_templates
17
+ end
18
+ end
@@ -3,6 +3,7 @@ module Actions
3
3
  class RunHostJob < Actions::EntryAction
4
4
  include ::Actions::Helpers::WithContinuousOutput
5
5
  include ::Actions::Helpers::WithDelegatedAction
6
+ include ::Actions::ObservableAction
6
7
 
7
8
  middleware.do_not_use Dynflow::Middleware::Common::Transaction
8
9
  middleware.use Actions::Middleware::HideSecrets
@@ -16,7 +17,7 @@ module Actions
16
17
  end
17
18
 
18
19
  def plan(job_invocation, host, template_invocation, proxy_selector = ::RemoteExecutionProxySelector.new, options = {})
19
- action_subject(host, :job_category => job_invocation.job_category, :description => job_invocation.description)
20
+ action_subject(host, :job_category => job_invocation.job_category, :description => job_invocation.description, :job_invocation_id => job_invocation.id)
20
21
 
21
22
  template_invocation.host_id = host.id
22
23
  template_invocation.run_host_job_task_id = task.id
@@ -121,6 +122,26 @@ module Actions
121
122
  delegated_output[:exit_status]
122
123
  end
123
124
 
125
+ def host_id
126
+ input['host']['id']
127
+ end
128
+
129
+ def host_name
130
+ input['host']['name']
131
+ end
132
+
133
+ def job_invocation_id
134
+ input['job_invocation_id']
135
+ end
136
+
137
+ def job_invocation
138
+ @job_invocation ||= ::JobInvocation.authorized.find(job_invocation_id)
139
+ end
140
+
141
+ def host
142
+ @host ||= ::Host.authorized.find(host_id)
143
+ end
144
+
124
145
  private
125
146
 
126
147
  def update_host_status
@@ -173,6 +194,22 @@ module Actions
173
194
  end
174
195
  proxy
175
196
  end
197
+
198
+ extend ApipieDSL::Class
199
+ apipie :class, "An action representing execution of a job against a host" do
200
+ name 'Actions::RemoteExecution::RunHostJob'
201
+ refs 'Actions::RemoteExecution::RunHostJob'
202
+ sections only: %w[webhooks]
203
+ property :task, object_of: 'Task', desc: 'Returns the task to which this action belongs'
204
+ property :host_name, String, desc: "Returns the name of the host"
205
+ property :host_id, Integer, desc: "Returns the id of the host"
206
+ property :host, object_of: 'Host', desc: "Returns the host"
207
+ property :job_invocation_id, Integer, desc: "Returns the id of the job invocation"
208
+ property :job_invocation, object_of: 'JobInvocation', desc: "Returns the job invocation"
209
+ end
210
+ class Jail < ::Actions::ObservableAction::Jail
211
+ allow :host_name, :host_id, :host, :job_invocation_id, :job_invocation
212
+ end
176
213
  end
177
214
  end
178
215
  end
@@ -65,8 +65,16 @@ module Actions
65
65
  hosts.offset(from).limit(size)
66
66
  end
67
67
 
68
+ def initiate
69
+ output[:host_count] = total_count
70
+ super
71
+ end
72
+
68
73
  def total_count
69
- output[:total_count] || hosts.count
74
+ # For compatibility with already existing tasks
75
+ return output[:total_count] unless output.has_key?(:host_count) || task.pending?
76
+
77
+ output[:host_count] || hosts.count
70
78
  end
71
79
 
72
80
  def hosts
@@ -44,29 +44,33 @@ module ForemanRemoteExecution
44
44
  @execution_status_label ||= get_status(HostStatus::ExecutionStatus).to_label(options)
45
45
  end
46
46
 
47
+ # rubocop:disable Naming/MemoizedInstanceVariableName
47
48
  def host_params_hash
48
- params = super
49
- keys = remote_execution_ssh_keys
50
- source = 'global'
51
- if keys.present?
52
- value, safe_value = params.fetch('remote_execution_ssh_keys', {}).values_at(:value, :safe_value).map { |v| [v].flatten.compact }
53
- params['remote_execution_ssh_keys'] = {:value => value + keys, :safe_value => safe_value + keys, :source => source}
54
- end
55
- [:remote_execution_ssh_user, :remote_execution_effective_user_method,
56
- :remote_execution_connect_by_ip].each do |key|
57
- value = Setting[key]
58
- params[key.to_s] = {:value => value, :safe_value => value, :source => source} unless params.key?(key.to_s)
59
- end
60
- params
49
+ @cached_rex_host_params_hash ||= extend_host_params_hash(super)
61
50
  end
51
+ # rubocop:enable Naming/MemoizedInstanceVariableName
62
52
 
63
53
  def execution_interface
64
54
  get_interface_by_flag(:execution)
65
55
  end
66
56
 
57
+ def set_execution_interface(identifier)
58
+ if interfaces.find_by(identifier: identifier).nil?
59
+ msg = (N_("Interface with the '%s' identifier was specified as a remote execution interface, however the interface was not found on the host. If the interface exists, it needs to be created in Foreman during the registration.") % identifier)
60
+ raise ActiveRecord::RecordNotFound, msg
61
+ end
62
+
63
+ # Only one interface at time can be used for REX, all other must be set to false
64
+ interfaces.each { |int| int.execution = (int.identifier == identifier) }
65
+ interfaces.each(&:save!)
66
+ end
67
+
67
68
  def remote_execution_proxies(provider, authorized = true)
68
69
  proxies = {}
69
- proxies[:subnet] = execution_interface.subnet.remote_execution_proxies.with_features(provider) if execution_interface&.subnet
70
+ proxies[:subnet] = []
71
+ proxies[:subnet] += execution_interface.subnet6.remote_execution_proxies.with_features(provider) if execution_interface&.subnet6
72
+ proxies[:subnet] += execution_interface.subnet.remote_execution_proxies.with_features(provider) if execution_interface&.subnet
73
+ proxies[:subnet].uniq!
70
74
  proxies[:fallback] = smart_proxies.with_features(provider) if Setting[:remote_execution_fallback_proxy]
71
75
 
72
76
  if Setting[:remote_execution_global_proxy]
@@ -102,8 +106,28 @@ module ForemanRemoteExecution
102
106
  super(*args)
103
107
  end
104
108
 
109
+ def clear_host_parameters_cache!
110
+ super
111
+ @cached_rex_host_params_hash = nil
112
+ end
113
+
105
114
  private
106
115
 
116
+ def extend_host_params_hash(params)
117
+ keys = remote_execution_ssh_keys
118
+ source = 'global'
119
+ if keys.present?
120
+ value, safe_value = params.fetch('remote_execution_ssh_keys', {}).values_at(:value, :safe_value).map { |v| [v].flatten.compact }
121
+ params['remote_execution_ssh_keys'] = {:value => value + keys, :safe_value => safe_value + keys, :source => source}
122
+ end
123
+ [:remote_execution_ssh_user, :remote_execution_effective_user_method,
124
+ :remote_execution_connect_by_ip].each do |key|
125
+ value = Setting[key]
126
+ params[key.to_s] = {:value => value, :safe_value => value, :source => source} unless params.key?(key.to_s)
127
+ end
128
+ params
129
+ end
130
+
107
131
  def build_required_interfaces(attrs = {})
108
132
  super(attrs)
109
133
  self.primary_interface.execution = true if self.execution_interface.nil?
@@ -4,7 +4,7 @@ class ForeignInputSet < ApplicationRecord
4
4
  class CircularDependencyError < Foreman::Exception
5
5
  end
6
6
 
7
- attr_exportable :exclude, :include, :include_all, :template => ->(input_set) { input_set.template.name }
7
+ attr_exportable :exclude, :include, :include_all, :template => ->(input_set) { input_set.target_template.name }
8
8
 
9
9
  belongs_to :template
10
10
  belongs_to :target_template, :class_name => 'Template'
@@ -49,6 +49,13 @@ class HostStatus::ExecutionStatus < HostStatus::Status
49
49
  end
50
50
  end
51
51
 
52
+ def status_link
53
+ job_invocation = last_stopped_task.parent_task.job_invocations.first
54
+ return nil unless User.current.can?(:view_job_invocations, job_invocation)
55
+
56
+ Rails.application.routes.url_helpers.job_invocation_path(job_invocation)
57
+ end
58
+
52
59
  class ExecutionTaskStatusMapper
53
60
  attr_accessor :task
54
61
 
@@ -22,6 +22,7 @@ class JobInvocation < ApplicationRecord
22
22
  validates :job_category, :presence => true
23
23
  validates_associated :targeting, :all_template_invocations
24
24
 
25
+ scoped_search :on => :id, :complete_value => true
25
26
  scoped_search :on => :job_category, :complete_value => true
26
27
  scoped_search :on => :description, :complete_value => true
27
28
 
@@ -240,7 +241,7 @@ class JobInvocation < ApplicationRecord
240
241
  end
241
242
 
242
243
  def finished?
243
- !task.pending?
244
+ !(task.nil? || task.pending?)
244
245
  end
245
246
 
246
247
  def missing_hosts_count
@@ -482,7 +482,7 @@ class JobInvocationComposer
482
482
 
483
483
  def input_value_for(input)
484
484
  invocations = pattern_template_invocations
485
- default = TemplateInvocationInputValue.new
485
+ default = TemplateInvocationInputValue.new(:template_input_id => input.id)
486
486
  invocations.map(&:input_values).flatten.detect { |iv| iv.template_input_id == input.id } || default
487
487
  end
488
488
 
@@ -1,5 +1,5 @@
1
1
  class RemoteExecutionFeature < ApplicationRecord
2
- VALID_OPTIONS = [:provided_inputs, :description, :host_action_button, :notification_builder].freeze
2
+ VALID_OPTIONS = [:provided_inputs, :description, :host_action_button, :notification_builder, :proxy_selector_override].freeze
3
3
  validates :label, :name, :presence => true, :uniqueness => true
4
4
 
5
5
  belongs_to :job_template
@@ -24,8 +24,10 @@ class RemoteExecutionFeature < ApplicationRecord
24
24
  end
25
25
 
26
26
  def self.register(label, name, options = {})
27
+ pending_migrations = ::Foreman::Plugin.registered_plugins[:foreman_remote_execution]&.pending_migrations
27
28
  begin
28
- return false unless RemoteExecutionFeature.table_exists?
29
+ # Let's not try to register features if rex is not registered as a plugin
30
+ return false if pending_migrations || pending_migrations.nil?
29
31
  rescue ActiveRecord::NoDatabaseError => e
30
32
  # just ignore the problem if DB does not exist yet (rake db:create call)
31
33
  return false
@@ -41,6 +43,7 @@ class RemoteExecutionFeature < ApplicationRecord
41
43
  :provided_input_names => options[:provided_inputs],
42
44
  :description => options[:description],
43
45
  :host_action_button => options[:host_action_button],
46
+ :proxy_selector_override => options[:proxy_selector_override],
44
47
  :notification_builder => builder }
45
48
  # in case DB does not have the attribute created yet but plugin initializer registers the feature, we need to skip this attribute
46
49
  attrs = [ :host_action_button, :notification_builder ]
@@ -101,7 +101,12 @@ class RemoteExecutionProvider
101
101
 
102
102
  # Return a specific proxy selector to use for running a given template
103
103
  # Returns either nil to use the default selector or an instance of a (sub)class of ::ForemanTasks::ProxySelector
104
- def required_proxy_selector_for(_template)
104
+ def required_proxy_selector_for(template)
105
+ if template.remote_execution_features
106
+ .where(:proxy_selector_override => ::RemoteExecutionProxySelector::INTERNAL_PROXY)
107
+ .any?
108
+ ::DefaultProxyProxySelector.new
109
+ end
105
110
  end
106
111
  end
107
112
  end
@@ -1,4 +1,7 @@
1
1
  class RemoteExecutionProxySelector < ::ForemanTasks::ProxySelector
2
+
3
+ INTERNAL_PROXY = 'internal'.freeze
4
+
2
5
  def available_proxies(host, provider)
3
6
  host.remote_execution_proxies(provider)
4
7
  end
@@ -35,7 +35,7 @@ child :task do
35
35
  end
36
36
 
37
37
  child @template_invocations do
38
- attributes :template_id, :template_name
38
+ attributes :template_id, :template_name, :host_id
39
39
  child :input_values do
40
40
  attributes :template_input_name, :template_input_id
41
41
  node :value do |iv|
@@ -0,0 +1,12 @@
1
+ <div class='form-group'>
2
+ <label class='col-md-2 control-label'>
3
+ <%= _('Remote Execution Interface') %>
4
+ <% help = _('Identifier of the Host interface for Remote execution') %>
5
+ <a rel="popover" data-content="<%= help %>" data-trigger="focus" data-container="body" data-html="true" tabindex="-1">
6
+ <span class="pficon pficon-info "></span>
7
+ </a>
8
+ </label>
9
+ <div class='col-md-4'>
10
+ <%= text_field_tag 'remote_execution_interface', params[:remote_execution_interface], class: 'form-control' %>
11
+ </div>
12
+ </div>
@@ -1,7 +1,7 @@
1
1
  <% output_line_set['output'].gsub("\r\n", "\n").sub(/\n\Z/, '').split("\n", -1).each do |line| %>
2
2
  <%= content_tag :div, :class => 'line ' + output_line_set['output_type'], :data => { :timestamp => output_line_set['timestamp'] } do %>
3
3
 
4
- <%= content_tag(:span, (@line_counter += 1).to_s.rjust(4).gsub(' ', '&nbsp;').html_safe + ':', :class => 'counter', :title => (output_line_set['timestamp'] && Time.at(output_line_set['timestamp']))) %>
4
+ <%= content_tag(:span, (@line_counter += 1).to_s.rjust(4).gsub(' ', '&nbsp;').html_safe + ':', :class => 'counter', :title => (output_line_set['timestamp'] && Time.at(output_line_set['timestamp'].to_f))) %>
5
5
  <%= content_tag(:div, colorize_line(line.gsub(JobInvocationOutputHelper::COLOR_PATTERN, '').empty? ? "#{line}\n" : line).html_safe, :class => 'content') %>
6
6
  <% end %>
7
7
  <% end %>
@@ -1,12 +1,16 @@
1
1
  <% items = [{ :caption => _('Job invocations'), :url => job_invocations_path },
2
2
  { :caption => @template_invocation.job_invocation.description,
3
- :url => job_invocation_path(@template_invocation.job_invocation_id) },
4
- { :caption => _('Template Invocation for %s') % @template_invocation.host.name }] %>
3
+ :url => job_invocation_path(@template_invocation.job_invocation_id) }]
5
4
 
6
- <% breadcrumbs(:resource_url => template_invocations_api_job_invocation_path(@template_invocation.job_invocation_id),
5
+ if @host
6
+ items << { :caption => _('Template Invocation for %s') % @host.name }
7
+ breadcrumbs(:resource_url => template_invocations_api_job_invocation_path(@template_invocation.job_invocation_id),
7
8
  :name_field => 'host_name',
8
9
  :switcher_item_url => template_invocation_path(':id'),
9
10
  :items => items)
11
+ else
12
+ breadcrumbs(items: items, switchable: false)
13
+ end
10
14
  %>
11
15
 
12
16
  <% stylesheet 'foreman_remote_execution/foreman_remote_execution' %>
@@ -18,31 +22,34 @@
18
22
  <%= button_group(link_to_function(_('Toggle command'), '$("div.preview").toggle()', :class => 'btn btn-default'),
19
23
  link_to_function(_('Toggle STDERR'), '$("div.line.stderr").toggle()', :class => 'btn btn-default'),
20
24
  link_to_function(_('Toggle STDOUT'), '$("div.line.stdout").toggle()', :class => 'btn btn-default'),
21
- link_to_function(_('Toggle DEBUG'), '$("div.line.debug").toggle()', :class => 'btn btn-default')) %>
25
+ link_to_function(_('Toggle DEBUG'), '$("div.line.debug").toggle()', :class => 'btn btn-default')) if @host %>
22
26
  <%= button_group(template_invocation_task_buttons(@template_invocation_task, @template_invocation.job_invocation)) %>
23
27
  </div>
24
28
  </div>
29
+ <% if @host %>
30
+ <h3><%= _('Target: ') %><%= link_to(@host.name, host_path(@host)) %></h3>
25
31
 
26
- <h3><%= _('Target: ') %><%= link_to(@host.name, host_path(@host)) %></h3>
27
-
28
- <div class="preview hidden">
29
- <%= preview_box(@template_invocation, @host) %>
30
- </div>
32
+ <div class="preview hidden">
33
+ <%= preview_box(@template_invocation, @host) %>
34
+ </div>
31
35
 
32
- <div class="terminal" data-refresh-url="<%= template_invocation_path(@template_invocation) %>">
33
- <% if @error %>
34
- <div class="line error"><%= @error %></div>
35
- <% else %>
36
- <%= link_to_function(_('Scroll to bottom'), '$("html, body").animate({ scrollTop: $(document).height() }, "slow");', :class => 'pull-right scroll-link-bottom') %>
36
+ <div class="terminal" data-refresh-url="<%= template_invocation_path(@template_invocation) %>">
37
+ <% if @error %>
38
+ <div class="line error"><%= @error %></div>
39
+ <% else %>
40
+ <%= link_to_function(_('Scroll to bottom'), '$("html, body").animate({ scrollTop: $(document).height() }, "slow");', :class => 'pull-right scroll-link-bottom') %>
37
41
 
38
- <div class="printable">
39
- <%= render :partial => 'output_line_set', :collection => normalize_line_sets(@line_sets) %>
40
- </div>
42
+ <div class="printable">
43
+ <%= render :partial => 'output_line_set', :collection => normalize_line_sets(@line_sets) %>
44
+ </div>
41
45
 
42
- <%= link_to_function(_('Scroll to top'), '$("html, body").animate({ scrollTop: 0 }, "slow");', :class => 'pull-right scroll-link-top') %>
43
- <% end %>
44
- </div>
46
+ <%= link_to_function(_('Scroll to top'), '$("html, body").animate({ scrollTop: 0 }, "slow");', :class => 'pull-right scroll-link-top') %>
47
+ <% end %>
48
+ </div>
45
49
 
46
- <script>
47
- <%= render :partial => 'refresh.js' %>
48
- </script>
50
+ <script>
51
+ <%= render :partial => 'refresh.js' %>
52
+ </script>
53
+ <% else %>
54
+ <%= _("Could not display data for job invocation.") %>
55
+ <% end %>