diff options
Diffstat (limited to 'buildbot/buildbot/status/web/waterfall.py')
-rw-r--r-- | buildbot/buildbot/status/web/waterfall.py | 962 |
1 files changed, 962 insertions, 0 deletions
diff --git a/buildbot/buildbot/status/web/waterfall.py b/buildbot/buildbot/status/web/waterfall.py new file mode 100644 index 0000000..1d3ab60 --- /dev/null +++ b/buildbot/buildbot/status/web/waterfall.py @@ -0,0 +1,962 @@ +# -*- test-case-name: buildbot.test.test_web -*- + +from zope.interface import implements +from twisted.python import log, components +from twisted.web import html +import urllib + +import time +import operator + +from buildbot import interfaces, util +from buildbot import version +from buildbot.status import builder + +from buildbot.status.web.base import Box, HtmlResource, IBox, ICurrentBox, \ + ITopBox, td, build_get_class, path_to_build, path_to_step, map_branches + + + +class CurrentBox(components.Adapter): + # this provides the "current activity" box, just above the builder name + implements(ICurrentBox) + + def formatETA(self, prefix, eta): + if eta is None: + return [] + if eta < 60: + return ["< 1 min"] + eta_parts = ["~"] + eta_secs = eta + if eta_secs > 3600: + eta_parts.append("%d hrs" % (eta_secs / 3600)) + eta_secs %= 3600 + if eta_secs > 60: + eta_parts.append("%d mins" % (eta_secs / 60)) + eta_secs %= 60 + abstime = time.strftime("%H:%M", time.localtime(util.now()+eta)) + return [prefix, " ".join(eta_parts), "at %s" % abstime] + + def getBox(self, status): + # getState() returns offline, idle, or building + state, builds = self.original.getState() + + # look for upcoming builds. We say the state is "waiting" if the + # builder is otherwise idle and there is a scheduler which tells us a + # build will be performed some time in the near future. TODO: this + # functionality used to be in BuilderStatus.. maybe this code should + # be merged back into it. + upcoming = [] + builderName = self.original.getName() + for s in status.getSchedulers(): + if builderName in s.listBuilderNames(): + upcoming.extend(s.getPendingBuildTimes()) + if state == "idle" and upcoming: + state = "waiting" + + if state == "building": + text = ["building"] + if builds: + for b in builds: + eta = b.getETA() + text.extend(self.formatETA("ETA in", eta)) + elif state == "offline": + text = ["offline"] + elif state == "idle": + text = ["idle"] + elif state == "waiting": + text = ["waiting"] + else: + # just in case I add a state and forget to update this + text = [state] + + # TODO: for now, this pending/upcoming stuff is in the "current + # activity" box, but really it should go into a "next activity" row + # instead. The only times it should show up in "current activity" is + # when the builder is otherwise idle. + + # are any builds pending? (waiting for a slave to be free) + pbs = self.original.getPendingBuilds() + if pbs: + text.append("%d pending" % len(pbs)) + for t in upcoming: + eta = t - util.now() + text.extend(self.formatETA("next in", eta)) + return Box(text, class_="Activity " + state) + +components.registerAdapter(CurrentBox, builder.BuilderStatus, ICurrentBox) + + +class BuildTopBox(components.Adapter): + # this provides a per-builder box at the very top of the display, + # showing the results of the most recent build + implements(IBox) + + def getBox(self, req): + assert interfaces.IBuilderStatus(self.original) + branches = [b for b in req.args.get("branch", []) if b] + builder = self.original + builds = list(builder.generateFinishedBuilds(map_branches(branches), + num_builds=1)) + if not builds: + return Box(["none"], class_="LastBuild") + b = builds[0] + name = b.getBuilder().getName() + number = b.getNumber() + url = path_to_build(req, b) + text = b.getText() + tests_failed = b.getSummaryStatistic('tests-failed', operator.add, 0) + if tests_failed: text.extend(["Failed tests: %d" % tests_failed]) + # TODO: maybe add logs? + # TODO: add link to the per-build page at 'url' + class_ = build_get_class(b) + return Box(text, class_="LastBuild %s" % class_) +components.registerAdapter(BuildTopBox, builder.BuilderStatus, ITopBox) + +class BuildBox(components.Adapter): + # this provides the yellow "starting line" box for each build + implements(IBox) + + def getBox(self, req): + b = self.original + number = b.getNumber() + url = path_to_build(req, b) + reason = b.getReason() + text = ('<a title="Reason: %s" href="%s">Build %d</a>' + % (html.escape(reason), url, number)) + class_ = "start" + if b.isFinished() and not b.getSteps(): + # the steps have been pruned, so there won't be any indication + # of whether it succeeded or failed. + class_ = build_get_class(b) + return Box([text], class_="BuildStep " + class_) +components.registerAdapter(BuildBox, builder.BuildStatus, IBox) + +class StepBox(components.Adapter): + implements(IBox) + + def getBox(self, req): + urlbase = path_to_step(req, self.original) + text = self.original.getText() + if text is None: + log.msg("getText() gave None", urlbase) + text = [] + text = text[:] + logs = self.original.getLogs() + for num in range(len(logs)): + name = logs[num].getName() + if logs[num].hasContents(): + url = urlbase + "/logs/%s" % urllib.quote(name) + text.append("<a href=\"%s\">%s</a>" % (url, html.escape(name))) + else: + text.append(html.escape(name)) + urls = self.original.getURLs() + ex_url_class = "BuildStep external" + for name, target in urls.items(): + text.append('[<a href="%s" class="%s">%s</a>]' % + (target, ex_url_class, html.escape(name))) + class_ = "BuildStep " + build_get_class(self.original) + return Box(text, class_=class_) +components.registerAdapter(StepBox, builder.BuildStepStatus, IBox) + + +class EventBox(components.Adapter): + implements(IBox) + + def getBox(self, req): + text = self.original.getText() + class_ = "Event" + return Box(text, class_=class_) +components.registerAdapter(EventBox, builder.Event, IBox) + + +class Spacer: + implements(interfaces.IStatusEvent) + + def __init__(self, start, finish): + self.started = start + self.finished = finish + + def getTimes(self): + return (self.started, self.finished) + def getText(self): + return [] + +class SpacerBox(components.Adapter): + implements(IBox) + + def getBox(self, req): + #b = Box(["spacer"], "white") + b = Box([]) + b.spacer = True + return b +components.registerAdapter(SpacerBox, Spacer, IBox) + +def insertGaps(g, lastEventTime, idleGap=2): + debug = False + + e = g.next() + starts, finishes = e.getTimes() + if debug: log.msg("E0", starts, finishes) + if finishes == 0: + finishes = starts + if debug: log.msg("E1 finishes=%s, gap=%s, lET=%s" % \ + (finishes, idleGap, lastEventTime)) + if finishes is not None and finishes + idleGap < lastEventTime: + if debug: log.msg(" spacer0") + yield Spacer(finishes, lastEventTime) + + followingEventStarts = starts + if debug: log.msg(" fES0", starts) + yield e + + while 1: + e = g.next() + starts, finishes = e.getTimes() + if debug: log.msg("E2", starts, finishes) + if finishes == 0: + finishes = starts + if finishes is not None and finishes + idleGap < followingEventStarts: + # there is a gap between the end of this event and the beginning + # of the next one. Insert an idle event so the waterfall display + # shows a gap here. + if debug: + log.msg(" finishes=%s, gap=%s, fES=%s" % \ + (finishes, idleGap, followingEventStarts)) + yield Spacer(finishes, followingEventStarts) + yield e + followingEventStarts = starts + if debug: log.msg(" fES1", starts) + +HELP = ''' +<form action="../waterfall" method="GET"> + +<h1>The Waterfall Display</h1> + +<p>The Waterfall display can be controlled by adding query arguments to the +URL. For example, if your Waterfall is accessed via the URL +<tt>http://buildbot.example.org:8080</tt>, then you could add a +<tt>branch=</tt> argument (described below) by going to +<tt>http://buildbot.example.org:8080?branch=beta4</tt> instead. Remember that +query arguments are separated from each other with ampersands, but they are +separated from the main URL with a question mark, so to add a +<tt>branch=</tt> and two <tt>builder=</tt> arguments, you would use +<tt>http://buildbot.example.org:8080?branch=beta4&builder=unix&builder=macos</tt>.</p> + +<h2>Limiting the Displayed Interval</h2> + +<p>The <tt>last_time=</tt> argument is a unix timestamp (seconds since the +start of 1970) that will be used as an upper bound on the interval of events +displayed: nothing will be shown that is more recent than the given time. +When no argument is provided, all events up to and including the most recent +steps are included.</p> + +<p>The <tt>first_time=</tt> argument provides the lower bound. No events will +be displayed that occurred <b>before</b> this timestamp. Instead of providing +<tt>first_time=</tt>, you can provide <tt>show_time=</tt>: in this case, +<tt>first_time</tt> will be set equal to <tt>last_time</tt> minus +<tt>show_time</tt>. <tt>show_time</tt> overrides <tt>first_time</tt>.</p> + +<p>The display normally shows the latest 200 events that occurred in the +given interval, where each timestamp on the left hand edge counts as a single +event. You can add a <tt>num_events=</tt> argument to override this this.</p> + +<h2>Hiding non-Build events</h2> + +<p>By passing <tt>show_events=false</tt>, you can remove the "buildslave +attached", "buildslave detached", and "builder reconfigured" events that +appear in-between the actual builds.</p> + +%(show_events_input)s + +<h2>Showing only Certain Branches</h2> + +<p>If you provide one or more <tt>branch=</tt> arguments, the display will be +limited to builds that used one of the given branches. If no <tt>branch=</tt> +arguments are given, builds from all branches will be displayed.</p> + +Erase the text from these "Show Branch:" boxes to remove that branch filter. + +%(show_branches_input)s + +<h2>Limiting the Builders that are Displayed</h2> + +<p>By adding one or more <tt>builder=</tt> arguments, the display will be +limited to showing builds that ran on the given builders. This serves to +limit the display to the specific named columns. If no <tt>builder=</tt> +arguments are provided, all Builders will be displayed.</p> + +<p>To view a Waterfall page with only a subset of Builders displayed, select +the Builders you are interested in here.</p> + +%(show_builders_input)s + + +<h2>Auto-reloading the Page</h2> + +<p>Adding a <tt>reload=</tt> argument will cause the page to automatically +reload itself after that many seconds.</p> + +%(show_reload_input)s + +<h2>Reload Waterfall Page</h2> + +<input type="submit" value="View Waterfall" /> +</form> +''' + +class WaterfallHelp(HtmlResource): + title = "Waterfall Help" + + def __init__(self, categories=None): + HtmlResource.__init__(self) + self.categories = categories + + def body(self, request): + data = '' + status = self.getStatus(request) + + showEvents_checked = 'checked="checked"' + if request.args.get("show_events", ["true"])[0].lower() == "true": + showEvents_checked = '' + show_events_input = ('<p>' + '<input type="checkbox" name="show_events" ' + 'value="false" %s>' + 'Hide non-Build events' + '</p>\n' + ) % showEvents_checked + + branches = [b + for b in request.args.get("branch", []) + if b] + branches.append('') + show_branches_input = '<table>\n' + for b in branches: + show_branches_input += ('<tr>' + '<td>Show Branch: ' + '<input type="text" name="branch" ' + 'value="%s">' + '</td></tr>\n' + ) % (b,) + show_branches_input += '</table>\n' + + # this has a set of toggle-buttons to let the user choose the + # builders + showBuilders = request.args.get("show", []) + showBuilders.extend(request.args.get("builder", [])) + allBuilders = status.getBuilderNames(categories=self.categories) + + show_builders_input = '<table>\n' + for bn in allBuilders: + checked = "" + if bn in showBuilders: + checked = 'checked="checked"' + show_builders_input += ('<tr>' + '<td><input type="checkbox"' + ' name="builder" ' + 'value="%s" %s></td> ' + '<td>%s</td></tr>\n' + ) % (bn, checked, bn) + show_builders_input += '</table>\n' + + # a couple of radio-button selectors for refresh time will appear + # just after that text + show_reload_input = '<table>\n' + times = [("none", "None"), + ("60", "60 seconds"), + ("300", "5 minutes"), + ("600", "10 minutes"), + ] + current_reload_time = request.args.get("reload", ["none"]) + if current_reload_time: + current_reload_time = current_reload_time[0] + if current_reload_time not in [t[0] for t in times]: + times.insert(0, (current_reload_time, current_reload_time) ) + for value, name in times: + checked = "" + if value == current_reload_time: + checked = 'checked="checked"' + show_reload_input += ('<tr>' + '<td><input type="radio" name="reload" ' + 'value="%s" %s></td> ' + '<td>%s</td></tr>\n' + ) % (value, checked, name) + show_reload_input += '</table>\n' + + fields = {"show_events_input": show_events_input, + "show_branches_input": show_branches_input, + "show_builders_input": show_builders_input, + "show_reload_input": show_reload_input, + } + data += HELP % fields + return data + +class WaterfallStatusResource(HtmlResource): + """This builds the main status page, with the waterfall display, and + all child pages.""" + + def __init__(self, categories=None): + HtmlResource.__init__(self) + self.categories = categories + self.putChild("help", WaterfallHelp(categories)) + + def getTitle(self, request): + status = self.getStatus(request) + p = status.getProjectName() + if p: + return "BuildBot: %s" % p + else: + return "BuildBot" + + def getChangemaster(self, request): + # TODO: this wants to go away, access it through IStatus + return request.site.buildbot_service.getChangeSvc() + + def get_reload_time(self, request): + if "reload" in request.args: + try: + reload_time = int(request.args["reload"][0]) + return max(reload_time, 15) + except ValueError: + pass + return None + + def head(self, request): + head = '' + reload_time = self.get_reload_time(request) + if reload_time is not None: + head += '<meta http-equiv="refresh" content="%d">\n' % reload_time + return head + + def body(self, request): + "This method builds the main waterfall display." + + status = self.getStatus(request) + data = '' + + projectName = status.getProjectName() + projectURL = status.getProjectURL() + + phase = request.args.get("phase",["2"]) + phase = int(phase[0]) + + # we start with all Builders available to this Waterfall: this is + # limited by the config-file -time categories= argument, and defaults + # to all defined Builders. + allBuilderNames = status.getBuilderNames(categories=self.categories) + builders = [status.getBuilder(name) for name in allBuilderNames] + + # but if the URL has one or more builder= arguments (or the old show= + # argument, which is still accepted for backwards compatibility), we + # use that set of builders instead. We still don't show anything + # outside the config-file time set limited by categories=. + showBuilders = request.args.get("show", []) + showBuilders.extend(request.args.get("builder", [])) + if showBuilders: + builders = [b for b in builders if b.name in showBuilders] + + # now, if the URL has one or category= arguments, use them as a + # filter: only show those builders which belong to one of the given + # categories. + showCategories = request.args.get("category", []) + if showCategories: + builders = [b for b in builders if b.category in showCategories] + + builderNames = [b.name for b in builders] + + if phase == -1: + return self.body0(request, builders) + (changeNames, builderNames, timestamps, eventGrid, sourceEvents) = \ + self.buildGrid(request, builders) + if phase == 0: + return self.phase0(request, (changeNames + builderNames), + timestamps, eventGrid) + # start the table: top-header material + data += '<table border="0" cellspacing="0">\n' + + if projectName and projectURL: + # TODO: this is going to look really ugly + topleft = '<a href="%s">%s</a><br />last build' % \ + (projectURL, projectName) + else: + topleft = "last build" + data += ' <tr class="LastBuild">\n' + data += td(topleft, align="right", colspan=2, class_="Project") + for b in builders: + box = ITopBox(b).getBox(request) + data += box.td(align="center") + data += " </tr>\n" + + data += ' <tr class="Activity">\n' + data += td('current activity', align='right', colspan=2) + for b in builders: + box = ICurrentBox(b).getBox(status) + data += box.td(align="center") + data += " </tr>\n" + + data += " <tr>\n" + TZ = time.tzname[time.localtime()[-1]] + data += td("time (%s)" % TZ, align="center", class_="Time") + data += td('<a href="%s">changes</a>' % request.childLink("../changes"), + align="center", class_="Change") + for name in builderNames: + safename = urllib.quote(name, safe='') + data += td('<a href="%s">%s</a>' % + (request.childLink("../builders/%s" % safename), name), + align="center", class_="Builder") + data += " </tr>\n" + + if phase == 1: + f = self.phase1 + else: + f = self.phase2 + data += f(request, changeNames + builderNames, timestamps, eventGrid, + sourceEvents) + + data += "</table>\n" + + data += '<hr /><div class="footer">\n' + + def with_args(req, remove_args=[], new_args=[], new_path=None): + # sigh, nevow makes this sort of manipulation easier + newargs = req.args.copy() + for argname in remove_args: + newargs[argname] = [] + if "branch" in newargs: + newargs["branch"] = [b for b in newargs["branch"] if b] + for k,v in new_args: + if k in newargs: + newargs[k].append(v) + else: + newargs[k] = [v] + newquery = "&".join(["%s=%s" % (k, v) + for k in newargs + for v in newargs[k] + ]) + if new_path: + new_url = new_path + elif req.prepath: + new_url = req.prepath[-1] + else: + new_url = '' + if newquery: + new_url += "?" + newquery + return new_url + + if timestamps: + bottom = timestamps[-1] + nextpage = with_args(request, ["last_time"], + [("last_time", str(int(bottom)))]) + data += '[<a href="%s">next page</a>]\n' % nextpage + + helpurl = self.path_to_root(request) + "waterfall/help" + helppage = with_args(request, new_path=helpurl) + data += '[<a href="%s">help</a>]\n' % helppage + + welcomeurl = self.path_to_root(request) + "index.html" + data += '[<a href="%s">welcome</a>]\n' % welcomeurl + + if self.get_reload_time(request) is not None: + no_reload_page = with_args(request, remove_args=["reload"]) + data += '[<a href="%s">Stop Reloading</a>]\n' % no_reload_page + + data += "<br />\n" + + + bburl = "http://buildbot.net/?bb-ver=%s" % urllib.quote(version) + data += '<a href="%s">Buildbot-%s</a> ' % (bburl, version) + if projectName: + data += "working for the " + if projectURL: + data += '<a href="%s">%s</a> project.' % (projectURL, + projectName) + else: + data += "%s project." % projectName + data += "<br />\n" + # TODO: push this to the right edge, if possible + data += ("Page built: " + + time.strftime("%a %d %b %Y %H:%M:%S", + time.localtime(util.now())) + + "\n") + data += '</div>\n' + return data + + def body0(self, request, builders): + # build the waterfall display + data = "" + data += "<h2>Basic display</h2>\n" + data += '<p>See <a href="%s">here</a>' % request.childLink("../waterfall") + data += " for the waterfall display</p>\n" + + data += '<table border="0" cellspacing="0">\n' + names = map(lambda builder: builder.name, builders) + + # the top row is two blank spaces, then the top-level status boxes + data += " <tr>\n" + data += td("", colspan=2) + for b in builders: + text = "" + state, builds = b.getState() + if state != "offline": + text += "%s<br />\n" % state #b.getCurrentBig().text[0] + else: + text += "OFFLINE<br />\n" + data += td(text, align="center") + + # the next row has the column headers: time, changes, builder names + data += " <tr>\n" + data += td("Time", align="center") + data += td("Changes", align="center") + for name in names: + data += td('<a href="%s">%s</a>' % + (request.childLink("../" + urllib.quote(name)), name), + align="center") + data += " </tr>\n" + + # all further rows involve timestamps, commit events, and build events + data += " <tr>\n" + data += td("04:00", align="bottom") + data += td("fred", align="center") + for name in names: + data += td("stuff", align="center") + data += " </tr>\n" + + data += "</table>\n" + return data + + def buildGrid(self, request, builders): + debug = False + # TODO: see if we can use a cached copy + + showEvents = False + if request.args.get("show_events", ["true"])[0].lower() == "true": + showEvents = True + filterBranches = [b for b in request.args.get("branch", []) if b] + filterBranches = map_branches(filterBranches) + maxTime = int(request.args.get("last_time", [util.now()])[0]) + if "show_time" in request.args: + minTime = maxTime - int(request.args["show_time"][0]) + elif "first_time" in request.args: + minTime = int(request.args["first_time"][0]) + else: + minTime = None + spanLength = 10 # ten-second chunks + maxPageLen = int(request.args.get("num_events", [200])[0]) + + # first step is to walk backwards in time, asking each column + # (commit, all builders) if they have any events there. Build up the + # array of events, and stop when we have a reasonable number. + + commit_source = self.getChangemaster(request) + + lastEventTime = util.now() + sources = [commit_source] + builders + changeNames = ["changes"] + builderNames = map(lambda builder: builder.getName(), builders) + sourceNames = changeNames + builderNames + sourceEvents = [] + sourceGenerators = [] + + def get_event_from(g): + try: + while True: + e = g.next() + # e might be builder.BuildStepStatus, + # builder.BuildStatus, builder.Event, + # waterfall.Spacer(builder.Event), or changes.Change . + # The showEvents=False flag means we should hide + # builder.Event . + if not showEvents and isinstance(e, builder.Event): + continue + break + event = interfaces.IStatusEvent(e) + if debug: + log.msg("gen %s gave1 %s" % (g, event.getText())) + except StopIteration: + event = None + return event + + for s in sources: + gen = insertGaps(s.eventGenerator(filterBranches), lastEventTime) + sourceGenerators.append(gen) + # get the first event + sourceEvents.append(get_event_from(gen)) + eventGrid = [] + timestamps = [] + + lastEventTime = 0 + for e in sourceEvents: + if e and e.getTimes()[0] > lastEventTime: + lastEventTime = e.getTimes()[0] + if lastEventTime == 0: + lastEventTime = util.now() + + spanStart = lastEventTime - spanLength + debugGather = 0 + + while 1: + if debugGather: log.msg("checking (%s,]" % spanStart) + # the tableau of potential events is in sourceEvents[]. The + # window crawls backwards, and we examine one source at a time. + # If the source's top-most event is in the window, is it pushed + # onto the events[] array and the tableau is refilled. This + # continues until the tableau event is not in the window (or is + # missing). + + spanEvents = [] # for all sources, in this span. row of eventGrid + firstTimestamp = None # timestamp of first event in the span + lastTimestamp = None # last pre-span event, for next span + + for c in range(len(sourceGenerators)): + events = [] # for this source, in this span. cell of eventGrid + event = sourceEvents[c] + while event and spanStart < event.getTimes()[0]: + # to look at windows that don't end with the present, + # condition the .append on event.time <= spanFinish + if not IBox(event, None): + log.msg("BAD EVENT", event, event.getText()) + assert 0 + if debug: + log.msg("pushing", event.getText(), event) + events.append(event) + starts, finishes = event.getTimes() + firstTimestamp = util.earlier(firstTimestamp, starts) + event = get_event_from(sourceGenerators[c]) + if debug: + log.msg("finished span") + + if event: + # this is the last pre-span event for this source + lastTimestamp = util.later(lastTimestamp, + event.getTimes()[0]) + if debugGather: + log.msg(" got %s from %s" % (events, sourceNames[c])) + sourceEvents[c] = event # refill the tableau + spanEvents.append(events) + + # only show events older than maxTime. This makes it possible to + # visit a page that shows what it would be like to scroll off the + # bottom of this one. + if firstTimestamp is not None and firstTimestamp <= maxTime: + eventGrid.append(spanEvents) + timestamps.append(firstTimestamp) + + if lastTimestamp: + spanStart = lastTimestamp - spanLength + else: + # no more events + break + if minTime is not None and lastTimestamp < minTime: + break + + if len(timestamps) > maxPageLen: + break + + + # now loop + + # loop is finished. now we have eventGrid[] and timestamps[] + if debugGather: log.msg("finished loop") + assert(len(timestamps) == len(eventGrid)) + return (changeNames, builderNames, timestamps, eventGrid, sourceEvents) + + def phase0(self, request, sourceNames, timestamps, eventGrid): + # phase0 rendering + if not timestamps: + return "no events" + data = "" + for r in range(0, len(timestamps)): + data += "<p>\n" + data += "[%s]<br />" % timestamps[r] + row = eventGrid[r] + assert(len(row) == len(sourceNames)) + for c in range(0, len(row)): + if row[c]: + data += "<b>%s</b><br />\n" % sourceNames[c] + for e in row[c]: + log.msg("Event", r, c, sourceNames[c], e.getText()) + lognames = [loog.getName() for loog in e.getLogs()] + data += "%s: %s: %s<br />" % (e.getText(), + e.getTimes()[0], + lognames) + else: + data += "<b>%s</b> [none]<br />\n" % sourceNames[c] + return data + + def phase1(self, request, sourceNames, timestamps, eventGrid, + sourceEvents): + # phase1 rendering: table, but boxes do not overlap + data = "" + if not timestamps: + return data + lastDate = None + for r in range(0, len(timestamps)): + chunkstrip = eventGrid[r] + # chunkstrip is a horizontal strip of event blocks. Each block + # is a vertical list of events, all for the same source. + assert(len(chunkstrip) == len(sourceNames)) + maxRows = reduce(lambda x,y: max(x,y), + map(lambda x: len(x), chunkstrip)) + for i in range(maxRows): + data += " <tr>\n"; + if i == 0: + stuff = [] + # add the date at the beginning, and each time it changes + today = time.strftime("<b>%d %b %Y</b>", + time.localtime(timestamps[r])) + todayday = time.strftime("<b>%a</b>", + time.localtime(timestamps[r])) + if today != lastDate: + stuff.append(todayday) + stuff.append(today) + lastDate = today + stuff.append( + time.strftime("%H:%M:%S", + time.localtime(timestamps[r]))) + data += td(stuff, valign="bottom", align="center", + rowspan=maxRows, class_="Time") + for c in range(0, len(chunkstrip)): + block = chunkstrip[c] + assert(block != None) # should be [] instead + # bottom-justify + offset = maxRows - len(block) + if i < offset: + data += td("") + else: + e = block[i-offset] + box = IBox(e).getBox(request) + box.parms["show_idle"] = 1 + data += box.td(valign="top", align="center") + data += " </tr>\n" + + return data + + def phase2(self, request, sourceNames, timestamps, eventGrid, + sourceEvents): + data = "" + if not timestamps: + return data + # first pass: figure out the height of the chunks, populate grid + grid = [] + for i in range(1+len(sourceNames)): + grid.append([]) + # grid is a list of columns, one for the timestamps, and one per + # event source. Each column is exactly the same height. Each element + # of the list is a single <td> box. + lastDate = time.strftime("<b>%d %b %Y</b>", + time.localtime(util.now())) + for r in range(0, len(timestamps)): + chunkstrip = eventGrid[r] + # chunkstrip is a horizontal strip of event blocks. Each block + # is a vertical list of events, all for the same source. + assert(len(chunkstrip) == len(sourceNames)) + maxRows = reduce(lambda x,y: max(x,y), + map(lambda x: len(x), chunkstrip)) + for i in range(maxRows): + if i != maxRows-1: + grid[0].append(None) + else: + # timestamp goes at the bottom of the chunk + stuff = [] + # add the date at the beginning (if it is not the same as + # today's date), and each time it changes + todayday = time.strftime("<b>%a</b>", + time.localtime(timestamps[r])) + today = time.strftime("<b>%d %b %Y</b>", + time.localtime(timestamps[r])) + if today != lastDate: + stuff.append(todayday) + stuff.append(today) + lastDate = today + stuff.append( + time.strftime("%H:%M:%S", + time.localtime(timestamps[r]))) + grid[0].append(Box(text=stuff, class_="Time", + valign="bottom", align="center")) + + # at this point the timestamp column has been populated with + # maxRows boxes, most None but the last one has the time string + for c in range(0, len(chunkstrip)): + block = chunkstrip[c] + assert(block != None) # should be [] instead + for i in range(maxRows - len(block)): + # fill top of chunk with blank space + grid[c+1].append(None) + for i in range(len(block)): + # so the events are bottom-justified + b = IBox(block[i]).getBox(request) + b.parms['valign'] = "top" + b.parms['align'] = "center" + grid[c+1].append(b) + # now all the other columns have maxRows new boxes too + # populate the last row, if empty + gridlen = len(grid[0]) + for i in range(len(grid)): + strip = grid[i] + assert(len(strip) == gridlen) + if strip[-1] == None: + if sourceEvents[i-1]: + filler = IBox(sourceEvents[i-1]).getBox(request) + else: + # this can happen if you delete part of the build history + filler = Box(text=["?"], align="center") + strip[-1] = filler + strip[-1].parms['rowspan'] = 1 + # second pass: bubble the events upwards to un-occupied locations + # Every square of the grid that has a None in it needs to have + # something else take its place. + noBubble = request.args.get("nobubble",['0']) + noBubble = int(noBubble[0]) + if not noBubble: + for col in range(len(grid)): + strip = grid[col] + if col == 1: # changes are handled differently + for i in range(2, len(strip)+1): + # only merge empty boxes. Don't bubble commit boxes. + if strip[-i] == None: + next = strip[-i+1] + assert(next) + if next: + #if not next.event: + if next.spacer: + # bubble the empty box up + strip[-i] = next + strip[-i].parms['rowspan'] += 1 + strip[-i+1] = None + else: + # we are above a commit box. Leave it + # be, and turn the current box into an + # empty one + strip[-i] = Box([], rowspan=1, + comment="commit bubble") + strip[-i].spacer = True + else: + # we are above another empty box, which + # somehow wasn't already converted. + # Shouldn't happen + pass + else: + for i in range(2, len(strip)+1): + # strip[-i] will go from next-to-last back to first + if strip[-i] == None: + # bubble previous item up + assert(strip[-i+1] != None) + strip[-i] = strip[-i+1] + strip[-i].parms['rowspan'] += 1 + strip[-i+1] = None + else: + strip[-i].parms['rowspan'] = 1 + # third pass: render the HTML table + for i in range(gridlen): + data += " <tr>\n"; + for strip in grid: + b = strip[i] + if b: + data += b.td() + else: + if noBubble: + data += td([]) + # Nones are left empty, rowspan should make it all fit + data += " </tr>\n" + return data + |