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 sorted(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=[], stepinfo=self) 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 buildstep.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 587 if isinstance(e, buildstep.BuildStepStatus): 588 # unfinished steps are always shown 589 if e.isFinished() and e.isHidden(): 590 continue 591 592 break 593 event = interfaces.IStatusEvent(e) 594 if debug: 595 log.msg("gen %s gave1 %s" % (g, event.getText())) 596 except StopIteration: 597 event = None 598 return event
599 600 for s in sources: 601 gen = insertGaps(s.eventGenerator(filterBranches, 602 filterCategories, 603 filterCommitters, 604 minTime), 605 showEvents, 606 lastEventTime) 607 sourceGenerators.append(gen) 608 # get the first event 609 sourceEvents.append(get_event_from(gen)) 610 eventGrid = [] 611 timestamps = [] 612 613 lastEventTime = 0 614 for e in sourceEvents: 615 if e and e.getTimes()[0] > lastEventTime: 616 lastEventTime = e.getTimes()[0] 617 if lastEventTime == 0: 618 lastEventTime = util.now() 619 620 spanStart = lastEventTime - spanLength 621 debugGather = 0 622 623 while 1: 624 if debugGather: log.msg("checking (%s,]" % spanStart) 625 # the tableau of potential events is in sourceEvents[]. The 626 # window crawls backwards, and we examine one source at a time. 627 # If the source's top-most event is in the window, is it pushed 628 # onto the events[] array and the tableau is refilled. This 629 # continues until the tableau event is not in the window (or is 630 # missing). 631 632 spanEvents = [] # for all sources, in this span. row of eventGrid 633 firstTimestamp = None # timestamp of first event in the span 634 lastTimestamp = None # last pre-span event, for next span 635 636 for c in range(len(sourceGenerators)): 637 events = [] # for this source, in this span. cell of eventGrid 638 event = sourceEvents[c] 639 while event and spanStart < event.getTimes()[0]: 640 # to look at windows that don't end with the present, 641 # condition the .append on event.time <= spanFinish 642 if not IBox(event, None): 643 log.msg("BAD EVENT", event, event.getText()) 644 assert 0 645 if debug: 646 log.msg("pushing", event.getText(), event) 647 events.append(event) 648 starts, finishes = event.getTimes() 649 firstTimestamp = earlier(firstTimestamp, starts) 650 event = get_event_from(sourceGenerators[c]) 651 if debug: 652 log.msg("finished span") 653 654 if event: 655 # this is the last pre-span event for this source 656 lastTimestamp = later(lastTimestamp, 657 event.getTimes()[0]) 658 if debugGather: 659 log.msg(" got %s from %s" % (events, sourceNames[c])) 660 sourceEvents[c] = event # refill the tableau 661 spanEvents.append(events) 662 663 # only show events older than maxTime. This makes it possible to 664 # visit a page that shows what it would be like to scroll off the 665 # bottom of this one. 666 if firstTimestamp is not None and firstTimestamp <= maxTime: 667 eventGrid.append(spanEvents) 668 timestamps.append(firstTimestamp) 669 670 if lastTimestamp: 671 spanStart = lastTimestamp - spanLength 672 else: 673 # no more events 674 break 675 if minTime is not None and lastTimestamp < minTime: 676 break 677 678 if len(timestamps) > maxPageLen: 679 break 680 681 682 # now loop 683 684 # loop is finished. now we have eventGrid[] and timestamps[] 685 if debugGather: log.msg("finished loop") 686 assert(len(timestamps) == len(eventGrid)) 687 return (changeNames, builderNames, timestamps, eventGrid, sourceEvents) 688
689 - def phase2(self, request, sourceNames, timestamps, eventGrid, 690 sourceEvents):
691 692 if not timestamps: 693 return dict(grid=[], gridlen=0) 694 695 # first pass: figure out the height of the chunks, populate grid 696 grid = [] 697 for i in range(1+len(sourceNames)): 698 grid.append([]) 699 # grid is a list of columns, one for the timestamps, and one per 700 # event source. Each column is exactly the same height. Each element 701 # of the list is a single <td> box. 702 lastDate = time.strftime("%d %b %Y", 703 time.localtime(util.now())) 704 for r in range(0, len(timestamps)): 705 chunkstrip = eventGrid[r] 706 # chunkstrip is a horizontal strip of event blocks. Each block 707 # is a vertical list of events, all for the same source. 708 assert(len(chunkstrip) == len(sourceNames)) 709 maxRows = reduce(lambda x,y: max(x,y), 710 map(lambda x: len(x), chunkstrip)) 711 for i in range(maxRows): 712 if i != maxRows-1: 713 grid[0].append(None) 714 else: 715 # timestamp goes at the bottom of the chunk 716 stuff = [] 717 # add the date at the beginning (if it is not the same as 718 # today's date), and each time it changes 719 todayday = time.strftime("%a", 720 time.localtime(timestamps[r])) 721 today = time.strftime("%d %b %Y", 722 time.localtime(timestamps[r])) 723 if today != lastDate: 724 stuff.append(todayday) 725 stuff.append(today) 726 lastDate = today 727 stuff.append( 728 time.strftime("%H:%M:%S", 729 time.localtime(timestamps[r]))) 730 grid[0].append(Box(text=stuff, class_="Time", 731 valign="bottom", align="center")) 732 733 # at this point the timestamp column has been populated with 734 # maxRows boxes, most None but the last one has the time string 735 for c in range(0, len(chunkstrip)): 736 block = chunkstrip[c] 737 assert(block != None) # should be [] instead 738 for i in range(maxRows - len(block)): 739 # fill top of chunk with blank space 740 grid[c+1].append(None) 741 for i in range(len(block)): 742 # so the events are bottom-justified 743 b = IBox(block[i]).getBox(request) 744 b.parms['valign'] = "top" 745 b.parms['align'] = "center" 746 grid[c+1].append(b) 747 # now all the other columns have maxRows new boxes too 748 # populate the last row, if empty 749 gridlen = len(grid[0]) 750 for i in range(len(grid)): 751 strip = grid[i] 752 assert(len(strip) == gridlen) 753 if strip[-1] == None: 754 if sourceEvents[i-1]: 755 filler = IBox(sourceEvents[i-1]).getBox(request) 756 else: 757 # this can happen if you delete part of the build history 758 filler = Box(text=["?"], align="center") 759 strip[-1] = filler 760 strip[-1].parms['rowspan'] = 1 761 # second pass: bubble the events upwards to un-occupied locations 762 # Every square of the grid that has a None in it needs to have 763 # something else take its place. 764 noBubble = request.args.get("nobubble",['0']) 765 noBubble = int(noBubble[0]) 766 if not noBubble: 767 for col in range(len(grid)): 768 strip = grid[col] 769 if col == 1: # changes are handled differently 770 for i in range(2, len(strip)+1): 771 # only merge empty boxes. Don't bubble commit boxes. 772 if strip[-i] == None: 773 next = strip[-i+1] 774 assert(next) 775 if next: 776 #if not next.event: 777 if next.spacer: 778 # bubble the empty box up 779 strip[-i] = next 780 strip[-i].parms['rowspan'] += 1 781 strip[-i+1] = None 782 else: 783 # we are above a commit box. Leave it 784 # be, and turn the current box into an 785 # empty one 786 strip[-i] = Box([], rowspan=1, 787 comment="commit bubble") 788 strip[-i].spacer = True 789 else: 790 # we are above another empty box, which 791 # somehow wasn't already converted. 792 # Shouldn't happen 793 pass 794 else: 795 for i in range(2, len(strip)+1): 796 # strip[-i] will go from next-to-last back to first 797 if strip[-i] == None: 798 # bubble previous item up 799 assert(strip[-i+1] != None) 800 strip[-i] = strip[-i+1] 801 strip[-i].parms['rowspan'] += 1 802 strip[-i+1] = None 803 else: 804 strip[-i].parms['rowspan'] = 1 805 806 # convert to dicts 807 for i in range(gridlen): 808 for strip in grid: 809 if strip[i]: 810 strip[i] = strip[i].td() 811 812 return dict(grid=grid, gridlen=gridlen, no_bubble=noBubble, time=lastDate)
813