# -*- 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 = ('Build %d' % (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("%s" % (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('[%s]' % (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 = '''

The Waterfall Display

The Waterfall display can be controlled by adding query arguments to the URL. For example, if your Waterfall is accessed via the URL http://buildbot.example.org:8080, then you could add a branch= argument (described below) by going to http://buildbot.example.org:8080?branch=beta4 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 branch= and two builder= arguments, you would use http://buildbot.example.org:8080?branch=beta4&builder=unix&builder=macos.

Limiting the Displayed Interval

The last_time= 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.

The first_time= argument provides the lower bound. No events will be displayed that occurred before this timestamp. Instead of providing first_time=, you can provide show_time=: in this case, first_time will be set equal to last_time minus show_time. show_time overrides first_time.

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 num_events= argument to override this this.

Hiding non-Build events

By passing show_events=false, you can remove the "buildslave attached", "buildslave detached", and "builder reconfigured" events that appear in-between the actual builds.

%(show_events_input)s

Showing only Certain Branches

If you provide one or more branch= arguments, the display will be limited to builds that used one of the given branches. If no branch= arguments are given, builds from all branches will be displayed.

Erase the text from these "Show Branch:" boxes to remove that branch filter. %(show_branches_input)s

Limiting the Builders that are Displayed

By adding one or more builder= 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 builder= arguments are provided, all Builders will be displayed.

To view a Waterfall page with only a subset of Builders displayed, select the Builders you are interested in here.

%(show_builders_input)s

Auto-reloading the Page

Adding a reload= argument will cause the page to automatically reload itself after that many seconds.

%(show_reload_input)s

Reload Waterfall Page

''' 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 = ('

' '' 'Hide non-Build events' '

\n' ) % showEvents_checked branches = [b for b in request.args.get("branch", []) if b] branches.append('') show_branches_input = '\n' for b in branches: show_branches_input += ('' '\n' ) % (b,) show_branches_input += '
Show Branch: ' '' '
\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 = '\n' for bn in allBuilders: checked = "" if bn in showBuilders: checked = 'checked="checked"' show_builders_input += ('' ' ' '\n' ) % (bn, checked, bn) show_builders_input += '
%s
\n' # a couple of radio-button selectors for refresh time will appear # just after that text show_reload_input = '\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 += ('' ' ' '\n' ) % (value, checked, name) show_reload_input += '
%s
\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 += '\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 += '\n' if projectName and projectURL: # TODO: this is going to look really ugly topleft = '%s
last build' % \ (projectURL, projectName) else: topleft = "last build" data += ' \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 += " \n" data += ' \n' data += td('current activity', align='right', colspan=2) for b in builders: box = ICurrentBox(b).getBox(status) data += box.td(align="center") data += " \n" data += " \n" TZ = time.tzname[time.localtime()[-1]] data += td("time (%s)" % TZ, align="center", class_="Time") data += td('changes' % request.childLink("../changes"), align="center", class_="Change") for name in builderNames: safename = urllib.quote(name, safe='') data += td('%s' % (request.childLink("../builders/%s" % safename), name), align="center", class_="Builder") data += " \n" if phase == 1: f = self.phase1 else: f = self.phase2 data += f(request, changeNames + builderNames, timestamps, eventGrid, sourceEvents) data += "
\n" data += '
\n' return data def body0(self, request, builders): # build the waterfall display data = "" data += "

Basic display

\n" data += '

See here' % request.childLink("../waterfall") data += " for the waterfall display

\n" data += '\n' names = map(lambda builder: builder.name, builders) # the top row is two blank spaces, then the top-level status boxes data += " \n" data += td("", colspan=2) for b in builders: text = "" state, builds = b.getState() if state != "offline": text += "%s
\n" % state #b.getCurrentBig().text[0] else: text += "OFFLINE
\n" data += td(text, align="center") # the next row has the column headers: time, changes, builder names data += " \n" data += td("Time", align="center") data += td("Changes", align="center") for name in names: data += td('%s' % (request.childLink("../" + urllib.quote(name)), name), align="center") data += " \n" # all further rows involve timestamps, commit events, and build events data += " \n" data += td("04:00", align="bottom") data += td("fred", align="center") for name in names: data += td("stuff", align="center") data += " \n" data += "
\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 += "

\n" data += "[%s]
" % timestamps[r] row = eventGrid[r] assert(len(row) == len(sourceNames)) for c in range(0, len(row)): if row[c]: data += "%s
\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
" % (e.getText(), e.getTimes()[0], lognames) else: data += "%s [none]
\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 += " \n"; if i == 0: stuff = [] # add the date at the beginning, and each time it changes today = time.strftime("%d %b %Y", time.localtime(timestamps[r])) todayday = time.strftime("%a", 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 += " \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 box. lastDate = time.strftime("%d %b %Y", 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("%a", time.localtime(timestamps[r])) today = time.strftime("%d %b %Y", 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 += " \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 += " \n" return data