Фронтенд будет написан с помощью AngularJS.
В директории web, что располагается в корне проекта создайте директорий templates и файлы application.js, index.html, style.css
index.html (+/-)
<!DOCTYPE html>
<html ng-app="app"><!-- Говорим, что это корневой элемент для модуля пол названием app -->
<head>
<meta http-equiv="content-type" content="text/html" charset="utf-8" />
<link rel="stylesheet" href="/style.css" type="text/css"/>
<link rel="shortcut icon" href="/favicon.ico" type="image/x-icon">
<title>BuildServer</title>
</head>
<body>
<ul class="nav">
<li><a href="#/">BuildServer</a></li>
<li><a href="#/new-project">New project</a></li>
</ul>
<div class="content" ng-view></div> <!-- Здесь будет отображаться содержимое шаблонов -->
<script type="text/javascript" src="/vendor/angular/angular.js"></script>
<script type="text/javascript" src="/vendor/angular-route/angular-route.js"></script>
<script type="text/javascript" src="/vendor/angular-websocket/angular-websocket.js"></script>
<script type="text/javascript" src="/application.js"></script>
</body>
</html>style.css (+/-)
body {
background: #202020;
color: #888888;
padding: 0;
margin: 0;
}
a {
color: #fd5a4b;
text-decoration: none;
}
a:hover {
border-bottom: 1px dotted #fd5a4b;
}
ul.nav {
list-style: none;
padding: 15px 17px;
margin: 0 0 20px 0;
border-bottom: 1px solid #303030;
box-shadow: inset 0 0 8px #000000;
font-variant: small-caps;
}
ul.nav > li {
display: inline-block;
margin: 0 10px;
padding: 0;
}
.content {
max-width: 80%;
margin: 0 auto;
}
.title {
color: #fd5a4b;
font-size: 14pt;
font-variant: small-caps;
}
.project {
border: 1px solid #303030;
box-shadow: 0 0 2px #000000;
padding: 5px 17px;
margin: 7px 0;
}
.project > ul.nav {
padding: 5px 7px;
box-shadow: none;
font-variant: normal;
border: 0;
border-top: 1px solid #303030;
}
.project > ul.nav > li {
margin: 0 4px;
}
.project > ul.nav > li > a {
color: #999999;
cursor: pointer;
}
.project > ul.nav > li > a:hover {
border: 0;
}
.form-group, .error {
padding: 7px 17px;
margin: 10px 0;
border: 1px solid #191919;
box-shadow: 0 0 2px #303030;
}
.error {
color: #fd5a4b;
}
.form-group > label {
display: block;
padding: 0;
margin: 0 0 5px 0;
cursor: pointer;
}
input[type="text"], textarea {
padding: 4px 7px;
margin: 0;
border: 1px solid #252525;
background: #101010;
width: 200px;
color: #777777;
}
textarea {
width: 400px;
height: 250px;
}
input[type="button"] {
padding: 7px 17px;
margin: 0;
border: 1px solid #303030;
background: #181818;
color: #777777;
cursor: pointer;
}
table.builds {
padding: 4px;
margin: 20px 0;
border: 1px solid #303030;
color: #777777;
width: 100%;
box-shadow: inset 0 0 2px #000000;
}
table.builds > thead > tr > th {
border: 1px solid #303030;
text-align: left;
padding: 2px 4px;
font-weight: normal;
font-variant: small-caps;
}
table.builds > tbody > tr > td {
padding: 6px 4px;
border-bottom: 1px solid #242424;
}
table.builds > tbody > tr > td.state {
text-transform: capitalize;
}
table.builds > tbody > tr > td.success {
color: #88bF8E;
}
table.builds > tbody > tr > td.failed {
color: #fd5a4b;
}
table.builds > tbody > tr > td.running {
color: #dfd270;
}
pre.log {
border: 1px solid #303030;
padding: 10px;
}Шаблоны (+/-)
templates/home.html -- Домашняя страница
<div class="project" ng-repeat="project in projects">
<a href="#project/{{project['id']}} ">{{project['name']}}</a>
<dl>
<dt>Description:</dt><dd>{{project['description']}}</dd>
<dt>Repository:</dt><dd>{{project['url']}}</dd>
</dl>
</div>templates/project.html -- Страница просмотра проекта
<div class="project">
<div class="title">{{project['name']}}</div>
<dl>
<dt>Description:</dt><dd>{{project['description']}}</dd>
<dt>Repository:</dt><dd>{{project['url']}}</dd>
</dl>
<ul class="nav">
<li><a ng-click="startBuild()">Build</a></li>
<li><a href="#/edit-project/{{project['id']}}">Edit</a></li>
<li><a href="#/remove-project/{{project['id']}}">Remove</a></li>
</ul>
</div>
<table class="builds" ng-show="builds">
<thead>
<tr><th>№</th><th>Started</th><th>Finished</th><th>State</th></tr>
</thead>
<tbody>
<tr ng-repeat="build in builds">
<td><a href="#/build/{{project['id']}}/{{build['id']}}">#{{build['id']}}</a></td>
<td>{{build['start_date']}}</td>
<td>{{build['finish_date']}}</td>
<td class="state {{build['state']}}">{{build['state']}}</td>
</tr>
</tbody>
</table>templates/build.html -- Страница просмотра сборки
<div class="project">
<div class="title"><a href="#/project/{{project['id']}}">{{project['name']}}</a></div>
<dl>
<dt>Description:</dt><dd>{{project['description']}}</dd>
<dt>Repository:</dt><dd>{{project['url']}}</dd>
</dl>
</div>
<table class="builds">
<thead>
<tr><th>№</th><th>Started</th><th>Finished</th><th>State</th></tr>
</thead>
<tbody>
<tr>
<td>#{{build['id']}}</td>
<td>{{build['start_date']}}</td>
<td>{{build['finish_date']}}</td>
<td class="state {{build['state']}}">{{build['state']}}</td>
</tr>
</tbody>
</table>
<pre class="log" ng-show="build['log']">{{build['log']}}</pre>templates/project-form.html -- Форма создания/редактирования проекта
<div class="title">{{title}}</div>
<div class="form-group">
<label for="name">Name:</label>
<input id="name" type="text" ng-model="project['name']" />
</div>
<div class="form-group">
<label for="description">Description:</label>
<textarea id="description" ng-model="project['description']"></textarea>
</div>
<div class="form-group">
<label for="url">Repository URL:</label>
<input id="url" type="text" ng-model="project['url']" />
</div>
<div class="error" ng-show="error">Error: {{error}}</div>
<div class="form-group">
<input type="button" ng-click="save()" value="Save" />
</div>templates/project-remove.html -- Форма удаления проекта
<p>Do you really want to remove project?</p>
<div>
<input type="button" value="Remove" ng-click="confirm()" />
<input type="button" value="Cancel" ng-click="cancel()" />
</div>templates/error.html -- Сообщение об ошибке
<div>An error has occurred</div>application.js (+/-)
(function () {
angular.module(
'app',
['ngRoute', 'angular-websocket'], // Модули, которые должны быть загружены перед загрузкой нашего приложения
function ($routeProvider) {
// Определяем роуты
$routeProvider
.when('/', {templateUrl: '/templates/home.html', controller: 'home'})
.when('/error', {templateUrl: '/templates/error.html'})
.when('/project/:id', {templateUrl: '/templates/project.html', controller: 'project'})
.when('/new-project', {templateUrl: '/templates/project-form.html', controller: 'newProject'})
.when('/edit-project/:id', {templateUrl: '/templates/project-form.html', controller: 'editProject'})
.when('/remove-project/:id', {templateUrl: '/templates/project-remove.html', controller: 'removeProject'})
.when('/build/:pid/:bid', {templateUrl: '/templates/build.html', controller: 'showBuild'})
.otherwise('/error') // Редирект, если обратились по несуществующему адресу
}
).config(
function(WebSocketProvider){
WebSocketProvider.prefix('').uri('ws://buildserver/broadcast/'); // Устанавливаем урлу для вебсокетов
}
).run(
function ($rootScope, WebSocket) {
$rootScope.$on("$routeChangeStart", function (event, next, prev) {
if (prev && prev['$$route']['controller'] == 'showBuild') {
WebSocket.send(JSON.stringify({action: 'unsubscribe'})); // Шлём сообщение о том, что больше не хотим следить за логом сборки
}
});
}
).controller(
'home', // Домашняя страница
function ($scope, $http) {
$scope.projects = [];
$http.get('/api/v1/projects').success(function (data) {
$scope.projects = data['items'];
});
}
).controller(
'project', // Просмотр проекта
function ($scope, $routeParams, $http, $location) {
$scope.project = {};
$scope.builds = {};
$http.get('/api/v1/projects/' + $routeParams.id).success(
function (data) {
$scope.project = data['project'];
$scope.builds = data['builds'];
}
).error(
function () {
$location.path('/error')
}
);
$scope.startBuild = function () {
$http.post('/api/v1/projects/' + $routeParams.id + '/build', {}).success(
function (data) {
console.log('START BUILD:', data); // TODO: show notification
}
).error(
function (data) {
console.log('START BUILD:', data); // TODO: show notification
}
);
}
}
).controller(
'newProject', // Создание проекта
function ($scope, $http, $location) {
$scope.title = 'New project';
$scope.project = {'name': '', 'description': '', 'url': ''};
$scope.error = '';
$scope.save = function () {
$http.post('/api/v1/projects', $scope.project).success(
function (data) {
$location.path('/project/' + data['id']);
}
).error(
function (data) {
if (data.hasOwnProperty('message')) {
$scope.error = data['message'];
} else {
$scope.error = 'An error has occurred';
}
}
);
}
}
).controller(
'editProject', // Редактирование проекта
function ($scope, $http, $location, $routeParams) {
$scope.title = 'Edit project';
$http.get('/api/v1/projects/' + $routeParams.id).success(
function (data) {
$scope.project = data['project'];
$scope.save = function () {
var params = {
'name': $scope.project['name'],
'description': $scope.project['description'],
'url': $scope.project['url']
};
$http.put('/api/v1/projects/' + $routeParams.id, params).success(
function (data) {
$location.path('/project/' + data['id']);
}
).error(
function (data) {
if (data.hasOwnProperty('message')) {
$scope.error = data['message'];
} else {
$scope.error = 'An error has occurred';
}
}
);
}
}
).error(
function () {
$location.path('/error');
}
);
}
).controller(
'removeProject', // Удаление проекта
function ($scope, $http, $location, $routeParams) {
$scope.confirm = function () {
$http.delete('/api/v1/projects/' + $routeParams.id).success(
function () {
$location.path('/');
}
).error(
function () {
$location.path('/error');
}
);
};
$scope.cancel = function () {
$location.path('/project/' + $routeParams.id);
}
}
).controller(
'showBuild', // Просмотр лога сборки
function ($scope, $http, $routeParams, $location, WebSocket) {
$http.get('/api/v1/projects/' + $routeParams['pid'] + '/builds/' + $routeParams['bid']).success(
function (data) {
$scope.project = data['project'];
$scope.build = data['build'];
if ($scope.build['log'] == undefined) {
$scope.build['log'] = '';
}
var subscribe = function () {
// Шлём сообщение tornado, что хоти следить за логом
WebSocket.send(JSON.stringify({action: 'subscribe', params: {build_id: $scope.build.id}}));
};
if (WebSocket.currentState() == 'OPEN') {
// Если соединение открыто, то шлём сразу
subscribe();
} else {
// Иначе ждём, пока соединение не будет открыто
WebSocket.onopen(subscribe);
}
WebSocket.onmessage(function (event) {
var msg = JSON.parse(event.data);
if (msg['action'] == 'build_finished') {
// Билд завершён, обновляем состояние в $scope, что отразится на шаблоне
$scope.build['state'] = msg['params']['state']
} else if (msg['action'] == 'build_log') {
// Обновляем лог сборки
$scope.build['log'] = $scope.build['log'] + '\n' + msg['params']['line'];
}
});
}
).error(
function () {
$location.path('/error');
}
);
}
);
})();С фронтендом почти покончено.
Обновляем setup.py:
from setuptools import setup, find_packages
setup(
name='buildserver',
version='0.1',
description='Simple buildserver',
packages=find_packages('src'),
package_dir={'': 'src'},
install_requires=[
'flask',
'psycopg2',
'pyzmq',
'tornado'
],
# Добавили точки входа
entry_points={
'console_scripts': [
'broadcast=buildserver.broadcast:run',
'web=buildserver.web:run',
'worker=buildserver.worker:run'
]
}
)Выполняем bin/buildout, после чего будут сгенерированы bin/broadcast, bin/web и bin/worker
Запускаем их.
Создаём тестовый проект:
$ mkdir /tmp/testrepo && cd /tmp/testrepo && git init
$ editor .buildserver
Пишем туда следующее:
echo 'Step'
sleep 1
echo 'Step'
sleep 1
echo 'Step'
sleep 1
echo 'Step'
sleep 1
echo 'Step'
sleep 1
echo 'Step'
sleep 1
echo 'Step'
sleep 1
echo 'Step'
sleep 1$ git add .buildserver && git commit -m "Init"
Открываем в браузере http://buildserver добавляем проект. В качестве урлы указываем /tmp/testrepo.
После сохранения проекта жмём Build, обновляем страницу и переходим к просмотру лога.
Если я нигде не ошибся и вы всё сделали правильно, то всё должно быть ok.
Ну а теперь домашнее задание. Гг.
Сделать отображение уведомления о том, что билд был добавлен в очередь на сборку.
Обновлять список сборок в режиме реального времени (добавление/изменение/удаление).
Причём не ддосить базу запросами, а задействовать zmq и tornado.
На этом всё. Исходный код проекта можно найти здесь: https://github.com/Kilte/buildserver