Просмотр поста

.
reaper

Фронтенд будет написан с помощью 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