1
0
mirror of https://github.com/ohwgiles/laminar.git synced 2026-10-18 00:54:25 +00:00
Files
ohwgiles_laminar/src/resources/js/app.js
Oliver Giles 713462f37f update progress bar when navigating between Runs
When navigating between Run pages, the jobsRunning array used
by the ProgressUpdater was cleared after the status message had
inserted the new Run instead of before
2017-08-15 08:33:15 +03:00

446 lines
13 KiB
JavaScript

/* laminar.js
* frontend application for Laminar Continuous Integration
* https://laminar.ohwg.net
*/
const WebsocketHandler = function() {
function setupWebsocket(path, next) {
var ws = new WebSocket("ws://" + window.location.host + path);
ws.onmessage = function(msg) {
msg = JSON.parse(msg.data);
// "status" is the first message the websocket always delivers.
// Use this to confirm the navigation. The component is not
// created until next() is called, so creating a reference
// for other message types must be deferred
if (msg.type === 'status') {
next(comp => {
// Set up bidirectional reference
// 1. needed to reference the component for other msg types
this.comp = comp;
// 2. needed to close the ws on navigation away
comp.ws = this;
// Update html and nav titles
document.title = comp.$root.title = msg.title;
// Component-specific callback handler
comp[msg.type](msg.data);
});
} else {
// at this point, the component must be defined
if (!this.comp)
return console.error("Page component was undefined");
else if (typeof this.comp[msg.type] === 'function')
this.comp[msg.type](msg.data);
}
};
};
return {
beforeRouteEnter(to, from, next) {
setupWebsocket(to.path, (fn) => { next(fn); });
},
beforeRouteUpdate(to, from, next) {
this.ws.close();
setupWebsocket(to.path, (fn) => { fn(this); next(); });
},
beforeRouteLeave(to, from, next) {
this.ws.close();
next();
},
};
}();
const Utils = {
methods: {
runIcon(result) {
return result === "success" ? '<span style="color:forestgreen;font-family:\'Zapf Dingbats\';">✔</span>' : result === "failed" || result === "aborted" ? '<span style="color:crimson;">✘</span>' : '';
},
formatDate: function(unix) {
// TODO: reimplement when toLocaleDateString() accepts formatting options on most browsers
var d = new Date(1000 * unix);
var m = d.getMinutes();
if (m < 10) m = '0' + m;
return d.getHours() + ':' + m + ' on ' + ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'][d.getDay()] + ' ' +
d.getDate() + '. ' + ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun',
'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'
][d.getMonth()] + ' ' +
d.getFullYear();
},
}
};
const ProgressUpdater = {
data() { return { jobsRunning: [] }; },
methods: {
updateProgress(o) {
if (o.etc) {
var p = ((new Date()).getTime() / 1000 - o.started) / (o.etc - o.started);
if (p > 1.2) {
o.overtime = true;
} else 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: []
};
var chtUtilization, chtBuildsPerDay, chtBuildsPerJob, chtTimePerJob;
var updateUtilization = function(busy) {
chtUtilization.segments[0].value += busy ? 1 : -1;
chtUtilization.segments[1].value -= busy ? 1 : -1;
chtUtilization.update();
}
return {
template: '#home',
mixins: [WebsocketHandler, Utils, ProgressUpdater],
data: function() {
return state;
},
methods: {
status: function(msg) {
state.jobsQueued = msg.queued;
state.jobsRunning = msg.running;
state.jobsRecent = msg.recent;
this.$forceUpdate();
// setup charts
chtUtilization = new Chart(document.getElementById("chartUtil").getContext("2d")).Pie(
[{
value: msg.executorsBusy,
color: "tan",
label: "Busy"
},
{
value: msg.executorsTotal,
color: "darkseagreen",
label: "Idle"
}
], {
animationEasing: 'easeInOutQuad'
}
);
chtBuildsPerDay = new Chart(document.getElementById("chartBpd").getContext("2d")).Line({
labels: function() {
res = [];
var now = new Date();
for (var i = 6; i >= 0; --i) {
var then = new Date(now.getTime() - i * 86400000);
res.push(["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"][then.getDay()]);
}
return res;
}(),
datasets: [{
label: "Successful Builds",
fillColor: "darkseagreen",
strokeColor: "forestgreen",
data: msg.buildsPerDay.map(function(e) {
return e.success || 0;
})
}, {
label: "Failed Bulids",
fillColor: "darksalmon",
strokeColor: "crimson",
data: msg.buildsPerDay.map(function(e) {
return e.failed || 0;
})
}]
}, {
showTooltips: false
});
chtBuildsPerJob = new Chart(document.getElementById("chartBpj").getContext("2d")).HorizontalBar({
labels: Object.keys(msg.buildsPerJob),
datasets: [{
fillColor: "lightsteelblue",
data: Object.keys(msg.buildsPerJob).map(function(e) {
return msg.buildsPerJob[e];
})
}]
}, {});
chtTimePerJob = new Chart(document.getElementById("chartTpj").getContext("2d")).HorizontalBar({
labels: Object.keys(msg.timePerJob),
datasets: [{
fillColor: "lightsteelblue",
data: Object.keys(msg.timePerJob).map(function(e) {
return msg.timePerJob[e];
})
}]
}, {});
},
job_queued: function(data) {
state.jobsQueued.splice(0, 0, data);
this.$forceUpdate();
},
job_started: function(data) {
state.jobsQueued.splice(state.jobsQueued.length - data.queueIndex - 1, 1);
state.jobsRunning.splice(0, 0, data);
this.$forceUpdate();
updateUtilization(true);
},
job_completed: function(data) {
if (data.result === "success")
chtBuildsPerDay.datasets[0].points[6].value++;
else
chtBuildsPerDay.datasets[1].points[6].value++;
chtBuildsPerDay.update();
for (var i = 0; i < state.jobsRunning.length; ++i) {
var job = state.jobsRunning[i];
if (job.name == data.name && job.number == data.number) {
state.jobsRunning.splice(i, 1);
state.jobsRecent.splice(0, 0, data);
this.$forceUpdate();
break;
}
}
updateUtilization(false);
for (var j = 0; j < chtBuildsPerJob.datasets[0].bars.length; ++j) {
if (chtBuildsPerJob.datasets[0].bars[j].label == job.name) {
chtBuildsPerJob.datasets[0].bars[j].value++;
chtBuildsPerJob.update();
break;
}
}
}
}
};
}();
const Jobs = function() {
var state = {
jobs: [],
search: '',
tags: [],
tag: null
};
return {
template: '#jobs',
mixins: [WebsocketHandler, Utils],
data: function() { return state; },
computed: {
filteredJobs() {
var ret = this.jobs;
var tag = this.tag;
if (tag) {
ret = ret.filter(function(job) {
return job.tags.indexOf(tag) >= 0;
});
}
var search = this.search;
if (search) {
ret = ret.filter(function(job) {
return job.name.indexOf(search) > -1;
});
}
return ret;
}
},
methods: {
status: function(msg) {
state.jobs = msg.jobs;
var tags = {};
for (var i in state.jobs) {
for (var j in state.jobs[i].tags) {
tags[state.jobs[i].tags[j]] = true;
}
}
state.tags = Object.keys(tags);
},
job_completed: function(data) {
for (var i in state.jobs) {
if (state.jobs[i].name === data.name) {
state.jobs[i] = data;
this.$forceUpdate();
break;
}
}
}
}
};
}();
var Job = function() {
var state = {
jobsRunning: [],
jobsRecent: [],
lastSuccess: null,
lastFailed: null,
nQueued: 0,
};
return Vue.extend({
template: '#job',
mixins: [WebsocketHandler, Utils],
data: function() {
return state;
},
methods: {
status: function(msg) {
state.jobsRunning = msg.running;
state.jobsRecent = msg.recent;
state.lastSuccess = msg.lastSuccess;
state.lastFailed = msg.lastFailed;
var chtBt = new Chart(document.getElementById("chartBt").getContext("2d")).Bar({
labels: msg.recent.map(function(e) {
return '#' + e.number;
}).reverse(),
datasets: [{
fillColor: "darkseagreen",
strokeColor: "forestgreen",
data: msg.recent.map(function(e) {
return e.duration;
}).reverse()
}]
}, {
barValueSpacing: 1,
barStrokeWidth: 1,
barDatasetSpacing: 0
});
for (var i = 0, n = msg.recent.length; i < n; ++i) {
if (msg.recent[i].result != "success") {
chtBt.datasets[0].bars[n - i - 1].fillColor = "darksalmon";
chtBt.datasets[0].bars[n - i - 1].strokeColor = "crimson";
}
}
chtBt.update();
},
job_queued: function() {
state.nQueued++;
},
job_started: function(data) {
state.nQueued--;
state.jobsRunning.splice(0, 0, data);
this.$forceUpdate();
},
job_completed: function(data) {
for (var i = 0; i < state.jobsRunning.length; ++i) {
var job = state.jobsRunning[i];
if (job.number === data.number) {
state.jobsRunning.splice(i, 1);
state.jobsRecent.splice(0, 0, data);
this.$forceUpdate();
// TODO: update the chart
break;
}
}
}
}
});
}();
const Run = function() {
var state = {
job: { artifacts: [] },
latestNum: null,
log: '',
autoscroll: false
};
var firstLog = false;
var logHandler = function(vm, d) {
state.log += d;
vm.$forceUpdate();
if (!firstLog) {
firstLog = true;
} else if (state.autoscroll) {
window.scrollTo(0, document.body.scrollHeight);
}
};
return {
template: '#run',
mixins: [WebsocketHandler, Utils, ProgressUpdater],
data: function() {
return state;
},
methods: {
status: function(data) {
state.jobsRunning = [];
state.log = '';
state.job = data;
state.latestNum = data.latestNum;
if (!!state.job.etc)
state.jobsRunning = [data];
},
job_started: function(data) {
state.latestNum++;
this.$forceUpdate();
},
job_completed: function(data) {
state.job = data;
state.jobsRunning = [];
this.$forceUpdate();
},
runComplete: function(run) {
return !!run && (run.result === 'aborted' || run.result === 'failed' || run.result === 'success');
},
},
beforeRouteEnter(to, from, next) {
next(vm => {
vm.logws = new WebSocket("ws://" + location.host + to.path + '/log');
vm.logws.onmessage = function(msg) {
logHandler(vm, msg.data);
}
});
},
beforeRouteUpdate(to, from, next) {
var vm = this;
vm.logws.close();
vm.logws = new WebSocket("ws://" + location.host + to.path + '/log');
vm.logws.onmessage = function(msg) {
logHandler(vm, msg.data);
}
next();
},
beforeRouteLeave(to, from, next) {
this.logws.close();
next();
}
};
}();
new Vue({
el: '#app',
data: {
title: '' // populated by status ws message
},
router: new VueRouter({
mode: 'history',
routes: [
{ path: '/', component: Home },
{ path: '/jobs', component: Jobs },
{ path: '/jobs/:name', component: Job },
{ path: '/jobs/:name/:number', component: Run }
],
}),
});