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
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 %>