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
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
):
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.
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
:
48 if state
== "building":
55 text
.extend(self
.formatETA(eta
))
56 elif state
== "offline":
62 elif state
== "waiting":
66 # just in case I add a state and forget to update this
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()
78 text
.append("%d pending" % len(pbs
))
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
101 assert interfaces
.IBuilderStatus(self
.original
)
102 b
= self
.original
.getLastFinishedBuild()
104 return Box(["none"], "white", class_
="LastBuild")
105 name
= b
.getBuilder().getName()
106 number
= b
.getNumber()
107 url
= "%s/builds/%d" % (name
, number
)
110 # TODO: add link to the per-build page at 'url'
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
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
))
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
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
):
143 b
= self
.original
.getBuild()
144 urlbase
= "builders/%s/builds/%d/steps/%s" % (
145 urllib
.quote(b
.getBuilder().getName(), safe
=''),
147 urllib
.quote(self
.original
.getName(), safe
=''))
148 text
= self
.original
.getText()
150 log
.msg("getText() gave None", urlbase
)
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
)))
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
):
176 text
= self
.original
.getText()
177 color
= self
.original
.getColor()
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
):
188 self
.finished
= finish
190 class SpacerBox(components
.Adapter
):
194 #b = Box(["spacer"], "white")
198 components
.registerAdapter(SpacerBox
, Spacer
, IBox
)
200 def insertGaps(g
, lastEventTime
, idleGap
=2):
204 starts
, finishes
= e
.getTimes()
205 if debug
: log
.msg("E0", starts
, finishes
)
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
)
220 starts
, finishes
= e
.getTimes()
221 if debug
: log
.msg("E2", starts
, finishes
)
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
229 log
.msg(" finishes=%s, gap=%s, fES=%s" % \
230 (finishes
, idleGap
, followingEventStarts
))
231 yield Spacer(finishes
, followingEventStarts
)
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
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()
249 return "BuildBot: %s" % p
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
)
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
)
273 for b
in showBuilders
:
274 if b
not in allBuilders
:
276 if b
in builderNames
:
278 builderNames
.append(b
)
280 builderNames
= allBuilders
281 builders
= map(lambda name
: status
.getBuilder(name
),
285 return self
.body0(request
, builders
)
286 (changeNames
, builderNames
, timestamps
, eventGrid
, sourceEvents
) = \
287 self
.buildGrid(request
, builders
)
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
)
299 topleft
= "last build"
300 data
+= ' <tr class="LastBuild">\n'
301 data
+= td(topleft
, align
="right", colspan
=2, class_
="Project")
303 box
= ITopBox(b
).getBox()
304 data
+= box
.td(align
="center")
307 data
+= ' <tr class="Activity">\n'
308 data
+= td('current activity', align
='right', colspan
=2)
310 box
= ICurrentBox(b
).getBox(status
)
311 data
+= box
.td(align
="center")
315 TZ
= time
.tzname
[time
.daylight
]
316 data
+= td("time (%s)" % TZ
, align
="center", class_
="Time")
317 name
= changeNames
[0]
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")
331 data
+= f(request
, changeNames
+ builderNames
, timestamps
, eventGrid
,
338 bburl
= "http://buildbot.net/?bb-ver=%s" % urllib
.quote(version
)
339 data
+= "<a href=\"%s\">Buildbot-%s</a> " % (bburl
, version
)
341 data
+= "working for the "
343 data
+= "<a href=\"%s\">%s</a> project." % (projectURL
,
346 data
+= "%s project." % projectName
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()))
355 def body0(self
, request
, builders
):
356 # build the waterfall display
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
368 data
+= td("", colspan
=2)
372 state
, builds
= b
.getState()
373 if state
!= "offline":
374 text
+= "%s<br />\n" % state
#b.getCurrentBig().text[0]
376 text
+= "OFFLINE<br />\n"
378 data
+= td(text
, align
="center", bgcolor
=color
)
380 # the next row has the column headers: time, changes, builder names
382 data
+= td("Time", align
="center")
383 data
+= td("Changes", align
="center")
386 "<a href=\"%s\">%s</a>" % (urllib
.quote(request
.childLink(name
)), name
),
390 # all further rows involve timestamps, commit events, and build events
392 data
+= td("04:00", align
="bottom")
393 data
+= td("fred", align
="center")
395 data
+= td("stuff", align
="center", bgcolor
="red")
401 def buildGrid(self
, request
, builders
):
404 # XXX: see if we can use a cached copy
407 if request
.args
.get("show_events", ["false"])[0].lower() == "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
422 sourceGenerators
= []
424 def get_event_from(g
):
428 if not showEvents
and isinstance(e
, builder
.Event
):
431 event
= interfaces
.IStatusEvent(e
)
433 log
.msg("gen %s gave1 %s" % (g
, event
.getText()))
434 except StopIteration:
439 gen
= insertGaps(s
.eventGenerator(), lastEventTime
)
440 sourceGenerators
.append(gen
)
441 # get the first event
442 sourceEvents
.append(get_event_from(gen
))
445 spanLength
= 10 # ten-second chunks
446 tooOld
= util
.now() - 12*60*60 # never show more than 12 hours
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
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
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())
482 log
.msg("pushing", event
.getText(), event
)
484 starts
, finishes
= event
.getTimes()
485 firstTimestamp
= util
.earlier(firstTimestamp
, starts
)
486 event
= get_event_from(sourceGenerators
[c
])
488 log
.msg("finished span")
491 # this is the last pre-span event for this source
492 lastTimestamp
= util
.later(lastTimestamp
,
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
)
505 spanStart
= lastTimestamp
- spanLength
509 if lastTimestamp
< tooOld
:
512 if len(timestamps
) > maxPageLen
:
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
):
528 for r
in range(0, len(timestamps
)):
530 data
+= "[%s]<br />" % timestamps
[r
]
532 assert(len(row
) == len(sourceNames
))
533 for c
in range(0, len(row
)):
535 data
+= "<b>%s</b><br />\n" % sourceNames
[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(),
544 data
+= "<b>%s</b> [none]<br />\n" % sourceNames
[c
]
547 def phase1(self
, request
, sourceNames
, timestamps
, eventGrid
,
549 # phase1 rendering: table, but boxes do not overlap
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
):
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
)
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
583 offset
= maxRows
- len(block
)
588 box
= IBox(e
).getBox()
589 box
.parms
["show_idle"] = 1
590 data
+= box
.td(valign
="top", align
="center")
595 def phase2(self
, request
, sourceNames
, timestamps
, eventGrid
,
600 # first pass: figure out the height of the chunks, populate grid
602 for i
in range(1+len(sourceNames
)):
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
):
620 # timestamp goes at the bottom of the chunk
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
)
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"
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
)):
657 assert(len(strip
) == gridlen
)
658 if strip
[-1] == None:
659 if sourceEvents
[i
-1]:
660 filler
= IBox(sourceEvents
[i
-1]).getBox()
662 # this can happen if you delete part of the build history
663 filler
= Box(text
=["?"], align
="center")
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])
672 for col
in range(len(grid
)):
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:
683 # bubble the empty box up
685 strip
[-i
].parms
['rowspan'] += 1
688 # we are above a commit box. Leave it
689 # be, and turn the current box into an
691 strip
[-i
] = Box([], rowspan
=1,
692 comment
="commit bubble")
693 strip
[-i
].spacer
= True
695 # we are above another empty box, which
696 # somehow wasn't already converted.
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
709 strip
[-i
].parms
['rowspan'] = 1
710 # third pass: render the HTML table
711 for i
in range(gridlen
):
720 # Nones are left empty, rowspan should make it all fit