Merge pull request #596 from knu/agent_dry_runnable

Add a "Dry Run" feature to the Agent form.

Akinori MUSHA 9 ans auparavant
Parent
Commettre
38c53ba091

+ 21 - 0
app/assets/javascripts/components/utils.js.coffee

@@ -12,3 +12,24 @@ class @Utils
12 12
         window.currentPage = new klass()
13 13
     else
14 14
       new klass()
15
+
16
+  @showDynamicModal: (content = '', { title, body, onHide } = {}) ->
17
+    $("body").append """
18
+      <div class="modal fade" tabindex="-1" id='dynamic-modal' role="dialog" aria-labelledby="dynamic-modal-label" aria-hidden="true">
19
+        <div class="modal-dialog modal-lg">
20
+          <div class="modal-content">
21
+            <div class="modal-header">
22
+              <button type="button" class="close" data-dismiss="modal"><span aria-hidden="true">&times;</span><span class="sr-only">Close</span></button>
23
+              <h4 class="modal-title" id="dynamic-modal-label"></h4>
24
+            </div>
25
+            <div class="modal-body">#{content}</div>
26
+          </div>
27
+        </div>
28
+      </div>
29
+      """
30
+    modal = document.querySelector('#dynamic-modal')
31
+    $(modal).find('.modal-title').text(title || '').end().on 'hidden.bs.modal', ->
32
+      $('#dynamic-modal').remove()
33
+      onHide?()
34
+    body?(modal.querySelector('.modal-body'))
35
+    $(modal).modal('show')

+ 38 - 0
app/assets/javascripts/pages/agent-edit-page.js.coffee

@@ -7,6 +7,8 @@ class @AgentEditPage
7 7
     if $("#agent_type").length
8 8
       $("#agent_type").on "change", => @handleTypeChange(false)
9 9
       @handleTypeChange(true)
10
+    else
11
+      @enableDryRunButton()
10 12
 
11 13
   handleTypeChange: (firstTime) ->
12 14
     $(".event-descriptions").html("").hide()
@@ -50,6 +52,8 @@ class @AgentEditPage
50 52
           $('.agent-options').html(json.form_options) if json.form_options?
51 53
           window.jsonEditor = setupJsonEditor()[0]
52 54
 
55
+        @enableDryRunButton()
56
+
53 57
         window.initializeFormCompletable()
54 58
 
55 59
         $("#agent-spinner").stop(true, true).fadeOut();
@@ -122,5 +126,39 @@ class @AgentEditPage
122 126
       else
123 127
         @hideEventCreation()
124 128
 
129
+  enableDryRunButton: ->
130
+    $(".agent-dry-run-button").prop('disabled', false).off().on "click", @invokeDryRun
131
+
132
+  disableDryRunButton: ->
133
+    $(".agent-dry-run-button").prop('disabled', true)
134
+
135
+  invokeDryRun: (e) ->
136
+    e.preventDefault()
137
+    button = this
138
+    $(button).prop('disabled', true)
139
+    $('body').css(cursor: 'progress')
140
+    $.ajax type: 'POST', url: $(button).data('action-url'), dataType: 'json', data: $(button.form).serialize()
141
+      .always =>
142
+        $("body").css(cursor: 'auto')
143
+      .done (json) =>
144
+        Utils.showDynamicModal """
145
+          <h5>Log</h5>
146
+          <pre class="agent-dry-run-log"></pre>
147
+          <h5>Events</h5>
148
+          <pre class="agent-dry-run-events"></pre>
149
+          <h5>Memory</h5>
150
+          <pre class="agent-dry-run-memory"></pre>
151
+          """,
152
+          body: (body) ->
153
+            $(body).
154
+              find('.agent-dry-run-log').text(json.log).end().
155
+              find('.agent-dry-run-events').text(json.events).end().
156
+              find('.agent-dry-run-memory').text(json.memory)
157
+          title: 'Dry Run Results',
158
+          onHide: -> $(button).prop('disabled', false)
159
+      .fail (xhr, status, error) ->
160
+        alert('Error: ' + error)
161
+        $(button).prop('disabled', false)
162
+
125 163
 $ ->
126 164
   Utils.registerPage(AgentEditPage, forPathsMatching: /^agents/)

+ 4 - 16
app/assets/javascripts/pages/agent-show-page.js.coffee

@@ -19,22 +19,10 @@ class @AgentShowPage
19 19
         $button = $(this)
20 20
         $button.on 'click', (e) ->
21 21
           e.preventDefault()
22
-          $("body").append """
23
-            <div class="modal fade" tabindex="-1" id='dynamic-modal' role="dialog" aria-labelledby="dynamic-modal-label" aria-hidden="true">
24
-              <div class="modal-dialog modal-lg">
25
-                <div class="modal-content">
26
-                  <div class="modal-header">
27
-                    <button type="button" class="close" data-dismiss="modal"><span aria-hidden="true">&times;</span><span class="sr-only">Close</span></button>
28
-                    <h4 class="modal-title" id="dynamic-modal-label"></h4>
29
-                  </div>
30
-                  <div class="modal-body"><pre></pre></div>
31
-                </div>
32
-              </div>
33
-            </div>
34
-          """
35
-          $('#dynamic-modal').find('.modal-title').text $button.data('modal-title')
36
-          $('#dynamic-modal').find('.modal-body pre').text $button.data('modal-content')
37
-          $('#dynamic-modal').modal('show').on 'hidden.bs.modal', -> $('#dynamic-modal').remove()
22
+          Utils.showDynamicModal '<pre></pre>',
23
+            title: $button.data('modal-title'),
24
+            body: (body) ->
25
+              $(body).find('pre').text $button.data('modal-content')
38 26
 
39 27
       $("#logs .spinner").stop(true, true).fadeOut ->
40 28
         $("#logs .refresh, #logs .clear").show()

+ 64 - 0
app/concerns/dry_runnable.rb

@@ -0,0 +1,64 @@
1
+module DryRunnable
2
+  def dry_run!
3
+    readonly!
4
+
5
+    class << self
6
+      prepend Sandbox
7
+    end
8
+
9
+    log = StringIO.new
10
+    @dry_run_logger = Logger.new(log)
11
+    @dry_run_results = {
12
+      events: [],
13
+    }
14
+
15
+    begin
16
+      raise "#{short_type} does not support dry-run" unless can_dry_run?
17
+      check
18
+    rescue => e
19
+      error "Exception during dry-run. #{e.message}: #{e.backtrace.join("\n")}"
20
+    end
21
+
22
+    @dry_run_results.update(
23
+      memory: memory,
24
+      log: log.string,
25
+    )
26
+  end
27
+
28
+  module Sandbox
29
+    attr_accessor :results
30
+
31
+    def logger
32
+      @dry_run_logger
33
+    end
34
+
35
+    def save
36
+      valid?
37
+    end
38
+
39
+    def save!
40
+      save or raise ActiveRecord::RecordNotSaved
41
+    end
42
+
43
+    def log(message, options = {})
44
+      case options[:level] || 3
45
+      when 0..2
46
+        sev = Logger::DEBUG
47
+      when 3
48
+        sev = Logger::INFO
49
+      else
50
+        sev = Logger::ERROR
51
+      end
52
+
53
+      logger.log(sev, message)
54
+    end
55
+
56
+    def create_event(event_hash)
57
+      if can_create_events?
58
+        @dry_run_results[:events] << event_hash[:payload]
59
+      else
60
+        error "This Agent cannot create events!"
61
+      end
62
+    end
63
+  end
64
+end

+ 44 - 10
app/controllers/agents_controller.rb

@@ -1,5 +1,6 @@
1 1
 class AgentsController < ApplicationController
2 2
   include DotHelper
3
+  include ActionView::Helpers::TextHelper
3 4
   include SortableTable
4 5
 
5 6
   def index
@@ -33,20 +34,53 @@ class AgentsController < ApplicationController
33 34
     end
34 35
   end
35 36
 
37
+  def dry_run
38
+    attrs = params[:agent]
39
+    if agent = current_user.agents.find_by(id: params[:id])
40
+      # PUT /agents/:id/dry_run
41
+      type = agent.type
42
+    else
43
+      # POST /agents/dry_run
44
+      type = attrs.delete(:type)
45
+    end
46
+    agent = Agent.build_for_type(type, current_user, attrs)
47
+    agent.name ||= '(Untitled)'
48
+
49
+    if agent.valid?
50
+      results = agent.dry_run!
51
+
52
+      render json: {
53
+        log: results[:log],
54
+        events: Utils.pretty_print(results[:events], false),
55
+        memory: Utils.pretty_print(results[:memory] || {}, false),
56
+      }
57
+    else
58
+      render json: {
59
+        log: [
60
+          "#{pluralize(agent.errors.count, "error")} prohibited this Agent from being saved:",
61
+          *agent.errors.full_messages
62
+        ].join("\n- "),
63
+        events: '',
64
+        memory: '',
65
+      }
66
+    end
67
+  end
68
+
36 69
   def type_details
37 70
     @agent = Agent.build_for_type(params[:type], current_user, {})
38 71
     initialize_presenter
39 72
 
40
-    render :json => {
41
-        :can_be_scheduled => @agent.can_be_scheduled?,
42
-        :default_schedule => @agent.default_schedule,
43
-        :can_receive_events => @agent.can_receive_events?,
44
-        :can_create_events => @agent.can_create_events?,
45
-        :can_control_other_agents => @agent.can_control_other_agents?,
46
-        :options => @agent.default_options,
47
-        :description_html => @agent.html_description,
48
-        :oauthable => render_to_string(partial: 'oauth_dropdown', locals: { agent: @agent }),
49
-        :form_options => render_to_string(partial: 'options', locals: { agent: @agent })
73
+    render json: {
74
+        can_be_scheduled: @agent.can_be_scheduled?,
75
+        default_schedule: @agent.default_schedule,
76
+        can_receive_events: @agent.can_receive_events?,
77
+        can_create_events: @agent.can_create_events?,
78
+        can_control_other_agents: @agent.can_control_other_agents?,
79
+        can_dry_run: @agent.can_dry_run?,
80
+        options: @agent.default_options,
81
+        description_html: @agent.html_description,
82
+        oauthable: render_to_string(partial: 'oauth_dropdown', locals: { agent: @agent }),
83
+        form_options: render_to_string(partial: 'options', locals: { agent: @agent })
50 84
     }
51 85
   end
52 86
 

+ 13 - 0
app/models/agent.rb

@@ -12,6 +12,7 @@ class Agent < ActiveRecord::Base
12 12
   include LiquidInterpolatable
13 13
   include HasGuid
14 14
   include LiquidDroppable
15
+  include DryRunnable
15 16
 
16 17
   markdown_class_attributes :description, :event_description
17 18
 
@@ -194,6 +195,10 @@ class Agent < ActiveRecord::Base
194 195
     self.class.can_control_other_agents?
195 196
   end
196 197
 
198
+  def can_dry_run?
199
+    self.class.can_dry_run?
200
+  end
201
+
197 202
   def log(message, options = {})
198 203
     AgentLog.log_for_agent(self, message, options)
199 204
   end
@@ -328,6 +333,14 @@ class Agent < ActiveRecord::Base
328 333
       include? AgentControllerConcern
329 334
     end
330 335
 
336
+    def can_dry_run!
337
+      @can_dry_run = true
338
+    end
339
+
340
+    def can_dry_run?
341
+      !!@can_dry_run
342
+    end
343
+
331 344
     def gem_dependency_check
332 345
       @gem_dependencies_checked = true
333 346
       @gem_dependencies_met = yield

+ 2 - 0
app/models/agents/website_agent.rb

@@ -5,6 +5,8 @@ module Agents
5 5
   class WebsiteAgent < Agent
6 6
     include WebRequestConcern
7 7
 
8
+    can_dry_run!
9
+
8 10
     default_schedule "every_12h"
9 11
 
10 12
     UNIQUENESS_LOOK_BACK = 200

+ 4 - 1
app/views/agents/_options.erb

@@ -24,4 +24,7 @@
24 24
 <% end %>
25 25
 <div class="form-group">
26 26
   <%= submit_tag "Save", :class => "btn btn-primary" %>
27
-</div>
27
+  <% if agent.can_dry_run? %>
28
+    <%= button_tag class: 'btn btn-default agent-dry-run-button', type: 'button', 'data-action-url' => agent.persisted? ? dry_run_agent_path(agent) : dry_run_agents_path do %><%= icon_tag('glyphicon-refresh') %> Dry Run<% end %>
29
+  <% end %>
30
+</div>

+ 2 - 0
config/routes.rb

@@ -2,6 +2,7 @@ Huginn::Application.routes.draw do
2 2
   resources :agents do
3 3
     member do
4 4
       post :run
5
+      put :dry_run
5 6
       post :handle_details_post
6 7
       put :leave_scenario
7 8
       delete :remove_events
@@ -10,6 +11,7 @@ Huginn::Application.routes.draw do
10 11
     collection do
11 12
       post :propagate
12 13
       get :type_details
14
+      post :dry_run
13 15
       get :event_descriptions
14 16
       post :validate
15 17
       post :complete

+ 56 - 0
spec/concerns/dry_runnable_spec.rb

@@ -0,0 +1,56 @@
1
+require 'spec_helper'
2
+
3
+describe DryRunnable do
4
+  class Agents::SandboxedAgent < Agent
5
+    default_schedule "3pm"
6
+
7
+    can_dry_run!
8
+
9
+    def check
10
+      log "Logging"
11
+      create_event payload: { test: "foo" }
12
+      error "Recording error"
13
+      create_event payload: { test: "bar" }
14
+      self.memory = { last_status: "ok" }
15
+      save!
16
+    end
17
+  end
18
+
19
+  before do
20
+    stub(Agents::SandboxedAgent).valid_type?("Agents::SandboxedAgent") { true }
21
+
22
+    @agent = Agents::SandboxedAgent.create(name: "some agent") { |agent|
23
+      agent.user = users(:bob)
24
+    }
25
+  end
26
+
27
+  it "traps logging, event emission and memory updating" do
28
+    results = nil
29
+
30
+    expect {
31
+      results = @agent.dry_run!
32
+    }.not_to change {
33
+      [users(:bob).agents.count, users(:bob).events.count, users(:bob).logs.count]
34
+    }
35
+
36
+    expect(results[:log]).to match(/\AI, .+ INFO -- : Logging\nE, .+ ERROR -- : Recording error\n/)
37
+    expect(results[:events]).to eq([{ test: 'foo' }, { test: 'bar' }])
38
+    expect(results[:memory]).to eq({ "last_status" => "ok" })
39
+  end
40
+
41
+  it "does not perform dry-run if Agent does not support dry-run" do
42
+    stub(@agent).can_dry_run? { false }
43
+
44
+    results = nil
45
+
46
+    expect {
47
+      results = @agent.dry_run!
48
+    }.not_to change {
49
+      [users(:bob).agents.count, users(:bob).events.count, users(:bob).logs.count]
50
+    }
51
+
52
+    expect(results[:log]).to match(/\AE, .+ ERROR -- : Exception during dry-run. SandboxedAgent does not support dry-run: /)
53
+    expect(results[:events]).to eq([])
54
+    expect(results[:memory]).to eq({})
55
+  end
56
+end

+ 27 - 0
spec/controllers/agents_controller_spec.rb

@@ -347,4 +347,31 @@ describe AgentsController do
347 347
       end
348 348
     end
349 349
   end
350
+
351
+  describe "POST dry_run" do
352
+    it "does not actually create any agent, event or log" do
353
+      sign_in users(:bob)
354
+      expect {
355
+        post :dry_run, agent: valid_attributes()
356
+      }.not_to change {
357
+        [users(:bob).agents.count, users(:bob).events.count, users(:bob).logs.count]
358
+      }
359
+      json = JSON.parse(response.body)
360
+      expect(json['log']).to be_a(String)
361
+      expect(json['events']).to be_a(String)
362
+      expect(JSON.parse(json['events']).map(&:class)).to eq([Hash])
363
+      expect(json['memory']).to be_a(String)
364
+      expect(JSON.parse(json['memory'])).to be_a(Hash)
365
+    end
366
+
367
+    it "does not actually update an agent" do
368
+      sign_in users(:bob)
369
+      agent = agents(:bob_weather_agent)
370
+      expect {
371
+        post :dry_run, id: agents(:bob_website_agent), agent: valid_attributes(name: 'New Name')
372
+      }.not_to change {
373
+        [users(:bob).agents.count, users(:bob).events.count, users(:bob).logs.count, agent.name, agent.updated_at]
374
+      }
375
+    end
376
+  end
350 377
 end