Re-merge slavealloc into tools
authorDustin J. Mitchell <dustin@mozilla.com>
Fri, 11 Feb 2011 18:48:43 -0600
changeset 1279 afd2a61118c33f295940305fa7b74256c76e5e40
parent 1267 626b6ee1b19ceba04a5fbcdeb6d4c4aa5a6670ab (current diff)
parent 1278 c3cbd48337bacbc233855f8ee69c17286e1107b3 (diff)
child 1280 8fe4dbc09d03fd50c43b00181f47b815f57647c9
push id894
push userdmitchell@mozilla.com
push dateFri, 18 Mar 2011 22:21:21 +0000
Re-merge slavealloc into tools
.hgignore
lib/python/slavealloc/data/engine.py
lib/python/slavealloc/scripts/pools.py
lib/python/slavealloc/scripts/silos.py
lib/python/slavealloc/www/js/deps/backbone.js
setup.py
--- a/.hgignore
+++ b/.hgignore
@@ -1,10 +1,11 @@
 \.pyc$
 moz-n810-v
 moz-n900-v
 flasher-
 RX-44
 RX-51
 build/
-slavealloc.db
-.*.swp
-twistd.pid
+slavealloc\.db
+\..*\.swp
+twistd\.pid
+.*\.egg-info
new file mode 100644
--- /dev/null
+++ b/lib/python/slavealloc/client.py
@@ -0,0 +1,84 @@
+import simplejson
+from zope.interface import implements
+from twisted.web import client, iweb, http_headers, _newclient
+from twisted.internet import protocol, defer
+
+# _newclient horks its Failure objects up by putting them in a list,
+# so this errback will unhork them
+def unhorkNewclientFailure(f):
+    f.trap(_newclient.RequestGenerationFailed)
+    exc = f.value
+    if len(exc.reasons) != 1:
+        return f
+    return exc.reasons[0]
+
+class JsonProducer(object):
+    "Produce a JSON-encoded request body"
+    implements(iweb.IBodyProducer)
+
+    def __init__(self, data):
+        self.body = simplejson.dumps(data)
+        self.length = len(self.body)
+
+    def startProducing(self, consumer):
+        consumer.write(self.body)
+        return defer.succeed(None)
+
+    def pauseProducing(self):
+        pass
+
+    def stopProducing(self):
+        pass
+
+class JsonProtocol(protocol.Protocol):
+    "Consume JSON data and fire a Deferred with the decoded result"
+    def __init__(self, d):
+        self.d = d
+        self.d.addCallback(self.toJson)
+        self.data_segments = []
+
+    def dataReceived(self, bytes):
+        self.data_segments.append(bytes)
+
+    def toJson(self, data):
+        try:
+            return simplejson.loads(data)
+        except:
+            print "RESPONSE BODY:\n" + data
+            raise
+
+    def connectionLost(self, reason):
+        if reason.check(client.ResponseDone):
+            data = ''.join(self.data_segments)
+            self.d.callback(data)
+        else:
+            self.d.errback(reason)
+
+class RestAgent(client.Agent):
+    """A wrapper around L{Agent} to make JSON-based REST requests simpler."""
+
+    def __init__(self, reactor, apiurl):
+        """Construct the REST agent, including the base URL of the API"""
+        client.Agent.__init__(self, reactor)
+        self.apiurl = apiurl.rstrip('/')
+
+    def restRequest(self, method, path, request_data):
+        """Issue a REST request, wrapping L{Agent.request}.  The C{pathparts}
+        argument is a tuple of path components, and C{request_data} is a Python
+        data structure that will be JSON-encoded.  The result will be decoded
+        and returned as the value of the Deferred."""
+        body_producer = JsonProducer(request_data)
+        headers = http_headers.Headers({
+            'User-Agent' : [ 'slavealloc command line' ],
+            'Content-Type' : [ 'application/json' ],
+        })
+        url = self.apiurl + '/' + path
+        d = self.request(method, url, headers, body_producer)
+        d.addErrback(unhorkNewclientFailure)
+        def json_to_python(response):
+            # TODO: error handling, check content type
+            d = defer.Deferred()
+            response.deliverBody(JsonProtocol(d))
+            return d
+        d.addCallback(json_to_python)
+        return d
--- a/lib/python/slavealloc/daemon/http/api.py
+++ b/lib/python/slavealloc/daemon/http/api.py
@@ -1,131 +1,253 @@
 import sqlalchemy as sa
 import simplejson
-from twisted.internet import defer
 from twisted.python import log
-from twisted.web import resource, server, error
+from twisted.web import resource
 from slavealloc import exceptions
 from slavealloc.data import queries, model
+from slavealloc.logic import allocate, buildbottac
+
+# point your browser to /api/ to see the full set of docs
+docs_tpl = """
+<h1>REST Interface</h1>
+<p>This is a JSON-based REST interface.  Rows are represented as JSON objects with
+keys corersponding to database columns.</p>
+
+<p>For each table, there is a similarly named sub-URL, e.g., <a
+href="/api/slaves">/api/slaves</a>.  A GET to that URL will return a full dump
+of the table.  As a convenience, the slaves and masters tables have all of
+their normalized fields denormalized, so in addition to a <tt>poolid</tt> you
+will get a <tt>pool</tt> string.</p>
+
+<p>A particular row can be fetched by appending the primary key in the next URL
+component, e.g., <a href="/api/pools/3">/api/pools/3</a>.  To fetch the row by
+name instead, use the name in the URL and add <tt>?byname=1</tt>, e.g., <a
+href="/api/masters/pm03?byname=1">/api/masters/pm03?byname=1</a>.  If a fetched
+row does not exist, the API will return <tt>{}</tt> with a 404 status code.</p>
+
+<p>You can PUT a JSON object with any subset of keys to either of these URLs to
+modify the row.  The denormalized columns in the masters and slaves cannot be
+PUT.</p>
+
+<p>The entire set of available tables is:
+    <ul>
+    %(tables)s
+    </ul>
+</p>
+
+<p>The <a href="/api/gettac/slavename">/api/gettac/slavename</a> URL is
+different: it invokes the allocator, but does not "commit" the allocation --
+meaning that the selected master is not recorded in the
+<tt>current_masterid</tt> table for the requested slave.  The result is JSON --
+either {success=False} or {success=True, tac='content of buildbot.tac'}.</p>
+
+"""
 
 # base classes
 
+class Instance(resource.Resource):
+    isLeaf = True
+    okResponse = simplejson.dumps(dict(success=True))
+    missingResponse = simplejson.dumps({})
+
+    # a tuple of columns that can be updated via PUT
+    update_keys = ()
+
+    # the table to access
+    table = None
+
+    # the column containing unique id's
+    id_column = None
+
+    # the column containing unique names
+    name_column = None
+
+    def __init__(self, id=None, name=None):
+        self.id = id
+        self.name = name
+
+    def whereClause(self, require_id=False):
+        "Calculate a where clause and an args dict for this instance"
+        if self.id is not None:
+            return (self.id_column == sa.bindparam('id'),
+                    dict(id=self.id))
+        else:
+            assert not require_id
+            return (self.name_column == sa.bindparam('name'),
+                    dict(name=self.name))
+
+    def render_GET(self, request):
+        wc, args = self.whereClause()
+        res = self.table.select(wc).execute(args)
+        row = res.fetchone()
+        res.close()
+
+        request.setHeader('content-type', 'application/json')
+
+        # handle nonexistent rows
+        if not row:
+            request.setResponseCode(404)
+            return self.missingResponse
+
+        return simplejson.dumps(dict(row))
+
+    def render_PUT(self, request):
+        json = simplejson.load(request.content)
+
+        sets = dict((k, json[k]) for k in self.update_keys if k in json)
+        if not sets:
+            return # nothing to do!
+
+        log.msg("%s: updating id %s from %r" %
+                (self.table.name, self.id, sets))
+
+        wc, args = self.whereClause(require_id=True)
+        args.update(sets)
+        self.table.update(wc).execute(args)
+
+        return self.okResponse
+
 class Collection(resource.Resource):
     addSlash = True
     isLeaf = False
-    def getChild(self, name, request):
-        if name:
-            return self.instance_class(name)
+
+    # the query to return for GET requests; if None, this will use the instance
+    # class's table and just select everything
+    query = None
+
+    def getChild(self, path_component, request):
+        if not path_component:
+            return
+
+        # if we're looking up by name, try that instead
+        if request.args.get('byname'):
+            return self.instance_class(name=path_component)
+        else:
+            return self.instance_class(id=path_component)
 
     def render_GET(self, request):
-        res = self.query.execute()
+        query = self.query
+        if query is None:
+            query = self.instance_class.table.select()
+        res = query.execute()
+
         request.setHeader('content-type', 'application/json')
         return simplejson.dumps([ dict(r.items()) for r in res.fetchall() ])
 
-class Instance(resource.Resource):
-    isLeaf = True
-    ok_response = simplejson.dumps(dict(success=True))
-
-    def __init__(self, id):
-        self.id = id
-
-    def render_PUT(self, request):
-        json = simplejson.load(request.content)
-        args = dict((k, json[k]) for k in self.update_keys)
-        log.msg("%s: updating id %s from %r" %
-                (self.__class__.__name__, self.id, args))
-        args['id'] = self.id
-        self.update_query.execute(args)
-        return self.ok_response
 
 # concrete classes
 
+# slaves
 class SlaveResource(Instance):
-    update_query = model.slaves.update(
-            model.slaves.c.slaveid == sa.bindparam('id'))
-    # TODO: lock some of these down so they are not editable via the
-    # interface
+    table = model.slaves
+    id_column = model.slaves.c.slaveid
+    name_column = model.slaves.c.name
     update_keys = ('distroid', 'dcid', 'bitsid', 'purposeid', 'trustid',
                    'envid', 'poolid', 'basedir', 'locked_masterid', 'enabled')
 
 class SlavesResource(Collection):
     instance_class = SlaveResource
     query = queries.denormalized_slaves
 
+# masters
 class MasterResource(Instance):
-    update_query = model.masters.update(
-            model.masters.c.masterid == sa.bindparam('id'))
-    # TODO: lock some of these down so they are not editable via the
-    # interface
-    update_keys = ('nickname', 'fqdn', 'pb_port', 'http_port',
-                   'poolid', 'dcid')
+    table = model.masters
+    id_column = model.masters.c.masterid
+    name_column = model.masters.c.nickname
+    update_keys = ('nickname', 'fqdn', 'pb_port', 'http_port', 'poolid',
+                   'dcid')
 
 class MastersResource(Collection):
     instance_class = MasterResource
     query = queries.denormalized_masters
 
-class DistroResource(Instance):
-    pass
+# simple
+def simple_table_resource(tbl, id_column_name, name_column_name='name'):
+    "make instance and collection classes for a simple id/name table"
+    class SimpleInstance(Instance):
+        table = tbl
+        id_column = table.c[id_column_name]
+        name_column = table.c[name_column_name]
+        update_keys = (name_column_name,)
 
-class DistrosResource(Collection):
-    instance_class = DistroResource
-    query = model.distros.select()
+    class SimpleCollection(Collection):
+        instance_class = SimpleInstance
 
-class DatacenterResource(Instance):
-    pass
+    return SimpleCollection
 
-class DatacentersResource(Collection):
-    instance_class = DatacenterResource
-    query = model.datacenters.select()
-
-class BitlengthResource(Instance):
-    pass
+DistrosResource = simple_table_resource(model.distros, 'distroid')
+DatacentersResource = simple_table_resource(model.datacenters, 'dcid')
+BitlengthsResource = simple_table_resource(model.bitlengths, 'bitsid')
+SpeedsResource = simple_table_resource(model.speeds, 'speedid')
+PurposesResource = simple_table_resource(model.purposes, 'purposeid')
+TrustlevelsResource = simple_table_resource(model.trustlevels, 'trustid')
+EnvironmentsResource = simple_table_resource(model.environments, 'envid')
+PoolsResource = simple_table_resource(model.pools, 'poolid')
 
-class BitlengthsResource(Collection):
-    instance_class = BitlengthResource
-    query = model.bitlengths.select()
+# allocator
 
-class PurposeResource(Instance):
-    pass
+class BuildbotTacResource(resource.Resource):
+    isLeaf = True
 
-class PurposesResource(Collection):
-    instance_class = PurposeResource
-    query = model.purposes.select()
+    def __init__(self, slave):
+        self.slave = slave
 
-class TrustlevelResource(Instance):
-    pass
+    def render_GET(self, request):
+        try:
+            alloc = allocate.Allocation(self.slave)
+        except exceptions.NoAllocationError:
+            alloc = None
 
-class TrustlevelsResource(Collection):
-    instance_class = TrustlevelResource
-    query = model.trustlevels.select()
+        request.setHeader('content-type', 'application/json')
 
-class EnvironmentResource(Instance):
-    pass
-
-class EnvironmentsResource(Collection):
-    instance_class = EnvironmentResource
-    query = model.environments.select()
+        if not alloc:
+            request.setResponseCode(404)
+            return simplejson.dumps(dict(success=False))
+        else:
+            return simplejson.dumps(dict(
+                success=True,
+                tac=buildbottac.make_buildbot_tac(alloc)))
 
-class PoolResource(Instance):
-    pass
+class BuildbotTacRootResource(resource.Resource):
+    "A JSON-style allocator that will get a TAC file, but not record it"
+    isLeaf = False
 
-class PoolsResource(Collection):
-    instance_class = PoolResource
-    query = model.pools.select()
+    def getChild(self, path_component, request):
+        return BuildbotTacResource(path_component)
+
+# root URI
 
 class ApiRoot(resource.Resource):
     addSlash = True
     isLeaf = False
 
     def __init__(self):
         resource.Resource.__init__(self)
-        self.putChild('slaves', SlavesResource())
-        self.putChild('masters', MastersResource())
-        self.putChild('distros', DistrosResource())
-        self.putChild('datacenters', DatacentersResource())
-        self.putChild('bitlengths', BitlengthsResource())
-        self.putChild('purposes', PurposesResource())
-        self.putChild('trustlevels', TrustlevelsResource())
-        self.putChild('environments', EnvironmentsResource())
-        self.putChild('pools', PoolsResource())
+        self.tables = []
+
+        self.addTable('slaves', SlavesResource)
+        self.addTable('masters', MastersResource)
+        self.addTable('distros', DistrosResource)
+        self.addTable('datacenters', DatacentersResource)
+        self.addTable('bitlengths', BitlengthsResource)
+        self.addTable('speeds', SpeedsResource)
+        self.addTable('purposes', PurposesResource)
+        self.addTable('trustlevels', TrustlevelsResource)
+        self.addTable('environments', EnvironmentsResource)
+        self.addTable('pools', PoolsResource)
+        self.addTable('gettac', BuildbotTacRootResource)
+
+    def addTable(self, name, coll_class):
+        self.tables.append(name)
+        self.putChild(name, coll_class())
+
+    def getChild(self, path_component, request):
+        # allow '/api/' to mean the same as '/api'
+        if not path_component:
+            return self
+
+    def render_GET(self, request):
+        tables_list = '\n'.join([ '<li>%s</li>' % t for t in self.tables ])
+        return docs_tpl % dict(tables=tables_list)
 
 def makeRootResource():
     return ApiRoot()
--- a/lib/python/slavealloc/daemon/http/ui.py
+++ b/lib/python/slavealloc/daemon/http/ui.py
@@ -1,12 +1,10 @@
 import os
 from twisted.web import static
-from twisted.web import resource
-from slavealloc.daemon.ui import slaves
 
 def makeRootResource():
     # root corresponds to slavealloc/www
     wwwdir = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..", "www"))
     root = static.File(wwwdir)
 
     # serve 'index.html' at the root
     root.putChild('', static.File(os.path.join(wwwdir, 'index.html')))
--- a/lib/python/slavealloc/daemon/service.py
+++ b/lib/python/slavealloc/daemon/service.py
@@ -1,11 +1,11 @@
 from twisted.python import log
 from twisted.internet import defer
-from twisted.application import service, strports
+from twisted.application import service
 import sqlalchemy
 from slavealloc.logic import allocate, buildbottac
 from slavealloc.data import model
 from slavealloc import exceptions
 
 class AllocatorService(service.Service):
     
     def __init__(self, db_url):
deleted file mode 100644
--- a/lib/python/slavealloc/data/engine.py
+++ /dev/null
@@ -1,14 +0,0 @@
-import sqlalchemy as sa
-
-class NoDBError(Exception):
-    pass
-
-def add_data_arguments(parser):
-    parser.add_argument('-D', '--db', dest='dburl',
-            default='sqlite:///slavealloc.db', # temporary
-            help='SQLAlchemy database URL')
-
-def create_engine(args):
-    if not args.dburl:
-        raise NoDBError
-    return sa.create_engine(args.dburl)
--- a/lib/python/slavealloc/data/model.py
+++ b/lib/python/slavealloc/data/model.py
@@ -20,16 +20,21 @@ datacenters = sa.Table('datacenters', me
     sa.Column('name', sa.Text, nullable=False),
 )
 
 bitlengths = sa.Table('bitlengths', metadata,
     sa.Column('bitsid', sa.Integer, primary_key=True),
     sa.Column('name', sa.Text, nullable=False),
 )
 
+speeds = sa.Table('speeds', metadata,
+    sa.Column('speedid', sa.Integer, primary_key=True),
+    sa.Column('name', sa.Text, nullable=False),
+)
+
 purposes = sa.Table('purposes', metadata,
     sa.Column('purposeid', sa.Integer, primary_key=True),
     sa.Column('name', sa.Text, nullable=False),
 )
 
 trustlevels = sa.Table('trustlevels', metadata,
     sa.Column('trustid', sa.Integer, primary_key=True),
     sa.Column('name', sa.Text, nullable=False),
@@ -60,23 +65,26 @@ slave_passwords = sa.Table('slave_passwo
 )
 
 # all slaves
 
 slaves = sa.Table('slaves', metadata,
     sa.Column('slaveid', sa.Integer, primary_key=True),
     sa.Column('name', sa.Text, nullable=False),
 
-    # silo
+    # silo (c.f. corresponding index below)
     sa.Column('distroid', sa.Integer, sa.ForeignKey('distros.distroid'), nullable=False),
     sa.Column('bitsid', sa.Integer, sa.ForeignKey('bitlengths.bitsid'), nullable=False),
+    sa.Column('speedid', sa.Integer, sa.ForeignKey('speeds.speedid'), nullable=False, default=0),
     sa.Column('purposeid', sa.Integer, sa.ForeignKey('purposes.purposeid'), nullable=False),
     sa.Column('dcid', sa.Integer, sa.ForeignKey('datacenters.dcid'), nullable=False),
     sa.Column('trustid', sa.Integer, sa.ForeignKey('trustlevels.trustid'), nullable=False),
     sa.Column('envid', sa.Integer, sa.ForeignKey('environments.envid'), nullable=False),
+
+    # pool
     sa.Column('poolid', sa.Integer, sa.ForeignKey('pools.poolid'), nullable=False),
 
     # config
     sa.Column('basedir', sa.Text, nullable=False),
     sa.Column('locked_masterid', sa.Integer, sa.ForeignKey('masters.masterid')),
     sa.Column('enabled', sa.Boolean, nullable=False, default=True),
 
     # state
@@ -89,8 +97,23 @@ masters = sa.Table('masters', metadata,
     sa.Column('masterid', sa.Integer, primary_key=True),
     sa.Column('nickname', sa.Text, nullable=False),
     sa.Column('fqdn', sa.Text, nullable=False),
     sa.Column('http_port', sa.Integer, nullable=False),
     sa.Column('pb_port', sa.Integer, nullable=False),
     sa.Column('dcid', sa.Integer, sa.ForeignKey('datacenters.dcid'), nullable=False),
     sa.Column('poolid', sa.Integer, sa.ForeignKey('pools.poolid'), nullable=False),
 )
+
+# indices
+
+sa.Index('slave_silo',
+        slaves.c.distroid,
+        slaves.c.bitsid,
+        slaves.c.speedid,
+        slaves.c.purposeid,
+        slaves.c.dcid,
+        slaves.c.trustid,
+        slaves.c.envid,
+        )
+
+sa.Index('slave_poolid',
+        slaves.c.poolid)
--- a/lib/python/slavealloc/data/queries.py
+++ b/lib/python/slavealloc/data/queries.py
@@ -3,26 +3,28 @@ from slavealloc.data import model
 
 def denormalized_slaves():
     locked_masters = model.masters.alias('locked_masters')
     current_masters = model.masters.alias('current_masters')
     q = sa.select([
             model.slaves,
             model.distros.c.name.label('distro'),
             model.bitlengths.c.name.label('bitlength'),
+            model.speeds.c.name.label('speed'),
             model.purposes.c.name.label('purpose'),
             model.datacenters.c.name.label('datacenter'),
             model.trustlevels.c.name.label('trustlevel'),
             model.environments.c.name.label('environment'),
             model.pools.c.name.label('pool'),
             locked_masters.c.nickname.label('locked_master'),
             current_masters.c.nickname.label('current_master'),
         ], whereclause=(
             (model.distros.c.distroid == model.slaves.c.distroid) &
             (model.bitlengths.c.bitsid == model.slaves.c.bitsid) &
+            (model.speeds.c.speedid == model.slaves.c.speedid) &
             (model.purposes.c.purposeid == model.slaves.c.purposeid) &
             (model.datacenters.c.dcid == model.slaves.c.dcid) &
             (model.trustlevels.c.trustid == model.slaves.c.trustid) &
             (model.environments.c.envid == model.slaves.c.envid) &
             (model.pools.c.poolid == model.slaves.c.poolid)
         ), from_obj=[
             # note that the other tables will be joined automatically
             model.slaves.outerjoin(
--- a/lib/python/slavealloc/exceptions.py
+++ b/lib/python/slavealloc/exceptions.py
@@ -1,2 +1,5 @@
 class NoAllocationError(Exception):
     "base class for errors that should result in a 404"
+
+class CmdlineError(Exception):
+    "A message for the command-line user"
--- a/lib/python/slavealloc/logic/allocate.py
+++ b/lib/python/slavealloc/logic/allocate.py
@@ -27,18 +27,18 @@ class Allocation(object):
         q = model.slaves.select(whereclause=(model.slaves.c.name == slavename))
         slave_row = q.execute().fetchone()
         if not slave_row:
             raise exceptions.NoAllocationError
         self.slaveid = slave_row.slaveid
         self.enabled = slave_row.enabled
         self.slave_basedir = slave_row.basedir
 
-        # bail out early if this slave is enabled
-        if self.enabled:
+        # bail out early if this slave is not enabled
+        if not self.enabled:
             return
 
         # slave password
         q = queries.slave_password
         self.slave_password = q.execute(slaveid=self.slaveid).scalar()
 
         # if this slave has a locked_masterid, just get that row; otherwise, run
         # the self algorithm
--- a/lib/python/slavealloc/logic/buildbottac.py
+++ b/lib/python/slavealloc/logic/buildbottac.py
@@ -1,29 +1,27 @@
 import time
 import socket
 
-from slavealloc.data import model
-
 tac_template = """\
 # AUTOMATICALLY GENERATED - DO NOT MODIFY
 # generated: %(gendate)s on %(genhost)s
 from twisted.application import service
 from buildbot.slave.bot import BuildSlave
 from twisted.python.logfile import LogFile
 from twisted.python.log import ILogObserver, FileLogObserver
 
 maxdelay = 300
 buildmaster_host = %(buildmaster_host)r
 passwd = %(passwd)r
 maxRotatedFiles = None
 basedir = %(basedir)r
 umask = 002
 slavename = %(slavename)r
-usepty = 1
+usepty = False
 rotateLength = 1000000
 port = %(port)r
 keepalive = None
 
 application = service.Application('buildslave')
 logfile = LogFile.fromFullPath("twistd.log", rotateLength=rotateLength,
                              maxRotatedFiles=maxRotatedFiles)
 application.setComponent(ILogObserver, FileLogObserver(logfile).emit)
new file mode 100644
--- /dev/null
+++ b/lib/python/slavealloc/scripts/dbdump.py
@@ -0,0 +1,45 @@
+import cPickle
+import sqlalchemy as sa
+import sys
+from slavealloc.data import model
+
+def setup_argparse(subparsers):
+    subparser = subparsers.add_parser('dbdump', help="""dump the slavealloc
+            database to a file suitable for use with dbinit.  Note that this
+            does not use the API, but reads from the database directly.""")
+
+    subparser.add_argument('-D', '--db', dest='dburl',
+            default='sqlite:///slavealloc.db',
+            help="""SQLAlchemy database URL; defaults to slavealloc.db in the
+            current dir""")
+
+    subparser.add_argument('dumpfile', nargs='?',
+            help="""filename to dump to; default is standard output""")
+
+    return subparser
+
+def process_args(subparser, args):
+    pass
+
+# DATA FORMAT
+#
+# This format is meant to support debugging dumps of the database, and not for
+# long-term storage, so there is no provision for versioning -- as the db
+# schema changes, the file format wil change.
+#
+# The file contains a single pickled dictionary with keys for each table.  Each
+# key points to a list of rows in a format suitable for use with insert().
+
+def main(args):
+    eng = sa.create_engine(args.dburl)
+    model.metadata.bind = eng
+
+    def dump_tbl(table):
+        res = table.select().execute()
+        return [ dict(row) for row in res ]
+    rv = dict( (tname, dump_tbl(tbl)) for (tname, tbl) in model.metadata.tables.items() )
+
+    output = sys.stdout
+    if args.dumpfile:
+        output = open(args.dumpfile, "w")
+    cPickle.dump(rv, output)
--- a/lib/python/slavealloc/scripts/dbinit.py
+++ b/lib/python/slavealloc/scripts/dbinit.py
@@ -1,98 +1,41 @@
-import csv
+import cPickle
+import sqlalchemy as sa
 from slavealloc.data import model
 
 def setup_argparse(subparsers):
-    subparser = subparsers.add_parser('dbinit', help='initialize a fresh database')
 
-    subparser.add_argument('--slave-data', dest='slave_data',
-            help="""csv of slave data to import (columns: name, distro,
-            bitlength, purpose, size, datacenter, trustlevel, and
-            environment)""")
+    subparser = subparsers.add_parser('dbinit', help="""Initialize a fresh
+            database, optionally from a dump file.  Note that this does not use
+            the REST API, but writes to the database directly.""")
 
-    subparser.add_argument('--master-data', dest='master_data',
-            help="""csv of master data to import (columns: nickname, fqdn,
-            http_port, pb_port, and pool)""")
+    subparser.add_argument('-D', '--db', dest='dburl',
+            default='sqlite:///slavealloc.db',
+            help="""SQLAlchemy database URL; defaults to slavealloc.db in the
+            current dir""")
 
-    subparser.add_argument('--password-data', dest='password_data',
-            help="""csv of password data to import (columns: pool, distro,
-            password); a distro of '*' is converted to NULL""")
+    subparser.add_argument('dumpfile', nargs='?',
+            help="""dump file as generated by 'slavealloc dbdump'; if not
+            specified, then an empty database will be initialized.""")
 
     return subparser
 
 def process_args(subparser, args):
-    if not args.master_data or not args.slave_data or not args.password_data:
-        subparser.error("--master-data, --slave-data, and --password-data are all required")
+    pass
 
 def main(args):
+    eng = sa.create_engine(args.dburl)
+    model.metadata.bind = eng
+
     model.metadata.drop_all()
     model.metadata.create_all()
 
-    rdr = csv.DictReader(open(args.slave_data))
-    slaves = list(rdr)
-    # ignore mobile slaves for now
-    slaves = [ s for s in slaves if s['size'] == 'desktop' ]
-
-    rdr = csv.DictReader(open(args.master_data))
-    masters = list(rdr)
-
-    rdr = csv.DictReader(open(args.password_data))
-    passwords = list(rdr)
-
-    def normalize(table, idcolumn, values):
-        values = list(enumerate(list(set(values)))) # remove duplicates, add ids
-        table.insert().execute([ { 'name' : n, idcolumn : i }
-                                 for i, n in values ])
-        return dict((n,i) for i, n in values)
-
-    distros = normalize(model.distros, 'distroid',
-            [ r['distro'] for r in slaves ])
-    bitlengths = normalize(model.bitlengths, 'bitsid',
-            [ r['bitlength'] for r in slaves ])
-    purposes = normalize(model.purposes, 'purposeid',
-            [ r['purpose'] for r in slaves ])
-    datacenters = normalize(model.datacenters, 'dcid',
-            [ r['datacenter'] for r in slaves ] +
-            [ r['datacenter'] for r in masters ])
-    trustlevels = normalize(model.trustlevels, 'trustid',
-            [ r['trustlevel'] for r in slaves ])
-    environments = normalize(model.environments, 'envid',
-            [ r['environment'] for r in slaves ])
-    pools = normalize(model.pools, 'poolid',
-            [ r['pool'] for r in passwords ] +
-            [ r['pool'] for r in slaves ] +
-            [ r['pool'] for r in masters ])
+    if args.dumpfile:
+        load_data(args)
 
-    model.masters.insert().execute([
-        dict(nickname=row['nickname'],
-             fqdn=row['fqdn'],
-             http_port=int(row['http_port']),
-             pb_port=int(row['pb_port']),
-             dcid=datacenters[row['datacenter']],
-             poolid=pools[row['pool']])
-        for row in masters ])
-
-    model.slaves.insert().execute([
-        dict(name=row['name'],
-             distroid=distros[row['distro']],
-             bitsid=bitlengths[row['bitlength']],
-             purposeid=purposes[row['purpose']],
-             dcid=datacenters[row['datacenter']],
-             trustid=trustlevels[row['trustlevel']],
-             envid=environments[row['environment']],
-             poolid=pools[row['pool']],
-             basedir=row['basedir'],
-             enabled=True,
-             current_masterid=None,
-             locked_masterid=None)
-        for row in slaves ])
-
-    # convert a distro of '*' to NULL
-    distros_or_null = distros.copy()
-    distros_or_null['*'] = None
-
-    model.slave_passwords.insert().execute([
-        dict(poolid=pools[row['pool']],
-             distroid=distros_or_null[row['distro']],
-             password=row['password'])
-        for row in passwords ])
-
+def load_data(args):
+    # see dbdump.py for the data format here
+    dumpdict = cPickle.load(open(args.dumpfile))
+    for tbl in model.metadata.sorted_tables:
+        tname = tbl.name
+        tbl = model.metadata.tables[tname]
+        tbl.insert().execute(dumpdict[tname])
--- a/lib/python/slavealloc/scripts/disable.py
+++ b/lib/python/slavealloc/scripts/disable.py
@@ -1,29 +1,49 @@
 import sys
-from slavealloc import exceptions
-from slavealloc.data import model
+from twisted.internet import defer, reactor
+from slavealloc import client, exceptions
 
 def setup_argparse(subparsers):
     subparser = subparsers.add_parser('disable', help='disable a slave, preventing it from starting')
     subparser.add_argument('slave',
             help="slave to disable (or enable with --enable)")
     subparser.add_argument('-e', '--enable', dest='enable',
             default=False, action='store_true',
             help="enable a disabled slave")
     return subparser
 
 def process_args(subparser, args):
     if not args.slave:
         subparser.error("slave name is required")
     if '.' in ''.join(args.slave):
         subparser.error("slave name must not contain '.'; give the unqualified hostname")
 
+def bool_to_word(bool):
+    return {True : 'enabled', False : 'disabled'}[bool]
+
+@defer.inlineCallbacks
 def main(args):
-    q = model.slaves.update(values=dict(enabled=args.enable),
-            whereclause=(model.slaves.c.name == args.slave))
-    if not q.execute().rowcount:
-        print >>sys.stderr, "No slave found named '%s'." % args.slave
-        sys.exit(1)
+    agent = client.RestAgent(reactor, args.apiurl)
+
+    # first get the slaveid
+    path = 'slaves/%s?byname=1' % args.slave
+    slave = yield agent.restRequest('GET', path, {})
+    if not slave:
+        raise exceptions.CmdlineError(
+                "No slave found named '%s'." % args.slave)
+    assert slave['name'] == args.slave
+    slaveid = slave['slaveid']
+
+    # then set its state, if not already set
+    if ((args.enable and not slave['enabled']) or 
+        (not args.enable and slave['enabled'])):
+        set_result = yield agent.restRequest('PUT',
+                    'slaves/%d' % slaveid,
+                    { 'enabled' : args.enable })
+        success = set_result.get('success')
+        if not success:
+            raise exceptions.CmdlineError("Operation failed on server.")
+        print >>sys.stderr, "%s %s" % (
+                args.slave, bool_to_word(args.enable))
     else:
-        print >>sys.stderr, "%s '%s'" % (
-                {True : 'Enabled', False : 'Disabled'}[args.enable],
-                args.slave)
+        print >>sys.stderr, "%s is already %s" % (
+                args.slave, bool_to_word(args.enable))
--- a/lib/python/slavealloc/scripts/gettac.py
+++ b/lib/python/slavealloc/scripts/gettac.py
@@ -1,42 +1,30 @@
-import sys
-from slavealloc import exceptions
-from slavealloc.logic import allocate, buildbottac
+from twisted.internet import defer, reactor
+from slavealloc import client, exceptions
 
 def setup_argparse(subparsers):
     subparser = subparsers.add_parser('gettac', help='get a tac file for a slave')
     subparser.add_argument('slave', nargs='*',
             help="slave hostnames to allocate for (no domain)")
     subparser.add_argument('-n', '--noop', dest='noop',
             default=False, action='store_true',
             help="don't actually allocate")
-    subparser.add_argument('-q', '--quiet', dest='quiet',
-            default=False, action='store_true',
-            help="don't actually output the tac file; just the allocation made")
     return subparser
 
 def process_args(subparser, args):
     if not args.slave:
         subparser.error("at least one slave name is required")
     if '.' in ''.join(args.slave):
         subparser.error("slave name must not contain '.'; give the unqualified hostname")
 
+@defer.inlineCallbacks
 def main(args):
-    for slave in args.slave:
-        try:
-            allocation = allocate.Allocation(slave)
-        except exceptions.NoAllocationError:
-            print >>sys.stderr, "No buildbot.tac available (404 from slave allocator)"
-            sys.exit(1)
+    agent = client.RestAgent(reactor, args.apiurl)
 
-        if not args.quiet:
-            print buildbottac.make_buildbot_tac(allocation)
+    for slave in args.slave:
+        path = 'gettac/%s' % slave
+        res = yield agent.restRequest('GET', path, {})
 
-        if not args.noop:
-            allocation.commit()
-        if allocation.enabled:
-            print >>sys.stderr, "Allocated '%s' to '%s' (%s:%s)" % (slave,
-                allocation.master_nickname,
-                allocation.master_fqdn,
-                allocation.master_pb_port)
-        else:
-            print >>sys.stderr, "Slave '%s' is disabled; no allocation made" % slave
+        if not res.get('success'):
+            raise exceptions.CmdlineError("could not generate TAC for %s" % slave)
+
+        print res['tac']
--- a/lib/python/slavealloc/scripts/lock.py
+++ b/lib/python/slavealloc/scripts/lock.py
@@ -1,11 +1,11 @@
 import sys
-from slavealloc import exceptions
-from slavealloc.data import model
+from twisted.internet import defer, reactor
+from slavealloc import client, exceptions
 
 def setup_argparse(subparsers):
     subparser = subparsers.add_parser('lock', help='lock a slave to a particular master')
     subparser.add_argument('slave',
             help="slave to (un)lock")
     subparser.add_argument('master', nargs='?',
             help="master to lock it to")
     subparser.add_argument('-u', '--unlock', dest='unlock',
@@ -16,36 +16,51 @@ def setup_argparse(subparsers):
 def process_args(subparser, args):
     if not args.slave:
         subparser.error("slave name is required")
     if '.' in ''.join(args.slave):
         subparser.error("slave name must not contain '.'; give the unqualified hostname")
     if not args.master and not args.unlock:
         subparser.error("master name is required to lock")
 
+@defer.inlineCallbacks
 def main(args):
+    agent = client.RestAgent(reactor, args.apiurl)
+
+    # first get the slaveid
+    path = 'slaves/%s?byname=1' % args.slave
+    slave = yield agent.restRequest('GET', path, {})
+    if not slave:
+        raise exceptions.CmdlineError(
+                "No slave found named '%s'." % args.slave)
+    assert slave['name'] == args.slave
+    slaveid = slave['slaveid']
+
     if args.unlock:
-        q = model.slaves.update(values=dict(locked_masterid=None),
-                whereclause=(model.slaves.c.name == args.slave))
-        if not q.execute().rowcount:
-            print >>sys.stderr, "No slave found named '%s'." % args.slave
-            sys.exit(1)
-        else:
-            print >>sys.stderr, "Slave '%s' unlocked" % args.slave
+        if not slave['locked_masterid']:
+            raise exceptions.CmdlineError("Slave is not locked")
+
+        set_result = yield agent.restRequest('PUT',
+                    'slaves/%d' % slaveid,
+                    { 'locked_masterid' : None })
+        success = set_result.get('success')
+        if not success:
+            raise exceptions.CmdlineError("Operation failed on server.")
+
+        print >>sys.stderr, "Slave '%s' unlocked" % args.slave
     else:
-        # find the masterid first
-        q = model.masters.select(
-                whereclause=(model.masters.c.nickname == args.master))
-        r = q.execute()
-        master_row = r.fetchone()
-        if not master_row:
-            print >>sys.stderr, "no master found with nickname '%s'." % args.master
-            sys.exit(1)
-        masterid = master_row.masterid
+        # get the masterid
+        path = 'masters/%s?byname=1' % args.master
+        master = yield agent.restRequest('GET', path, {})
+        if not master:
+            raise exceptions.CmdlineError(
+                    "No master found named '%s'." % args.master)
+        masterid = master['masterid']
 
-        q = model.slaves.update(values=dict(locked_masterid=masterid),
-                whereclause=(model.slaves.c.name == args.slave))
-        if not q.execute().rowcount:
-            print >>sys.stderr, "No slave found named '%s'." % args.slave
-            sys.exit(1)
-        else:
-            print >>sys.stderr, "Locked '%s' to '%s' (%s:%s)" % (args.slave,
-                master_row.nickname, master_row.fqdn, master_row.pb_port)
+        set_result = yield agent.restRequest('PUT',
+                    'slaves/%d' % slaveid,
+                    { 'locked_masterid' : masterid })
+        success = set_result.get('success')
+        if not success:
+            raise exceptions.CmdlineError("Operation failed on server.")
+
+        print >>sys.stderr, "Locked '%s' to '%s' (%s:%s)" % (args.slave,
+            master['nickname'], master['fqdn'], master['pb_port'])
--- a/lib/python/slavealloc/scripts/main.py
+++ b/lib/python/slavealloc/scripts/main.py
@@ -1,38 +1,64 @@
 import sys
 import argparse
-import textwrap
-
-from slavealloc.data import engine, model
+from twisted.internet import defer, reactor
+from twisted.python import log
+from slavealloc import exceptions
 
 # subcommands
-from slavealloc.scripts import silos, dbinit, pools, gettac, lock, disable
-subcommands = [ silos, dbinit, pools, gettac, lock, disable ]
+from slavealloc.scripts import dbinit, gettac, lock, disable, dbdump
+subcommands = [ dbinit, gettac, lock, disable, dbdump ]
 
 def parse_options():
     parser = argparse.ArgumentParser(description="Runs slavealloc subcommands")
     parser.set_defaults(_module=None)
 
-    engine.add_data_arguments(parser)
+    parser.add_argument('-A', '--api', dest='apiurl',
+            default='http://slavealloc.build.mozilla.org/api',
+            help="""URL of the REST API to use for most subcommands""")
 
     subparsers = parser.add_subparsers(title='subcommands')
 
     for module in subcommands:
         subparser = module.setup_argparse(subparsers)
         subparser.set_defaults(module=module, subparser=subparser)
 
+    # parse the args
     args = parser.parse_args()
 
+    # make sure we got a subcommand
     if not args.module:
         parser.error("No subcommand specified")
 
-    # set up the SQLAlchemy binding of metadata to engine
-    eng = engine.create_engine(args)
-    model.metadata.bind = eng
-
+    # let it process its own args
     args.module.process_args(args.subparser, args)
 
+    # and return the results
     return args.module.main, args
 
 def main():
+    errors = []
+
     func, args = parse_options()
-    func(args)
+
+    def do_command():
+        d = defer.maybeDeferred(func, args)
+
+        # catch command-line errors and don't show the traceback
+        def cmdline_error(f):
+            f.trap(exceptions.CmdlineError)
+            errors.append(str(f.value))
+        d.addErrback(cmdline_error)
+
+        # but catch everything else..
+        d.addErrback(log.err, "while executing subcommand")
+
+        # before unconditionally stopping the reactor
+        d.addBoth(lambda _ : reactor.stop())
+    reactor.callWhenRunning(do_command)
+    reactor.run()
+
+    # handle any errors after the reactor is done
+    if errors:
+        for error in errors:
+            print >>sys.stderr, error
+        sys.exit(1)
deleted file mode 100644
--- a/lib/python/slavealloc/scripts/pools.py
+++ /dev/null
@@ -1,58 +0,0 @@
-import sys
-import csv
-import collections
-import sqlalchemy as sa
-from slavealloc.data import model, queries
-
-pools_keys = 'pool nmasters nslaves masters'.split()
-
-def setup_argparse(subparsers):
-    subparser = subparsers.add_parser('pools', help='show master pools')
-    subparser.add_argument('-c', '--columns', dest='columns',
-            help='comma-separated list of columns to show',
-            default=','.join(pools_keys))
-    return subparser
-
-def process_args(subparser, args):
-    args.columns = [ c.strip() for c in args.columns.split(',') ]
-
-    unrecognized_columns = set(args.columns) - set(pools_keys)
-    if unrecognized_columns:
-        subparser.error("unrecognized columns: %s" % (" ".join(list(unrecognized_columns)),))
-
-def main(args):
-    pools = collections.defaultdict(lambda:
-            dict(masters=[], nmasters=0, nslaves=0))
-
-    col_titles = args.columns
-
-    if 'nmasters' in col_titles or 'masters' in col_titles:
-        q = queries.denormalized_masters
-        for master in q.execute():
-            p = pools[master.pool]
-            p['nmasters'] += 1
-            p['masters'].append(master.nickname)
-
-    if 'nslaves' in col_titles:
-        q = queries.denormalized_slaves
-        for slave in q.execute():
-            p = pools[slave.pool]
-            p['nslaves'] += 1
-
-    datagrid = [col_titles]
-    for name, pooldict in sorted(pools.items()):
-        row = []
-        for col in col_titles:
-            if col == 'pool':
-                row.append(name)
-            elif col in ('nmasters', 'nslaves'):
-                row.append(str(pooldict[col]))
-            elif col == 'masters':
-                row.append(' '.join(pooldict[col]))
-        datagrid.append(row)
-
-    fmtmsg = " ".join([
-        "%%-%ds" % max(len(r[i]) for r in datagrid)
-        for i in range(len(col_titles)) ])
-    for row in datagrid:
-        print fmtmsg % tuple(row)
deleted file mode 100644
--- a/lib/python/slavealloc/scripts/silos.py
+++ /dev/null
@@ -1,79 +0,0 @@
-import sys
-import csv
-import collections
-import sqlalchemy as sa
-from slavealloc.data import model, queries
-
-silo_keys = 'environment purpose distro bitlength datacenter trustlevel'.split()
-
-def setup_argparse(subparsers):
-    subparser = subparsers.add_parser('silos', help='show slave silos with counts')
-    subparser.add_argument('-c', '--columns', dest='columns',
-            help='comma-separated list of columns to show',
-            default=','.join(silo_keys))
-    subparser.add_argument('-s', '--sort', dest='sort',
-            help='comma-separated list of columns to sort on; defaluts to COLUMNS. ' +
-                 'note that \'count\' is a valid sort key, too',
-            default=None)
-    subparser.add_argument('--csv', dest='csv', default=False, action='store_true',
-            help='output in CSV format')
-    return subparser
-
-def process_args(subparser, args):
-    args.columns = [ c.strip() for c in args.columns.split(',') ]
-
-    unrecognized_columns = set(args.columns) - set(silo_keys)
-    if unrecognized_columns:
-        subparser.error("unrecognized columns: %s" % (" ".join(list(unrecognized_columns)),))
-
-    if args.sort is None:
-        args.sort = args.columns[:]
-    else:
-        args.sort = [ c.strip() for c in args.sort.split(',') ]
-
-    unrecognized_keys = set(args.sort) - set(args.columns) - set(['count'])
-    if unrecognized_keys:
-        subparser.error("sort keys not available: %s" % (" ".join(list(unrecognized_keys)),))
-
-def main(args):
-    silos = collections.defaultdict(lambda : 0)
-
-    # get the denormalized slave data
-    q = queries.denormalized_slaves
-    slaves = [ r for r in q.execute() ]
-
-    # count the slaves into silos, using a key composed of the desired
-    # columns.
-    for slave in slaves:
-        k = tuple(slave[c] for c in args.columns)
-        silos[k] += 1
-
-    # add the count to the end of 'columns' and 'silos'
-    col_titles = args.columns + ['count']
-    rows = [ k + (count,) for k, count in silos.iteritems() ]
-
-    # sort the rows
-    sortidxes = [ col_titles.index(col) for col in args.sort ]
-    def keyfunc(row):
-        return [ row[i] for i in sortidxes ]
-    rows.sort(key=keyfunc)
-
-    # shorten up some long names
-    for long, short in [ ('datacenter', 'dc'), ('bitlength', 'bits') ]:
-        if long in col_titles:
-            col_titles[args.columns.index(long)] = short
-
-    if args.csv:
-        writer = csv.writer(sys.stdout)
-        writer.writerow(col_titles)
-        for row in rows:
-            writer.writerow(row)
-    else:
-        # calculate an appropriate format for the observed lengths
-        lengths = [ max([ len(col_titles[i]) ] + [ len(str(row[i])) for row in rows ])
-                    for i in xrange(len(col_titles)) ]
-        fmtmsg = " ".join(["%%-%ds" % l for l in lengths])
-
-        print fmtmsg % tuple(col_titles)
-        for row in rows:
-            print fmtmsg % row
--- a/lib/python/slavealloc/www/css/slavealloc.css
+++ b/lib/python/slavealloc/www/css/slavealloc.css
@@ -2,35 +2,44 @@
 @import "/ui/css/demo_table_jui.css";
 
 /* overall layout */
 
 body {
     margin: 0;
 }
 
+div#error {
+    position: fixed;
+    right: 1em;
+    top: 1em;
+    width: 25%;
+    background: red;
+    border: thin white solid;
+    color: white;
+    text-align: center;
+    display: none;
+}
+
 div#viewtabs {
     border-bottom: solid 1px #DEDEDE;
     text-align: center;
     background: black;
     padding: 4px;
 }
 
 div#viewcontent {
     margin: 8px;
 }
 
 div.loading {
     text-align: center;
 }
 
-/* dashboard */
-
 div.dashboard {
-    text-align: center;
 }
 
 /* table views */
 
 td.edit-row {
     padding: 0px !important;
 }
 
new file mode 100644
--- /dev/null
+++ b/lib/python/slavealloc/www/dashboard.html
@@ -0,0 +1,32 @@
+<!-- HTML snippet loaded in the dashboard view -->
+<center>Select a view from the menu above.</center>
+<h1>Definitions</h1>
+<dl>
+    <dt>Silo</dt> <dd>A set of interchangeable slaves.  For example, a Darwin9
+    slave isn't interchangeable with a Linux64 slave.  Silos are based on
+    relatively fixed attributes of the slaves' hardware and sofware
+    configuration -- attributes that cannot be changed without manual
+    intervention.</dd>
+
+    <dt>Pool</dt> <dd>a set of masters and slaves, where any of the slaves can
+    be allocated to any of the masters.  Pools are the primary configuration
+    knob for the allocator.</dd>
+
+    <dt>Trustlevel<dt> <dd>The trust level specifies the kind of code that can
+    run on this slave.  It currently maps to the various levels of
+    version-control commit priviledges -- tryuser and core.</dd>
+</dl>
+<h1>Allocation Process</h1>
+<p>Within each pool, the allocator attempts to balance the slaves in each silo across the available masters.  The balancing algorithm proceeds as follows:
+<ul>
+    <li> determine the pool for the slave</li>
+    <li> determine the silo for the slave </li>
+    <li> for each active master in the pool, count the number of attached slaves from the silo </li>
+    <li> attach the new slave to the master with the lowest count, sorting by master name where counts are equal </li>
+</ul>
+</p>
+
+<p>Note that the balancing applies to the intersection of a silo and a pool.
+The more slaves in each such intersection, the better the allocator can
+perform.  As such, we should make all efforts to minimize the number of
+distinct pools and silos into which we divide our slaves.</p>
--- a/lib/python/slavealloc/www/index.html
+++ b/lib/python/slavealloc/www/index.html
@@ -1,17 +1,16 @@
 <html>
     <head>
         <title>Slave Allocator</title>
         <link rel="stylesheet" type="text/css" href="/ui/css/slavealloc.css" />
         <script src="/ui/js/deps/load-min.js" type="text/javascript"></script>
         <script type="text/javascript"> load("/ui/js/slavealloc.js") </script>
     </head>
     <body>
-        <form>
-            <div id="viewtabs"></div>
-            <div id="viewcontent">
-                <div class="loading"> Loading... </div>
-            </div>
-        </form>
+        <div id="error">onoes</div>
+        <div id="viewtabs"></div>
+        <div id="viewcontent">
+            <div class="loading"> Loading... </div>
+        </div>
     </body>
 </html>
 
deleted file mode 100644
--- a/lib/python/slavealloc/www/js/deps/backbone.js
+++ /dev/null
@@ -1,1066 +0,0 @@
-//     Backbone.js 0.3.3
-//     (c) 2010 Jeremy Ashkenas, DocumentCloud Inc.
-//     Backbone may be freely distributed under the MIT license.
-//     For all details and documentation:
-//     http://documentcloud.github.com/backbone
-
-(function(){
-
-  // Initial Setup
-  // -------------
-
-  // The top-level namespace. All public Backbone classes and modules will
-  // be attached to this. Exported for both CommonJS and the browser.
-  var Backbone;
-  if (typeof exports !== 'undefined') {
-    Backbone = exports;
-  } else {
-    Backbone = this.Backbone = {};
-  }
-
-  // Current version of the library. Keep in sync with `package.json`.
-  Backbone.VERSION = '0.3.3';
-
-  // Require Underscore, if we're on the server, and it's not already present.
-  var _ = this._;
-  if (!_ && (typeof require !== 'undefined')) _ = require('underscore')._;
-
-  // For Backbone's purposes, either jQuery or Zepto owns the `$` variable.
-  var $ = this.jQuery || this.Zepto;
-
-  // Turn on `emulateHTTP` to use support legacy HTTP servers. Setting this option will
-  // fake `"PUT"` and `"DELETE"` requests via the `_method` parameter and set a
-  // `X-Http-Method-Override` header.
-  Backbone.emulateHTTP = false;
-
-  // Turn on `emulateJSON` to support legacy servers that can't deal with direct
-  // `application/json` requests ... will encode the body as
-  // `application/x-www-form-urlencoded` instead and will send the model in a
-  // form param named `model`.
-  Backbone.emulateJSON = false;
-
-  // Backbone.Events
-  // -----------------
-
-  // A module that can be mixed in to *any object* in order to provide it with
-  // custom events. You may `bind` or `unbind` a callback function to an event;
-  // `trigger`-ing an event fires all callbacks in succession.
-  //
-  //     var object = {};
-  //     _.extend(object, Backbone.Events);
-  //     object.bind('expand', function(){ alert('expanded'); });
-  //     object.trigger('expand');
-  //
-  Backbone.Events = {
-
-    // Bind an event, specified by a string name, `ev`, to a `callback` function.
-    // Passing `"all"` will bind the callback to all events fired.
-    bind : function(ev, callback) {
-      var calls = this._callbacks || (this._callbacks = {});
-      var list  = this._callbacks[ev] || (this._callbacks[ev] = []);
-      list.push(callback);
-      return this;
-    },
-
-    // Remove one or many callbacks. If `callback` is null, removes all
-    // callbacks for the event. If `ev` is null, removes all bound callbacks
-    // for all events.
-    unbind : function(ev, callback) {
-      var calls;
-      if (!ev) {
-        this._callbacks = {};
-      } else if (calls = this._callbacks) {
-        if (!callback) {
-          calls[ev] = [];
-        } else {
-          var list = calls[ev];
-          if (!list) return this;
-          for (var i = 0, l = list.length; i < l; i++) {
-            if (callback === list[i]) {
-              list.splice(i, 1);
-              break;
-            }
-          }
-        }
-      }
-      return this;
-    },
-
-    // Trigger an event, firing all bound callbacks. Callbacks are passed the
-    // same arguments as `trigger` is, apart from the event name.
-    // Listening for `"all"` passes the true event name as the first argument.
-    trigger : function(ev) {
-      var list, calls, i, l;
-      if (!(calls = this._callbacks)) return this;
-      if (calls[ev]) {
-        list = calls[ev].slice(0);
-        for (i = 0, l = list.length; i < l; i++) {
-          list[i].apply(this, Array.prototype.slice.call(arguments, 1));
-        }
-      }
-      if (calls['all']) {
-        list = calls['all'].slice(0);
-        for (i = 0, l = list.length; i < l; i++) {
-          list[i].apply(this, arguments);
-        }
-      }
-      return this;
-    }
-
-  };
-
-  // Backbone.Model
-  // --------------
-
-  // Create a new model, with defined attributes. A client id (`cid`)
-  // is automatically generated and assigned for you.
-  Backbone.Model = function(attributes, options) {
-    var defaults;
-    attributes || (attributes = {});
-    if (defaults = this.defaults) {
-      if (_.isFunction(defaults)) defaults = defaults();
-      attributes = _.extend({}, defaults, attributes);
-    }
-    this.attributes = {};
-    this._escapedAttributes = {};
-    this.cid = _.uniqueId('c');
-    this.set(attributes, {silent : true});
-    this._changed = false;
-    this._previousAttributes = _.clone(this.attributes);
-    if (options && options.collection) this.collection = options.collection;
-    this.initialize(attributes, options);
-  };
-
-  // Attach all inheritable methods to the Model prototype.
-  _.extend(Backbone.Model.prototype, Backbone.Events, {
-
-    // A snapshot of the model's previous attributes, taken immediately
-    // after the last `"change"` event was fired.
-    _previousAttributes : null,
-
-    // Has the item been changed since the last `"change"` event?
-    _changed : false,
-
-    // Initialize is an empty function by default. Override it with your own
-    // initialization logic.
-    initialize : function(){},
-
-    // Return a copy of the model's `attributes` object.
-    toJSON : function() {
-      return _.clone(this.attributes);
-    },
-
-    // Get the value of an attribute.
-    get : function(attr) {
-      return this.attributes[attr];
-    },
-
-    // Get the HTML-escaped value of an attribute.
-    escape : function(attr) {
-      var html;
-      if (html = this._escapedAttributes[attr]) return html;
-      var val = this.attributes[attr];
-      return this._escapedAttributes[attr] = escapeHTML(val == null ? '' : '' + val);
-    },
-
-    // Returns `true` if the attribute contains a value that is not null
-    // or undefined.
-    has : function(attr) {
-      return this.attributes[attr] != null;
-    },
-
-    // Set a hash of model attributes on the object, firing `"change"` unless you
-    // choose to silence it.
-    set : function(attrs, options) {
-
-      // Extract attributes and options.
-      options || (options = {});
-      if (!attrs) return this;
-      if (attrs.attributes) attrs = attrs.attributes;
-      var now = this.attributes, escaped = this._escapedAttributes;
-
-      // Run validation.
-      if (!options.silent && this.validate && !this._performValidation(attrs, options)) return false;
-
-      // Check for changes of `id`.
-      if ('id' in attrs) this.id = attrs.id;
-
-      // Update attributes.
-      for (var attr in attrs) {
-        var val = attrs[attr];
-        if (!_.isEqual(now[attr], val)) {
-          now[attr] = val;
-          delete escaped[attr];
-          this._changed = true;
-          if (!options.silent) this.trigger('change:' + attr, this, val, options);
-        }
-      }
-
-      // Fire the `"change"` event, if the model has been changed.
-      if (!options.silent && this._changed) this.change(options);
-      return this;
-    },
-
-    // Remove an attribute from the model, firing `"change"` unless you choose
-    // to silence it.
-    unset : function(attr, options) {
-      options || (options = {});
-      var value = this.attributes[attr];
-
-      // Run validation.
-      var validObj = {};
-      validObj[attr] = void 0;
-      if (!options.silent && this.validate && !this._performValidation(validObj, options)) return false;
-
-      // Remove the attribute.
-      delete this.attributes[attr];
-      delete this._escapedAttributes[attr];
-      this._changed = true;
-      if (!options.silent) {
-        this.trigger('change:' + attr, this, void 0, options);
-        this.change(options);
-      }
-      return this;
-    },
-
-    // Clear all attributes on the model, firing `"change"` unless you choose
-    // to silence it.
-    clear : function(options) {
-      options || (options = {});
-      var old = this.attributes;
-
-      // Run validation.
-      var validObj = {};
-      for (attr in old) validObj[attr] = void 0;
-      if (!options.silent && this.validate && !this._performValidation(validObj, options)) return false;
-
-      this.attributes = {};
-      this._escapedAttributes = {};
-      this._changed = true;
-      if (!options.silent) {
-        for (attr in old) {
-          this.trigger('change:' + attr, this, void 0, options);
-        }
-        this.change(options);
-      }
-      return this;
-    },
-
-    // Fetch the model from the server. If the server's representation of the
-    // model differs from its current attributes, they will be overriden,
-    // triggering a `"change"` event.
-    fetch : function(options) {
-      options || (options = {});
-      var model = this;
-      var success = options.success;
-      options.success = function(resp) {
-        if (!model.set(model.parse(resp), options)) return false;
-        if (success) success(model, resp);
-      };
-      options.error = wrapError(options.error, model, options);
-      (this.sync || Backbone.sync)('read', this, options);
-      return this;
-    },
-
-    // Set a hash of model attributes, and sync the model to the server.
-    // If the server returns an attributes hash that differs, the model's
-    // state will be `set` again.
-    save : function(attrs, options) {
-      options || (options = {});
-      if (attrs && !this.set(attrs, options)) return false;
-      var model = this;
-      var success = options.success;
-      options.success = function(resp) {
-        if (!model.set(model.parse(resp), options)) return false;
-        if (success) success(model, resp);
-      };
-      options.error = wrapError(options.error, model, options);
-      var method = this.isNew() ? 'create' : 'update';
-      (this.sync || Backbone.sync)(method, this, options);
-      return this;
-    },
-
-    // Destroy this model on the server. Upon success, the model is removed
-    // from its collection, if it has one.
-    destroy : function(options) {
-      options || (options = {});
-      var model = this;
-      var success = options.success;
-      options.success = function(resp) {
-        model.trigger('destroy', model, model.collection, options);
-        if (success) success(model, resp);
-      };
-      options.error = wrapError(options.error, model, options);
-      (this.sync || Backbone.sync)('delete', this, options);
-      return this;
-    },
-
-    // Default URL for the model's representation on the server -- if you're
-    // using Backbone's restful methods, override this to change the endpoint
-    // that will be called.
-    url : function() {
-      var base = getUrl(this.collection) || this.urlRoot || urlError();
-      if (this.isNew()) return base;
-      return base + (base.charAt(base.length - 1) == '/' ? '' : '/') + this.id;
-    },
-
-    // **parse** converts a response into the hash of attributes to be `set` on
-    // the model. The default implementation is just to pass the response along.
-    parse : function(resp) {
-      return resp;
-    },
-
-    // Create a new model with identical attributes to this one.
-    clone : function() {
-      return new this.constructor(this);
-    },
-
-    // A model is new if it has never been saved to the server, and has a negative
-    // ID.
-    isNew : function() {
-      return !this.id;
-    },
-
-    // Call this method to manually fire a `change` event for this model.
-    // Calling this will cause all objects observing the model to update.
-    change : function(options) {
-      this.trigger('change', this, options);
-      this._previousAttributes = _.clone(this.attributes);
-      this._changed = false;
-    },
-
-    // Determine if the model has changed since the last `"change"` event.
-    // If you specify an attribute name, determine if that attribute has changed.
-    hasChanged : function(attr) {
-      if (attr) return this._previousAttributes[attr] != this.attributes[attr];
-      return this._changed;
-    },
-
-    // Return an object containing all the attributes that have changed, or false
-    // if there are no changed attributes. Useful for determining what parts of a
-    // view need to be updated and/or what attributes need to be persisted to
-    // the server.
-    changedAttributes : function(now) {
-      now || (now = this.attributes);
-      var old = this._previousAttributes;
-      var changed = false;
-      for (var attr in now) {
-        if (!_.isEqual(old[attr], now[attr])) {
-          changed = changed || {};
-          changed[attr] = now[attr];
-        }
-      }
-      return changed;
-    },
-
-    // Get the previous value of an attribute, recorded at the time the last
-    // `"change"` event was fired.
-    previous : function(attr) {
-      if (!attr || !this._previousAttributes) return null;
-      return this._previousAttributes[attr];
-    },
-
-    // Get all of the attributes of the model at the time of the previous
-    // `"change"` event.
-    previousAttributes : function() {
-      return _.clone(this._previousAttributes);
-    },
-
-    // Run validation against a set of incoming attributes, returning `true`
-    // if all is well. If a specific `error` callback has been passed,
-    // call that instead of firing the general `"error"` event.
-    _performValidation : function(attrs, options) {
-      var error = this.validate(attrs);
-      if (error) {
-        if (options.error) {
-          options.error(this, error);
-        } else {
-          this.trigger('error', this, error, options);
-        }
-        return false;
-      }
-      return true;
-    }
-
-  });
-
-  // Backbone.Collection
-  // -------------------
-
-  // Provides a standard collection class for our sets of models, ordered
-  // or unordered. If a `comparator` is specified, the Collection will maintain
-  // its models in sort order, as they're added and removed.
-  Backbone.Collection = function(models, options) {
-    options || (options = {});
-    if (options.comparator) {
-      this.comparator = options.comparator;
-      delete options.comparator;
-    }
-    _.bindAll(this, '_onModelEvent', '_removeReference');
-    this._reset();
-    if (models) this.refresh(models, {silent: true});
-    this.initialize(models, options);
-  };
-
-  // Define the Collection's inheritable methods.
-  _.extend(Backbone.Collection.prototype, Backbone.Events, {
-
-    // The default model for a collection is just a **Backbone.Model**.
-    // This should be overridden in most cases.
-    model : Backbone.Model,
-
-    // Initialize is an empty function by default. Override it with your own
-    // initialization logic.
-    initialize : function(){},
-
-    // The JSON representation of a Collection is an array of the
-    // models' attributes.
-    toJSON : function() {
-      return this.map(function(model){ return model.toJSON(); });
-    },
-
-    // Add a model, or list of models to the set. Pass **silent** to avoid
-    // firing the `added` event for every new model.
-    add : function(models, options) {
-      if (_.isArray(models)) {
-        for (var i = 0, l = models.length; i < l; i++) {
-          this._add(models[i], options);
-        }
-      } else {
-        this._add(models, options);
-      }
-      return this;
-    },
-
-    // Remove a model, or a list of models from the set. Pass silent to avoid
-    // firing the `removed` event for every model removed.
-    remove : function(models, options) {
-      if (_.isArray(models)) {
-        for (var i = 0, l = models.length; i < l; i++) {
-          this._remove(models[i], options);
-        }
-      } else {
-        this._remove(models, options);
-      }
-      return this;
-    },
-
-    // Get a model from the set by id.
-    get : function(id) {
-      if (id == null) return null;
-      return this._byId[id.id != null ? id.id : id];
-    },
-
-    // Get a model from the set by client id.
-    getByCid : function(cid) {
-      return cid && this._byCid[cid.cid || cid];
-    },
-
-    // Get the model at the given index.
-    at: function(index) {
-      return this.models[index];
-    },
-
-    // Force the collection to re-sort itself. You don't need to call this under normal
-    // circumstances, as the set will maintain sort order as each item is added.
-    sort : function(options) {
-      options || (options = {});
-      if (!this.comparator) throw new Error('Cannot sort a set without a comparator');
-      this.models = this.sortBy(this.comparator);
-      if (!options.silent) this.trigger('refresh', this, options);
-      return this;
-    },
-
-    // Pluck an attribute from each model in the collection.
-    pluck : function(attr) {
-      return _.map(this.models, function(model){ return model.get(attr); });
-    },
-
-    // When you have more items than you want to add or remove individually,
-    // you can refresh the entire set with a new list of models, without firing
-    // any `added` or `removed` events. Fires `refresh` when finished.
-    refresh : function(models, options) {
-      models  || (models = []);
-      options || (options = {});
-      this.each(this._removeReference);
-      this._reset();
-      this.add(models, {silent: true});
-      if (!options.silent) this.trigger('refresh', this, options);
-      return this;
-    },
-
-    // Fetch the default set of models for this collection, refreshing the
-    // collection when they arrive. If `add: true` is passed, appends the
-    // models to the collection instead of refreshing.
-    fetch : function(options) {
-      options || (options = {});
-      var collection = this;
-      var success = options.success;
-      options.success = function(resp) {
-        collection[options.add ? 'add' : 'refresh'](collection.parse(resp), options);
-        if (success) success(collection, resp);
-      };
-      options.error = wrapError(options.error, collection, options);
-      (this.sync || Backbone.sync)('read', this, options);
-      return this;
-    },
-
-    // Create a new instance of a model in this collection. After the model
-    // has been created on the server, it will be added to the collection.
-    create : function(model, options) {
-      var coll = this;
-      options || (options = {});
-      if (!(model instanceof Backbone.Model)) {
-        model = new this.model(model, {collection: coll});
-      } else {
-        model.collection = coll;
-      }
-      var success = options.success;
-      options.success = function(nextModel, resp) {
-        coll.add(nextModel);
-        if (success) success(nextModel, resp);
-      };
-      return model.save(null, options);
-    },
-
-    // **parse** converts a response into a list of models to be added to the
-    // collection. The default implementation is just to pass it through.
-    parse : function(resp) {
-      return resp;
-    },
-
-    // Proxy to _'s chain. Can't be proxied the same way the rest of the
-    // underscore methods are proxied because it relies on the underscore
-    // constructor.
-    chain: function () {
-      return _(this.models).chain();
-    },
-
-    // Reset all internal state. Called when the collection is refreshed.
-    _reset : function(options) {
-      this.length = 0;
-      this.models = [];
-      this._byId  = {};
-      this._byCid = {};
-    },
-
-    // Internal implementation of adding a single model to the set, updating
-    // hash indexes for `id` and `cid` lookups.
-    _add : function(model, options) {
-      options || (options = {});
-      if (!(model instanceof Backbone.Model)) {
-        model = new this.model(model, {collection: this});
-      }
-      var already = this.getByCid(model);
-      if (already) throw new Error(["Can't add the same model to a set twice", already.id]);
-      this._byId[model.id] = model;
-      this._byCid[model.cid] = model;
-      if (!model.collection) {
-        model.collection = this;
-      }
-      var index = this.comparator ? this.sortedIndex(model, this.comparator) : this.length;
-      this.models.splice(index, 0, model);
-      model.bind('all', this._onModelEvent);
-      this.length++;
-      if (!options.silent) model.trigger('add', model, this, options);
-      return model;
-    },
-
-    // Internal implementation of removing a single model from the set, updating
-    // hash indexes for `id` and `cid` lookups.
-    _remove : function(model, options) {
-      options || (options = {});
-      model = this.getByCid(model) || this.get(model);
-      if (!model) return null;
-      delete this._byId[model.id];
-      delete this._byCid[model.cid];
-      this.models.splice(this.indexOf(model), 1);
-      this.length--;
-      if (!options.silent) model.trigger('remove', model, this, options);
-      this._removeReference(model);
-      return model;
-    },
-
-    // Internal method to remove a model's ties to a collection.
-    _removeReference : function(model) {
-      if (this == model.collection) {
-        delete model.collection;
-      }
-      model.unbind('all', this._onModelEvent);
-    },
-
-    // Internal method called every time a model in the set fires an event.
-    // Sets need to update their indexes when models change ids. All other
-    // events simply proxy through. "add" and "remove" events that originate
-    // in other collections are ignored.
-    _onModelEvent : function(ev, model, collection, options) {
-      if ((ev == 'add' || ev == 'remove') && collection != this) return;
-      if (ev == 'destroy') {
-        this._remove(model, options);
-      }
-      if (ev === 'change:id') {
-        delete this._byId[model.previous('id')];
-        this._byId[model.id] = model;
-      }
-      this.trigger.apply(this, arguments);
-    }
-
-  });
-
-  // Underscore methods that we want to implement on the Collection.
-  var methods = ['forEach', 'each', 'map', 'reduce', 'reduceRight', 'find', 'detect',
-    'filter', 'select', 'reject', 'every', 'all', 'some', 'any', 'include',
-    'invoke', 'max', 'min', 'sortBy', 'sortedIndex', 'toArray', 'size',
-    'first', 'rest', 'last', 'without', 'indexOf', 'lastIndexOf', 'isEmpty'];
-
-  // Mix in each Underscore method as a proxy to `Collection#models`.
-  _.each(methods, function(method) {
-    Backbone.Collection.prototype[method] = function() {
-      return _[method].apply(_, [this.models].concat(_.toArray(arguments)));
-    };
-  });
-
-  // Backbone.Controller
-  // -------------------
-
-  // Controllers map faux-URLs to actions, and fire events when routes are
-  // matched. Creating a new one sets its `routes` hash, if not set statically.
-  Backbone.Controller = function(options) {
-    options || (options = {});
-    if (options.routes) this.routes = options.routes;
-    this._bindRoutes();
-    this.initialize(options);
-  };
-
-  // Cached regular expressions for matching named param parts and splatted
-  // parts of route strings.
-  var namedParam    = /:([\w\d]+)/g;
-  var splatParam    = /\*([\w\d]+)/g;
-  var escapeRegExp  = /[-[\]{}()+?.,\\^$|#\s]/g;
-
-  // Set up all inheritable **Backbone.Controller** properties and methods.
-  _.extend(Backbone.Controller.prototype, Backbone.Events, {
-
-    // Initialize is an empty function by default. Override it with your own
-    // initialization logic.
-    initialize : function(){},
-
-    // Manually bind a single named route to a callback. For example:
-    //
-    //     this.route('search/:query/p:num', 'search', function(query, num) {
-    //       ...
-    //     });
-    //
-    route : function(route, name, callback) {
-      Backbone.history || (Backbone.history = new Backbone.History);
-      if (!_.isRegExp(route)) route = this._routeToRegExp(route);
-      Backbone.history.route(route, _.bind(function(fragment) {
-        var args = this._extractParameters(route, fragment);
-        callback.apply(this, args);
-        this.trigger.apply(this, ['route:' + name].concat(args));
-      }, this));
-    },
-
-    // Simple proxy to `Backbone.history` to save a fragment into the history,
-    // without triggering routes.
-    saveLocation : function(fragment) {
-      Backbone.history.saveLocation(fragment);
-    },
-
-    // Bind all defined routes to `Backbone.history`.
-    _bindRoutes : function() {
-      if (!this.routes) return;
-      for (var route in this.routes) {
-        var name = this.routes[route];
-        this.route(route, name, this[name]);
-      }
-    },
-
-    // Convert a route string into a regular expression, suitable for matching
-    // against the current location fragment.
-    _routeToRegExp : function(route) {
-      route = route.replace(escapeRegExp, "\\$&")
-                   .replace(namedParam, "([^\/]*)")
-                   .replace(splatParam, "(.*?)");
-      return new RegExp('^' + route + '$');
-    },
-
-    // Given a route, and a URL fragment that it matches, return the array of
-    // extracted parameters.
-    _extractParameters : function(route, fragment) {
-      return route.exec(fragment).slice(1);
-    }
-
-  });
-
-  // Backbone.History
-  // ----------------
-
-  // Handles cross-browser history management, based on URL hashes. If the
-  // browser does not support `onhashchange`, falls back to polling.
-  Backbone.History = function() {
-    this.handlers = [];
-    this.fragment = this.getFragment();
-    _.bindAll(this, 'checkUrl');
-  };
-
-  // Cached regex for cleaning hashes.
-  var hashStrip = /^#*/;
-
-  // Set up all inheritable **Backbone.History** properties and methods.
-  _.extend(Backbone.History.prototype, {
-
-    // The default interval to poll for hash changes, if necessary, is
-    // twenty times a second.
-    interval: 50,
-
-    // Get the cross-browser normalized URL fragment.
-    getFragment : function(loc) {
-      return (loc || window.location).hash.replace(hashStrip, '');
-    },
-
-    // Start the hash change handling, returning `true` if the current URL matches
-    // an existing route, and `false` otherwise.
-    start : function() {
-      var docMode = document.documentMode;
-      var oldIE = ($.browser.msie && (!docMode || docMode <= 7));
-      if (oldIE) {
-        this.iframe = $('<iframe src="javascript:0" tabindex="-1" />').hide().appendTo('body')[0].contentWindow;
-      }
-      if ('onhashchange' in window && !oldIE) {
-        $(window).bind('hashchange', this.checkUrl);
-      } else {
-        setInterval(this.checkUrl, this.interval);
-      }
-      return this.loadUrl();
-    },
-
-    // Add a route to be tested when the hash changes. Routes are matched in the
-    // order they are added.
-    route : function(route, callback) {
-      this.handlers.push({route : route, callback : callback});
-    },
-
-    // Checks the current URL to see if it has changed, and if it has,
-    // calls `loadUrl`, normalizing across the hidden iframe.
-    checkUrl : function() {
-      var current = this.getFragment();
-      if (current == this.fragment && this.iframe) {
-        current = this.getFragment(this.iframe.location);
-      }
-      if (current == this.fragment ||
-          current == decodeURIComponent(this.fragment)) return false;
-      if (this.iframe) {
-        window.location.hash = this.iframe.location.hash = current;
-      }
-      this.loadUrl();
-    },
-
-    // Attempt to load the current URL fragment. If a route succeeds with a
-    // match, returns `true`. If no defined routes matches the fragment,
-    // returns `false`.
-    loadUrl : function() {
-      var fragment = this.fragment = this.getFragment();
-      var matched = _.any(this.handlers, function(handler) {
-        if (handler.route.test(fragment)) {
-          handler.callback(fragment);
-          return true;
-        }
-      });
-      return matched;
-    },
-
-    // Save a fragment into the hash history. You are responsible for properly
-    // URL-encoding the fragment in advance. This does not trigger
-    // a `hashchange` event.
-    saveLocation : function(fragment) {
-      fragment = (fragment || '').replace(hashStrip, '');
-      if (this.fragment == fragment) return;
-      window.location.hash = this.fragment = fragment;
-      if (this.iframe && (fragment != this.getFragment(this.iframe.location))) {
-        this.iframe.document.open().close();
-        this.iframe.location.hash = fragment;
-      }
-    }
-
-  });
-
-  // Backbone.View
-  // -------------
-
-  // Creating a Backbone.View creates its initial element outside of the DOM,
-  // if an existing element is not provided...
-  Backbone.View = function(options) {
-    this.cid = _.uniqueId('view');
-    this._configure(options || {});
-    this._ensureElement();
-    this.delegateEvents();
-    this.initialize(options);
-  };
-
-  // Element lookup, scoped to DOM elements within the current view.
-  // This should be prefered to global lookups, if you're dealing with
-  // a specific view.
-  var selectorDelegate = function(selector) {
-    return $(selector, this.el);
-  };
-
-  // Cached regex to split keys for `delegate`.
-  var eventSplitter = /^(\w+)\s*(.*)$/;
-
-  // Set up all inheritable **Backbone.View** properties and methods.
-  _.extend(Backbone.View.prototype, Backbone.Events, {
-
-    // The default `tagName` of a View's element is `"div"`.
-    tagName : 'div',
-
-    // Attach the `selectorDelegate` function as the `$` property.
-    $       : selectorDelegate,
-
-    // Initialize is an empty function by default. Override it with your own
-    // initialization logic.
-    initialize : function(){},
-
-    // **render** is the core function that your view should override, in order
-    // to populate its element (`this.el`), with the appropriate HTML. The
-    // convention is for **render** to always return `this`.
-    render : function() {
-      return this;
-    },
-
-    // Remove this view from the DOM. Note that the view isn't present in the
-    // DOM by default, so calling this method may be a no-op.
-    remove : function() {
-      $(this.el).remove();
-      return this;
-    },
-
-    // For small amounts of DOM Elements, where a full-blown template isn't
-    // needed, use **make** to manufacture elements, one at a time.
-    //
-    //     var el = this.make('li', {'class': 'row'}, this.model.get('title'));
-    //
-    make : function(tagName, attributes, content) {
-      var el = document.createElement(tagName);
-      if (attributes) $(el).attr(attributes);
-      if (content) $(el).html(content);
-      return el;
-    },
-
-    // Set callbacks, where `this.callbacks` is a hash of
-    //
-    // *{"event selector": "callback"}*
-    //
-    //     {
-    //       'mousedown .title':  'edit',
-    //       'click .button':     'save'
-    //     }
-    //
-    // pairs. Callbacks will be bound to the view, with `this` set properly.
-    // Uses event delegation for efficiency.
-    // Omitting the selector binds the event to `this.el`.
-    // This only works for delegate-able events: not `focus`, `blur`, and
-    // not `change`, `submit`, and `reset` in Internet Explorer.
-    delegateEvents : function(events) {
-      if (!(events || (events = this.events))) return;
-      $(this.el).unbind('.delegateEvents' + this.cid);
-      for (var key in events) {
-        var methodName = events[key];
-        var match = key.match(eventSplitter);
-        var eventName = match[1], selector = match[2];
-        var method = _.bind(this[methodName], this);
-        eventName += '.delegateEvents' + this.cid;
-        if (selector === '') {
-          $(this.el).bind(eventName, method);
-        } else {
-          $(this.el).delegate(selector, eventName, method);
-        }
-      }
-    },
-
-    // Performs the initial configuration of a View with a set of options.
-    // Keys with special meaning *(model, collection, id, className)*, are
-    // attached directly to the view.
-    _configure : function(options) {
-      if (this.options) options = _.extend({}, this.options, options);
-      if (options.model)      this.model      = options.model;
-      if (options.collection) this.collection = options.collection;
-      if (options.el)         this.el         = options.el;
-      if (options.id)         this.id         = options.id;
-      if (options.className)  this.className  = options.className;
-      if (options.tagName)    this.tagName    = options.tagName;
-      this.options = options;
-    },
-
-    // Ensure that the View has a DOM element to render into.
-    // If `this.el` is a string, pass it through `$()`, take the first
-    // matching element, and re-assign it to `el`. Otherwise, create
-    // an element from the `id`, `className` and `tagName` proeprties.
-    _ensureElement : function() {
-      if (!this.el) {
-        var attrs = {};
-        if (this.id) attrs.id = this.id;
-        if (this.className) attrs['class'] = this.className;
-        this.el = this.make(this.tagName, attrs);
-      } else if (_.isString(this.el)) {
-        this.el = $(this.el).get(0);
-      }
-    }
-
-  });
-
-  // The self-propagating extend function that Backbone classes use.
-  var extend = function (protoProps, classProps) {
-    var child = inherits(this, protoProps, classProps);
-    child.extend = extend;
-    return child;
-  };
-
-  // Set up inheritance for the model, collection, and view.
-  Backbone.Model.extend = Backbone.Collection.extend =
-    Backbone.Controller.extend = Backbone.View.extend = extend;
-
-  // Map from CRUD to HTTP for our default `Backbone.sync` implementation.
-  var methodMap = {
-    'create': 'POST',
-    'update': 'PUT',
-    'delete': 'DELETE',
-    'read'  : 'GET'
-  };
-
-  // Backbone.sync
-  // -------------
-
-  // Override this function to change the manner in which Backbone persists
-  // models to the server. You will be passed the type of request, and the
-  // model in question. By default, uses makes a RESTful Ajax request
-  // to the model's `url()`. Some possible customizations could be:
-  //
-  // * Use `setTimeout` to batch rapid-fire updates into a single request.
-  // * Send up the models as XML instead of JSON.
-  // * Persist models via WebSockets instead of Ajax.
-  //
-  // Turn on `Backbone.emulateHTTP` in order to send `PUT` and `DELETE` requests
-  // as `POST`, with a `_method` parameter containing the true HTTP method,
-  // as well as all requests with the body as `application/x-www-form-urlencoded` instead of
-  // `application/json` with the model in a param named `model`.
-  // Useful when interfacing with server-side languages like **PHP** that make
-  // it difficult to read the body of `PUT` requests.
-  Backbone.sync = function(method, model, options) {
-    var type = methodMap[method];
-
-    // Default JSON-request options.
-    var params = _.extend({
-      type:         type,
-      contentType:  'application/json',
-      dataType:     'json',
-      processData:  false
-    }, options);
-
-    // Ensure that we have a URL.
-    if (!params.url) {
-      params.url = getUrl(model) || urlError();
-    }
-
-    // Ensure that we have the appropriate request data.
-    if (!params.data && model && (method == 'create' || method == 'update')) {
-      params.data = JSON.stringify(model.toJSON());
-    }
-
-    // For older servers, emulate JSON by encoding the request into an HTML-form.
-    if (Backbone.emulateJSON) {
-      params.contentType = 'application/x-www-form-urlencoded';
-      params.processData = true;
-      params.data        = params.data ? {model : params.data} : {};
-    }
-
-    // For older servers, emulate HTTP by mimicking the HTTP method with `_method`
-    // And an `X-HTTP-Method-Override` header.
-    if (Backbone.emulateHTTP) {
-      if (type === 'PUT' || type === 'DELETE') {
-        if (Backbone.emulateJSON) params.data._method = type;
-        params.type = 'POST';
-        params.beforeSend = function(xhr) {
-          xhr.setRequestHeader('X-HTTP-Method-Override', type);
-        };
-      }
-    }
-
-    // Make the request.
-    $.ajax(params);
-  };
-
-  // Helpers
-  // -------
-
-  // Shared empty constructor function to aid in prototype-chain creation.
-  var ctor = function(){};
-
-  // Helper function to correctly set up the prototype chain, for subclasses.
-  // Similar to `goog.inherits`, but uses a hash of prototype properties and
-  // class properties to be extended.
-  var inherits = function(parent, protoProps, staticProps) {
-    var child;
-
-    // The constructor function for the new subclass is either defined by you
-    // (the "constructor" property in your `extend` definition), or defaulted
-    // by us to simply call `super()`.
-    if (protoProps && protoProps.hasOwnProperty('constructor')) {
-      child = protoProps.constructor;
-    } else {
-      child = function(){ return parent.apply(this, arguments); };
-    }
-
-    // Inherit class (static) properties from parent.
-    _.extend(child, parent);
-
-    // Set the prototype chain to inherit from `parent`, without calling
-    // `parent`'s constructor function.
-    ctor.prototype = parent.prototype;
-    child.prototype = new ctor();
-
-    // Add prototype properties (instance properties) to the subclass,
-    // if supplied.
-    if (protoProps) _.extend(child.prototype, protoProps);
-
-    // Add static properties to the constructor function, if supplied.
-    if (staticProps) _.extend(child, staticProps);
-
-    // Correctly set child's `prototype.constructor`, for `instanceof`.
-    child.prototype.constructor = child;
-
-    // Set a convenience property in case the parent's prototype is needed later.
-    child.__super__ = parent.prototype;
-
-    return child;
-  };
-
-  // Helper function to get a URL from a Model or Collection as a property
-  // or as a function.
-  var getUrl = function(object) {
-    if (!(object && object.url)) return null;
-    return _.isFunction(object.url) ? object.url() : object.url;
-  };
-
-  // Throw an error when a URL is needed, and none is supplied.
-  var urlError = function() {
-    throw new Error("A 'url' property or function must be specified");
-  };
-
-  // Wrap an optional error callback with a fallback error event.
-  var wrapError = function(onError, model, options) {
-    return function(resp) {
-      if (onError) {
-        onError(model, resp, options);
-      } else {
-        model.trigger('error', model, resp, options);
-      }
-    };
-  };
-
-  // Helper function to escape a string for HTML rendering.
-  var escapeHTML = function(string) {
-    return string.replace(/&(?!\w+;)/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
-  };
-
-}).call(this);
--- a/lib/python/slavealloc/www/js/models.js
+++ b/lib/python/slavealloc/www/js/models.js
@@ -30,16 +30,17 @@ var DenormalizedModel = Backbone.Model.e
 var Slave = DenormalizedModel.extend({
     initialize: function() {
         this.id = this.get('slaveid');
 
         this.bindDenormalizedColumns([
             { name_col: 'distro', model: window.distros, id_col: 'distroid' },
             { name_col: 'datacenter', model: window.datacenters, id_col: 'dcid' },
             { name_col: 'bitlength', model: window.bitlengths, id_col: 'bitsid' },
+            { name_col: 'speed', model: window.speeds, id_col: 'speedid' },
             { name_col: 'purpose', model: window.purposes, id_col: 'purposeid' },
             { name_col: 'trustlevel', model: window.trustlevels, id_col: 'trustid' },
             { name_col: 'environment', model: window.environments, id_col: 'envid' },
             { name_col: 'pool', model: window.pools, id_col: 'poolid' },
             { name_col: 'locked_master', model: window.masters,
                     id_col: 'locked_masterid', foreign_name_col: 'nickname' },
             { name_col: 'current_master', model: window.masters,
                     id_col: 'current_masterid', foreign_name_col: 'nickname' }
@@ -118,16 +119,32 @@ var Bitlengths = Backbone.Collection.ext
     model: Bitlength,
 
     // information about the columns in this collection
     columns: [
         { id: "name", title: "Name" }
     ]
 });
 
+var Speed = Backbone.Model.extend({
+    initialize: function() {
+        this.id = this.get('speedid');
+    }
+});
+
+var Speeds = Backbone.Collection.extend({
+    url: '/api/speeds',
+    model: Speed,
+
+    // information about the columns in this collection
+    columns: [
+        { id: "name", title: "Name" }
+    ]
+});
+
 var Purpose = Backbone.Model.extend({
     initialize: function() {
         this.id = this.get('purposeid');
     }
 });
 
 var Purposes = Backbone.Collection.extend({
     url: '/api/purposes',
--- a/lib/python/slavealloc/www/js/slavealloc.js
+++ b/lib/python/slavealloc/www/js/slavealloc.js
@@ -1,54 +1,69 @@
 var js_root = '/ui/js/';
+
 load(
     js_root + 'deps/jquery-1.4.4.min.js',
     js_root + 'deps/underscore-min.js')
 .thenLoad(
     js_root + 'deps/jquery.dataTables.min.js',
     js_root + 'deps/jquery-ui-1.8.9.custom.min.js',
-    js_root + 'deps/backbone.js') // TODO: switch back to -min
+    js_root + 'deps/backbone-min.js')
 .thenLoad(
     js_root + 'models.js',
     js_root + 'views.js',
     js_root + 'controller.js')
 .thenRun(
     function (next) {
         window.masters = new Masters();
-        window.masters.fetch({ success: next });
+        window.masters.fetch({ success: next,
+            error: function() { $('#error').text('error loading masters').show(); } });
     },
     function (next) {
         window.slaves = new Slaves();
-        window.slaves.fetch({ success: next });
+        window.slaves.fetch({ success: next,
+            error: function() { $('#error').text('error loading slaves').show(); } });
     },
     function (next) {
         window.distros = new Distros();
-        window.distros.fetch({ success: next });
+        window.distros.fetch({ success: next,
+            error: function() { $('#error').text('error loading distros').show(); } });
     },
     function (next) {
         window.datacenters = new Datacenters();
-        window.datacenters.fetch({ success: next });
+        window.datacenters.fetch({ success: next,
+            error: function() { $('#error').text('error loading datacenters').show(); } });
     },
     function (next) {
         window.bitlengths = new Bitlengths();
-        window.bitlengths.fetch({ success: next });
+        window.bitlengths.fetch({ success: next,
+            error: function() { $('#error').text('error loading bitlengths').show(); } });
+    },
+    function (next) {
+        window.speeds = new Speeds();
+        window.speeds.fetch({ success: next,
+            error: function() { $('#error').text('error loading speeds').show(); } });
     },
     function (next) {
         window.purposes = new Purposes();
-        window.purposes.fetch({ success: next });
+        window.purposes.fetch({ success: next,
+            error: function() { $('#error').text('error loading purposes').show(); } });
     },
     function (next) {
         window.trustlevels = new Trustlevels();
-        window.trustlevels.fetch({ success: next });
+        window.trustlevels.fetch({ success: next,
+            error: function() { $('#error').text('error loading trustlevels').show(); } });
     },
     function (next) {
         window.environments = new Environments();
-        window.environments.fetch({ success: next });
+        window.environments.fetch({ success: next,
+            error: function() { $('#error').text('error loading environments').show(); } });
     },
     function (next) {
         window.pools = new Pools();
-        window.pools.fetch({ success: next });
+        window.pools.fetch({ success: next,
+            error: function() { $('#error').text('error loading pools').show(); } });
     })
 .thenRun(function () {
     // fire up the controller and start the history mgmt
     var controller = new SlaveallocController();
     Backbone.history.start();
 });
--- a/lib/python/slavealloc/www/js/views.js
+++ b/lib/python/slavealloc/www/js/views.js
@@ -368,16 +368,19 @@ var SlaveEditRowView = TableEditRowView.
 
         this.formElements = [
             new SelectEditControlView({model: this.model,
                     column: 'distroid', title: 'Distro',
                     choice_collection: window.distros }),
             new SelectEditControlView({model: this.model,
                     column: 'bitsid', title: 'Bitlength',
                     choice_collection: window.bitlengths }),
+            new SelectEditControlView({model: this.model,
+                    column: 'speedid', title: 'Speed',
+                    choice_collection: window.speeds }),
             new TextEditControlView({model: this.model,
                     column: 'basedir', title: 'Basedir'}),
             new SelectEditControlView({model: this.model,
                     column: 'dcid', title: 'Datacenter',
                     choice_collection: window.datacenters }),
             new SelectEditControlView({model: this.model,
                     column: 'trustid', title: 'Trustlevel',
                     choice_collection: window.trustlevels }),
@@ -408,16 +411,17 @@ var SlaveTableRowView = TableRowView.ext
 var SlavesTableView = TableView.extend({
     rowViewClass: SlaveTableRowView,
     defaultPageLength: 10,
 
     columns: [
         { id: "name", title: "Name" },
         { id: "distro", title: "Distro" },
         { id: "bitlength", title: "Bits" },
+        { id: "speed", title: "Speed" },
         { id: "basedir", title: "Basedir" },
         { id: "datacenter", title: "DC" },
         { id: "trustlevel", title: "Trust" },
         { id: "environment", title: "Environ" },
         { id: "purpose", title: "Purpose" },
         { id: "pool", title: "Pool" },
         { id: "current_master", title: "Current" },
         { id: "locked_master", title: "Locked" },
@@ -597,16 +601,17 @@ var SilosTableView = ReportTableView.ext
     makeData: function() {
         var pools = {};
         var silo_info = [];
 
         window.slaves.each(function (slave) {
             var silo_tuple = [
                 slave.get('distro'), 
                 slave.get('bitlength'),
+                slave.get('speed'),
                 slave.get('datacenter'),
                 slave.get('trustlevel'),
                 slave.get('environment'),
                 slave.get('purpose')
             ];
 
             var silokey = silo_tuple.join(':');
             var this_silo;
@@ -631,16 +636,17 @@ var SilosTableView = ReportTableView.ext
 
         // convert to a sorted array
         var pool_names = _.keys(pools).sort();
 
         // make up the columns: silo components followed by pools
         var columns = [
             { sTitle: 'Distro' },
             { sTitle: 'Bits' },
+            { sTitle: 'Speed' },
             { sTitle: 'DC' },
             { sTitle: 'Trust' },
             { sTitle: 'Env' },
             { sTitle: 'Purp' }
         ];
         columns = columns.concat(_.map(pool_names, function(pool) {
             return { sTitle: pool };
         }));
@@ -658,17 +664,16 @@ var SilosTableView = ReportTableView.ext
             return row;
         });
 
         this.reportData = data;
     },
 
     makeReportData: function() {
         this.makeData();
-        console.log(this.reportColumns);
         return this.reportData;
     },
 
     makeReportColumns: function() {
         this.makeData();
         return this.reportColumns;
     }
 });
@@ -681,13 +686,13 @@ var DashboardView = Backbone.View.extend
     tagName: 'div',
     className: 'dashboard',
 
     initialize: function(args) {
         this.render = $.proxy(this, 'render');
     },
 
     render: function() {
-        $(this.el).text('Select a view.');
+        $(this.el).load('/ui/dashboard.html');
         return this;
     }
 });
 
--- a/setup.py
+++ b/setup.py
@@ -16,16 +16,17 @@ setup(
     package_dir = { '' : "lib/python" },
 
     test_suite = 'buildtools.test',
 
     install_requires = [
         'sqlalchemy',
         'argparse',
         'twisted',
+        'simplejson',
     ],
 
     entry_points = {
         'console_scripts': [
             'slavealloc = slavealloc.scripts.main:main'
         ],
     }
 )