OLD | NEW |
| (Empty) |
1 # -*- test-case-name: buildbot.test.test_web -*- | |
2 | |
3 from zope.interface import implements | |
4 from twisted.python import log, components | |
5 from twisted.web import html | |
6 import urllib | |
7 | |
8 import time | |
9 import operator | |
10 | |
11 from buildbot import interfaces, util | |
12 from buildbot import version | |
13 from buildbot.status import builder | |
14 | |
15 from buildbot.status.web.base import Box, HtmlResource, IBox, ICurrentBox, \ | |
16 ITopBox, td, build_get_class, path_to_build, path_to_step, map_branches | |
17 | |
18 | |
19 | |
20 class CurrentBox(components.Adapter): | |
21 # this provides the "current activity" box, just above the builder name | |
22 implements(ICurrentBox) | |
23 | |
24 def formatETA(self, prefix, eta): | |
25 if eta is None: | |
26 return [] | |
27 if eta < 60: | |
28 return ["< 1 min"] | |
29 eta_parts = ["~"] | |
30 eta_secs = eta | |
31 if eta_secs > 3600: | |
32 eta_parts.append("%d hrs" % (eta_secs / 3600)) | |
33 eta_secs %= 3600 | |
34 if eta_secs > 60: | |
35 eta_parts.append("%d mins" % (eta_secs / 60)) | |
36 eta_secs %= 60 | |
37 abstime = time.strftime("%H:%M", time.localtime(util.now()+eta)) | |
38 return [prefix, " ".join(eta_parts), "at %s" % abstime] | |
39 | |
40 def getBox(self, status): | |
41 # getState() returns offline, idle, or building | |
42 state, builds = self.original.getState() | |
43 | |
44 # look for upcoming builds. We say the state is "waiting" if the | |
45 # builder is otherwise idle and there is a scheduler which tells us a | |
46 # build will be performed some time in the near future. TODO: this | |
47 # functionality used to be in BuilderStatus.. maybe this code should | |
48 # be merged back into it. | |
49 upcoming = [] | |
50 builderName = self.original.getName() | |
51 for s in status.getSchedulers(): | |
52 if builderName in s.listBuilderNames(): | |
53 upcoming.extend(s.getPendingBuildTimes()) | |
54 if state == "idle" and upcoming: | |
55 state = "waiting" | |
56 | |
57 if state == "building": | |
58 text = ["building"] | |
59 if builds: | |
60 for b in builds: | |
61 eta = b.getETA() | |
62 text.extend(self.formatETA("ETA in", eta)) | |
63 elif state == "offline": | |
64 text = ["offline"] | |
65 elif state == "idle": | |
66 text = ["idle"] | |
67 elif state == "waiting": | |
68 text = ["waiting"] | |
69 else: | |
70 # just in case I add a state and forget to update this | |
71 text = [state] | |
72 | |
73 # TODO: for now, this pending/upcoming stuff is in the "current | |
74 # activity" box, but really it should go into a "next activity" row | |
75 # instead. The only times it should show up in "current activity" is | |
76 # when the builder is otherwise idle. | |
77 | |
78 # are any builds pending? (waiting for a slave to be free) | |
79 pbs = self.original.getPendingBuilds() | |
80 if pbs: | |
81 text.append("%d pending" % len(pbs)) | |
82 for t in upcoming: | |
83 eta = t - util.now() | |
84 text.extend(self.formatETA("next in", eta)) | |
85 return Box(text, class_="Activity " + state) | |
86 | |
87 components.registerAdapter(CurrentBox, builder.BuilderStatus, ICurrentBox) | |
88 | |
89 | |
90 class BuildTopBox(components.Adapter): | |
91 # this provides a per-builder box at the very top of the display, | |
92 # showing the results of the most recent build | |
93 implements(IBox) | |
94 | |
95 def getBox(self, req): | |
96 assert interfaces.IBuilderStatus(self.original) | |
97 branches = [b for b in req.args.get("branch", []) if b] | |
98 builder = self.original | |
99 builds = list(builder.generateFinishedBuilds(map_branches(branches), | |
100 num_builds=1)) | |
101 if not builds: | |
102 return Box(["none"], class_="LastBuild") | |
103 b = builds[0] | |
104 name = b.getBuilder().getName() | |
105 number = b.getNumber() | |
106 url = path_to_build(req, b) | |
107 text = b.getText() | |
108 tests_failed = b.getSummaryStatistic('tests-failed', operator.add, 0) | |
109 if tests_failed: text.extend(["Failed tests: %d" % tests_failed]) | |
110 # TODO: maybe add logs? | |
111 # TODO: add link to the per-build page at 'url' | |
112 class_ = build_get_class(b) | |
113 return Box(text, class_="LastBuild %s" % class_) | |
114 components.registerAdapter(BuildTopBox, builder.BuilderStatus, ITopBox) | |
115 | |
116 class BuildBox(components.Adapter): | |
117 # this provides the yellow "starting line" box for each build | |
118 implements(IBox) | |
119 | |
120 def getBox(self, req): | |
121 b = self.original | |
122 number = b.getNumber() | |
123 url = path_to_build(req, b) | |
124 reason = b.getReason() | |
125 text = ('<a title="Reason: %s" href="%s">Build %d</a>' | |
126 % (html.escape(reason), url, number)) | |
127 class_ = "start" | |
128 if b.isFinished() and not b.getSteps(): | |
129 # the steps have been pruned, so there won't be any indication | |
130 # of whether it succeeded or failed. | |
131 class_ = build_get_class(b) | |
132 return Box([text], class_="BuildStep " + class_) | |
133 components.registerAdapter(BuildBox, builder.BuildStatus, IBox) | |
134 | |
135 class StepBox(components.Adapter): | |
136 implements(IBox) | |
137 | |
138 def getBox(self, req): | |
139 urlbase = path_to_step(req, self.original) | |
140 text = self.original.getText() | |
141 if text is None: | |
142 log.msg("getText() gave None", urlbase) | |
143 text = [] | |
144 text = text[:] | |
145 logs = self.original.getLogs() | |
146 for num in range(len(logs)): | |
147 name = logs[num].getName() | |
148 if logs[num].hasContents(): | |
149 url = urlbase + "/logs/%s" % urllib.quote(name) | |
150 text.append("<a href=\"%s\">%s</a>" % (url, html.escape(name))) | |
151 else: | |
152 text.append(html.escape(name)) | |
153 urls = self.original.getURLs() | |
154 ex_url_class = "BuildStep external" | |
155 for name, target in urls.items(): | |
156 text.append('[<a href="%s" class="%s">%s</a>]' % | |
157 (target, ex_url_class, html.escape(name))) | |
158 class_ = "BuildStep " + build_get_class(self.original) | |
159 return Box(text, class_=class_) | |
160 components.registerAdapter(StepBox, builder.BuildStepStatus, IBox) | |
161 | |
162 | |
163 class EventBox(components.Adapter): | |
164 implements(IBox) | |
165 | |
166 def getBox(self, req): | |
167 text = self.original.getText() | |
168 class_ = "Event" | |
169 return Box(text, class_=class_) | |
170 components.registerAdapter(EventBox, builder.Event, IBox) | |
171 | |
172 | |
173 class Spacer: | |
174 implements(interfaces.IStatusEvent) | |
175 | |
176 def __init__(self, start, finish): | |
177 self.started = start | |
178 self.finished = finish | |
179 | |
180 def getTimes(self): | |
181 return (self.started, self.finished) | |
182 def getText(self): | |
183 return [] | |
184 | |
185 class SpacerBox(components.Adapter): | |
186 implements(IBox) | |
187 | |
188 def getBox(self, req): | |
189 #b = Box(["spacer"], "white") | |
190 b = Box([]) | |
191 b.spacer = True | |
192 return b | |
193 components.registerAdapter(SpacerBox, Spacer, IBox) | |
194 | |
195 def insertGaps(g, showEvents, lastEventTime, idleGap=2): | |
196 debug = False | |
197 | |
198 e = g.next() | |
199 starts, finishes = e.getTimes() | |
200 if debug: log.msg("E0", starts, finishes) | |
201 if finishes == 0: | |
202 finishes = starts | |
203 if debug: log.msg("E1 finishes=%s, gap=%s, lET=%s" % \ | |
204 (finishes, idleGap, lastEventTime)) | |
205 if finishes is not None and finishes + idleGap < lastEventTime: | |
206 if debug: log.msg(" spacer0") | |
207 yield Spacer(finishes, lastEventTime) | |
208 | |
209 followingEventStarts = starts | |
210 if debug: log.msg(" fES0", starts) | |
211 yield e | |
212 | |
213 while 1: | |
214 e = g.next() | |
215 if not showEvents and isinstance(e, builder.Event): | |
216 continue | |
217 starts, finishes = e.getTimes() | |
218 if debug: log.msg("E2", starts, finishes) | |
219 if finishes == 0: | |
220 finishes = starts | |
221 if finishes is not None and finishes + idleGap < followingEventStarts: | |
222 # there is a gap between the end of this event and the beginning | |
223 # of the next one. Insert an idle event so the waterfall display | |
224 # shows a gap here. | |
225 if debug: | |
226 log.msg(" finishes=%s, gap=%s, fES=%s" % \ | |
227 (finishes, idleGap, followingEventStarts)) | |
228 yield Spacer(finishes, followingEventStarts) | |
229 yield e | |
230 followingEventStarts = starts | |
231 if debug: log.msg(" fES1", starts) | |
232 | |
233 HELP = ''' | |
234 <form action="../waterfall" method="GET"> | |
235 | |
236 <h1>The Waterfall Display</h1> | |
237 | |
238 <p>The Waterfall display can be controlled by adding query arguments to the | |
239 URL. For example, if your Waterfall is accessed via the URL | |
240 <tt>http://buildbot.example.org:8080</tt>, then you could add a | |
241 <tt>branch=</tt> argument (described below) by going to | |
242 <tt>http://buildbot.example.org:8080?branch=beta4</tt> instead. Remember that | |
243 query arguments are separated from each other with ampersands, but they are | |
244 separated from the main URL with a question mark, so to add a | |
245 <tt>branch=</tt> and two <tt>builder=</tt> arguments, you would use | |
246 <tt>http://buildbot.example.org:8080?branch=beta4&builder=unix&builder=m
acos</tt>.</p> | |
247 | |
248 <h2>Limiting the Displayed Interval</h2> | |
249 | |
250 <p>The <tt>last_time=</tt> argument is a unix timestamp (seconds since the | |
251 start of 1970) that will be used as an upper bound on the interval of events | |
252 displayed: nothing will be shown that is more recent than the given time. | |
253 When no argument is provided, all events up to and including the most recent | |
254 steps are included.</p> | |
255 | |
256 <p>The <tt>first_time=</tt> argument provides the lower bound. No events will | |
257 be displayed that occurred <b>before</b> this timestamp. Instead of providing | |
258 <tt>first_time=</tt>, you can provide <tt>show_time=</tt>: in this case, | |
259 <tt>first_time</tt> will be set equal to <tt>last_time</tt> minus | |
260 <tt>show_time</tt>. <tt>show_time</tt> overrides <tt>first_time</tt>.</p> | |
261 | |
262 <p>The display normally shows the latest 200 events that occurred in the | |
263 given interval, where each timestamp on the left hand edge counts as a single | |
264 event. You can add a <tt>num_events=</tt> argument to override this this.</p> | |
265 | |
266 <h2>Showing non-Build events</h2> | |
267 | |
268 <p>By passing <tt>show_events=true</tt>, you can add the "buildslave | |
269 attached", "buildslave detached", and "builder reconfigured" events that | |
270 appear in-between the actual builds.</p> | |
271 | |
272 %(show_events_input)s | |
273 | |
274 <h2>Showing only the Builders with failures</h2> | |
275 | |
276 <p>By adding the <tt>failures_only=true</tt> argument, the display will be limit
ed | |
277 to showing builders that are currently failing. A builder is considered | |
278 failing if the last finished build was not successful, a step in the current | |
279 build(s) failed, or if the builder is offline. | |
280 | |
281 %(failures_only_input)s | |
282 | |
283 <h2>Showing only Certain Branches</h2> | |
284 | |
285 <p>If you provide one or more <tt>branch=</tt> arguments, the display will be | |
286 limited to builds that used one of the given branches. If no <tt>branch=</tt> | |
287 arguments are given, builds from all branches will be displayed.</p> | |
288 | |
289 Erase the text from these "Show Branch:" boxes to remove that branch filter. | |
290 | |
291 %(show_branches_input)s | |
292 | |
293 <h2>Limiting the Builders that are Displayed</h2> | |
294 | |
295 <p>By adding one or more <tt>builder=</tt> arguments, the display will be | |
296 limited to showing builds that ran on the given builders. This serves to | |
297 limit the display to the specific named columns. If no <tt>builder=</tt> | |
298 arguments are provided, all Builders will be displayed.</p> | |
299 | |
300 <p>To view a Waterfall page with only a subset of Builders displayed, select | |
301 the Builders you are interested in here.</p> | |
302 | |
303 %(show_builders_input)s | |
304 | |
305 <h2>Limiting the Builds that are Displayed</h2> | |
306 | |
307 <p>By adding one or more <tt>committer=</tt> arguments, the display will be | |
308 limited to showing builds that were started by the given committer. If no | |
309 <tt>committer=</tt> arguments are provided, all builds will be displayed.</p> | |
310 | |
311 <p>To view a Waterfall page with only a subset of Builds displayed, select | |
312 the committers your are interested in here.</p> | |
313 | |
314 %(show_committers_input)s | |
315 | |
316 | |
317 <h2>Auto-reloading the Page</h2> | |
318 | |
319 <p>Adding a <tt>reload=</tt> argument will cause the page to automatically | |
320 reload itself after that many seconds.</p> | |
321 | |
322 %(show_reload_input)s | |
323 | |
324 <h2>Reload Waterfall Page</h2> | |
325 | |
326 <input type="submit" value="View Waterfall" /> | |
327 </form> | |
328 ''' | |
329 | |
330 class WaterfallHelp(HtmlResource): | |
331 title = "Waterfall Help" | |
332 | |
333 def __init__(self, categories=None): | |
334 HtmlResource.__init__(self) | |
335 self.categories = categories | |
336 | |
337 def body(self, request): | |
338 data = '' | |
339 status = self.getStatus(request) | |
340 | |
341 showEvents_checked = '' | |
342 if request.args.get("show_events", ["false"])[0].lower() == "true": | |
343 showEvents_checked = 'checked="checked"' | |
344 show_events_input = ('<p>' | |
345 '<input type="checkbox" name="show_events" ' | |
346 'value="true" %s>' | |
347 'Show non-Build events' | |
348 '</p>\n' | |
349 ) % showEvents_checked | |
350 | |
351 failuresOnly_checked = '' | |
352 if request.args.get("failures_only", ["false"])[0].lower() == "true": | |
353 failuresOnly_checked = 'checked="checked"' | |
354 failures_only_input = ('<p>' | |
355 '<input type="checkbox" name="failures_only" ' | |
356 'value="true" %s>' | |
357 'Show failures only' | |
358 '</p>\n' | |
359 ) % failuresOnly_checked | |
360 | |
361 branches = [b | |
362 for b in request.args.get("branch", []) | |
363 if b] | |
364 branches.append('') | |
365 show_branches_input = '<table>\n' | |
366 for b in branches: | |
367 show_branches_input += ('<tr>' | |
368 '<td>Show Branch: ' | |
369 '<input type="text" name="branch" ' | |
370 'value="%s">' | |
371 '</td></tr>\n' | |
372 ) % (html.escape(b),) | |
373 show_branches_input += '</table>\n' | |
374 | |
375 # this has a set of toggle-buttons to let the user choose the | |
376 # builders | |
377 showBuilders = request.args.get("show", []) | |
378 showBuilders.extend(request.args.get("builder", [])) | |
379 allBuilders = status.getBuilderNames(categories=self.categories) | |
380 | |
381 show_builders_input = '<table>\n' | |
382 for bn in allBuilders: | |
383 checked = "" | |
384 if bn in showBuilders: | |
385 checked = 'checked="checked"' | |
386 show_builders_input += ('<tr>' | |
387 '<td><input type="checkbox"' | |
388 ' name="builder" ' | |
389 'value="%s" %s></td> ' | |
390 '<td>%s</td></tr>\n' | |
391 ) % (bn, checked, bn) | |
392 show_builders_input += '</table>\n' | |
393 | |
394 committers = [c for c in request.args.get("committer", []) if c] | |
395 committers.append('') | |
396 show_committers_input = '<table>\n' | |
397 for c in committers: | |
398 show_committers_input += ('<tr>' | |
399 '<td>Show committer: ' | |
400 '<input type="text" name="committer" ' | |
401 'value="%s">' | |
402 '</td></tr>\n' | |
403 ) % (html.escape(c),) | |
404 show_committers_input += '</table>\n' | |
405 | |
406 # a couple of radio-button selectors for refresh time will appear | |
407 # just after that text | |
408 show_reload_input = '<table>\n' | |
409 times = [("none", "None"), | |
410 ("60", "60 seconds"), | |
411 ("300", "5 minutes"), | |
412 ("600", "10 minutes"), | |
413 ] | |
414 current_reload_time = request.args.get("reload", ["none"]) | |
415 if current_reload_time: | |
416 current_reload_time = current_reload_time[0] | |
417 if current_reload_time not in [t[0] for t in times]: | |
418 times.insert(0, (current_reload_time, current_reload_time) ) | |
419 for value, name in times: | |
420 checked = "" | |
421 if value == current_reload_time: | |
422 checked = 'checked="checked"' | |
423 show_reload_input += ('<tr>' | |
424 '<td><input type="radio" name="reload" ' | |
425 'value="%s" %s></td> ' | |
426 '<td>%s</td></tr>\n' | |
427 ) % (html.escape(value), checked, html.escape(
name)) | |
428 show_reload_input += '</table>\n' | |
429 | |
430 fields = {"show_events_input": show_events_input, | |
431 "show_branches_input": show_branches_input, | |
432 "show_builders_input": show_builders_input, | |
433 "show_committers_input": show_committers_input, | |
434 "show_reload_input": show_reload_input, | |
435 "failures_only_input": failures_only_input, | |
436 } | |
437 data += HELP % fields | |
438 return data | |
439 | |
440 class WaterfallStatusResource(HtmlResource): | |
441 """This builds the main status page, with the waterfall display, and | |
442 all child pages.""" | |
443 | |
444 def __init__(self, categories=None, num_events=200, num_events_max=None): | |
445 HtmlResource.__init__(self) | |
446 self.categories = categories | |
447 self.num_events=num_events | |
448 self.num_events_max=num_events_max | |
449 self.putChild("help", WaterfallHelp(categories)) | |
450 | |
451 def getTitle(self, request): | |
452 status = self.getStatus(request) | |
453 p = status.getProjectName() | |
454 if p: | |
455 return "BuildBot: %s" % p | |
456 else: | |
457 return "BuildBot" | |
458 | |
459 def getChangemaster(self, request): | |
460 # TODO: this wants to go away, access it through IStatus | |
461 return request.site.buildbot_service.getChangeSvc() | |
462 | |
463 def get_reload_time(self, request): | |
464 if "reload" in request.args: | |
465 try: | |
466 reload_time = int(request.args["reload"][0]) | |
467 return max(reload_time, 15) | |
468 except ValueError: | |
469 pass | |
470 return None | |
471 | |
472 def head(self, request): | |
473 head = '' | |
474 reload_time = self.get_reload_time(request) | |
475 if reload_time is not None: | |
476 head += '<meta http-equiv="refresh" content="%d">\n' % reload_time | |
477 return head | |
478 | |
479 def isSuccess(self, builderStatus): | |
480 # Helper function to return True if the builder is not failing. | |
481 # The function will return false if the current state is "offline", | |
482 # the last build was not successful, or if a step from the current | |
483 # build(s) failed. | |
484 | |
485 # Make sure the builder is online. | |
486 if builderStatus.getState()[0] == 'offline': | |
487 return False | |
488 | |
489 # Look at the last finished build to see if it was success or not. | |
490 lastBuild = builderStatus.getLastFinishedBuild() | |
491 if lastBuild and lastBuild.getResults() != builder.SUCCESS: | |
492 return False | |
493 | |
494 # Check all the current builds to see if one step is already | |
495 # failing. | |
496 currentBuilds = builderStatus.getCurrentBuilds() | |
497 if currentBuilds: | |
498 for build in currentBuilds: | |
499 for step in build.getSteps(): | |
500 if step.getResults()[0] == builder.FAILURE: | |
501 return False | |
502 | |
503 # The last finished build was successful, and all the current builds | |
504 # don't have any failed steps. | |
505 return True | |
506 | |
507 def body(self, request): | |
508 "This method builds the main waterfall display." | |
509 | |
510 status = self.getStatus(request) | |
511 data = '' | |
512 | |
513 projectName = status.getProjectName() | |
514 projectURL = status.getProjectURL() | |
515 | |
516 phase = request.args.get("phase",["2"]) | |
517 phase = int(phase[0]) | |
518 | |
519 # we start with all Builders available to this Waterfall: this is | |
520 # limited by the config-file -time categories= argument, and defaults | |
521 # to all defined Builders. | |
522 allBuilderNames = status.getBuilderNames(categories=self.categories) | |
523 builders = [status.getBuilder(name) for name in allBuilderNames] | |
524 | |
525 # but if the URL has one or more builder= arguments (or the old show= | |
526 # argument, which is still accepted for backwards compatibility), we | |
527 # use that set of builders instead. We still don't show anything | |
528 # outside the config-file time set limited by categories=. | |
529 showBuilders = request.args.get("show", []) | |
530 showBuilders.extend(request.args.get("builder", [])) | |
531 if showBuilders: | |
532 builders = [b for b in builders if b.name in showBuilders] | |
533 | |
534 # now, if the URL has one or category= arguments, use them as a | |
535 # filter: only show those builders which belong to one of the given | |
536 # categories. | |
537 showCategories = request.args.get("category", []) | |
538 if showCategories: | |
539 builders = [b for b in builders if b.category in showCategories] | |
540 | |
541 # If the URL has the failures_only=true argument, we remove all the | |
542 # builders that are not currently red or won't be turning red at the end | |
543 # of their current run. | |
544 failuresOnly = request.args.get("failures_only", ["false"])[0] | |
545 if failuresOnly.lower() == "true": | |
546 builders = [b for b in builders if not self.isSuccess(b)] | |
547 | |
548 builderNames = [b.name for b in builders] | |
549 | |
550 if phase == -1: | |
551 return self.body0(request, builders) | |
552 (changeNames, builderNames, timestamps, eventGrid, sourceEvents) = \ | |
553 self.buildGrid(request, builders) | |
554 if phase == 0: | |
555 return self.phase0(request, (changeNames + builderNames), | |
556 timestamps, eventGrid) | |
557 # start the table: top-header material | |
558 data += '<table border="0" cellspacing="0">\n' | |
559 | |
560 if projectName and projectURL: | |
561 # TODO: this is going to look really ugly | |
562 topleft = '<a href="%s">%s</a><br />last build' % \ | |
563 (projectURL, projectName) | |
564 else: | |
565 topleft = "last build" | |
566 data += ' <tr class="LastBuild">\n' | |
567 data += td(topleft, align="right", colspan=2, class_="Project") | |
568 for b in builders: | |
569 box = ITopBox(b).getBox(request) | |
570 data += box.td(align="center") | |
571 data += " </tr>\n" | |
572 | |
573 data += ' <tr class="Activity">\n' | |
574 data += td('current activity', align='right', colspan=2) | |
575 for b in builders: | |
576 box = ICurrentBox(b).getBox(status) | |
577 data += box.td(align="center") | |
578 data += " </tr>\n" | |
579 | |
580 data += " <tr>\n" | |
581 TZ = time.tzname[time.localtime()[-1]] | |
582 data += td("time (%s)" % TZ, align="center", class_="Time") | |
583 data += td('<a href="%s">changes</a>' % request.childLink("../changes"), | |
584 align="center", class_="Change") | |
585 for name in builderNames: | |
586 safename = urllib.quote(name, safe='') | |
587 data += td('<a href="%s">%s</a>' % | |
588 (request.childLink("../builders/%s" % safename), name), | |
589 align="center", class_="Builder") | |
590 data += " </tr>\n" | |
591 | |
592 if phase == 1: | |
593 f = self.phase1 | |
594 else: | |
595 f = self.phase2 | |
596 data += f(request, changeNames + builderNames, timestamps, eventGrid, | |
597 sourceEvents) | |
598 | |
599 data += "</table>\n" | |
600 | |
601 | |
602 def with_args(req, remove_args=[], new_args=[], new_path=None): | |
603 # sigh, nevow makes this sort of manipulation easier | |
604 newargs = req.args.copy() | |
605 for argname in remove_args: | |
606 newargs[argname] = [] | |
607 if "branch" in newargs: | |
608 newargs["branch"] = [b for b in newargs["branch"] if b] | |
609 for k,v in new_args: | |
610 if k in newargs: | |
611 newargs[k].append(v) | |
612 else: | |
613 newargs[k] = [v] | |
614 newquery = "&".join(["%s=%s" % (urllib.quote(k), urllib.quote(v)) | |
615 for k in newargs | |
616 for v in newargs[k] | |
617 ]) | |
618 if new_path: | |
619 new_url = new_path | |
620 elif req.prepath: | |
621 new_url = req.prepath[-1] | |
622 else: | |
623 new_url = '' | |
624 if newquery: | |
625 new_url += "?" + newquery | |
626 return new_url | |
627 | |
628 if timestamps: | |
629 bottom = timestamps[-1] | |
630 nextpage = with_args(request, ["last_time"], | |
631 [("last_time", str(int(bottom)))]) | |
632 data += '[<a href="%s">next page</a>]\n' % nextpage | |
633 | |
634 helpurl = self.path_to_root(request) + "waterfall/help" | |
635 helppage = with_args(request, new_path=helpurl) | |
636 data += '[<a href="%s">help</a>]\n' % helppage | |
637 | |
638 if self.get_reload_time(request) is not None: | |
639 no_reload_page = with_args(request, remove_args=["reload"]) | |
640 data += '[<a href="%s">Stop Reloading</a>]\n' % no_reload_page | |
641 | |
642 data += "<br />\n" | |
643 data += self.footer(status, request) | |
644 | |
645 return data | |
646 | |
647 def body0(self, request, builders): | |
648 # build the waterfall display | |
649 data = "" | |
650 data += "<h2>Basic display</h2>\n" | |
651 data += '<p>See <a href="%s">here</a>' % request.childLink("../waterfall
") | |
652 data += " for the waterfall display</p>\n" | |
653 | |
654 data += '<table border="0" cellspacing="0">\n' | |
655 names = map(lambda builder: builder.name, builders) | |
656 | |
657 # the top row is two blank spaces, then the top-level status boxes | |
658 data += " <tr>\n" | |
659 data += td("", colspan=2) | |
660 for b in builders: | |
661 text = "" | |
662 state, builds = b.getState() | |
663 if state != "offline": | |
664 text += "%s<br />\n" % state #b.getCurrentBig().text[0] | |
665 else: | |
666 text += "OFFLINE<br />\n" | |
667 data += td(text, align="center") | |
668 | |
669 # the next row has the column headers: time, changes, builder names | |
670 data += " <tr>\n" | |
671 data += td("Time", align="center") | |
672 data += td("Changes", align="center") | |
673 for name in names: | |
674 data += td('<a href="%s">%s</a>' % | |
675 (request.childLink("../" + urllib.quote(name)), name), | |
676 align="center") | |
677 data += " </tr>\n" | |
678 | |
679 # all further rows involve timestamps, commit events, and build events | |
680 data += " <tr>\n" | |
681 data += td("04:00", align="bottom") | |
682 data += td("fred", align="center") | |
683 for name in names: | |
684 data += td("stuff", align="center") | |
685 data += " </tr>\n" | |
686 | |
687 data += "</table>\n" | |
688 return data | |
689 | |
690 def buildGrid(self, request, builders): | |
691 debug = False | |
692 # TODO: see if we can use a cached copy | |
693 | |
694 showEvents = False | |
695 if request.args.get("show_events", ["false"])[0].lower() == "true": | |
696 showEvents = True | |
697 filterCategories = request.args.get('category', []) | |
698 filterBranches = [b for b in request.args.get("branch", []) if b] | |
699 filterBranches = map_branches(filterBranches) | |
700 filterCommitters = [c for c in request.args.get("committer", []) if c] | |
701 maxTime = int(request.args.get("last_time", [util.now()])[0]) | |
702 if "show_time" in request.args: | |
703 minTime = maxTime - int(request.args["show_time"][0]) | |
704 elif "first_time" in request.args: | |
705 minTime = int(request.args["first_time"][0]) | |
706 elif filterBranches or filterCommitters: | |
707 minTime = util.now() - 24 * 60 * 60 | |
708 else: | |
709 minTime = 0 | |
710 spanLength = 10 # ten-second chunks | |
711 req_events=int(request.args.get("num_events", [self.num_events])[0]) | |
712 if self.num_events_max and req_events > self.num_events_max: | |
713 maxPageLen = self.num_events_max | |
714 else: | |
715 maxPageLen = req_events | |
716 | |
717 # first step is to walk backwards in time, asking each column | |
718 # (commit, all builders) if they have any events there. Build up the | |
719 # array of events, and stop when we have a reasonable number. | |
720 | |
721 commit_source = self.getChangemaster(request) | |
722 | |
723 lastEventTime = util.now() | |
724 sources = [commit_source] + builders | |
725 changeNames = ["changes"] | |
726 builderNames = map(lambda builder: builder.getName(), builders) | |
727 sourceNames = changeNames + builderNames | |
728 sourceEvents = [] | |
729 sourceGenerators = [] | |
730 | |
731 def get_event_from(g): | |
732 try: | |
733 while True: | |
734 e = g.next() | |
735 # e might be builder.BuildStepStatus, | |
736 # builder.BuildStatus, builder.Event, | |
737 # waterfall.Spacer(builder.Event), or changes.Change . | |
738 # The showEvents=False flag means we should hide | |
739 # builder.Event . | |
740 if not showEvents and isinstance(e, builder.Event): | |
741 continue | |
742 break | |
743 event = interfaces.IStatusEvent(e) | |
744 if debug: | |
745 log.msg("gen %s gave1 %s" % (g, event.getText())) | |
746 except StopIteration: | |
747 event = None | |
748 return event | |
749 | |
750 for s in sources: | |
751 gen = insertGaps(s.eventGenerator(filterBranches, | |
752 filterCategories, | |
753 filterCommitters, | |
754 minTime), | |
755 showEvents, | |
756 lastEventTime) | |
757 sourceGenerators.append(gen) | |
758 # get the first event | |
759 sourceEvents.append(get_event_from(gen)) | |
760 eventGrid = [] | |
761 timestamps = [] | |
762 | |
763 lastEventTime = 0 | |
764 for e in sourceEvents: | |
765 if e and e.getTimes()[0] > lastEventTime: | |
766 lastEventTime = e.getTimes()[0] | |
767 if lastEventTime == 0: | |
768 lastEventTime = util.now() | |
769 | |
770 spanStart = lastEventTime - spanLength | |
771 debugGather = 0 | |
772 | |
773 while 1: | |
774 if debugGather: log.msg("checking (%s,]" % spanStart) | |
775 # the tableau of potential events is in sourceEvents[]. The | |
776 # window crawls backwards, and we examine one source at a time. | |
777 # If the source's top-most event is in the window, is it pushed | |
778 # onto the events[] array and the tableau is refilled. This | |
779 # continues until the tableau event is not in the window (or is | |
780 # missing). | |
781 | |
782 spanEvents = [] # for all sources, in this span. row of eventGrid | |
783 firstTimestamp = None # timestamp of first event in the span | |
784 lastTimestamp = None # last pre-span event, for next span | |
785 | |
786 for c in range(len(sourceGenerators)): | |
787 events = [] # for this source, in this span. cell of eventGrid | |
788 event = sourceEvents[c] | |
789 while event and spanStart < event.getTimes()[0]: | |
790 # to look at windows that don't end with the present, | |
791 # condition the .append on event.time <= spanFinish | |
792 if not IBox(event, None): | |
793 log.msg("BAD EVENT", event, event.getText()) | |
794 assert 0 | |
795 if debug: | |
796 log.msg("pushing", event.getText(), event) | |
797 events.append(event) | |
798 starts, finishes = event.getTimes() | |
799 firstTimestamp = util.earlier(firstTimestamp, starts) | |
800 event = get_event_from(sourceGenerators[c]) | |
801 if debug: | |
802 log.msg("finished span") | |
803 | |
804 if event: | |
805 # this is the last pre-span event for this source | |
806 lastTimestamp = util.later(lastTimestamp, | |
807 event.getTimes()[0]) | |
808 if debugGather: | |
809 log.msg(" got %s from %s" % (events, sourceNames[c])) | |
810 sourceEvents[c] = event # refill the tableau | |
811 spanEvents.append(events) | |
812 | |
813 # only show events older than maxTime. This makes it possible to | |
814 # visit a page that shows what it would be like to scroll off the | |
815 # bottom of this one. | |
816 if firstTimestamp is not None and firstTimestamp <= maxTime: | |
817 eventGrid.append(spanEvents) | |
818 timestamps.append(firstTimestamp) | |
819 | |
820 if lastTimestamp: | |
821 spanStart = lastTimestamp - spanLength | |
822 else: | |
823 # no more events | |
824 break | |
825 if minTime is not None and lastTimestamp < minTime: | |
826 break | |
827 | |
828 if len(timestamps) > maxPageLen: | |
829 break | |
830 | |
831 | |
832 # now loop | |
833 | |
834 # loop is finished. now we have eventGrid[] and timestamps[] | |
835 if debugGather: log.msg("finished loop") | |
836 assert(len(timestamps) == len(eventGrid)) | |
837 return (changeNames, builderNames, timestamps, eventGrid, sourceEvents) | |
838 | |
839 def phase0(self, request, sourceNames, timestamps, eventGrid): | |
840 # phase0 rendering | |
841 if not timestamps: | |
842 return "no events" | |
843 data = "" | |
844 for r in range(0, len(timestamps)): | |
845 data += "<p>\n" | |
846 data += "[%s]<br />" % timestamps[r] | |
847 row = eventGrid[r] | |
848 assert(len(row) == len(sourceNames)) | |
849 for c in range(0, len(row)): | |
850 if row[c]: | |
851 data += "<b>%s</b><br />\n" % sourceNames[c] | |
852 for e in row[c]: | |
853 log.msg("Event", r, c, sourceNames[c], e.getText()) | |
854 lognames = [loog.getName() for loog in e.getLogs()] | |
855 data += "%s: %s: %s<br />" % (e.getText(), | |
856 e.getTimes()[0], | |
857 lognames) | |
858 else: | |
859 data += "<b>%s</b> [none]<br />\n" % sourceNames[c] | |
860 return data | |
861 | |
862 def phase1(self, request, sourceNames, timestamps, eventGrid, | |
863 sourceEvents): | |
864 # phase1 rendering: table, but boxes do not overlap | |
865 data = "" | |
866 if not timestamps: | |
867 return data | |
868 lastDate = None | |
869 for r in range(0, len(timestamps)): | |
870 chunkstrip = eventGrid[r] | |
871 # chunkstrip is a horizontal strip of event blocks. Each block | |
872 # is a vertical list of events, all for the same source. | |
873 assert(len(chunkstrip) == len(sourceNames)) | |
874 maxRows = reduce(lambda x,y: max(x,y), | |
875 map(lambda x: len(x), chunkstrip)) | |
876 for i in range(maxRows): | |
877 data += " <tr>\n"; | |
878 if i == 0: | |
879 stuff = [] | |
880 # add the date at the beginning, and each time it changes | |
881 today = time.strftime("<b>%d %b %Y</b>", | |
882 time.localtime(timestamps[r])) | |
883 todayday = time.strftime("<b>%a</b>", | |
884 time.localtime(timestamps[r])) | |
885 if today != lastDate: | |
886 stuff.append(todayday) | |
887 stuff.append(today) | |
888 lastDate = today | |
889 stuff.append( | |
890 time.strftime("%H:%M:%S", | |
891 time.localtime(timestamps[r]))) | |
892 data += td(stuff, valign="bottom", align="center", | |
893 rowspan=maxRows, class_="Time") | |
894 for c in range(0, len(chunkstrip)): | |
895 block = chunkstrip[c] | |
896 assert(block != None) # should be [] instead | |
897 # bottom-justify | |
898 offset = maxRows - len(block) | |
899 if i < offset: | |
900 data += td("") | |
901 else: | |
902 e = block[i-offset] | |
903 box = IBox(e).getBox(request) | |
904 box.parms["show_idle"] = 1 | |
905 data += box.td(valign="top", align="center") | |
906 data += " </tr>\n" | |
907 | |
908 return data | |
909 | |
910 def phase2(self, request, sourceNames, timestamps, eventGrid, | |
911 sourceEvents): | |
912 data = "" | |
913 if not timestamps: | |
914 return data | |
915 # first pass: figure out the height of the chunks, populate grid | |
916 grid = [] | |
917 for i in range(1+len(sourceNames)): | |
918 grid.append([]) | |
919 # grid is a list of columns, one for the timestamps, and one per | |
920 # event source. Each column is exactly the same height. Each element | |
921 # of the list is a single <td> box. | |
922 lastDate = time.strftime("<b>%d %b %Y</b>", | |
923 time.localtime(util.now())) | |
924 for r in range(0, len(timestamps)): | |
925 chunkstrip = eventGrid[r] | |
926 # chunkstrip is a horizontal strip of event blocks. Each block | |
927 # is a vertical list of events, all for the same source. | |
928 assert(len(chunkstrip) == len(sourceNames)) | |
929 maxRows = reduce(lambda x,y: max(x,y), | |
930 map(lambda x: len(x), chunkstrip)) | |
931 for i in range(maxRows): | |
932 if i != maxRows-1: | |
933 grid[0].append(None) | |
934 else: | |
935 # timestamp goes at the bottom of the chunk | |
936 stuff = [] | |
937 # add the date at the beginning (if it is not the same as | |
938 # today's date), and each time it changes | |
939 todayday = time.strftime("<b>%a</b>", | |
940 time.localtime(timestamps[r])) | |
941 today = time.strftime("<b>%d %b %Y</b>", | |
942 time.localtime(timestamps[r])) | |
943 if today != lastDate: | |
944 stuff.append(todayday) | |
945 stuff.append(today) | |
946 lastDate = today | |
947 stuff.append( | |
948 time.strftime("%H:%M:%S", | |
949 time.localtime(timestamps[r]))) | |
950 grid[0].append(Box(text=stuff, class_="Time", | |
951 valign="bottom", align="center")) | |
952 | |
953 # at this point the timestamp column has been populated with | |
954 # maxRows boxes, most None but the last one has the time string | |
955 for c in range(0, len(chunkstrip)): | |
956 block = chunkstrip[c] | |
957 assert(block != None) # should be [] instead | |
958 for i in range(maxRows - len(block)): | |
959 # fill top of chunk with blank space | |
960 grid[c+1].append(None) | |
961 for i in range(len(block)): | |
962 # so the events are bottom-justified | |
963 b = IBox(block[i]).getBox(request) | |
964 b.parms['valign'] = "top" | |
965 b.parms['align'] = "center" | |
966 grid[c+1].append(b) | |
967 # now all the other columns have maxRows new boxes too | |
968 # populate the last row, if empty | |
969 gridlen = len(grid[0]) | |
970 for i in range(len(grid)): | |
971 strip = grid[i] | |
972 assert(len(strip) == gridlen) | |
973 if strip[-1] == None: | |
974 if sourceEvents[i-1]: | |
975 filler = IBox(sourceEvents[i-1]).getBox(request) | |
976 else: | |
977 # this can happen if you delete part of the build history | |
978 filler = Box(text=["?"], align="center") | |
979 strip[-1] = filler | |
980 strip[-1].parms['rowspan'] = 1 | |
981 # second pass: bubble the events upwards to un-occupied locations | |
982 # Every square of the grid that has a None in it needs to have | |
983 # something else take its place. | |
984 noBubble = request.args.get("nobubble",['0']) | |
985 noBubble = int(noBubble[0]) | |
986 if not noBubble: | |
987 for col in range(len(grid)): | |
988 strip = grid[col] | |
989 if col == 1: # changes are handled differently | |
990 for i in range(2, len(strip)+1): | |
991 # only merge empty boxes. Don't bubble commit boxes. | |
992 if strip[-i] == None: | |
993 next = strip[-i+1] | |
994 assert(next) | |
995 if next: | |
996 #if not next.event: | |
997 if next.spacer: | |
998 # bubble the empty box up | |
999 strip[-i] = next | |
1000 strip[-i].parms['rowspan'] += 1 | |
1001 strip[-i+1] = None | |
1002 else: | |
1003 # we are above a commit box. Leave it | |
1004 # be, and turn the current box into an | |
1005 # empty one | |
1006 strip[-i] = Box([], rowspan=1, | |
1007 comment="commit bubble") | |
1008 strip[-i].spacer = True | |
1009 else: | |
1010 # we are above another empty box, which | |
1011 # somehow wasn't already converted. | |
1012 # Shouldn't happen | |
1013 pass | |
1014 else: | |
1015 for i in range(2, len(strip)+1): | |
1016 # strip[-i] will go from next-to-last back to first | |
1017 if strip[-i] == None: | |
1018 # bubble previous item up | |
1019 assert(strip[-i+1] != None) | |
1020 strip[-i] = strip[-i+1] | |
1021 strip[-i].parms['rowspan'] += 1 | |
1022 strip[-i+1] = None | |
1023 else: | |
1024 strip[-i].parms['rowspan'] = 1 | |
1025 # third pass: render the HTML table | |
1026 for i in range(gridlen): | |
1027 data += " <tr>\n"; | |
1028 for strip in grid: | |
1029 b = strip[i] | |
1030 if b: | |
1031 # convert data to a unicode string, whacking any non-ASCII c
haracters it might contain | |
1032 s = b.td() | |
1033 if isinstance(s, unicode): | |
1034 s = s.encode("utf-8", "replace") | |
1035 data += s | |
1036 else: | |
1037 if noBubble: | |
1038 data += td([]) | |
1039 # Nones are left empty, rowspan should make it all fit | |
1040 data += " </tr>\n" | |
1041 return data | |
1042 | |
OLD | NEW |