diff --git a/README.md b/README.md index 7d97efd..2570550 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,7 @@ work in progress... * ~~Persistent build and console output information~~ * ~~Project relations (blocks, triggers, etc)~~ * ~~Writes to stderr must not break the build~~ -* Mail and jabber notifications (with commits, current step and error) +* ~~Mail and jabber notifications~~ * ~~Rename notification strategies according to statuses~~ * ~~Work with git~~ * ~~Build every commit, commit with tag, etc~~ @@ -33,12 +33,15 @@ work in progress... * ~~git checkout before reset~~ * slow move out from build page (with lot of output) to main page - several sec -* when long line appear console output row numbers not on the same line with -content +* ~~when long line appear console output row numbers not on the same line with +content~~ * some "undefined" comments in scm changes * projects list scroll -* Error during send: TypeError: Cannot read property 'changes' of undefined -* Builds loss +* ~~Error during send: TypeError: Cannot read property 'changes' of undefined~~ +* ~~Builds loss~~ +* ~~error on git after change branch: fatal: ambiguous argument '18a8ea4..branch': +unknown revision or path not in the working tree.~~ +* "Uncaught TypeError: Cannot read property 'name' of undefined" at item.js (jade) ## Feature requests @@ -46,7 +49,7 @@ content * ~~should write at the end of build console out that build is done (or error)~~ * ~~share workspace files at static~~ * "clear workspace" button -* show more builds button (or infinity scroll) on start page +* ~~show more builds button (or infinity scroll) on start page~~ * ~~hide console output by default (when go on completed build page you scroll down to the output which could be very long)~~ * speed up build points animation at ff (maybe borrow something from animate.css?) diff --git a/app.js b/app.js index 1e5d1fa..050a588 100644 --- a/app.js +++ b/app.js @@ -32,7 +32,7 @@ var server = http.createServer(function(req, res) { } if (req.url.indexOf('/data.io.js') === -1) { - if (/(js|css|fonts)/.test(req.url)) { + if (/(js|css|fonts|images)/.test(req.url)) { staticServer.serve(req, res); } else { // serve index for all app pages diff --git a/app/components/builds/item/index.jade b/app/components/builds/item/index.jade index 3d1a049..506af6f 100644 --- a/app/components/builds/item/index.jade +++ b/app/components/builds/item/index.jade @@ -13,42 +13,61 @@ mixin statusText(build) - var build = this.props.build; -.build(class="") - .build_content - .build_status - .status(class="status__#{build.status}") - div.build_header - if build.project - span - Scm(scm=build.project.scm.type) +.builds_item(class="builds_item__#{build.status}") + .builds_inner + .row + .builds_header + if build.project + span + Scm(scm=build.project.scm.type) + | + Link(to="project", params={name: build.project.name}) + span= build.project.name | - Link(to="project", params={name: build.project.name}) - span= build.project.name - | - if build.number - span(style={fontSize: '15px', color: '#a6a6a6'}) build - | - if build.status !== 'queued' - Link(to="build", params={id: build.id}) - span # - span= build.number - else - span # - span= build.number + if build.number + span(style={fontSize: '15px', color: '#a6a6a6'}) build + | + if build.status !== 'queued' + Link(to="build", params={id: build.id}) + span # + span= build.number + else + span # + span= build.number - if build.waitReason - span ( - span= build.waitReason - span , waiting) + if build.waitReason + span ( + span= build.waitReason + span , waiting) - if build.status === 'in-progress' && build.currentStep - span ( - span= build.currentStep - span ) + if build.status === 'in-progress' && build.currentStep + span ( + span= build.currentStep + span ) - div + .builds_controls + if build.completed + .builds_buttons + a.btn.btn-sm.btn-default(href="javascript:void(0);", onClick=this.onRebuildProject(build.project.name)) + i.fa.fa-fw.fa-repeat(title="Rebuild") + | + | Build again + + if build.status === 'in-progress' + .builds_progress + if build.project.avgBuildDuration + Progress(build=build) + + if build.status === 'queued' + .builds_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 + + .builds_content if build.endDate - span.build_info + span.builds_info i.fa.fa-fw.fa-clock-o | finished DateTime(value=build.endDate) @@ -56,39 +75,18 @@ mixin statusText(build) Duration(value=(build.endDate - build.startDate), withSuffix=true) else if build.startDate - span.build_info + span.builds_info i.fa.fa-fw.fa-clock-o | started DateTime(value=build.startDate) else - span.build_info + span.builds_info i.fa.fa-fw.fa-clock-o | queued DateTime(value=build.createDate) | if build.scm - span.build_info + span.builds_info i.fa.fa-fw.fa-comment-o | span= utils.prune(build.scm.rev.comment, 40) - | - - .build_controls - if build.completed - .build_controls_buttons - a.btn.btn-sm.btn-default(href="javascript:void(0);", onClick=this.onRebuildProject(build.project.name)) - i.fa.fa-fw.fa-repeat(title="Rebuild") - | - | Build again - if build.status === 'in-progress' - .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/app/components/builds/list/index.jade b/app/components/builds/list/index.jade index c08bbd9..db68abf 100644 --- a/app/components/builds/list/index.jade +++ b/app/components/builds/list/index.jade @@ -1,6 +1,15 @@ -.builds - if !this.state.items.length - p Build history is empty - each build, index in this.state.items - Item(build=build, key=build.id) +- var itemsCount = this.state.items.length; +if itemsCount + .builds.builds__timeline.builds__timeline-large(class="builds__timeline-#{itemsCount % 2 ? 'left' : 'right'}") + each build, index in this.state.items + Item(build=build, key=build.id) +else + p Build history is empty + +if itemsCount && itemsCount % 20 === 0 + .text-center + a.btn.btn-sm.btn-default(href="javascript:void(0);", onClick=this.onShowMoreBuilds(this.props.projectName)) + i.fa.fa-fw.fa-plus(title="Show more builds") + | + | Show more builds diff --git a/app/components/builds/view/index.jade b/app/components/builds/view/index.jade index 6076cec..f2380ed 100644 --- a/app/components/builds/view/index.jade +++ b/app/components/builds/view/index.jade @@ -15,29 +15,30 @@ mixin statusBadge(build) if this.state.build .col-sm-3.hidden-xs BuildSidebar(projectName=this.state.build.project.name) + .col-sm-9 - h1 + h1.page-header .pull-right(style={fontSize: '22px'}) mixin statusBadge(this.state.build) span Build # span= this.state.build.number - .text-muted(style={marginTop: '-10px'}) - | Initiated by - - var initiator = this.state.build.initiator; - if initiator.type === 'build' - Link(to="project", params={name: initiator.project.name}) - span= initiator.project.name - | - | during the - | - Link(to="build", params={id: initiator.id}) - span build # - span= initiator.number - else - span= initiator.type + .small.text-muted + | Initiated by + - var initiator = this.state.build.initiator; + if initiator.type === 'build' + Link(to="project", params={name: initiator.project.name}) + span= initiator.project.name + | + | during the + | + Link(to="build", params={id: initiator.id}) + span build # + span= initiator.number + else + span= initiator.type - hr + //- hr .build-view_info if this.state.build.error @@ -133,5 +134,5 @@ mixin statusBadge(build) button.btn.btn-primary(onClick=this.toggleConsole) i.fa.fa-fw.fa-refresh | - | Load console output... + | Show full console output diff --git a/app/components/builds/view/sidebar/index.jade b/app/components/builds/view/sidebar/index.jade index 86eeca3..e155b5b 100644 --- a/app/components/builds/view/sidebar/index.jade +++ b/app/components/builds/view/sidebar/index.jade @@ -1,17 +1,18 @@ -.builds(style={paddingTop: '20px'}) +.builds.builds__timeline.builds__timeline-small each item in this.state.items - .build.build__small(key=item.id) - .build_content - .build_status - .status.status__small(class="status__#{item.status}") - .build_header - Link(to="build", params={id: item.id}) - span build # - span= item.number - .build_controls - if item.status === 'in-progress' - .build_controls_progress - if item.project.avgBuildDuration - Progress(build=item) - if item.endDate - DateTime(value=item.endDate) + .builds_item(key=item.id, class="builds_item__#{item.status}") + .builds_inner + .row + .builds_header + Link(to="build", params={id: item.id}) + span build # + span= item.number + + .builds_controls + if item.status === 'in-progress' + .builds_progress + if item.project.avgBuildDuration + Progress(build=item) + + if !item.endDate + DateTime(value=item.endDate) diff --git a/app/components/common/progress/index.jade b/app/components/common/progress/index.jade index ef90fb2..cb7e1dc 100644 --- a/app/components/common/progress/index.jade +++ b/app/components/common/progress/index.jade @@ -1,2 +1,2 @@ .progress - .progress-bar.progress-bar-success(style={width: this.state.percent + '%'}) + .progress-bar.progress-bar-success.progress-bar-striped.active(style={width: this.state.percent + '%'}) diff --git a/app/components/dashboard/index.jade b/app/components/dashboard/index.jade index f680a21..cfefea7 100644 --- a/app/components/dashboard/index.jade +++ b/app/components/dashboard/index.jade @@ -1,5 +1,4 @@ -.main-row - .row - .col-md-8.col-sm-12 - h2 Builds history - BuildsList() +div + h1.page-header Builds history + + BuildsList() diff --git a/app/components/projects/view/index.jade b/app/components/projects/view/index.jade index 8e0c063..ab5d1ee 100644 --- a/app/components/projects/view/index.jade +++ b/app/components/projects/view/index.jade @@ -1,58 +1,56 @@ -.row - .col-md-8 - h1.clearfix - .pull-right - button.btn.btn-sm.btn-primary.dropdown-toggle( - data-toggle="dropdown", - aria-expanded="false", - disabled="true" - ) - | target revision: - span= this.state.project.scm ? this.state.project.scm.rev : '' - | - if this.state.project.name - button.btn.btn-sm.btn-success(onClick=this.onBuildProject) - i.fa.fa-fw.fa-play - | - span Build - div - Scm(scm=this.state.project.scm ? this.state.project.scm.type : '') - span= this.state.project.name - - hr - - div.text-muted - - var lastDoneBuild = this.state.project.lastDoneBuild; - p Last successfully built: - if lastDoneBuild - DateTime(value=lastDoneBuild.endDate) +div + h1.page-header.clearfix + .pull-right + button.btn.btn-sm.btn-primary.dropdown-toggle( + data-toggle="dropdown", + aria-expanded="false", + disabled="true" + ) + | target revision: + span= this.state.project.scm ? this.state.project.scm.rev : '' + | + if this.state.project.name + button.btn.btn-sm.btn-success(onClick=this.onBuildProject) + i.fa.fa-fw.fa-play | - | (build # - span= lastDoneBuild.number - | ) - else - | - + span Build + div + Scm(scm=this.state.project.scm ? this.state.project.scm.type : '') + span= this.state.project.name - p Current successfully streak: - if lastDoneBuild - span= this.state.project.doneBuildsStreak - else - | - + div.text-muted + - var lastDoneBuild = this.state.project.lastDoneBuild; + p Last successfully built: + if lastDoneBuild + DateTime(value=lastDoneBuild.endDate) + | + | (build # + span= lastDoneBuild.number + | ) + else + | - - p Last build duration: - if lastDoneBuild - Duration(value=(lastDoneBuild.endDate - lastDoneBuild.startDate)) - else - | - + p Current successfully streak: + if lastDoneBuild + span= this.state.project.doneBuildsStreak + else + | - - p Average build duration: - if this.state.project.avgBuildDuration - Duration(value=this.state.project.avgBuildDuration) - else - | - + p Last build duration: + if lastDoneBuild + Duration(value=(lastDoneBuild.endDate - lastDoneBuild.startDate)) + else + | - - h2 - i.fa.fa-fw.fa-history - span - span Build history - Builds(projectName=this.props.params.name) + p Average build duration: + if this.state.project.avgBuildDuration + Duration(value=this.state.project.avgBuildDuration) + else + | - + + h2 + i.fa.fa-fw.fa-history + span + span Build history + + Builds(projectName=this.props.params.name) diff --git a/app/components/terminal/index.jade b/app/components/terminal/index.jade index 304ffa3..35cbd36 100644 --- a/app/components/terminal/index.jade +++ b/app/components/terminal/index.jade @@ -1,3 +1,3 @@ .terminal pre.terminal_code - .terminal_footer(style={height: '30px'}) + .terminal_footer diff --git a/app/styles/components/builds.less b/app/styles/components/builds.less index 8c2e49b..5beb586 100644 --- a/app/styles/components/builds.less +++ b/app/styles/components/builds.less @@ -6,20 +6,48 @@ } } -.builds { - padding: 0 15px; -} +@animation-duration: 1.5s; -.build { - .row(); - padding: 15px 0; - margin-bottom: 3px; - background: @well-bg; +.builds { + &_item { + &:hover { + .builds { + &_buttons { + opacity: 1; + } + } + } + } + + &_inner { + background: @well-bg; + padding: 15px; + } + + &_header { + .make-xs-column(9); + } + + &_controls { + .make-xs-column(3); + .text-right; + } + + &_buttons { + transition: opacity 0.2s ease; + opacity: 0; + } + + &_progress { + .progress { + height: 18px; + margin-bottom: 0; + } + } &_status { float: left; - padding-top: 14px; - margin-right: 13px; + margin-right: 8px; } &_info { @@ -27,126 +55,301 @@ margin-right: 10px; } - &_controls { - .make-md-column(3); - .make-xs-column(3); - .text-right; + &__timeline { + position: relative; - &_buttons { - margin-top: 8px; - transition: opacity 0.2s ease; - opacity: 0; + .builds { + &_inner { + border-left: 6px solid darken(@well-bg, 10%); + } + + &_header { + margin-bottom: 6px; + font-size: 18px; + } + + &_progress { + padding: 3px 0; + } + + &_item { + margin: 4px 0; + position: relative; + + &:after { + content: ''; + position: absolute; + border-radius: 50%; + background: @well-bg; + left: 0; + z-index: 1; + } + + &:before { + content: ''; + position: absolute; + border: 11px solid transparent; + top: 25px; + } + + &__in-progress { + &:after { + background: @brand-info; + + -webkit-animation: pulsate @animation-duration ease-out; + -webkit-animation-iteration-count: infinite; + -moz-animation: pulsate @animation-duration ease-out; + -moz-animation-iteration-count: infinite; + -o-animation: pulsate @animation-duration ease-out; + -o-animation-iteration-count: infinite; + animation: pulsate @animation-duration ease-out; + animation-iteration-count: infinite; + } + } + + &__done { + &:after { + background: @link-color; + } + } + + &__error { + &:after { + background: @brand-danger; + } + } + + &__queued { + &:after { + background: @brand-primary; + } + } + } } - &_progress { - padding-top: 14px; - .progress { - height: 18px; - margin-bottom: 0; + &:after { + content: ''; + position: absolute; + top: 0; + bottom: 0; + width: 2px; + margin-left: -1px; + background: darken(@well-bg, 10%); + } + + &-large { + .builds { + &_item { + padding-left: 40px; + + &:after { + width: 24px; + height: 24px; + top: 25px; + } + + &:before { + left: 20px; + border-right-color: darken(@well-bg, 10%); + top: 25px; + } + } + } + + &:after { + left: 12px; + } + } + + &-small { + .builds { + &_item { + padding-left: 30px; + + &:after { + top: 16px; + height: 16px; + width: 16px; + } + + &:before { + left: 10px; + border-right-color: darken(@well-bg, 10%); + top: 13px; + } + } + + &_header { + font-size: 14px; + .make-xs-column(7); + margin-bottom: 0; + } + + &_controls { + .make-xs-column(5); + font-size: 12px; + } + + &_progress { + padding: 1px 0; + } + } + + &:after { + left: 8px; } } } +} - &_content { - .make-md-column(9); - .make-xs-column(9); - } +@media (min-width: @screen-sm-min) { + .builds { + &__timeline { + &-large { + .builds { + &_item { + padding-left: 0; + display: inline-block; + vertical-align: top; + width: 50%; + margin: 10px 0; - &_header { - margin-bottom: 6px; - font-size: 18px; - a { - font-size: inherit; - } - } + &:after { + left: auto; + } - &:hover { - .build_controls_buttons { - opacity: 1; - } - } + &:before { + left: auto; + border-right-color: transparent; + } + } - &__small { - .build_status { - padding-top: 5px; - } - .build_header { - font-size: 14px; - } - .build_content { - .make-xs-column(7); - } - .build_controls { - .make-xs-column(5); - font-size: 12px; - &_progress { - padding-top: 4px; - .progress { - height: 12px; + &_inner { + border-left: 0; + } + } + + &:after { + left: 50%; + } + } + + &-left { + .builds { + &_item { + &:nth-child(odd) { + padding-right: 30px; + + .builds { + &_inner { + border-right: 6px solid darken(@well-bg, 10%); + } + } + + &:after { + right: -12px; + } + + &:before { + right: 10px; + border-left-color: darken(@well-bg, 10%); + } + } + + &:nth-child(even) { + padding-left: 30px; + top: 50px; + + .builds { + &_inner { + border-left: 6px solid darken(@well-bg, 10%); + } + } + + &:after { + left: -12px; + } + + &:before { + left: 10px; + border-right-color: darken(@well-bg, 10%); + } + } + } + } + } + + &-right { + .builds { + &_item { + &:first-child { + margin-left: 50%; + } + + &:nth-child(even) { + padding-right: 30px; + top: -50px; + + .builds { + &_inner { + border-right: 6px solid darken(@well-bg, 10%); + } + } + + &:after { + right: -12px; + } + + &:before { + right: 10px; + border-left-color: darken(@well-bg, 10%); + } + } + + &:nth-child(odd) { + padding-left: 30px; + + .builds { + &_inner { + border-left: 6px solid darken(@well-bg, 10%); + } + } + + &:after { + left: -12px; + } + + &:before { + left: 10px; + border-right-color: darken(@well-bg, 10%); + } + } + } } } } } } -@animation-duration: 1.5s; - -.status { - width: 25px; - height: 25px; - border-radius: 50%; - - &__in-progress { - background: @brand-info; - - -webkit-animation: pulsate @animation-duration ease-out; - -webkit-animation-iteration-count: infinite; - -moz-animation: pulsate @animation-duration ease-out; - -moz-animation-iteration-count: infinite; - -o-animation: pulsate @animation-duration ease-out; - -o-animation-iteration-count: infinite; - animation: pulsate @animation-duration ease-out; - animation-iteration-count: infinite; - } - &__done { - background: @link-color; - } - &__error { - background: @brand-danger; - } - &__queued { - background: @brand-primary; - } - - &__small { - width: 10px; - height: 10px; - } -} - .pulsate-frames() { .transform(@scaleX, @scaleY) { - -webkit-transform: scale(@scaleX, @scaleY); opacity: 1; - -moz-transform: scale(@scaleX, @scaleY); opacity: 1; - -ms-transform: scale(@scaleX, @scaleY); opacity: 1; - -o-transform: scale(@scaleX, @scaleY); opacity: 1; - transform: scale(@scaleX, @scaleY); opacity: 1; + -webkit-transform: scale3d(@scaleX, @scaleY, 1); + -moz-transform: scale3d(@scaleX, @scaleY, 1); + -ms-transform: scale3d(@scaleX, @scaleY, 1); + -o-transform: scale3d(@scaleX, @scaleY, 1); + transform: scale3d(@scaleX, @scaleY, 1); } 0% { - .transform(1.0, 1.0); - } - 25% { - opacity: 1.0; + .transform(1, 1); } + 50% { .transform(0.75, 0.75); } - 50% { - opacity: 1.0; - } + 100% { - .transform(1.0, 1.0); + .transform(1, 1); } } diff --git a/app/styles/components/layout.less b/app/styles/components/layout.less index b1f864c..9337f27 100644 --- a/app/styles/components/layout.less +++ b/app/styles/components/layout.less @@ -1,7 +1,18 @@ body { font-family: 'Open Sans', sans-serif; + padding-top: @navbar-height; + min-width: 320px; } .page-wrapper { - margin-top: @navbar-height + 15px; + padding: 20px 0; } + +.page-header { + margin-top: 0; + border-bottom-color: #d7d7d7; + + .small { + font-size: @font-size-base; + } +} \ No newline at end of file diff --git a/db.js b/db.js index e2873fd..f0a0b88 100644 --- a/db.js +++ b/db.js @@ -210,7 +210,9 @@ nlevel.DocsSection.prototype._beforePut = function(docs, callback) { // update createDate before put to provide latest date for last id // it's rquired for correct generateIds function _(docs).each(function(doc) { - doc.createDate = Date.now(); + if (!doc.id) { + doc.createDate = Date.now(); + } }); self.beforePut(docs, callback); diff --git a/lib/distributor.js b/lib/distributor.js index 42076b1..780ef0b 100644 --- a/lib/distributor.js +++ b/lib/distributor.js @@ -182,10 +182,21 @@ Distributor.prototype._onBuildComplete = function(build, callback) { Distributor.prototype._updateBuild = function(build, changes, callback) { var self = this; callback = callback || _.noop; - var isWithNumber = Boolean(build.number); + var isWithId = Boolean(build.id), + isWithNumber = Boolean(build.number); Steppy( function() { + if (build.id && changes.status && changes.status !== build.status) { + logger.log( + 'Build #%s (project "%s") change status: %s -> %s', + build.id, + build.project.name, + build.status, + changes.status + ); + } + _(build).extend(changes); // skip saving to db of unimportant data @@ -196,6 +207,14 @@ Distributor.prototype._updateBuild = function(build, changes, callback) { } }, function() { + if (!isWithId && build.id) { + logger.log( + 'Build #%s (project "%s") %s', + build.id, + build.project.name, + build.status + ); + } // if number appear after save to db then add it to changes // TODO: might be better to generate number right there (instead diff --git a/lib/scm/git.js b/lib/scm/git.js index b3ca2f4..948de55 100644 --- a/lib/scm/git.js +++ b/lib/scm/git.js @@ -133,12 +133,26 @@ Scm.prototype.getChanges = function(rev1, rev2, callback) { var self = this; Steppy( function() { + // get current rev to update on it after git log + self.getCurrent(this.slot()); + }, + function(err, currentRev) { + this.pass(currentRev); + + // update to rev2 to prevent git error when switch to branch that + // doesn't exist locally: "unknown revision or path not in the + // working tree" + self.update(rev2, this.slot()); + }, + function(err, currentRev) { + this.pass(currentRev); + self.run({cmd: 'git', args: [ 'log', rev1 ? rev1 + '..' + rev2 : rev2, '--pretty=' + self._revTemplate ]}, this.slot()); }, - function(err, stdout) { + function(err, currentRev, stdout) { // always skip last line - it's empty var rows = stdout.split('\n').slice(0, -1); @@ -146,7 +160,12 @@ Scm.prototype.getChanges = function(rev1, rev2, callback) { return self._parseRev(str); }); + this.pass(currentRev, changes); + }, + function(err, currentRev, changes) { this.pass(changes); + + self.update(currentRev.id, this.slot()); }, callback ); diff --git a/lib/scm/mercurial.js b/lib/scm/mercurial.js index 2cf50c7..c69a4e2 100644 --- a/lib/scm/mercurial.js +++ b/lib/scm/mercurial.js @@ -15,13 +15,9 @@ inherits(Scm, ParentScm); Scm.prototype.defaultRev = 'default'; -// use 2 invisible separators as fields separator -Scm.prototype._fieldsSeparator = String.fromCharCode(2063); -Scm.prototype._fieldsSeparator += Scm.prototype._fieldsSeparator; - -// use 2 vertical tabs as arrays separator -Scm.prototype._arraysSeparator = String.fromCharCode(11); -Scm.prototype._arraysSeparator += Scm.prototype._arraysSeparator; +Scm.prototype._arraysSeparator = '\u2064' + '\u2064'; +Scm.prototype._fieldsSeparator = '\u2063' + '\u2063'; +Scm.prototype._linesSeparator = '\u2028' + '\u2028'; Scm.prototype._revTemplate = [ '{node|short}', @@ -94,13 +90,15 @@ Scm.prototype.getChanges = function(rev1, rev2, callback) { function() { self.run({cmd: 'hg', args: [ 'log', '--rev', rev2 + ':' + rev1, - '--template', self._revTemplate + '\n' + '--template', self._revTemplate + self._linesSeparator ]}, this.slot()); }, function(err, stdout) { // always skip last line - it's empty and also skip first // rev if we see range - var rows = stdout.split('\n').slice(0, rev1 === rev2 ? -1 : -2); + var rows = stdout.split(self._linesSeparator).slice( + 0, rev1 === rev2 ? -1 : -2 + ); var changes = _(rows).map(function(str) { return self._parseRev(str); diff --git a/package.json b/package.json index bebfda1..ab92625 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "nci", - "version": "0.3.6", + "version": "0.4.1", "description": "Continuous integration server written in node.js", "bin": { "nci": "bin/nci" diff --git a/static/images/preloader.gif b/static/images/preloader.gif new file mode 100644 index 0000000..a531cf9 Binary files /dev/null and b/static/images/preloader.gif differ diff --git a/static/js/app/components/builds/list.js b/static/js/app/components/builds/list.js new file mode 100644 index 0000000..361528d --- /dev/null +++ b/static/js/app/components/builds/list.js @@ -0,0 +1,39 @@ +'use strict'; + +define([ + 'react', + 'reflux', + 'underscore', + './item', + 'app/actions/build', + 'app/stores/builds', + 'templates/app/components/builds/list' +], function(React, Reflux, _, Item, BuildActions, buildsStore, template) { + template = template.locals({ + Item: Item + }); + + var Component = React.createClass({ + mixins: [ + Reflux.connectFilter(buildsStore, 'items', function(items) { + var projectName = this.props.projectName; + if (projectName) { + return _(items).filter(function(item) { + return item.project && item.project.name === projectName; + }); + } else { + return items; + } + }) + ], + onShowMoreBuilds: function(projectName) { + BuildActions.readAll({ + projectName: projectName, + limit: this.state.items.length + 20 + }); + }, + render: template + }); + + return Component; +}); diff --git a/static/js/app/components/builds/view.js b/static/js/app/components/builds/view.js new file mode 100644 index 0000000..0531024 --- /dev/null +++ b/static/js/app/components/builds/view.js @@ -0,0 +1,66 @@ +'use strict'; + +define([ + 'react', + 'react-router', + 'reflux', + 'app/actions/build', + 'app/stores/build', + 'app/components/terminal/terminal', + 'app/components/buildSidebar/index', + 'templates/app/components/builds/view', + 'app/components/common/index' +], function( + React, Router, Reflux, BuildActions, buildStore, TerminalComponent, + BuildSidebar, template, CommonComponents +) { + template = template.locals({ + DateTime: CommonComponents.DateTime, + Duration: CommonComponents.Duration, + Scm: CommonComponents.Scm, + Terminal: TerminalComponent, + Link: Router.Link, + BuildSidebar: BuildSidebar + }); + + var Component = React.createClass({ + mixins: [Reflux.ListenerMixin], + statics: { + willTransitionTo: function(transition, params, query) { + BuildActions.read(Number(params.id)); + } + }, + componentDidMount: function() { + this.listenTo(buildStore, this.updateBuild); + }, + componentWillReceiveProps: function(nextProps) { + // reset console status when go from build page to another build + // page (did mount and mount not called in this case) + if (Number(nextProps.params.id) !== this.state.build.id) { + this.setState({showConsole: this.getInitialState().showConsole}); + } + }, + updateBuild: function(build) { + if (build) { + BuildActions.readAll({projectName: build.project.name}); + } + this.setState({build: build}); + }, + render: template, + getInitialState: function() { + return { + build: null, + showConsole: false + }; + }, + toggleConsole: function() { + var consoleState = !this.state.showConsole; + if (consoleState) { + BuildActions.readTerminalOutput(this.state.build); + } + this.setState({showConsole: consoleState}); + } + }); + + return Component; +}); diff --git a/static/js/app/components/terminal/terminal.js b/static/js/app/components/terminal/terminal.js new file mode 100644 index 0000000..83ca011 --- /dev/null +++ b/static/js/app/components/terminal/terminal.js @@ -0,0 +1,131 @@ +'use strict'; + +define([ + 'underscore', + 'react', + 'reflux', + 'app/stores/terminal', + 'app/stores/build', + 'ansi_up', + 'templates/app/components/terminal/terminal' +], function( + _, + React, + Reflux, + terminalStore, + buildStore, + ansiUp, + template +) { + var Component = React.createClass({ + mixins: [Reflux.ListenerMixin], + + shouldScrollBottom: true, + data: [], + linesCount: 0, + + componentDidMount: function() { + this.listenTo(terminalStore, this.updateItems); + var node = document.getElementsByClassName('terminal')[0]; + this.initialScrollPosition = node.getBoundingClientRect().top; + if (this.props.showPreloader) { + this.getTerminal().insertAdjacentHTML('afterend', + '' + ); + + this.listenTo(buildStore, function(build) { + if (build.completed) { + this.removePreloader(); + } + }); + } + + window.onscroll = this.onScroll; + }, + removePreloader: function() { + var preloader = document.getElementsByClassName( + 'terminal_preloader' + )[0]; + preloader.parentNode.removeChild(preloader); + }, + componentWillUnmount: function() { + window.onscroll = null; + }, + prepareRow: function(row) { + return ansiUp.ansi_to_html(row.replace('\r', '')); + }, + prepareOutput: function(output) { + var self = this; + return output.map(function(row) { + return self.prepareRow(row); + }); + }, + getTerminal: function() { + return document.getElementsByClassName('terminal')[0]; + }, + getBody: function() { + return document.getElementsByTagName('body')[0]; + }, + onScroll: function() { + var node = this.getTerminal(), + body = this.getBody(); + + this.shouldScrollBottom = window.innerHeight + body.scrollTop >= + node.offsetHeight + this.initialScrollPosition; + }, + ensureScrollPosition: function() { + if (this.shouldScrollBottom) { + var node = this.getTerminal(), + body = this.getBody(); + + body.scrollTop = this.initialScrollPosition + node.offsetHeight; + } + }, + makeCodeLineContent: function(line) { + return '' + '' + + '
' + this.prepareRow(line) + '
'; + }, + makeCodeLine: function(line, index) { + return '
' + + this.makeCodeLineContent(line) + '
'; + }, + renderBuffer: _.throttle(function() { + var data = this.data, + currentLinesCount = data.length, + terminal = document.getElementsByClassName('terminal_code')[0], + rows = terminal.childNodes; + + if (rows.length) { + // replace our last node + var index = this.linesCount - 1; + rows[index].innerHTML = this.makeCodeLineContent(data[index]); + } + + var self = this; + terminal.insertAdjacentHTML('beforeend', + _(data.slice(this.linesCount)).map(function(line, index) { + return self.makeCodeLine(line, self.linesCount + index); + }).join('') + ); + + this.linesCount = currentLinesCount; + this.ensureScrollPosition(); + }, 100), + updateItems: function(build) { + // listen just our console update + if (build.buildId === this.props.build) { + this.data = build.data; + this.renderBuffer(); + } + if (this.props.showPreloader && build.buildCompleted) { + this.removePreloader(); + } + }, + shouldComponentUpdate: function() { + return false; + }, + render: template + }); + + return Component; +}); diff --git a/static/js/app/stores/terminal.js b/static/js/app/stores/terminal.js new file mode 100644 index 0000000..7082b0a --- /dev/null +++ b/static/js/app/stores/terminal.js @@ -0,0 +1,66 @@ +'use strict'; + +define([ + 'underscore', 'reflux', 'app/actions/build', 'app/connect' +], function( + _, Reflux, BuildActions, connect +) { + var Store = Reflux.createStore({ + listenables: BuildActions, + + init: function() { + // the only purpose of this hash to reconnect all the time + // except first, see notes at using + this.connectedResourcesHash = {}; + }, + + onReadTerminalOutput: function(build) { + var self = this, + output = [], + resourceName = 'build' + build.id; + + var connectToBuildDataResource = function() { + // reconnect for get data below (at subscribe), coz + // data emitted only once during connect + if (self.connectedResourcesHash[resourceName]) { + connect.resource(resourceName).reconnect(); + } else { + self.connectedResourcesHash[resourceName] = 1; + } + + connect.resource(resourceName).subscribe('data', function(data) { + var lastLine = _(self.lines).last(); + if (lastLine && (_(data.lines).first().number === lastLine.number)) { + self.lines = _(self.lines).initial(); + } + self.lines = self.lines.concat(data.lines); + self.trigger({ + buildId: build.id, + buildCompleted: build.completed, + name: 'Console for build #' + build.id, + data: _(self.lines).pluck('text') + }); + }); + }; + + this.lines = []; + this.currentLine = ''; + + // create data resource for completed build + if (build.completed) { + connect.resource('projects').sync( + 'createBuildDataResource', + {buildId: build.id}, + function(err) { + if (err) throw err; + connectToBuildDataResource(); + } + ); + } else { + connectToBuildDataResource(); + } + } + }); + + return Store; +}); diff --git a/test/db/concurrent.js b/test/db/concurrent.js index eb63556..d4f74b1 100644 --- a/test/db/concurrent.js +++ b/test/db/concurrent.js @@ -37,7 +37,7 @@ describe('Db concurrency', function() { ); }); - describe('prallel builds put should produce different ids', function() { + describe('prallel builds add should produce different ids', function() { var expectedIds = []; var builds = _(100).chain().range().map(function(number) { @@ -45,7 +45,7 @@ describe('Db concurrency', function() { return makeBuild({project: {name: 'project' + number}}); }).value(); - it('put two builds in parallel without errors', function(done) { + it('put builds in parallel without errors', function(done) { Steppy( function() { var putGroup = this.makeGroup(); @@ -68,6 +68,43 @@ describe('Db concurrency', function() { }); }); + describe('prallel builds add/update should produce different ids', function() { + + var expectedIds = []; + var builds = _(200).chain().range().map(function(number) { + expectedIds.push(number + 1); + return makeBuild({project: {name: 'project' + number}}); + }).value(); + + it('put builds in parallel without errors', function(done) { + Steppy( + function() { + var putGroup = this.makeGroup(); + _(builds.slice(0, 190)).each(function(build) { + db.builds.put(build, putGroup.slot()); + }); + }, + function() { + var putGroup = this.makeGroup(); + _(builds).each(function(build) { + db.builds.put(build, putGroup.slot()); + }); + }, + done + ); + }); + + it('shoud have all ' + expectedIds.length +' ids ', function() { + expect(_(builds).chain().pluck('id').sortBy().value()).eql( + expectedIds + ); + }); + + after(function(done) { + db.builds.del(expectedIds, done); + }); + }); + describe('prallel builds put should produce different numbers', function() { var expectedIds = []; @@ -79,7 +116,7 @@ describe('Db concurrency', function() { }); }).value(); - it('put three builds in parallel without errors', function(done) { + it('put builds in parallel without errors', function(done) { Steppy( function() { var putGroup = this.makeGroup();