Index: statusgrid/buildbot/status/html.py
===================================================================
--- statusgrid.orig/buildbot/status/html.py	2007-07-25 09:46:41.280896262 -0500
+++ statusgrid/buildbot/status/html.py	2007-07-25 11:10:46.693375958 -0500
@@ -3,5 +3,13 @@
 # compatibility wrapper. This is currently the preferred place for master.cfg
 # to import from.
 
+# compatibility function
 from buildbot.status.web.waterfall import Waterfall
 _hush_pyflakes = [Waterfall]
+
+
+from buildbot.status.web.waterfall import WaterfallStatusResource
+_hush_pyflakes = [WaterfallStatusResource]
+
+from buildbot.status.web.site import WebStatus
+_hush_pyflakes = [WebStatus]
Index: statusgrid/buildbot/status/web/site.py
===================================================================
--- /dev/null	1970-01-01 00:00:00.000000000 +0000
+++ statusgrid/buildbot/status/web/site.py	2007-07-25 11:14:08.258252990 -0500
@@ -0,0 +1,204 @@
+import sys, os.path
+
+from twisted.web.resource import Resource
+from twisted.application import strports
+from twisted.web import static, html, server, distrib
+from twisted.web.error import NoResource
+from twisted.spread import pb
+
+from buildbot import interfaces
+from buildbot.status.base import StatusReceiverMultiService
+from buildbot.status.web.base import HtmlResource, IToplevelResource
+
+if hasattr(sys, "frozen"):
+    # all 'data' files are in the directory of our executable
+    here = os.path.dirname(sys.executable)
+    buildbot_icon = os.path.abspath(os.path.join(here, "buildbot.png"))
+else:
+    # running from source
+    # the icon is sibpath(__file__, "../buildbot.png") . This is for
+    # portability.
+    up = os.path.dirname
+    buildbot_icon = os.path.abspath(os.path.join(up(up(up(__file__))),
+                                                 "buildbot.png"))
+
+class WebStatus(StatusReceiverMultiService):
+    compare_attrs = ["http_port", "distrib_port", "favicon", "robots_txt"]
+
+    def __init__(self, top_page=None, http_port=None, distrib_port=None,
+                 favicon=None, robots_txt=None, **named_pages):
+        """To have the buildbot run its own web server, pass a port number to
+        C{http_port}. To have it run a web.distrib server
+
+        @type  top_page: IToplevelResource
+        @param top_page: an IToplevelResource that should be displayed
+                         as the main resource of this page
+
+        @type  http_port: int or L{twisted.application.strports} string
+        @param http_port: a strports specification describing which port the
+                          buildbot should use for its web server, with the
+                          Waterfall display as the root page. For backwards
+                          compatibility this can also be an int. Use
+                          'tcp:8000' to listen on that port, or
+                          'tcp:12345:interface=127.0.0.1' if you only want
+                          local processes to connect to it (perhaps because
+                          you are using an HTTP reverse proxy to make the
+                          buildbot available to the outside world, and do not
+                          want to make the raw port visible).
+
+        @type  distrib_port: int or L{twisted.application.strports} string
+        @param distrib_port: Use this if you want to publish the Waterfall
+                             page using web.distrib instead. The most common
+                             case is to provide a string that is an absolute
+                             pathname to the unix socket on which the
+                             publisher should listen
+                             (C{os.path.expanduser(~/.twistd-web-pb)} will
+                             match the default settings of a standard
+                             twisted.web 'personal web server'). Another
+                             possibility is to pass an integer, which means
+                             the publisher should listen on a TCP socket,
+                             allowing the web server to be on a different
+                             machine entirely. Both forms are provided for
+                             backwards compatibility; the preferred form is a
+                             strports specification like
+                             'unix:/home/buildbot/.twistd-web-pb'. Providing
+                             a non-absolute pathname will probably confuse
+                             the strports parser.
+
+        @type  favicon: string
+        @param favicon: if set, provide the pathname of an image file that
+                        will be used for the 'favicon.ico' resource. Many
+                        browsers automatically request this file and use it
+                        as an icon in any bookmark generated from this site.
+                        Defaults to the buildbot/buildbot.png image provided
+                        in the distribution. Can be set to None to avoid
+                        using a favicon at all.
+
+        @type  robots_txt: string
+        @param robots_txt: if set, provide the pathname of a robots.txt file.
+                           Many search engines request this file and obey the
+                           rules in it. E.g. to disallow them to crawl the
+                           status page, put the following two lines in
+                           robots.txt::
+                              User-agent: *
+                              Disallow: /
+        """
+        if favicon is None: favicon = buildbot_icon
+
+        StatusReceiverMultiService.__init__(self)
+        if type(http_port) is int:
+            http_port = "tcp:%d" % http_port
+        self.http_port = http_port
+        if distrib_port is not None:
+            if type(distrib_port) is int:
+                distrib_port = "tcp:%d" % distrib_port
+            if distrib_port[0] in "/~.": # pathnames
+                distrib_port = "unix:%s" % distrib_port
+        self.distrib_port = distrib_port
+        self.favicon = favicon
+        self.robots_txt = robots_txt
+
+        self.top_page = top_page
+        self.named_pages = named_pages
+
+    def __repr__(self):
+        if self.http_port is None:
+            return "<WebStatus on path %s>" % self.distrib_port
+        if self.distrib_port is None:
+            return "<WebStatus on port %s>" % self.http_port
+        return "<WebStatus on port %s and path %s>" % (self.http_port,
+                                                       self.distrib_port)
+
+    def setServiceParent(self, parent):
+        """
+        @type  parent: L{buildbot.master.BuildMaster}
+        """
+        StatusReceiverMultiService.setServiceParent(self, parent)
+        self.setup(parent)
+
+    def setup(self, parent):
+        # first, tell all of the named pages, and the top page, who
+        # the buildmaster is
+        if self.top_page:
+            IToplevelResource(self.top_page).setBuildmaster(parent)
+        for named_page in self.named_pages.values():
+            IToplevelResource(named_page).setBuildmaster(parent)
+
+        wsr = WebStatusResource(self.parent, self.favicon, self.robots_txt,
+                self.top_page, self.named_pages)
+        self.site = server.Site(wsr)
+
+        if self.http_port is not None:
+            s = strports.service(self.http_port, self.site)
+            s.setServiceParent(self)
+        if self.distrib_port is not None:
+            f = pb.PBServerFactory(distrib.ResourcePublisher(self.site))
+            s = strports.service(self.distrib_port, f)
+            s.setServiceParent(self)
+
+class MenuResource(HtmlResource):
+    title = "Menu"
+    def __init__(self, menu_items):
+        HtmlResource.__init__(self)
+        self.menu_items = menu_items
+
+    def body(self, request):
+        data = [ '<ul>' ]
+        data += [ '<li><a href="%s/">%s</a>' % (name,name) for name in self.menu_items.keys() ]
+        data += [ '</ul>' ]
+        return "\n".join(data)
+
+class WebStatusResource(Resource):
+    """
+    A generic top-level resource for a site.  This resource handles at least
+    robots.txt and favicon.  If supplied with a top_page, then it delegates
+    all other methods to that resource.  Otherwise, it produces a simple HTML
+    menu of its named_pages.
+    """
+    buildmaster = None
+
+    favicon = None
+    robots_txt = None
+
+    def __init__(self, buildmaster, favicon, robots_txt, top_page, named_pages):
+        """
+        @type  buildmaster: L{buildbot.master.BuildMaster}
+        @type  favicon: string
+        @type  robots_txt: string
+        @type  top_page: Resource or None
+        @type  named_pages: dictionary mapping names to Resources
+        """
+        Resource.__init__(self)
+        self.buildmaster = buildmaster
+        self.favicon = favicon
+        self.robots_txt = robots_txt
+        self.top_page = top_page
+        self.named_pages = named_pages
+
+        if self.top_page:
+            self.putChild("", self.top_page)
+        else:
+            self.putChild("", MenuResource(self.named_pages))
+
+    def render(self, request):
+        # always add a slash if one isn't present
+        request.redirect(request.prePathURL() + '/')
+        request.finish()
+
+    def getChild(self, path, request):
+        if path == "robots.txt" and self.robots_txt:
+            return static.File(self.robots_txt)
+        if path == "favicon.ico":
+            if self.favicon:
+                return static.File(self.favicon)
+            return NoResource("No favicon.ico registered")
+
+        # point to a named page, if present
+        if self.named_pages.has_key(path):
+            return self.named_pages[path]
+
+        # delegate anything else to top_page, if present
+        if self.top_page:
+            return self.top_page.getChild(path, request)
+
+        return NoResource("No such page '%s'" % path)
Index: statusgrid/buildbot/status/web/waterfall.py
===================================================================
--- statusgrid.orig/buildbot/status/web/waterfall.py	2007-07-25 09:46:41.280896262 -0500
+++ statusgrid/buildbot/status/web/waterfall.py	2007-07-25 11:12:09.558445232 -0500
@@ -19,9 +19,14 @@
 from buildbot import interfaces, util
 from buildbot import version
 from buildbot.sourcestamp import SourceStamp
-from buildbot.status import builder, base
+from buildbot.status import builder
+from buildbot.status.base import StatusReceiverMultiService
 from buildbot.changes import changes
 from buildbot.process.base import BuildRequest
+from buildbot.status.web.site import WebStatus
+from buildbot.status.web.base import HtmlResource, StaticHTML, IToplevelResource, IHTMLLog
+from buildbot.status.web.changes import StatusResourceChanges
+from buildbot.status.web.builder import StatusResourceBuilder
 
 class ITopBox(Interface):
     """I represent a box in the top row of the waterfall display: the one
@@ -36,26 +41,6 @@
     """I represent a box in the waterfall display."""
     pass
 
-class IHTMLLog(Interface):
-    pass
-
-ROW_TEMPLATE = '''
-<div class="row">
-  <span class="label">%(label)s</span>
-  <span class="field">%(field)s</span>
-</div>'''
-
-def make_row(label, field):
-    """Create a name/value row for the HTML.
-
-    `label` is plain text; it will be HTML-encoded.
-
-    `field` is a bit of HTML structure; it will not be encoded in
-    any way.
-    """
-    label = html.escape(label)
-    return ROW_TEMPLATE % {"label": label, "field": field}
-
 colormap = {
     'green': '#72ff75',
     }
@@ -140,537 +125,6 @@
         return td(text, props, bgcolor=self.color, class_=self.class_)
 
 
-class HtmlResource(Resource):
-    css = None
-    contentType = "text/html; charset=UTF-8"
-    title = "Dummy"
-
-    def render(self, request):
-        data = self.content(request)
-        if isinstance(data, unicode):
-            data = data.encode("utf-8")
-        request.setHeader("content-type", self.contentType)
-        if request.method == "HEAD":
-            request.setHeader("content-length", len(data))
-            return ''
-        return data
-
-    def content(self, request):
-        data = ('<!DOCTYPE html PUBLIC'
-                ' "-//W3C//DTD XHTML 1.0 Transitional//EN"\n'
-                '"http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">\n'
-                '<html'
-                ' xmlns="http://www.w3.org/1999/xhtml"'
-                ' lang="en"'
-                ' xml:lang="en">\n')
-        data += "<head>\n"
-        data += "  <title>" + self.title + "</title>\n"
-        if self.css:
-            # TODO: use some sort of relative link up to the root page, so
-            # this css can be used from child pages too
-            data += ('  <link href="%s" rel="stylesheet" type="text/css"/>\n'
-                     % "buildbot.css")
-        data += "</head>\n"
-        data += '<body vlink="#800080">\n'
-        data += self.body(request)
-        data += "</body></html>\n"
-        return data
-
-    def body(self, request):
-        return "Dummy\n"
-
-class StaticHTML(HtmlResource):
-    def __init__(self, body, title):
-        HtmlResource.__init__(self)
-        self.bodyHTML = body
-        self.title = title
-    def body(self, request):
-        return self.bodyHTML
-
-# $builder/builds/NN/stepname
-class StatusResourceBuildStep(HtmlResource):
-    title = "Build Step"
-
-    def __init__(self, status, step):
-        HtmlResource.__init__(self)
-        self.status = status
-        self.step = step
-
-    def body(self, request):
-        s = self.step
-        b = s.getBuild()
-        data = "<h1>BuildStep %s:#%d:%s</h1>\n" % \
-               (b.getBuilder().getName(), b.getNumber(), s.getName())
-
-        if s.isFinished():
-            data += ("<h2>Finished</h2>\n"
-                     "<p>%s</p>\n" % html.escape("%s" % s.getText()))
-        else:
-            data += ("<h2>Not Finished</h2>\n"
-                     "<p>ETA %s seconds</p>\n" % s.getETA())
-
-        exp = s.getExpectations()
-        if exp:
-            data += ("<h2>Expectations</h2>\n"
-                     "<ul>\n")
-            for e in exp:
-                data += "<li>%s: current=%s, target=%s</li>\n" % \
-                        (html.escape(e[0]), e[1], e[2])
-            data += "</ul>\n"
-        logs = s.getLogs()
-        if logs:
-            data += ("<h2>Logs</h2>\n"
-                     "<ul>\n")
-            for num in range(len(logs)):
-                if logs[num].hasContents():
-                    # FIXME: If the step name has a / in it, this is broken
-                    # either way.  If we quote it but say '/'s are safe,
-                    # it chops up the step name.  If we quote it and '/'s
-                    # are not safe, it escapes the / that separates the
-                    # step name from the log number.
-                    data += '<li><a href="%s">%s</a></li>\n' % \
-                            (urllib.quote(request.childLink("%d" % num)),
-                             html.escape(logs[num].getName()))
-                else:
-                    data += ('<li>%s</li>\n' %
-                             html.escape(logs[num].getName()))
-            data += "</ul>\n"
-
-        return data
-
-    def getChild(self, path, request):
-        logname = path
-        try:
-            log = self.step.getLogs()[int(logname)]
-            if log.hasContents():
-                return IHTMLLog(interfaces.IStatusLog(log))
-            return NoResource("Empty Log '%s'" % logname)
-        except (IndexError, ValueError):
-            return NoResource("No such Log '%s'" % logname)
-
-# $builder/builds/NN/tests/TESTNAME
-class StatusResourceTestResult(HtmlResource):
-    title = "Test Logs"
-
-    def __init__(self, status, name, result):
-        HtmlResource.__init__(self)
-        self.status = status
-        self.name = name
-        self.result = result
-
-    def body(self, request):
-        dotname = ".".join(self.name)
-        logs = self.result.getLogs()
-        lognames = logs.keys()
-        lognames.sort()
-        data = "<h1>%s</h1>\n" % html.escape(dotname)
-        for name in lognames:
-            data += "<h2>%s</h2>\n" % html.escape(name)
-            data += "<pre>" + logs[name] + "</pre>\n\n"
-
-        return data
-
-
-# $builder/builds/NN/tests
-class StatusResourceTestResults(HtmlResource):
-    title = "Test Results"
-
-    def __init__(self, status, results):
-        HtmlResource.__init__(self)
-        self.status = status
-        self.results = results
-
-    def body(self, request):
-        r = self.results
-        data = "<h1>Test Results</h1>\n"
-        data += "<ul>\n"
-        testnames = r.keys()
-        testnames.sort()
-        for name in testnames:
-            res = r[name]
-            dotname = ".".join(name)
-            data += " <li>%s: " % dotname
-            # TODO: this could break on weird test names. At the moment,
-            # test names only come from Trial tests, where the name
-            # components must be legal python names, but that won't always
-            # be a restriction.
-            url = request.childLink(dotname)
-            data += "<a href=\"%s\">%s</a>" % (url, " ".join(res.getText()))
-            data += "</li>\n"
-        data += "</ul>\n"
-        return data
-
-    def getChild(self, path, request):
-        try:
-            name = tuple(path.split("."))
-            result = self.results[name]
-            return StatusResourceTestResult(self.status, name, result)
-        except KeyError:
-            return NoResource("No such test name '%s'" % path)
-
-
-# $builder/builds/NN
-class StatusResourceBuild(HtmlResource):
-    title = "Build"
-
-    def __init__(self, status, build, builderControl, buildControl):
-        HtmlResource.__init__(self)
-        self.status = status
-        self.build = build
-        self.builderControl = builderControl
-        self.control = buildControl
-
-    def body(self, request):
-        b = self.build
-        buildbotURL = self.status.getBuildbotURL()
-        projectName = self.status.getProjectName()
-        data = '<div class="title"><a href="%s">%s</a></div>\n'%(buildbotURL,
-                                                                 projectName)
-        # the color in the following line gives python-mode trouble
-        data += ("<h1>Build <a href=\"%s\">%s</a>:#%d</h1>\n"
-                 % (self.status.getURLForThing(b.getBuilder()),
-                    b.getBuilder().getName(), b.getNumber()))
-        data += "<h2>Buildslave:</h2>\n %s\n" % html.escape(b.getSlavename())
-        data += "<h2>Reason:</h2>\n%s\n" % html.escape(b.getReason())
-
-        branch, revision, patch = b.getSourceStamp()
-        data += "<h2>SourceStamp:</h2>\n"
-        data += " <ul>\n"
-        if branch:
-            data += "  <li>Branch: %s</li>\n" % html.escape(branch)
-        if revision:
-            data += "  <li>Revision: %s</li>\n" % html.escape(str(revision))
-        if patch:
-            data += "  <li>Patch: YES</li>\n" # TODO: provide link to .diff
-        if b.getChanges():
-            data += "  <li>Changes: see below</li>\n"
-        if (branch is None and revision is None and patch is None
-            and not b.getChanges()):
-            data += "  <li>build of most recent revision</li>\n"
-        data += " </ul>\n"
-        if b.isFinished():
-            data += "<h2>Results:</h2>\n"
-            data += " ".join(b.getText()) + "\n"
-            if b.getTestResults():
-                url = request.childLink("tests")
-                data += "<h3><a href=\"%s\">test results</a></h3>\n" % url
-        else:
-            data += "<h2>Build In Progress</h2>"
-            if self.control is not None:
-                stopURL = urllib.quote(request.childLink("stop"))
-                data += """
-                <form action="%s" class='command stopbuild'>
-                <p>To stop this build, fill out the following fields and
-                push the 'Stop' button</p>\n""" % stopURL
-                data += make_row("Your name:",
-                                 "<input type='text' name='username' />")
-                data += make_row("Reason for stopping build:",
-                                 "<input type='text' name='comments' />")
-                data += """<input type="submit" value="Stop Builder" />
-                </form>
-                """
-
-        if b.isFinished() and self.builderControl is not None:
-            data += "<h3>Resubmit Build:</h3>\n"
-            # can we rebuild it exactly?
-            exactly = (revision is not None) or b.getChanges()
-            if exactly:
-                data += ("<p>This tree was built from a specific set of \n"
-                         "source files, and can be rebuilt exactly</p>\n")
-            else:
-                data += ("<p>This tree was built from the most recent "
-                         "revision")
-                if branch:
-                    data += " (along some branch)"
-                data += (" and thus it might not be possible to rebuild it \n"
-                         "exactly. Any changes that have been committed \n"
-                         "after this build was started <b>will</b> be \n"
-                         "included in a rebuild.</p>\n")
-            rebuildURL = urllib.quote(request.childLink("rebuild"))
-            data += ('<form action="%s" class="command rebuild">\n'
-                     % rebuildURL)
-            data += make_row("Your name:",
-                             "<input type='text' name='username' />")
-            data += make_row("Reason for re-running build:",
-                             "<input type='text' name='comments' />")
-            data += '<input type="submit" value="Rebuild" />\n'
-            data += '</form>\n'
-
-        data += "<h2>Steps and Logfiles:</h2>\n"
-        if b.getLogs():
-            data += "<ol>\n"
-            for s in b.getSteps():
-                data += (" <li><a href=\"%s\">%s</a> [%s]\n"
-                         % (self.status.getURLForThing(s), s.getName(),
-                            " ".join(s.getText())))
-                if s.getLogs():
-                    data += "  <ol>\n"
-                    for logfile in s.getLogs():
-                        data += ("   <li><a href=\"%s\">%s</a></li>\n" %
-                                 (self.status.getURLForThing(logfile),
-                                  logfile.getName()))
-                    data += "  </ol>\n"
-                data += " </li>\n"
-            data += "</ol>\n"
-
-        data += ("<h2>Blamelist:</h2>\n"
-                 " <ol>\n")
-        for who in b.getResponsibleUsers():
-            data += "  <li>%s</li>\n" % html.escape(who)
-        data += (" </ol>\n"
-                 "<h2>All Changes</h2>\n")
-        changes = b.getChanges()
-        if changes:
-            data += "<ol>\n"
-            for c in changes:
-                data += "<li>" + c.asHTML() + "</li>\n"
-            data += "</ol>\n"
-        #data += html.PRE(b.changesText()) # TODO
-        return data
-
-    def stop(self, request):
-        log.msg("web stopBuild of build %s:%s" % \
-                (self.build.getBuilder().getName(),
-                 self.build.getNumber()))
-        name = request.args.get("username", ["<unknown>"])[0]
-        comments = request.args.get("comments", ["<no reason specified>"])[0]
-        reason = ("The web-page 'stop build' button was pressed by "
-                  "'%s': %s\n" % (name, comments))
-        self.control.stopBuild(reason)
-        # we're at http://localhost:8080/svn-hello/builds/5/stop?[args] and
-        # we want to go to: http://localhost:8080/svn-hello/builds/5 or
-        # http://localhost:8080/
-        #
-        #return Redirect("../%d" % self.build.getNumber())
-        r = Redirect("../../..")
-        d = defer.Deferred()
-        reactor.callLater(1, d.callback, r)
-        return DeferredResource(d)
-
-    def rebuild(self, request):
-        log.msg("web rebuild of build %s:%s" % \
-                (self.build.getBuilder().getName(),
-                 self.build.getNumber()))
-        name = request.args.get("username", ["<unknown>"])[0]
-        comments = request.args.get("comments", ["<no reason specified>"])[0]
-        reason = ("The web-page 'rebuild' button was pressed by "
-                  "'%s': %s\n" % (name, comments))
-        if not self.builderControl or not self.build.isFinished():
-            log.msg("could not rebuild: bc=%s, isFinished=%s"
-                    % (self.builderControl, self.build.isFinished()))
-            # TODO: indicate an error
-        else:
-            self.builderControl.resubmitBuild(self.build, reason)
-        # we're at http://localhost:8080/svn-hello/builds/5/rebuild?[args] and
-        # we want to go to the top, at http://localhost:8080/
-        r = Redirect("../../..")
-        d = defer.Deferred()
-        reactor.callLater(1, d.callback, r)
-        return DeferredResource(d)
-
-    def getChild(self, path, request):
-        if path == "tests":
-            return StatusResourceTestResults(self.status,
-                                             self.build.getTestResults())
-        if path == "stop":
-            return self.stop(request)
-        if path == "rebuild":
-            return self.rebuild(request)
-        if path.startswith("step-"):
-            stepname = path[len("step-"):]
-            steps = self.build.getSteps()
-            for s in steps:
-                if s.getName() == stepname:
-                    return StatusResourceBuildStep(self.status, s)
-            return NoResource("No such BuildStep '%s'" % stepname)
-        return NoResource("No such resource '%s'" % path)
-
-# $builder
-class StatusResourceBuilder(HtmlResource):
-
-    def __init__(self, status, builder, control):
-        HtmlResource.__init__(self)
-        self.status = status
-        self.title = builder.getName() + " Builder"
-        self.builder = builder
-        self.control = control
-
-    def body(self, request):
-        b = self.builder
-        slaves = b.getSlaves()
-        connected_slaves = [s for s in slaves if s.isConnected()]
-
-        buildbotURL = self.status.getBuildbotURL()
-        projectName = self.status.getProjectName()
-        data = "<a href=\"%s\">%s</a>\n" % (buildbotURL, projectName)
-        data += make_row("Builder:", html.escape(b.getName()))
-        b1 = b.getBuild(-1)
-        if b1 is not None:
-            data += make_row("Current/last build:", str(b1.getNumber()))
-        data += "\n<br />BUILDSLAVES<br />\n"
-        data += "<ol>\n"
-        for slave in slaves:
-            data += "<li><b>%s</b>: " % html.escape(slave.getName())
-            if slave.isConnected():
-                data += "CONNECTED\n"
-                if slave.getAdmin():
-                    data += make_row("Admin:", html.escape(slave.getAdmin()))
-                if slave.getHost():
-                    data += "<span class='label'>Host info:</span>\n"
-                    data += html.PRE(slave.getHost())
-            else:
-                data += ("NOT CONNECTED\n")
-            data += "</li>\n"
-        data += "</ol>\n"
-
-        if self.control is not None and connected_slaves:
-            forceURL = urllib.quote(request.childLink("force"))
-            data += (
-                """
-                <form action='%(forceURL)s' class='command forcebuild'>
-                <p>To force a build, fill out the following fields and
-                push the 'Force Build' button</p>"""
-                + make_row("Your name:",
-                           "<input type='text' name='username' />")
-                + make_row("Reason for build:",
-                           "<input type='text' name='comments' />")
-                + make_row("Branch to build:",
-                           "<input type='text' name='branch' />")
-                + make_row("Revision to build:",
-                           "<input type='text' name='revision' />")
-                + """
-                <input type='submit' value='Force Build' />
-                </form>
-                """) % {"forceURL": forceURL}
-        elif self.control is not None:
-            data += """
-            <p>All buildslaves appear to be offline, so it's not possible
-            to force this build to execute at this time.</p>
-            """
-
-        if self.control is not None:
-            pingURL = urllib.quote(request.childLink("ping"))
-            data += """
-            <form action="%s" class='command pingbuilder'>
-            <p>To ping the buildslave(s), push the 'Ping' button</p>
-
-            <input type="submit" value="Ping Builder" />
-            </form>
-            """ % pingURL
-
-        return data
-
-    def force(self, request):
-        name = request.args.get("username", ["<unknown>"])[0]
-        reason = request.args.get("comments", ["<no reason specified>"])[0]
-        branch = request.args.get("branch", [""])[0]
-        revision = request.args.get("revision", [""])[0]
-
-        r = "The web-page 'force build' button was pressed by '%s': %s\n" \
-            % (name, reason)
-        log.msg("web forcebuild of builder '%s', branch='%s', revision='%s'"
-                % (self.builder.name, branch, revision))
-
-        if not self.control:
-            # TODO: tell the web user that their request was denied
-            log.msg("but builder control is disabled")
-            return Redirect("..")
-
-        # keep weird stuff out of the branch and revision strings. TODO:
-        # centralize this somewhere.
-        if not re.match(r'^[\w\.\-\/]*$', branch):
-            log.msg("bad branch '%s'" % branch)
-            return Redirect("..")
-        if not re.match(r'^[\w\.\-\/]*$', revision):
-            log.msg("bad revision '%s'" % revision)
-            return Redirect("..")
-        if branch == "":
-            branch = None
-        if revision == "":
-            revision = None
-
-        # TODO: if we can authenticate that a particular User pushed the
-        # button, use their name instead of None, so they'll be informed of
-        # the results.
-        s = SourceStamp(branch=branch, revision=revision)
-        req = BuildRequest(r, s, self.builder.getName())
-        try:
-            self.control.requestBuildSoon(req)
-        except interfaces.NoSlaveError:
-            # TODO: tell the web user that their request could not be
-            # honored
-            pass
-        return Redirect("..")
-
-    def ping(self, request):
-        log.msg("web ping of builder '%s'" % self.builder.name)
-        self.control.ping() # TODO: there ought to be an ISlaveControl
-        return Redirect("..")
-
-    def getChild(self, path, request):
-        if path == "force":
-            return self.force(request)
-        if path == "ping":
-            return self.ping(request)
-        if not path in ("events", "builds"):
-            return NoResource("Bad URL '%s'" % path)
-        num = request.postpath.pop(0)
-        request.prepath.append(num)
-        num = int(num)
-        if path == "events":
-            # TODO: is this dead code? .statusbag doesn't exist,right?
-            log.msg("getChild['path']: %s" % request.uri)
-            return NoResource("events are unavailable until code gets fixed")
-            filename = request.postpath.pop(0)
-            request.prepath.append(filename)
-            e = self.builder.statusbag.getEventNumbered(num)
-            if not e:
-                return NoResource("No such event '%d'" % num)
-            file = e.files.get(filename, None)
-            if file == None:
-                return NoResource("No such file '%s'" % filename)
-            if type(file) == type(""):
-                if file[:6] in ("<HTML>", "<html>"):
-                    return static.Data(file, "text/html")
-                return static.Data(file, "text/plain")
-            return file
-        if path == "builds":
-            build = self.builder.getBuild(num)
-            if build:
-                control = None
-                if self.control:
-                    control = self.control.getBuild(num)
-                return StatusResourceBuild(self.status, build,
-                                           self.control, control)
-            else:
-                return NoResource("No such build '%d'" % num)
-        return NoResource("really weird URL %s" % path)
-
-# $changes/NN
-class StatusResourceChanges(HtmlResource):
-    def __init__(self, status, changemaster):
-        HtmlResource.__init__(self)
-        self.status = status
-        self.changemaster = changemaster
-    def body(self, request):
-        data = ""
-        data += "Change sources:\n"
-        sources = list(self.changemaster)
-        if sources:
-            data += "<ol>\n"
-            for s in sources:
-                data += "<li>%s</li>\n" % s.describe()
-            data += "</ol>\n"
-        else:
-            data += "none (push only)\n"
-        return data
-    def getChild(self, path, request):
-        num = int(path)
-        c = self.changemaster.getChangeNumbered(num)
-        if not c:
-            return NoResource("No change number '%d'" % num)
-        return StaticHTML(c.asHTML(), "Change #%d" % num)
-
 textlog_stylesheet = """
 <style type="text/css">
  div.data {
@@ -1035,19 +489,101 @@
         if debug: log.msg(" fES1", starts)
 
 
+if hasattr(sys, "frozen"):
+    # all 'data' files are in the directory of our executable
+    here = os.path.dirname(sys.executable)
+    buildbot_css = os.path.abspath(os.path.join(here, "classic.css"))
+else:
+    # running from source
+    # the icon is sibpath(__file__, "../buildbot.png") . This is for
+    # portability.
+    up = os.path.dirname
+    buildbot_css = os.path.abspath(os.path.join(up(__file__), "classic.css"))
+
 class WaterfallStatusResource(HtmlResource):
-    """This builds the main status page, with the waterfall display, and
-    all child pages."""
+    """I implement the primary web-page status interface, called a 'Waterfall
+    Display' because builds and steps are presented in a grid of boxes which
+    move downwards over time. The top edge is always the present. Each column
+    represents a single builder. Each box describes a single Step, which may
+    have logfiles or other status information.
+
+    All these pages are served via a web server of some sort. The simplest
+    approach is to let the buildmaster run its own webserver, on a given TCP
+    port, but it can also publish its pages to a L{twisted.web.distrib}
+    distributed web server (which lets the buildbot pages be a subset of some
+    other web server).
+
+    Since 0.6.3, BuildBot defines class attributes on elements so they can be
+    styled with CSS stylesheets. Buildbot uses some generic classes to
+    identify the type of object, and some more specific classes for the
+    various kinds of those types. It does this by specifying both in the
+    class attributes where applicable, separated by a space. It is important
+    that in your CSS you declare the more generic class styles above the more
+    specific ones. For example, first define a style for .Event, and below
+    that for .SUCCESS
+
+    The following CSS class names are used:
+        - Activity, Event, BuildStep, LastBuild: general classes
+        - waiting, interlocked, building, offline, idle: Activity states
+        - start, running, success, failure, warnings, skipped, exception:
+          LastBuild and BuildStep states
+        - Change: box with change
+        - Builder: box for builder name (at top)
+        - Project
+        - Time
+
+    @type buildmaster: L{buildbot.master.BuildMaster}
+    @ivar buildmaster: like all status plugins, this object is a child of the
+                  BuildMaster, so C{.parent} points to a
+                  L{buildbot.master.BuildMaster} instance, through which
+                  the status-reporting object is acquired.
+    """
+
     title = "BuildBot"
-    def __init__(self, status, changemaster, categories, css=None):
+
+    implements(IToplevelResource)
+
+    status = None
+    control = None
+    changemaster = None
+
+    def __init__(self, allowForce=True, categories=None, css=None):
+        if not css: css = buildbot_css
+
         HtmlResource.__init__(self)
-        self.status = status
-        self.changemaster = changemaster
+        self.addSlash = True
+        self.allowForce = allowForce
         self.categories = categories
+        self.css = css
+
+    def setBuildmaster(self, buildmaster):
+        self.status = buildmaster.getStatus()
+        if self.allowForce:
+            self.control = interfaces.IControl(buildmaster)
+        else:
+            self.control = None
+        self.changemaster = buildmaster.change_svc
+
+        # try to set the page title
         p = self.status.getProjectName()
         if p:
             self.title = "BuildBot: %s" % p
-        self.css = css
+
+    def getChild(self, path, request):
+        if path == "": return self # follow the trailing /
+        if path == "buildbot.css" and self.css:
+            return static.File(self.css)
+        if path == "changes":
+            return StatusResourceChanges(self.status, self.changemaster)
+
+        if path in self.status.getBuilderNames():
+            builder = self.status.getBuilder(path)
+            control = None
+            if self.control:
+                control = self.control.getBuilder(path)
+            return StatusResourceBuilder(self.status, builder, control)
+
+        return NoResource("No such Builder '%s'" % path)
 
     def body(self, request):
         "This method builds the main waterfall display."
@@ -1511,221 +1047,13 @@
             data += " </tr>\n"
         return data
 
-
-class StatusResource(Resource):
-    status = None
-    control = None
-    favicon = None
-    robots_txt = None
-
-    def __init__(self, status, control, changemaster, categories, css):
-        """
-        @type  status:       L{buildbot.status.builder.Status}
-        @type  control:      L{buildbot.master.Control}
-        @type  changemaster: L{buildbot.changes.changes.ChangeMaster}
-        """
-        Resource.__init__(self)
-        self.status = status
-        self.control = control
-        self.changemaster = changemaster
-        self.categories = categories
-        self.css = css
-        waterfall = WaterfallStatusResource(self.status, changemaster,
-                                            categories, css)
-        self.putChild("", waterfall)
-
-    def render(self, request):
-        request.redirect(request.prePathURL() + '/')
-        request.finish()
-
-    def getChild(self, path, request):
-        if path == "robots.txt" and self.robots_txt:
-            return static.File(self.robots_txt)
-        if path == "buildbot.css" and self.css:
-            return static.File(self.css)
-        if path == "changes":
-            return StatusResourceChanges(self.status, self.changemaster)
-        if path == "favicon.ico":
-            if self.favicon:
-                return static.File(self.favicon)
-            return NoResource("No favicon.ico registered")
-
-        if path in self.status.getBuilderNames():
-            builder = self.status.getBuilder(path)
-            control = None
-            if self.control:
-                control = self.control.getBuilder(path)
-            return StatusResourceBuilder(self.status, builder, control)
-
-        return NoResource("No such Builder '%s'" % path)
-
-if hasattr(sys, "frozen"):
-    # all 'data' files are in the directory of our executable
-    here = os.path.dirname(sys.executable)
-    buildbot_icon = os.path.abspath(os.path.join(here, "buildbot.png"))
-    buildbot_css = os.path.abspath(os.path.join(here, "classic.css"))
-else:
-    # running from source
-    # the icon is sibpath(__file__, "../buildbot.png") . This is for
-    # portability.
-    up = os.path.dirname
-    buildbot_icon = os.path.abspath(os.path.join(up(up(up(__file__))),
-                                                 "buildbot.png"))
-    buildbot_css = os.path.abspath(os.path.join(up(__file__), "classic.css"))
-
-class Waterfall(base.StatusReceiverMultiService):
-    """I implement the primary web-page status interface, called a 'Waterfall
-    Display' because builds and steps are presented in a grid of boxes which
-    move downwards over time. The top edge is always the present. Each column
-    represents a single builder. Each box describes a single Step, which may
-    have logfiles or other status information.
-
-    All these pages are served via a web server of some sort. The simplest
-    approach is to let the buildmaster run its own webserver, on a given TCP
-    port, but it can also publish its pages to a L{twisted.web.distrib}
-    distributed web server (which lets the buildbot pages be a subset of some
-    other web server).
-
-    Since 0.6.3, BuildBot defines class attributes on elements so they can be
-    styled with CSS stylesheets. Buildbot uses some generic classes to
-    identify the type of object, and some more specific classes for the
-    various kinds of those types. It does this by specifying both in the
-    class attributes where applicable, separated by a space. It is important
-    that in your CSS you declare the more generic class styles above the more
-    specific ones. For example, first define a style for .Event, and below
-    that for .SUCCESS
-
-    The following CSS class names are used:
-        - Activity, Event, BuildStep, LastBuild: general classes
-        - waiting, interlocked, building, offline, idle: Activity states
-        - start, running, success, failure, warnings, skipped, exception:
-          LastBuild and BuildStep states
-        - Change: box with change
-        - Builder: box for builder name (at top)
-        - Project
-        - Time
-
-    @type parent: L{buildbot.master.BuildMaster}
-    @ivar parent: like all status plugins, this object is a child of the
-                  BuildMaster, so C{.parent} points to a
-                  L{buildbot.master.BuildMaster} instance, through which
-                  the status-reporting object is acquired.
-    """
-
-    compare_attrs = ["http_port", "distrib_port", "allowForce",
-                     "categories", "css", "favicon", "robots_txt"]
-
-    def __init__(self, http_port=None, distrib_port=None, allowForce=True,
-                 categories=None, css=buildbot_css, favicon=buildbot_icon,
-                 robots_txt=None):
-        """To have the buildbot run its own web server, pass a port number to
-        C{http_port}. To have it run a web.distrib server
-
-        @type  http_port: int or L{twisted.application.strports} string
-        @param http_port: a strports specification describing which port the
-                          buildbot should use for its web server, with the
-                          Waterfall display as the root page. For backwards
-                          compatibility this can also be an int. Use
-                          'tcp:8000' to listen on that port, or
-                          'tcp:12345:interface=127.0.0.1' if you only want
-                          local processes to connect to it (perhaps because
-                          you are using an HTTP reverse proxy to make the
-                          buildbot available to the outside world, and do not
-                          want to make the raw port visible).
-
-        @type  distrib_port: int or L{twisted.application.strports} string
-        @param distrib_port: Use this if you want to publish the Waterfall
-                             page using web.distrib instead. The most common
-                             case is to provide a string that is an absolute
-                             pathname to the unix socket on which the
-                             publisher should listen
-                             (C{os.path.expanduser(~/.twistd-web-pb)} will
-                             match the default settings of a standard
-                             twisted.web 'personal web server'). Another
-                             possibility is to pass an integer, which means
-                             the publisher should listen on a TCP socket,
-                             allowing the web server to be on a different
-                             machine entirely. Both forms are provided for
-                             backwards compatibility; the preferred form is a
-                             strports specification like
-                             'unix:/home/buildbot/.twistd-web-pb'. Providing
-                             a non-absolute pathname will probably confuse
-                             the strports parser.
-
-        @type  allowForce: bool
-        @param allowForce: if True, present a 'Force Build' button on the
-                           per-Builder page that allows visitors to the web
-                           site to initiate a build. If False, don't provide
-                           this button.
-
-        @type  favicon: string
-        @param favicon: if set, provide the pathname of an image file that
-                        will be used for the 'favicon.ico' resource. Many
-                        browsers automatically request this file and use it
-                        as an icon in any bookmark generated from this site.
-                        Defaults to the buildbot/buildbot.png image provided
-                        in the distribution. Can be set to None to avoid
-                        using a favicon at all.
-
-        @type  robots_txt: string
-        @param robots_txt: if set, provide the pathname of a robots.txt file.
-                           Many search engines request this file and obey the
-                           rules in it. E.g. to disallow them to crawl the
-                           status page, put the following two lines in
-                           robots.txt::
-                              User-agent: *
-                              Disallow: /
-        """
-
-        base.StatusReceiverMultiService.__init__(self)
-        assert allowForce in (True, False) # TODO: implement others
-        if type(http_port) is int:
-            http_port = "tcp:%d" % http_port
-        self.http_port = http_port
-        if distrib_port is not None:
-            if type(distrib_port) is int:
-                distrib_port = "tcp:%d" % distrib_port
-            if distrib_port[0] in "/~.": # pathnames
-                distrib_port = "unix:%s" % distrib_port
-        self.distrib_port = distrib_port
-        self.allowForce = allowForce
-        self.categories = categories
-        self.css = css
-        self.favicon = favicon
-        self.robots_txt = robots_txt
-
-    def __repr__(self):
-        if self.http_port is None:
-            return "<Waterfall on path %s>" % self.distrib_port
-        if self.distrib_port is None:
-            return "<Waterfall on port %s>" % self.http_port
-        return "<Waterfall on port %s and path %s>" % (self.http_port,
-                                                       self.distrib_port)
-
-    def setServiceParent(self, parent):
-        """
-        @type  parent: L{buildbot.master.BuildMaster}
-        """
-        base.StatusReceiverMultiService.setServiceParent(self, parent)
-        self.setup()
-
-    def setup(self):
-        status = self.parent.getStatus()
-        if self.allowForce:
-            control = interfaces.IControl(self.parent)
-        else:
-            control = None
-        change_svc = self.parent.change_svc
-        sr = StatusResource(status, control, change_svc, self.categories,
-                            self.css)
-        sr.favicon = self.favicon
-        sr.robots_txt = self.robots_txt
-        self.site = server.Site(sr)
-
-        if self.http_port is not None:
-            s = strports.service(self.http_port, self.site)
-            s.setServiceParent(self)
-        if self.distrib_port is not None:
-            f = pb.PBServerFactory(distrib.ResourcePublisher(self.site))
-            s = strports.service(self.distrib_port, f)
-            s.setServiceParent(self)
+# Compatibility function: sets up a new site with a waterfall as its
+# top_page
+def Waterfall(http_port=None, distrib_port=None, favicon=None, robots_txt=None,
+            allowForce=True, categories=None, css=None):
+    return WebStatus(
+        WaterfallStatusResource(allowForce=allowForce, categories=categories, css=css),
+        http_port=http_port,
+        distrib_port=distrib_port,
+        favicon=favicon,
+        robots_txt=robots_txt)
Index: statusgrid/buildbot/status/web/base.py
===================================================================
--- /dev/null	1970-01-01 00:00:00.000000000 +0000
+++ statusgrid/buildbot/status/web/base.py	2007-07-25 09:46:53.972391429 -0500
@@ -0,0 +1,66 @@
+from twisted.web.resource import Resource
+
+from zope.interface import Interface
+
+class HtmlResource(Resource):
+    css = None
+    contentType = "text/html; charset=UTF-8"
+    title = "Dummy"
+    addSlash = False
+
+    def render(self, request):
+        if self.addSlash and request.prePathURL()[-1] != '/':
+            request.redirect(request.prePathURL() + '/')
+            request.finish()
+
+        data = self.content(request)
+        if isinstance(data, unicode):
+            data = data.encode("utf-8")
+        request.setHeader("content-type", self.contentType)
+        if request.method == "HEAD":
+            request.setHeader("content-length", len(data))
+            return ''
+        return data
+
+    def content(self, request):
+        data = ('<!DOCTYPE html PUBLIC'
+                ' "-//W3C//DTD XHTML 1.0 Transitional//EN"\n'
+                '"http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">\n'
+                '<html'
+                ' xmlns="http://www.w3.org/1999/xhtml"'
+                ' lang="en"'
+                ' xml:lang="en">\n')
+        data += "<head>\n"
+        data += "  <title>" + self.title + "</title>\n"
+        if self.css:
+            # TODO: use some sort of relative link up to the root page, so
+            # this css can be used from child pages too
+            data += ('  <link href="%s" rel="stylesheet" type="text/css"/>\n'
+                     % "buildbot.css")
+        data += "</head>\n"
+        data += '<body vlink="#800080">\n'
+        data += self.body(request)
+        data += "</body></html>\n"
+        return data
+
+    def body(self, request):
+        return "Dummy\n"
+
+class StaticHTML(HtmlResource):
+    def __init__(self, body, title):
+        HtmlResource.__init__(self)
+        self.bodyHTML = body
+        self.title = title
+    def body(self, request):
+        return self.bodyHTML
+
+class IToplevelResource(Interface):
+    """
+    I represent a Resource which is instantiated once, and which expects
+    to be told about a buildmaster before I am rendered.
+    """
+    def setBuildmaster(buildmaster): pass
+
+class IHTMLLog(Interface):
+    pass
+
Index: statusgrid/buildbot/status/web/builder.py
===================================================================
--- /dev/null	1970-01-01 00:00:00.000000000 +0000
+++ statusgrid/buildbot/status/web/builder.py	2007-07-25 10:23:17.800283376 -0500
@@ -0,0 +1,505 @@
+import urllib, re
+
+from twisted.python import log
+from twisted.web import static, html
+from twisted.internet import defer, reactor
+from twisted.web.error import NoResource
+from twisted.web.util import Redirect, DeferredResource
+
+from buildbot import interfaces, util
+from buildbot.sourcestamp import SourceStamp
+from buildbot.status.web.base import HtmlResource, StaticHTML, IHTMLLog
+from buildbot.process.base import BuildRequest
+
+ROW_TEMPLATE = '''
+<div class="row">
+  <span class="label">%(label)s</span>
+  <span class="field">%(field)s</span>
+</div>'''
+
+def make_row(label, field):
+    """Create a name/value row for the HTML.
+
+    `label` is plain text; it will be HTML-encoded.
+
+    `field` is a bit of HTML structure; it will not be encoded in
+    any way.
+    """
+    label = html.escape(label)
+    return ROW_TEMPLATE % {"label": label, "field": field}
+
+class StatusResourceBuildStep(HtmlResource):
+    """
+    I represent a particular step of a particular build, at a URL like
+    BUILDER/builds/NN/STEPNAME.
+    """
+    title = "Build Step"
+
+    def __init__(self, status, step):
+        HtmlResource.__init__(self)
+        self.status = status
+        self.step = step
+
+    def body(self, request):
+        s = self.step
+        b = s.getBuild()
+        data = "<h1>BuildStep %s:#%d:%s</h1>\n" % \
+               (b.getBuilder().getName(), b.getNumber(), s.getName())
+
+        if s.isFinished():
+            data += ("<h2>Finished</h2>\n"
+                     "<p>%s</p>\n" % html.escape("%s" % s.getText()))
+        else:
+            data += ("<h2>Not Finished</h2>\n"
+                     "<p>ETA %s seconds</p>\n" % s.getETA())
+
+        exp = s.getExpectations()
+        if exp:
+            data += ("<h2>Expectations</h2>\n"
+                     "<ul>\n")
+            for e in exp:
+                data += "<li>%s: current=%s, target=%s</li>\n" % \
+                        (html.escape(e[0]), e[1], e[2])
+            data += "</ul>\n"
+        logs = s.getLogs()
+        if logs:
+            data += ("<h2>Logs</h2>\n"
+                     "<ul>\n")
+            for num in range(len(logs)):
+                if logs[num].hasContents():
+                    # FIXME: If the step name has a / in it, this is broken
+                    # either way.  If we quote it but say '/'s are safe,
+                    # it chops up the step name.  If we quote it and '/'s
+                    # are not safe, it escapes the / that separates the
+                    # step name from the log number.
+                    data += '<li><a href="%s">%s</a></li>\n' % \
+                            (urllib.quote(request.childLink("%d" % num)),
+                             html.escape(logs[num].getName()))
+                else:
+                    data += ('<li>%s</li>\n' %
+                             html.escape(logs[num].getName()))
+            data += "</ul>\n"
+
+        return data
+
+    def getChild(self, path, request):
+        logname = path
+        try:
+            log = self.step.getLogs()[int(logname)]
+            if log.hasContents():
+                return IHTMLLog(interfaces.IStatusLog(log))
+            return NoResource("Empty Log '%s'" % logname)
+        except (IndexError, ValueError):
+            return NoResource("No such Log '%s'" % logname)
+
+# $builder/builds/NN/tests/TESTNAME
+class StatusResourceTestResult(HtmlResource):
+    """
+    I represent the result of a particular test of a particular
+    build, at a URL like BUILDER/builds/NN/tests/TESTNAME.
+    """
+    title = "Test Logs"
+
+    def __init__(self, status, name, result):
+        HtmlResource.__init__(self)
+        self.status = status
+        self.name = name
+        self.result = result
+
+    def body(self, request):
+        dotname = ".".join(self.name)
+        logs = self.result.getLogs()
+        lognames = logs.keys()
+        lognames.sort()
+        data = "<h1>%s</h1>\n" % html.escape(dotname)
+        for name in lognames:
+            data += "<h2>%s</h2>\n" % html.escape(name)
+            data += "<pre>" + logs[name] + "</pre>\n\n"
+
+        return data
+
+
+# $builder/builds/NN/tests
+class StatusResourceTestResults(HtmlResource):
+    """
+    I represent a summary of the tests in a particular build, at a
+    URL like BUILDER/builds/NN/tests.
+    """
+    title = "Test Results"
+
+    def __init__(self, status, results):
+        HtmlResource.__init__(self)
+        self.status = status
+        self.results = results
+
+    def body(self, request):
+        r = self.results
+        data = "<h1>Test Results</h1>\n"
+        data += "<ul>\n"
+        testnames = r.keys()
+        testnames.sort()
+        for name in testnames:
+            res = r[name]
+            dotname = ".".join(name)
+            data += " <li>%s: " % dotname
+            # TODO: this could break on weird test names. At the moment,
+            # test names only come from Trial tests, where the name
+            # components must be legal python names, but that won't always
+            # be a restriction.
+            url = request.childLink(dotname)
+            data += "<a href=\"%s\">%s</a>" % (url, " ".join(res.getText()))
+            data += "</li>\n"
+        data += "</ul>\n"
+        return data
+
+    def getChild(self, path, request):
+        try:
+            name = tuple(path.split("."))
+            result = self.results[name]
+            return StatusResourceTestResult(self.status, name, result)
+        except KeyError:
+            return NoResource("No such test name '%s'" % path)
+
+
+class StatusResourceBuild(HtmlResource):
+    """
+    I represent a particular build, at a URL like BUILDER/builds/NN.
+    """
+    title = "Build"
+
+    def __init__(self, status, build, builderControl, buildControl):
+        HtmlResource.__init__(self)
+        self.status = status
+        self.build = build
+        self.builderControl = builderControl
+        self.control = buildControl
+
+    def body(self, request):
+        b = self.build
+        buildbotURL = self.status.getBuildbotURL()
+        projectName = self.status.getProjectName()
+        data = '<div class="title"><a href="%s">%s</a></div>\n'%(buildbotURL,
+                                                                 projectName)
+        # the color in the following line gives python-mode trouble
+        data += ("<h1>Build <a href=\"%s\">%s</a>:#%d</h1>\n"
+                 % (self.status.getURLForThing(b.getBuilder()),
+                    b.getBuilder().getName(), b.getNumber()))
+        data += "<h2>Buildslave:</h2>\n %s\n" % html.escape(b.getSlavename())
+        data += "<h2>Reason:</h2>\n%s\n" % html.escape(b.getReason())
+
+        branch, revision, patch = b.getSourceStamp()
+        data += "<h2>SourceStamp:</h2>\n"
+        data += " <ul>\n"
+        if branch:
+            data += "  <li>Branch: %s</li>\n" % html.escape(branch)
+        if revision:
+            data += "  <li>Revision: %s</li>\n" % html.escape(str(revision))
+        if patch:
+            data += "  <li>Patch: YES</li>\n" # TODO: provide link to .diff
+        if b.getChanges():
+            data += "  <li>Changes: see below</li>\n"
+        if (branch is None and revision is None and patch is None
+            and not b.getChanges()):
+            data += "  <li>build of most recent revision</li>\n"
+        data += " </ul>\n"
+        if b.isFinished():
+            data += "<h2>Results:</h2>\n"
+            data += " ".join(b.getText()) + "\n"
+            if b.getTestResults():
+                url = request.childLink("tests")
+                data += "<h3><a href=\"%s\">test results</a></h3>\n" % url
+        else:
+            data += "<h2>Build In Progress</h2>"
+            if self.control is not None:
+                stopURL = urllib.quote(request.childLink("stop"))
+                data += """
+                <form action="%s" class='command stopbuild'>
+                <p>To stop this build, fill out the following fields and
+                push the 'Stop' button</p>\n""" % stopURL
+                data += make_row("Your name:",
+                                 "<input type='text' name='username' />")
+                data += make_row("Reason for stopping build:",
+                                 "<input type='text' name='comments' />")
+                data += """<input type="submit" value="Stop Builder" />
+                </form>
+                """
+
+        if b.isFinished() and self.builderControl is not None:
+            data += "<h3>Resubmit Build:</h3>\n"
+            # can we rebuild it exactly?
+            exactly = (revision is not None) or b.getChanges()
+            if exactly:
+                data += ("<p>This tree was built from a specific set of \n"
+                         "source files, and can be rebuilt exactly</p>\n")
+            else:
+                data += ("<p>This tree was built from the most recent "
+                         "revision")
+                if branch:
+                    data += " (along some branch)"
+                data += (" and thus it might not be possible to rebuild it \n"
+                         "exactly. Any changes that have been committed \n"
+                         "after this build was started <b>will</b> be \n"
+                         "included in a rebuild.</p>\n")
+            rebuildURL = urllib.quote(request.childLink("rebuild"))
+            data += ('<form action="%s" class="command rebuild">\n'
+                     % rebuildURL)
+            data += make_row("Your name:",
+                             "<input type='text' name='username' />")
+            data += make_row("Reason for re-running build:",
+                             "<input type='text' name='comments' />")
+            data += '<input type="submit" value="Rebuild" />\n'
+            data += '</form>\n'
+
+        data += "<h2>Steps and Logfiles:</h2>\n"
+        if b.getLogs():
+            data += "<ol>\n"
+            for s in b.getSteps():
+                data += (" <li><a href=\"%s\">%s</a> [%s]\n"
+                         % (self.status.getURLForThing(s), s.getName(),
+                            " ".join(s.getText())))
+                if s.getLogs():
+                    data += "  <ol>\n"
+                    for logfile in s.getLogs():
+                        data += ("   <li><a href=\"%s\">%s</a></li>\n" %
+                                 (self.status.getURLForThing(logfile),
+                                  logfile.getName()))
+                    data += "  </ol>\n"
+                data += " </li>\n"
+            data += "</ol>\n"
+
+        data += ("<h2>Blamelist:</h2>\n"
+                 " <ol>\n")
+        for who in b.getResponsibleUsers():
+            data += "  <li>%s</li>\n" % html.escape(who)
+        data += (" </ol>\n"
+                 "<h2>All Changes</h2>\n")
+        changes = b.getChanges()
+        if changes:
+            data += "<ol>\n"
+            for c in changes:
+                data += "<li>" + c.asHTML() + "</li>\n"
+            data += "</ol>\n"
+        #data += html.PRE(b.changesText()) # TODO
+        return data
+
+    def stop(self, request):
+        log.msg("web stopBuild of build %s:%s" % \
+                (self.build.getBuilder().getName(),
+                 self.build.getNumber()))
+        name = request.args.get("username", ["<unknown>"])[0]
+        comments = request.args.get("comments", ["<no reason specified>"])[0]
+        reason = ("The web-page 'stop build' button was pressed by "
+                  "'%s': %s\n" % (name, comments))
+        self.control.stopBuild(reason)
+        # we're at http://localhost:8080/svn-hello/builds/5/stop?[args] and
+        # we want to go to: http://localhost:8080/svn-hello/builds/5 or
+        # http://localhost:8080/
+        #
+        #return Redirect("../%d" % self.build.getNumber())
+        r = Redirect("../../..")
+        d = defer.Deferred()
+        reactor.callLater(1, d.callback, r)
+        return DeferredResource(d)
+
+    def rebuild(self, request):
+        log.msg("web rebuild of build %s:%s" % \
+                (self.build.getBuilder().getName(),
+                 self.build.getNumber()))
+        name = request.args.get("username", ["<unknown>"])[0]
+        comments = request.args.get("comments", ["<no reason specified>"])[0]
+        reason = ("The web-page 'rebuild' button was pressed by "
+                  "'%s': %s\n" % (name, comments))
+        if not self.builderControl or not self.build.isFinished():
+            log.msg("could not rebuild: bc=%s, isFinished=%s"
+                    % (self.builderControl, self.build.isFinished()))
+            # TODO: indicate an error
+        else:
+            self.builderControl.resubmitBuild(self.build, reason)
+        # we're at http://localhost:8080/svn-hello/builds/5/rebuild?[args] and
+        # we want to go to the top, at http://localhost:8080/
+        r = Redirect("../../..")
+        d = defer.Deferred()
+        reactor.callLater(1, d.callback, r)
+        return DeferredResource(d)
+
+    def getChild(self, path, request):
+        if path == "tests":
+            return StatusResourceTestResults(self.status,
+                                             self.build.getTestResults())
+        if path == "stop":
+            return self.stop(request)
+        if path == "rebuild":
+            return self.rebuild(request)
+        if path.startswith("step-"):
+            stepname = path[len("step-"):]
+            steps = self.build.getSteps()
+            for s in steps:
+                if s.getName() == stepname:
+                    return StatusResourceBuildStep(self.status, s)
+            return NoResource("No such BuildStep '%s'" % stepname)
+        return NoResource("No such resource '%s'" % path)
+
+class StatusResourceBuilder(HtmlResource):
+    """
+    I display a builder's status.  I have several child pages containing
+    other interesting data, implemented by the other classes in this module.
+    """
+    def __init__(self, status, builder, control):
+        HtmlResource.__init__(self)
+        self.status = status
+        self.title = builder.getName() + " Builder"
+        self.builder = builder
+        self.control = control
+
+    def body(self, request):
+        b = self.builder
+        slaves = b.getSlaves()
+        connected_slaves = [s for s in slaves if s.isConnected()]
+
+        buildbotURL = self.status.getBuildbotURL()
+        projectName = self.status.getProjectName()
+        data = "<a href=\"%s\">%s</a>\n" % (buildbotURL, projectName)
+        data += make_row("Builder:", html.escape(b.getName()))
+        b1 = b.getBuild(-1)
+        if b1 is not None:
+            data += make_row("Current/last build:", str(b1.getNumber()))
+        data += "\n<br />BUILDSLAVES<br />\n"
+        data += "<ol>\n"
+        for slave in slaves:
+            data += "<li><b>%s</b>: " % html.escape(slave.getName())
+            if slave.isConnected():
+                data += "CONNECTED\n"
+                if slave.getAdmin():
+                    data += make_row("Admin:", html.escape(slave.getAdmin()))
+                if slave.getHost():
+                    data += "<span class='label'>Host info:</span>\n"
+                    data += html.PRE(slave.getHost())
+            else:
+                data += ("NOT CONNECTED\n")
+            data += "</li>\n"
+        data += "</ol>\n"
+
+        if self.control is not None and connected_slaves:
+            forceURL = urllib.quote(request.childLink("force"))
+            data += (
+                """
+                <form action='%(forceURL)s' class='command forcebuild'>
+                <p>To force a build, fill out the following fields and
+                push the 'Force Build' button</p>"""
+                + make_row("Your name:",
+                           "<input type='text' name='username' />")
+                + make_row("Reason for build:",
+                           "<input type='text' name='comments' />")
+                + make_row("Branch to build:",
+                           "<input type='text' name='branch' />")
+                + make_row("Revision to build:",
+                           "<input type='text' name='revision' />")
+                + """
+                <input type='submit' value='Force Build' />
+                </form>
+                """) % {"forceURL": forceURL}
+        elif self.control is not None:
+            data += """
+            <p>All buildslaves appear to be offline, so it's not possible
+            to force this build to execute at this time.</p>
+            """
+
+        if self.control is not None:
+            pingURL = urllib.quote(request.childLink("ping"))
+            data += """
+            <form action="%s" class='command pingbuilder'>
+            <p>To ping the buildslave(s), push the 'Ping' button</p>
+
+            <input type="submit" value="Ping Builder" />
+            </form>
+            """ % pingURL
+
+        return data
+
+    def force(self, request):
+        name = request.args.get("username", ["<unknown>"])[0]
+        reason = request.args.get("comments", ["<no reason specified>"])[0]
+        branch = request.args.get("branch", [""])[0]
+        revision = request.args.get("revision", [""])[0]
+
+        r = "The web-page 'force build' button was pressed by '%s': %s\n" \
+            % (name, reason)
+        log.msg("web forcebuild of builder '%s', branch='%s', revision='%s'"
+                % (self.builder.name, branch, revision))
+
+        if not self.control:
+            # TODO: tell the web user that their request was denied
+            log.msg("but builder control is disabled")
+            return Redirect("..")
+
+        # keep weird stuff out of the branch and revision strings. TODO:
+        # centralize this somewhere.
+        if not re.match(r'^[\w\.\-\/]*$', branch):
+            log.msg("bad branch '%s'" % branch)
+            return Redirect("..")
+        if not re.match(r'^[\w\.\-\/]*$', revision):
+            log.msg("bad revision '%s'" % revision)
+            return Redirect("..")
+        if branch == "":
+            branch = None
+        if revision == "":
+            revision = None
+
+        # TODO: if we can authenticate that a particular User pushed the
+        # button, use their name instead of None, so they'll be informed of
+        # the results.
+        s = SourceStamp(branch=branch, revision=revision)
+        req = BuildRequest(r, s, self.builder.getName())
+        try:
+            self.control.requestBuildSoon(req)
+        except interfaces.NoSlaveError:
+            # TODO: tell the web user that their request could not be
+            # honored
+            pass
+        return Redirect("..")
+
+    def ping(self, request):
+        log.msg("web ping of builder '%s'" % self.builder.name)
+        self.control.ping() # TODO: there ought to be an ISlaveControl
+        return Redirect("..")
+
+    def getChild(self, path, request):
+        if path == "force":
+            return self.force(request)
+        if path == "ping":
+            return self.ping(request)
+        if not path in ("events", "builds"):
+            return NoResource("Bad URL '%s'" % path)
+        num = request.postpath.pop(0)
+        request.prepath.append(num)
+        num = int(num)
+        if path == "events":
+            # TODO: is this dead code? .statusbag doesn't exist,right?
+            log.msg("getChild['path']: %s" % request.uri)
+            return NoResource("events are unavailable until code gets fixed")
+            filename = request.postpath.pop(0)
+            request.prepath.append(filename)
+            e = self.builder.statusbag.getEventNumbered(num)
+            if not e:
+                return NoResource("No such event '%d'" % num)
+            file = e.files.get(filename, None)
+            if file == None:
+                return NoResource("No such file '%s'" % filename)
+            if type(file) == type(""):
+                if file[:6] in ("<HTML>", "<html>"):
+                    return static.Data(file, "text/html")
+                return static.Data(file, "text/plain")
+            return file
+        if path == "builds":
+            build = self.builder.getBuild(num)
+            if build:
+                control = None
+                if self.control:
+                    control = self.control.getBuild(num)
+                return StatusResourceBuild(self.status, build,
+                                           self.control, control)
+            else:
+                return NoResource("No such build '%d'" % num)
+        return NoResource("really weird URL %s" % path)
+
+
Index: statusgrid/buildbot/status/web/changes.py
===================================================================
--- /dev/null	1970-01-01 00:00:00.000000000 +0000
+++ statusgrid/buildbot/status/web/changes.py	2007-07-25 09:46:53.972391429 -0500
@@ -0,0 +1,31 @@
+from buildbot.status.web.base import HtmlResource, StaticHTML
+
+class StatusResourceChanges(HtmlResource):
+    """
+    I display the list of change sources in the changemaster by default,
+    and my children display specific numbered changes.
+    """
+    def __init__(self, status, changemaster):
+        HtmlResource.__init__(self)
+        self.status = status
+        self.changemaster = changemaster
+    def body(self, request):
+        data = ""
+        data += "Change sources:\n"
+        sources = list(self.changemaster)
+        if sources:
+            data += "<ol>\n"
+            for s in sources:
+                data += "<li>%s</li>\n" % s.describe()
+            data += "</ol>\n"
+        else:
+            data += "none (push only)\n"
+        return data
+    def getChild(self, path, request):
+        num = int(path)
+        c = self.changemaster.getChangeNumbered(num)
+        if not c:
+            return NoResource("No change number '%d'" % num)
+        return StaticHTML(c.asHTML(), "Change #%d" % num)
+
+

