frontend refactor

use a more modern style, reduce variable scopes where possible,
fix several minor bugs such as pagination and scales in chart
tooltips
pull/137/head
Oliver Giles 4 years ago
parent c6b60646f6
commit bd489bdbb0

@ -3,30 +3,27 @@
* https://laminar.ohwg.net * https://laminar.ohwg.net
*/ */
// A hash function added to String helps generating consistent
// colours from job names for use in charts
String.prototype.hashCode = function() { String.prototype.hashCode = function() {
for(var r=0, i=0; i<this.length; i++) for(var r=0, i=0; i<this.length; i++)
r=(r<<5)-r+this.charCodeAt(i),r&=r; r=(r<<5)-r+this.charCodeAt(i),r&=r;
return r; return r;
}; };
Vue.filter('iecFileSize', function(bytes) { // Filter to pretty-print the size of artifacts
var exp = Math.floor(Math.log(bytes) / Math.log(1024)); Vue.filter('iecFileSize', bytes => {
const exp = Math.floor(Math.log(bytes) / Math.log(1024));
return (bytes / Math.pow(1024, exp)).toFixed(1) + ' ' + return (bytes / Math.pow(1024, exp)).toFixed(1) + ' ' +
['B', 'KiB', 'MiB', 'GiB', 'TiB'][exp]; ['B', 'KiB', 'MiB', 'GiB', 'TiB'][exp];
}); });
const timeScale = function(max){ // Mixin handling retrieving dynamic updates from the backend
return max > 3600 Vue.mixin((() => {
? { scale:function(v){return Math.round(v/360)/10}, label:'Hours' } const setupEventSource = (to, query, next, comp) => {
: max > 60
? { scale:function(v){return Math.round(v/6)/10}, label:'Minutes' }
: { scale:function(v){return v;}, label:'Seconds' };
}
const ServerEventHandler = function() {
function setupEventSource(to, query, next, comp) {
const es = new EventSource(document.head.baseURI + to.path.substr(1) + query); const es = new EventSource(document.head.baseURI + to.path.substr(1) + query);
es.comp = comp; es.comp = comp; // When reconnecting, we already have a component. Usually this will be null.
es.path = to.path; // save for later in case we need to add query params es.to = to; // Save a ref, needed for adding query params for pagination.
es.onmessage = function(msg) { es.onmessage = function(msg) {
msg = JSON.parse(msg.data); msg = JSON.parse(msg.data);
// "status" is the first message the server always delivers. // "status" is the first message the server always delivers.
@ -39,7 +36,7 @@ const ServerEventHandler = function() {
// should not be handled here, but treated the same as any other // should not be handled here, but treated the same as any other
// message. An exception is if the connection has been lost - in // message. An exception is if the connection has been lost - in
// that case we should treat this as a "first-time" status message. // that case we should treat this as a "first-time" status message.
// this.comp.es is used as a proxy for this. // !this.comp.es is used to test this condition.
if (msg.type === 'status' && (!this.comp || !this.comp.es)) { if (msg.type === 'status' && (!this.comp || !this.comp.es)) {
next(comp => { next(comp => {
// Set up bidirectional reference // Set up bidirectional reference
@ -72,6 +69,7 @@ const ServerEventHandler = function() {
es.onerror = function(e) { es.onerror = function(e) {
this.comp.$root.connected = false; this.comp.$root.connected = false;
setTimeout(() => { setTimeout(() => {
// Recrate the EventSource, passing in the existing component
this.comp.es = setupEventSource(to, query, null, this.comp); this.comp.es = setupEventSource(to, query, null, this.comp);
}, this.comp.esReconnectInterval); }, this.comp.esReconnectInterval);
if(this.comp.esReconnectInterval < 7500) if(this.comp.esReconnectInterval < 7500)
@ -95,16 +93,52 @@ const ServerEventHandler = function() {
methods: { methods: {
query(q) { query(q) {
this.es.close(); this.es.close();
setupEventSource(this.es.path, '?' + Object.entries(q).map(([k,v])=>`${k}=${v}`).join('&'), (fn) => { fn(this); }); setupEventSource(this.es.to, '?' + Object.entries(q).map(([k,v])=>`${k}=${v}`).join('&'), fn => fn(this));
} }
} }
}; };
}(); })());
// Mixin for periodically updating a progress bar
Vue.mixin({
data: () => ({ jobsRunning: [] }),
methods: {
updateProgress(o) {
if (o.etc) {
const p = (Math.floor(Date.now()/1000) + this.$root.clockSkew - o.started) / (o.etc - o.started);
if (p > 1.2)
o.overtime = true;
o.progress = (p >= 1) ? 99 : 100 * p;
}
}
},
beforeDestroy: () => {
clearInterval(this.updateTimer);
},
watch: {
jobsRunning(val) {
// this function handles several cases:
// - the route has changed to a different run of the same job
// - the current job has ended
// - the current job has started (practically hard to reach)
clearInterval(this.updateTimer);
if (val.length) {
// TODO: first, a non-animated progress update
this.updateTimer = setInterval(() => {
this.jobsRunning.forEach(this.updateProgress);
this.$forceUpdate();
}, 1000);
}
}
}
});
const Utils = { // Utility methods
Vue.mixin({
methods: { methods: {
runIcon(result) { // Get an svg icon given a run result
return (result == 'success') ? /* checkmark */ runIcon: result =>
(result == 'success') ? /* checkmark */
`<svg class="status success" viewBox="0 0 100 100"> `<svg class="status success" viewBox="0 0 100 100">
<path d="m 23,46 c -6,0 -17,3 -17,11 0,8 9,30 12,32 3,2 14,5 20,-2 6,-6 24,-36 <path d="m 23,46 c -6,0 -17,3 -17,11 0,8 9,30 12,32 3,2 14,5 20,-2 6,-6 24,-36
56,-71 5,-3 -9,-8 -23,-2 -13,6 -33,42 -41,47 -6,-3 -5,-12 -8,-15 z" /> 56,-71 5,-3 -9,-8 -23,-2 -13,6 -33,42 -41,47 -6,-3 -5,-12 -8,-15 z" />
@ -125,20 +159,20 @@ const Utils = {
<circle cx="50" cy="50" r="40" stroke-width="15" fill="none" stroke-dasharray="175"> <circle cx="50" cy="50" r="40" stroke-width="15" fill="none" stroke-dasharray="175">
<animateTransform attributeName="transform" type="rotate" repeatCount="indefinite" dur="2s" values="0 50 50;360 50 50"></animateTransform> <animateTransform attributeName="transform" type="rotate" repeatCount="indefinite" dur="2s" values="0 50 50;360 50 50"></animateTransform>
</circle> </circle>
</svg>` </svg>`,
; // Pretty-print a unix date
}, formatDate: unix => {
formatDate: function(unix) {
// TODO: reimplement when toLocaleDateString() accepts formatting options on most browsers // TODO: reimplement when toLocaleDateString() accepts formatting options on most browsers
var d = new Date(1000 * unix); const d = new Date(1000 * unix);
var m = d.getMinutes(); let m = d.getMinutes();
if (m < 10) m = '0' + m; if (m < 10)
return d.getHours() + ':' + m + ' on ' + ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'][d.getDay()] + ' ' + m = '0' + m;
d.getDate() + '. ' + ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', return d.getHours() + ':' + m + ' on ' +
'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec' ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'][d.getDay()] + ' ' + d.getDate() + '. ' +
][d.getMonth()] + ' ' + ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'][d.getMonth()] + ' ' +
d.getFullYear(); d.getFullYear();
}, },
// Pretty-print a duration
formatDuration: function(start, end) { formatDuration: function(start, end) {
if(!end) if(!end)
end = Math.floor(Date.now()/1000) + this.$root.clockSkew; end = Math.floor(Date.now()/1000) + this.$root.clockSkew;
@ -150,84 +184,24 @@ const Utils = {
return (end-start) + ' seconds'; return (end-start) + ' seconds';
} }
} }
}; });
const ProgressUpdater = {
data() { return { jobsRunning: [] }; },
methods: {
updateProgress(o) {
if (o.etc) {
var p = (Math.floor(Date.now()/1000) + this.$root.clockSkew - o.started) / (o.etc - o.started);
if (p > 1.2) {
o.overtime = true;
}
if (p >= 1) {
o.progress = 99;
} else {
o.progress = 100 * p;
}
}
}
},
beforeDestroy() {
clearInterval(this.updateTimer);
},
watch: {
jobsRunning(val) {
// this function handles several cases:
// - the route has changed to a different run of the same job
// - the current job has ended
// - the current job has started (practically hard to reach)
clearInterval(this.updateTimer);
if (val.length) {
// TODO: first, a non-animated progress update
this.updateTimer = setInterval(() => {
this.jobsRunning.forEach(this.updateProgress);
this.$forceUpdate();
}, 1000);
}
}
}
};
const Home = function() {
var state = {
jobsQueued: [],
jobsRecent: [],
resultChanged: [],
lowPassRates: [],
};
var chtUtilization, chtBuildsPerDay, chtBuildsPerJob, chtTimePerJob;
var updateUtilization = function(busy) {
chtUtilization.data.datasets[0].data[0] += busy ? 1 : -1;
chtUtilization.data.datasets[0].data[1] -= busy ? 1 : -1;
chtUtilization.update();
}
// Chart factory
const Charts = (() => {
// TODO usage is broken!
const timeScale = max => max > 3600
? { factor: 1/3600, ticks: v => v.toFixed(1), label:'Hours' }
: max > 60
? { factor: 1/60, ticks: v => v.toFixed(1), label:'Minutes' }
: { factor: 1, ticks: v => v, label:'Seconds' };
return { return {
template: '#home', createExecutorUtilizationChart: (id, nBusy, nTotal) => {
mixins: [ServerEventHandler, Utils, ProgressUpdater], const c = new Chart(document.getElementById(id), {
data: function() {
return state;
},
methods: {
status: function(msg) {
state.jobsQueued = msg.queued;
state.jobsRunning = msg.running;
state.jobsRecent = msg.recent;
state.resultChanged = msg.resultChanged;
state.lowPassRates = msg.lowPassRates;
this.$forceUpdate();
// setup charts
chtUtilization = new Chart(document.getElementById("chartUtil"), {
type: 'pie', type: 'pie',
data: { data: {
labels: [ "Busy", "Idle" ], labels: [ "Busy", "Idle" ],
datasets: [{ datasets: [{
data: [ msg.executorsBusy, msg.executorsTotal - msg.executorsBusy ], data: [ nBusy, nTotal - nBusy ],
backgroundColor: [ "#afa674", "#7483af" ] backgroundColor: [ "#afa674", "#7483af" ]
}] }]
}, },
@ -235,8 +209,16 @@ const Home = function() {
hover: { mode: null } hover: { mode: null }
} }
}); });
var buildsPerDayDates = function(){ c.executorBusyChanged = busy => {
res = []; c.data.datasets[0].data[0] += busy ? 1 : -1;
c.data.datasets[0].data[1] -= busy ? 1 : -1;
c.update();
}
return c;
},
createRunsPerDayChart: (id, data) => {
const dayNames = (() => {
const res = [];
var now = new Date(); var now = new Date();
for (var i = 6; i >= 0; --i) { for (var i = 6; i >= 0; --i) {
var then = new Date(now.getTime() - i * 86400000); var then = new Date(now.getTime() - i * 86400000);
@ -246,91 +228,104 @@ const Home = function() {
); );
} }
return res; return res;
}(); })();
chtBuildsPerDay = new Chart(document.getElementById("chartBpd"), { const c = new Chart(document.getElementById(id), {
type: 'line', type: 'line',
data: { data: {
labels: buildsPerDayDates.map((e)=>{ return e.short; }), labels: dayNames.map(e => e.short),
datasets: [{ datasets: [{
label: 'Failed Builds', label: 'Failed Builds',
backgroundColor: "#883d3d", backgroundColor: "#883d3d",
data: msg.buildsPerDay.map((e)=>{ return e.failed || 0; }) data: data.map(e => e.failed || 0)
},{ },{
label: 'Successful Builds', label: 'Successful Builds',
backgroundColor: "#74af77", backgroundColor: "#74af77",
data: msg.buildsPerDay.map((e)=>{ return e.success || 0; }) data: data.map(e => e.success || 0)
}] }]
}, },
options:{ options:{
title: { display: true, text: 'Builds per day' }, title: { display: true, text: 'Builds per day' },
tooltips:{callbacks:{title: function(tip, data) { tooltips:{callbacks:{title: (tip, data) => dayNames[tip[0].index].long}},
return buildsPerDayDates[tip[0].index].long;
}}},
scales:{yAxes:[{ scales:{yAxes:[{
ticks:{userCallback: (label, index, labels)=>{ ticks:{userCallback: (label, index, labels) => Number.isInteger(label) ? label: null},
if(Number.isInteger(label))
return label;
}},
stacked: true stacked: true
}]} }]}
} }
}); });
chtBuildsPerJob = new Chart(document.getElementById("chartBpj"), { c.jobCompleted = success => {
c.data.datasets[success ? 0 : 1].data[6]++;
c.update();
}
return c;
},
createRunsPerJobChart: (id, data) => {
const c = new Chart(document.getElementById("chartBpj"), {
type: 'horizontalBar', type: 'horizontalBar',
data: { data: {
labels: Object.keys(msg.buildsPerJob), labels: Object.keys(data),
datasets: [{ datasets: [{
label: 'Runs in last 24 hours', label: 'Runs in last 24 hours',
backgroundColor: "#7483af", backgroundColor: "#7483af",
data: Object.keys(msg.buildsPerJob).map((e)=>{ return msg.buildsPerJob[e]; }) data: Object.keys(data).map(e => data[e])
}] }]
}, },
options:{ options:{
title: { display: true, text: 'Builds per job' }, title: { display: true, text: 'Builds per job' },
hover: { mode: null }, hover: { mode: null },
scales:{xAxes:[{ticks:{userCallback: (label, index, labels)=>{ scales:{xAxes:[{ticks:{userCallback: (label, index, labels)=> Number.isInteger(label) ? label: null}}]}
if(Number.isInteger(label))
return label;
}}}]}
} }
}); });
var tpjScale = timeScale(Math.max(...Object.values(msg.timePerJob))); c.jobCompleted = name => {
chtTimePerJob = new Chart(document.getElementById("chartTpj"), { for (var j = 0; j < c.data.datasets[0].data.length; ++j) {
if (c.data.labels[j] == name) {
c.data.datasets[0].data[j]++;
c.update();
break;
}
}
}
return c;
},
createTimePerJobChart: (id, data) => {
const scale = timeScale(Math.max(...Object.values(data)));
return new Chart(document.getElementById(id), {
type: 'horizontalBar', type: 'horizontalBar',
data: { data: {
labels: Object.keys(msg.timePerJob), labels: Object.keys(data),
datasets: [{ datasets: [{
label: 'Mean run time this week', label: 'Mean run time this week',
backgroundColor: "#7483af", backgroundColor: "#7483af",
data: Object.keys(msg.timePerJob).map((e)=>{ return msg.timePerJob[e]; }) data: Object.keys(data).map(e => data[e] * scale.factor)
}] }]
}, },
options:{ options:{
title: { display: true, text: 'Mean run time this week' }, title: { display: true, text: 'Mean run time this week' },
hover: { mode: null }, hover: { mode: null },
scales:{xAxes:[{ scales:{xAxes:[{
ticks:{userCallback: tpjScale.scale}, ticks:{userCallback: scale.ticks},
scaleLabel: { scaleLabel: {
display: true, display: true,
labelString: tpjScale.label labelString: scale.label
} }
}]}, }]},
tooltips:{callbacks:{label:(tip, data)=>{ tooltips:{callbacks:{
return data.datasets[tip.datasetIndex].label + ': ' + tip.xLabel + ' ' + tpjScale.label.toLowerCase(); label: (tip, data) => data.datasets[tip.datasetIndex].label + ': ' + tip.xLabel + ' ' + scale.label.toLowerCase()
}}} }}
} }
}); });
const btcScale = timeScale(Math.max(...msg.buildTimeChanges.map(e=>Math.max(...e.durations)))); },
var chtBuildTimeChanges = new Chart(document.getElementById("chartBuildTimeChanges"), { createRunTimeChangesChart: (id, data) => {
const scale = timeScale(Math.max(...data.map(e => Math.max(...e.durations))));
return new Chart(document.getElementById(id), {
type: 'line', type: 'line',
data: { data: {
labels: [...Array(10).keys()], labels: [...Array(10).keys()],
datasets: msg.buildTimeChanges.map((e)=>{return { datasets: data.map(e => ({
label: e.name, label: e.name,
data: e.durations, data: e.durations.map(x => x * scale.factor),
borderColor: 'hsl('+(e.name.hashCode() % 360)+', 27%, 57%)', borderColor: 'hsl('+(e.name.hashCode() % 360)+', 27%, 57%)',
backgroundColor: 'transparent' backgroundColor: 'transparent'
}}) }))
}, },
options:{ options:{
title: { display: true, text: 'Build time changes' }, title: { display: true, text: 'Build time changes' },
@ -338,10 +333,10 @@ const Home = function() {
scales:{ scales:{
xAxes:[{ticks:{display: false}}], xAxes:[{ticks:{display: false}}],
yAxes:[{ yAxes:[{
ticks:{userCallback: btcScale.scale}, ticks:{userCallback: scale.ticks},
scaleLabel: { scaleLabel: {
display: true, display: true,
labelString: btcScale.label labelString: scale.label
} }
}] }]
}, },
@ -351,6 +346,96 @@ const Home = function() {
} }
}); });
}, },
createRunTimeChart: (id, jobs, avg) => {
const scale = timeScale(Math.max(...jobs.map(v=>v.completed-v.started)));
return new Chart(document.getElementById(id), {
type: 'bar',
data: {
labels: jobs.map(e => '#' + e.number).reverse(),
datasets: [{
label: 'Average',
type: 'line',
data: [{x:0, y:avg * scale.factor}, {x:1, y:avg * scale.factor}],
borderColor: '#7483af',
backgroundColor: 'transparent',
xAxisID: 'avg',
pointRadius: 0,
pointHitRadius: 0,
pointHoverRadius: 0,
},{
label: 'Build time',
backgroundColor: jobs.map(e => e.result == 'success' ? '#74af77': '#883d3d').reverse(),
data: jobs.map(e => (e.completed - e.started) * scale.factor).reverse()
}]
},
options: {
title: { display: true, text: 'Build time' },
hover: { mode: null },
scales:{
xAxes:[{
categoryPercentage: 0.95,
barPercentage: 1.0
},{
id: 'avg',
type: 'linear',
ticks: {
display: false
},
gridLines: {
display: false,
drawBorder: false
}
}],
yAxes:[{
ticks:{userCallback: scale.ticks},
scaleLabel:{display: true, labelString: scale.label}
}]
},
tooltips:{callbacks:{
label: (tip, data) => scale.ticks(tip.yLabel) + ' ' + scale.label.toLowerCase()
}}
}
});
}
};
})();
// For all charts, set miniumum Y to 0
Chart.scaleService.updateScaleDefaults('linear', {
ticks: { suggestedMin: 0 }
});
// Don't display legend by default
Chart.defaults.global.legend.display = false;
// Disable tooltip hover animations
Chart.defaults.global.hover.animationDuration = 0;
// Component for the / endpoint
const Home = templateId => {
const state = {
jobsQueued: [],
jobsRecent: [],
resultChanged: [],
lowPassRates: [],
};
let chtUtilization, chtBuildsPerDay, chtBuildsPerJob, chtTimePerJob;
return {
template: templateId,
data: () => state,
methods: {
status: function(msg) {
state.jobsQueued = msg.queued;
state.jobsRunning = msg.running;
state.jobsRecent = msg.recent;
state.resultChanged = msg.resultChanged;
state.lowPassRates = msg.lowPassRates;
this.$forceUpdate();
chtUtilization = Charts.createExecutorUtilizationChart("chartUtil", msg.executorsBusy, msg.executorsTotal);
chtBuildsPerDay = Charts.createRunsPerDayChart("chartBpd", msg.buildsPerDay);
chtBuildsPerJob = Charts.createRunsPerJobChart("chartBpj", msg.buildsPerJob);
chtTimePerJob = Charts.createTimePerJobChart("chartTpj", msg.timePerJob);
chtBuildTimeChanges = Charts.createRunTimeChangesChart("chartBuildTimeChanges", msg.buildTimeChanges);
},
job_queued: function(data) { job_queued: function(data) {
state.jobsQueued.splice(0, 0, data); state.jobsQueued.splice(0, 0, data);
this.$forceUpdate(); this.$forceUpdate();
@ -359,15 +444,9 @@ const Home = function() {
state.jobsQueued.splice(state.jobsQueued.length - data.queueIndex - 1, 1); state.jobsQueued.splice(state.jobsQueued.length - data.queueIndex - 1, 1);
state.jobsRunning.splice(0, 0, data); state.jobsRunning.splice(0, 0, data);
this.$forceUpdate(); this.$forceUpdate();
updateUtilization(true); chtUtilization.executorBusyChanged(true);
}, },
job_completed: function(data) { job_completed: function(data) {
if (data.result === "success")
chtBuildsPerDay.data.datasets[0].data[6]++;
else
chtBuildsPerDay.data.datasets[1].data[6]++;
chtBuildsPerDay.update();
for (var i = 0; i < state.jobsRunning.length; ++i) { for (var i = 0; i < state.jobsRunning.length; ++i) {
var job = state.jobsRunning[i]; var job = state.jobsRunning[i];
if (job.name == data.name && job.number == data.number) { if (job.name == data.name && job.number == data.number) {
@ -377,21 +456,17 @@ const Home = function() {
break; break;
} }
} }
updateUtilization(false); chtBuildsPerDay.jobCompleted(data.result === 'success')
for (var j = 0; j < chtBuildsPerJob.data.datasets[0].data.length; ++j) { chtUtilization.executorBusyChanged(false);
if (chtBuildsPerJob.data.labels[j] == job.name) { chtBuildsPerJob.jobCompleted(data.name)
chtBuildsPerJob.data.datasets[0].data[j]++;
chtBuildsPerJob.update();
break;
}
}
} }
} }
}; };
}(); };
const All = function(templateId) { // Component for the /jobs and /wallboard endpoints
var state = { const All = templateId => {
const state = {
jobs: [], jobs: [],
search: '', search: '',
groups: {}, groups: {},
@ -401,23 +476,22 @@ const All = function(templateId) {
}; };
return { return {
template: templateId, template: templateId,
mixins: [ServerEventHandler, Utils, ProgressUpdater], data: () => state,
data: function() { return state; },
methods: { methods: {
status: function(msg) { status: function(msg) {
state.jobs = msg.jobs; state.jobs = msg.jobs;
state.jobsRunning = msg.running; state.jobsRunning = msg.running;
// mix running and completed jobs // mix running and completed jobs
for (var i in msg.running) { msg.running.forEach(job => {
var idx = state.jobs.findIndex(job => job.name === msg.running[i].name); const idx = state.jobs.findIndex(j => j.name === job.name);
if (idx > -1) if (idx > -1)
state.jobs[idx] = msg.running[i]; state.jobs[idx] = job;
else { else {
// special case: first run of a job. // special case: first run of a job.
state.jobs.unshift(msg.running[i]); state.jobs.unshift(j);
state.jobs.sort(function(a, b){return a.name < b.name ? -1 : a.name > b.name ? 1 : 0;}); state.jobs.sort((a, b) => a.name < b.name ? -1 : a.name > b.name ? 1 : 0);
}
} }
});
state.groups = {}; state.groups = {};
Object.keys(msg.groups).forEach(k => state.regexps[k] = new RegExp(state.groups[k] = msg.groups[k])); Object.keys(msg.groups).forEach(k => state.regexps[k] = new RegExp(state.groups[k] = msg.groups[k]));
state.ungrouped = state.jobs.filter(j => !Object.values(state.regexps).some(r => r.test(j.name))).map(j => j.name); state.ungrouped = state.jobs.filter(j => !Object.values(state.regexps).some(r => r.test(j.name))).map(j => j.name);
@ -425,30 +499,18 @@ const All = function(templateId) {
}, },
job_started: function(data) { job_started: function(data) {
data.result = 'running'; // for wallboard css data.result = 'running'; // for wallboard css
var updAt = null;
// jobsRunning must be maintained for ProgressUpdater // jobsRunning must be maintained for ProgressUpdater
for (var i in state.jobsRunning) { let updAt = state.jobsRunning.findIndex(j => j.name === data.name);
if (state.jobsRunning[i].name === data.name) { if (updAt === -1) {
updAt = i;
break;
}
}
if (updAt === null) {
state.jobsRunning.unshift(data); state.jobsRunning.unshift(data);
} else { } else {
state.jobsRunning[updAt] = data; state.jobsRunning[updAt] = data;
} }
updAt = null; updAt = state.jobs.findIndex(j => j.name === data.name);
for (var i in state.jobs) { if (updAt === -1) {
if (state.jobs[i].name === data.name) {
updAt = i;
break;
}
}
if (updAt === null) {
// first execution of new job. TODO insert without resort // first execution of new job. TODO insert without resort
state.jobs.unshift(data); state.jobs.unshift(data);
state.jobs.sort(function(a, b){return a.name < b.name ? -1 : a.name > b.name ? 1 : 0;}); state.jobs.sort((a, b) => a.name < b.name ? -1 : a.name > b.name ? 1 : 0);
if(!Object.values(state.regexps).some(r => r.test(data.name))) if(!Object.values(state.regexps).some(r => r.test(data.name)))
state.ungrouped.push(data.name); state.ungrouped.push(data.name);
} else { } else {
@ -457,19 +519,13 @@ const All = function(templateId) {
this.$forceUpdate(); this.$forceUpdate();
}, },
job_completed: function(data) { job_completed: function(data) {
for (var i in state.jobs) { let updAt = state.jobs.findIndex(j => j.name === data.name);
if (state.jobs[i].name === data.name) { if (updAt > -1)
state.jobs[i] = data; state.jobs[updAt] = data;
// forceUpdate in second loop updAt = state.jobsRunning.findIndex(j => j.name === data.name);
break; if (updAt > -1) {
} state.jobsRunning.splice(updAt, 1);
}
for (var i in state.jobsRunning) {
if (state.jobsRunning[i].name === data.name) {
state.jobsRunning.splice(i, 1);
this.$forceUpdate(); this.$forceUpdate();
break;
}
} }
}, },
filteredJobs: function() { filteredJobs: function() {
@ -500,8 +556,9 @@ const All = function(templateId) {
}; };
}; };
var Job = function() { // Component for the /job/:name endpoint
var state = { const Job = templateId => {
const state = {
description: '', description: '',
jobsRunning: [], jobsRunning: [],
jobsRecent: [], jobsRecent: [],
@ -511,13 +568,10 @@ var Job = function() {
pages: 0, pages: 0,
sort: {} sort: {}
}; };
var chtBt = null; let chtBt = null;
return Vue.extend({ return {
template: '#job', template: templateId,
mixins: [ServerEventHandler, Utils, ProgressUpdater], data: () => state,
data: function() {
return state;
},
methods: { methods: {
status: function(msg) { status: function(msg) {
state.description = msg.description; state.description = msg.description;
@ -533,59 +587,7 @@ var Job = function() {
// old chart and recreate it to prevent flickering of old data // old chart and recreate it to prevent flickering of old data
if(chtBt) if(chtBt)
chtBt.destroy(); chtBt.destroy();
const btScale = timeScale(Math.max(...msg.recent.map(v=>v.completed-v.started))); chtBt = Charts.createRunTimeChart("chartBt", msg.recent, msg.averageRuntime);
chtBt = new Chart(document.getElementById("chartBt"), {
type: 'bar',
data: {
labels: msg.recent.map(function(e) {
return '#' + e.number;
}).reverse(),
datasets: [{
label: 'Average',
type: 'line',
data: [{x:0,y:msg.averageRuntime},{x:1,y:msg.averageRuntime}],
borderColor: '#7483af',
backgroundColor: 'transparent',
xAxisID: 'avg',
pointRadius: 0,
pointHitRadius: 0,
pointHoverRadius: 0,
},{
label: 'Build time',
backgroundColor: msg.recent.map(e => e.result == 'success' ? '#74af77': '#883d3d').reverse(),
data: msg.recent.map(function(e) {
return e.completed - e.started;
}).reverse()
}]
},
options: {
title: { display: true, text: 'Build time' },
hover: { mode: null },
scales:{
xAxes:[{
categoryPercentage: 1.0,
barPercentage: 1.0
},{
id: 'avg',
type: 'linear',
ticks: {
display: false
},
gridLines: {
display: false,
drawBorder: false
}
}],
yAxes:[{
ticks:{userCallback: btScale.scale},
scaleLabel:{display: true, labelString: btScale.label}
}]
},
tooltips:{callbacks:{label:(tip, data)=>{
return data.datasets[tip.datasetIndex].label + ': ' + tip.yLabel + ' ' + btScale.label.toLowerCase();
}}}
}
});
}, },
job_queued: function() { job_queued: function() {
state.nQueued++; state.nQueued++;
@ -596,15 +598,12 @@ var Job = function() {
this.$forceUpdate(); this.$forceUpdate();
}, },
job_completed: function(data) { job_completed: function(data) {
for (var i = 0; i < state.jobsRunning.length; ++i) { const i = state.jobsRunning.findIndex(j => j.number === data.number);
var job = state.jobsRunning[i]; if (i > -1) {
if (job.number === data.number) {
state.jobsRunning.splice(i, 1); state.jobsRunning.splice(i, 1);
state.jobsRecent.splice(0, 0, data); state.jobsRecent.splice(0, 0, data);
this.$forceUpdate(); this.$forceUpdate();
// TODO: update the chart // TODO: update the chart
break;
}
} }
}, },
page_next: function() { page_next: function() {
@ -625,12 +624,13 @@ var Job = function() {
this.query(state.sort) this.query(state.sort)
} }
} }
}); };
}(); };
const Run = function() { // Component for the /job/:name/:number endpoint
const Run = templateId => {
const utf8decoder = new TextDecoder('utf-8'); const utf8decoder = new TextDecoder('utf-8');
var state = { const state = {
job: { artifacts: [], upstream: {} }, job: { artifacts: [], upstream: {} },
latestNum: null, latestNum: null,
log: '', log: '',
@ -641,13 +641,19 @@ const Run = function() {
// ATOW pipeThrough not supported in Firefox // ATOW pipeThrough not supported in Firefox
//const reader = res.body.pipeThrough(new TextDecoderStream).getReader(); //const reader = res.body.pipeThrough(new TextDecoderStream).getReader();
const reader = res.body.getReader(); const reader = res.body.getReader();
let total = 0;
return function pump() { return function pump() {
return reader.read().then(({done, value}) => { return reader.read().then(({done, value}) => {
value = utf8decoder.decode(value); value = utf8decoder.decode(value);
if (done) if (done)
return; return;
state.log += ansi_up.ansi_to_html(value.replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/\033\[\{([^:]+):(\d+)\033\\/g, (m,$1,$2)=>{return '<a href="jobs/'+$1+'" onclick="return vroute(this);">'+$1+'</a>:<a href="jobs/'+$1+'/'+$2+'" onclick="return vroute(this);">#'+$2+'</a>';})); state.log += ansi_up.ansi_to_html(
value.replace(/</g,'&lt;')
.replace(/>/g,'&gt;')
.replace(/\033\[\{([^:]+):(\d+)\033\\/g, (m, $1, $2) =>
'<a href="jobs/'+$1+'" onclick="return vroute(this);">'+$1+'</a>:'+
'<a href="jobs/'+$1+'/'+$2+'" onclick="return vroute(this);">#'+$2+'</a>'
)
);
vm.$forceUpdate(); vm.$forceUpdate();
return pump(); return pump();
}); });
@ -655,13 +661,9 @@ const Run = function() {
}).catch(e => {}); }).catch(e => {});
return abort; return abort;
} }
return { return {
template: '#run', template: templateId,
mixins: [ServerEventHandler, Utils, ProgressUpdater], data: () => state,
data: function() {
return state;
},
methods: { methods: {
status: function(data, params) { status: function(data, params) {
// Check for the /latest endpoint // Check for the /latest endpoint
@ -705,72 +707,47 @@ const Run = function() {
}, },
} }
}; };
}(); };
// For all charts, set miniumum Y to 0
Chart.scaleService.updateScaleDefaults('linear', {
ticks: { suggestedMin: 0 }
});
// Don't display legend by default
Chart.defaults.global.legend.display = false;
// Disable tooltip hover animations
Chart.defaults.global.hover.animationDuration = 0;
// Plugin to move a DOM item on top of a chart element
Chart.plugins.register({
afterDatasetsDraw: (chart) => {
chart.data.datasets.forEach((dataset, i) => {
var meta = chart.getDatasetMeta(i);
if(dataset.itemid)
meta.data.forEach((e,j) => {
var pos = e.getCenterPoint();
var node = document.getElementById(dataset.itemid[j]);
node.style.top = (pos.y - node.clientHeight/2) + 'px';
});
});
}
});
new Vue({ new Vue({
el: '#app', el: '#app',
data: { data: {
title: '', // populated by status ws message title: '', // populated by status message
version: '', version: '',
clockSkew: 0, clockSkew: 0,
connected: false, connected: false,
notify: 'localStorage' in window && localStorage.getItem('showNotifications') == 1 notify: 'localStorage' in window && localStorage.getItem('showNotifications') == 1
}, },
computed: { computed: {
supportsNotifications() { supportsNotifications: () =>
return 'Notification' in window && Notification.permission !== 'denied'; 'Notification' in window && Notification.permission !== 'denied'
}
}, },
methods: { methods: {
toggleNotifications(en) { toggleNotifications: function(en) {
if(Notification.permission !== 'granted') if(Notification.permission !== 'granted')
Notification.requestPermission(p => this.notify = (p === 'granted')) Notification.requestPermission(p => this.notify = (p === 'granted'))
else else
this.notify = en; this.notify = en;
}, },
showNotify(msg, data) { showNotify: function(msg, data) {
if(this.notify && msg === 'job_completed') if(this.notify && msg === 'job_completed')
new Notification('Job ' + data.result, { new Notification('Job ' + data.result, {
body: data.name + ' ' + '#' + data.number + ': ' + data.result body: data.name + ' ' + '#' + data.number + ': ' + data.result
}); });
}, }
runIcon: Utils.methods.runIcon
}, },
watch: { watch: {
notify(e) { localStorage.setItem('showNotifications', e ? 1 : 0); } notify: e => localStorage.setItem('showNotifications', e ? 1 : 0)
}, },
router: new VueRouter({ router: new VueRouter({
mode: 'history', mode: 'history',
base: document.head.baseURI.substr(location.origin.length), base: document.head.baseURI.substr(location.origin.length),
routes: [ routes: [
{ path: '/', component: Home }, { path: '/', component: Home('#home') },
{ path: '/jobs', component: All('#jobs') }, { path: '/jobs', component: All('#jobs') },
{ path: '/wallboard', component: All('#wallboard') }, { path: '/wallboard', component: All('#wallboard') },
{ path: '/jobs/:name', component: Job }, { path: '/jobs/:name', component: Job('#job') },
{ path: '/jobs/:name/:number', component: Run } { path: '/jobs/:name/:number', component: Run('#run') }
], ],
}), }),
}); });

Loading…
Cancel
Save