From 9db950e554f212e4fa42aa2fee4629e1548b3ab1 Mon Sep 17 00:00:00 2001 From: Pierre Tardy Date: Thu, 13 Apr 2017 17:01:30 +0200 Subject: [PATCH] add tag sorting --- .../src/module/builders.fixture.json | 1 + www/console_view/src/module/console.tpl.jade | 33 ++-- .../src/module/main.module.coffee | 183 ++++++++++++++++-- .../src/module/main.module.spec.coffee | 12 ++ www/console_view/src/styles/styles.less | 46 +++-- 5 files changed, 229 insertions(+), 46 deletions(-) create mode 100644 www/console_view/src/module/builders.fixture.json diff --git a/www/console_view/src/module/builders.fixture.json b/www/console_view/src/module/builders.fixture.json new file mode 100644 index 000000000000..c5a7c6270c35 --- /dev/null +++ b/www/console_view/src/module/builders.fixture.json @@ -0,0 +1 @@ +{"builders":[{"builderid":1,"description":null,"masterids":[1],"name":"buildbot-job","tags":["job","buildbot"]},{"builderid":2,"description":null,"masterids":[1],"name":"buildbot","tags":["buildbot","trunk"]},{"builderid":3,"description":null,"masterids":[],"name":"buildbot SQLALCHEMY:latest TESTS:pylint TWISTED:latest python:2.7","tags":["buildbot","SQLALCHEMY:latest","TESTS:pylint","TWISTED:latest","python:2.7"]},{"builderid":4,"description":null,"masterids":[],"name":"buildbot SQLALCHEMY:latest TESTS:flake8 TWISTED:latest python:2.7","tags":["buildbot","SQLALCHEMY:latest","TWISTED:latest","python:2.7","TESTS:flake8"]},{"builderid":5,"description":null,"masterids":[],"name":"buildbot SQLALCHEMY:latest TESTS:isort TWISTED:latest python:2.7","tags":["buildbot","SQLALCHEMY:latest","TWISTED:latest","python:2.7","TESTS:isort"]},{"builderid":6,"description":null,"masterids":[],"name":"buildbot SQLALCHEMY:latest TESTS:docs TWISTED:latest python:2.7","tags":["buildbot","SQLALCHEMY:latest","TWISTED:latest","python:2.7","TESTS:docs"]},{"builderid":7,"description":null,"masterids":[],"name":"buildbot SQLALCHEMY:latest TESTS:coverage TWISTED:latest python:2.7","tags":["buildbot","SQLALCHEMY:latest","TWISTED:latest","python:2.7","TESTS:coverage"]},{"builderid":8,"description":null,"masterids":[],"name":"buildbot SQLALCHEMY:latest TESTS:js TWISTED:latest python:2.7","tags":["buildbot","SQLALCHEMY:latest","TWISTED:latest","python:2.7","TESTS:js"]},{"builderid":9,"description":null,"masterids":[],"name":"buildbot SQLALCHEMY:latest TESTS:smokes TWISTED:latest python:2.7","tags":["buildbot","SQLALCHEMY:latest","TWISTED:latest","python:2.7","TESTS:smokes"]},{"builderid":10,"description":null,"masterids":[],"name":"buildbot SQLALCHEMY:latest TESTS:trial TWISTED:14.0.2 python:2.7","tags":["buildbot","SQLALCHEMY:latest","python:2.7","TESTS:trial","TWISTED:14.0.2"]},{"builderid":11,"description":null,"masterids":[],"name":"buildbot SQLALCHEMY:latest TESTS:trial TWISTED:15.4.0 python:2.7","tags":["buildbot","SQLALCHEMY:latest","python:2.7","TESTS:trial","TWISTED:15.4.0"]},{"builderid":12,"description":null,"masterids":[],"name":"buildbot SQLALCHEMY:latest TESTS:trial TWISTED:latest python:2.7","tags":["buildbot","SQLALCHEMY:latest","TWISTED:latest","python:2.7","TESTS:trial"]},{"builderid":13,"description":null,"masterids":[],"name":"buildbot DB_TYPE:sqlite SQLALCHEMY:latest TESTS:trial TWISTED:latest python:2.7","tags":["buildbot","SQLALCHEMY:latest","TWISTED:latest","python:2.7","TESTS:trial","DB_TYPE:sqlite"]},{"builderid":14,"description":null,"masterids":[],"name":"buildbot DB_TYPE:mysql SQLALCHEMY:latest TESTS:trial TWISTED:latest python:2.7","tags":["buildbot","SQLALCHEMY:latest","TWISTED:latest","python:2.7","TESTS:trial","DB_TYPE:mysql"]},{"builderid":15,"description":null,"masterids":[],"name":"buildbot SQLALCHEMY:0.8.0 TESTS:trial TWISTED:15.5.0 python:2.7","tags":["buildbot","python:2.7","TESTS:trial","SQLALCHEMY:0.8.0","TWISTED:15.5.0"]},{"builderid":16,"description":null,"masterids":[],"name":"buildbot SQLALCHEMY:latest TESTS:trial TWISTED:15.5.0 python:2.7","tags":["buildbot","SQLALCHEMY:latest","python:2.7","TESTS:trial","TWISTED:15.5.0"]},{"builderid":17,"description":null,"masterids":[],"name":"buildbot SQLALCHEMY:latest TESTS:trial_worker TWISTED:10.2.0 python:2.7","tags":["buildbot","SQLALCHEMY:latest","python:2.7","TESTS:trial_worker","TWISTED:10.2.0"]},{"builderid":18,"description":null,"masterids":[],"name":"buildbot SQLALCHEMY:latest TESTS:trial_worker TWISTED:11.1.0 python:2.7","tags":["buildbot","SQLALCHEMY:latest","python:2.7","TESTS:trial_worker","TWISTED:11.1.0"]},{"builderid":19,"description":null,"masterids":[],"name":"buildbot SQLALCHEMY:latest TESTS:trial_worker TWISTED:12.2.0 python:2.7","tags":["buildbot","SQLALCHEMY:latest","python:2.7","TESTS:trial_worker","TWISTED:12.2.0"]},{"builderid":20,"description":null,"masterids":[],"name":"buildbot SQLALCHEMY:latest TESTS:trial_worker TWISTED:13.2.0 python:2.7","tags":["buildbot","SQLALCHEMY:latest","python:2.7","TESTS:trial_worker","TWISTED:13.2.0"]},{"builderid":21,"description":null,"masterids":[],"name":"buildbot SQLALCHEMY:latest TESTS:trial_worker TWISTED:14.0.2 python:2.6","tags":["buildbot","SQLALCHEMY:latest","TWISTED:14.0.2","TESTS:trial_worker","python:2.6"]},{"builderid":22,"description":null,"masterids":[],"name":"buildbot SQLALCHEMY:latest TESTS:trial_worker TWISTED:15.4.0 python:2.6","tags":["buildbot","SQLALCHEMY:latest","TWISTED:15.4.0","TESTS:trial_worker","python:2.6"]},{"builderid":23,"description":null,"masterids":[],"name":"buildbot SQLALCHEMY:latest TESTS:coverage TWISTED:trunk python:3.5","tags":["buildbot","SQLALCHEMY:latest","TESTS:coverage","TWISTED:trunk","python:3.5"]},{"builderid":24,"description":null,"masterids":[],"name":"buildbot SQLALCHEMY:latest TESTS:flake8 TWISTED:trunk python:3.5","tags":["buildbot","SQLALCHEMY:latest","TESTS:flake8","TWISTED:trunk","python:3.5"]},{"builderid":25,"description":null,"masterids":[],"name":"buildbot SQLALCHEMY:latest TESTS:smokes TWISTED:latest python:3.5","tags":["buildbot","SQLALCHEMY:latest","TWISTED:latest","TESTS:smokes","python:3.5"]},{"builderid":26,"description":null,"masterids":[],"name":"buildbot SQLALCHEMY:latest TESTS:trial TWISTED:trunk python:3.6","tags":["buildbot","SQLALCHEMY:latest","TESTS:trial","TWISTED:trunk","python:3.6"]}],"meta":{"total":26}} diff --git a/www/console_view/src/module/console.tpl.jade b/www/console_view/src/module/console.tpl.jade index 1094862970a5..f4a1038fe879 100644 --- a/www/console_view/src/module/console.tpl.jade +++ b/www/console_view/src/module/console.tpl.jade @@ -10,24 +10,27 @@ table.table.table-striped.table-bordered(ng-hide="c.changes.length==0" ng-class="{'table-fixedwidth': c.isBigTable()}") tr.first-row - td.row-header(ng-style="{'width': c.getRowHeaderWidth()}") - i.fa.fa-plus-circle.pull-right(ng-click='c.openAll()' uib-tooltip='Open information for all changes' uib-tooltip-placement='right') - i.fa.fa-minus-circle.pull-right(ng-click='c.closeAll()' uib-tooltip='Close information for all changes' uib-tooltip-placement='right') - td.column(ng-repeat="builder in c.builders | orderBy: 'name' track by builder.builderid") - a.builder( - ng-href='#/builders/{{ builder.builderid }}' - ng-bind='builder.name') + th.row-header(ng-style="{'width': c.getRowHeaderWidth()}") + i.fa.fa-plus-circle.pull-left(ng-click='c.openAll()' uib-tooltip='Open information for all changes' uib-tooltip-placement='right') + i.fa.fa-minus-circle.pull-left(ng-click='c.closeAll()' uib-tooltip='Close information for all changes' uib-tooltip-placement='right') + th.column(ng-repeat="builder in c.builders") + span.builder(ng-style="{'margin-top': c.getColHeaderHeight()}") + a(ng-href='#/builders/{{ builder.builderid }}' + ng-bind='builder.name') + tr.tag_row(ng-repeat="tag_line in c.tag_lines") + td.row-header + td(ng-repeat="tag in tag_line" colspan="{{tag.colspan}}") + span(uib-tooltip='{{ tag.tag }}' ng-style='{width: tag.colspan*50}') {{tag.tag}} tr(ng-repeat="change in c.filtered_changes | orderBy: ['-changeid'] track by change.changeid") td - .change-avatar - img(ng-src="avatar?email={{change.author_email}}") - i.fa.fa-chevron-circle-right(ng-click='c.toggleInfo(change)' ng-class="{'fa-rotate-90': c.infoIsExpanded(change)}") - span(uib-tooltip='{{ change.comments }}' uib-tooltip-placement='top') + .change-avatar(uib-tooltip='{{ change.author }}') a(ng-if='change.author_email' ng-href='mailto: {{change.author_email}}' target='_blank') - | {{ change.author_name }} - span(ng-if='!change.author_email') {{ change.author }} + img(ng-src="avatar?email={{change.author_email}}") + img(ng-if='!change.author_email' ng-src="avatar?email={{change.author_email}}" de) + span(ng-click='c.toggleInfo(change)' uib-tooltip='{{ change.comments }}' uib-tooltip-placement='top') {{ change.subject }}  + i.fa.fa-chevron-circle-right(ng-class="{'fa-rotate-90': c.infoIsExpanded(change)}") div(ng-show='c.infoIsExpanded(change)') // Comment .info @@ -43,9 +46,9 @@ i.fa.fa-file-o p(ng-repeat='file in change.files') | {{ file }} - td.column(ng-repeat="builder in change.builders | orderBy: ['name'] track by builder.builderid" + td.column(ng-repeat="builder in change.builders" title="{{builder.name}}") - a(ng-repeat="build in builder.builds | orderBy: ['started_at']") + a(ng-repeat="build in builder.builds | orderBy: ['number']") span.badge-status(ng-if='build.buildid' ng-class="c.results2class(build, 'pulse')" ng-click='c.selectBuild(build)') diff --git a/www/console_view/src/module/main.module.coffee b/www/console_view/src/module/main.module.coffee index 6808a7fe8bf1..de771632641f 100644 --- a/www/console_view/src/module/main.module.coffee +++ b/www/console_view/src/module/main.module.coffee @@ -55,7 +55,8 @@ class State extends Config ] class Console extends Controller - constructor: (@$scope, $q, @$window, dataService, bbSettingsService, resultsService, @$uibModal) -> + constructor: (@$scope, $q, @$window, dataService, bbSettingsService, resultsService, + @$uibModal, @$timeout) -> angular.extend this, resultsService settings = bbSettingsService.getSettingsGroup('Console') @buildLimit = settings.buildLimit.value @@ -64,11 +65,21 @@ class Console extends Controller @_infoIsExpanded = {} @$scope.all_builders = @all_builders = @dataAccessor.getBuilders() @$scope.builders = @builders = [] + if Intl? + collator = new Intl.Collator(undefined, {numeric: true, sensitivity: 'base'}) + @strcompare = collator.compare + else + @strcompare = (a, b) -> + if a < b + return -1 + if a == b + return 0 + return 1 @$scope.builds = @builds = @dataAccessor.getBuilds property: ["got_revision"] limit: @buildLimit - order: '-complete_at' + order: '-started_at' @changes = @dataAccessor.getChanges({limit: @changeLimit, order: '-changeid'}) @buildrequests = @dataAccessor.getBuildrequests({limit: @buildLimit, order: '-submitted_at'}) @buildsets = @dataAccessor.getBuildsets({limit: @buildLimit, order: '-submitted_at'}) @@ -80,24 +91,16 @@ class Console extends Controller if @builds.length == 0 or @all_builders.length == 0 or @changes.length == 0 or @buildsets.length == 0 or @buildrequests == 0 return + if not @onchange_debounce? + @onchange_debounce = @$timeout(@_onChange, 500) + _onChange: => + @onchange_debounce = undefined # we only display builders who actually have builds for build in @builds @all_builders.get(build.builderid).hasBuild = true - @builders = [] - for builder in @all_builders - if builder.hasBuild - @builders.push(builder) - @$scope.builders = @builders - if Intl? - collator = new Intl.Collator(undefined, {numeric: true, sensitivity: 'base'}) - compare = collator.compare - else - compare = (a, b) -> - return a < b ? -1 : a > b; - @builders.sort (a, b) -> compare(a.name, b.name) - + @sortBuildersByTags(@all_builders) @changesBySSID = {} for change in @changes @@ -110,18 +113,144 @@ class Console extends Controller @filtered_changes = [] for ssid, change of @changesBySSID + if change.comments + change.subject = change.comments.split("\n")[0] for builder in change.builders - if builder.builds.length>0 + if builder.builds.length > 0 @filtered_changes.push(change) break ### + # Sort builders by tags + # Buildbot eight has the category option, but it was only limited to one category per builder, + # which make it easy to sort by category + # Here, we have multiple tags per builder, we need to try to group builders with same tags together + # The algorithm is rather twisted. It is a first try at the concept of grouping builders by tags.. + ### + + sortBuildersByTags: (all_builders) -> + # first we only want builders with builds + builders_with_builds = [] + builderids_with_builds = "" + for builder in all_builders + if builder.hasBuild + builders_with_builds.push(builder) + builderids_with_builds += "." + builder.builderid + + if builderids_with_builds == @last_builderids_with_builds + # dont recalculate if it hasn't changed! + return + # we call recursive function, which finds non-overlapping groups + tag_line = @_sortBuildersByTags(builders_with_builds) + # we get a tree of builders grouped by tags + # we now need to flatten the tree, in order to build several lines of tags + # (each line is representing a depth in the tag tree) + # we walk the tree left to right and build the list of builders in the tree order, and the tag_lines + # in the tree, there are groups of remaining builders, which could not be grouped together, + # those have the empty tag '' + tag_lines = [] + + sorted_builders = [] + set_tag_line = (depth, tag, colspan) -> + # we build the tag lines by using a sparse array + _tag_line = tag_lines[depth] + if not _tag_line? + # initialize the sparse array + _tag_line = tag_lines[depth] = [] + else + # if we were already initialized, look at the last tag if this is the same + # we merge the two entries + last_tag = _tag_line[_tag_line.length - 1] + if last_tag.tag == tag + last_tag.colspan += colspan + return + _tag_line.push(tag: tag, colspan: colspan) + self = @ + # recursive tree walking + walk_tree = (tag, depth) -> + set_tag_line(depth, tag.tag, tag.builders.length) + if not tag.tag_line? or tag.tag_line.length == 0 + # this is the leaf of the tree, sort by buildername, and add them to the + # list of sorted builders + tag.builders.sort (a, b) -> self.strcompare(a.name, b.name) + sorted_builders = sorted_builders.concat(tag.builders) + for i in [1..100] # set the remaining depth of the tree to the same colspan + # (we hardcode the maximum depth for now :/ ) + set_tag_line(depth + i, '', tag.builders.length) + return + for _tag in tag.tag_line + walk_tree(_tag, depth + 1) + + for tag in tag_line + walk_tree(tag, 0) + + @builders = sorted_builders + @tag_lines = [] + # make a new array to avoid it to be sparse, and to remove lines filled with null tags + for tag_line in tag_lines + if not (tag_line.length == 1 and tag_line[0].tag == "") + @tag_lines.push(tag_line) + @last_builderids_with_builds = builderids_with_builds + ### + # recursive function which sorts the builders by tags + # call recursively with groups of builders smaller and smaller + ### + _sortBuildersByTags: (all_builders) -> + + # first find out how many builders there is by tags in that group + builders_by_tags = {} + for builder in all_builders + if builder.tags? + for tag in builder.tags + if not builders_by_tags[tag]? + builders_by_tags[tag] = [] + builders_by_tags[tag].push(builder) + tags = [] + for tag, builders of builders_by_tags + # we dont want the tags that are on all the builders + if builders.length < all_builders.length + tags.push(tag: tag, builders: builders) + + # sort the tags to first look at tags with the larger number of builders + # @FIXME maybe this is not the best method to find the best groups + tags.sort (a, b) -> b.builders.length - a.builders.length + + tag_line = [] + chosen_builderids = {} + # pick the tags one by one, by making sure we make non-overalaping groups + for tag in tags + excluded = false + for builder in tag.builders + if chosen_builderids.hasOwnProperty(builder.builderid) + excluded = true + break + if not excluded + for builder in tag.builders + chosen_builderids[builder.builderid] = tag.tag + tag_line.push(tag) + + # some builders do not have tags, we put them in another group + remaining_builders = [] + for builder in all_builders + if not chosen_builderids.hasOwnProperty(builder.builderid) + remaining_builders.push(builder) + + if remaining_builders.length + tag_line.push(tag: "", builders: remaining_builders) + + # if there is more than one tag in this line, we need to recurse + if tag_line.length > 1 + for tag in tag_line + tag.tag_line = @_sortBuildersByTags(tag.builders) + return tag_line + + ### # fill a change with a list of builders ### populateChange: (change) -> change.builders = [] change.buildersById = {} for builder in @builders - builder = builderid:builder.builderid, name:builder.name, builds: [] + builder = builderid: builder.builderid, name: builder.name, builds: [] change.builders.push(builder) change.buildersById[builder.builderid] = builder ### @@ -132,6 +261,8 @@ class Console extends Controller if not buildrequest? return buildset = @buildsets.get(buildrequest.buildsetid) + if not buildset? + return if buildset? and buildset.sourcestamps? for sourcestamp in buildset.sourcestamps change = @changesBySSID[sourcestamp.ssid] @@ -148,7 +279,12 @@ class Console extends Controller makeFakeChange: (codebase, revision) => change = @changesBySSID[revision] if not change? - change = codebase: codebase, revision: revision, changeid: revision, author: "unknown author for " + revision + change = + codebase: codebase + revision: revision + changeid: revision + author: "unknown author for " + revision + comment: revision @changesBySSID[revision] = change @populateChange(change) return change @@ -175,6 +311,15 @@ class Console extends Controller return 400 # magic value enough to hold 78 characters lines else return 200 + ### + # Calculate col header (aka first row) height + # It depends on the length of the longest builder + ### + getColHeaderHeight: -> + max_buildername = 0 + for builder in @builders + max_buildername = Math.max(builder.name.length, max_buildername) + return Math.max(100, max_buildername * 3) ### # @@ -216,7 +361,7 @@ class Console extends Controller # toggle display of additional info for that change # ### - toggleInfo: (change)-> + toggleInfo: (change) -> @_infoIsExpanded[change.changeid] = !@_infoIsExpanded[change.changeid] infoIsExpanded: (change) -> return @_infoIsExpanded[change.changeid] diff --git a/www/console_view/src/module/main.module.spec.coffee b/www/console_view/src/module/main.module.spec.coffee index 9be8611f90b9..578dfb5359dd 100644 --- a/www/console_view/src/module/main.module.spec.coffee +++ b/www/console_view/src/module/main.module.spec.coffee @@ -142,6 +142,7 @@ describe 'Console view controller', -> it 'should bind the builds, builders, changes, buildrequests and buildsets to scope', -> createController() $rootScope.$digest() + $timeout.flush() expect(scope.c.builds).toBeDefined() expect(scope.c.builds.length).toBe(builds.length) expect(scope.c.all_builders).toBeDefined() @@ -156,6 +157,8 @@ describe 'Console view controller', -> it 'should match the builds with the change', -> createController() $timeout.flush() + $rootScope.$digest() + $timeout.flush() expect(scope.c.changes[0]).toBeDefined() expect(scope.c.changes[0].builders).toBeDefined() builders = scope.c.changes[0].builders @@ -163,3 +166,12 @@ describe 'Console view controller', -> expect(builders[1].builds[0].buildid).toBe(2) expect(builders[2].builds[0].buildid).toBe(4) expect(builders[3].builds[0].buildid).toBe(3) + + xit 'should match sort the builders by tag groups', -> + createController() + _builders = FIXTURES['builders.fixture.json'].builders + for builder in _builders + builder.hasBuild = true + scope.c.sortBuildersByTags(_builders) + expect(_builders.length).toBe(scope.c.builders.length) + expect(scope.c.tag_lines.length).toEqual(5) diff --git a/www/console_view/src/styles/styles.less b/www/console_view/src/styles/styles.less index 9a57b5d063a1..57979a1c4c46 100644 --- a/www/console_view/src/styles/styles.less +++ b/www/console_view/src/styles/styles.less @@ -1,5 +1,3 @@ -@row-header-width: 200px; -@row-header-width-expanded: 400px; @column-width: 40px; .console { @@ -30,15 +28,39 @@ max-width: @column-width; width: @column-width; } - - .builder { - margin-top: 100px; - position: relative; - float: left; - text-align: center; - transform: rotate(-25deg); - transform-origin: 0% 100%; - text-decoration: none; - white-space: nowrap; + table { + border: none; } + .tag_row{ + td { + margin:0px; + padding:0px; + } + span { + position: relative; + float: left; + font-size: 10px; + overflow: hidden; + text-decoration: none; + white-space: nowrap; + } + } + tr.first-row { + background-color: #fff!important; + th { + border: none; + background-color: #fff !important; + } + .builder { + position: relative; + float: left; + font-size: 12px; + text-align: center; + transform: rotate(-25deg) ; + transform-origin: 0% 100%; + text-decoration: none; + white-space: nowrap; + } + } + }