zuul/web/jquery.zuul.js

930 lines
31 KiB
JavaScript

/* 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))