Package buildbot :: Package status :: Package web :: Module waterfall
[frames] | no frames]

Source Code for Module buildbot.status.web.waterfall

  1  # This file is part of Buildbot.  Buildbot is free software: you can 
  2  # redistribute it and/or modify it under the terms of the GNU General Public 
  3  # License as published by the Free Software Foundation, version 2. 
  4  # 
  5  # This program is distributed in the hope that it will be useful, but WITHOUT 
  6  # ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS 
  7  # FOR A PARTICULAR PURPOSE.  See the GNU General Public License for more 
  8  # details. 
  9  # 
 10  # You should have received a copy of the GNU General Public License along with 
 11  # this program; if not, write to the Free Software Foundation, Inc., 51 
 12  # Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. 
 13  # 
 14  # Copyright Buildbot Team Members 
 15   
 16   
 17  from zope.interface import implements 
 18  from twisted.python import log, components 
 19  from twisted.internet import defer 
 20  import urllib 
 21   
 22  import time, locale 
 23  import operator 
 24   
 25  from buildbot import interfaces, util 
 26  from buildbot.status import builder, buildstep, build 
 27  from buildbot.changes import changes 
 28   
 29  from buildbot.status.web.base import Box, HtmlResource, IBox, ICurrentBox, \ 
 30       ITopBox, build_get_class, path_to_build, path_to_step, path_to_root, \ 
 31       map_branches 
 32   
 33   
34 -def earlier(old, new):
35 # minimum of two things, but "None" counts as +infinity 36 if old: 37 if new < old: 38 return new 39 return old 40 return new
41
42 -def later(old, new):
43 # maximum of two things, but "None" counts as -infinity 44 if old: 45 if new > old: 46 return new 47 return old 48 return new
49 50
51 -class CurrentBox(components.Adapter):
52 # this provides the "current activity" box, just above the builder name 53 implements(ICurrentBox) 54
55 - def formatETA(self, prefix, eta):
56 if eta is None: 57 return [] 58 if eta < 60: 59 return ["< 1 min"] 60 eta_parts = ["~"] 61 eta_secs = eta 62 if eta_secs > 3600: 63 eta_parts.append("%d hrs" % (eta_secs / 3600)) 64 eta_secs %= 3600 65 if eta_secs > 60: 66 eta_parts.append("%d mins" % (eta_secs / 60)) 67 eta_secs %= 60 68 abstime = time.strftime("%H:%M", time.localtime(util.now()+eta)) 69 return [prefix, " ".join(eta_parts), "at %s" % abstime]
70
71 - def getBox(self, status, brcounts):
72 # getState() returns offline, idle, or building 73 state, builds = self.original.getState() 74 75 # look for upcoming builds. We say the state is "waiting" if the 76 # builder is otherwise idle and there is a scheduler which tells us a 77 # build will be performed some time in the near future. TODO: this 78 # functionality used to be in BuilderStatus.. maybe this code should 79 # be merged back into it. 80 upcoming = [] 81 builderName = self.original.getName() 82 for s in status.getSchedulers(): 83 if builderName in s.listBuilderNames(): 84 upcoming.extend(s.getPendingBuildTimes()) 85 if state == "idle" and upcoming: 86 state = "waiting" 87 88 if state == "building": 89 text = ["building"] 90 if builds: 91 for b in builds: 92 eta = b.getETA() 93 text.extend(self.formatETA("ETA in", eta)) 94 elif state == "offline": 95 text = ["offline"] 96 elif state == "idle": 97 text = ["idle"] 98 elif state == "waiting": 99 text = ["waiting"] 100 else: 101 # just in case I add a state and forget to update this 102 text = [state] 103 104 # TODO: for now, this pending/upcoming stuff is in the "current 105 # activity" box, but really it should go into a "next activity" row 106 # instead. The only times it should show up in "current activity" is 107 # when the builder is otherwise idle. 108 109 # are any builds pending? (waiting for a slave to be free) 110 brcount = brcounts[builderName] 111 if brcount: 112 text.append("%d pending" % brcount) 113 for t in upcoming: 114 if t is not None: 115 eta = t - util.now() 116 text.extend(self.formatETA("next in", eta)) 117 return Box(text, class_="Activity " + state)
118 119 components.registerAdapter(CurrentBox, builder.BuilderStatus, ICurrentBox) 120 121
122 -class BuildTopBox(components.Adapter):
123 # this provides a per-builder box at the very top of the display, 124 # showing the results of the most recent build 125 implements(IBox) 126
127 - def getBox(self, req):
128 assert interfaces.IBuilderStatus(self.original) 129 branches = [b for b in req.args.get("branch", []) if b] 130 builder = self.original 131 builds = list(builder.generateFinishedBuilds(map_branches(branches), 132 num_builds=1)) 133 if not builds: 134 return Box(["none"], class_="LastBuild") 135 b = builds[0] 136 url = path_to_build(req, b) 137 text = b.getText() 138 tests_failed = b.getSummaryStatistic('tests-failed', operator.add, 0) 139 if tests_failed: text.extend(["Failed tests: %d" % tests_failed]) 140 # TODO: maybe add logs? 141 class_ = build_get_class(b) 142 return Box(text, urlbase=url, class_="LastBuild %s" % class_)
143 components.registerAdapter(BuildTopBox, builder.BuilderStatus, ITopBox) 144
145 -class BuildBox(components.Adapter):
146 # this provides the yellow "starting line" box for each build 147 implements(IBox) 148
149 - def getBox(self, req):
150 b = self.original 151 number = b.getNumber() 152 url = path_to_build(req, b) 153 reason = b.getReason() 154 template = req.site.buildbot_service.templates.get_template("box_macros.html") 155 text = template.module.build_box(reason=reason,url=url,number=number) 156 class_ = "start" 157 if b.isFinished() and not b.getSteps(): 158 # the steps have been pruned, so there won't be any indication 159 # of whether it succeeded or failed. 160 class_ = build_get_class(b) 161 return Box([text], class_="BuildStep " + class_)
162 components.registerAdapter(BuildBox, build.BuildStatus, IBox) 163
164 -class StepBox(components.Adapter):
165 implements(IBox) 166
167 - def getBox(self, req):
168 urlbase = path_to_step(req, self.original) 169 text = self.original.getText() 170 if text is None: 171 log.msg("getText() gave None", urlbase) 172 text = [] 173 text = text[:] 174 logs = self.original.getLogs() 175 176 cxt = dict(text=text, logs=[], urls=[]) 177 178 for num in range(len(logs)): 179 name = logs[num].getName() 180 if logs[num].hasContents(): 181 url = urlbase + "/logs/%s" % urllib.quote(name) 182 else: 183 url = None 184 cxt['logs'].append(dict(name=name, url=url)) 185 186 for name, target in self.original.getURLs().items(): 187 cxt['urls'].append(dict(link=target,name=name)) 188 189 template = req.site.buildbot_service.templates.get_template("box_macros.html") 190 text = template.module.step_box(**cxt) 191 192 class_ = "BuildStep " + build_get_class(self.original) 193 return Box(text, class_=class_)
194 components.registerAdapter(StepBox, buildstep.BuildStepStatus, IBox) 195 196
197 -class EventBox(components.Adapter):
198 implements(IBox) 199
200 - def getBox(self, req):
201 text = self.original.getText() 202 class_ = "Event" 203 return Box(text, class_=class_)
204 components.registerAdapter(EventBox, builder.Event, IBox) 205 206
207 -class Spacer:
208 implements(interfaces.IStatusEvent) 209
210 - def __init__(self, start, finish):
211 self.started = start 212 self.finished = finish
213
214 - def getTimes(self):
215 return (self.started, self.finished)
216 - def getText(self):
217 return []
218
219 -class SpacerBox(components.Adapter):
220 implements(IBox) 221
222 - def getBox(self, req):
223 #b = Box(["spacer"], "white") 224 b = Box([]) 225 b.spacer = True 226 return b
227 components.registerAdapter(SpacerBox, Spacer, IBox) 228
229 -def insertGaps(g, showEvents, lastEventTime, idleGap=2):
230 debug = False 231 232 e = g.next() 233 starts, finishes = e.getTimes() 234 if debug: log.msg("E0", starts, finishes) 235 if finishes == 0: 236 finishes = starts 237 if debug: log.msg("E1 finishes=%s, gap=%s, lET=%s" % \ 238 (finishes, idleGap, lastEventTime)) 239 if finishes is not None and finishes + idleGap < lastEventTime: 240 if debug: log.msg(" spacer0") 241 yield Spacer(finishes, lastEventTime) 242 243 followingEventStarts = starts 244 if debug: log.msg(" fES0", starts) 245 yield e 246 247 while 1: 248 e = g.next() 249 if not showEvents and isinstance(e, builder.Event): 250 continue 251 starts, finishes = e.getTimes() 252 if debug: log.msg("E2", starts, finishes) 253 if finishes == 0: 254 finishes = starts 255 if finishes is not None and finishes + idleGap < followingEventStarts: 256 # there is a gap between the end of this event and the beginning 257 # of the next one. Insert an idle event so the waterfall display 258 # shows a gap here. 259 if debug: 260 log.msg(" finishes=%s, gap=%s, fES=%s" % \ 261 (finishes, idleGap, followingEventStarts)) 262 yield Spacer(finishes, followingEventStarts) 263 yield e 264 followingEventStarts = starts 265 if debug: log.msg(" fES1", starts)
266 267
268 -class WaterfallHelp(HtmlResource):
269 pageTitle = "Waterfall Help" 270
271 - def __init__(self, categories=None):
272 HtmlResource.__init__(self) 273 self.categories = categories
274
275 - def content(self, request, cxt):
276 status = self.getStatus(request) 277 278 cxt['show_events_checked'] = request.args.get("show_events", ["false"])[0].lower() == "true" 279 cxt['branches'] = [b for b in request.args.get("branch", []) if b] 280 cxt['failures_only'] = request.args.get("failures_only", ["false"])[0].lower() == "true" 281 cxt['committers'] = [c for c in request.args.get("committer", []) if c] 282 283 # this has a set of toggle-buttons to let the user choose the 284 # builders 285 show_builders = request.args.get("show", []) 286 show_builders.extend(request.args.get("builder", [])) 287 cxt['show_builders'] = show_builders 288 cxt['all_builders'] = status.getBuilderNames(categories=self.categories) 289 290 # a couple of radio-button selectors for refresh time will appear 291 # just after that text 292 times = [("none", "None"), 293 ("60", "60 seconds"), 294 ("300", "5 minutes"), 295 ("600", "10 minutes"), 296 ] 297 current_reload_time = request.args.get("reload", ["none"]) 298 if current_reload_time: 299 current_reload_time = current_reload_time[0] 300 if current_reload_time not in [t[0] for t in times]: 301 times.insert(0, (current_reload_time, current_reload_time) ) 302 303 cxt['times'] = times 304 cxt['current_reload_time'] = current_reload_time 305 306 template = request.site.buildbot_service.templates.get_template("waterfallhelp.html") 307 return template.render(**cxt)
308 309
310 -class ChangeEventSource(object):
311 "A wrapper around a list of changes to supply the IEventSource interface"
312 - def __init__(self, changes):
313 self.changes = changes 314 # we want them in newest-to-oldest order 315 self.changes.reverse()
316
317 - def eventGenerator(self, branches, categories, committers, minTime):
318 for change in self.changes: 319 if branches and change.branch not in branches: 320 continue 321 if categories and change.category not in categories: 322 continue 323 if committers and change.author not in committers: 324 continue 325 if minTime and change.when < minTime: 326 continue 327 yield change
328
329 -class WaterfallStatusResource(HtmlResource):
330 """This builds the main status page, with the waterfall display, and 331 all child pages.""" 332
333 - def __init__(self, categories=None, num_events=200, num_events_max=None):
334 HtmlResource.__init__(self) 335 self.categories = categories 336 self.num_events=num_events 337 self.num_events_max=num_events_max 338 self.putChild("help", WaterfallHelp(categories))
339
340 - def getPageTitle(self, request):
341 status = self.getStatus(request) 342 p = status.getTitle() 343 if p: 344 return "BuildBot: %s" % p 345 else: 346 return "BuildBot"
347
348 - def getChangeManager(self, request):
349 # TODO: this wants to go away, access it through IStatus 350 return request.site.buildbot_service.getChangeSvc()
351
352 - def get_reload_time(self, request):
353 if "reload" in request.args: 354 try: 355 reload_time = int(request.args["reload"][0]) 356 return max(reload_time, 15) 357 except ValueError: 358 pass 359 return None
360
361 - def isSuccess(self, builderStatus):
362 # Helper function to return True if the builder is not failing. 363 # The function will return false if the current state is "offline", 364 # the last build was not successful, or if a step from the current 365 # build(s) failed. 366 367 # Make sure the builder is online. 368 if builderStatus.getState()[0] == 'offline': 369 return False 370 371 # Look at the last finished build to see if it was success or not. 372 lastBuild = builderStatus.getLastFinishedBuild() 373 if lastBuild and lastBuild.getResults() != builder.SUCCESS: 374 return False 375 376 # Check all the current builds to see if one step is already 377 # failing. 378 currentBuilds = builderStatus.getCurrentBuilds() 379 if currentBuilds: 380 for build in currentBuilds: 381 for step in build.getSteps(): 382 if step.getResults()[0] == builder.FAILURE: 383 return False 384 385 # The last finished build was successful, and all the current builds 386 # don't have any failed steps. 387 return True
388
389 - def content(self, request, ctx):
390 status = self.getStatus(request) 391 master = request.site.buildbot_service.master 392 393 # before calling content_with_db_data, make a bunch of database 394 # queries. This is a sick hack, but beats rewriting the entire 395 # waterfall around asynchronous calls 396 397 results = {} 398 399 # recent changes 400 changes_d = master.db.changes.getRecentChanges(40) 401 def to_changes(chdicts): 402 return defer.gatherResults([ 403 changes.Change.fromChdict(master, chdict) 404 for chdict in chdicts ])
405 changes_d.addCallback(to_changes) 406 def keep_changes(changes): 407 results['changes'] = changes
408 changes_d.addCallback(keep_changes) 409 410 # build request counts for each builder 411 allBuilderNames = status.getBuilderNames(categories=self.categories) 412 brstatus_ds = [] 413 brcounts = {} 414 def keep_count(statuses, builderName): 415 brcounts[builderName] = len(statuses) 416 for builderName in allBuilderNames: 417 builder_status = status.getBuilder(builderName) 418 d = builder_status.getPendingBuildRequestStatuses() 419 d.addCallback(keep_count, builderName) 420 brstatus_ds.append(d) 421 422 # wait for it all to finish 423 d = defer.gatherResults([ changes_d ] + brstatus_ds) 424 def call_content(_): 425 return self.content_with_db_data(results['changes'], 426 brcounts, request, ctx) 427 d.addCallback(call_content) 428 return d 429
430 - def content_with_db_data(self, changes, brcounts, request, ctx):
431 status = self.getStatus(request) 432 ctx['refresh'] = self.get_reload_time(request) 433 434 # we start with all Builders available to this Waterfall: this is 435 # limited by the config-file -time categories= argument, and defaults 436 # to all defined Builders. 437 allBuilderNames = status.getBuilderNames(categories=self.categories) 438 builders = [status.getBuilder(name) for name in allBuilderNames] 439 440 # but if the URL has one or more builder= arguments (or the old show= 441 # argument, which is still accepted for backwards compatibility), we 442 # use that set of builders instead. We still don't show anything 443 # outside the config-file time set limited by categories=. 444 showBuilders = request.args.get("show", []) 445 showBuilders.extend(request.args.get("builder", [])) 446 if showBuilders: 447 builders = [b for b in builders if b.name in showBuilders] 448 449 # now, if the URL has one or category= arguments, use them as a 450 # filter: only show those builders which belong to one of the given 451 # categories. 452 showCategories = request.args.get("category", []) 453 if showCategories: 454 builders = [b for b in builders if b.category in showCategories] 455 456 # If the URL has the failures_only=true argument, we remove all the 457 # builders that are not currently red or won't be turning red at the end 458 # of their current run. 459 failuresOnly = request.args.get("failures_only", ["false"])[0] 460 if failuresOnly.lower() == "true": 461 builders = [b for b in builders if not self.isSuccess(b)] 462 463 (changeNames, builderNames, timestamps, eventGrid, sourceEvents) = \ 464 self.buildGrid(request, builders, changes) 465 466 # start the table: top-header material 467 locale_enc = locale.getdefaultlocale()[1] 468 if locale_enc is not None: 469 locale_tz = unicode(time.tzname[time.localtime()[-1]], locale_enc) 470 else: 471 locale_tz = unicode(time.tzname[time.localtime()[-1]]) 472 ctx['tz'] = locale_tz 473 ctx['changes_url'] = request.childLink("../changes") 474 475 bn = ctx['builders'] = [] 476 477 for name in builderNames: 478 builder = status.getBuilder(name) 479 top_box = ITopBox(builder).getBox(request) 480 current_box = ICurrentBox(builder).getBox(status, brcounts) 481 bn.append({'name': name, 482 'url': request.childLink("../builders/%s" % urllib.quote(name, safe='')), 483 'top': top_box.text, 484 'top_class': top_box.class_, 485 'status': current_box.text, 486 'status_class': current_box.class_, 487 }) 488 489 ctx.update(self.phase2(request, changeNames + builderNames, timestamps, eventGrid, 490 sourceEvents)) 491 492 def with_args(req, remove_args=[], new_args=[], new_path=None): 493 # sigh, nevow makes this sort of manipulation easier 494 newargs = req.args.copy() 495 for argname in remove_args: 496 newargs[argname] = [] 497 if "branch" in newargs: 498 newargs["branch"] = [b for b in newargs["branch"] if b] 499 for k,v in new_args: 500 if k in newargs: 501 newargs[k].append(v) 502 else: 503 newargs[k] = [v] 504 newquery = "&amp;".join(["%s=%s" % (urllib.quote(k), urllib.quote(v)) 505 for k in newargs 506 for v in newargs[k] 507 ]) 508 if new_path: 509 new_url = new_path 510 elif req.prepath: 511 new_url = req.prepath[-1] 512 else: 513 new_url = '' 514 if newquery: 515 new_url += "?" + newquery 516 return new_url
517 518 if timestamps: 519 bottom = timestamps[-1] 520 ctx['nextpage'] = with_args(request, ["last_time"], 521 [("last_time", str(int(bottom)))]) 522 523 524 helpurl = path_to_root(request) + "waterfall/help" 525 ctx['help_url'] = with_args(request, new_path=helpurl) 526 527 if self.get_reload_time(request) is not None: 528 ctx['no_reload_page'] = with_args(request, remove_args=["reload"]) 529 530 template = request.site.buildbot_service.templates.get_template("waterfall.html") 531 data = template.render(**ctx) 532 return data 533
534 - def buildGrid(self, request, builders, changes):
535 debug = False 536 # TODO: see if we can use a cached copy 537 538 showEvents = False 539 if request.args.get("show_events", ["false"])[0].lower() == "true": 540 showEvents = True 541 filterCategories = request.args.get('category', []) 542 filterBranches = [b for b in request.args.get("branch", []) if b] 543 filterBranches = map_branches(filterBranches) 544 filterCommitters = [c for c in request.args.get("committer", []) if c] 545 maxTime = int(request.args.get("last_time", [util.now()])[0]) 546 if "show_time" in request.args: 547 minTime = maxTime - int(request.args["show_time"][0]) 548 elif "first_time" in request.args: 549 minTime = int(request.args["first_time"][0]) 550 elif filterBranches or filterCommitters: 551 minTime = util.now() - 24 * 60 * 60 552 else: 553 minTime = 0 554 spanLength = 10 # ten-second chunks 555 req_events=int(request.args.get("num_events", [self.num_events])[0]) 556 if self.num_events_max and req_events > self.num_events_max: 557 maxPageLen = self.num_events_max 558 else: 559 maxPageLen = req_events 560 561 # first step is to walk backwards in time, asking each column 562 # (commit, all builders) if they have any events there. Build up the 563 # array of events, and stop when we have a reasonable number. 564 565 commit_source = ChangeEventSource(changes) 566 567 lastEventTime = util.now() 568 sources = [commit_source] + builders 569 changeNames = ["changes"] 570 builderNames = map(lambda builder: builder.getName(), builders) 571 sourceNames = changeNames + builderNames 572 sourceEvents = [] 573 sourceGenerators = [] 574 575 def get_event_from(g): 576 try: 577 while True: 578 e = g.next() 579 # e might be builder.BuildStepStatus, 580 # builder.BuildStatus, builder.Event, 581 # waterfall.Spacer(builder.Event), or changes.Change . 582 # The showEvents=False flag means we should hide 583 # builder.Event . 584 if not showEvents and isinstance(e, builder.Event): 585 continue 586 break 587 event = interfaces.IStatusEvent(e) 588 if debug: 589 log.msg("gen %s gave1 %s" % (g, event.getText())) 590 except StopIteration: 591 event = None 592 return event
593 594 for s in sources: 595 gen = insertGaps(s.eventGenerator(filterBranches, 596 filterCategories, 597 filterCommitters, 598 minTime), 599 showEvents, 600 lastEventTime) 601 sourceGenerators.append(gen) 602 # get the first event 603 sourceEvents.append(get_event_from(gen)) 604 eventGrid = [] 605 timestamps = [] 606 607 lastEventTime = 0 608 for e in sourceEvents: 609 if e and e.getTimes()[0] > lastEventTime: 610 lastEventTime = e.getTimes()[0] 611 if lastEventTime == 0: 612 lastEventTime = util.now() 613 614 spanStart = lastEventTime - spanLength 615 debugGather = 0 616 617 while 1: 618 if debugGather: log.msg("checking (%s,]" % spanStart) 619 # the tableau of potential events is in sourceEvents[]. The 620 # window crawls backwards, and we examine one source at a time. 621 # If the source's top-most event is in the window, is it pushed 622 # onto the events[] array and the tableau is refilled. This 623 # continues until the tableau event is not in the window (or is 624 # missing). 625 626 spanEvents = [] # for all sources, in this span. row of eventGrid 627 firstTimestamp = None # timestamp of first event in the span 628 lastTimestamp = None # last pre-span event, for next span 629 630 for c in range(len(sourceGenerators)): 631 events = [] # for this source, in this span. cell of eventGrid 632 event = sourceEvents[c] 633 while event and spanStart < event.getTimes()[0]: 634 # to look at windows that don't end with the present, 635 # condition the .append on event.time <= spanFinish 636 if not IBox(event, None): 637 log.msg("BAD EVENT", event, event.getText()) 638 assert 0 639 if debug: 640 log.msg("pushing", event.getText(), event) 641 events.append(event) 642 starts, finishes = event.getTimes() 643 firstTimestamp = earlier(firstTimestamp, starts) 644 event = get_event_from(sourceGenerators[c]) 645 if debug: 646 log.msg("finished span") 647 648 if event: 649 # this is the last pre-span event for this source 650 lastTimestamp = later(lastTimestamp, 651 event.getTimes()[0]) 652 if debugGather: 653 log.msg(" got %s from %s" % (events, sourceNames[c])) 654 sourceEvents[c] = event # refill the tableau 655 spanEvents.append(events) 656 657 # only show events older than maxTime. This makes it possible to 658 # visit a page that shows what it would be like to scroll off the 659 # bottom of this one. 660 if firstTimestamp is not None and firstTimestamp <= maxTime: 661 eventGrid.append(spanEvents) 662 timestamps.append(firstTimestamp) 663 664 if lastTimestamp: 665 spanStart = lastTimestamp - spanLength 666 else: 667 # no more events 668 break 669 if minTime is not None and lastTimestamp < minTime: 670 break 671 672 if len(timestamps) > maxPageLen: 673 break 674 675 676 # now loop 677 678 # loop is finished. now we have eventGrid[] and timestamps[] 679 if debugGather: log.msg("finished loop") 680 assert(len(timestamps) == len(eventGrid)) 681 return (changeNames, builderNames, timestamps, eventGrid, sourceEvents) 682
683 - def phase2(self, request, sourceNames, timestamps, eventGrid, 684 sourceEvents):
685 686 if not timestamps: 687 return dict(grid=[], gridlen=0) 688 689 # first pass: figure out the height of the chunks, populate grid 690 grid = [] 691 for i in range(1+len(sourceNames)): 692 grid.append([]) 693 # grid is a list of columns, one for the timestamps, and one per 694 # event source. Each column is exactly the same height. Each element 695 # of the list is a single <td> box. 696 lastDate = time.strftime("%d %b %Y", 697 time.localtime(util.now())) 698 for r in range(0, len(timestamps)): 699 chunkstrip = eventGrid[r] 700 # chunkstrip is a horizontal strip of event blocks. Each block 701 # is a vertical list of events, all for the same source. 702 assert(len(chunkstrip) == len(sourceNames)) 703 maxRows = reduce(lambda x,y: max(x,y), 704 map(lambda x: len(x), chunkstrip)) 705 for i in range(maxRows): 706 if i != maxRows-1: 707 grid[0].append(None) 708 else: 709 # timestamp goes at the bottom of the chunk 710 stuff = [] 711 # add the date at the beginning (if it is not the same as 712 # today's date), and each time it changes 713 todayday = time.strftime("%a", 714 time.localtime(timestamps[r])) 715 today = time.strftime("%d %b %Y", 716 time.localtime(timestamps[r])) 717 if today != lastDate: 718 stuff.append(todayday) 719 stuff.append(today) 720 lastDate = today 721 stuff.append( 722 time.strftime("%H:%M:%S", 723 time.localtime(timestamps[r]))) 724 grid[0].append(Box(text=stuff, class_="Time", 725 valign="bottom", align="center")) 726 727 # at this point the timestamp column has been populated with 728 # maxRows boxes, most None but the last one has the time string 729 for c in range(0, len(chunkstrip)): 730 block = chunkstrip[c] 731 assert(block != None) # should be [] instead 732 for i in range(maxRows - len(block)): 733 # fill top of chunk with blank space 734 grid[c+1].append(None) 735 for i in range(len(block)): 736 # so the events are bottom-justified 737 b = IBox(block[i]).getBox(request) 738 b.parms['valign'] = "top" 739 b.parms['align'] = "center" 740 grid[c+1].append(b) 741 # now all the other columns have maxRows new boxes too 742 # populate the last row, if empty 743 gridlen = len(grid[0]) 744 for i in range(len(grid)): 745 strip = grid[i] 746 assert(len(strip) == gridlen) 747 if strip[-1] == None: 748 if sourceEvents[i-1]: 749 filler = IBox(sourceEvents[i-1]).getBox(request) 750 else: 751 # this can happen if you delete part of the build history 752 filler = Box(text=["?"], align="center") 753 strip[-1] = filler 754 strip[-1].parms['rowspan'] = 1 755 # second pass: bubble the events upwards to un-occupied locations 756 # Every square of the grid that has a None in it needs to have 757 # something else take its place. 758 noBubble = request.args.get("nobubble",['0']) 759 noBubble = int(noBubble[0]) 760 if not noBubble: 761 for col in range(len(grid)): 762 strip = grid[col] 763 if col == 1: # changes are handled differently 764 for i in range(2, len(strip)+1): 765 # only merge empty boxes. Don't bubble commit boxes. 766 if strip[-i] == None: 767 next = strip[-i+1] 768 assert(next) 769 if next: 770 #if not next.event: 771 if next.spacer: 772 # bubble the empty box up 773 strip[-i] = next 774 strip[-i].parms['rowspan'] += 1 775 strip[-i+1] = None 776 else: 777 # we are above a commit box. Leave it 778 # be, and turn the current box into an 779 # empty one 780 strip[-i] = Box([], rowspan=1, 781 comment="commit bubble") 782 strip[-i].spacer = True 783 else: 784 # we are above another empty box, which 785 # somehow wasn't already converted. 786 # Shouldn't happen 787 pass 788 else: 789 for i in range(2, len(strip)+1): 790 # strip[-i] will go from next-to-last back to first 791 if strip[-i] == None: 792 # bubble previous item up 793 assert(strip[-i+1] != None) 794 strip[-i] = strip[-i+1] 795 strip[-i].parms['rowspan'] += 1 796 strip[-i+1] = None 797 else: 798 strip[-i].parms['rowspan'] = 1 799 800 # convert to dicts 801 for i in range(gridlen): 802 for strip in grid: 803 if strip[i]: 804 strip[i] = strip[i].td() 805 806 return dict(grid=grid, gridlen=gridlen, no_bubble=noBubble, time=lastDate)
807