1
0
mirror of https://github.com/ohwgiles/laminar.git synced 2026-03-02 03:40:21 +00:00

frontend: replace angular with vue

This commit is contained in:
Oliver Giles
2017-07-13 21:57:28 +03:00
parent 56bc2581bf
commit ad9837fd96
9 changed files with 627 additions and 554 deletions

View File

@@ -1,340 +1,445 @@
angular.module('laminar',['ngRoute','ngSanitize'])
.config(function($routeProvider, $locationProvider, $sceProvider) {
$routeProvider
.when('/', {
templateUrl: 'tpl/home.html',
controller: 'mainController'
})
.when('/jobs', {
templateUrl: 'tpl/browse.html',
controller: 'BrowseController',
})
.when('/jobs/:name', {
templateUrl: 'tpl/job.html',
controller: 'JobController'
})
.when('/jobs/:name/:num', {
templateUrl: 'tpl/run.html',
controller: 'RunController'
})
$locationProvider.html5Mode(true);
$sceProvider.enabled(false);
})
.factory('$ws',function($q,$location){
return {
statusListener: function(callbacks) {
var ws = new WebSocket("ws://" + location.host + $location.path());
ws.onmessage = function(message) {
message = JSON.parse(message.data);
callbacks[message.type](message.data);
};
},
logListener: function(callback) {
var ws = new WebSocket("ws://" + location.host + $location.path() + '/log');
ws.onmessage = function(message) {
callback(message.data);
};
}
};
})
.controller('mainController', function($rootScope, $scope, $ws, $interval){
$rootScope.bc = {
nodes: [],
current: 'Home'
};
/* 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();
},
};
}();
$scope.jobsQueued = [];
$scope.jobsRunning = [];
$scope.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();
}
$ws.statusListener({
status: function(data) {
$rootScope.title = data.title;
// populate jobs
$scope.jobsQueued = data.queued;
data.running.forEach($rootScope.updateProgress);
$scope.jobsRunning = data.running;
$scope.jobsRecent = data.recent;
$scope.$apply();
// setup charts
chtUtilization = new Chart(document.getElementById("chartUtil").getContext("2d")).Pie(
[{value: data.executorsBusy, color:"tan", label: "Busy"},
{value: data.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: data.buildsPerDay.map(function(e){return e.success||0;})
},{
label: "Failed Bulids",
fillColor: "darksalmon",
strokeColor: "crimson",
data: data.buildsPerDay.map(function(e){return e.failed||0;})
}]},
{ showTooltips: false }
);
chtBuildsPerJob = new Chart(document.getElementById("chartBpj").getContext("2d")).HorizontalBar({
labels: Object.keys(data.buildsPerJob),
datasets: [{
fillColor: "lightsteelblue",
data: Object.keys(data.buildsPerJob).map(function(e){return data.buildsPerJob[e];})
}]
},{});
chtTimePerJob = new Chart(document.getElementById("chartTpj").getContext("2d")).HorizontalBar({
labels: Object.keys(data.timePerJob),
datasets: [{
fillColor: "lightsteelblue",
data: Object.keys(data.timePerJob).map(function(e){return data.timePerJob[e];})
}]
},{});
},
job_queued: function(data) {
$scope.jobsQueued.splice(0,0,data);
$scope.$apply();
},
job_started: function(data) {
$scope.jobsQueued.splice($scope.jobsQueued.length - data.queueIndex - 1,1);
$scope.jobsRunning.splice(0,0,data);
$scope.$apply();
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();
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();
},
}
};
for(var i = 0; i < $scope.jobsRunning.length; ++i) {
var job = $scope.jobsRunning[i];
if(job.name == data.name && job.number == data.number) {
$scope.jobsRunning.splice(i,1);
$scope.jobsRecent.splice(0,0,data);
$scope.$apply();
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;
}
}
}
});
var timeUpdater = $interval(function() {
$scope.jobsRunning.forEach($rootScope.updateProgress);
}, 1000);
$scope.$on('$destroy', function() {
$interval.cancel(timeUpdater);
});
})
.controller('BrowseController', function($rootScope, $scope, $ws, $interval){
$rootScope.bc = {
nodes: [{ href: '/', label: 'Home' }],
current: 'Jobs'
};
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);
}
}
}
};
$scope.currentTag = null;
$scope.activeTag = function(t) {
return $scope.currentTag === t;
};
$scope.bytag = function(job) {
if($scope.currentTag === null) return true;
return job.tags.indexOf($scope.currentTag) >= 0;
};
const Home = function() {
var state = {
jobsQueued: [],
jobsRecent: []
};
$scope.jobs = [];
$ws.statusListener({
status: function(data) {
$rootScope.title = data.title;
$scope.jobs = data.jobs;
var tags = {};
for(var i in data.jobs) {
for(var j in data.jobs[i].tags) {
tags[data.jobs[i].tags[j]] = true;
}
}
$scope.tags = Object.keys(tags);
$scope.$apply();
},
job_completed: function(data) {
for(var i in $scope.jobs) {
if($scope.jobs[i].name === data.name) {
$scope.jobs[i] = data;
$scope.$apply;
break;
}
}
}
});
})
.controller('JobController', function($rootScope, $scope, $routeParams, $ws) {
$rootScope.bc = {
nodes: [{ href: '/', label: 'Home' },{ href: '/jobs', label: 'Jobs' }],
current: $routeParams.name
};
$scope.name = $routeParams.name;
$scope.jobsRunning = [];
$scope.jobsRecent = [];
var chtUtilization, chtBuildsPerDay, chtBuildsPerJob, chtTimePerJob;
$ws.statusListener({
status: function(data) {
$rootScope.title = data.title;
var updateUtilization = function(busy) {
chtUtilization.segments[0].value += busy ? 1 : -1;
chtUtilization.segments[1].value -= busy ? 1 : -1;
chtUtilization.update();
}
$scope.jobsRunning = data.running;
$scope.jobsRecent = data.recent;
$scope.lastSuccess = data.lastSuccess;
$scope.lastFailed = data.lastFailed;
$scope.$apply();
var chtBt = new Chart(document.getElementById("chartBt").getContext("2d")).Bar({
labels: data.recent.map(function(e){return '#' + e.number;}).reverse(),
datasets: [{
fillColor: "darkseagreen",
strokeColor: "forestgreen",
data: data.recent.map(function(e){return e.duration;}).reverse()
}]
},
{barValueSpacing: 1,barStrokeWidth: 1,barDatasetSpacing:0}
);
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();
for(var i = 0, n = data.recent.length; i < n; ++i) {
if(data.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() {
$scope.nQueued++;
},
job_started: function(data) {
$scope.nQueued--;
$scope.jobsRunning.splice(0,0,data);
$scope.$apply();
},
job_completed: function(data) {
for(var i = 0; i < $scope.jobsRunning.length; ++i) {
var job = $scope.jobsRunning[i];
if(job.number === data.number) {
$scope.jobsRunning.splice(i,1);
$scope.jobsRecent.splice(0,0,data);
$scope.$apply();
break;
}
}
}
});
})
.controller('RunController', function($rootScope, $scope, $routeParams, $ws, $interval) {
$rootScope.bc = {
nodes: [{ href: '/', label: 'Home' },
{ href: '/jobs', label: 'Jobs' },
{ href: '/jobs/'+$routeParams.name, label: $routeParams.name }
],
current: '#' + $routeParams.num
};
// 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];
})
}]
}, {});
$scope.name = $routeParams.name;
$scope.num = parseInt($routeParams.num);
$ws.statusListener({
status: function(data) {
$rootScope.title = data.title;
$rootScope.updateProgress(data);
$scope.job = data;
$scope.latestNum = data.latestNum;
$scope.$apply();
},
job_started: function() {
$scope.latestNum++;
$scope.$apply();
},
job_completed: function(data) {
$scope.job = data;
$scope.$apply();
}
});
$scope.log = ""
$scope.autoscroll = false;
var firstLog = false;
$ws.logListener(function(data) {
$scope.log += ansi_up.ansi_to_html(data.replace('<','&lt;').replace('>','&gt;'));
$scope.$apply();
if(!firstLog) {
firstLog = true;
} else if($scope.autoscroll) {
window.scrollTo(0, document.body.scrollHeight);
}
});
var timeUpdater = $interval(function() {
$rootScope.updateProgress($scope.job);
}, 1000);
$scope.$on('$destroy', function() {
$interval.cancel(timeUpdater);
});
})
.run(function($rootScope) {
angular.extend($rootScope, {
runIcon: function(result) {
return result === "success" ? '<span style="color:forestgreen;font-family:\'Zapf Dingbats\';">✔</span>' : result === "failed" || result === "aborted" ? '<span style="color:crimson;">✘</span>' : '';
},
runComplete: function(run) {
return !!run && (run.result === 'aborted' || run.result === 'failed' || run.result === 'success');
},
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();
},
updateProgress: function(o){
if(o.etc) {
var d = new Date();
var p = (d.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;
}
}
}
});
},
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.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;
state.jobsRunning = [];
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 }
],
}),
});