@@ -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">×</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') |
@@ -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/) |
@@ -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">×</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() |
@@ -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 |
@@ -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 |
|
@@ -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 |
@@ -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 |
@@ -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,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 |
@@ -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 |
@@ -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 |