| 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 |