diff --git a/README.md b/README.md index 6f4e0a1..4d1aff6 100644 --- a/README.md +++ b/README.md @@ -67,4 +67,4 @@ problems detected by regexp) then step will be rerun without error * Ability to change build parameters from ui (at least target branch) * ~~Embedded database (apparently level db)~~ * ~~Lightweight (minimal dependencies)~~ -* Cancel build +* ~~Cancel build~~ diff --git a/data/config.yaml b/data/config.yaml index 6521b97..afe114b 100644 --- a/data/config.yaml +++ b/data/config.yaml @@ -14,7 +14,7 @@ http: storage: backend: memdown - # backend: medeadown + # backend: leveldown notify: mail: diff --git a/distributor.js b/distributor.js index 9a14e27..d8151b1 100644 --- a/distributor.js +++ b/distributor.js @@ -33,6 +33,14 @@ exports.init = function(app, callback) { }, callback ); + }, + removeBuild: function(build, callback) { + Steppy( + function() { + db.builds.del([build.id], this.slot()); + }, + callback + ); } }); @@ -72,9 +80,9 @@ exports.init = function(app, callback) { exports.createBuildDataResource = createBuildDataResource; - distributor.on('buildUpdate', function(build, changes) { - var buildsResource = app.dataio.resource('builds'); + var buildsResource = app.dataio.resource('builds'); + distributor.on('buildUpdate', function(build, changes) { if (build.status === 'queued') { createBuildDataResource(build.id); } @@ -91,6 +99,10 @@ exports.init = function(app, callback) { }); }); + distributor.on('buildCancel', function(build) { + buildsResource.clientEmitSync('cancel', {buildId: build.id}); + }); + var buildLogLineNumbersHash = {}; distributor.on('buildData', function(build, data) { diff --git a/lib/distributor.js b/lib/distributor.js index d15d925..1aaba32 100644 --- a/lib/distributor.js +++ b/lib/distributor.js @@ -22,6 +22,10 @@ function Distributor(params) { callback(null, build); }; + self.removeBuild = params.removeBuild || function(build, callback) { + callback(); + }; + self.projects = params.projects; } @@ -209,6 +213,36 @@ Distributor.prototype._updateBuild = function(build, changes, callback) { ); }; +Distributor.prototype.cancel = function(params, callback) { + var self = this; + Steppy( + function() { + var queueItemIndex = _(self.queue).findIndex(function(item) { + return item.build.id === params.buildId; + }); + + if (queueItemIndex === -1) { + throw new Error( + 'Build with id "' + params.buildId + '" not found for cancel' + ); + } + + // only queued build are in the queue, so there is no reason + // to check status + var build = self.queue[queueItemIndex].build; + + // remove from queue + self.queue.splice(queueItemIndex, 1)[0]; + + // remove from db + self.removeBuild(build, this.slot()); + + self.emit('buildCancel', build); + }, + callback + ); +}; + Distributor.prototype.run = function(params, callback) { var self = this, project; diff --git a/resources/builds.js b/resources/builds.js index 2e0d046..6dc7ddb 100644 --- a/resources/builds.js +++ b/resources/builds.js @@ -3,10 +3,12 @@ var Steppy = require('twostep').Steppy, _ = require('underscore'), db = require('../db'), - utils = require('../lib/utils'); + utils = require('../lib/utils'), + logger = require('../lib/logger')('builds resource'); module.exports = function(app) { - var resource = app.dataio.resource('builds'); + var resource = app.dataio.resource('builds'), + distributor = app.distributor; resource.use('readAll', function(req, res, next) { Steppy( @@ -104,5 +106,19 @@ module.exports = function(app) { ); }); + resource.use('cancel', function(req, res, next) { + Steppy( + function() { + var buildId = req.data.buildId; + logger.log('Cancel build: "%s"', buildId); + distributor.cancel({buildId: buildId}, this.slot()); + }, + function() { + res.send(); + }, + next + ); + }); + return resource; }; diff --git a/static/js/app/actions/build.js b/static/js/app/actions/build.js index 00aa110..be64877 100644 --- a/static/js/app/actions/build.js +++ b/static/js/app/actions/build.js @@ -2,6 +2,7 @@ define(['reflux'], function(Reflux) { var Actions = Reflux.createActions([ + 'cancel', 'readTerminalOutput', 'readAll', 'read' diff --git a/static/js/app/components/builds/item.jade b/static/js/app/components/builds/item.jade index 1578765..2478d1c 100644 --- a/static/js/app/components/builds/item.jade +++ b/static/js/app/components/builds/item.jade @@ -84,4 +84,10 @@ mixin statusText(build) .build_controls_progress if build.project.avgBuildDuration Progress(build=build) + if build.status === 'queued' + .build_controls_buttons + a.btn.btn-sm.btn-default(href="javascript:void(0);", onClick=this.onCancelBuild(build.id)) + i.fa.fa-fw.fa-times(title="Cancel build") + | + | Cancel build diff --git a/static/js/app/components/builds/item.js b/static/js/app/components/builds/item.js index 5d44903..d10440f 100644 --- a/static/js/app/components/builds/item.js +++ b/static/js/app/components/builds/item.js @@ -30,6 +30,9 @@ define([ onRebuildProject: function(projectName) { ProjectActions.run(projectName) }, + onCancelBuild: function(buildId) { + BuildActions.cancel(buildId); + }, onShowTerminal: function(build) { this.setState({showTerminal: !this.state.showTerminal}); BuildActions.readTerminalOutput(this.props.build); diff --git a/static/js/app/stores/builds.js b/static/js/app/stores/builds.js index f70912d..f765ca5 100644 --- a/static/js/app/stores/builds.js +++ b/static/js/app/stores/builds.js @@ -14,7 +14,7 @@ define([ return this.builds; }, - onChange: function(data, action) { + onChanged: function(data) { var oldBuild = _(this.builds).findWhere({id: data.buildId}); if (oldBuild) { _(oldBuild).extend(data.changes); @@ -27,8 +27,23 @@ define([ this.trigger(this.builds); }, + onCancelled: function(data) { + // WORKAROUND: client that trigger `onCancel` gets one `onCancelled` + // call other clients get 2 calls (second with empty data) + if (!data) { + return; + } + var index = _(this.builds).findIndex({id: data.buildId}); + if (index !== -1) { + this.builds.splice(index, 1); + } + + this.trigger(this.builds); + }, + init: function() { - resource.subscribe('change', this.onChange); + resource.subscribe('change', this.onChanged); + resource.subscribe('cancel', this.onCancelled); }, onReadAll: function(params) { @@ -39,6 +54,12 @@ define([ self.trigger(self.builds); }); }, + + onCancel: function(buildId) { + resource.sync('cancel', {buildId: buildId}, function(err) { + if (err) throw err; + }); + } }); return Store; diff --git a/test/distributor/main.js b/test/distributor/main.js index 69d8e86..e7eab9f 100644 --- a/test/distributor/main.js +++ b/test/distributor/main.js @@ -128,4 +128,99 @@ describe('Distributor main', function() { Distributor.prototype._createNode.restore(); }); }); + + describe('with success project and build cancel', function() { + before(function() { + sinon.stub(Distributor.prototype, '_createNode', createNodeMock( + sinon.stub().callsArgAsync(1) + )); + }); + + var distributorParams = { + projects: projects, + nodes: [{type: 'local', maxExecutorsCount: 1}], + saveBuild: function(build, callback) { + build.id = 1; + callback(null, build); + } + }; + + describe('when cancel queued buid', function() { + var updateBuildSpy; + + var cancelError; + it('instance should be created without errors', function() { + distributor = new Distributor(distributorParams); + + var originalRunNext = distributor._runNext; + distributor._runNext = function() { + distributor.cancel({buildId: 1}, function(err) { + cancelError = err; + }); + originalRunNext.apply(distributor, arguments); + }; + + updateBuildSpy = sinon.spy(distributor, '_updateBuild'); + }); + + it('should run without errors', function(done) { + distributor.run({projectName: 'project1'}, function(err) { + expect(err).not.ok(); + done(); + }); + }); + + it('build should be queued', function() { + var changes = updateBuildSpy.getCall(0).args[1]; + expect(changes).only.have.keys( + 'project', 'initiator', 'params', 'createDate', 'status', + 'completed' + ); + expect(changes.status).equal('queued'); + expect(changes.completed).equal(false); + }); + + it('should be cancelled without error', function() { + expect(cancelError).not.ok(); + }); + + it('update build called only once', function() { + expect(updateBuildSpy.callCount).equal(1); + }); + }); + + describe('when try to cancel unexisted build', function() { + var cancelError; + + it('instance should be created without errors', function() { + distributor = new Distributor(distributorParams); + + var originalRunNext = distributor._runNext; + distributor._runNext = function() { + distributor.cancel({buildId: 2}, function(err) { + cancelError = err; + }); + originalRunNext.apply(distributor, arguments); + }; + }); + + it('should run without errors', function(done) { + distributor.run({projectName: 'project1'}, function(err) { + expect(err).not.ok(); + done(); + }); + }); + + it('should be cancelled with error (build not found)', function() { + expect(cancelError).ok(); + expect(cancelError.message).eql( + 'Build with id "2" not found for cancel' + ); + }); + }); + + after(function() { + Distributor.prototype._createNode.restore(); + }); + }); });