OLD | NEW |
| (Empty) |
1 | |
2 import urlparse, urllib, time, re | |
3 from zope.interface import Interface | |
4 from twisted.python import log | |
5 from twisted.web import html, resource | |
6 from buildbot.status import builder | |
7 from buildbot.status.builder import SUCCESS, WARNINGS, FAILURE, SKIPPED, EXCEPTI
ON | |
8 from buildbot import version, util | |
9 from buildbot.process.properties import Properties | |
10 | |
11 import datetime | |
12 | |
13 class ITopBox(Interface): | |
14 """I represent a box in the top row of the waterfall display: the one | |
15 which shows the status of the last build for each builder.""" | |
16 def getBox(self, request): | |
17 """Return a Box instance, which can produce a <td> cell. | |
18 """ | |
19 | |
20 class ICurrentBox(Interface): | |
21 """I represent the 'current activity' box, just above the builder name.""" | |
22 def getBox(self, status): | |
23 """Return a Box instance, which can produce a <td> cell. | |
24 """ | |
25 | |
26 class IBox(Interface): | |
27 """I represent a box in the waterfall display.""" | |
28 def getBox(self, request): | |
29 """Return a Box instance, which wraps an Event and can produce a <td> | |
30 cell. | |
31 """ | |
32 | |
33 class IHTMLLog(Interface): | |
34 pass | |
35 | |
36 css_classes = {SUCCESS: "success", | |
37 WARNINGS: "warnings", | |
38 FAILURE: "failure", | |
39 SKIPPED: "skipped", | |
40 EXCEPTION: "exception", | |
41 None: "", | |
42 } | |
43 | |
44 ROW_TEMPLATE = ''' | |
45 <div class="row"> | |
46 <span class="label">%(label)s</span> | |
47 <span class="field">%(field)s</span> | |
48 </div> | |
49 ''' | |
50 | |
51 def make_row(label, field): | |
52 """Create a name/value row for the HTML. | |
53 | |
54 `label` is plain text; it will be HTML-encoded. | |
55 | |
56 `field` is a bit of HTML structure; it will not be encoded in | |
57 any way. | |
58 """ | |
59 label = html.escape(label) | |
60 return ROW_TEMPLATE % {"label": label, "field": field} | |
61 | |
62 def make_name_user_passwd_form(useUserPasswd): | |
63 """helper function to create HTML prompt for 'name' when | |
64 C{useUserPasswd} is C{False} or 'username' / 'password' prompt | |
65 when C{True}.""" | |
66 | |
67 if useUserPasswd: | |
68 label = "Your username:" | |
69 else: | |
70 label = "Your name:" | |
71 data = make_row(label, '<input type="text" name="username" />') | |
72 if useUserPasswd: | |
73 data += make_row("Your password:", | |
74 '<input type="password" name="passwd" />') | |
75 return data | |
76 | |
77 def make_stop_form(stopURL, useUserPasswd, on_all=False, label="Build"): | |
78 if on_all: | |
79 data = """<form method="post" action="%s" class='command stopbuild'> | |
80 <p>To stop all builds, fill out the following fields and | |
81 push the 'Stop' button</p>\n""" % stopURL | |
82 else: | |
83 data = """<form method="post" action="%s" class='command stopbuild'> | |
84 <p>To stop this build, fill out the following fields and | |
85 push the 'Stop' button</p>\n""" % stopURL | |
86 data += make_name_user_passwd_form(useUserPasswd) | |
87 data += make_row("Reason for stopping build:", | |
88 "<input type='text' name='comments' />") | |
89 data += '<input type="submit" value="Stop %s" /></form>\n' % label | |
90 return data | |
91 | |
92 def make_extra_property_row(N): | |
93 """helper function to create the html for adding extra build | |
94 properties to a forced (or resubmitted) build. "N" is an integer | |
95 inserted into the form names so that more than one property can be | |
96 used in the form. | |
97 """ | |
98 prop_html = ''' | |
99 <div class="row">Property %(N)i | |
100 <span class="label">Name:</span> | |
101 <span class="field"><input type="text" name="property%(N)iname" /></span> | |
102 <span class="label">Value:</span> | |
103 <span class="field"><input type="text" name="property%(N)ivalue" /></span> | |
104 </div> | |
105 ''' % {"N": N} | |
106 return prop_html | |
107 | |
108 def make_force_build_form(forceURL, useUserPasswd, on_all=False): | |
109 if on_all: | |
110 data = """<form method="post" action="%s" class="command forcebuild"> | |
111 <p>To force a build on all Builders, fill out the following fields | |
112 and push the 'Force Build' button</p>""" % forceURL | |
113 else: | |
114 data = """<form method="post" action="%s" class="command forcebuild"> | |
115 <p>To force a build, fill out the following fields and | |
116 push the 'Force Build' button</p>""" % forceURL | |
117 return (data | |
118 + make_name_user_passwd_form(useUserPasswd) | |
119 + make_row("Reason for build:", | |
120 "<input type='text' name='comments' />") | |
121 + make_row("Branch to build:", | |
122 "<input type='text' name='branch' />") | |
123 + make_row("Revision to build:", | |
124 "<input type='text' name='revision' />") | |
125 + make_extra_property_row(1) | |
126 + make_extra_property_row(2) | |
127 + make_extra_property_row(3) | |
128 + '<input type="submit" value="Force Build" /></form>\n') | |
129 | |
130 def getAndCheckProperties(req): | |
131 """ | |
132 Fetch custom build properties from the HTTP request of a "Force build" or | |
133 "Resubmit build" HTML form. | |
134 Check the names for valid strings, and return None if a problem is found. | |
135 Return a new Properties object containing each property found in req. | |
136 """ | |
137 properties = Properties() | |
138 for i in (1,2,3): | |
139 pname = req.args.get("property%dname" % i, [""])[0] | |
140 pvalue = req.args.get("property%dvalue" % i, [""])[0] | |
141 if pname and pvalue: | |
142 if not re.match(r'^[\w\.\-\/\~:]*$', pname) \ | |
143 or not re.match(r'^[\w\.\-\/\~:]*$', pvalue): | |
144 log.msg("bad property name='%s', value='%s'" % (pname, pvalue)) | |
145 return None | |
146 properties.setProperty(pname, pvalue, "Force Build Form") | |
147 return properties | |
148 | |
149 def td(text="", parms={}, **props): | |
150 data = "" | |
151 data += " " | |
152 #if not props.has_key("border"): | |
153 # props["border"] = 1 | |
154 props.update(parms) | |
155 comment = props.get("comment", None) | |
156 if comment: | |
157 data += "<!-- %s -->" % comment | |
158 data += "<td" | |
159 class_ = props.get('class_', None) | |
160 if class_: | |
161 props["class"] = class_ | |
162 for prop in ("align", "colspan", "rowspan", "border", | |
163 "valign", "halign", "class"): | |
164 p = props.get(prop, None) | |
165 if p != None: | |
166 data += " %s=\"%s\"" % (prop, p) | |
167 data += ">" | |
168 if not text: | |
169 text = " " | |
170 if isinstance(text, list): | |
171 data += "<br />".join(text) | |
172 else: | |
173 data += text | |
174 data += "</td>\n" | |
175 return data | |
176 | |
177 def build_get_class(b): | |
178 """ | |
179 Return the class to use for a finished build or buildstep, | |
180 based on the result. | |
181 """ | |
182 # FIXME: this getResults duplicity might need to be fixed | |
183 result = b.getResults() | |
184 #print "THOMAS: result for b %r: %r" % (b, result) | |
185 if isinstance(b, builder.BuildStatus): | |
186 result = b.getResults() | |
187 elif isinstance(b, builder.BuildStepStatus): | |
188 result = b.getResults()[0] | |
189 # after forcing a build, b.getResults() returns ((None, []), []), ugh | |
190 if isinstance(result, tuple): | |
191 result = result[0] | |
192 else: | |
193 raise TypeError, "%r is not a BuildStatus or BuildStepStatus" % b | |
194 | |
195 if result == None: | |
196 # FIXME: this happens when a buildstep is running ? | |
197 return "running" | |
198 return builder.Results[result] | |
199 | |
200 def path_to_root(request): | |
201 # /waterfall : ['waterfall'] -> '' | |
202 # /somewhere/lower : ['somewhere', 'lower'] -> '../' | |
203 # /somewhere/indexy/ : ['somewhere', 'indexy', ''] -> '../../' | |
204 # / : [] -> '' | |
205 if request.prepath: | |
206 segs = len(request.prepath) - 1 | |
207 else: | |
208 segs = 0 | |
209 root = "../" * segs | |
210 return root | |
211 | |
212 def path_to_builder(request, builderstatus): | |
213 return (path_to_root(request) + | |
214 "builders/" + | |
215 urllib.quote(builderstatus.getName(), safe='')) | |
216 | |
217 def path_to_build(request, buildstatus): | |
218 return (path_to_builder(request, buildstatus.getBuilder()) + | |
219 "/builds/%d" % buildstatus.getNumber()) | |
220 | |
221 def path_to_step(request, stepstatus): | |
222 return (path_to_build(request, stepstatus.getBuild()) + | |
223 "/steps/%s" % urllib.quote(stepstatus.getName(), safe='')) | |
224 | |
225 def path_to_slave(request, slave): | |
226 return (path_to_root(request) + | |
227 "buildslaves/" + | |
228 urllib.quote(slave.getName(), safe='')) | |
229 | |
230 def path_to_change(request, change): | |
231 return (path_to_root(request) + | |
232 "changes/%s" % change.number) | |
233 | |
234 class Box: | |
235 # a Box wraps an Event. The Box has HTML <td> parameters that Events | |
236 # lack, and it has a base URL to which each File's name is relative. | |
237 # Events don't know about HTML. | |
238 spacer = False | |
239 def __init__(self, text=[], class_=None, urlbase=None, | |
240 **parms): | |
241 self.text = text | |
242 self.class_ = class_ | |
243 self.urlbase = urlbase | |
244 self.show_idle = 0 | |
245 if parms.has_key('show_idle'): | |
246 del parms['show_idle'] | |
247 self.show_idle = 1 | |
248 | |
249 self.parms = parms | |
250 # parms is a dict of HTML parameters for the <td> element that will | |
251 # represent this Event in the waterfall display. | |
252 | |
253 def td(self, **props): | |
254 props.update(self.parms) | |
255 text = self.text | |
256 if not text and self.show_idle: | |
257 text = ["[idle]"] | |
258 return td(text, props, class_=self.class_) | |
259 | |
260 | |
261 class HtmlResource(resource.Resource): | |
262 # this is a cheap sort of template thingy | |
263 contentType = "text/html; charset=UTF-8" | |
264 title = "Buildbot" | |
265 addSlash = False # adapted from Nevow | |
266 | |
267 def getChild(self, path, request): | |
268 if self.addSlash and path == "" and len(request.postpath) == 0: | |
269 return self | |
270 return resource.Resource.getChild(self, path, request) | |
271 | |
272 def render(self, request): | |
273 # tell the WebStatus about the HTTPChannel that got opened, so they | |
274 # can close it if we get reconfigured and the WebStatus goes away. | |
275 # They keep a weakref to this, since chances are good that it will be | |
276 # closed by the browser or by us before we get reconfigured. See | |
277 # ticket #102 for details. | |
278 if hasattr(request, "channel"): | |
279 # web.distrib.Request has no .channel | |
280 request.site.buildbot_service.registerChannel(request.channel) | |
281 | |
282 # Our pages no longer require that their URL end in a slash. Instead, | |
283 # they all use request.childLink() or some equivalent which takes the | |
284 # last path component into account. This clause is left here for | |
285 # historical and educational purposes. | |
286 if False and self.addSlash and request.prepath[-1] != '': | |
287 # this is intended to behave like request.URLPath().child('') | |
288 # but we need a relative URL, since we might be living behind a | |
289 # reverse proxy | |
290 # | |
291 # note that the Location: header (as used in redirects) are | |
292 # required to have absolute URIs, and my attempt to handle | |
293 # reverse-proxies gracefully violates rfc2616. This frequently | |
294 # works, but single-component paths sometimes break. The best | |
295 # strategy is to avoid these redirects whenever possible by using | |
296 # HREFs with trailing slashes, and only use the redirects for | |
297 # manually entered URLs. | |
298 url = request.prePathURL() | |
299 scheme, netloc, path, query, fragment = urlparse.urlsplit(url) | |
300 new_url = request.prepath[-1] + "/" | |
301 if query: | |
302 new_url += "?" + query | |
303 request.redirect(new_url) | |
304 return '' | |
305 | |
306 data = self.content(request) | |
307 if isinstance(data, unicode): | |
308 data = data.encode("utf-8") | |
309 request.setHeader("content-type", self.contentType) | |
310 if request.method == "HEAD": | |
311 request.setHeader("content-length", len(data)) | |
312 return '' | |
313 | |
314 # Make sure we get fresh pages. | |
315 now = datetime.datetime.utcnow() | |
316 expires = now + datetime.timedelta(seconds=60) | |
317 request.setHeader("Expires", expires.strftime("%a, %d %b %Y %H:%M:%S GMT
")) | |
318 request.setHeader("Pragma", "no-cache") | |
319 | |
320 return data | |
321 | |
322 def getStatus(self, request): | |
323 return request.site.buildbot_service.getStatus() | |
324 | |
325 def getControl(self, request): | |
326 return request.site.buildbot_service.getControl() | |
327 | |
328 def isUsingUserPasswd(self, request): | |
329 return request.site.buildbot_service.isUsingUserPasswd() | |
330 | |
331 def authUser(self, request): | |
332 user = request.args.get("username", ["<unknown>"])[0] | |
333 passwd = request.args.get("passwd", ["<no-password>"])[0] | |
334 if user == "<unknown>" or passwd == "<no-password>": | |
335 return False | |
336 return request.site.buildbot_service.authUser(user, passwd) | |
337 | |
338 def getChangemaster(self, request): | |
339 return request.site.buildbot_service.getChangeSvc() | |
340 | |
341 def path_to_root(self, request): | |
342 return path_to_root(request) | |
343 | |
344 def footer(self, status, req): | |
345 # TODO: this stuff should be generated by a template of some sort | |
346 projectURL = status.getProjectURL() | |
347 projectName = status.getProjectName() | |
348 data = '<hr /><div class="footer">\n' | |
349 | |
350 welcomeurl = self.path_to_root(req) + "index.html" | |
351 data += '[<a href="%s">welcome</a>]\n' % welcomeurl | |
352 data += "<br />\n" | |
353 | |
354 data += '<a href="http://buildbot.sourceforge.net/">Buildbot</a>' | |
355 data += "-%s " % version | |
356 if projectName: | |
357 data += "working for the " | |
358 if projectURL: | |
359 data += "<a href=\"%s\">%s</a> project." % (projectURL, | |
360 projectName) | |
361 else: | |
362 data += "%s project." % projectName | |
363 data += "<br />\n" | |
364 data += ("Page built: " + | |
365 time.strftime("%a %d %b %Y %H:%M:%S", | |
366 time.localtime(util.now())) | |
367 + "\n") | |
368 data += '</div>\n' | |
369 | |
370 return data | |
371 | |
372 def getTitle(self, request): | |
373 return self.title | |
374 | |
375 def fillTemplate(self, template, request): | |
376 s = request.site.buildbot_service | |
377 values = s.template_values.copy() | |
378 values['root'] = self.path_to_root(request) | |
379 # e.g. to reference the top-level 'buildbot.css' page, use | |
380 # "%(root)sbuildbot.css" | |
381 values['title'] = self.getTitle(request) | |
382 return template % values | |
383 | |
384 def content(self, request): | |
385 s = request.site.buildbot_service | |
386 data = "" | |
387 data += self.fillTemplate(s.header, request) | |
388 data += "<head>\n" | |
389 for he in s.head_elements: | |
390 data += " " + self.fillTemplate(he, request) + "\n" | |
391 data += self.head(request) | |
392 data += "</head>\n\n" | |
393 | |
394 data += '<body %s>\n' % " ".join(['%s="%s"' % (k,v) | |
395 for (k,v) in s.body_attrs.items()]) | |
396 data += self.body(request) | |
397 data += "</body>\n" | |
398 data += self.fillTemplate(s.footer, request) | |
399 return data | |
400 | |
401 def head(self, request): | |
402 return "" | |
403 | |
404 def body(self, request): | |
405 return "Dummy\n" | |
406 | |
407 class StaticHTML(HtmlResource): | |
408 def __init__(self, body, title): | |
409 HtmlResource.__init__(self) | |
410 self.bodyHTML = body | |
411 self.title = title | |
412 def body(self, request): | |
413 return self.bodyHTML | |
414 | |
415 MINUTE = 60 | |
416 HOUR = 60*MINUTE | |
417 DAY = 24*HOUR | |
418 WEEK = 7*DAY | |
419 MONTH = 30*DAY | |
420 | |
421 def plural(word, words, num): | |
422 if int(num) == 1: | |
423 return "%d %s" % (num, word) | |
424 else: | |
425 return "%d %s" % (num, words) | |
426 | |
427 def abbreviate_age(age): | |
428 if age <= 90: | |
429 return "%s ago" % plural("second", "seconds", age) | |
430 if age < 90*MINUTE: | |
431 return "about %s ago" % plural("minute", "minutes", age / MINUTE) | |
432 if age < DAY: | |
433 return "about %s ago" % plural("hour", "hours", age / HOUR) | |
434 if age < 2*WEEK: | |
435 return "about %s ago" % plural("day", "days", age / DAY) | |
436 if age < 2*MONTH: | |
437 return "about %s ago" % plural("week", "weeks", age / WEEK) | |
438 return "a long time ago" | |
439 | |
440 | |
441 class OneLineMixin: | |
442 LINE_TIME_FORMAT = "%b %d %H:%M" | |
443 | |
444 def get_line_values(self, req, build): | |
445 ''' | |
446 Collect the data needed for each line display | |
447 ''' | |
448 builder_name = build.getBuilder().getName() | |
449 results = build.getResults() | |
450 text = build.getText() | |
451 try: | |
452 rev = build.getProperty("got_revision") | |
453 if rev is None: | |
454 rev = "??" | |
455 except KeyError: | |
456 rev = "??" | |
457 rev = str(rev) | |
458 if len(rev) > 40: | |
459 rev = "version is too-long" | |
460 root = self.path_to_root(req) | |
461 css_class = css_classes.get(results, "") | |
462 values = {'class': css_class, | |
463 'builder_name': builder_name, | |
464 'buildnum': build.getNumber(), | |
465 'results': css_class, | |
466 'text': " ".join(build.getText()), | |
467 'buildurl': path_to_build(req, build), | |
468 'builderurl': path_to_builder(req, build.getBuilder()), | |
469 'rev': rev, | |
470 'time': time.strftime(self.LINE_TIME_FORMAT, | |
471 time.localtime(build.getTimes()[0])), | |
472 } | |
473 return values | |
474 | |
475 def make_line(self, req, build, include_builder=True): | |
476 ''' | |
477 Format and render a single line into HTML | |
478 ''' | |
479 values = self.get_line_values(req, build) | |
480 fmt_pieces = ['<font size="-1">(%(time)s)</font>', | |
481 'rev=[%(rev)s]', | |
482 '<span class="%(class)s">%(results)s</span>', | |
483 ] | |
484 if include_builder: | |
485 fmt_pieces.append('<a href="%(builderurl)s">%(builder_name)s</a>') | |
486 fmt_pieces.append('<a href="%(buildurl)s">#%(buildnum)d</a>:') | |
487 fmt_pieces.append('%(text)s') | |
488 data = " ".join(fmt_pieces) % values | |
489 return data | |
490 | |
491 def map_branches(branches): | |
492 # when the query args say "trunk", present that to things like | |
493 # IBuilderStatus.generateFinishedBuilds as None, since that's the | |
494 # convention in use. But also include 'trunk', because some VC systems | |
495 # refer to it that way. In the long run we should clean this up better, | |
496 # maybe with Branch objects or something. | |
497 if "trunk" in branches: | |
498 return branches + [None] | |
499 return branches | |
OLD | NEW |