Use yarn and webpack to manage zuul-web javascript

yarn drives package and dependency management. webpack handles
bundling, minification and transpiling down to browser-acceptable
javascript but allows for more modern javascript like import statements.

There are some really neat things in the webpack dev server. CSS
changes, for instance, get applied immediately without a refresh. Other
things, like the jquery plugin do need a refresh, but it's handled just
on a file changing.

As a followup, we can also consider turning the majority of the status page
into a webpack library that other people can depend on as a mechanism
for direct use. Things like that haven't been touched because allowing
folks to poke at the existing known status page without too many changes
using the tools seems like a good way for people to learn/understand the
stack.

Move things so that the built content gets put
into zuul/web/static so that the built-in static serving from zuul-web
will/can serve the files.

Update MANIFEST.in so that if npm run build:dist is run before the
python setup.py sdist, the built html/javascript content will be
included in the source tarball.

Add a pbr hook so that if yarn is installed, javascript content will be
built before the tarball.

Add a zuul job with a success url that contains a source_url
pointing to the live v3 data.

This adds a framework for verifying that we can serve the web app
urls and their dependencies for all of the various ways we want to
support folks hosting zuul-web.

It includes a very simple reverse proxy server for approximating
what we do in openstack to "white label" the Zuul service -- that
is, hide the multitenancy aspect and present the single tenant
at the site root.

We can run similar tests without the proxy to ensure the default,
multi-tenant view works as well.

Add babel transpiling enabling use of ES6 features

ECMAScript6 has a bunch of nice things, like block scoped variables,
const, template strings and classes. Babel is a javascript transpiler
which webpack can use to allow us to write using modern javascript but
the resulting code to still work on older browsers.

Use the babel-plugin-angularjs-annotate so that angular's dependency
injection doesn't get borked by babel's transpiling things (which causes
variables to otherwise be renamed in a way that causes angular to not
find them)

While we're at it, replace our use of var with let (let is the new
block-scoped version of var) and toss in some use of const and template
strings for good measure.

Add StandardJS eslint config for linting

JavaScript Standard Style is a code style similar to pep8/flake8. It's
being added here not because of the pep8 part, but because the pyflakes
equivalent can catch real errors. This uses the babel-eslint parser
since we're using Babel to transpile already.

This auto-formats the existing code with:

  npm run format

Rather than using StandardJS directly through the 'standard' package,
use the standardjs eslint plugin so that we can ignore the camelCase
rule (and any other rule that might emerge in the future)

Many of under_score/camelCase were fixed in a previous version of the patch.
Since the prevailing zuul style is camelCase methods anyway, those fixes
were left. That warning has now been disabled.

Other things, such as == vs. === and ensuring template
strings are in backticks are fixed.

Ignore indentation errors for now - we'll fix them at the end of this
stack and then remove the exclusion.

Add a 'format' npm run target that will run the eslint command with
--fix for ease of fixing reported issues.

Add a 'lint' npm run target and a 'lint' environment that runs with
linting turned to errors. The next patch makes the lint environment more
broadly useful.

When we run lint, also run the BundleAnalyzerPlugin and set the
success-url to the report.

Add an angular controller for status and stream page

Wrap the status and stream page construction with an angular controller
so that all the javascripts can be bundled in a single file.

Building the files locally is wonderful and all, but what we really want
is to make a tarball that has the built code so that it can be deployed.

Put it in the root source dir so that it can be used with the zuul
fetch-javascript-tarball role.

Also, replace the custom npm job with the new build-javascript-content
job which naturally grabs the content we want.

Make a 'main.js' file that imports the other three so that we just have
a single bundle. Then, add a 'vendor' entry in the common webpack file
and use the CommonsChunkPlugin to extract dependencies into their own
bundle. A second CommonsChunkPlugin entry pulls out a little bit of
metadata that would otherwise cause the main and vendor chunks to change
even with no source change. Then add chunkhash into the filename. This
way the files themselves can be aggressively cached.

This all follows recommendations from https://webpack.js.org/guides/caching/
https://webpack.js.org/guides/code-splitting/ and
https://webpack.js.org/guides/output-management/

Change-Id: I2e1230783fe57f1bc3b7818460463df1e659936b
Co-Authored-By: Tristan Cacqueray <tdecacqu@redhat.com>
Co-Authored-By: James E. Blair <jeblair@redhat.com>
diff --git a/web/jquery.zuul.js b/web/jquery.zuul.js
new file mode 100644
index 0000000..737b018
--- /dev/null
+++ b/web/jquery.zuul.js
@@ -0,0 +1,929 @@
+/* global Image, jQuery */
+// jquery plugin for Zuul status page
+//
+// @licstart  The following is the entire license notice for the
+// JavaScript code in this page.
+//
+// Copyright 2012 OpenStack Foundation
+// Copyright 2013 Timo Tijhof
+// Copyright 2013 Wikimedia Foundation
+// Copyright 2014 Rackspace Australia
+//
+// Licensed under the Apache License, Version 2.0 (the "License"); you may
+// not use this file except in compliance with the License. You may obtain
+// a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+// WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+// License for the specific language governing permissions and limitations
+// under the License.
+//
+// @licend  The above is the entire license notice
+// for the JavaScript code in this page.
+
+import RedImage from './images/red.png'
+import GreyImage from './images/grey.png'
+import GreenImage from './images/green.png'
+import BlackImage from './images/black.png'
+import LineImage from './images/line.png'
+import LineAngleImage from './images/line-angle.png'
+import LineTImage from './images/line-t.png';
+
+(function ($) {
+  function setCookie (name, value) {
+    document.cookie = name + '=' + value + '; path=/'
+  }
+
+  function readCookie (name, defaultValue) {
+    let nameEQ = name + '='
+    let ca = document.cookie.split(';')
+    for (let i = 0; i < ca.length; i++) {
+      let c = ca[i]
+      while (c.charAt(0) === ' ') {
+        c = c.substring(1, c.length)
+      }
+      if (c.indexOf(nameEQ) === 0) {
+        return c.substring(nameEQ.length, c.length)
+      }
+    }
+    return defaultValue
+  }
+
+  $.zuul = function (options) {
+    options = $.extend({
+      'enabled': true,
+      'graphite_url': '',
+      'source': 'status',
+      'source_data': null,
+      'msg_id': '#zuul_msg',
+      'pipelines_id': '#zuul_pipelines',
+      'queue_events_num': '#zuul_queue_events_num',
+      'queue_management_events_num': '#zuul_queue_management_events_num',
+      'queue_results_num': '#zuul_queue_results_num'
+    }, options)
+
+    let collapsedExceptions = []
+    let currentFilter = readCookie('zuul_filter_string', '')
+    let changeSetInURL = window.location.href.split('#')[1]
+    if (changeSetInURL) {
+      currentFilter = changeSetInURL
+    }
+    let $jq
+
+    let xhr
+    let zuulGraphUpdateCount = 0
+    let zuulSparklineURLs = {}
+
+    function getSparklineURL (pipelineName) {
+      if (options.graphite_url !== '') {
+        if (!(pipelineName in zuulSparklineURLs)) {
+          zuulSparklineURLs[pipelineName] = $.fn.graphite
+                        .geturl({
+                          url: options.graphite_url,
+                          from: '-8hours',
+                          width: 100,
+                          height: 26,
+                          margin: 0,
+                          hideLegend: true,
+                          hideAxes: true,
+                          hideGrid: true,
+                          target: [
+                            'color(stats.gauges.zuul.pipeline.' + pipelineName +
+                                ".current_changes, '6b8182')"
+                          ]
+                        })
+        }
+        return zuulSparklineURLs[pipelineName]
+      }
+      return false
+    }
+
+    let format = {
+      job: function (job) {
+        let $jobLine = $('<span />')
+
+        if (job.result !== null) {
+          $jobLine.append(
+                        $('<a />')
+                            .addClass('zuul-job-name')
+                            .attr('href', job.report_url)
+                            .text(job.name)
+                    )
+        } else if (job.url !== null) {
+          $jobLine.append(
+                        $('<a />')
+                            .addClass('zuul-job-name')
+                            .attr('href', job.url)
+                            .text(job.name)
+                    )
+        } else {
+          $jobLine.append(
+                        $('<span />')
+                            .addClass('zuul-job-name')
+                            .text(job.name)
+                    )
+        }
+
+        $jobLine.append(this.job_status(job))
+
+        if (job.voting === false) {
+          $jobLine.append(
+                        $(' <small />')
+                            .addClass('zuul-non-voting-desc')
+                            .text(' (non-voting)')
+                    )
+        }
+
+        $jobLine.append($('<div style="clear: both"></div>'))
+        return $jobLine
+      },
+
+      job_status: function (job) {
+        let result = job.result ? job.result.toLowerCase() : null
+        if (result === null) {
+          result = job.url ? 'in progress' : 'queued'
+        }
+
+        if (result === 'in progress') {
+          return this.job_progress_bar(job.elapsed_time,
+                                                        job.remaining_time)
+        } else {
+          return this.status_label(result)
+        }
+      },
+
+      status_label: function (result) {
+        let $status = $('<span />')
+        $status.addClass('zuul-job-result label')
+
+        switch (result) {
+          case 'success':
+            $status.addClass('label-success')
+            break
+          case 'failure':
+            $status.addClass('label-danger')
+            break
+          case 'unstable':
+            $status.addClass('label-warning')
+            break
+          case 'skipped':
+            $status.addClass('label-info')
+            break
+          // 'in progress' 'queued' 'lost' 'aborted' ...
+          default:
+            $status.addClass('label-default')
+        }
+        $status.text(result)
+        return $status
+      },
+
+      job_progress_bar: function (elapsedTime, remainingTime) {
+        let progressPercent = 100 * (elapsedTime / (elapsedTime +
+                                                              remainingTime))
+        let $barInner = $('<div />')
+                    .addClass('progress-bar')
+                    .attr('role', 'progressbar')
+                    .attr('aria-valuenow', 'progressbar')
+                    .attr('aria-valuemin', progressPercent)
+                    .attr('aria-valuemin', '0')
+                    .attr('aria-valuemax', '100')
+                    .css('width', progressPercent + '%')
+
+        let $barOutter = $('<div />')
+                    .addClass('progress zuul-job-result')
+                    .append($barInner)
+
+        return $barOutter
+      },
+
+      enqueueTime: function (ms) {
+        // Special format case for enqueue time to add style
+        let hours = 60 * 60 * 1000
+        let now = Date.now()
+        let delta = now - ms
+        let status = 'text-success'
+        let text = this.time(delta, true)
+        if (delta > (4 * hours)) {
+          status = 'text-danger'
+        } else if (delta > (2 * hours)) {
+          status = 'text-warning'
+        }
+        return '<span class="' + status + '">' + text + '</span>'
+      },
+
+      time: function (ms, words) {
+        if (typeof (words) === 'undefined') {
+          words = false
+        }
+        let seconds = (+ms) / 1000
+        let minutes = Math.floor(seconds / 60)
+        let hours = Math.floor(minutes / 60)
+        seconds = Math.floor(seconds % 60)
+        minutes = Math.floor(minutes % 60)
+        let r = ''
+        if (words) {
+          if (hours) {
+            r += hours
+            r += ' hr '
+          }
+          r += minutes + ' min'
+        } else {
+          if (hours < 10) {
+            r += '0'
+          }
+          r += hours + ':'
+          if (minutes < 10) {
+            r += '0'
+          }
+          r += minutes + ':'
+          if (seconds < 10) {
+            r += '0'
+          }
+          r += seconds
+        }
+        return r
+      },
+
+      changeTotalProgressBar: function (change) {
+        let jobPercent = Math.floor(100 / change.jobs.length)
+        let $barOutter = $('<div />')
+                    .addClass('progress zuul-change-total-result')
+
+        $.each(change.jobs, function (i, job) {
+          let result = job.result ? job.result.toLowerCase() : null
+          if (result === null) {
+            result = job.url ? 'in progress' : 'queued'
+          }
+
+          if (result !== 'queued') {
+            let $barInner = $('<div />')
+                            .addClass('progress-bar')
+
+            switch (result) {
+              case 'success':
+                $barInner.addClass('progress-bar-success')
+                break
+              case 'lost':
+              case 'failure':
+                $barInner.addClass('progress-bar-danger')
+                break
+              case 'unstable':
+                $barInner.addClass('progress-bar-warning')
+                break
+              case 'in progress':
+              case 'queued':
+                break
+            }
+            $barInner.attr('title', job.name)
+                            .css('width', jobPercent + '%')
+            $barOutter.append($barInner)
+          }
+        })
+        return $barOutter
+      },
+
+      changeHeader: function (change) {
+        let changeId = change.id || 'NA'
+
+        let $changeLink = $('<small />')
+        if (change.url !== null) {
+          let githubId = changeId.match(/^([0-9]+),([0-9a-f]{40})$/)
+          if (githubId) {
+            $changeLink.append(
+                            $('<a />').attr('href', change.url).append(
+                                $('<abbr />')
+                                    .attr('title', changeId)
+                                    .text('#' + githubId[1])
+                            )
+                        )
+          } else if (/^[0-9a-f]{40}$/.test(changeId)) {
+            let changeIdShort = changeId.slice(0, 7)
+            $changeLink.append(
+                            $('<a />').attr('href', change.url).append(
+                                $('<abbr />')
+                                    .attr('title', changeId)
+                                    .text(changeIdShort)
+                            )
+                        )
+          } else {
+            $changeLink.append(
+                            $('<a />').attr('href', change.url).text(changeId)
+                        )
+          }
+        } else {
+          if (changeId.length === 40) {
+            changeId = changeId.substr(0, 7)
+          }
+          $changeLink.text(changeId)
+        }
+
+        let $changeProgressRowLeft = $('<div />')
+                    .addClass('col-xs-4')
+                    .append($changeLink)
+        let $changeProgressRowRight = $('<div />')
+                    .addClass('col-xs-8')
+                    .append(this.changeTotalProgressBar(change))
+
+        let $changeProgressRow = $('<div />')
+                    .addClass('row')
+                    .append($changeProgressRowLeft)
+                    .append($changeProgressRowRight)
+
+        let $projectSpan = $('<span />')
+                    .addClass('change_project')
+                    .text(change.project)
+
+        let $left = $('<div />')
+                    .addClass('col-xs-8')
+                    .append($projectSpan, $changeProgressRow)
+
+        let remainingTime = this.time(change.remaining_time, true)
+        let enqueueTime = this.enqueueTime(change.enqueue_time)
+        let $remainingTime = $('<small />').addClass('time')
+                    .attr('title', 'Remaining Time').html(remainingTime)
+        let $enqueueTime = $('<small />').addClass('time')
+                    .attr('title', 'Elapsed Time').html(enqueueTime)
+
+        let $right = $('<div />')
+        if (change.live === true) {
+          $right.addClass('col-xs-4 text-right')
+                        .append($remainingTime, $('<br />'), $enqueueTime)
+        }
+
+        let $header = $('<div />')
+                    .addClass('row')
+                    .append($left, $right)
+        return $header
+      },
+
+      change_list: function (jobs) {
+        let format = this
+        let $list = $('<ul />')
+                    .addClass('list-group zuul-patchset-body')
+
+        $.each(jobs, function (i, job) {
+          let $item = $('<li />')
+                        .addClass('list-group-item')
+                        .addClass('zuul-change-job')
+                        .append(format.job(job))
+          $list.append($item)
+        })
+
+        return $list
+      },
+
+      changePanel: function (change) {
+        let $header = $('<div />')
+                    .addClass('panel-heading zuul-patchset-header')
+                    .append(this.changeHeader(change))
+
+        let panelId = change.id ? change.id.replace(',', '_')
+                                         : change.project.replace('/', '_') +
+                                           '-' + change.enqueue_time
+        let $panel = $('<div />')
+                    .attr('id', panelId)
+                    .addClass('panel panel-default zuul-change')
+                    .append($header)
+                    .append(this.change_list(change.jobs))
+
+        $header.click(this.toggle_patchset)
+        return $panel
+      },
+
+      change_status_icon: function (change) {
+        let iconFile = GreenImage
+        let iconTitle = 'Succeeding'
+
+        if (change.active !== true) {
+          // Grey icon
+          iconFile = GreyImage
+          iconTitle = 'Waiting until closer to head of queue to' +
+                        ' start jobs'
+        } else if (change.live !== true) {
+          // Grey icon
+          iconFile = GreyImage
+          iconTitle = 'Dependent change required for testing'
+        } else if (change.failing_reasons &&
+                         change.failing_reasons.length > 0) {
+          let reason = change.failing_reasons.join(', ')
+          iconTitle = 'Failing because ' + reason
+          if (reason.match(/merge conflict/)) {
+            // Black icon
+            iconFile = BlackImage
+          } else {
+            // Red icon
+            iconFile = RedImage
+          }
+        }
+
+        let $icon = $('<img />')
+                    .attr('src', iconFile)
+                    .attr('title', iconTitle)
+                    .css('margin-top', '-6px')
+
+        return $icon
+      },
+
+      change_with_status_tree: function (change, changeQueue) {
+        let $changeRow = $('<tr />')
+
+        for (let i = 0; i < changeQueue._tree_columns; i++) {
+          let $treeCell = $('<td />')
+                        .css('height', '100%')
+                        .css('padding', '0 0 10px 0')
+                        .css('margin', '0')
+                        .css('width', '16px')
+                        .css('min-width', '16px')
+                        .css('overflow', 'hidden')
+                        .css('vertical-align', 'top')
+
+          if (i < change._tree.length && change._tree[i] !== null) {
+            $treeCell.css('background-image',
+                                       'url(' + LineImage + ')')
+                            .css('background-repeat', 'repeat-y')
+          }
+
+          if (i === change._tree_index) {
+            $treeCell.append(
+                            this.change_status_icon(change))
+          }
+          if (change._tree_branches.indexOf(i) !== -1) {
+            let $image = $('<img />')
+                            .css('vertical-align', 'baseline')
+            if (change._tree_branches.indexOf(i) ===
+                            change._tree_branches.length - 1) {
+              // Angle line
+              $image.attr('src', LineAngleImage)
+            } else {
+              // T line
+              $image.attr('src', LineTImage)
+            }
+            $treeCell.append($image)
+          }
+          $changeRow.append($treeCell)
+        }
+
+        let changeWidth = 360 - 16 * changeQueue._tree_columns
+        let $changeColumn = $('<td />')
+                    .css('width', changeWidth + 'px')
+                    .addClass('zuul-change-cell')
+                    .append(this.changePanel(change))
+
+        $changeRow.append($changeColumn)
+
+        let $changeTable = $('<table />')
+                    .addClass('zuul-change-box')
+                    .css('-moz-box-sizing', 'content-box')
+                    .css('box-sizing', 'content-box')
+                    .append($changeRow)
+
+        return $changeTable
+      },
+
+      pipeline_sparkline: function (pipelineName) {
+        if (options.graphite_url !== '') {
+          let $sparkline = $('<img />')
+                        .addClass('pull-right')
+                        .attr('src', getSparklineURL(pipelineName))
+          return $sparkline
+        }
+        return false
+      },
+
+      pipeline_header: function (pipeline, count) {
+        // Format the pipeline name, sparkline and description
+        let $headerDiv = $('<div />')
+                    .addClass('zuul-pipeline-header')
+
+        let $heading = $('<h3 />')
+                    .css('vertical-align', 'middle')
+                    .text(pipeline.name)
+                    .append(
+                        $('<span />')
+                            .addClass('badge pull-right')
+                            .css('vertical-align', 'middle')
+                            .css('margin-top', '0.5em')
+                            .text(count)
+                    )
+                    .append(this.pipeline_sparkline(pipeline.name))
+
+        $headerDiv.append($heading)
+
+        if (typeof pipeline.description === 'string') {
+          let descr = $('<small />')
+          $.each(pipeline.description.split(/\r?\n\r?\n/),
+                  function (index, descrPart) {
+                    descr.append($('<p />').text(descrPart))
+                  })
+          $headerDiv.append($('<p />').append(descr))
+        }
+        return $headerDiv
+      },
+
+      pipeline: function (pipeline, count) {
+        let format = this
+        let $html = $('<div />')
+                    .addClass('zuul-pipeline col-md-4')
+                    .append(this.pipeline_header(pipeline, count))
+
+        $.each(pipeline.change_queues, function (queueIndex, changeQueue) {
+          $.each(changeQueue.heads, function (headIndex, changes) {
+            if (pipeline.change_queues.length > 1 && headIndex === 0) {
+              let name = changeQueue.name
+              let shortName = name
+              if (shortName.length > 32) {
+                shortName = shortName.substr(0, 32) + '...'
+              }
+              $html.append($('<p />')
+                            .text('Queue: ')
+                            .append(
+                                $('<abbr />')
+                                .attr('title', name)
+                                .text(shortName)
+                                )
+                            )
+            }
+
+            $.each(changes, function (changeIndex, change) {
+              let $changeBox =
+                        format.change_with_status_tree(
+                                change, changeQueue)
+              $html.append($changeBox)
+              format.display_patchset($changeBox)
+            })
+          })
+        })
+        return $html
+      },
+
+      toggle_patchset: function (e) {
+        // Toggle showing/hiding the patchset when the header is clicked.
+        if (e.target.nodeName.toLowerCase() === 'a') {
+                    // Ignore clicks from gerrit patch set link
+          return
+        }
+
+        // Grab the patchset panel
+        let $panel = $(e.target).parents('.zuul-change')
+        let $body = $panel.children('.zuul-patchset-body')
+        $body.toggle(200)
+        let collapsedIndex = collapsedExceptions.indexOf(
+                    $panel.attr('id'))
+        if (collapsedIndex === -1) {
+          // Currently not an exception, add it to list
+          collapsedExceptions.push($panel.attr('id'))
+        } else {
+          // Currently an except, remove from exceptions
+          collapsedExceptions.splice(collapsedIndex, 1)
+        }
+      },
+
+      display_patchset: function ($changeBox, animate) {
+        // Determine if to show or hide the patchset and/or the results
+        // when loaded
+
+        // See if we should hide the body/results
+        let $panel = $changeBox.find('.zuul-change')
+        let panelChange = $panel.attr('id')
+        let $body = $panel.children('.zuul-patchset-body')
+        let expandByDefault = $('#expand_by_default')
+                    .prop('checked')
+
+        let collapsedIndex = collapsedExceptions
+                    .indexOf(panelChange)
+
+        if ((expandByDefault && collapsedIndex === -1) ||
+                    (!expandByDefault && collapsedIndex !== -1)) {
+          // Expand by default, or is an exception
+          $body.show(animate)
+        } else {
+          $body.hide(animate)
+        }
+
+        // Check if we should hide the whole panel
+        let panelProject = $panel.find('.change_project').text()
+                    .toLowerCase()
+
+        let panelPipeline = $changeBox
+                    .parents('.zuul-pipeline')
+                    .find('.zuul-pipeline-header > h3')
+                    .html()
+                    .toLowerCase()
+
+        if (currentFilter !== '') {
+          let showPanel = false
+          let filter = currentFilter.trim().split(/[\s,]+/)
+          $.each(filter, function (index, filterVal) {
+            if (filterVal !== '') {
+              filterVal = filterVal.toLowerCase()
+              if (panelProject.indexOf(filterVal) !== -1 ||
+                      panelPipeline.indexOf(filterVal) !== -1 ||
+                      panelChange.indexOf(filterVal) !== -1) {
+                showPanel = true
+              }
+            }
+          })
+          if (showPanel === true) {
+            $changeBox.show(animate)
+          } else {
+            $changeBox.hide(animate)
+          }
+        } else {
+          $changeBox.show(animate)
+        }
+      }
+    }
+
+    let app = {
+      schedule: function (app) {
+        app = app || this
+        if (!options.enabled) {
+          setTimeout(function () { app.schedule(app) }, 5000)
+          return
+        }
+        app.update().always(function () {
+          setTimeout(function () { app.schedule(app) }, 5000)
+        })
+
+        // Only update graphs every minute
+        if (zuulGraphUpdateCount > 11) {
+          zuulGraphUpdateCount = 0
+          $.zuul.update_sparklines()
+        }
+      },
+      injest: function (data, $msg) {
+        if ('message' in data) {
+          $msg.removeClass('alert-danger')
+                        .addClass('alert-info')
+                        .text(data.message)
+                        .show()
+        } else {
+          $msg.empty()
+                        .hide()
+        }
+
+        if ('zuul_version' in data) {
+          $('#zuul-version-span').text(data.zuul_version)
+        }
+        if ('last_reconfigured' in data) {
+          let lastReconfigured =
+                        new Date(data.last_reconfigured)
+          $('#last-reconfigured-span').text(
+                        lastReconfigured.toString())
+        }
+
+        let $pipelines = $(options.pipelines_id)
+        $pipelines.html('')
+        $.each(data.pipelines, function (i, pipeline) {
+          let count = app.create_tree(pipeline)
+          $pipelines.append(
+                        format.pipeline(pipeline, count))
+        })
+
+        $(options.queue_events_num).text(
+                    data.trigger_event_queue
+                        ? data.trigger_event_queue.length : '0'
+                )
+        $(options.queue_results_num).text(
+                    data.result_event_queue
+                        ? data.result_event_queue.length : '0'
+                )
+      },
+      /** @return {jQuery.Promise} */
+      update: function () {
+        // Cancel the previous update if it hasn't completed yet.
+        if (xhr) {
+          xhr.abort()
+        }
+
+        this.emit('update-start')
+        let app = this
+
+        let $msg = $(options.msg_id)
+        if (options.source_data !== null) {
+          app.injest(options.source_data, $msg)
+          return
+        }
+        xhr = $.getJSON(options.source)
+                    .done(function (data) {
+                      app.injest(data, $msg)
+                    })
+                    .fail(function (jqXHR, statusText, errMsg) {
+                      if (statusText === 'abort') {
+                        return
+                      }
+                      $msg.text(options.source + ': ' + errMsg)
+                            .addClass('alert-danger')
+                            .removeClass('zuul-msg-wrap-off')
+                            .show()
+                    })
+                    .always(function () {
+                      xhr = undefined
+                      app.emit('update-end')
+                    })
+
+        return xhr
+      },
+
+      update_sparklines: function () {
+        $.each(zuulSparklineURLs, function (name, url) {
+          let newimg = new Image()
+          let parts = url.split('#')
+          newimg.src = parts[0] + '#' + new Date().getTime()
+          $(newimg).load(function () {
+            zuulSparklineURLs[name] = newimg.src
+          })
+        })
+      },
+
+      emit: function () {
+        $jq.trigger.apply($jq, arguments)
+        return this
+      },
+      on: function () {
+        $jq.on.apply($jq, arguments)
+        return this
+      },
+      one: function () {
+        $jq.one.apply($jq, arguments)
+        return this
+      },
+
+      controlForm: function () {
+        // Build the filter form filling anything from cookies
+
+        let $controlForm = $('<form />')
+                    .attr('role', 'form')
+                    .addClass('form-inline')
+                    .submit(this.handleFilterChange)
+
+        $controlForm
+                    .append(this.filterFormGroup())
+                    .append(this.expandFormGroup())
+
+        return $controlForm
+      },
+
+      filterFormGroup: function () {
+        // Update the filter form with a clear button if required
+
+        let $label = $('<label />')
+                    .addClass('control-label')
+                    .attr('for', 'filter_string')
+                    .text('Filters')
+                    .css('padding-right', '0.5em')
+
+        let $input = $('<input />')
+                    .attr('type', 'text')
+                    .attr('id', 'filter_string')
+                    .addClass('form-control')
+                    .attr('title',
+                          'project(s), pipeline(s) or review(s) comma ' +
+                          'separated')
+                    .attr('value', currentFilter)
+
+        $input.change(this.handleFilterChange)
+
+        let $clearIcon = $('<span />')
+                    .addClass('form-control-feedback')
+                    .addClass('glyphicon glyphicon-remove-circle')
+                    .attr('id', 'filter_form_clear_box')
+                    .attr('title', 'clear filter')
+                    .css('cursor', 'pointer')
+
+        $clearIcon.click(function () {
+          $('#filter_string').val('').change()
+        })
+
+        if (currentFilter === '') {
+          $clearIcon.hide()
+        }
+
+        let $formGroup = $('<div />')
+                    .addClass('form-group has-feedback')
+                    .append($label, $input, $clearIcon)
+        return $formGroup
+      },
+
+      expandFormGroup: function () {
+        let expandByDefault = (
+                    readCookie('zuul_expand_by_default', false) === 'true')
+
+        let $checkbox = $('<input />')
+                    .attr('type', 'checkbox')
+                    .attr('id', 'expand_by_default')
+                    .prop('checked', expandByDefault)
+                    .change(this.handleExpandByDefault)
+
+        let $label = $('<label />')
+                    .css('padding-left', '1em')
+                    .html('Expand by default: ')
+                    .append($checkbox)
+
+        let $formGroup = $('<div />')
+                    .addClass('checkbox')
+                    .append($label)
+        return $formGroup
+      },
+
+      handleFilterChange: function () {
+        // Update the filter and save it to a cookie
+        currentFilter = $('#filter_string').val()
+        setCookie('zuul_filter_string', currentFilter)
+        if (currentFilter === '') {
+          $('#filter_form_clear_box').hide()
+        } else {
+          $('#filter_form_clear_box').show()
+        }
+
+        $('.zuul-change-box').each(function (index, obj) {
+          let $changeBox = $(obj)
+          format.display_patchset($changeBox, 200)
+        })
+        return false
+      },
+
+      handleExpandByDefault: function (e) {
+        // Handle toggling expand by default
+        setCookie('zuul_expand_by_default', e.target.checked)
+        collapsedExceptions = []
+        $('.zuul-change-box').each(function (index, obj) {
+          let $changeBox = $(obj)
+          format.display_patchset($changeBox, 200)
+        })
+      },
+
+      create_tree: function (pipeline) {
+        let count = 0
+        let pipelineMaxTreeColumns = 1
+        $.each(pipeline.change_queues,
+                function (changeQueueIndex, changeQueue) {
+                  let tree = []
+                  let maxTreeColumns = 1
+                  let changes = []
+                  let lastTreeLength = 0
+                  $.each(changeQueue.heads, function (headIndex, head) {
+                    $.each(head, function (changeIndex, change) {
+                      changes[change.id] = change
+                      change._tree_position = changeIndex
+                    })
+                  })
+                  $.each(changeQueue.heads, function (headIndex, head) {
+                    $.each(head, function (changeIndex, change) {
+                      if (change.live === true) {
+                        count += 1
+                      }
+                      let idx = tree.indexOf(change.id)
+                      if (idx > -1) {
+                        change._tree_index = idx
+                        // remove...
+                        tree[idx] = null
+                        while (tree[tree.length - 1] === null) {
+                          tree.pop()
+                        }
+                      } else {
+                        change._tree_index = 0
+                      }
+                      change._tree_branches = []
+                      change._tree = []
+                      if (typeof (change.items_behind) === 'undefined') {
+                        change.items_behind = []
+                      }
+                      change.items_behind.sort(function (a, b) {
+                        return (changes[b]._tree_position - changes[a]._tree_position)
+                      })
+                      $.each(change.items_behind, function (i, id) {
+                        tree.push(id)
+                        if (tree.length > lastTreeLength && lastTreeLength > 0) {
+                          change._tree_branches.push(tree.length - 1)
+                        }
+                      })
+                      if (tree.length > maxTreeColumns) {
+                        maxTreeColumns = tree.length
+                      }
+                      if (tree.length > pipelineMaxTreeColumns) {
+                        pipelineMaxTreeColumns = tree.length
+                      }
+                      change._tree = tree.slice(0) // make a copy
+                      lastTreeLength = tree.length
+                    })
+                  })
+                  changeQueue._tree_columns = maxTreeColumns
+                })
+        pipeline._tree_columns = pipelineMaxTreeColumns
+        return count
+      }
+    }
+
+    $jq = $(app)
+    return {
+      options: options,
+      format: format,
+      app: app,
+      jq: $jq
+    }
+  }
+}(jQuery))