WebStatus: remove css= argument, all pages use root/buildbot.css
[buildbot.git] / buildbot / status / web / waterfall.py
blob154f2131e24c2660004f56e070513e00ec11662e
1 # -*- test-case-name: buildbot.test.test_web -*-
3 from zope.interface import implements
4 from twisted.python import log, components
5 from twisted.web import html
6 import urllib
8 import time
10 from buildbot import interfaces, util
11 from buildbot import version
12 from buildbot.status import builder
14 from buildbot.status.web.base import Box, HtmlResource, IBox, ICurrentBox, \
15 ITopBox, td, build_get_class
19 class CurrentBox(components.Adapter):
20 # this provides the "current activity" box, just above the builder name
21 implements(ICurrentBox)
23 def formatETA(self, eta):
24 if eta is None:
25 return []
26 if eta < 0:
27 return ["Soon"]
28 abstime = time.strftime("%H:%M:%S", time.localtime(util.now()+eta))
29 return ["ETA in", "%d secs" % eta, "at %s" % abstime]
31 def getBox(self, status):
32 # getState() returns offline, idle, or building
33 state, builds = self.original.getState()
35 # look for upcoming builds. We say the state is "waiting" if the
36 # builder is otherwise idle and there is a scheduler which tells us a
37 # build will be performed some time in the near future. TODO: this
38 # functionality used to be in BuilderStatus.. maybe this code should
39 # be merged back into it.
40 upcoming = []
41 builderName = self.original.getName()
42 for s in status.getSchedulers():
43 if builderName in s.listBuilderNames():
44 upcoming.extend(s.getPendingBuildTimes())
45 if state == "idle" and upcoming:
46 state = "waiting"
48 if state == "building":
49 color = "yellow"
50 text = ["building"]
51 if builds:
52 for b in builds:
53 eta = b.getETA()
54 if eta:
55 text.extend(self.formatETA(eta))
56 elif state == "offline":
57 color = "red"
58 text = ["offline"]
59 elif state == "idle":
60 color = "white"
61 text = ["idle"]
62 elif state == "waiting":
63 color = "yellow"
64 text = ["waiting"]
65 else:
66 # just in case I add a state and forget to update this
67 color = "white"
68 text = [state]
70 # TODO: for now, this pending/upcoming stuff is in the "current
71 # activity" box, but really it should go into a "next activity" row
72 # instead. The only times it should show up in "current activity" is
73 # when the builder is otherwise idle.
75 # are any builds pending? (waiting for a slave to be free)
76 pbs = self.original.getPendingBuilds()
77 if pbs:
78 text.append("%d pending" % len(pbs))
79 for t in upcoming:
80 text.extend(["next at",
81 time.strftime("%H:%M:%S", time.localtime(t)),
82 "[%d secs]" % (t - util.now()),
84 # TODO: the upcoming-builds box looks like:
85 # ['waiting', 'next at', '22:14:15', '[86 secs]']
86 # while the currently-building box is reversed:
87 # ['building', 'ETA in', '2 secs', 'at 22:12:50']
88 # consider swapping one of these to make them look the same. also
89 # consider leaving them reversed to make them look different.
90 return Box(text, color=color, class_="Activity " + state)
92 components.registerAdapter(CurrentBox, builder.BuilderStatus, ICurrentBox)
95 class BuildTopBox(components.Adapter):
96 # this provides a per-builder box at the very top of the display,
97 # showing the results of the most recent build
98 implements(IBox)
100 def getBox(self):
101 assert interfaces.IBuilderStatus(self.original)
102 b = self.original.getLastFinishedBuild()
103 if not b:
104 return Box(["none"], "white", class_="LastBuild")
105 name = b.getBuilder().getName()
106 number = b.getNumber()
107 url = "%s/builds/%d" % (name, number)
108 text = b.getText()
109 # TODO: add logs?
110 # TODO: add link to the per-build page at 'url'
111 c = b.getColor()
112 class_ = build_get_class(b)
113 return Box(text, c, class_="LastBuild %s" % class_)
114 components.registerAdapter(BuildTopBox, builder.BuilderStatus, ITopBox)
116 class BuildBox(components.Adapter):
117 # this provides the yellow "starting line" box for each build
118 implements(IBox)
120 def getBox(self):
121 b = self.original
122 name = b.getBuilder().getName()
123 number = b.getNumber()
124 url = "builders/%s/builds/%d" % (urllib.quote(name, safe=''), number)
125 reason = b.getReason()
126 text = ('<a title="Reason: %s" href="%s">Build %d</a>'
127 % (html.escape(reason), url, number))
128 color = "yellow"
129 class_ = "start"
130 if b.isFinished() and not b.getSteps():
131 # the steps have been pruned, so there won't be any indication
132 # of whether it succeeded or failed. Color the box red or green
133 # to show its status
134 color = b.getColor()
135 class_ = build_get_class(b)
136 return Box([text], color=color, class_="BuildStep " + class_)
137 components.registerAdapter(BuildBox, builder.BuildStatus, IBox)
139 class StepBox(components.Adapter):
140 implements(IBox)
142 def getBox(self):
143 b = self.original.getBuild()
144 urlbase = "builders/%s/builds/%d/steps/%s" % (
145 urllib.quote(b.getBuilder().getName(), safe=''),
146 b.getNumber(),
147 urllib.quote(self.original.getName(), safe=''))
148 text = self.original.getText()
149 if text is None:
150 log.msg("getText() gave None", urlbase)
151 text = []
152 text = text[:]
153 logs = self.original.getLogs()
154 for num in range(len(logs)):
155 name = logs[num].getName()
156 if logs[num].hasContents():
157 url = urlbase + "/logs/%s" % urllib.quote(name)
158 text.append("<a href=\"%s\">%s</a>" % (url, html.escape(name)))
159 else:
160 text.append(html.escape(name))
161 urls = self.original.getURLs()
162 ex_url_class = "BuildStep external"
163 for name, target in urls.items():
164 text.append('[<a href="%s" class="%s">%s</a>]' %
165 (target, ex_url_class, html.escape(name)))
166 color = self.original.getColor()
167 class_ = "BuildStep " + build_get_class(self.original)
168 return Box(text, color, class_=class_)
169 components.registerAdapter(StepBox, builder.BuildStepStatus, IBox)
172 class EventBox(components.Adapter):
173 implements(IBox)
175 def getBox(self):
176 text = self.original.getText()
177 color = self.original.getColor()
178 class_ = "Event"
179 if color:
180 class_ += " " + color
181 return Box(text, color, class_=class_)
182 components.registerAdapter(EventBox, builder.Event, IBox)
185 class Spacer(builder.Event):
186 def __init__(self, start, finish):
187 self.started = start
188 self.finished = finish
190 class SpacerBox(components.Adapter):
191 implements(IBox)
193 def getBox(self):
194 #b = Box(["spacer"], "white")
195 b = Box([])
196 b.spacer = True
197 return b
198 components.registerAdapter(SpacerBox, Spacer, IBox)
200 def insertGaps(g, lastEventTime, idleGap=2):
201 debug = False
203 e = g.next()
204 starts, finishes = e.getTimes()
205 if debug: log.msg("E0", starts, finishes)
206 if finishes == 0:
207 finishes = starts
208 if debug: log.msg("E1 finishes=%s, gap=%s, lET=%s" % \
209 (finishes, idleGap, lastEventTime))
210 if finishes is not None and finishes + idleGap < lastEventTime:
211 if debug: log.msg(" spacer0")
212 yield Spacer(finishes, lastEventTime)
214 followingEventStarts = starts
215 if debug: log.msg(" fES0", starts)
216 yield e
218 while 1:
219 e = g.next()
220 starts, finishes = e.getTimes()
221 if debug: log.msg("E2", starts, finishes)
222 if finishes == 0:
223 finishes = starts
224 if finishes is not None and finishes + idleGap < followingEventStarts:
225 # there is a gap between the end of this event and the beginning
226 # of the next one. Insert an idle event so the waterfall display
227 # shows a gap here.
228 if debug:
229 log.msg(" finishes=%s, gap=%s, fES=%s" % \
230 (finishes, idleGap, followingEventStarts))
231 yield Spacer(finishes, followingEventStarts)
232 yield e
233 followingEventStarts = starts
234 if debug: log.msg(" fES1", starts)
237 class WaterfallStatusResource(HtmlResource):
238 """This builds the main status page, with the waterfall display, and
239 all child pages."""
241 def __init__(self, categories=None):
242 HtmlResource.__init__(self)
243 self.categories = categories
245 def getTitle(self, request):
246 status = self.getStatus(request)
247 p = status.getProjectName()
248 if p:
249 return "BuildBot: %s" % p
250 else:
251 return "BuildBot"
253 def getChangemaster(self, request):
254 # TODO: this wants to go away, access it through IStatus
255 return request.site.buildbot_service.parent.change_svc
257 def body(self, request):
258 "This method builds the main waterfall display."
260 status = self.getStatus(request)
261 data = ''
263 projectName = status.getProjectName()
264 projectURL = status.getProjectURL()
266 phase = request.args.get("phase",["2"])
267 phase = int(phase[0])
269 showBuilders = request.args.get("show", None)
270 allBuilders = status.getBuilderNames(categories=self.categories)
271 if showBuilders:
272 builderNames = []
273 for b in showBuilders:
274 if b not in allBuilders:
275 continue
276 if b in builderNames:
277 continue
278 builderNames.append(b)
279 else:
280 builderNames = allBuilders
281 builders = map(lambda name: status.getBuilder(name),
282 builderNames)
284 if phase == -1:
285 return self.body0(request, builders)
286 (changeNames, builderNames, timestamps, eventGrid, sourceEvents) = \
287 self.buildGrid(request, builders)
288 if phase == 0:
289 return self.phase0(request, (changeNames + builderNames),
290 timestamps, eventGrid)
291 # start the table: top-header material
292 data += '<table border="0" cellspacing="0">\n'
294 if projectName and projectURL:
295 # TODO: this is going to look really ugly
296 topleft = "<a href=\"%s\">%s</a><br />last build" % \
297 (projectURL, projectName)
298 else:
299 topleft = "last build"
300 data += ' <tr class="LastBuild">\n'
301 data += td(topleft, align="right", colspan=2, class_="Project")
302 for b in builders:
303 box = ITopBox(b).getBox()
304 data += box.td(align="center")
305 data += " </tr>\n"
307 data += ' <tr class="Activity">\n'
308 data += td('current activity', align='right', colspan=2)
309 for b in builders:
310 box = ICurrentBox(b).getBox(status)
311 data += box.td(align="center")
312 data += " </tr>\n"
314 data += " <tr>\n"
315 TZ = time.tzname[time.daylight]
316 data += td("time (%s)" % TZ, align="center", class_="Time")
317 name = changeNames[0]
318 data += td(
319 "<a href=\"%s\">%s</a>" % (urllib.quote(name, safe=''), name),
320 align="center", class_="Change")
321 for name in builderNames:
322 safename = urllib.quote(name, safe='')
323 data += td( "<a href=\"builders/%s\">%s</a>" % (safename, name),
324 align="center", class_="Builder")
325 data += " </tr>\n"
327 if phase == 1:
328 f = self.phase1
329 else:
330 f = self.phase2
331 data += f(request, changeNames + builderNames, timestamps, eventGrid,
332 sourceEvents)
334 data += "</table>\n"
336 data += "<hr />\n"
338 bburl = "http://buildbot.net/?bb-ver=%s" % urllib.quote(version)
339 data += "<a href=\"%s\">Buildbot-%s</a> " % (bburl, version)
340 if projectName:
341 data += "working for the "
342 if projectURL:
343 data += "<a href=\"%s\">%s</a> project." % (projectURL,
344 projectName)
345 else:
346 data += "%s project." % projectName
347 data += "<br />\n"
348 # TODO: push this to the right edge, if possible
349 data += ("Page built: " +
350 time.strftime("%a %d %b %Y %H:%M:%S",
351 time.localtime(util.now()))
352 + "\n")
353 return data
355 def body0(self, request, builders):
356 # build the waterfall display
357 data = ""
358 data += "<h2>Basic display</h2>\n"
359 data += "<p>See <a href=\"%s\">here</a>" % \
360 urllib.quote(request.childLink("waterfall"))
361 data += " for the waterfall display</p>\n"
363 data += '<table border="0" cellspacing="0">\n'
364 names = map(lambda builder: builder.name, builders)
366 # the top row is two blank spaces, then the top-level status boxes
367 data += " <tr>\n"
368 data += td("", colspan=2)
369 for b in builders:
370 text = ""
371 color = "#ca88f7"
372 state, builds = b.getState()
373 if state != "offline":
374 text += "%s<br />\n" % state #b.getCurrentBig().text[0]
375 else:
376 text += "OFFLINE<br />\n"
377 color = "#ffe0e0"
378 data += td(text, align="center", bgcolor=color)
380 # the next row has the column headers: time, changes, builder names
381 data += " <tr>\n"
382 data += td("Time", align="center")
383 data += td("Changes", align="center")
384 for name in names:
385 data += td(
386 "<a href=\"%s\">%s</a>" % (urllib.quote(request.childLink(name)), name),
387 align="center")
388 data += " </tr>\n"
390 # all further rows involve timestamps, commit events, and build events
391 data += " <tr>\n"
392 data += td("04:00", align="bottom")
393 data += td("fred", align="center")
394 for name in names:
395 data += td("stuff", align="center", bgcolor="red")
396 data += " </tr>\n"
398 data += "</table>\n"
399 return data
401 def buildGrid(self, request, builders):
402 debug = False
404 # XXX: see if we can use a cached copy
406 showEvents = False
407 if request.args.get("show_events", ["false"])[0].lower() == "true":
408 showEvents = True
410 # first step is to walk backwards in time, asking each column
411 # (commit, all builders) if they have any events there. Build up the
412 # array of events, and stop when we have a reasonable number.
414 commit_source = self.getChangemaster(request)
416 lastEventTime = util.now()
417 sources = [commit_source] + builders
418 changeNames = ["changes"]
419 builderNames = map(lambda builder: builder.getName(), builders)
420 sourceNames = changeNames + builderNames
421 sourceEvents = []
422 sourceGenerators = []
424 def get_event_from(g):
425 try:
426 while True:
427 e = g.next()
428 if not showEvents and isinstance(e, builder.Event):
429 continue
430 break
431 event = interfaces.IStatusEvent(e)
432 if debug:
433 log.msg("gen %s gave1 %s" % (g, event.getText()))
434 except StopIteration:
435 event = None
436 return event
438 for s in sources:
439 gen = insertGaps(s.eventGenerator(), lastEventTime)
440 sourceGenerators.append(gen)
441 # get the first event
442 sourceEvents.append(get_event_from(gen))
443 eventGrid = []
444 timestamps = []
445 spanLength = 10 # ten-second chunks
446 tooOld = util.now() - 12*60*60 # never show more than 12 hours
447 maxPageLen = 200
449 lastEventTime = 0
450 for e in sourceEvents:
451 if e and e.getTimes()[0] > lastEventTime:
452 lastEventTime = e.getTimes()[0]
453 if lastEventTime == 0:
454 lastEventTime = util.now()
456 spanStart = lastEventTime - spanLength
457 debugGather = 0
459 while 1:
460 if debugGather: log.msg("checking (%s,]" % spanStart)
461 # the tableau of potential events is in sourceEvents[]. The
462 # window crawls backwards, and we examine one source at a time.
463 # If the source's top-most event is in the window, is it pushed
464 # onto the events[] array and the tableau is refilled. This
465 # continues until the tableau event is not in the window (or is
466 # missing).
468 spanEvents = [] # for all sources, in this span. row of eventGrid
469 firstTimestamp = None # timestamp of first event in the span
470 lastTimestamp = None # last pre-span event, for next span
472 for c in range(len(sourceGenerators)):
473 events = [] # for this source, in this span. cell of eventGrid
474 event = sourceEvents[c]
475 while event and spanStart < event.getTimes()[0]:
476 # to look at windows that don't end with the present,
477 # condition the .append on event.time <= spanFinish
478 if not IBox(event, None):
479 log.msg("BAD EVENT", event, event.getText())
480 assert 0
481 if debug:
482 log.msg("pushing", event.getText(), event)
483 events.append(event)
484 starts, finishes = event.getTimes()
485 firstTimestamp = util.earlier(firstTimestamp, starts)
486 event = get_event_from(sourceGenerators[c])
487 if debug:
488 log.msg("finished span")
490 if event:
491 # this is the last pre-span event for this source
492 lastTimestamp = util.later(lastTimestamp,
493 event.getTimes()[0])
494 if debugGather:
495 log.msg(" got %s from %s" % (events, sourceNames[c]))
496 sourceEvents[c] = event # refill the tableau
497 spanEvents.append(events)
499 if firstTimestamp is not None:
500 eventGrid.append(spanEvents)
501 timestamps.append(firstTimestamp)
504 if lastTimestamp:
505 spanStart = lastTimestamp - spanLength
506 else:
507 # no more events
508 break
509 if lastTimestamp < tooOld:
510 pass
511 #break
512 if len(timestamps) > maxPageLen:
513 break
516 # now loop
518 # loop is finished. now we have eventGrid[] and timestamps[]
519 if debugGather: log.msg("finished loop")
520 assert(len(timestamps) == len(eventGrid))
521 return (changeNames, builderNames, timestamps, eventGrid, sourceEvents)
523 def phase0(self, request, sourceNames, timestamps, eventGrid):
524 # phase0 rendering
525 if not timestamps:
526 return "no events"
527 data = ""
528 for r in range(0, len(timestamps)):
529 data += "<p>\n"
530 data += "[%s]<br />" % timestamps[r]
531 row = eventGrid[r]
532 assert(len(row) == len(sourceNames))
533 for c in range(0, len(row)):
534 if row[c]:
535 data += "<b>%s</b><br />\n" % sourceNames[c]
536 for e in row[c]:
537 log.msg("Event", r, c, sourceNames[c], e.getText())
538 lognames = [loog.getName() for loog in e.getLogs()]
539 data += "%s: %s: %s %s<br />" % (e.getText(),
540 e.getTimes()[0],
541 e.getColor(),
542 lognames)
543 else:
544 data += "<b>%s</b> [none]<br />\n" % sourceNames[c]
545 return data
547 def phase1(self, request, sourceNames, timestamps, eventGrid,
548 sourceEvents):
549 # phase1 rendering: table, but boxes do not overlap
550 data = ""
551 if not timestamps:
552 return data
553 lastDate = None
554 for r in range(0, len(timestamps)):
555 chunkstrip = eventGrid[r]
556 # chunkstrip is a horizontal strip of event blocks. Each block
557 # is a vertical list of events, all for the same source.
558 assert(len(chunkstrip) == len(sourceNames))
559 maxRows = reduce(lambda x,y: max(x,y),
560 map(lambda x: len(x), chunkstrip))
561 for i in range(maxRows):
562 data += " <tr>\n";
563 if i == 0:
564 stuff = []
565 # add the date at the beginning, and each time it changes
566 today = time.strftime("<b>%d %b %Y</b>",
567 time.localtime(timestamps[r]))
568 todayday = time.strftime("<b>%a</b>",
569 time.localtime(timestamps[r]))
570 if today != lastDate:
571 stuff.append(todayday)
572 stuff.append(today)
573 lastDate = today
574 stuff.append(
575 time.strftime("%H:%M:%S",
576 time.localtime(timestamps[r])))
577 data += td(stuff, valign="bottom", align="center",
578 rowspan=maxRows, class_="Time")
579 for c in range(0, len(chunkstrip)):
580 block = chunkstrip[c]
581 assert(block != None) # should be [] instead
582 # bottom-justify
583 offset = maxRows - len(block)
584 if i < offset:
585 data += td("")
586 else:
587 e = block[i-offset]
588 box = IBox(e).getBox()
589 box.parms["show_idle"] = 1
590 data += box.td(valign="top", align="center")
591 data += " </tr>\n"
593 return data
595 def phase2(self, request, sourceNames, timestamps, eventGrid,
596 sourceEvents):
597 data = ""
598 if not timestamps:
599 return data
600 # first pass: figure out the height of the chunks, populate grid
601 grid = []
602 for i in range(1+len(sourceNames)):
603 grid.append([])
604 # grid is a list of columns, one for the timestamps, and one per
605 # event source. Each column is exactly the same height. Each element
606 # of the list is a single <td> box.
607 lastDate = time.strftime("<b>%d %b %Y</b>",
608 time.localtime(util.now()))
609 for r in range(0, len(timestamps)):
610 chunkstrip = eventGrid[r]
611 # chunkstrip is a horizontal strip of event blocks. Each block
612 # is a vertical list of events, all for the same source.
613 assert(len(chunkstrip) == len(sourceNames))
614 maxRows = reduce(lambda x,y: max(x,y),
615 map(lambda x: len(x), chunkstrip))
616 for i in range(maxRows):
617 if i != maxRows-1:
618 grid[0].append(None)
619 else:
620 # timestamp goes at the bottom of the chunk
621 stuff = []
622 # add the date at the beginning (if it is not the same as
623 # today's date), and each time it changes
624 todayday = time.strftime("<b>%a</b>",
625 time.localtime(timestamps[r]))
626 today = time.strftime("<b>%d %b %Y</b>",
627 time.localtime(timestamps[r]))
628 if today != lastDate:
629 stuff.append(todayday)
630 stuff.append(today)
631 lastDate = today
632 stuff.append(
633 time.strftime("%H:%M:%S",
634 time.localtime(timestamps[r])))
635 grid[0].append(Box(text=stuff, class_="Time",
636 valign="bottom", align="center"))
638 # at this point the timestamp column has been populated with
639 # maxRows boxes, most None but the last one has the time string
640 for c in range(0, len(chunkstrip)):
641 block = chunkstrip[c]
642 assert(block != None) # should be [] instead
643 for i in range(maxRows - len(block)):
644 # fill top of chunk with blank space
645 grid[c+1].append(None)
646 for i in range(len(block)):
647 # so the events are bottom-justified
648 b = IBox(block[i]).getBox()
649 b.parms['valign'] = "top"
650 b.parms['align'] = "center"
651 grid[c+1].append(b)
652 # now all the other columns have maxRows new boxes too
653 # populate the last row, if empty
654 gridlen = len(grid[0])
655 for i in range(len(grid)):
656 strip = grid[i]
657 assert(len(strip) == gridlen)
658 if strip[-1] == None:
659 if sourceEvents[i-1]:
660 filler = IBox(sourceEvents[i-1]).getBox()
661 else:
662 # this can happen if you delete part of the build history
663 filler = Box(text=["?"], align="center")
664 strip[-1] = filler
665 strip[-1].parms['rowspan'] = 1
666 # second pass: bubble the events upwards to un-occupied locations
667 # Every square of the grid that has a None in it needs to have
668 # something else take its place.
669 noBubble = request.args.get("nobubble",['0'])
670 noBubble = int(noBubble[0])
671 if not noBubble:
672 for col in range(len(grid)):
673 strip = grid[col]
674 if col == 1: # changes are handled differently
675 for i in range(2, len(strip)+1):
676 # only merge empty boxes. Don't bubble commit boxes.
677 if strip[-i] == None:
678 next = strip[-i+1]
679 assert(next)
680 if next:
681 #if not next.event:
682 if next.spacer:
683 # bubble the empty box up
684 strip[-i] = next
685 strip[-i].parms['rowspan'] += 1
686 strip[-i+1] = None
687 else:
688 # we are above a commit box. Leave it
689 # be, and turn the current box into an
690 # empty one
691 strip[-i] = Box([], rowspan=1,
692 comment="commit bubble")
693 strip[-i].spacer = True
694 else:
695 # we are above another empty box, which
696 # somehow wasn't already converted.
697 # Shouldn't happen
698 pass
699 else:
700 for i in range(2, len(strip)+1):
701 # strip[-i] will go from next-to-last back to first
702 if strip[-i] == None:
703 # bubble previous item up
704 assert(strip[-i+1] != None)
705 strip[-i] = strip[-i+1]
706 strip[-i].parms['rowspan'] += 1
707 strip[-i+1] = None
708 else:
709 strip[-i].parms['rowspan'] = 1
710 # third pass: render the HTML table
711 for i in range(gridlen):
712 data += " <tr>\n";
713 for strip in grid:
714 b = strip[i]
715 if b:
716 data += b.td()
717 else:
718 if noBubble:
719 data += td([])
720 # Nones are left empty, rowspan should make it all fit
721 data += " </tr>\n"
722 return data