1
0
mirror of https://github.com/ohwgiles/laminar.git synced 2024-09-28 06:20:47 +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

@ -58,14 +58,13 @@ add_custom_command(OUTPUT laminar.capnp.c++ laminar.capnp.h
# Zip and compile statically served resources # Zip and compile statically served resources
generate_compressed_bins(${CMAKE_SOURCE_DIR}/src/resources index.html js/app.js generate_compressed_bins(${CMAKE_SOURCE_DIR}/src/resources index.html js/app.js
tpl/home.html tpl/job.html tpl/run.html tpl/browse.html tpl/home.html tpl/job.html tpl/run.html tpl/browse.html
favicon.ico favicon-152.png icon.png progress.png) favicon.ico favicon-152.png icon.png progress.gif)
# Download 3rd-party frontend JS libs... # Download 3rd-party frontend JS libs...
file(DOWNLOAD https://ajax.googleapis.com/ajax/libs/angularjs/1.3.14/angular.min.js file(DOWNLOAD https://cdnjs.cloudflare.com/ajax/libs/vue/2.3.4/vue.min.js
js/angular.min.js EXPECTED_MD5 b1137641dbb512a60e83d673f7e2d98f) js/vue.min.js EXPECTED_MD5 ae2fca1cfa0e31377819b1b0ffef704c)
file(DOWNLOAD https://ajax.googleapis.com/ajax/libs/angularjs/1.3.14/angular-route.min.js file(DOWNLOAD https://cdnjs.cloudflare.com/ajax/libs/vue-router/2.7.0/vue-router.min.js
js/angular-route.min.js EXPECTED_MD5 28ef7d7b4349ae0dce602748185ef32a) js/vue-router.min.js EXPECTED_MD5 5d3e35710dbe02de78c39e3e439b8d4e)
file(DOWNLOAD https://ajax.googleapis.com/ajax/libs/angularjs/1.3.14/angular-sanitize.min.js
js/angular-sanitize.min.js EXPECTED_MD5 0854eae86bcdf5f92b1ab2b458d8d054)
file(DOWNLOAD https://raw.githubusercontent.com/drudru/ansi_up/v1.3.0/ansi_up.js file(DOWNLOAD https://raw.githubusercontent.com/drudru/ansi_up/v1.3.0/ansi_up.js
js/ansi_up.js EXPECTED_MD5 158566dc1ff8f2804de972f7e841e2f6) js/ansi_up.js EXPECTED_MD5 158566dc1ff8f2804de972f7e841e2f6)
file(DOWNLOAD https://cdnjs.cloudflare.com/ajax/libs/Chart.js/1.0.2/Chart.min.js file(DOWNLOAD https://cdnjs.cloudflare.com/ajax/libs/Chart.js/1.0.2/Chart.min.js
@ -75,9 +74,8 @@ file(DOWNLOAD https://raw.githubusercontent.com/tomsouthall/Chart.HorizontalBar.
file(DOWNLOAD https://maxcdn.bootstrapcdn.com/bootstrap/3.3.5/css/bootstrap.min.css file(DOWNLOAD https://maxcdn.bootstrapcdn.com/bootstrap/3.3.5/css/bootstrap.min.css
css/bootstrap.min.css EXPECTED_MD5 5d5357cb3704e1f43a1f5bfed2aebf42) css/bootstrap.min.css EXPECTED_MD5 5d5357cb3704e1f43a1f5bfed2aebf42)
# ...and compile them # ...and compile them
generate_compressed_bins(${CMAKE_BINARY_DIR} js/angular.min.js js/angular-route.min.js generate_compressed_bins(${CMAKE_BINARY_DIR} js/vue-router.min.js js/vue.min.js
js/angular-sanitize.min.js js/ansi_up.js js/Chart.min.js js/Chart.HorizontalBar.js js/ansi_up.js js/Chart.min.js js/Chart.HorizontalBar.js css/bootstrap.min.css)
css/bootstrap.min.css)
# (see resources.cpp where these are fetched) # (see resources.cpp where these are fetched)
## Server ## Server

View File

@ -30,7 +30,7 @@ Resources::Resources()
INIT_RESOURCE("/", index_html); INIT_RESOURCE("/", index_html);
INIT_RESOURCE("/favicon.ico", favicon_ico); INIT_RESOURCE("/favicon.ico", favicon_ico);
INIT_RESOURCE("/favicon-152.png", favicon_152_png); INIT_RESOURCE("/favicon-152.png", favicon_152_png);
INIT_RESOURCE("/progress.png", progress_png); INIT_RESOURCE("/progress.gif", progress_gif);
INIT_RESOURCE("/icon.png", icon_png); INIT_RESOURCE("/icon.png", icon_png);
INIT_RESOURCE("/js/app.js", js_app_js); INIT_RESOURCE("/js/app.js", js_app_js);
INIT_RESOURCE("/js/Chart.HorizontalBar.js", js_Chart_HorizontalBar_js); INIT_RESOURCE("/js/Chart.HorizontalBar.js", js_Chart_HorizontalBar_js);
@ -39,9 +39,8 @@ Resources::Resources()
INIT_RESOURCE("/tpl/job.html", tpl_job_html); INIT_RESOURCE("/tpl/job.html", tpl_job_html);
INIT_RESOURCE("/tpl/run.html", tpl_run_html); INIT_RESOURCE("/tpl/run.html", tpl_run_html);
INIT_RESOURCE("/tpl/browse.html", tpl_browse_html); INIT_RESOURCE("/tpl/browse.html", tpl_browse_html);
INIT_RESOURCE("/js/angular.min.js", js_angular_min_js); INIT_RESOURCE("/js/vue.min.js", js_vue_min_js);
INIT_RESOURCE("/js/angular-route.min.js", js_angular_route_min_js); INIT_RESOURCE("/js/vue-router.min.js", js_vue_router_min_js);
INIT_RESOURCE("/js/angular-sanitize.min.js", js_angular_sanitize_min_js);
INIT_RESOURCE("/js/ansi_up.js", js_ansi_up_js); INIT_RESOURCE("/js/ansi_up.js", js_ansi_up_js);
INIT_RESOURCE("/js/Chart.min.js", js_Chart_min_js); INIT_RESOURCE("/js/Chart.min.js", js_Chart_min_js);
INIT_RESOURCE("/js/Chart.HorizontalBar.js", js_Chart_HorizontalBar_js); INIT_RESOURCE("/js/Chart.HorizontalBar.js", js_Chart_HorizontalBar_js);

View File

@ -1,5 +1,5 @@
<!doctype html> <!doctype html>
<html ng-app="laminar"> <html>
<head> <head>
<base href="/"> <base href="/">
<meta charset="utf-8"> <meta charset="utf-8">
@ -8,14 +8,13 @@
<meta name="apple-mobile-web-app-capable" content="yes" /> <meta name="apple-mobile-web-app-capable" content="yes" />
<link rel="apple-touch-icon-precomposed" href="/favicon-152.png"> <link rel="apple-touch-icon-precomposed" href="/favicon-152.png">
<title>Laminar</title> <title>Laminar</title>
<script src="/js/angular.min.js"></script> <script src="/js/vue.min.js"></script>
<script src="/js/angular-route.min.js"></script> <script src="/js/vue-router.min.js"></script>
<script src="/js/angular-sanitize.min.js"></script> <script src="/js/ansi_up.js"></script>
<script src="/js/ansi_up.js" type="text/javascript"></script>
<script src="/js/Chart.min.js"></script> <script src="/js/Chart.min.js"></script>
<script src="/js/Chart.HorizontalBar.js"></script> <script src="/js/Chart.HorizontalBar.js"></script>
<link href="/css/bootstrap.min.css" rel="stylesheet"> <link href="/css/bootstrap.min.css" rel="stylesheet">
<script src="/js/app.js"></script> <script src="/js/app.js" defer></script>
<style> <style>
body, html { height: 100%; } body, html { height: 100%; }
.navbar { margin-bottom: 0; } .navbar { margin-bottom: 0; }
@ -36,42 +35,187 @@
margin-top: 5px; margin-top: 5px;
margin-bottom: 0; margin-bottom: 0;
} }
.spin { table#joblist tr:first-child td { border-top: 0; }
-webkit-animation: rotation 2s infinite linear;
}
@-webkit-keyframes rotation {
from {-webkit-transform: rotate(0deg);}
to {-webkit-transform: rotate(359deg);}
}
img.spin.small {
width: 11px;
height: 11px;
}
img.spin {
-webkit-animation:spin 4s linear infinite;
-moz-animation:spin 4s linear infinite;
animation:spin 4s linear infinite;
}
@-moz-keyframes spin { 100% { -moz-transform: rotate(360deg); } }
@-webkit-keyframes spin { 100% { -webkit-transform: rotate(360deg); } }
@keyframes spin { 100% { -webkit-transform: rotate(360deg); transform:rotate(360deg); } }
</style> </style>
</head> </head>
<body> <body>
<nav class="navbar navbar-inverse"> <template id="home"><div>
<div class="container-fluid"> <ol class="breadcrumb"><li class="active">Home</li></ol>
<div> <div class="container-fluid"><div class="row">
<a class="navbar-brand" href="/"><img src="/icon.png">{{title}}</a> <div class="col-sm-5 col-md-4 col-lg-3 dash">
<a class="btn navbar-btn pull-right" href="/jobs">Jobs</a> <table class="table table-bordered">
<tr v-for="job in jobsQueued">
<td><router-link :to="'/jobs/'+job.name">{{job.name}}</router-link> <i>queued</i></td>
</tr>
<tr v-for="job in jobsRunning">
<td><img class="spin small" src="/progress.gif"> <router-link :to="'/jobs/'+job.name">{{job.name}}</router-link> <router-link :to="'/jobs/'+job.name+'/'+job.number">#{{job.number}}</router-link> <div class="progress">
<div class="progress-bar progress-bar-striped" :class="'progress-bar-'+(job.overtime?'warning':'info')" :class="job.etc?'':'active'" :style="'width:'+(!job.etc?'100':job.progress)+'%'"></div>
</div>
</td>
</tr>
<tr v-for="job in jobsRecent">
<td><span v-html="runIcon(job.result)"></span> <router-link :to="'/jobs/'+job.name">{{job.name}}</router-link> <router-link :to="'/jobs/'+job.name+'/'+job.number">#{{job.number}}</router-link><br><small>Took {{job.duration}}s at {{formatDate(job.started)}}</small></td>
</tr>
</table>
</div> </div>
<div class="col-sm-7 col-md-8 col-lg-9"><div class="row">
<div class="col-md-6">
<div class="panel panel-default">
<div class="panel-heading">Total builds per day this week</div>
<div class="panel-body">
<canvas id="chartBpd"></canvas>
</div>
</div>
</div>
<div class="col-md-6">
<div class="panel panel-default">
<div class="panel-heading">Builds per job in the last 24 hours</div>
<div class="panel-body" id="chartStatus">
<canvas id="chartBpj"></canvas>
</div>
</div>
</div>
<div class="col-md-6">
<div class="panel panel-default">
<div class="panel-heading">Average build time per job this week</div>
<div class="panel-body">
<canvas id="chartTpj"></canvas>
</div>
</div>
</div>
<div class="col-md-6">
<div class="panel panel-default">
<div class="panel-heading">Current executor utilization</div>
<div class="panel-body">
<canvas id="chartUtil"></canvas>
</div>
</div>
</div>
</div></div>
</div></div>
</div></template>
<template id="jobs"><div>
<ol class="breadcrumb"><li><router-link to="/">Home</router-link></li><li class="active">Jobs</li></ol>
<div class="container-fluid"><div class="row">
<div class="col-xs-12">
<div class="pull-right">
<input class="form-control" id="jobFilter" v-model="search" placeholder="Filter...">
</div>
<ul class="nav nav-tabs">
<li :class="{'active':tag==null}"><a href v-on:click.prevent="tag = null">All Jobs</a></li>
<li v-for="t in tags" :class="{'active':t==tag}"><a href v-on:click.prevent="tag = t">{{t}}</a></li>
</ul>
<table class="table table-striped" id="joblist">
<tr v-for="job in filteredJobs">
<td><router-link :to="'/jobs/'+job.name">{{job.name}}</router-link></td>
<td class="text-center"><span v-html="runIcon(job.result)"></span> <router-link :to="'/jobs/'+job.name+'/'+job.number">#{{job.number}}</router-link></td>
<td class="text-center">{{formatDate(job.started)}}</td>
</tr>
</table>
</div>
</div></div>
</div></template>
<template id="job"><div>
<ol class="breadcrumb"><li><router-link to="/">Home</router-link></li><li><router-link to="/jobs">Jobs</router-link></li><li class="active">{{$route.params.name}}</li></ol></ol>
<div class="container-fluid">
<div class="row">
<div class="col-sm-5 col-md-6 col-lg-7">
<h3>{{$route.params.name}}</h3>
<dl class="dl-horizontal">
<dt>Last Successful Run</dt>
<dd><router-link v-if="lastSuccess" :to="'/jobs/'+$route.params.name+'/'+lastSuccess.number">#{{lastSuccess.number}}</router-link> {{lastSuccess?' - at '+formatDate(lastSuccess.started):'never'}}</dd>
<dt>Last Failed Run</dt>
<dd><router-link v-if="lastFailed" :to="'/jobs/'+$route.params.name+'/'+lastFailed.number">#{{lastFailed.number}}</router-link> {{lastFailed?' - at '+formatDate(lastFailed.started):'never'}}</dd>
</dl>
</div>
<div class="col-sm-7 col-md-6 col-lg-5">
<div class="panel panel-default">
<div class="panel-heading">Build time</div>
<div class="panel-body">
<canvas id="chartBt"></canvas>
</div>
</div>
</div>
</div>
<div class="row"><div class="col-xs-12">
<table class="table table-striped"><thead>
<tr><th>Run</th><th class="text-center">Started</th><th class="text-center">Duration</th><th class="text-center hidden-xs">Reason</th></tr></thead>
<tr v-show="nQueued">
<td colspan="4"><i>{{nQueued}} run(s) queued</i></td>
</tr>
<tr v-for="job in jobsRunning" track-by="$index">
<td><img class="spin small" src="/progress.gif"> <router-link :to="'/jobs/'+$route.params.name+'/'+job.number">#{{job.number}}</router-link></td>
<td class="text-center">{{formatDate(job.started)}}</td>
<td class="text-center">--</td>
<td class="text-center hidden-xs">{{job.reason}}</td>
</tr>
<tr v-for="job in jobsRecent" track-by="$index">
<td><span v-html="runIcon(job.result)"></span> <router-link :to="'/jobs/'+$route.params.name+'/'+job.number">#{{job.number}}</router-link></td>
<td class="text-center">{{formatDate(job.started)}}</td>
<td class="text-center">{{job.duration + " seconds"}}</td>
<td class="text-center hidden-xs">{{job.reason}}</td>
</tr>
</table>
</div></div>
</div> </div>
</nav> </div></template>
<ol class="breadcrumb">
<li ng-repeat="n in bc.nodes track by $index"><a href="{{n.href}}">{{n.label}}</a></li> <template id="run"><div>
<li class="active">{{bc.current}}</li> <ol class="breadcrumb"><li><router-link to="/">Home</router-link></li><li><router-link to="/jobs">Jobs</router-link></li><li><router-link :to="'/jobs/'+$route.params.name">{{$route.params.name}}</router-link></li><li class="active">#{{$route.params.number}}</li></ol></ol>
</ol> <div class="container-fluid">
<div ng-view></div> <div class="row">
<div class="col-sm-5 col-md-6 col-lg-7">
<h3 style="float:left"><img class="spin" src="/progress.gif" v-show="job.result === 'running'"><span v-html="runIcon(job.result)"></span> {{$route.params.name}} #{{$route.params.number}}</h3>
<nav class="pull-left">
<ul class="pagination" style="margin:15px 20px">
<li v-show="$route.params.number > 1"><router-link :to="'/jobs/'+$route.params.name+'/'+($route.params.number-1)">&laquo;</router-link></li>
<li v-show="latestNum > $route.params.number"><router-link :to="'/jobs/'+$route.params.name+'/'+(parseInt($route.params.number)+1)">&raquo;</router-link></li>
</ul>
</nav>
<div style="clear:both;"></div>
<dl class="dl-horizontal">
<dt>Reason</dt><dd>{{job.reason}}</dd>
<dt>Queued for</dt><dd>{{job.queued}}s</dd>
<dt>Started</dt><dd>{{formatDate(job.started)}}</dd>
<dt v-show="runComplete(job)">Completed</dt><dd v-show="job.completed">{{formatDate(job.completed)}}</dd>
<dt v-show="runComplete(job)">Duration</dt><dd v-show="runComplete(job)">{{job.duration}}s</dd>
</dl>
</div>
<div class="col-sm-7 col-md-6 col-lg-5">
<div class="progress" v-show="job.result == 'running'">
<div class="progress-bar progress-bar-striped" :class="'progress-bar-'+(job.overtime?'warning':'info')" :class="job.etc?'':'active'" :style="{width:!job.etc?100:job.progress + '%'}"></div>
</div>
<div class="panel panel-default" v-show="job.artifacts.length">
<div class="panel-heading">Artifacts</div>
<div class="panel-body">
<ul class="list-unstyled" style="margin-bottom: 0">
<li v-for="art in job.artifacts"><router-link :to="art.url" target="_self">{{art.filename}}</router-link></li>
</ul>
</div>
</div>
</div>
</div>
<div class="row"><div class="col-xs-12">
<button type="button" class="btn btn-default btn-xs pull-right" :class="{'active':autoscroll}" v-on:click="autoscroll = !autoscroll" style="margin-top:10px">Autoscroll</button>
<h4>Console output</h4>
<pre v-html="log"></pre>
</div></div>
</div>
</div></template>
<div id="app">
<nav class="navbar navbar-inverse">
<div class="container-fluid">
<div>
<router-link to="/" class="navbar-brand"><img src="/icon.png">{{title}}</router-link>
<router-link to="/jobs" class="btn navbar-btn pull-right">Jobs</router-link>
</div>
</div>
</nav>
<router-view></router-view>
</div>
</body> </body>
</html> </html>

View File

@ -1,340 +1,445 @@
angular.module('laminar',['ngRoute','ngSanitize']) /* laminar.js
.config(function($routeProvider, $locationProvider, $sceProvider) { * frontend application for Laminar Continuous Integration
$routeProvider * https://laminar.ohwg.net
.when('/', { */
templateUrl: 'tpl/home.html', const WebsocketHandler = function() {
controller: 'mainController' function setupWebsocket(path, next) {
}) var ws = new WebSocket("ws://" + window.location.host + path);
.when('/jobs', { ws.onmessage = function(msg) {
templateUrl: 'tpl/browse.html', msg = JSON.parse(msg.data);
controller: 'BrowseController', // "status" is the first message the websocket always delivers.
}) // Use this to confirm the navigation. The component is not
.when('/jobs/:name', { // created until next() is called, so creating a reference
templateUrl: 'tpl/job.html', // for other message types must be deferred
controller: 'JobController' if (msg.type === 'status') {
}) next(comp => {
.when('/jobs/:name/:num', { // Set up bidirectional reference
templateUrl: 'tpl/run.html', // 1. needed to reference the component for other msg types
controller: 'RunController' this.comp = comp;
}) // 2. needed to close the ws on navigation away
$locationProvider.html5Mode(true); comp.ws = this;
$sceProvider.enabled(false); // Update html and nav titles
}) document.title = comp.$root.title = msg.title;
.factory('$ws',function($q,$location){ // Component-specific callback handler
return { comp[msg.type](msg.data);
statusListener: function(callbacks) { });
var ws = new WebSocket("ws://" + location.host + $location.path()); } else {
ws.onmessage = function(message) { // at this point, the component must be defined
message = JSON.parse(message.data); if (!this.comp)
callbacks[message.type](message.data); return console.error("Page component was undefined");
}; else if (typeof this.comp[msg.type] === 'function')
}, this.comp[msg.type](msg.data);
logListener: function(callback) { }
var ws = new WebSocket("ws://" + location.host + $location.path() + '/log'); };
ws.onmessage = function(message) { };
callback(message.data); return {
}; beforeRouteEnter(to, from, next) {
} setupWebsocket(to.path, (fn) => { next(fn); });
}; },
}) beforeRouteUpdate(to, from, next) {
.controller('mainController', function($rootScope, $scope, $ws, $interval){ this.ws.close();
$rootScope.bc = { setupWebsocket(to.path, (fn) => { fn(this); next(); });
nodes: [], },
current: 'Home' beforeRouteLeave(to, from, next) {
}; this.ws.close();
next();
},
};
}();
$scope.jobsQueued = []; const Utils = {
$scope.jobsRunning = []; methods: {
$scope.jobsRecent = []; runIcon(result) {
var chtUtilization, chtBuildsPerDay, chtBuildsPerJob, chtTimePerJob; 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();
},
}
};
var updateUtilization = function(busy) { const ProgressUpdater = {
chtUtilization.segments[0].value += busy ? 1 : -1; data() { return { jobsRunning: [] }; },
chtUtilization.segments[1].value -= busy ? 1 : -1; methods: {
chtUtilization.update(); 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);
}
}
}
};
$ws.statusListener({ const Home = function() {
status: function(data) { var state = {
$rootScope.title = data.title; jobsQueued: [],
// populate jobs jobsRecent: []
$scope.jobsQueued = data.queued; };
data.running.forEach($rootScope.updateProgress);
$scope.jobsRunning = data.running;
$scope.jobsRecent = data.recent;
$scope.$apply();
// setup charts var chtUtilization, chtBuildsPerDay, chtBuildsPerJob, chtTimePerJob;
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();
for(var i = 0; i < $scope.jobsRunning.length; ++i) { var updateUtilization = function(busy) {
var job = $scope.jobsRunning[i]; chtUtilization.segments[0].value += busy ? 1 : -1;
if(job.name == data.name && job.number == data.number) { chtUtilization.segments[1].value -= busy ? 1 : -1;
$scope.jobsRunning.splice(i,1); chtUtilization.update();
$scope.jobsRecent.splice(0,0,data); }
$scope.$apply();
break; return {
} template: '#home',
} mixins: [WebsocketHandler, Utils, ProgressUpdater],
updateUtilization(false); data: function() {
for(var j = 0; j < chtBuildsPerJob.datasets[0].bars.length; ++j) { return state;
if(chtBuildsPerJob.datasets[0].bars[j].label == job.name) { },
chtBuildsPerJob.datasets[0].bars[j].value++; methods: {
chtBuildsPerJob.update(); status: function(msg) {
break; state.jobsQueued = msg.queued;
} state.jobsRunning = msg.running;
} state.jobsRecent = msg.recent;
} this.$forceUpdate();
});
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'
};
$scope.currentTag = null; // setup charts
$scope.activeTag = function(t) { chtUtilization = new Chart(document.getElementById("chartUtil").getContext("2d")).Pie(
return $scope.currentTag === t; [{
}; value: msg.executorsBusy,
$scope.bytag = function(job) { color: "tan",
if($scope.currentTag === null) return true; label: "Busy"
return job.tags.indexOf($scope.currentTag) >= 0; },
}; {
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.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 = []; job_queued: function(data) {
$scope.jobsRecent = []; 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();
$ws.statusListener({ for (var i = 0; i < state.jobsRunning.length; ++i) {
status: function(data) { var job = state.jobsRunning[i];
$rootScope.title = data.title; 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;
}
}
}
}
};
}();
$scope.jobsRunning = data.running; const Jobs = function() {
$scope.jobsRecent = data.recent; var state = {
$scope.lastSuccess = data.lastSuccess; jobs: [],
$scope.lastFailed = data.lastFailed; search: '',
$scope.$apply(); 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 chtBt = new Chart(document.getElementById("chartBt").getContext("2d")).Bar({ var Job = function() {
labels: data.recent.map(function(e){return '#' + e.number;}).reverse(), var state = {
datasets: [{ jobsRunning: [],
fillColor: "darkseagreen", jobsRecent: [],
strokeColor: "forestgreen", lastSuccess: null,
data: data.recent.map(function(e){return e.duration;}).reverse() lastFailed: null,
}] nQueued: 0,
}, };
{barValueSpacing: 1,barStrokeWidth: 1,barDatasetSpacing: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;
for(var i = 0, n = data.recent.length; i < n; ++i) { var chtBt = new Chart(document.getElementById("chartBt").getContext("2d")).Bar({
if(data.recent[i].result != "success") { labels: msg.recent.map(function(e) {
chtBt.datasets[0].bars[n-i-1].fillColor = "darksalmon"; return '#' + e.number;
chtBt.datasets[0].bars[n-i-1].strokeColor = "crimson"; }).reverse(),
} datasets: [{
} fillColor: "darkseagreen",
chtBt.update(); 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) {
job_queued: function() { if (msg.recent[i].result != "success") {
$scope.nQueued++; chtBt.datasets[0].bars[n - i - 1].fillColor = "darksalmon";
}, chtBt.datasets[0].bars[n - i - 1].strokeColor = "crimson";
job_started: function(data) { }
$scope.nQueued--; }
$scope.jobsRunning.splice(0,0,data); chtBt.update();
$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
};
$scope.name = $routeParams.name; },
$scope.num = parseInt($routeParams.num); 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;
}
}
}
}
});
}();
$ws.statusListener({ const Run = function() {
status: function(data) { var state = {
$rootScope.title = data.title; job: { artifacts: [] },
$rootScope.updateProgress(data); latestNum: null,
$scope.job = data; log: '',
$scope.latestNum = data.latestNum; autoscroll: false
$scope.$apply(); };
}, var firstLog = false;
job_started: function() { var logHandler = function(vm, d) {
$scope.latestNum++; state.log += d;
$scope.$apply(); vm.$forceUpdate();
}, if (!firstLog) {
job_completed: function(data) { firstLog = true;
$scope.job = data; } else if (state.autoscroll) {
$scope.$apply(); window.scrollTo(0, document.body.scrollHeight);
} }
}); };
$scope.log = "" return {
$scope.autoscroll = false; template: '#run',
var firstLog = false; mixins: [WebsocketHandler, Utils, ProgressUpdater],
$ws.logListener(function(data) { data: function() {
$scope.log += ansi_up.ansi_to_html(data.replace('<','&lt;').replace('>','&gt;')); return state;
$scope.$apply(); },
if(!firstLog) { methods: {
firstLog = true; status: function(data) {
} else if($scope.autoscroll) { state.log = '';
window.scrollTo(0, document.body.scrollHeight); 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();
}
};
}();
var timeUpdater = $interval(function() { new Vue({
$rootScope.updateProgress($scope.job); el: '#app',
}, 1000); data: {
$scope.$on('$destroy', function() { title: '' // populated by status ws message
$interval.cancel(timeUpdater); },
}); router: new VueRouter({
}) mode: 'history',
.run(function($rootScope) { routes: [
angular.extend($rootScope, { { path: '/', component: Home },
runIcon: function(result) { { path: '/jobs', component: Jobs },
return result === "success" ? '<span style="color:forestgreen;font-family:\'Zapf Dingbats\';">✔</span>' : result === "failed" || result === "aborted" ? '<span style="color:crimson;">✘</span>' : ''; { path: '/jobs/:name', component: Job },
}, { path: '/jobs/:name/:number', component: Run }
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;
}
}
}
});
}); });

BIN
src/resources/progress.gif Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

View File

@ -1,21 +0,0 @@
<div class="container-fluid">
<div class="row">
<div class="col-xs-12">
<div class="pull-right">
<input class="form-control" id="jobFilter" ng-model="search.name" placeholder="Filter...">
</div>
<ul class="nav nav-tabs">
<li ng-class="{active:activeTag(null)}"><a href ng-click="currentTag = null">All Jobs</a></li>
<li ng-repeat="tag in tags" ng-class="{active:activeTag(tag)}"><a href ng-click="$parent.currentTag = tag">{{tag}}</a></li>
</ul>
<style>table#joblist tr:first-child td { border-top: 0; }</style>
<table class="table table-striped" id="joblist">
<tr class="animate-repeat" ng-repeat="job in jobs | filter:bytag | filter:search">
<td><a href="jobs/{{job.name}}">{{job.name}}</a></td>
<td class="text-center"><span ng-bind-html="runIcon(job.result)"></span> <a href="jobs/{{job.name}}/{{job.number}}">#{{job.number}}</a></td>
<td class="text-center">{{formatDate(job.started)}}</a></td>
</tr>
</table>
</div>
</div>
</div>

View File

@ -1,58 +0,0 @@
<div class="container-fluid">
<div class="row">
<div class="col-sm-5 col-md-4 col-lg-3 dash">
<table class="table table-bordered">
<tr class="animate-repeat" ng-repeat="job in jobsQueued track by $index">
<td><a href="jobs/{{job.name}}">{{job.name}}</a> <i>queued</i></td>
</tr>
<tr class="animate-repeat" ng-repeat="job in jobsRunning track by $index">
<td><img class="spin small" src="/progress.png"> <a href="jobs/{{job.name}}">{{job.name}}</a> <a href="jobs/{{job.name}}/{{job.number}}">#{{job.number}}</a> <div class="progress">
<div class="progress-bar progress-bar-{{job.overtime?'warning':'info'}} progress-bar-striped {{job.etc?'':'active'}}" style="width:{{!job.etc?'100':job.progress}}%"></div>
</div>
</td>
</tr>
<tr class="animate-repeat" ng-repeat="job in jobsRecent track by $index">
<td><span ng-bind-html="runIcon(job.result)"></span> <a href="jobs/{{job.name}}">{{job.name}}</a> <a href="jobs/{{job.name}}/{{job.number}}">#{{job.number}}</a><br><small>Took {{job.duration}}s at {{formatDate(job.started)}}</small></td>
</tr>
</table>
</div>
<div class="col-sm-7 col-md-8 col-lg-9">
<div class="row">
<div class="col-md-6">
<div class="panel panel-default">
<div class="panel-heading">Total builds per day this week</div>
<div class="panel-body">
<canvas id="chartBpd"></canvas>
</div>
</div>
</div>
<div class="col-md-6">
<div class="panel panel-default">
<div class="panel-heading">Builds per job in the last 24 hours</div>
<div class="panel-body" id="chartStatus">
<canvas id="chartBpj"></canvas>
</div>
</div>
</div>
<div class="col-md-6">
<div class="panel panel-default">
<div class="panel-heading">Average build time per job this week</div>
<div class="panel-body">
<canvas id="chartTpj"></canvas>
</div>
</div>
</div>
<div class="col-md-6">
<div class="panel panel-default">
<div class="panel-heading">Current executor utilization</div>
<div class="panel-body">
<canvas id="chartUtil"></canvas>
</div>
</div>
</div>
</div>
</div>
</div>
</div>

View File

@ -1,51 +0,0 @@
<div class="container-fluid">
<div class="row">
<div class="col-sm-5 col-md-6 col-lg-7">
<h3>{{name}}</h3>
<dl class="dl-horizontal">
<dt>Last Successful Run</dt><dd>
<a ng-show="lastSuccess" href="jobs/{{name}}/{{lastSuccess.number}}">#{{lastSuccess.number}}</a>
{{lastSuccess?" - at "+formatDate(lastSuccess.started):"never"}}</dd>
<dt>Last Failed Run</dt><dd>
<a ng-show="lastFailed" href="jobs/{{name}}/{{lastFailed.number}}">#{{lastFailed.number}}</a>
{{lastFailed?" - at "+formatDate(lastFailed.started):"never"}}</dd>
</dl>
</div>
<div class="col-sm-7 col-md-6 col-lg-5">
<div class="panel panel-default">
<div class="panel-heading">Build time</div>
<div class="panel-body">
<canvas id="chartBt"></canvas>
</div>
</div>
</div>
</div>
<div class="row">
<div class="col-xs-12">
<table class="table table-striped"><thead>
<tr><th>Run</th><th class="text-center">Started</th><th class="text-center">Duration</th><th class="text-center hidden-xs">Reason</th></tr></thead>
<tr ng-show="nQueued">
<td colspan="4"><i>{{nQueued}} run(s) queued</i></td>
</tr>
<tr class="animate-repeat" ng-repeat="job in jobsRunning track by $index">
<td><img class="spin small" src="/progress.png"> <a href="jobs/{{name}}/{{job.number}}">#{{job.number}}</a></td>
<td class="text-center">{{formatDate(job.started)}}</td>
<td class="text-center">--</td>
<td class="text-center hidden-xs">{{job.reason}}</td>
</tr>
<tr class="animate-repeat" ng-repeat="job in jobsRecent track by $index">
<td><span ng-bind-html="runIcon(job.result)"></span> <a href="jobs/{{name}}/{{job.number}}">#{{job.number}}</a></td>
<td class="text-center">{{formatDate(job.started)}}</td>
<td class="text-center">{{job.duration + " seconds"}}</td>
<td class="text-center hidden-xs">{{job.reason}}</td>
</tr>
</table>
</div>
</div>
</div>

View File

@ -1,43 +0,0 @@
<div class="container-fluid">
<div class="row">
<div class="col-sm-5 col-md-6 col-lg-7">
<h3 style="float:left"><img class="spin" src="/progress.png" ng-show="job.result === 'running'"><span ng-bind-html="runIcon(job.result)"></span> {{name}} #{{num}}</h3>
<nav class="pull-left">
<ul class="pagination" style="margin:15px 20px">
<li ng-show="num > 1"><a href="jobs/{{name}}/{{num-1}}">&laquo;</a></li>
<li ng-show="latestNum > num"><a ng-href="jobs/{{name}}/{{num+1}}">&raquo;</a></li>
</ul>
</nav>
<div style="clear:both;"></div>
<dl class="dl-horizontal">
<dt>Reason</dt><dd>{{job.reason}}</dd>
<dt>Queued for</dt><dd>{{job.queued}}s</dd>
<dt>Started</dt><dd>{{formatDate(job.started)}}</dd>
<dt ng-show="runComplete(job)">Completed</dt><dd ng-show="runComplete(job)">{{formatDate(job.completed)}}</dd>
<dt ng-show="runComplete(job)">Duration</dt><dd ng-show="runComplete(job)">{{job.duration}}s</dd>
</dl>
</div>
<div class="col-sm-7 col-md-6 col-lg-5">
<div class="progress" ng-show="job.result == 'running'">
<div class="progress-bar progress-bar-{{job.overtime?'warning':'info'}} progress-bar-striped {{job.etc?'':'active'}}" style="width:{{!job.etc?'100':job.progress}}%;"></div>
</div>
<div class="panel panel-default" ng-show="job.artifacts.length">
<div class="panel-heading">Artifacts</div>
<div class="panel-body">
<ul class="list-unstyled" style="margin-bottom: 0">
<li ng-repeat="art in job.artifacts">
<a href="{{art.url}}" target="_self">{{art.filename}}</a>
</li>
</ul>
</div>
</div>
</div>
</div>
<div class="row">
<div class="col-xs-12">
<button type="button" class="btn btn-default btn-xs pull-right" ng-class="{active:autoscroll}" ng-click="autoscroll = !autoscroll" style="margin-top:10px">Autoscroll</button>
<h4>Console output</h4>
<pre ng-bind-html="log"></pre>
</div>
</div>
</div>