diff --git a/zuul/rpclistener.py b/zuul/rpclistener.py index e7d8dac2de..8c8c783ab8 100644 --- a/zuul/rpclistener.py +++ b/zuul/rpclistener.py @@ -57,6 +57,7 @@ class RPCListener(object): self.worker.registerFunction("zuul:get_running_jobs") self.worker.registerFunction("zuul:get_job_log_stream_address") self.worker.registerFunction("zuul:tenant_list") + self.worker.registerFunction("zuul:status_get") def getFunctions(self): functions = {} @@ -277,3 +278,8 @@ class RPCListener(object): output.append({'name': tenant_name, 'projects': len(tenant.untrusted_projects)}) job.sendWorkComplete(json.dumps(output)) + + def handle_status_get(self, job): + args = json.loads(job.arguments) + output = self.sched.formatStatusJSON(args.get("tenant")) + job.sendWorkComplete(output) diff --git a/zuul/web/__init__.py b/zuul/web/__init__.py index 53a0c887d9..61a1cee5b3 100755 --- a/zuul/web/__init__.py +++ b/zuul/web/__init__.py @@ -19,6 +19,7 @@ import asyncio import json import logging import os +import time import uvloop import aiohttp @@ -151,16 +152,36 @@ class LogStreamingHandler(object): class GearmanHandler(object): log = logging.getLogger("zuul.web.GearmanHandler") + # Tenant status cache expiry + cache_expiry = 1 + def __init__(self, rpc): self.rpc = rpc + self.cache = {} + self.cache_time = {} self.controllers = { 'tenant_list': self.tenant_list, + 'status_get': self.status_get, } def tenant_list(self, request): job = self.rpc.submitJob('zuul:tenant_list', {}) return web.json_response(json.loads(job.data[0])) + def status_get(self, request): + tenant = request.match_info["tenant"] + if tenant not in self.cache or \ + (time.time() - self.cache_time[tenant]) > self.cache_expiry: + job = self.rpc.submitJob('zuul:status_get', {'tenant': tenant}) + self.cache[tenant] = json.loads(job.data[0]) + self.cache_time[tenant] = time.time() + resp = web.json_response(self.cache[tenant]) + resp.headers['Access-Control-Allow-Origin'] = '*' + resp.headers["Cache-Control"] = "public, max-age=%d" % \ + self.cache_expiry + resp.last_modified = self.cache_time[tenant] + return resp + async def processRequest(self, request, action): try: resp = self.controllers[action](request) @@ -198,10 +219,15 @@ class ZuulWeb(object): return await self.gearman_handler.processRequest(request, 'tenant_list') + async def _handleStatusRequest(self, request): + return await self.gearman_handler.processRequest(request, 'status_get') + async def _handleStaticRequest(self, request): fp = None if request.path.endswith("tenants.html") or request.path.endswith("/"): fp = os.path.join(STATIC_DIR, "index.html") + elif request.path.endswith("status.html"): + fp = os.path.join(STATIC_DIR, "status.html") return web.FileResponse(fp) def run(self, loop=None): @@ -218,6 +244,8 @@ class ZuulWeb(object): routes = [ ('GET', '/console-stream', self._handleWebsocket), ('GET', '/tenants.json', self._handleTenantsRequest), + ('GET', '/{tenant}/status.json', self._handleStatusRequest), + ('GET', '/{tenant}/status.html', self._handleStaticRequest), ('GET', '/tenants.html', self._handleStaticRequest), ('GET', '/', self._handleStaticRequest), ] diff --git a/zuul/web/static/images/black.png b/zuul/web/static/images/black.png new file mode 100644 index 0000000000..252d874702 Binary files /dev/null and b/zuul/web/static/images/black.png differ diff --git a/zuul/web/static/images/green.png b/zuul/web/static/images/green.png new file mode 100644 index 0000000000..a8765f1412 Binary files /dev/null and b/zuul/web/static/images/green.png differ diff --git a/zuul/web/static/images/grey.png b/zuul/web/static/images/grey.png new file mode 100644 index 0000000000..eaee0d7ce3 Binary files /dev/null and b/zuul/web/static/images/grey.png differ diff --git a/zuul/web/static/images/line-angle.png b/zuul/web/static/images/line-angle.png new file mode 100644 index 0000000000..fa748682a2 Binary files /dev/null and b/zuul/web/static/images/line-angle.png differ diff --git a/zuul/web/static/images/line-t.png b/zuul/web/static/images/line-t.png new file mode 100644 index 0000000000..cfd3111a3d Binary files /dev/null and b/zuul/web/static/images/line-t.png differ diff --git a/zuul/web/static/images/line.png b/zuul/web/static/images/line.png new file mode 100644 index 0000000000..ace6bab3d5 Binary files /dev/null and b/zuul/web/static/images/line.png differ diff --git a/zuul/web/static/images/red.png b/zuul/web/static/images/red.png new file mode 100644 index 0000000000..e9956e8c5a Binary files /dev/null and b/zuul/web/static/images/red.png differ diff --git a/zuul/web/static/javascripts/jquery.zuul.js b/zuul/web/static/javascripts/jquery.zuul.js new file mode 100644 index 0000000000..7e6788b052 --- /dev/null +++ b/zuul/web/static/javascripts/jquery.zuul.js @@ -0,0 +1,945 @@ +// 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. + +(function ($) { + 'use strict'; + + function set_cookie(name, value) { + document.cookie = name + '=' + value + '; path=/'; + } + + function read_cookie(name, default_value) { + 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) { + return c.substring(nameEQ.length, c.length); + } + } + return default_value; + } + + $.zuul = function(options) { + options = $.extend({ + 'enabled': true, + 'graphite_url': '', + 'source': 'status.json', + '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); + + var collapsed_exceptions = []; + var current_filter = read_cookie('zuul_filter_string', ''); + var change_set_in_url = window.location.href.split('#')[1]; + if (change_set_in_url) { + current_filter = change_set_in_url; + } + var $jq; + + var xhr, + zuul_graph_update_count = 0, + zuul_sparkline_urls = {}; + + function get_sparkline_url(pipeline_name) { + if (options.graphite_url !== '') { + if (!(pipeline_name in zuul_sparkline_urls)) { + zuul_sparkline_urls[pipeline_name] = $.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." + pipeline_name + + ".current_changes, '6b8182')" + ] + }); + } + return zuul_sparkline_urls[pipeline_name]; + } + return false; + } + + var format = { + job: function(job) { + var $job_line = $(''); + + if (job.result !== null) { + $job_line.append( + $('') + .addClass('zuul-job-name') + .attr('href', job.report_url) + .text(job.name) + ); + } + else if (job.url !== null) { + $job_line.append( + $('') + .addClass('zuul-job-name') + .attr('href', job.url) + .text(job.name) + ); + } + else { + $job_line.append( + $('') + .addClass('zuul-job-name') + .text(job.name) + ); + } + + $job_line.append(this.job_status(job)); + + if (job.voting === false) { + $job_line.append( + $(' ') + .addClass('zuul-non-voting-desc') + .text(' (non-voting)') + ); + } + + $job_line.append($('
')); + return $job_line; + }, + + job_status: function(job) { + var 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) { + var $status = $(''); + $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(elapsed_time, remaining_time) { + var progress_percent = 100 * (elapsed_time / (elapsed_time + + remaining_time)); + var $bar_inner = $('
') + .addClass('progress-bar') + .attr('role', 'progressbar') + .attr('aria-valuenow', 'progressbar') + .attr('aria-valuemin', progress_percent) + .attr('aria-valuemin', '0') + .attr('aria-valuemax', '100') + .css('width', progress_percent + '%'); + + var $bar_outter = $('
') + .addClass('progress zuul-job-result') + .append($bar_inner); + + return $bar_outter; + }, + + enqueue_time: function(ms) { + // Special format case for enqueue time to add style + var hours = 60 * 60 * 1000; + var now = Date.now(); + var delta = now - ms; + var status = 'text-success'; + var text = this.time(delta, true); + if (delta > (4 * hours)) { + status = 'text-danger'; + } else if (delta > (2 * hours)) { + status = 'text-warning'; + } + return '' + text + ''; + }, + + time: function(ms, words) { + 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); + var 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; + }, + + change_total_progress_bar: function(change) { + var job_percent = Math.floor(100 / change.jobs.length); + var $bar_outter = $('
') + .addClass('progress zuul-change-total-result'); + + $.each(change.jobs, function (i, job) { + var result = job.result ? job.result.toLowerCase() : null; + if (result === null) { + result = job.url ? 'in progress' : 'queued'; + } + + if (result !== 'queued') { + var $bar_inner = $('
') + .addClass('progress-bar'); + + switch (result) { + case 'success': + $bar_inner.addClass('progress-bar-success'); + break; + case 'lost': + case 'failure': + $bar_inner.addClass('progress-bar-danger'); + break; + case 'unstable': + $bar_inner.addClass('progress-bar-warning'); + break; + case 'in progress': + case 'queued': + break; + } + $bar_inner.attr('title', job.name) + .css('width', job_percent + '%'); + $bar_outter.append($bar_inner); + } + }); + return $bar_outter; + }, + + change_header: function(change) { + var change_id = change.id || 'NA'; + + var $change_link = $(''); + if (change.url !== null) { + var github_id = change_id.match(/^([0-9]+),([0-9a-f]{40})$/); + if (github_id) { + $change_link.append( + $('').attr('href', change.url).append( + $('') + .attr('title', change_id) + .text('#' + github_id[1]) + ) + ); + } else if (/^[0-9a-f]{40}$/.test(change_id)) { + var change_id_short = change_id.slice(0, 7); + $change_link.append( + $('').attr('href', change.url).append( + $('') + .attr('title', change_id) + .text(change_id_short) + ) + ); + } + else { + $change_link.append( + $('').attr('href', change.url).text(change_id) + ); + } + } + else { + if (change_id.length === 40) { + change_id = change_id.substr(0, 7); + } + $change_link.text(change_id); + } + + var $change_progress_row_left = $('
') + .addClass('col-xs-4') + .append($change_link); + var $change_progress_row_right = $('
') + .addClass('col-xs-8') + .append(this.change_total_progress_bar(change)); + + var $change_progress_row = $('
') + .addClass('row') + .append($change_progress_row_left) + .append($change_progress_row_right); + + var $project_span = $('') + .addClass('change_project') + .text(change.project); + + var $left = $('
') + .addClass('col-xs-8') + .append($project_span, $change_progress_row); + + var remaining_time = this.time( + change.remaining_time, true); + var enqueue_time = this.enqueue_time( + change.enqueue_time); + var $remaining_time = $('').addClass('time') + .attr('title', 'Remaining Time').html(remaining_time); + var $enqueue_time = $('').addClass('time') + .attr('title', 'Elapsed Time').html(enqueue_time); + + var $right = $('
'); + if (change.live === true) { + $right.addClass('col-xs-4 text-right') + .append($remaining_time, $('
'), $enqueue_time); + } + + var $header = $('
') + .addClass('row') + .append($left, $right); + return $header; + }, + + change_list: function(jobs) { + var format = this; + var $list = $('
    ') + .addClass('list-group zuul-patchset-body'); + + $.each(jobs, function (i, job) { + var $item = $('
  • ') + .addClass('list-group-item') + .addClass('zuul-change-job') + .append(format.job(job)); + $list.append($item); + }); + + return $list; + }, + + change_panel: function (change) { + var $header = $('
    ') + .addClass('panel-heading zuul-patchset-header') + .append(this.change_header(change)); + + var panel_id = change.id ? change.id.replace(',', '_') + : change.project.replace('/', '_') + + '-' + change.enqueue_time; + var $panel = $('
    ') + .attr('id', panel_id) + .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) { + var icon_name = 'green.png'; + var icon_title = 'Succeeding'; + + if (change.active !== true) { + // Grey icon + icon_name = 'grey.png'; + icon_title = 'Waiting until closer to head of queue to' + + ' start jobs'; + } + else if (change.live !== true) { + // Grey icon + icon_name = 'grey.png'; + icon_title = 'Dependent change required for testing'; + } + else if (change.failing_reasons && + change.failing_reasons.length > 0) { + var reason = change.failing_reasons.join(', '); + icon_title = 'Failing because ' + reason; + if (reason.match(/merge conflict/)) { + // Black icon + icon_name = 'black.png'; + } + else { + // Red icon + icon_name = 'red.png'; + } + } + + var $icon = $('') + .attr('src', '../static/images/' + icon_name) + .attr('title', icon_title) + .css('margin-top', '-6px'); + + return $icon; + }, + + change_with_status_tree: function(change, change_queue) { + var $change_row = $(''); + + for (var i = 0; i < change_queue._tree_columns; i++) { + var $tree_cell = $('') + .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) { + $tree_cell.css('background-image', + 'url(\'../static/images/line.png\')') + .css('background-repeat', 'repeat-y'); + } + + if (i === change._tree_index) { + $tree_cell.append( + this.change_status_icon(change)); + } + if (change._tree_branches.indexOf(i) !== -1) { + var $image = $('') + .css('vertical-align', 'baseline'); + if (change._tree_branches.indexOf(i) === + change._tree_branches.length - 1) { + // Angle line + $image.attr('src', '../static/images/line-angle.png'); + } + else { + // T line + $image.attr('src', '../static/images/line-t.png'); + } + $tree_cell.append($image); + } + $change_row.append($tree_cell); + } + + var change_width = 360 - 16*change_queue._tree_columns; + var $change_column = $('') + .css('width', change_width + 'px') + .addClass('zuul-change-cell') + .append(this.change_panel(change)); + + $change_row.append($change_column); + + var $change_table = $('') + .addClass('zuul-change-box') + .css('-moz-box-sizing', 'content-box') + .css('box-sizing', 'content-box') + .append($change_row); + + return $change_table; + }, + + pipeline_sparkline: function(pipeline_name) { + if (options.graphite_url !== '') { + var $sparkline = $('') + .addClass('pull-right') + .attr('src', get_sparkline_url(pipeline_name)); + return $sparkline; + } + return false; + }, + + pipeline_header: function(pipeline, count) { + // Format the pipeline name, sparkline and description + var $header_div = $('
    ') + .addClass('zuul-pipeline-header'); + + var $heading = $('

    ') + .css('vertical-align', 'middle') + .text(pipeline.name) + .append( + $('') + .addClass('badge pull-right') + .css('vertical-align', 'middle') + .css('margin-top', '0.5em') + .text(count) + ) + .append(this.pipeline_sparkline(pipeline.name)); + + $header_div.append($heading); + + if (typeof pipeline.description === 'string') { + var descr = $('') + $.each( pipeline.description.split(/\r?\n\r?\n/), function(index, descr_part){ + descr.append($('

    ').text(descr_part)); + }); + $header_div.append( + $('

    ').append(descr) + ); + } + return $header_div; + }, + + pipeline: function (pipeline, count) { + var format = this; + var $html = $('

    ') + .addClass('zuul-pipeline col-md-4') + .append(this.pipeline_header(pipeline, count)); + + $.each(pipeline.change_queues, + function (queue_i, change_queue) { + $.each(change_queue.heads, function (head_i, changes) { + if (pipeline.change_queues.length > 1 && + head_i === 0) { + var name = change_queue.name; + var short_name = name; + if (short_name.length > 32) { + short_name = short_name.substr(0, 32) + '...'; + } + $html.append( + $('

    ') + .text('Queue: ') + .append( + $('') + .attr('title', name) + .text(short_name) + ) + ); + } + + $.each(changes, function (change_i, change) { + var $change_box = + format.change_with_status_tree( + change, change_queue); + $html.append($change_box); + format.display_patchset($change_box); + }); + }); + }); + 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 + var $panel = $(e.target).parents('.zuul-change'); + var $body = $panel.children('.zuul-patchset-body'); + $body.toggle(200); + var collapsed_index = collapsed_exceptions.indexOf( + $panel.attr('id')); + if (collapsed_index === -1 ) { + // Currently not an exception, add it to list + collapsed_exceptions.push($panel.attr('id')); + } + else { + // Currently an except, remove from exceptions + collapsed_exceptions.splice(collapsed_index, 1); + } + }, + + display_patchset: function($change_box, animate) { + // Determine if to show or hide the patchset and/or the results + // when loaded + + // See if we should hide the body/results + var $panel = $change_box.find('.zuul-change'); + var panel_change = $panel.attr('id'); + var $body = $panel.children('.zuul-patchset-body'); + var expand_by_default = $('#expand_by_default') + .prop('checked'); + + var collapsed_index = collapsed_exceptions + .indexOf(panel_change); + + if (expand_by_default && collapsed_index === -1 || + !expand_by_default && collapsed_index !== -1) { + // Expand by default, or is an exception + $body.show(animate); + } + else { + $body.hide(animate); + } + + // Check if we should hide the whole panel + var panel_project = $panel.find('.change_project').text() + .toLowerCase(); + + + var panel_pipeline = $change_box + .parents('.zuul-pipeline') + .find('.zuul-pipeline-header > h3') + .html() + .toLowerCase(); + + if (current_filter !== '') { + var show_panel = false; + var filter = current_filter.trim().split(/[\s,]+/); + $.each(filter, function(index, 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) { + show_panel = true; + } + } + }); + if (show_panel === true) { + $change_box.show(animate); + } + else { + $change_box.hide(animate); + } + } + else { + $change_box.show(animate); + } + }, + }; + + var 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 (zuul_graph_update_count > 11) { + zuul_graph_update_count = 0; + zuul.update_sparklines(); + } + }, + + /** @return {jQuery.Promise} */ + update: function () { + // Cancel the previous update if it hasn't completed yet. + if (xhr) { + xhr.abort(); + } + + this.emit('update-start'); + var app = this; + + var $msg = $(options.msg_id); + xhr = $.getJSON(options.source) + .done(function (data) { + 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) { + var last_reconfigured = + new Date(data.last_reconfigured); + $('#last-reconfigured-span').text( + last_reconfigured.toString()); + } + + var $pipelines = $(options.pipelines_id); + $pipelines.html(''); + $.each(data.pipelines, function (i, pipeline) { + var 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_management_events_num).text( + data.management_event_queue ? + data.management_event_queue.length : '0' + ); + $(options.queue_results_num).text( + data.result_event_queue ? + data.result_event_queue.length : '0' + ); + }) + .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(zuul_sparkline_urls, function(name, url) { + var newimg = new Image(); + var parts = url.split('#'); + newimg.src = parts[0] + '#' + new Date().getTime(); + $(newimg).load(function () { + zuul_sparkline_urls[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; + }, + + control_form: function() { + // Build the filter form filling anything from cookies + + var $control_form = $('
    ') + .attr('role', 'form') + .addClass('form-inline') + .submit(this.handle_filter_change); + + $control_form + .append(this.filter_form_group()) + .append(this.expand_form_group()); + + return $control_form; + }, + + filter_form_group: function() { + // Update the filter form with a clear button if required + + var $label = $('

    ') + .addClass('form-group has-feedback') + .append($label, $input, $clear_icon); + return $form_group; + }, + + expand_form_group: function() { + var expand_by_default = ( + read_cookie('zuul_expand_by_default', false) === 'true'); + + var $checkbox = $('') + .attr('type', 'checkbox') + .attr('id', 'expand_by_default') + .prop('checked', expand_by_default) + .change(this.handle_expand_by_default); + + var $label = $('