diff --git a/.env.example b/.env.example index 3a487249be..28f6df2fb2 100644 --- a/.env.example +++ b/.env.example @@ -147,7 +147,7 @@ ENABLE_SECOND_PRECISION_SCHEDULE=false # Specify the scheduler frequency in seconds (default: 0.3). # Increasing this value will help reduce the use of system resources # at the expense of time accuracy. -#SCHEDULER_FREQUENCY=0.3 +SCHEDULER_FREQUENCY=0.3 # Use Graphviz for generating diagrams instead of using Google Chart # Tools. Specify a dot(1) command path built with SVG support @@ -159,3 +159,9 @@ TIMEZONE="Pacific Time (US & Canada)" # Number of failed jobs to keep in the database FAILED_JOBS_TO_KEEP=100 + +# Maximum runtime of background jobs in minutes +DELAYED_JOB_MAX_RUNTIME=20 + +# Amount of seconds for delayed_job to sleep before checking for new jobs +DELAYED_JOB_SLEEP_DELAY=10 \ No newline at end of file diff --git a/Gemfile b/Gemfile index b958f7b8d1..4c24f9c2bc 100644 --- a/Gemfile +++ b/Gemfile @@ -58,6 +58,7 @@ gem 'daemons', '~> 1.1.9' gem 'delayed_job', '~> 4.0.0' gem 'delayed_job_active_record', '~> 4.0.0' gem 'devise', '~> 3.4.0' +gem 'dotenv-rails', '~> 2.0.1' gem 'em-http-request', '~> 1.1.2' gem 'faraday', '~> 0.9.0' gem 'faraday_middleware' @@ -96,27 +97,25 @@ group :development do gem 'guard' gem 'guard-livereload' gem 'guard-rspec' -end -group :development, :test do - gem 'coveralls', require: false - gem 'delorean' - gem 'dotenv-rails' - gem 'pry' - gem 'rr' - gem 'rspec', '~> 3.2' - gem 'rspec-collection_matchers', '~> 1.1.0' - gem 'rspec-rails', '~> 3.1' - gem 'rspec-html-matchers', '~> 0.7' - gem 'shoulda-matchers' - gem 'spring', '~> 1.3.0' - gem 'spring-commands-rspec' - gem 'vcr' - gem 'webmock', '~> 1.17.4', require: false + group :test do + gem 'coveralls', require: false + gem 'delorean' + gem 'pry' + gem 'rr' + gem 'rspec', '~> 3.2' + gem 'rspec-collection_matchers', '~> 1.1.0' + gem 'rspec-rails', '~> 3.1' + gem 'rspec-html-matchers', '~> 0.7' + gem 'shoulda-matchers' + gem 'spring', '~> 1.3.0' + gem 'spring-commands-rspec' + gem 'vcr' + gem 'webmock', '~> 1.17.4', require: false + end end group :production do - gem 'dotenv-deployment' gem 'rack' end @@ -126,15 +125,23 @@ gem 'tzinfo', '>= 1.2.0' # required by rails; 1.2.0 has support for *BSD and Sol # Windows does not have zoneinfo files, so bundle the tzinfo-data gem. gem 'tzinfo-data', platforms: [:mingw, :mswin, :x64_mingw] -# This hack needs some explanation. When on Heroku, use the pg, unicorn, and rails12factor gems. -# When not on Heroku, we still want our Gemfile.lock to include these gems, so we scope them to -# an unsupported platform. -if ENV['ON_HEROKU'] || ENV['HEROKU_POSTGRESQL_ROSE_URL'] || ENV['HEROKU_POSTGRESQL_GOLD_URL'] || File.read(File.join(File.dirname(__FILE__), 'Procfile')) =~ /intended for Heroku/ +# Introduces a scope for Heroku specific gems. +def on_heroku + if ENV['ON_HEROKU'] || + ENV['HEROKU_POSTGRESQL_ROSE_URL'] || + ENV['HEROKU_POSTGRESQL_GOLD_URL'] || + File.read(File.join(File.dirname(__FILE__), 'Procfile')) =~ /intended for Heroku/ + yield + else + # When not on Heroku, we still want our Gemfile.lock to include + # Heroku specific gems, so we scope them to an unsupported + # platform. + platform :ruby_18, &proc + end +end + +on_heroku do gem 'pg' gem 'unicorn' gem 'rails_12factor', group: :production -else - gem 'pg', platform: :ruby_18 - gem 'unicorn', platform: :ruby_18 - gem 'rails_12factor', platform: :ruby_18 end diff --git a/Gemfile.lock b/Gemfile.lock index 2e989c72be..34c3da2b11 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -124,11 +124,9 @@ GEM docile (1.1.5) domain_name (0.5.24) unf (>= 0.0.5, < 1.0.0) - dotenv (0.11.1) - dotenv-deployment (~> 0.0.2) - dotenv-deployment (0.0.2) - dotenv-rails (0.11.1) - dotenv (= 0.11.1) + dotenv (2.0.1) + dotenv-rails (2.0.1) + dotenv (= 2.0.1) dropbox-api (0.4.2) hashie multi_json @@ -507,8 +505,7 @@ DEPENDENCIES delayed_job_active_record (~> 4.0.0) delorean devise (~> 3.4.0) - dotenv-deployment - dotenv-rails + dotenv-rails (~> 2.0.1) dropbox-api em-http-request (~> 1.1.2) faraday (~> 0.9.0) diff --git a/app/assets/javascripts/components/utils.js.coffee b/app/assets/javascripts/components/utils.js.coffee index cf29e5c46f..7fb2b3c258 100644 --- a/app/assets/javascripts/components/utils.js.coffee +++ b/app/assets/javascripts/components/utils.js.coffee @@ -33,3 +33,29 @@ class @Utils onHide?() body?(modal.querySelector('.modal-body')) $(modal).modal('show') + + @handleDryRunButton: (button, data = $(button.form).serialize()) -> + $(button).prop('disabled', true) + $('body').css(cursor: 'progress') + $.ajax type: 'POST', url: $(button).data('action-url'), dataType: 'json', data: data + .always => + $('body').css(cursor: 'auto') + .done (json) => + Utils.showDynamicModal """ +
Log
+

+          
Events
+

+          
Memory
+

+          """,
+          body: (body) ->
+            $(body).
+              find('.agent-dry-run-log').text(json.log).end().
+              find('.agent-dry-run-events').text(json.events).end().
+              find('.agent-dry-run-memory').text(json.memory)
+          title: 'Dry Run Results',
+          onHide: -> $(button).prop('disabled', false)
+      .fail (xhr, status, error) ->
+        alert('Error: ' + error)
+        $(button).prop('disabled', false)
diff --git a/app/assets/javascripts/pages/agent-edit-page.js.coffee b/app/assets/javascripts/pages/agent-edit-page.js.coffee
index 62642de3cd..9adfe820d9 100644
--- a/app/assets/javascripts/pages/agent-edit-page.js.coffee
+++ b/app/assets/javascripts/pages/agent-edit-page.js.coffee
@@ -174,32 +174,7 @@ class @AgentEditPage
 
   invokeDryRun: (e) =>
     e.preventDefault()
-    button = e.target
-    $(button).prop('disabled', true)
-    $('body').css(cursor: 'progress')
-    @updateFromEditors()
-    $.ajax type: 'POST', url: $(button).data('action-url'), dataType: 'json', data: $(button.form).serialize()
-      .always =>
-        $("body").css(cursor: 'auto')
-      .done (json) =>
-        Utils.showDynamicModal """
-          
Log
-

-          
Events
-

-          
Memory
-

-          """,
-          body: (body) ->
-            $(body).
-              find('.agent-dry-run-log').text(json.log).end().
-              find('.agent-dry-run-events').text(json.events).end().
-              find('.agent-dry-run-memory').text(json.memory)
-          title: 'Dry Run Results',
-          onHide: -> $(button).prop('disabled', false)
-      .fail (xhr, status, error) ->
-        alert('Error: ' + error)
-        $(button).prop('disabled', false)
+    Utils.handleDryRunButton(this)
 
 $ ->
   Utils.registerPage(AgentEditPage, forPathsMatching: /^agents/)
diff --git a/app/assets/stylesheets/diagram.css.scss b/app/assets/stylesheets/diagram.css.scss
index c068ca62c2..ba5acb4dfd 100644
--- a/app/assets/stylesheets/diagram.css.scss
+++ b/app/assets/stylesheets/diagram.css.scss
@@ -8,13 +8,9 @@
   }
 
   .overlay-container {
-    position: absolute;
-    top: 0;
-    left: 0;
     z-index: auto;
 
     .overlay {
-      position: relative;
       z-index: auto;
       width: 100%;
       height: 100%;
diff --git a/app/concerns/dry_runnable.rb b/app/concerns/dry_runnable.rb
index 165573eb96..d3951635f8 100644
--- a/app/concerns/dry_runnable.rb
+++ b/app/concerns/dry_runnable.rb
@@ -25,6 +25,10 @@ class << self
     )
   end
 
+  def dry_run?
+    is_a? Sandbox
+  end
+
   module Sandbox
     attr_accessor :results
 
diff --git a/app/controllers/agents_controller.rb b/app/controllers/agents_controller.rb
index d656b371bc..d19f7cd25c 100644
--- a/app/controllers/agents_controller.rb
+++ b/app/controllers/agents_controller.rb
@@ -35,15 +35,18 @@ def run
   end
 
   def dry_run
-    attrs = params[:agent]
+    attrs = params[:agent] || {}
     if agent = current_user.agents.find_by(id: params[:id])
       # PUT /agents/:id/dry_run
-      type = agent.type
+      if attrs.present?
+        type = agent.type
+        agent = Agent.build_for_type(type, current_user, attrs)
+      end
     else
       # POST /agents/dry_run
       type = attrs.delete(:type)
+      agent = Agent.build_for_type(type, current_user, attrs)
     end
-    agent = Agent.build_for_type(type, current_user, attrs)
     agent.name ||= '(Untitled)'
 
     if agent.valid?
diff --git a/app/controllers/diagrams_controller.rb b/app/controllers/diagrams_controller.rb
index 6772ea4101..9092e0b204 100644
--- a/app/controllers/diagrams_controller.rb
+++ b/app/controllers/diagrams_controller.rb
@@ -1,9 +1,13 @@
 class DiagramsController < ApplicationController
   def show
-    @agents = if params[:scenario_id].present?
-                current_user.scenarios.find(params[:scenario_id]).agents.includes(:receivers)
-              else
-                current_user.agents.includes(:receivers)
-              end
+    if params[:scenario_id].present?
+      @scenario = current_user.scenarios.find(params[:scenario_id])
+      agents = @scenario.agents
+    else
+      agents = current_user.agents
+    end
+    @disabled_agents = agents.inactive
+    agents = agents.active if params[:exclude_disabled].present?
+    @agents = agents.includes(:receivers)
   end
 end
diff --git a/app/models/agent.rb b/app/models/agent.rb
index c0aa3d8ab9..2d893d2573 100644
--- a/app/models/agent.rb
+++ b/app/models/agent.rb
@@ -60,7 +60,8 @@ class Agent < ActiveRecord::Base
   has_many :scenario_memberships, :dependent => :destroy, :inverse_of => :agent
   has_many :scenarios, :through => :scenario_memberships, :inverse_of => :agents
 
-  scope :active, -> { where(disabled: false) }
+  scope :active,   -> { where(disabled: false) }
+  scope :inactive, -> { where(disabled: true) }
 
   scope :of_type, lambda { |type|
     type = case type
diff --git a/app/models/agents/imap_folder_agent.rb b/app/models/agents/imap_folder_agent.rb
index c39abbb7e4..87959634b5 100644
--- a/app/models/agents/imap_folder_agent.rb
+++ b/app/models/agents/imap_folder_agent.rb
@@ -6,13 +6,15 @@ module Agents
   class ImapFolderAgent < Agent
     cannot_receive_events!
 
+    can_dry_run!
+
     default_schedule "every_30m"
 
     description <<-MD
 
       The ImapFolderAgent checks an IMAP server in specified folders
       and creates Events based on new mails found since the last run.
-      In the first visit to a foler, this agent only checks for the
+      In the first visit to a folder, this agent only checks for the
       initial status and does not create events.
 
       Specify an IMAP server to connect with `host`, and set `ssl` to
@@ -45,8 +47,8 @@ class ImapFolderAgent < Agent
           specified, will be chosen as the "body" value in a created
           event.
 
-          Named captues will appear in the "matches" hash in a created
-          event.
+          Named captures will appear in the "matches" hash in a
+          created event.
 
       - "from", "to", "cc"
 
@@ -311,7 +313,7 @@ def check
 
         if boolify(interpolated['mark_as_read'])
           log 'Marking as read'
-          mail.mark_as_read
+          mail.mark_as_read unless dry_run?
         end
       }
     end
@@ -322,7 +324,7 @@ def each_unread_mail
       port = (Integer(port) if port.present?)
 
       log "Connecting to #{host}#{':%d' % port if port}#{' via SSL' if ssl}"
-      Client.open(host, port, ssl) { |imap|
+      Client.open(host, port: port, ssl: ssl) { |imap|
         log "Logging in as #{username}"
         imap.login(username, interpolated[:password])
 
@@ -437,8 +439,8 @@ def pluralize(count, noun)
 
     class Client < ::Net::IMAP
       class << self
-        def open(host, port, ssl)
-          imap = new(host, port, ssl)
+        def open(host, *args)
+          imap = new(host, *args)
           yield imap
         ensure
           imap.disconnect unless imap.nil?
@@ -525,17 +527,19 @@ def initialize(client, fetch_data, props = {})
 
       def has_attachment?
         @has_attachment ||=
-          begin
-            data = @client.uid_fetch(@uid, 'BODYSTRUCTURE').first
+          if data = @client.uid_fetch(@uid, 'BODYSTRUCTURE').first
             struct_has_attachment?(data.attr['BODYSTRUCTURE'])
+          else
+            false
           end
       end
 
       def fetch
         @parsed ||=
-          begin
-            data = @client.uid_fetch(@uid, 'BODY.PEEK[]').first
+          if data = @client.uid_fetch(@uid, 'BODY.PEEK[]').first
             Mail.read_from_string(data.attr['BODY[]'])
+          else
+            Mail.read_from_string('')
           end
       end
 
diff --git a/app/views/agents/_action_menu.html.erb b/app/views/agents/_action_menu.html.erb
index d251acde2e..5a8a59e8da 100644
--- a/app/views/agents/_action_menu.html.erb
+++ b/app/views/agents/_action_menu.html.erb
@@ -5,6 +5,12 @@
     
   <% end %>
 
+  <% if agent.can_dry_run? %>
+    
  • + <%= link_to icon_tag('glyphicon-refresh') + ' Dry Run', '#', 'data-action-url' => dry_run_agent_path(agent), tabindex: "-1", onclick: "Utils.handleDryRunButton(this, '_method=PUT')" %> +
  • + <% end %> +
  • <%= link_to icon_tag('glyphicon-eye-open') + ' Show'.html_safe, agent_path(agent) %>
  • diff --git a/app/views/diagrams/show.html.erb b/app/views/diagrams/show.html.erb index f09b0d0c1e..f106fb7367 100644 --- a/app/views/diagrams/show.html.erb +++ b/app/views/diagrams/show.html.erb @@ -9,7 +9,14 @@

    Agent Event Flow

    - <%= link_to icon_tag('glyphicon-chevron-left') + ' Back'.html_safe, (params[:scenario_id] ? scenario_path(params[:scenario_id]) : agents_path), class: "btn btn-default" %> + <%= link_to icon_tag('glyphicon-chevron-left') + ' Back'.html_safe, (@scenario ? scenario_path(@scenario) : agents_path), class: "btn btn-default" %> + <% if (num_disabled = @disabled_agents.count).nonzero? -%> + <% if params[:exclude_disabled] %> + <%= link_to @scenario ? scenario_diagram_path(@scenario) : diagram_path, class: 'btn btn-default' do %><%= icon_tag('glyphicon-eye-open') %> Show <%= pluralize(num_disabled, 'disabled Agent') %><% end %> + <% else %> + <%= link_to @scenario ? scenario_diagram_path(@scenario, exclude_disabled: true) : diagram_path(exclude_disabled: true), class: 'btn btn-default' do %><%= icon_tag('glyphicon-eye-close') %> Hide <%= pluralize(num_disabled, 'disabled Agent') %><% end %> + <% end %> + <% end %>
    diff --git a/bin/threaded.rb b/bin/threaded.rb index 56c99c46f7..9e68d825f6 100644 --- a/bin/threaded.rb +++ b/bin/threaded.rb @@ -2,6 +2,8 @@ require 'huginn_scheduler' require 'twitter_stream' +Rails.configuration.cache_classes = true + STDOUT.sync = true STDERR.sync = true diff --git a/config/application.rb b/config/application.rb index f4156e403a..91e82fe8da 100644 --- a/config/application.rb +++ b/config/application.rb @@ -4,10 +4,10 @@ Bundler.require(:default, Rails.env) -Dotenv.overload File.expand_path('../../spec/env.test', __FILE__) if Rails.env.test? - module Huginn class Application < Rails::Application + Dotenv.overload File.expand_path('../../spec/env.test', __FILE__) if Rails.env.test? + # Settings in config/environments/* take precedence over those specified here. # Application configuration should go into files in config/initializers # -- all .rb files in that directory are automatically loaded. diff --git a/config/initializers/delayed_job.rb b/config/initializers/delayed_job.rb index cf2d29c1f3..5a55bdc2a8 100644 --- a/config/initializers/delayed_job.rb +++ b/config/initializers/delayed_job.rb @@ -1,9 +1,10 @@ Delayed::Worker.destroy_failed_jobs = false Delayed::Worker.max_attempts = 5 -Delayed::Worker.max_run_time = 20.minutes +Delayed::Worker.max_run_time = (ENV['DELAYED_JOB_MAX_RUNTIME'].presence || 20).to_i.minutes Delayed::Worker.read_ahead = 5 Delayed::Worker.default_priority = 10 Delayed::Worker.delay_jobs = !Rails.env.test? +Delayed::Worker.sleep_delay = (ENV['DELAYED_JOB_SLEEP_DELAY'].presence || 10).to_f # Delayed::Worker.logger = Logger.new(Rails.root.join('log', 'delayed_job.log')) # Delayed::Worker.logger.level = Logger::DEBUG diff --git a/spec/concerns/dry_runnable_spec.rb b/spec/concerns/dry_runnable_spec.rb index bfba97fe61..4eaf3f89a0 100644 --- a/spec/concerns/dry_runnable_spec.rb +++ b/spec/concerns/dry_runnable_spec.rb @@ -8,10 +8,10 @@ class Agents::SandboxedAgent < Agent def check log "Logging" - create_event payload: { test: "foo" } + create_event payload: { 'test' => 'foo' } error "Recording error" - create_event payload: { test: "bar" } - self.memory = { last_status: "ok" } + create_event payload: { 'test' => 'bar' } + self.memory = { 'last_status' => 'ok', 'dry_run' => dry_run? } save! end end @@ -24,18 +24,41 @@ def check } end - it "traps logging, event emission and memory updating" do + def counts + [users(:bob).agents.count, users(:bob).events.count, users(:bob).logs.count] + end + + it "does not affect normal run, with dry_run? returning false" do + before = counts + after = before.zip([0, 2, 2]).map { |x, d| x + d } + + expect { + @agent.check + @agent.reload + }.to change { counts }.from(before).to(after) + + expect(@agent.memory).to eq({ 'last_status' => 'ok', 'dry_run' => false }) + + payloads = @agent.events.reorder(:id).last(2).map(&:payload) + expect(payloads).to eq([{ 'test' => 'foo' }, { 'test' => 'bar' }]) + + messages = @agent.logs.reorder(:id).last(2).map(&:message) + expect(messages).to eq(['Logging', 'Recording error']) + end + + it "traps logging, event emission and memory updating, with dry_run? returning true" do results = nil expect { results = @agent.dry_run! + @agent.reload }.not_to change { - [users(:bob).agents.count, users(:bob).events.count, users(:bob).logs.count] + [@agent.memory, counts] } expect(results[:log]).to match(/\AI, .+ INFO -- : Logging\nE, .+ ERROR -- : Recording error\n/) - expect(results[:events]).to eq([{ test: 'foo' }, { test: 'bar' }]) - expect(results[:memory]).to eq({ "last_status" => "ok" }) + expect(results[:events]).to eq([{ 'test' => 'foo' }, { 'test' => 'bar' }]) + expect(results[:memory]).to eq({ 'last_status' => 'ok', 'dry_run' => true }) end it "does not perform dry-run if Agent does not support dry-run" do @@ -45,8 +68,9 @@ def check expect { results = @agent.dry_run! + @agent.reload }.not_to change { - [users(:bob).agents.count, users(:bob).events.count, users(:bob).logs.count] + [@agent.memory, counts] } expect(results[:log]).to match(/\AE, .+ ERROR -- : Exception during dry-run. SandboxedAgent does not support dry-run: /)