Sync up recent work - warvox - VoIP based wardialing tool, forked from rapid7/warvox.
   DIR Log
   DIR Files
   DIR Refs
   DIR README
       ---
   DIR commit 6fba0686fb93cf3157e42be1dff58a5b9cace5b0
   DIR parent 876a89b6d4cde9a8e6df003f1aa0c08565040afc
  HTML Author: HD Moore <hd_moore@rapid7.com>
       Date:   Sun,  6 Jan 2013 21:34:24 -0600
       
       Sync up recent work
       
       Diffstat:
         A app/assets/images/search.png        |       0 
         M app/assets/javascripts/application… |     158 +++++++++++++++++++++++++++++++
         A app/assets/javascripts/dataTables.… |      44 +++++++++++++++++++++++++++++++
         A app/assets/javascripts/dataTables.… |      54 +++++++++++++++++++++++++++++++
         A app/assets/javascripts/dataTables.… |      15 +++++++++++++++
         A app/assets/javascripts/dataTables_… |     133 +++++++++++++++++++++++++++++++
         A app/assets/javascripts/jobs/view_r… |      52 +++++++++++++++++++++++++++++++
         A app/assets/javascripts/jquery.tabl… |     215 +++++++++++++++++++++++++++++++
         D app/assets/stylesheets/application… |      12 ------------
         A app/assets/stylesheets/application… |      74 +++++++++++++++++++++++++++++++
         M app/assets/stylesheets/bootstrap_a… |      22 ++++++++++++++++++++++
         M app/controllers/jobs_controller.rb  |     115 ++++++++++++++++++++++++++++---
         M app/helpers/application_helper.rb   |     153 +++++++++++++++++++++++++++++++
         M app/models/job.rb                   |      13 +++++++++----
         A app/views/jobs/_view_results.json.… |      20 ++++++++++++++++++++
         M app/views/jobs/view_results.html.e… |      52 +++++++++++++++++--------------
         M app/views/layouts/application.html… |      22 ++++++++++++----------
         M config/environments/development.rb  |       6 ++++--
         M config/routes.rb                    |      11 ++++++-----
         A db/migrate/20130106000000_add_inde… |      29 +++++++++++++++++++++++++++++
         M db/schema.rb                        |      19 ++++++++++++++++++-
         M lib/warvox/jobs/analysis.rb         |       9 ++++++---
       
       22 files changed, 1159 insertions(+), 69 deletions(-)
       ---
   DIR diff --git a/app/assets/images/search.png b/app/assets/images/search.png
       Binary files differ.
   DIR diff --git a/app/assets/javascripts/application.js b/app/assets/javascripts/application.js
       @@ -7,4 +7,162 @@
        //= require bootstrap-lightbox
        //= require dataTables/jquery.dataTables
        //= require dataTables/jquery.dataTables.bootstrap
       +//= require dataTables.hiddenTitle
       +//= require dataTables.filteringDelay
       +//= require dataTables.fnReloadAjax
       +//= require jquery.table
       +//= require dataTables_overrides
        //= require highcharts
       +
       +
       +
       +
       +function getParameterByName(name)
       +{
       +  name = name.replace(/[\[]/, "\\\[").replace(/[\]]/, "\\\]");
       +  var regexS = "[\\?&]" + name + "=([^&#]*)";
       +  var regex = new RegExp(regexS);
       +  var results = regex.exec(window.location.href);
       +  if(results == null)
       +    return "";
       +  else
       +    return decodeURIComponent(results[1].replace(/\+/g, " "));
       +}
       +
       +
       +/*
       + * If the given select element is set to "", disables every other element
       + * inside the select's form.
       + */
       +function disable_fields_if_select_is_blank(select) {
       +        var formElement = Element.up(select, "form");
       +        var fields = formElement.getElements();
       +
       +        Element.observe(select, "change", function(e) {
       +                var v = select.getValue();
       +                for (var i in fields) {
       +                        if (fields[i] != select && fields[i].type && fields[i].type.toLowerCase() != 'hidden' && fields[i].type.toLowerCase() != 'submit') {
       +                                if (v != "") {
       +                                        fields[i].disabled = true
       +                                } else {
       +                                        fields[i].disabled = false;
       +                                }
       +                        }
       +                }
       +        });
       +}
       +
       +function enable_fields_with_checkbox(checkbox, div) {
       +        var fields;
       +
       +        if (!div) {
       +                div = Element.up(checkbox, "fieldset")
       +        }
       +
       +        f = function(e) {
       +                fields = div.descendants();
       +                var v = checkbox.getValue();
       +                for (var i in fields) {
       +                        if (fields[i] != checkbox && fields[i].type && fields[i].type.toLowerCase() != 'hidden') {
       +                                if (!v) {
       +                                        fields[i].disabled = true
       +                                } else {
       +                                        fields[i].disabled = false;
       +                                }
       +                        }
       +                }
       +        }
       +        f();
       +        Element.observe(checkbox, "change", f);
       +}
       +
       +function placeholder_text(field, text) {
       +        var formElement = Element.up(field, "form");
       +        var submitButton = Element.select(formElement, 'input[type="submit"]')[0];
       +
       +        if (field.value == "") {
       +                field.value = text;
       +                field.setAttribute("class", "placeholder");
       +        }
       +
       +        Element.observe(field, "focus", function(e) {
       +                field.setAttribute("class", "");
       +                if (field.value == text) {
       +                        field.value = "";
       +                }
       +        });
       +        Element.observe(field, "blur", function(e) {
       +                if (field.value == "") {
       +                        field.setAttribute("class", "placeholder");
       +                        field.value = text;
       +                }
       +        });
       +        submitButton.observe("click", function(e) {
       +                if (field.value == text) {
       +                        field.value = "";
       +                }
       +        });
       +}
       +
       +
       +function submit_checkboxes_to(path, token) {
       +        var f = document.createElement('form');
       +        f.style.display = 'none';
       +
       +        /* Set the post destination */
       +        f.method = "POST";
       +        f.action = path;
       +
       +        /* Create the authenticity_token */
       +        var s = document.createElement('input');
       +        s.setAttribute('type', 'hidden');
       +        s.setAttribute('name', 'authenticity_token');
       +        s.setAttribute('value', token);
       +        f.appendChild(s);
       +
       +        /* Copy the checkboxes from the host form */
       +        $("input[type=checkbox]").each(function(i,e) {
       +                if (e.checked)  {
       +                        var c = document.createElement('input');
       +                        c.setAttribute('type', 'hidden');
       +                        c.setAttribute('name',  e.getAttribute('name')  );
       +                        c.setAttribute('value', e.getAttribute('value') );
       +                        f.appendChild(c);
       +                }
       +        })
       +
       +        /* Look for hidden variables in checkbox form */
       +        $("input[type=hidden]").each(function(i,e) {
       +                if ( e.getAttribute('name').indexOf("[]") != -1 )  {
       +                        var c = document.createElement('input');
       +                        c.setAttribute('type', 'hidden');
       +                        c.setAttribute('name',  e.getAttribute('name')  );
       +                        c.setAttribute('value', e.getAttribute('value') );
       +                        f.appendChild(c);
       +                }
       +        })
       +
       +        /* Copy the search field from the host form */
       +        $("input#search").each(function (i,e) {
       +                if (e.getAttribute("class") != "placeholder") {
       +                        var c = document.createElement('input');
       +                        c.setAttribute('type', 'hidden');
       +                        c.setAttribute('name',  e.getAttribute('name')  );
       +                        c.setAttribute('value', e.value );
       +                        f.appendChild(c);
       +                }
       +        });
       +
       +        /* Append to the main form body */
       +        document.body.appendChild(f);
       +        f.submit();
       +        return false;
       +}
       +
       +
       +// Look for the other half of this in app/coffeescripts/forms.coffee
       +function enableSubmitButtons() {
       +  $("form.formtastic input[type='submit']").each(function(elmt) {
       +    elmt.removeClassName('disabled'); elmt.removeClassName('submitting');
       +  });
       +}
   DIR diff --git a/app/assets/javascripts/dataTables.filteringDelay.js b/app/assets/javascripts/dataTables.filteringDelay.js
       @@ -0,0 +1,44 @@
       +jQuery.fn.dataTableExt.oApi.fnSetFilteringDelay = function ( oSettings, iDelay ) {
       +        /*
       +         * Inputs:      object:oSettings - dataTables settings object - automatically given
       +         *              integer:iDelay - delay in milliseconds
       +         * Usage:       $('#example').dataTable().fnSetFilteringDelay(250);
       +         * Author:      Zygimantas Berziunas (www.zygimantas.com) and Allan Jardine
       +         * License:     GPL v2 or BSD 3 point style
       +         * Contact:     zygimantas.berziunas /AT\ hotmail.com
       +         */
       +        var
       +                _that = this,
       +                iDelay = (typeof iDelay == 'undefined') ? 250 : iDelay;
       +        
       +        this.each( function ( i ) {
       +                jQuery.fn.dataTableExt.iApiIndex = i;
       +                var
       +                        $this = this, 
       +                        oTimerId = null, 
       +                        sPreviousSearch = null,
       +                        anControl = jQuery( 'input', _that.fnSettings().aanFeatures.f );
       +                
       +                        anControl.unbind( 'keyup' ).bind( 'keyup', function() {
       +                        var $$this = $this;
       +
       +                        if (sPreviousSearch === null || sPreviousSearch != anControl.val()) {
       +                                window.clearTimeout(oTimerId);
       +                                sPreviousSearch = anControl.val();        
       +                                oTimerId = window.setTimeout(function() {
       +                                        jQuery.fn.dataTableExt.iApiIndex = i;
       +                                        _that.fnFilter( anControl.val() );
       +                                }, iDelay);
       +                        }
       +                });
       +                
       +                return this;
       +        } );
       +        return this;
       +}
       +
       +/* Example call
       +$(document).ready(function() {
       +        $('.dataTable').dataTable().fnSetFilteringDelay();
       +} ); */
       +
   DIR diff --git a/app/assets/javascripts/dataTables.fnReloadAjax.js b/app/assets/javascripts/dataTables.fnReloadAjax.js
       @@ -0,0 +1,53 @@
       +jQuery.fn.dataTableExt.oApi.fnReloadAjax = function ( oSettings, sNewSource, fnCallback, bStandingRedraw )
       +{
       +  if ( typeof sNewSource != 'undefined' && sNewSource != null ) {
       +    oSettings.sAjaxSource = sNewSource;
       +  }
       +
       +  // Server-side processing should just call fnDraw
       +  if ( oSettings.oFeatures.bServerSide ) {
       +    this.fnDraw();
       +    return;
       +  }
       +
       +  this.oApi._fnProcessingDisplay( oSettings, true );
       +  var that = this;
       +  var iStart = oSettings._iDisplayStart;
       +  var aData = [];
       +
       +  this.oApi._fnServerParams( oSettings, aData );
       +
       +  oSettings.fnServerData.call( oSettings.oInstance, oSettings.sAjaxSource, aData, function(json) {
       +    /* Clear the old information from the table */
       +    that.oApi._fnClearTable( oSettings );
       +      
       +    /* Got the data - add it to the table */
       +    var aData =  (oSettings.sAjaxDataProp !== "") ?
       +      that.oApi._fnGetObjectDataFn( oSettings.sAjaxDataProp )( json ) : json;
       +      
       +    for ( var i=0 ; i<aData.length ; i++ )
       +    {
       +      that.oApi._fnAddData( oSettings, aData[i] );
       +    }
       +
       +    oSettings.aiDisplay = oSettings.aiDisplayMaster.slice();
       +      
       +    if ( typeof bStandingRedraw != 'undefined' && bStandingRedraw === true )
       +    {
       +      oSettings._iDisplayStart = iStart;
       +      that.fnDraw( false );
       +    }
       +    else
       +    {
       +      that.fnDraw();
       +    }
       +
       +    that.oApi._fnProcessingDisplay( oSettings, false );
       +
       +    /* Callback user function - for event handlers etc */
       +    if ( typeof fnCallback == 'function' && fnCallback != null )
       +    {
       +      fnCallback( oSettings );
       +    }
       +  }, oSettings );
       +};
       +\ No newline at end of file
   DIR diff --git a/app/assets/javascripts/dataTables.hiddenTitle.js b/app/assets/javascripts/dataTables.hiddenTitle.js
       @@ -0,0 +1,15 @@
       +jQuery.fn.dataTableExt.oSort['title-numeric-asc']  = function(a,b) {
       +  var x = a.match(/title="*(-?[0-9]+)/)[1];
       +  var y = b.match(/title="*(-?[0-9]+)/)[1];
       +  x = parseFloat( x );
       +  y = parseFloat( y );
       +  return ((x < y) ? -1 : ((x > y) ?  1 : 0));
       +};
       +
       +jQuery.fn.dataTableExt.oSort['title-numeric-desc'] = function(a,b) {
       +  var x = a.match(/title="*(-?[0-9]+)/)[1];
       +  var y = b.match(/title="*(-?[0-9]+)/)[1];
       +  x = parseFloat( x );
       +  y = parseFloat( y );
       +  return ((x < y) ?  1 : ((x > y) ? -1 : 0));
       +};
   DIR diff --git a/app/assets/javascripts/dataTables_overrides.js b/app/assets/javascripts/dataTables_overrides.js
       @@ -0,0 +1,133 @@
       +$.extend( $.fn.dataTableExt.oStdClasses, {
       +    "sWrapper": "dataTables_wrapper form-inline"
       +} );
       +
       +
       +/* API method to get paging information */
       +$.fn.dataTableExt.oApi.fnPagingInfo = function ( oSettings )
       +{
       +        return {
       +                "iStart":         oSettings._iDisplayStart,
       +                "iEnd":           oSettings.fnDisplayEnd(),
       +                "iLength":        oSettings._iDisplayLength,
       +                "iTotal":         oSettings.fnRecordsTotal(),
       +                "iFilteredTotal": oSettings.fnRecordsDisplay(),
       +                "iPage":          Math.ceil( oSettings._iDisplayStart / oSettings._iDisplayLength ),
       +                "iTotalPages":    Math.ceil( oSettings.fnRecordsDisplay() / oSettings._iDisplayLength )
       +        };
       +};
       +
       +/* Bootstrap style pagination control */
       +$.extend( $.fn.dataTableExt.oPagination, {
       +        "bootstrap": {
       +                "fnInit": function( oSettings, nPaging, fnDraw ) {
       +                        var oLang = oSettings.oLanguage.oPaginate;
       +                        var fnClickHandler = function ( e ) {
       +                                e.preventDefault();
       +                                if ( oSettings.oApi._fnPageChange(oSettings, e.data.action) ) {
       +                                        fnDraw( oSettings );
       +                                }
       +                        };
       +
       +                        $(nPaging).addClass('pagination').append(
       +                                '<ul>'+
       +                                        '<li class="prev disabled"><a href="#">← '+oLang.sPrevious+'</a></li>'+
       +                                        '<li class="next disabled"><a href="#">'+oLang.sNext+' → </a></li>'+
       +                                '</ul>'
       +                        );
       +                        var els = $('a', nPaging);
       +                        $(els[0]).bind( 'click.DT', { action: "previous" }, fnClickHandler );
       +                        $(els[1]).bind( 'click.DT', { action: "next" }, fnClickHandler );
       +                },
       +
       +                "fnUpdate": function ( oSettings, fnDraw ) {
       +                        var iListLength = 5;
       +                        var oPaging = oSettings.oInstance.fnPagingInfo();
       +                        var an = oSettings.aanFeatures.p;
       +                        var i, j, sClass, iStart, iEnd, iHalf=Math.floor(iListLength/2);
       +
       +                        if ( oPaging.iTotalPages < iListLength) {
       +                                iStart = 1;
       +                                iEnd = oPaging.iTotalPages;
       +                        }
       +                        else if ( oPaging.iPage <= iHalf ) {
       +                                iStart = 1;
       +                                iEnd = iListLength;
       +                        } else if ( oPaging.iPage >= (oPaging.iTotalPages-iHalf) ) {
       +                                iStart = oPaging.iTotalPages - iListLength + 1;
       +                                iEnd = oPaging.iTotalPages;
       +                        } else {
       +                                iStart = oPaging.iPage - iHalf + 1;
       +                                iEnd = iStart + iListLength - 1;
       +                        }
       +
       +                        for ( i=0, iLen=an.length ; i<iLen ; i++ ) {
       +                                // Remove the middle elements
       +                                $('li:gt(0)', an[i]).filter(':not(:last)').remove();
       +
       +                                // Add the new list items and their event handlers
       +                                for ( j=iStart ; j<=iEnd ; j++ ) {
       +                                        sClass = (j==oPaging.iPage+1) ? 'class="active"' : '';
       +                                        $('<li '+sClass+'><a href="#">'+j+'</a></li>')
       +                                                .insertBefore( $('li:last', an[i])[0] )
       +                                                .bind('click', function (e) {
       +                                                        e.preventDefault();
       +                                                        oSettings._iDisplayStart = (parseInt($('a', this).text(),10)-1) * oPaging.iLength;
       +                                                        fnDraw( oSettings );
       +                                                } );
       +                                }
       +
       +                                // Add / remove disabled classes from the static elements
       +                                if ( oPaging.iPage === 0 ) {
       +                                        $('li:first', an[i]).addClass('disabled');
       +                                } else {
       +                                        $('li:first', an[i]).removeClass('disabled');
       +                                }
       +
       +                                if ( oPaging.iPage === oPaging.iTotalPages-1 || oPaging.iTotalPages === 0 ) {
       +                                        $('li:last', an[i]).addClass('disabled');
       +                                } else {
       +                                        $('li:last', an[i]).removeClass('disabled');
       +                                }
       +                        }
       +                }
       +        }
       +} );
       +
       +
       +/*
       + * TableTools Bootstrap compatibility
       + * Required TableTools 2.1+
       + */
       +if ( $.fn.DataTable.TableTools ) {
       +        // Set the classes that TableTools uses to something suitable for Bootstrap
       +        $.extend( true, $.fn.DataTable.TableTools.classes, {
       +                "container": "DTTT btn-group",
       +                "buttons": {
       +                        "normal": "btn",
       +                        "disabled": "disabled"
       +                },
       +                "collection": {
       +                        "container": "DTTT_dropdown dropdown-menu",
       +                        "buttons": {
       +                                "normal": "",
       +                                "disabled": "disabled"
       +                        }
       +                },
       +                "print": {
       +                        "info": "DTTT_print_info modal"
       +                },
       +                "select": {
       +                        "row": "active"
       +                }
       +        } );
       +
       +        // Have the collection use a bootstrap compatible dropdown
       +        $.extend( true, $.fn.DataTable.TableTools.DEFAULTS.oTags, {
       +                "collection": {
       +                        "container": "ul",
       +                        "button": "li",
       +                        "liner": "a"
       +                }
       +        } );
       +}
   DIR diff --git a/app/assets/javascripts/jobs/view_results.coffee b/app/assets/javascripts/jobs/view_results.coffee
       @@ -0,0 +1,52 @@
       +jQuery ($) ->
       +  $ ->
       +    resultsPath = $('#results-path').html()
       +    $resultsTable = $('#results-table')
       +
       +    # Enable DataTable for the results list.
       +    $resultsDataTable = $resultsTable.table
       +      analysisTab: true
       +      controlBarLocation: $('.analysis-control-bar')
       +      searchInputHint:   'Search Calls'
       +      searchable: true
       +      datatableOptions:
       +        "sDom": "<'row'<'span6'l><'span6'f>r>t<'row'<'span6'i><'span6'p>>",
       +        "sPaginationType": "bootstrap",
       +        "oLanguage":
       +          "sEmptyTable":    "No results for this job."
       +        "sAjaxSource":      resultsPath
       +        "aaSorting":      [[1, 'asc']]
       +        "aoColumns": [
       +          {"mDataProp": "checkbox", "bSortable": false}
       +          {"mDataProp": "number"}
       +          {"mDataProp": "caller_id"}
       +          {"mDataProp": "provider"}
       +          {"mDataProp": "answered"}
       +          {"mDataProp": "busy"}
       +          {"mDataProp": "audio_length"}
       +          {"mDataProp": "ring_length"}
       +        ]
       +
       +    # Gray out the table during loads.
       +    $("#results-table_processing").watch 'visibility', ->
       +      if $(this).css('visibility') == 'visible'
       +        $resultsTable.css opacity: 0.6
       +      else
       +        $resultsTable.css opacity: 1
       +
       +    # Display the search bar when the search icon is clicked
       +    $('.button .search').click (e) ->
       +      $filter = $('.dataTables_filter')
       +      $input = $('.dataTables_filter input')
       +      if $filter.css('bottom').charAt(0) == '-' # if (css matches -42px)
       +        # input box is visible, hide it
       +        # only allow user to hide if there is no search string
       +        if !$input.val() || $input.val().length < 1
       +          $filter.css('bottom', '99999999px')
       +      else # input box is invisible, display it
       +        $filter.css('bottom', '-42px')
       +        $input.focus()  # auto-focus input
       +      e.preventDefault()
       +
       +    searchVal = $('.dataTables_filter input').val()
       +    $('.button .search').click() if searchVal && searchVal.length > 0
   DIR diff --git a/app/assets/javascripts/jquery.table.coffee b/app/assets/javascripts/jquery.table.coffee
       @@ -0,0 +1,215 @@
       +# table plugin
       +#
       +# Adds sorting and other dynamic functions to tables.
       +jQuery ($) ->
       +  $.table =
       +    defaults:
       +      searchable:        true
       +      searchInputHint:   'Search'
       +      sortableClass:     'sortable'
       +      setFilteringDelay: false
       +      datatableOptions:
       +        "bStateSave":    true
       +        "oLanguage":
       +          "sSearch":  ""
       +          "sProcessing":    "Loading..."
       +        "fnDrawCallback": ->
       +          $.table.controlBar.buttons.enable()
       +        "sDom": '<"control-bar"f><"list-table-header clearfix"l>t<"list-table-footer clearfix"ip>r'
       +        "sPaginationType": "full_numbers"
       +        "fnInitComplete": (oSettings, json) ->
       +           # if old search term saved, display it
       +          searchTerm = getParameterByName 'search'
       +          # FIX ME
       +          $searchBox = $('#search', $(this).parents().eq(3))
       +
       +          if searchTerm
       +            $searchBox.val searchTerm
       +            $searchBox.focus()
       +
       +          # insert the cancel button to the left of the search box
       +          $searchBox.before('<a class="cancel-search" href="#"></a>')
       +          $a = $('.cancel-search')
       +          table = this
       +          searchTerm = $searchBox.val()
       +          searchBox = $searchBox.eq(0)
       +          $a.hide() if (!searchTerm || searchTerm.length < 1)
       +
       +          $a.click (e) ->  # called when red X is clicked
       +            $(this).hide()
       +            table.fnFilter ''
       +            $(searchBox).blur()           # blur to trigger filler text
       +            e.preventDefault()            # Other control code can be found in filteringDelay.js plugin.
       +          # bind to fnFilter() calls
       +          # do this by saving fnFilter to fnFilterOld & overriding
       +          table['fnFilterOld'] = table.fnFilter
       +          table.fnFilter = (str) ->
       +            $a = jQuery('.cancel-search')
       +            if str && str.length > 0
       +              $a.show()
       +            else
       +              $a.hide()
       +            table.fnFilterOld(str)
       +
       +          window.setTimeout ( =>
       +            this.fnFilter(searchTerm)
       +            ), 0
       +
       +          $('.button a.search').click() if searchTerm
       +
       +      analysisTabOptions:
       +        "aLengthMenu":      [[10, 50, 100, 250, 500, -1], [10, 50, 100, 250, 500, "All"]]
       +        "iDisplayLength":   10
       +        "bProcessing":      true
       +        "bServerSide":      true
       +        "bSortMulti":       false
       +
       +    checkboxes:
       +      bind: ->
       +        # TODO: This and any other 'table.list' selectors that appear in the plugin
       +        # code will trigger all sortable tables visible on the page.
       +        $("table.list thead tr th input[type='checkbox']").live 'click', (e) ->
       +          $checkboxes = $("input[type='checkbox']", "table.list tbody tr td:nth-child(1)")
       +          if $(this).attr 'checked'
       +            $checkboxes.attr 'checked', true
       +          else
       +            $checkboxes.attr 'checked', false
       +
       +    controlBar:
       +      buttons:
       +        # Disables/enables buttons based on number of checkboxes selected,
       +        # and the class name.
       +        enable: ->
       +          numChecked = $("tbody tr td input[type='checkbox']", "table.list").filter(':checked').not('.invisible').size()
       +          disable = ($button) ->
       +            $button.addClass 'disabled'
       +            $button.children('input').attr 'disabled', 'disabled'
       +          enable = ($button) ->
       +            $button.removeClass 'disabled'
       +            $button.children('input').removeAttr 'disabled'
       +
       +          switch numChecked
       +            when 0
       +              disable $('.btn.single',  '.control-bar')
       +              disable $('.btn.multiple','.control-bar')
       +              disable $('.btn.any',     '.control-bar')
       +            when 1
       +              enable  $('.btn.single',  '.control-bar')
       +              disable $('.btn.multiple','.control-bar')
       +              enable  $('.btn.any',     '.control-bar')
       +            else
       +              disable $('.btn.single',  '.control-bar')
       +              enable  $('.btn.multiple','.control-bar')
       +              enable  $('.btn.any',     '.control-bar')
       +
       +        show:
       +          bind: ->
       +            # Show button
       +            $showButton = $('span.button a.show', '.control-bar')
       +            if $showButton.length
       +              $showButton.click (e) ->
       +                unless $showButton.parent('span').hasClass 'disabled'
       +                  $("table.list tbody tr td input[type='checkbox']").filter(':checked').not('.invisible')
       +                  hostHref = $("table.list tbody tr td input[type='checkbox']")
       +                    .filter(':checked')
       +                    .parents('tr')
       +                    .children('td:nth-child(2)')
       +                    .children('a')
       +                    .attr('href')
       +                  window.location = hostHref
       +                e.preventDefault()
       +
       +        edit:
       +          bind: ->
       +            # Settings button
       +            $editButton = $('span.button a.edit', '.control-bar')
       +            if $editButton.length
       +              $editButton.click (e) ->
       +                unless $editButton.parent('span').hasClass 'disabled'
       +                  $("table.list tbody tr td input[type='checkbox']").filter(':checked').not('.invisible')
       +                  hostHref = $("table.list tbody tr td input[type='checkbox']")
       +                    .filter(':checked')
       +                    .parents('tr')
       +                    .children('td:nth-child(2)')
       +                    .children('span.settings-url')
       +                    .html()
       +                  window.location = hostHref
       +                e.preventDefault()
       +
       +        bind: (options) ->
       +          # Move the buttons into the control bar.
       +          $('.control-bar').prepend($('.control-bar-items').html())
       +          $('.control-bar-items').remove()
       +
       +          # Move the control bar to a new location, if specified.
       +          if !!options.controlBarLocation
       +            $('.control-bar').appendTo(options.controlBarLocation)
       +
       +          this.enable()
       +          this.show.bind()
       +          this.edit.bind()
       +
       +      bind: (options) ->
       +        this.buttons.bind(options)
       +        # Redraw the buttons with each checkbox click.
       +        $("input[type='checkbox']", "table.list").live 'click', (e) =>
       +          this.buttons.enable()
       +
       +    searchField:
       +      # Add an input hint to the search field.
       +      addInputHint: (options, $table) ->
       +        if options.searchable
       +          # if the searchbar is in a control bar, expand selector scope to include control bar
       +          searchScope = $table.parents().eq(3) if !!options.controlBarLocation
       +          searchScope ||= $table.parents().eq(2)  # otherwise limit scope to just the table
       +          $searchInput = $('.dataTables_filter input', searchScope)
       +          # We'll need this id set for the checkbox functions.
       +          $searchInput.attr 'id', 'search'
       +          $searchInput.attr 'placeholder', options.searchInputHint
       +          # $searchInput.inputHint()
       +
       +    bind: ($table, options) ->
       +      $tbody = $table.children('tbody')
       +      dataTable = null
       +      # Turn the table into a DataTable.
       +      if $table.hasClass options.sortableClass
       +        # Don't mess with the search input if there's no control bar.
       +        unless $('.control-bar-items').length
       +          options.datatableOptions["sDom"] = '<"list-table-header clearfix"lfr>t<"list-table-footer clearfix"ip>'
       +
       +        datatableOptions = options.datatableOptions
       +        # If we're loading under the Analysis tab, then load the standard
       +        # Analysis tab options.
       +        if options.analysisTab
       +          $.extend(datatableOptions, options.analysisTabOptions)
       +          options.setFilteringDelay = true
       +          options.controlBarLocation = $('.analysis-control-bar')
       +
       +        dataTable = $table.dataTable(datatableOptions)
       +        $table.data('dataTableObject', dataTable)
       +        dataTable.fnSetFilteringDelay(500) if options.setFilteringDelay
       +
       +        # If we're loading under the Analysis tab, then load the standard Analysis tab functions.
       +        if options.analysisTab
       +          # Gray out the table during loads.
       +          $("##{$table.attr('id')}_processing").watch 'visibility', ->
       +            if $(this).css('visibility') == 'visible'
       +              $table.css opacity: 0.6
       +            else
       +              $table.css opacity: 1
       +
       +          # Checking a host_ids checkbox should also check the invisible related object checkbox.
       +          $table.find('tbody tr td input[type=checkbox].hosts').live 'change', ->
       +            $(this).siblings('input[type=checkbox]').attr('checked', $(this).attr('checked'))
       +
       +        this.checkboxes.bind()
       +        this.controlBar.bind(options)
       +        # Add an input hint to the search field.
       +        this.searchField.addInputHint(options, $table)
       +        # Keep width at 100%.
       +        $table.css('width', '100%')
       +
       +  $.fn.table = (options) ->
       +    settings = $.extend true, {}, $.table.defaults, options
       +    $table   = $(this)
       +    return this.each -> $.table.bind($table, settings)
   DIR diff --git a/app/assets/stylesheets/application.css.scss b/app/assets/stylesheets/application.css.scss
       @@ -1,12 +0,0 @@
       -/*
       - *= require bootstrap_and_overrides
       - */
       -
       -/*
       - *= require_self
       - *= require formtastic
       - *= require formtastic-bootstrap
       - *= require formtastic-overrides
       - *= require bootstrap-lightbox
       - *= require dataTables/jquery.dataTables.bootstrap
       -*/
   DIR diff --git a/app/assets/stylesheets/application.css.scss.erb b/app/assets/stylesheets/application.css.scss.erb
       @@ -0,0 +1,74 @@
       +/*
       + *= require bootstrap_and_overrides
       + */
       +
       +/*
       + *= require_self
       + *= require formtastic
       + *= require formtastic-bootstrap
       + *= require formtastic-overrides
       + *= require bootstrap-lightbox
       + *= require dataTables/jquery.dataTables.bootstrap
       +*/
       +
       +
       +table.list {
       +  td.actions {
       +    vertical-align: middle;
       +  }
       +
       +  td.dataTables_empty {
       +    text-shadow: none !important;
       +  }
       +
       +  td a.datatables-search {
       +    color: blue;
       +
       +    &:hover{
       +      text-decoration: underline;
       +    }
       +  }
       +  thead tr {
       +    background-size: 100% 100%;
       +    background-color: #eeeeee;
       +  }
       +}
       +
       +.dataTables_filter {
       +  padding: 0px;
       +  width: auto !important;
       +
       +  input {
       +  background-image: url(<%= asset_path 'search.png' %>);
       +    background-position: 160px 6px;
       +    background-repeat: no-repeat;
       +    height: 18px;
       +    padding-left: 5px;
       +    width: 170px;
       +  }
       +}
       +
       +.dataTables_info {
       +        font-size: 11px;
       +        font-color: #666666;
       +}
       +
       +.dataTables_length label {
       +        font-weight: bold;
       +}
       +
       +.dataTables_length select {
       +        font-weight: bold;
       +}
       +
       +.control-bar {
       +        padding: 5px;
       +        text-align: center;
       +}
       +
       +.control-bar table {
       +        width: 320px;
       +        border: 0;
       +        margin-left: auto;
       +        margin-right: auto;
       +}
   DIR diff --git a/app/assets/stylesheets/bootstrap_and_overrides.css.less b/app/assets/stylesheets/bootstrap_and_overrides.css.less
       @@ -38,6 +38,28 @@ body {
        @navbarBackground: #ea5709;
        @navbarBackgroundHighlight: #4A1C04;
        
       +
       +// Datatables
       +
       +.paginate_disabled_previous {
       +        display: none;
       +}
       +
       +.paginate_disabled_next {
       +        display: none;
       +}
       +
       +.paginate_enabled_previous {
       +        color: red;
       +        margin-right: 20px;
       +}
       +
       +.paginate_enabled_next {
       +        color: green;
       +}
       +
       +// End of DataTables
       +
        .call-detail {
                font-size: 10px;
        }
   DIR diff --git a/app/controllers/jobs_controller.rb b/app/controllers/jobs_controller.rb
       @@ -1,5 +1,7 @@
        class JobsController < ApplicationController
        
       +  require 'shellwords'
       +
          def index
        
                @reload_interval = 20000
       @@ -42,11 +44,6 @@ class JobsController < ApplicationController
        
          def view_results
                  @job     = Job.find(params[:id])
       -          @results = @job.calls.paginate(
       -                :page => params[:page],
       -                :order => 'number ASC',
       -                :per_page => 30
       -        )
        
                @call_results = {
                        :Timeout  => @job.calls.count(:conditions => { :answered => false }),
       @@ -54,6 +51,78 @@ class JobsController < ApplicationController
                        :Answered => @job.calls.count(:conditions => { :answered => true }),
                }
        
       +    sort_by  = params[:sort_by] || 'number'
       +    sort_dir = params[:sort_dir] || 'asc'
       +
       +    @results = []
       +    @results_total_count = @job.calls.count()
       +
       +    if request.format.json?
       +      if params[:iDisplayLength] == '-1'
       +        @results_per_page = nil
       +      else
       +        @results_per_page = (params[:iDisplayLength] || 20).to_i
       +      end
       +      @results_offset = (params[:iDisplayStart] || 0).to_i
       +
       +          calls_search
       +      @results = @job.calls.includes(:provider).where(@search_conditions).limit(@results_per_page).offset(@results_offset).order(calls_sort_option)
       +      @results_total_display_count = @job.calls.includes(:provider).where(@search_conditions).count()
       +    end
       +
       +        respond_to do |format|
       +      format.html
       +      format.json { render :partial => 'view_results', :results => @results, :call_results => @call_results }
       +    end
       +  end
       +
       +  # Generate a SQL sort by option based on the incoming DataTables paramater.
       +  #
       +  # Returns the SQL String.
       +  def calls_sort_option
       +    column = case params[:iSortCol_0].to_s
       +             when '1'
       +               'number'
       +             when '2'
       +               'caller_id'
       +             when '3'
       +               'providers.name'
       +             when '4'
       +               'answered'
       +             when '5'
       +               'busy'
       +             when '6'
       +               'audio_length'
       +             when '7'
       +               'ring_length'
       +             end
       +    column + ' ' + (params[:sSortDir_0] =~ /^A/i ? 'asc' : 'desc') if column
       +  end
       +
       +  def calls_search
       +          @search_conditions = []
       +          terms = params[:sSearch].to_s
       +          terms = Shellword.shellwords(terms) rescue terms.split(/\s+/)
       +        where = ""
       +        param = []
       +        glue  = ""
       +        terms.each do |w|
       +                where << glue
       +                case w
       +                        when 'answered'
       +                                where << "answered = ? "
       +                                param << true
       +                        when 'busy'
       +                                where << "busy = ? "
       +                                param << true
       +                        else
       +                                where << "( number ILIKE ? OR caller_id ILIKE ? ) "
       +                                param << "%#{w}%"
       +                                param << "%#{w}%"
       +                end
       +                glue = "AND " if glue.empty?
       +                @search_conditions = [ where, *param ]
       +        end
          end
        
          def new_dialer
       @@ -64,12 +133,29 @@ class JobsController < ApplicationController
                    @job.project = Project.last
            end
        
       +    if params[:result_ids]
       +            nums = ""
       +            Call.find_each(:conditions => { :id => params[:result_ids] }) do |call|
       +                    nums << call.number + "\n"
       +            end
       +            @job.range = nums
       +    end
       +
       +
            respond_to do |format|
              format.html # new.html.erb
              format.xml  { render :xml => @job }
            end
          end
        
       +  def purge_calls
       +          @job = Job.find(params[:id])
       +        Call.delete_all(:id => params[:result_ids])
       +        CallMedium.delete_all(:call_id => params[:result_ids])
       +        flash[:notice] = "Purged #{params[:result_ids].length} calls"
       +        redirect_to view_results_path(@job.project_id, @job.id)
       +  end
       +
          def dialer
            @job = Job.new(params[:job])
            @job.created_by = current_user.login
       @@ -114,10 +200,21 @@ class JobsController < ApplicationController
        
          def analyze_job
                @job = Job.find(params[:id])
       -        @new = Job.new({
       -                :task => 'analysis', :scope => 'job', :target_id => @job.id,
       -                :project_id => @project.id, :status => 'submitted'
       -        })
       +
       +        # Handle analysis of specific call IDs via checkbox submission
       +        if params[:result_ids]
       +                @new = Job.new({
       +                        :task => 'analysis', :scope => 'calls', :target_ids => params[:result_ids],
       +                        :project_id => @project.id, :status => 'submitted'
       +                })
       +        else
       +        # Otherwise analyze the entire Job
       +                @new = Job.new({
       +                        :task => 'analysis', :scope => 'job', :target_id => @job.id,
       +                        :project_id => @project.id, :status => 'submitted'
       +                })
       +        end
       +
            respond_to do |format|
              if @new.schedule
                flash[:notice] = 'Analysis job was successfully created.'
   DIR diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb
       @@ -92,6 +92,159 @@ module ApplicationHelper
                        else
                                job.status.to_s.capitalize
                        end
       +        end
       +
       +        #
       +        # Includes any javascripts specific to this view. The hosts/show view
       +        # will automatically include any javascripts at public/javascripts/hosts/show.js.
       +        #
       +        # @return [void]
       +        def include_view_javascript
       +                #
       +                # Sprockets treats index.js as special, so the js for the index action must be called _index.js instead.
       +                # http://guides.rubyonrails.org/asset_pipeline.html#using-index-files
       +                #
       +
       +                controller_action_name = controller.action_name
       +
       +                if controller_action_name == 'index'
       +                        safe_action_name = '_index'
       +                else
       +                        safe_action_name = controller_action_name
       +                end
       +
       +                include_view_javascript_named(safe_action_name)
       +        end
       +
       +        # Includes the named javascript for this controller if it exists.
       +        #
       +        # @return [void]
       +        def include_view_javascript_named(name)
       +
       +                controller_path = controller.controller_path
       +                extensions = ['.coffee', '.js.coffee']
       +                javascript_controller_pathname = Rails.root.join('app', 'assets', 'javascripts', controller_path)
       +                pathnames = extensions.collect { |extension|
       +                        javascript_controller_pathname.join("#{name}#{extension}")
       +                }
       +
       +                if pathnames.any?(&:exist?)
       +                        path = File.join(controller_path, name)
       +                        content_for(:view_javascript) do
       +                                javascript_include_tag path
       +                        end
       +                end
       +        end
       +
       +
       +
       +  #
       +  # Generate pagination links
       +  #
       +  # Parameters:
       +  #   :name:: the kind of the items we're paginating
       +  #   :items:: the collection of items currently on the page
       +  #   :count:: total count of items to paginate
       +  #   :offset:: offset from the beginning where +items+ starts within the total
       +  #   :page:: current page
       +  #   :num_pages:: total number of pages
       +  #
       +  def page_links(opts={})
       +    link_method = opts[:link_method]
       +    if not link_method or not respond_to? link_method
       +      raise RuntimeError.new("Need a method for generating links")
       +    end
       +    name      = opts[:name] || ""
       +    items     = opts[:items] || []
       +    count     = opts[:count] || 0
       +    offset    = opts[:offset] || 0
       +    page      = opts[:page] || 1
       +    num_pages = opts[:num_pages] || 1
       +
       +    page_list = ""
       +    1.upto(num_pages) do |p|
       +      if p == page
       +        page_list << content_tag(:span, :class=>"current") { h page }
       +      else
       +        page_list << self.send(link_method, p, { :page => p })
       +      end
       +    end
       +    content_tag(:div, :id => "page_links") do
       +      content_tag(:span, :class => "index") do
       +        if items.size > 0
       +          "#{offset + 1}-#{offset + items.size} of #{h pluralize(count, name)}" + " "*3
       +        else
       +          h(name.pluralize)
       +        end.html_safe
       +      end +
       +        if num_pages > 1
       +          self.send(link_method, '', { :page => 0 }, { :class => 'start' }) +
       +            self.send(link_method, '', { :page => page-1 }, {:class => 'prev' }) +
       +            page_list +
       +            self.send(link_method, '', { :page => [page+1,num_pages].min }, { :class => 'next' }) +
       +            self.send(link_method, '', { :page => num_pages }, { :class => 'end' })
       +        else
       +          ""
       +        end
       +    end
       +  end
       +
       +        def submit_checkboxes_to(name, path, html={})
       +                if html[:confirm]
       +                        confirm = html.delete(:confirm)
       +                        link_to(name, "#", html.merge({:onclick => "if(confirm('#{h confirm}')){ submit_checkboxes_to('#{path}','#{form_authenticity_token}')}else{return false;}" }))
       +                else
       +                        link_to(name, "#", html.merge({:onclick => "submit_checkboxes_to('#{path}','#{form_authenticity_token}')" }))
       +                end
       +        end
       +
       +        # Scrub out data that can break the JSON parser
       +        #
       +        # data - The String json to be scrubbed.
       +        #
       +        # Returns the String json with invalid data removed.
       +        def json_data_scrub(data)
       +                data.to_s.gsub(/[\x00-\x1f]/){ |x| "\\x%.2x" % x.unpack("C*")[0] }
       +        end
        
       +        # Returns the properly escaped sEcho parameter that DataTables expects.
       +        def echo_data_tables
       +                h(params[:sEcho]).to_json.html_safe
                end
       +
       +        # Generate the markup for the call's row checkbox.
       +        # Returns the String markup html, escaped for json.
       +        def call_checkbox_tag(call)
       +                check_box_tag("result_ids[]", call.id, false, :id => nil).to_json.html_safe
       +        end
       +
       +        def call_number_html(call)
       +                json_data_scrub(h(call.number)).to_json.html_safe
       +        end
       +
       +        def call_caller_id_html(call)
       +                json_data_scrub(h(call.caller_id)).to_json.html_safe
       +        end
       +
       +        def call_provider_html(call)
       +                json_data_scrub(h(call.provider.name)).to_json.html_safe
       +        end
       +
       +        def call_answered_html(call)
       +                json_data_scrub(h(call.answered ? "Yes" : "No")).to_json.html_safe
       +        end
       +
       +        def call_busy_html(call)
       +                json_data_scrub(h(call.busy ? "Yes" : "No")).to_json.html_safe
       +        end
       +
       +        def call_audio_length_html(call)
       +                json_data_scrub(h(call.audio_length.to_s)).to_json.html_safe
       +        end
       +
       +        def call_ring_length_html(call)
       +                json_data_scrub(h(call.ring_lenght.to_s)).to_json.html_safe
       +        end
       +
       +
        end
   DIR diff --git a/app/models/job.rb b/app/models/job.rb
       @@ -23,8 +23,8 @@ class Job < ActiveRecord::Base
                                                record.errors[:lines] << "Lines should be between 1 and 10,000"
                                        end
                                when 'analysis'
       -                                unless ['job', 'project', 'global'].include?(record.scope)
       -                                        record.errors[:scope] << "Scope must be job, project, or global"
       +                                unless ['calls', 'job', 'project', 'global'].include?(record.scope)
       +                                        record.errors[:scope] << "Scope must be calls, job, project, or global"
                                        end
                                        if record.scope == "job" and Job.where(:id => record.target_id.to_i, :task => ['import', 'dialer']).count == 0
                                                record.errors[:job_id] << "The job_id is not valid"
       @@ -32,6 +32,9 @@ class Job < ActiveRecord::Base
                                        if record.scope == "project" and Project.where(:id => record.target_id.to_i).count == 0
                                                record.errors[:project_id] << "The project_id is not valid"
                                        end
       +                                if record.scope == "calls" and (record.target_ids.nil? or record.target_ids.length == 0)
       +                                        record.errors[:target_ids] << "The target_ids list is empty"
       +                                end
                                when 'import'
                                else
                                        record.errors[:base] << "Invalid task specified"
       @@ -64,8 +67,9 @@ class Job < ActiveRecord::Base
                attr_accessor :scope
                attr_accessor :force
                attr_accessor :target_id
       +        attr_accessor :target_ids
        
       -        attr_accessible :scope, :force, :target_id
       +        attr_accessible :scope, :force, :target_id, :target_ids
        
        
                validates_with JobValidator
       @@ -102,7 +106,8 @@ class Job < ActiveRecord::Base
                                self.args = Marshal.dump({
                                        :scope      => self.scope,          # job / project/ global
                                        :force      => !!(self.force),      # true / false
       -                                :target_id  => self.target_id.to_i  # job_id or project_id or nil
       +                                :target_id  => self.target_id.to_i, # job_id or project_id or nil
       +                                :target_ids => self.target_ids.map{|x| x.to_i }
                                })
                                return self.save
                        else
   DIR diff --git a/app/views/jobs/_view_results.json.erb b/app/views/jobs/_view_results.json.erb
       @@ -0,0 +1,20 @@
       +{
       +  "sEcho": <%= echo_data_tables %>,
       +  "iTotalRecords": <%= @results_total_count.to_json %>,
       +  "iTotalDisplayRecords": <%= @results_total_display_count.to_json %>,
       +  "aaData": [
       +  <% @results.each_with_index do |result, index| -%>
       +    {
       +      "DT_RowId":   <%= dom_id(result).to_json.html_safe%>,
       +      "checkbox":   <%= call_checkbox_tag(result) %>,
       +      "number":    <%= call_number_html(result) %>,
       +      "caller_id":    <%= call_caller_id_html(result) %>,
       +      "provider":    <%= call_provider_html(result) %>,
       +      "answered":    <%= call_answered_html(result) %>,
       +      "busy":    <%= call_busy_html(result) %>,
       +      "audio_length":    <%= call_audio_length_html(result) %>,
       +      "ring_length":    <%= call_audio_length_html(result) %>
       +    }<%= ',' unless index == (@results.size - 1) %>
       +  <% end -%>
       +  ]
       +}
   DIR diff --git a/app/views/jobs/view_results.html.erb b/app/views/jobs/view_results.html.erb
       @@ -1,10 +1,9 @@
       -<% if @results.length > 0 %>
       -
       +<% include_view_javascript %>
        
        <h1 class='title'>Call Results for Scan #<%=@job.id%></h1>
        
        
       -<table width='100%' align='center' border=0 cellspacing=0 cellpadding=6>
       +<table class='table table-striped table-condensed'>
        <tr>
                <td align='center'>
                        <%= render :partial => 'shared/graphs/call_results' %>
       @@ -12,13 +11,35 @@
        </tr>
        </table>
        
       -<br/>
        
       -<%= will_paginate @results, :renderer => BootstrapPagination::Rails %>
        
       -<table class='table table-striped table-condensed' width='90%' id='results'>
       +
       +<%= form_tag do %>
       +<div class="control-bar">
       +<table width='100%' border=0 cellpadding=6>
       +<tbody><tr>
       +<td>
       +        <%= submit_checkboxes_to(raw('<i class="icon-refresh"></i> Scan'), new_dialer_job_path, { :class => "btn btn-mini any" }) %>
       +</td><td>
       +        <%= submit_checkboxes_to(raw('<i class="icon-cog"></i> Analyze'), analyze_job_path, { :class => "btn btn-mini any" }) %>
       +</td><td>
       +        <%= submit_checkboxes_to(raw('<i class="icon-trash"></i> Delete'), purge_calls_job_path, { :class => "btn btn-mini any", :confirm => 'Purge selected calls?' }) %>
       +</td><td>
       +        <a class="btn btn-mini any" href="#"><i class="icon-trash"></i> Purge</button>
       +</td>
       +</tr></tbody></table>
       +
       +</div>
       +
       +
       +<div class="analysis-control-bar"> </div>
       +
       +<span id="results-path" class="invisible"><%= view_results_path(@project, @job.id, :format => :json) %></span>
       +
       +<table id='results-table' class='table table-striped table-condensed sortable list' >
          <thead>
          <tr>
       +        <th><%= check_box_tag "all_results", false %></th>
            <th>Number</th>
            <th>Source CID</th>
            <th>Provider</th>
       @@ -28,25 +49,10 @@
                <th>Ring Time</th>
          </tr>
          </thead>
       -  <tbody>
       -<% @results.each do |call| %>
       -  <tr>
       -    <td><%= call.number %></td>
       -    <td><%= call.caller_id %></td>
       -    <td><%= call.provider.name %></td>
       -    <td><%= call.answered ? "Yes" : "No" %></td>
       -    <td><%= call.busy %></td>
       -    <td><%= call.audio_length %></td>
       -    <td><%= call.ring_length %></td>
       -  </tr>
       -<% end %>
       +  <tbody id="results-list">
          </tbody>
        </table>
        
       -<%= will_paginate @results, :renderer => BootstrapPagination::Rails %>
       -
       -<% else %>
       -
       -<h1 class='title'>No Results</h1>
       +</div>
        
        <% end %>
   DIR diff --git a/app/views/layouts/application.html.erb b/app/views/layouts/application.html.erb
       @@ -1,18 +1,21 @@
        <!DOCTYPE html>
        <html lang="en">
          <head>
       -    <meta charset="utf-8">
       -    <meta http-equiv="X-UA-Compatible" content="IE=Edge,chrome=1">
       -    <meta name="viewport" content="width=device-width, initial-scale=1.0">
       +        <meta charset="utf-8">
       +        <meta http-equiv="X-UA-Compatible" content="IE=Edge,chrome=1">
       +        <meta name="viewport" content="width=device-width, initial-scale=1.0">
            <title><%= content_for?(:title) ? yield(:title) : "WarVOX v#{WarVOX::VERSION}" %></title>
       -    <%= csrf_meta_tags %>
       +        <%= csrf_meta_tags %>
        
       -    <!--[if lt IE 9]>
       -        <%= javascript_include_tag "html5" %>
       -    <![endif]-->
       +        <!--[if lt IE 9]>
       +                <%= javascript_include_tag "html5" %>
       +        <![endif]-->
       +
       +<%= javascript_include_tag "application" %>
       +<%= yield :view_javascript %>
       +<%= stylesheet_link_tag "application", :media => "all" %>
       +<%= yield :view_stylesheets %>
        
       -    <%= javascript_include_tag "application" %>
       -    <%= stylesheet_link_tag "application", :media => "all" %>
        
            <%= favicon_link_tag '/assets/apple-touch-icon-144x144-precomposed.png', :rel => 'apple-touch-icon-precomposed', :type => 'image/png', :sizes => '144x144' %>
            <%= favicon_link_tag '/assets/apple-touch-icon-114x114-precomposed.png', :rel => 'apple-touch-icon-precomposed', :type => 'image/png', :sizes => '114x114' %>
       @@ -90,7 +93,6 @@
                                <% end %>
                        <% end %>
        
       -
              <div class="row">
                <div class="span12 content">
                  <div class="content">
   DIR diff --git a/config/environments/development.rb b/config/environments/development.rb
       @@ -25,15 +25,17 @@ Web::Application.configure do
          # Raise exception on mass assignment protection for Active Record models
          config.active_record.mass_assignment_sanitizer = :strict
        
       +  config.log_level = :debug
       +
          # Log the query plan for queries taking more than this (works
          # with SQLite, MySQL, and PostgreSQL)
       -  config.active_record.auto_explain_threshold_in_seconds = 0.5
       +  config.active_record.auto_explain_threshold_in_seconds = 0.75
        
          # Do not compress assets
          config.assets.compress = false
        
          # Expands the lines which load the assets
       -  config.assets.debug = true
       +  config.assets.debug = false
        
          config.serve_static_assets = true
        end
   DIR diff --git a/config/routes.rb b/config/routes.rb
       @@ -8,11 +8,12 @@ Web::Application.routes.draw do
          match  '/projects/:project_id/all'                    => 'projects#index', :as => :all_projects
        
        
       -  match  '/jobs/dial'          => 'jobs#new_dialer', :as => :new_dialer_job
       -  match  '/jobs/dialer'          => 'jobs#dialer', :as => :dialer_job
       -  match  '/jobs/analyze'       => 'jobs#new_analyzer', :as => :new_analyzer_job
       -  match  '/jobs/analyzer'       => 'jobs#analyzer', :as => :analyzer_job
       -  match  '/jobs/:id/stop'          => 'jobs#stop', :as => :stop_job
       +  match  '/jobs/dial'            => 'jobs#new_dialer',   :as => :new_dialer_job
       +  match  '/jobs/dialer'          => 'jobs#dialer',       :as => :dialer_job
       +  match  '/jobs/analyze'         => 'jobs#new_analyzer', :as => :new_analyzer_job
       +  match  '/jobs/analyzer'        => 'jobs#analyzer',     :as => :analyzer_job
       +  match  '/jobs/:id/stop'        => 'jobs#stop',         :as => :stop_job
       +  match  '/jobs/:id/calls/purge' => "jobs#purge_calls",  :as => :purge_calls_job
        
          match  '/projects/:project_id/scans'          => 'jobs#results', :as => :results
          match  '/projects/:project_id/scans/:id'      => 'jobs#view_results', :as => :view_results
   DIR diff --git a/db/migrate/20130106000000_add_indexes.rb b/db/migrate/20130106000000_add_indexes.rb
       @@ -0,0 +1,29 @@
       +class AddIndexes < ActiveRecord::Migration
       +        def up
       +                add_index :jobs, :project_id
       +                add_index :lines, :number
       +                add_index :lines, :project_id
       +                add_index :line_attributes, :line_id
       +                add_index :line_attributes, :project_id
       +                add_index :calls, :number
       +                add_index :calls, :job_id
       +                add_index :calls, :provider_id
       +                add_index :call_media, :call_id
       +                add_index :call_media, :project_id
       +                add_index :signature_fp, :signature_id
       +        end
       +
       +        def down
       +                remove_index :jobs, :project_id
       +                remove_index :lines, :number
       +                remove_index :lines, :project_id
       +                remove_index :line_attributes, :line_id
       +                remove_index :line_attributes, :project_id
       +                remove_index :calls, :number
       +                remove_index :calls, :job_id
       +                remove_index :calls, :provider_id
       +                remove_index :call_media, :call_id
       +                remove_index :call_media, :project_id
       +                remove_index :signature_fp, :signature_id
       +        end
       +end
   DIR diff --git a/db/schema.rb b/db/schema.rb
       @@ -11,7 +11,7 @@
        #
        # It's strongly recommended to check this file into your version control system.
        
       -ActiveRecord::Schema.define(:version => 20121228171549) do
       +ActiveRecord::Schema.define(:version => 20130106000000) do
        
          add_extension "intarray"
        
       @@ -27,6 +27,9 @@ ActiveRecord::Schema.define(:version => 20121228171549) do
            t.binary  "png_sig_freq"
          end
        
       +  add_index "call_media", ["call_id"], :name => "index_call_media_on_call_id"
       +  add_index "call_media", ["project_id"], :name => "index_call_media_on_project_id"
       +
          create_table "calls", :force => true do |t|
            t.datetime "created_at",            :null => false
            t.datetime "updated_at",            :null => false
       @@ -49,6 +52,10 @@ ActiveRecord::Schema.define(:version => 20121228171549) do
            t.integer  "fprint",                                :array => true
          end
        
       +  add_index "calls", ["job_id"], :name => "index_calls_on_job_id"
       +  add_index "calls", ["number"], :name => "index_calls_on_number"
       +  add_index "calls", ["provider_id"], :name => "index_calls_on_provider_id"
       +
          create_table "jobs", :force => true do |t|
            t.datetime "created_at",                  :null => false
            t.datetime "updated_at",                  :null => false
       @@ -65,6 +72,8 @@ ActiveRecord::Schema.define(:version => 20121228171549) do
            t.integer  "progress",     :default => 0
          end
        
       +  add_index "jobs", ["project_id"], :name => "index_jobs_on_project_id"
       +
          create_table "line_attributes", :force => true do |t|
            t.datetime "created_at",                       :null => false
            t.datetime "updated_at",                       :null => false
       @@ -75,6 +84,9 @@ ActiveRecord::Schema.define(:version => 20121228171549) do
            t.string   "content_type", :default => "text"
          end
        
       +  add_index "line_attributes", ["line_id"], :name => "index_line_attributes_on_line_id"
       +  add_index "line_attributes", ["project_id"], :name => "index_line_attributes_on_project_id"
       +
          create_table "lines", :force => true do |t|
            t.datetime "created_at", :null => false
            t.datetime "updated_at", :null => false
       @@ -84,6 +96,9 @@ ActiveRecord::Schema.define(:version => 20121228171549) do
            t.text     "notes"
          end
        
       +  add_index "lines", ["number"], :name => "index_lines_on_number"
       +  add_index "lines", ["project_id"], :name => "index_lines_on_project_id"
       +
          create_table "projects", :force => true do |t|
            t.datetime "created_at",  :null => false
            t.datetime "updated_at",  :null => false
       @@ -122,6 +137,8 @@ ActiveRecord::Schema.define(:version => 20121228171549) do
            t.integer "fprint",                       :array => true
          end
        
       +  add_index "signature_fp", ["signature_id"], :name => "index_signature_fp_on_signature_id"
       +
          create_table "signatures", :force => true do |t|
            t.datetime "created_at",  :null => false
            t.datetime "updated_at",  :null => false
   DIR diff --git a/lib/warvox/jobs/analysis.rb b/lib/warvox/jobs/analysis.rb
       @@ -65,11 +65,11 @@ class Analysis < Base
                        end
        
                        case @conf[:scope]
       -                when 'call'
       +                when 'calls':
                                if @conf[:force]
       -                                query = {:id => @conf[:target_id], :answered => true, :busy => false}
       +                                query = {:id => @conf[:target_ids], :answered => true, :busy => false}
                                else
       -                                query = {:id => @conf[:target_id], :answered => true, :busy => false, :analysis_started_at => nil}
       +                                query = {:id => @conf[:target_ids], :answered => true, :busy => false, :analysis_started_at => nil}
                                end
                        when 'job'
                                if @conf[:force]
       @@ -89,6 +89,9 @@ class Analysis < Base
                                else
                                        query = {:answered => true, :busy => false, :analysis_started_at => nil}
                                end
       +                else
       +                        # Bail if we don't have a valid scope
       +                        return        
                        end
        
                        # Build a list of call IDs, as find_each() gets confused if the DB changes mid-iteration