Merge "Document zuul generated log paths"
diff --git a/etc/status/public_html/app.js b/etc/status/public_html/app.js
index 2649090..aee693b 100644
--- a/etc/status/public_html/app.js
+++ b/etc/status/public_html/app.js
@@ -16,6 +16,7 @@
 // WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
 // License for the specific language governing permissions and limitations
 // under the License.
+'use strict';
 
 (function ($) {
     var $container, $msg, $indicator, $queueInfo, $queueEventsNum,
@@ -27,19 +28,21 @@
         source = demo ?
             './status-' + (demo[1] || 'basic') + '.json-sample' :
             'status.json';
-        source = source_url ? source_url[1] : source;
+    source = source_url ? source_url[1] : source;
 
     function set_cookie(name, value) {
-        document.cookie = name + "=" + value + "; path=/";
+        document.cookie = name + '=' + value + '; path=/';
     }
 
     function read_cookie(name, default_value) {
-        var nameEQ = name + "=";
+        var nameEQ = name + '=';
         var ca = document.cookie.split(';');
         for(var i=0;i < ca.length;i++) {
             var c = ca[i];
-            while (c.charAt(0) == ' ') c = c.substring(1, c.length);
-            if (c.indexOf(nameEQ) == 0) {
+            while (c.charAt(0) === ' ') {
+                c = c.substring(1, c.length);
+            }
+            if (c.indexOf(nameEQ) === 0) {
                 return c.substring(nameEQ.length, c.length);
             }
         }
@@ -82,11 +85,11 @@
                     }
 
                     if ('zuul_version' in data) {
-                        $('#zuul-version-span').text(data['zuul_version']);
+                        $('#zuul-version-span').text(data.zuul_version);
                     }
                     if ('last_reconfigured' in data) {
                         var last_reconfigured =
-                            new Date(data['last_reconfigured']);
+                            new Date(data.last_reconfigured);
                         $('#last-reconfigured-span').text(
                             last_reconfigured.toString());
                     }
@@ -107,7 +110,7 @@
                 })
                 .fail(function (err, jqXHR, errMsg) {
                     $msg.text(source + ': ' + errMsg).show();
-                    $msgWrap.removeClass('zuul-msg-wrap-off');
+                    $msg.removeClass('zuul-msg-wrap-off');
                 })
                 .complete(function () {
                     xhr = undefined;
@@ -119,6 +122,7 @@
 
         format: {
             job: function(job) {
+                var $job_line;
                 if (job.url !== null) {
                     $job_line = $('<a href="' + job.url + '" />');
                 }
@@ -142,7 +146,7 @@
                     result = job.url ? 'in progress' : 'queued';
                 }
 
-                if (result == 'in progress') {
+                if (result === 'in progress') {
                     return zuul.format.job_progress_bar(job.elapsed_time,
                                                         job.remaining_time);
                 }
@@ -152,7 +156,7 @@
             },
 
             status_label: function(result) {
-                $status = $('<span />');
+                var $status = $('<span />');
                 $status.addClass('zuul-job-result label');
 
                 switch (result) {
@@ -199,24 +203,26 @@
                 var hours = 60 * 60 * 1000;
                 var now = Date.now();
                 var delta = now - ms;
-                var status = "text-success";
+                var status = 'text-success';
                 var text = zuul.format.time(delta, true);
                 if (delta > (4 * hours)) {
-                    status = "text-danger";
+                    status = 'text-danger';
                 } else if (delta > (2 * hours)) {
-                    status = "text-warning";
+                    status = 'text-warning';
                 }
                 return '<span class="' + status + '">' + text + '</span>';
             },
 
             time: function(ms, words) {
-                if (typeof(words) === 'undefined') words = false;
+                if (typeof(words) === 'undefined') {
+                    words = false;
+                }
                 var seconds = (+ms)/1000;
                 var minutes = Math.floor(seconds/60);
                 var hours = Math.floor(minutes/60);
                 seconds = Math.floor(seconds % 60);
                 minutes = Math.floor(minutes % 60);
-                r = '';
+                var r = '';
                 if (words) {
                     if (hours) {
                         r += hours;
@@ -224,18 +230,24 @@
                     }
                     r += minutes + ' min';
                 } else {
-                    if (hours < 10) r += '0';
+                    if (hours < 10) {
+                        r += '0';
+                    }
                     r += hours + ':';
-                    if (minutes < 10) r += '0';
+                    if (minutes < 10) {
+                        r += '0';
+                    }
                     r += minutes + ':';
-                    if (seconds < 10) r += '0';
+                    if (seconds < 10) {
+                        r += '0';
+                    }
                     r += seconds;
                 }
                 return r;
             },
 
             change_total_progress_bar: function(change) {
-                job_percent = Math.floor(100 / change.jobs.length);
+                var job_percent = Math.floor(100 / change.jobs.length);
                 var $bar_outter = $('<div />')
                     .addClass('progress zuul-change-total-result');
 
@@ -245,7 +257,7 @@
                         result = job.url ? 'in progress' : 'queued';
                     }
 
-                    if (result != 'queued') {
+                    if (result !== 'queued') {
                         var $bar_inner = $('<div />')
                             .addClass('progress-bar');
 
@@ -273,53 +285,55 @@
             },
 
             change_header: function(change) {
-                change_id = change.id || 'NA';
+                var change_id = change.id || 'NA';
                 if (change_id.length === 40) {
                     change_id = change_id.substr(0, 7);
                 }
 
-                $change_link = $('<small />');
+                var $change_link = $('<small />');
                 if (change.url !== null) {
                     $change_link.append(
-                        $("<a />").attr("href", change.url).text(change.id)
+                        $('<a />').attr('href', change.url).text(change.id)
                     );
                 }
                 else {
                     $change_link.text(change_id);
                 }
 
-                $change_progress_row_left = $('<div />')
+                var $change_progress_row_left = $('<div />')
                     .addClass('col-xs-3')
                     .append($change_link);
-                $change_progress_row_right = $('<div />')
+                var $change_progress_row_right = $('<div />')
                     .addClass('col-xs-9')
-                    .append(zuul.format.change_total_progress_bar(change))
+                    .append(zuul.format.change_total_progress_bar(change));
 
-                $change_progress_row = $('<div />')
+                var $change_progress_row = $('<div />')
                     .addClass('row')
                     .append($change_progress_row_left)
-                    .append($change_progress_row_right)
+                    .append($change_progress_row_right);
 
-                $project_span = $('<span />')
+                var $project_span = $('<span />')
                     .addClass('change_project')
                     .text(change.project);
 
-                $left = $('<div />')
+                var $left = $('<div />')
                     .addClass('col-xs-8')
                     .append($project_span, $('<br />'), $change_progress_row);
 
-                remaining_time = zuul.format.time(change.remaining_time, true);
-                enqueue_time = zuul.format.enqueue_time(change.enqueue_time);
-                $remaining_time = $('<small />').addClass('time')
+                var remaining_time = zuul.format.time(
+                        change.remaining_time, true);
+                var enqueue_time = zuul.format.enqueue_time(
+                        change.enqueue_time);
+                var $remaining_time = $('<small />').addClass('time')
                     .attr('title', 'Remaining Time').html(remaining_time);
-                $enqueue_time = $('<small />').addClass('time')
+                var $enqueue_time = $('<small />').addClass('time')
                     .attr('title', 'Elapsed Time').html(enqueue_time);
 
-                $right = $('<div />')
+                var $right = $('<div />')
                     .addClass('col-xs-4 text-right')
                     .append($remaining_time, $('<br />'), $enqueue_time);
 
-                $header = $('<div />')
+                var $header = $('<div />')
                     .addClass('row')
                     .append($left, $right);
                 return $header;
@@ -347,9 +361,9 @@
 
                 var panel_id = change.id ? change.id.replace(',', '_')
                                          : change.project.replace('/', '_') +
-                                           '-' + change.enqueue_time
+                                           '-' + change.enqueue_time;
                 var $panel = $('<div />')
-                    .attr("id", panel_id)
+                    .attr('id', panel_id)
                     .addClass('panel panel-default zuul-change')
                     .append($header)
                     .append(zuul.format.change_list(change.jobs));
@@ -393,7 +407,7 @@
                         }
                         $.each(changes, function (changeNum, change) {
                             var $panel = zuul.format.change_panel(change);
-                            $html.append($panel)
+                            $html.append($panel);
                             zuul.display_patchset($panel);
                         });
                     });
@@ -401,7 +415,7 @@
                 return $html;
             },
 
-            filter_form_group: function(default_text) {
+            filter_form_group: function() {
                 // Update the filter form with a clear button if required
 
                 var $label = $('<label />')
@@ -417,7 +431,7 @@
                     .attr('title',
                           'project(s), pipeline(s) or review(s) comma ' +
                           'separated')
-                    .attr('value', default_text);
+                    .attr('value', current_filter);
 
                 $input.change(zuul.handle_filter_change);
 
@@ -432,7 +446,7 @@
                     $('#filter_string').val('').change();
                 });
 
-                if (default_text == '') {
+                if (current_filter === '') {
                     $clear_icon.hide();
                 }
 
@@ -443,16 +457,16 @@
             },
 
             expand_form_group: function() {
-                expand_by_default = (
+                var expand_by_default = (
                     read_cookie('zuul_expand_by_default', false) === 'true');
 
-                $checkbox = $('<input />')
+                var $checkbox = $('<input />')
                     .attr('type', 'checkbox')
                     .attr('id', 'expand_by_default')
                     .prop('checked', expand_by_default)
                     .change(zuul.handle_expand_by_default);
 
-                $label = $('<label />')
+                var $label = $('<label />')
                     .css('padding-left', '1em')
                     .html('Expand by default: ')
                     .append($checkbox);
@@ -466,13 +480,13 @@
             control_form: function() {
                 // Build the filter form filling anything from cookies
 
-                $control_form = $('<form />')
+                var $control_form = $('<form />')
                     .attr('role', 'form')
                     .addClass('form-inline')
                     .submit(zuul.handle_filter_change);
 
                 $control_form
-                    .append(zuul.format.filter_form_group(current_filter))
+                    .append(zuul.format.filter_form_group())
                     .append(zuul.format.expand_form_group());
 
                 return $control_form;
@@ -500,7 +514,7 @@
             $body.toggle(200);
             var collapsed_index = zuul.collapsed_exceptions.indexOf(
                 $panel.attr('id'));
-            if (collapsed_index == -1 ) {
+            if (collapsed_index === -1 ) {
                 // Currently not an exception, add it to list
                 zuul.collapsed_exceptions.push($panel.attr('id'));
             }
@@ -519,8 +533,8 @@
             var expand_by_default = $('#expand_by_default').prop('checked');
             var collapsed_index = zuul.collapsed_exceptions.indexOf(
                 $panel.attr('id'));
-            if (expand_by_default && collapsed_index == -1 ||
-                !expand_by_default && collapsed_index != -1) {
+            if (expand_by_default && collapsed_index === -1 ||
+                !expand_by_default && collapsed_index !== -1) {
                 // Expand by default, or is an exception
                 $body.show(animate);
             }
@@ -534,20 +548,20 @@
             var panel_pipeline = $panel.parents('.zuul-pipeline')
                 .children('h3').text().toLowerCase();
             var panel_change = $panel.attr('id');
-            if (current_filter != '') {
-                show_panel = false;
-                filter = current_filter.trim().split(/[\s,]+/);
+            if (current_filter !== '') {
+                var show_panel = false;
+                var filter = current_filter.trim().split(/[\s,]+/);
                 $.each(filter, function(index, f_val) {
-                    if (f_val != '') {
+                    if (f_val !== '') {
                         f_val = f_val.toLowerCase();
-                        if (panel_project.indexOf(f_val) != '-1' ||
-                            panel_pipeline.indexOf(f_val) != '-1' ||
-                            panel_change.indexOf(f_val) != '-1') {
+                        if (panel_project.indexOf(f_val) !== -1 ||
+                            panel_pipeline.indexOf(f_val) !== -1 ||
+                            panel_change.indexOf(f_val) !== -1) {
                             show_panel = true;
                         }
                     }
                 });
-                if (show_panel == true) {
+                if (show_panel === true) {
                     $panel.show(animate);
                 }
                 else {
@@ -559,11 +573,11 @@
             }
         },
 
-        handle_filter_change: function(e) {
+        handle_filter_change: function() {
             // Update the filter and save it to a cookie
             current_filter = $('#filter_string').val();
             set_cookie('zuul_filter_string', current_filter);
-            if (current_filter == '') {
+            if (current_filter === '') {
                 $('#filter_form_clear_box').hide();
             }
             else {
@@ -571,9 +585,9 @@
             }
 
             $('.zuul-change').each(function(index, obj) {
-                $panel = $(obj);
+                var $panel = $(obj);
                 zuul.display_patchset($panel, 200);
-            })
+            });
             return false;
         },
 
@@ -582,9 +596,9 @@
             set_cookie('zuul_expand_by_default', e.target.checked);
             zuul.collapsed_exceptions = [];
             $('.zuul-change').each(function(index, obj) {
-                $panel = $(obj);
+                var $panel = $(obj);
                 zuul.display_patchset($panel, 200);
-            })
+            });
         },
     };
 
@@ -616,20 +630,21 @@
 
     $(function ($) {
         $msg = $('<div />').addClass('alert').hide();
-        $indicator = $('<button class="btn pull-right zuul-spinner">updating '
-                       + '<span class="glyphicon glyphicon-refresh"></span>'
-                       + '</button>');
+        $indicator = $('<button class="btn pull-right zuul-spinner">' +
+                       'updating ' +
+                       '<span class="glyphicon glyphicon-refresh"></span>' +
+                       '</button>');
         $queueInfo = $('<p>Queue lengths: <span>0</span> events, ' +
                        '<span>0</span> results.</p>');
         $queueEventsNum = $queueInfo.find('span').eq(0);
         $queueResultsNum = $queueEventsNum.next();
 
-        $control_form = zuul.format.control_form();
+        var $control_form = zuul.format.control_form();
 
         $pipelines = $('<div class="row"></div>');
-        $zuulVersion = $('<p>Zuul version: <span id="zuul-version-span">' +
+        var $zuulVersion = $('<p>Zuul version: <span id="zuul-version-span">' +
                          '</span></p>');
-        $lastReconf = $('<p>Last reconfigured: ' +
+        var $lastReconf = $('<p>Last reconfigured: ' +
                         '<span id="last-reconfigured-span"></span></p>');
 
         $container = $('#zuul-container').append($msg, $indicator,
diff --git a/requirements.txt b/requirements.txt
index bb48290..1c5587b 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -16,3 +16,5 @@
 apscheduler>=2.1.1,<3.0
 python-swiftclient>=1.6
 python-keystoneclient>=0.4.2
+PrettyTable>=0.6,<0.8
+babel
diff --git a/tests/fixtures/layout-idle.yaml b/tests/fixtures/layout-idle.yaml
new file mode 100644
index 0000000..e4574fa
--- /dev/null
+++ b/tests/fixtures/layout-idle.yaml
@@ -0,0 +1,12 @@
+pipelines:
+  - name: periodic
+    manager: IndependentPipelineManager
+    trigger:
+      timer:
+        - time: '* * * * * */1'
+
+projects:
+  - name: org/project
+    periodic:
+      - project-test1
+      - project-test2
diff --git a/tests/test_scheduler.py b/tests/test_scheduler.py
index 7e1416f..7cfea1c 100755
--- a/tests/test_scheduler.py
+++ b/tests/test_scheduler.py
@@ -3201,6 +3201,33 @@
         self.assertIn('project-bitrot-stable-old', status_jobs)
         self.assertIn('project-bitrot-stable-older', status_jobs)
 
+    def test_idle(self):
+        "Test that frequent periodic jobs work"
+        self.worker.hold_jobs_in_build = True
+        self.config.set('zuul', 'layout_config',
+                        'tests/fixtures/layout-idle.yaml')
+        self.sched.reconfigure(self.config)
+        self.registerJobs()
+
+        # The pipeline triggers every second, so we should have seen
+        # several by now.
+        time.sleep(5)
+        self.waitUntilSettled()
+        self.assertEqual(len(self.builds), 2)
+        self.worker.release('.*')
+        self.waitUntilSettled()
+        self.assertEqual(len(self.builds), 0)
+        self.assertEqual(len(self.history), 2)
+
+        time.sleep(5)
+        self.waitUntilSettled()
+        self.assertEqual(len(self.builds), 2)
+        self.assertEqual(len(self.history), 2)
+        self.worker.release('.*')
+        self.waitUntilSettled()
+        self.assertEqual(len(self.builds), 0)
+        self.assertEqual(len(self.history), 4)
+
     def test_check_smtp_pool(self):
         self.config.set('zuul', 'layout_config',
                         'tests/fixtures/layout-smtp.yaml')
@@ -3870,3 +3897,62 @@
         self.worker.hold_jobs_in_build = False
         self.worker.release()
         self.waitUntilSettled()
+
+    def test_client_get_running_jobs(self):
+        "Test that the RPC client can get a list of running jobs"
+        self.worker.hold_jobs_in_build = True
+        A = self.fake_gerrit.addFakeChange('org/project', 'master', 'A')
+        A.addApproval('CRVW', 2)
+        self.fake_gerrit.addEvent(A.addApproval('APRV', 1))
+        self.waitUntilSettled()
+
+        client = zuul.rpcclient.RPCClient('127.0.0.1',
+                                          self.gearman_server.port)
+
+        # Wait for gearman server to send the initial workData back to zuul
+        start = time.time()
+        while True:
+            if time.time() - start > 10:
+                raise Exception("Timeout waiting for gearman server to report "
+                                + "back to the client")
+            build = self.launcher.builds.values()[0]
+            if build.worker.name == "My Worker":
+                break
+            else:
+                time.sleep(0)
+
+        running_items = client.get_running_jobs()
+
+        self.assertEqual(1, len(running_items))
+        running_item = running_items[0]
+        self.assertEqual([], running_item['failing_reasons'])
+        self.assertEqual([], running_item['items_behind'])
+        self.assertEqual('https://hostname/1', running_item['url'])
+        self.assertEqual(None, running_item['item_ahead'])
+        self.assertEqual('org/project', running_item['project'])
+        self.assertEqual(None, running_item['remaining_time'])
+        self.assertEqual(True, running_item['active'])
+        self.assertEqual('1,1', running_item['id'])
+
+        self.assertEqual(3, len(running_item['jobs']))
+        for job in running_item['jobs']:
+            if job['name'] == 'project-merge':
+                self.assertEqual('project-merge', job['name'])
+                self.assertEqual('gate', job['pipeline'])
+                self.assertEqual(False, job['retry'])
+                self.assertEqual(13, len(job['parameters']))
+                self.assertEqual('https://server/job/project-merge/0/',
+                                 job['url'])
+                self.assertEqual(7, len(job['worker']))
+                self.assertEqual(False, job['canceled'])
+                self.assertEqual(True, job['voting'])
+                self.assertEqual(None, job['result'])
+                self.assertEqual('gate', job['pipeline'])
+                break
+
+        self.worker.hold_jobs_in_build = False
+        self.worker.release()
+        self.waitUntilSettled()
+
+        running_items = client.get_running_jobs()
+        self.assertEqual(0, len(running_items))
diff --git a/zuul/cmd/client.py b/zuul/cmd/client.py
index a334bff..147fade 100644
--- a/zuul/cmd/client.py
+++ b/zuul/cmd/client.py
@@ -15,11 +15,15 @@
 # under the License.
 
 import argparse
+import babel.dates
 import ConfigParser
+import datetime
 import logging
 import logging.config
 import os
+import prettytable
 import sys
+import time
 
 import zuul.rpcclient
 
@@ -66,6 +70,23 @@
                                  required=True, nargs='+')
         cmd_promote.set_defaults(func=self.promote)
 
+        cmd_show = subparsers.add_parser('show',
+                                         help='valid show subcommands')
+        show_subparsers = cmd_show.add_subparsers(title='show')
+        show_running_jobs = show_subparsers.add_parser(
+            'running-jobs',
+            help='show the running jobs'
+        )
+        show_running_jobs.add_argument(
+            '--columns',
+            help="comma separated list of columns to display (or 'ALL')",
+            choices=self._show_running_jobs_columns().keys().append('ALL'),
+            default='name, worker.name, start_time, result'
+        )
+
+        # TODO: add filters such as queue, project, changeid etc
+        show_running_jobs.set_defaults(func=self.show_running_jobs)
+
         self.args = parser.parse_args()
 
     def _get_version(self):
@@ -119,6 +140,147 @@
                            change_ids=self.args.changes)
         return r
 
+    def show_running_jobs(self):
+        client = zuul.rpcclient.RPCClient(self.server, self.port)
+        running_items = client.get_running_jobs()
+
+        if len(running_items) == 0:
+            print "No jobs currently running"
+            return True
+
+        all_fields = self._show_running_jobs_columns()
+        if self.args.columns.upper() == 'ALL':
+            fields = all_fields.keys()
+        else:
+            fields = [f.strip().lower() for f in self.args.columns.split(',')
+                      if f.strip().lower() in all_fields.keys()]
+
+        table = prettytable.PrettyTable(
+            field_names=[all_fields[f]['title'] for f in fields])
+        for item in running_items:
+            for job in item['jobs']:
+                values = []
+                for f in fields:
+                    v = job
+                    for part in f.split('.'):
+                        if hasattr(v, 'get'):
+                            v = v.get(part, '')
+                    if ('transform' in all_fields[f]
+                        and callable(all_fields[f]['transform'])):
+                        v = all_fields[f]['transform'](v)
+                    if 'append' in all_fields[f]:
+                        v += all_fields[f]['append']
+                    values.append(v)
+                table.add_row(values)
+        print table
+        return True
+
+    def _epoch_to_relative_time(self, epoch):
+        if epoch:
+            delta = datetime.timedelta(seconds=(time.time() - int(epoch)))
+            return babel.dates.format_timedelta(delta, locale='en_US')
+        else:
+            return "Unknown"
+
+    def _boolean_to_yes_no(self, value):
+        return 'Yes' if value else 'No'
+
+    def _boolean_to_pass_fail(self, value):
+        return 'Pass' if value else 'Fail'
+
+    def _format_list(self, l):
+        return ', '.join(l) if isinstance(l, list) else ''
+
+    def _show_running_jobs_columns(self):
+        """A helper function to get the list of available columns for
+        `zuul show running-jobs`. Also describes how to convert particular
+        values (for example epoch to time string)"""
+
+        return {
+            'name': {
+                'title': 'Job Name',
+            },
+            'elapsed_time': {
+                'title': 'Elapsed Time',
+                'transform': self._epoch_to_relative_time
+            },
+            'remaining_time': {
+                'title': 'Remaining Time',
+                'transform': self._epoch_to_relative_time
+            },
+            'url': {
+                'title': 'URL'
+            },
+            'result': {
+                'title': 'Result'
+            },
+            'voting': {
+                'title': 'Voting',
+                'transform': self._boolean_to_yes_no
+            },
+            'uuid': {
+                'title': 'UUID'
+            },
+            'launch_time': {
+                'title': 'Launch Time',
+                'transform': self._epoch_to_relative_time,
+                'append': ' ago'
+            },
+            'start_time': {
+                'title': 'Start Time',
+                'transform': self._epoch_to_relative_time,
+                'append': ' ago'
+            },
+            'end_time': {
+                'title': 'End Time',
+                'transform': self._epoch_to_relative_time,
+                'append': ' ago'
+            },
+            'estimated_time': {
+                'title': 'Estimated Time',
+                'transform': self._epoch_to_relative_time,
+                'append': ' to go'
+            },
+            'pipeline': {
+                'title': 'Pipeline'
+            },
+            'canceled': {
+                'title': 'Canceled',
+                'transform': self._boolean_to_yes_no
+            },
+            'retry': {
+                'title': 'Retry'
+            },
+            'number': {
+                'title': 'Number'
+            },
+            'parameters': {
+                'title': 'Parameters'
+            },
+            'worker.name': {
+                'title': 'Worker'
+            },
+            'worker.hostname': {
+                'title': 'Worker Hostname'
+            },
+            'worker.ips': {
+                'title': 'Worker IPs',
+                'transform': self._format_list
+            },
+            'worker.fqdn': {
+                'title': 'Worker Domain'
+            },
+            'worker.progam': {
+                'title': 'Worker Program'
+            },
+            'worker.version': {
+                'title': 'Worker Version'
+            },
+            'worker.extra': {
+                'title': 'Worker Extra'
+            },
+        }
+
 
 def main():
     client = Client()
diff --git a/zuul/model.py b/zuul/model.py
index 9028577..2f4110f 100644
--- a/zuul/model.py
+++ b/zuul/model.py
@@ -269,7 +269,7 @@
                     if j_changes:
                         j_queue['heads'].append(j_changes)
                     j_changes = []
-                j_changes.append(self.formatItemJSON(e))
+                j_changes.append(e.formatJSON())
                 if (len(j_changes) > 1 and
                     (j_changes[-2]['remaining_time'] is not None) and
                     (j_changes[-1]['remaining_time'] is not None)):
@@ -280,101 +280,6 @@
                 j_queue['heads'].append(j_changes)
         return j_pipeline
 
-    def formatStatus(self, item, indent=0, html=False):
-        changeish = item.change
-        indent_str = ' ' * indent
-        ret = ''
-        if html and hasattr(changeish, 'url') and changeish.url is not None:
-            ret += '%sProject %s change <a href="%s">%s</a>\n' % (
-                indent_str,
-                changeish.project.name,
-                changeish.url,
-                changeish._id())
-        else:
-            ret += '%sProject %s change %s based on %s\n' % (
-                indent_str,
-                changeish.project.name,
-                changeish._id(),
-                item.item_ahead)
-        for job in self.getJobs(changeish):
-            build = item.current_build_set.getBuild(job.name)
-            if build:
-                result = build.result
-            else:
-                result = None
-            job_name = job.name
-            if not job.voting:
-                voting = ' (non-voting)'
-            else:
-                voting = ''
-            if html:
-                if build:
-                    url = build.url
-                else:
-                    url = None
-                if url is not None:
-                    job_name = '<a href="%s">%s</a>' % (url, job_name)
-            ret += '%s  %s: %s%s' % (indent_str, job_name, result, voting)
-            ret += '\n'
-        return ret
-
-    def formatItemJSON(self, item):
-        changeish = item.change
-        ret = {}
-        ret['active'] = item.active
-        if hasattr(changeish, 'url') and changeish.url is not None:
-            ret['url'] = changeish.url
-        else:
-            ret['url'] = None
-        ret['id'] = changeish._id()
-        if item.item_ahead:
-            ret['item_ahead'] = item.item_ahead.change._id()
-        else:
-            ret['item_ahead'] = None
-        ret['items_behind'] = [i.change._id() for i in item.items_behind]
-        ret['failing_reasons'] = item.current_build_set.failing_reasons
-        ret['zuul_ref'] = item.current_build_set.ref
-        ret['project'] = changeish.project.name
-        ret['enqueue_time'] = int(item.enqueue_time * 1000)
-        ret['jobs'] = []
-        max_remaining = 0
-        for job in self.getJobs(changeish):
-            now = time.time()
-            build = item.current_build_set.getBuild(job.name)
-            elapsed = None
-            remaining = None
-            result = None
-            url = None
-            if build:
-                result = build.result
-                url = build.url
-                if build.start_time:
-                    if build.end_time:
-                        elapsed = int((build.end_time -
-                                       build.start_time) * 1000)
-                        remaining = 0
-                    else:
-                        elapsed = int((now - build.start_time) * 1000)
-                        if build.estimated_time:
-                            remaining = max(
-                                int(build.estimated_time * 1000) - elapsed,
-                                0)
-            if remaining and remaining > max_remaining:
-                max_remaining = remaining
-            ret['jobs'].append(
-                dict(
-                    name=job.name,
-                    elapsed_time=elapsed,
-                    remaining_time=remaining,
-                    url=url,
-                    result=result,
-                    voting=job.voting))
-        if self.haveAllJobsStarted(item):
-            ret['remaining_time'] = max_remaining
-        else:
-            ret['remaining_time'] = None
-        return ret
-
 
 class ActionReporter(object):
     """An ActionReporter has a reporter and its configured paramaters"""
@@ -760,6 +665,124 @@
     def setReportedResult(self, result):
         self.current_build_set.result = result
 
+    def formatJSON(self):
+        changeish = self.change
+        ret = {}
+        ret['active'] = self.active
+        if hasattr(changeish, 'url') and changeish.url is not None:
+            ret['url'] = changeish.url
+        else:
+            ret['url'] = None
+        ret['id'] = changeish._id()
+        if self.item_ahead:
+            ret['item_ahead'] = self.item_ahead.change._id()
+        else:
+            ret['item_ahead'] = None
+        ret['items_behind'] = [i.change._id() for i in self.items_behind]
+        ret['failing_reasons'] = self.current_build_set.failing_reasons
+        ret['zuul_ref'] = self.current_build_set.ref
+        ret['project'] = changeish.project.name
+        ret['enqueue_time'] = int(self.enqueue_time * 1000)
+        ret['jobs'] = []
+        max_remaining = 0
+        for job in self.pipeline.getJobs(changeish):
+            now = time.time()
+            build = self.current_build_set.getBuild(job.name)
+            elapsed = None
+            remaining = None
+            result = None
+            url = None
+            worker = None
+            if build:
+                result = build.result
+                url = build.url
+                if build.start_time:
+                    if build.end_time:
+                        elapsed = int((build.end_time -
+                                       build.start_time) * 1000)
+                        remaining = 0
+                    else:
+                        elapsed = int((now - build.start_time) * 1000)
+                        if build.estimated_time:
+                            remaining = max(
+                                int(build.estimated_time * 1000) - elapsed,
+                                0)
+                worker = {
+                    'name': build.worker.name,
+                    'hostname': build.worker.hostname,
+                    'ips': build.worker.ips,
+                    'fqdn': build.worker.fqdn,
+                    'program': build.worker.program,
+                    'version': build.worker.version,
+                    'extra': build.worker.extra
+                }
+            if remaining and remaining > max_remaining:
+                max_remaining = remaining
+
+            ret['jobs'].append({
+                'name': job.name,
+                'elapsed_time': elapsed,
+                'remaining_time': remaining,
+                'url': url,
+                'result': result,
+                'voting': job.voting,
+                'uuid': build.uuid if build else None,
+                'launch_time': build.launch_time if build else None,
+                'start_time': build.start_time if build else None,
+                'end_time': build.end_time if build else None,
+                'estimated_time': build.estimated_time if build else None,
+                'pipeline': build.pipeline.name if build else None,
+                'canceled': build.canceled if build else None,
+                'retry': build.retry if build else None,
+                'number': build.number if build else None,
+                'parameters': build.parameters if build else None,
+                'worker': worker
+            })
+
+        if self.pipeline.haveAllJobsStarted(self):
+            ret['remaining_time'] = max_remaining
+        else:
+            ret['remaining_time'] = None
+        return ret
+
+    def formatStatus(self, indent=0, html=False):
+        changeish = self.change
+        indent_str = ' ' * indent
+        ret = ''
+        if html and hasattr(changeish, 'url') and changeish.url is not None:
+            ret += '%sProject %s change <a href="%s">%s</a>\n' % (
+                indent_str,
+                changeish.project.name,
+                changeish.url,
+                changeish._id())
+        else:
+            ret += '%sProject %s change %s based on %s\n' % (
+                indent_str,
+                changeish.project.name,
+                changeish._id(),
+                self.item_ahead)
+        for job in self.pipeline.getJobs(changeish):
+            build = self.current_build_set.getBuild(job.name)
+            if build:
+                result = build.result
+            else:
+                result = None
+            job_name = job.name
+            if not job.voting:
+                voting = ' (non-voting)'
+            else:
+                voting = ''
+            if html:
+                if build:
+                    url = build.url
+                else:
+                    url = None
+                if url is not None:
+                    job_name = '<a href="%s">%s</a>' % (url, job_name)
+            ret += '%s  %s: %s%s' % (indent_str, job_name, result, voting)
+            ret += '\n'
+        return ret
+
 
 class Changeish(object):
     """Something like a change; either a change or a ref"""
@@ -882,6 +905,8 @@
         return None
 
     def equals(self, other):
+        if (self.project == other.project):
+            return True
         return False
 
     def isUpdateOf(self, other):
diff --git a/zuul/rpcclient.py b/zuul/rpcclient.py
index 69390c0..7f572be 100644
--- a/zuul/rpcclient.py
+++ b/zuul/rpcclient.py
@@ -46,7 +46,7 @@
         if job.exception:
             raise RPCFailure(job.exception)
         self.log.debug("Job complete, success: %s" % (not job.failure))
-        return (not job.failure)
+        return job
 
     def enqueue(self, pipeline, project, trigger, change):
         data = {'pipeline': pipeline,
@@ -54,13 +54,21 @@
                 'trigger': trigger,
                 'change': change,
                 }
-        return self.submitJob('zuul:enqueue', data)
+        return not self.submitJob('zuul:enqueue', data).failure
 
     def promote(self, pipeline, change_ids):
         data = {'pipeline': pipeline,
                 'change_ids': change_ids,
                 }
-        return self.submitJob('zuul:promote', data)
+        return not self.submitJob('zuul:promote', data).failure
+
+    def get_running_jobs(self):
+        data = {}
+        job = self.submitJob('zuul:get_running_jobs', data)
+        if job.failure:
+            return False
+        else:
+            return json.loads(job.data[0])
 
     def shutdown(self):
         self.gearman.shutdown()
diff --git a/zuul/rpclistener.py b/zuul/rpclistener.py
index c1b9216..a5a5b5d 100644
--- a/zuul/rpclistener.py
+++ b/zuul/rpclistener.py
@@ -48,6 +48,7 @@
     def register(self):
         self.worker.registerFunction("zuul:enqueue")
         self.worker.registerFunction("zuul:promote")
+        self.worker.registerFunction("zuul:get_running_jobs")
 
     def stop(self):
         self.log.debug("Stopping")
@@ -123,3 +124,14 @@
         change_ids = args['change_ids']
         self.sched.promote(pipeline_name, change_ids)
         job.sendWorkComplete()
+
+    def handle_get_running_jobs(self, job):
+        # args = json.loads(job.arguments)
+        # TODO: use args to filter by pipeline etc
+        running_items = []
+        for pipeline_name, pipeline in self.sched.layout.pipelines.iteritems():
+            for queue in pipeline.queues:
+                for item in queue.queue:
+                    running_items.append(item.formatJSON())
+
+        job.sendWorkComplete(json.dumps(running_items))
diff --git a/zuul/scheduler.py b/zuul/scheduler.py
index 18f44db..f5d6629 100644
--- a/zuul/scheduler.py
+++ b/zuul/scheduler.py
@@ -1305,7 +1305,7 @@
                 changed = True
                 status = ''
                 for item in queue.queue:
-                    status += self.pipeline.formatStatus(item)
+                    status += item.formatStatus()
                 if status:
                     self.log.debug("Queue %s status is now:\n %s" %
                                    (queue.name, status))
@@ -1334,7 +1334,7 @@
 
         self.pipeline.setResult(item, build)
         self.log.debug("Item %s status is now:\n %s" %
-                       (item, self.pipeline.formatStatus(item)))
+                       (item, item.formatStatus()))
         self.updateBuildDescriptions(build.build_set)
         return True