changeset 73:6d7753ea1798

Implemented basic management of target platforms. Closes #14.
author cmlenz
date Sun, 03 Jul 2005 21:11:59 +0000
parents b2d371dac270
children 1d4fa4c32afa
files bitten/master.py bitten/model.py bitten/tests/__init__.py bitten/tests/model.py bitten/trac_ext/main.py bitten/trac_ext/tests/__init__.py bitten/trac_ext/tests/web_ui.py bitten/trac_ext/web_ui.py
diffstat 8 files changed, 857 insertions(+), 186 deletions(-) [+]
line wrap: on
line diff
--- a/bitten/master.py
+++ b/bitten/master.py
@@ -23,7 +23,7 @@
 import time
 
 from trac.env import Environment
-from bitten.model import Build, BuildConfig, SlaveInfo
+from bitten.model import Build, BuildConfig, TargetPlatform
 from bitten.util import archive, beep, xmlio
 
 
@@ -147,13 +147,13 @@
             self.name = elem.attr['name']
             for child in elem.children():
                 if child.name == 'platform':
-                    self.props[SlaveInfo.MACHINE] = child.gettext()
-                    self.props[SlaveInfo.PROCESSOR] = child.attr.get('processor')
+                    self.props[Build.MACHINE] = child.gettext()
+                    self.props[Build.PROCESSOR] = child.attr.get('processor')
                 elif child.name == 'os':
-                    self.props[SlaveInfo.OS_NAME] = child.gettext()
-                    self.props[SlaveInfo.OS_FAMILY] = child.attr.get('family')
-                    self.props[SlaveInfo.OS_VERSION] = child.attr.get('version')
-            self.props[SlaveInfo.IP_ADDRESS] = self.session.addr[0]
+                    self.props[Build.OS_NAME] = child.gettext()
+                    self.props[Build.OS_FAMILY] = child.attr.get('family')
+                    self.props[Build.OS_VERSION] = child.attr.get('version')
+            self.props[Build.IP_ADDRESS] = self.session.addr[0]
 
             self.name = elem.attr['name']
             self.master.slaves[self.name] = self
@@ -212,6 +212,7 @@
                 if elem.name == 'started':
                     self.steps = []
                     build.slave = self.name
+                    build.slave_info = self.props
                     build.started = int(time.time())
                     build.status = Build.IN_PROGRESS
                     build.update()
@@ -247,13 +248,6 @@
                     build.started = 0
                 build.update()
 
-                # Insert slave info
-                slave_info = SlaveInfo(self.env)
-                slave_info.build = build.id
-                for name, value in self.props.items():
-                    slave_info[name] = value
-                slave_info.insert()
-
         # TODO: should not block while reading the file; rather stream it using
         #       asyncore push_with_producer()
         snapshot_path = self.master.get_snapshot(build, type, encoding)
--- a/bitten/model.py
+++ b/bitten/model.py
@@ -20,15 +20,15 @@
 
 from trac.db_default import Table, Column, Index
 
-schema_version = 1
-
 
 class BuildConfig(object):
     """Representation of a build configuration."""
 
-    _table = Table('bitten_config', key='name')[
-        Column('name'), Column('path'), Column('label'),
-        Column('active', type='int'), Column('description')
+    _schema = [
+        Table('bitten_config', key='name')[
+            Column('name'), Column('path'), Column('label'),
+            Column('active', type='int'), Column('description')
+        ]
     ]
 
     def __init__(self, env, name=None, db=None):
@@ -116,23 +116,146 @@
     select = classmethod(select)
 
 
+class TargetPlatform(object):
+    """Target platform for a build configuration."""
+
+    _schema = [
+        Table('bitten_platform', key='id')[
+            Column('id', auto_increment=True), Column('config'), Column('name')
+        ],
+        Table('bitten_rule', key=('id', 'propname'))[
+            Column('id'), Column('propname'), Column('pattern'),
+            Column('orderno', type='int')
+        ]
+    ]
+
+    def __init__(self, env, id=None, db=None):
+        self.env = env
+        self.rules = []
+        if id is not None:
+            self._fetch(id, db)
+        else:
+            self.id = self.config = self.name = None
+
+    def _fetch(self, id, db=None):
+        if not db:
+            db = self.env.get_db_cnx()
+
+        cursor = db.cursor()
+        cursor.execute("SELECT config,name FROM bitten_platform "
+                       "WHERE id=%s", (id,))
+        row = cursor.fetchone()
+        if not row:
+            raise Exception, 'Target platform %s does not exist' % id
+        self.id = id
+        self.config = row[0]
+        self.name = row[1]
+
+        cursor.execute("SELECT propname,pattern FROM bitten_rule "
+                       "WHERE id=%s ORDER BY orderno", (id,))
+        for propname, pattern in cursor:
+            self.rules.append((propname, pattern))
+
+    exists = property(fget=lambda self: self.id is not None)
+
+    def insert(self, db=None):
+        if not db:
+            db = self.env.get_db_cnx()
+            handle_ta = True
+        else:
+            handle_ta = False
+
+        assert not self.exists, 'Cannot insert existing target platform'
+        assert self.config, 'Target platform needs to be associated with a ' \
+                            'configuration'
+        assert self.name, 'Target platform requires a name'
+
+        cursor = db.cursor()
+        cursor.execute("INSERT INTO bitten_platform (config,name) "
+                       "VALUES (%s,%s)", (self.config, self.name))
+        self.id = db.get_last_id('bitten_platform')
+        cursor.executemany("INSERT INTO bitten_rule VALUES (%s,%s,%s,%s)",
+                           [(self.id, propname, pattern, idx) for
+                            idx, (propname, pattern) in enumerate(self.rules)])
+
+        if handle_ta:
+            db.commit()
+
+    def update(self, db=None):
+        assert self.exists, 'Cannot update a non-existing platform'
+        assert self.config, 'Target platform needs to be associated with a ' \
+                            'configuration'
+        assert self.name, 'Target platform requires a name'
+        if not db:
+            db = self.env.get_db_cnx()
+            handle_ta = True
+        else:
+            handle_ta = False
+
+        cursor = db.cursor()
+        cursor.execute("UPDATE bitten_platform SET name=%s WHERE id=%s",
+                       (self.name, self.id))
+        cursor.execute("DELETE FROM bitten_rule WHERE id=%s", (self.id))
+        cursor.executemany("INSERT INTO bitten_rule VALUES (%s,%s,%s,%s)",
+                           [(self.id, propname, pattern, idx) for
+                            idx, (propname, pattern) in enumerate(self.rules)])
+
+        if handle_ta:
+            db.commit()
+
+    def select(cls, env, config=None, db=None):
+        if not db:
+            db = env.get_db_cnx()
+
+        where_clauses = []
+        if config is not None:
+            where_clauses.append(("config=%s", config))
+        if where_clauses:
+            where = "WHERE " + " AND ".join([wc[0] for wc in where_clauses])
+        else:
+            where = ""
+
+        cursor = db.cursor()
+        cursor.execute("SELECT id FROM bitten_platform %s ORDER BY name"
+                       % where, [wc[1] for wc in where_clauses])
+        for (id,) in cursor:
+            yield TargetPlatform(env, id)
+    select = classmethod(select)
+
+
 class Build(object):
     """Representation of a build."""
 
-    _table = Table('bitten_build', key='id')[
-        Column('id', auto_increment=True), Column('config'), Column('rev'),
-        Column('rev_time', type='int'), Column('slave'),
-        Column('started', type='int'), Column('stopped', type='int'),
-        Column('status', size='1'),  Index(['config', 'rev', 'slave'])
+    _schema = [
+        Table('bitten_build', key='id')[
+            Column('id', auto_increment=True), Column('config'), Column('rev'),
+            Column('rev_time', type='int'), Column('slave'),
+            Column('started', type='int'), Column('stopped', type='int'),
+            Column('status', size='1'),  Index(['config', 'rev', 'slave'])
+        ],
+        Table('bitten_slave', key=('build', 'propname'))[
+            Column('build', type='int'), Column('propname'), Column('propvalue')
+        ]
     ]
 
+    # Build status codes
     PENDING = 'P'
     IN_PROGRESS = 'I'
     SUCCESS = 'S'
     FAILURE = 'F'
 
+    # Standard slave properties
+    IP_ADDRESS = 'ipnr'
+    MAINTAINER = 'owner'
+    OS_NAME = 'os'
+    OS_FAMILY = 'family'
+    OS_VERSION = 'version'
+    MACHINE = 'machine'
+    PROCESSOR = 'processor'
+
     def __init__(self, env, id=None, db=None):
         self.env = env
+        self.slave_info = {}
         if id is not None:
             self._fetch(id, db)
         else:
@@ -159,6 +282,11 @@
         self.stopped = row[5] and int(row[5])
         self.status = row[6]
 
+        cursor.execute("SELECT propname,propvalue FROM bitten_slave "
+                       "WHERE build=%s", (self.id,))
+        for propname, propvalue in cursor:
+            self.slave_info[propname] = propvalue
+
     exists = property(fget=lambda self: self.id is not None)
     completed = property(fget=lambda self: self.status != Build.IN_PROGRESS)
     successful = property(fget=lambda self: self.status == Build.SUCCESS)
@@ -197,6 +325,10 @@
                        (self.config, self.rev, self.rev_time, self.slave or '',
                         self.started or 0, self.stopped or 0, self.status))
         self.id = db.get_last_id('bitten_build')
+        cursor.executemany("INSERT INTO bitten_slave VALUES (%s,%s,%s)",
+                           [(self.id, name, value) for name, value
+                            in self.slave_info.items()])
+
         if handle_ta:
             db.commit()
 
@@ -260,59 +392,5 @@
     select = classmethod(select)
 
 
-class SlaveInfo(object):
-    _table = Table('bitten_slave', key=('build', 'propname'))[
-        Column('build', type='int'), Column('propname'), Column('propvalue')
-    ]
-
-    # Standard properties
-    IP_ADDRESS = 'ipnr'
-    MAINTAINER = 'owner'
-    OS_NAME = 'os'
-    OS_FAMILY = 'family'
-    OS_VERSION = 'version'
-    MACHINE = 'machine'
-    PROCESSOR = 'processor'
-
-    def __init__(self, env, build=None, db=None):
-        self.env = env
-        self.properties = {}
-        if build:
-            self._fetch(build, db)
-        else:
-            self.build = None
-
-    def _fetch(self, build, db=None):
-        if not db:
-            db = self.env.get_db_cnx()
-
-        cursor = db.cursor()
-        cursor.execute("SELECT propname,propvalue FROM bitten_slave "
-                       "WHERE build=%s", (build,))
-        for propname, propvalue in cursor:
-            self.properties[propname] = propvalue
-        if not self.properties:
-            raise Exception, "Slave info for %s not found" % build
-        self.build = build
-
-    def __getitem__(self, name):
-        return self.properties[name]
-
-    def __setitem__(self, name, value):
-        self.properties[name] = value
-
-    def insert(self, db=None):
-        if not db:
-            db = self.env.get_db_cnx()
-            handle_ta = True
-        else:
-            handle_ta = False
-
-        assert self.build
-
-        cursor = db.cursor()
-        cursor.executemany("INSERT INTO bitten_slave VALUES (%s,%s,%s)",
-                           [(self.build, name, value) for name, value
-                            in self.properties.items()])
-        if handle_ta:
-            db.commit()
+schema = BuildConfig._schema + TargetPlatform._schema + Build._schema
+schema_version = 1
--- a/bitten/tests/__init__.py
+++ b/bitten/tests/__init__.py
@@ -22,11 +22,13 @@
 
 from bitten.tests import model, recipe
 from bitten.util import tests as util
+from bitten.trac_ext import tests as trac_ext
 
 def suite():
     suite = unittest.TestSuite()
     suite.addTest(model.suite())
     suite.addTest(recipe.suite())
+    suite.addTest(trac_ext.suite())
     suite.addTest(util.suite())
     return suite
 
--- a/bitten/tests/model.py
+++ b/bitten/tests/model.py
@@ -21,7 +21,7 @@
 import unittest
 
 from trac.test import EnvironmentStub
-from bitten.model import Build, BuildConfig, SlaveInfo
+from bitten.model import Build, BuildConfig, TargetPlatform
 
 
 class BuildConfigTestCase(unittest.TestCase):
@@ -30,7 +30,8 @@
         self.env = EnvironmentStub()
         db = self.env.get_db_cnx()
         cursor = db.cursor()
-        cursor.execute(db.to_sql(BuildConfig._table))
+        for table in BuildConfig._schema:
+            cursor.execute(db.to_sql(table))
         db.commit()
 
     def test_new_config(self):
@@ -55,27 +56,86 @@
         self.assertRaises(AssertionError, config.insert)
 
 
+class TargetPlatformTestCase(unittest.TestCase):
+
+    def setUp(self):
+        self.env = EnvironmentStub()
+        db = self.env.get_db_cnx()
+        cursor = db.cursor()
+        for table in TargetPlatform._schema:
+            cursor.execute(db.to_sql(table))
+        db.commit()
+
+    def test_new(self):
+        platform = TargetPlatform(self.env)
+        self.assertEqual(False, platform.exists)
+        self.assertEqual([], platform.rules)
+
+    def test_insert(self):
+        platform = TargetPlatform(self.env)
+        platform.config = 'test'
+        platform.name = 'Windows XP'
+        platform.rules.append((Build.OS_NAME, 'Windows'))
+        platform.rules.append((Build.OS_VERSION, 'XP'))
+        platform.insert()
+
+        assert platform.exists
+        db = self.env.get_db_cnx()
+        cursor = db.cursor()
+        cursor.execute("SELECT config,name FROM bitten_platform "
+                       "WHERE id=%s", (platform.id,))
+        self.assertEqual(('test', 'Windows XP'), cursor.fetchone())
+
+        cursor.execute("SELECT propname,pattern,orderno FROM bitten_rule "
+                       "WHERE id=%s", (platform.id,))
+        self.assertEqual((Build.OS_NAME, 'Windows', 0), cursor.fetchone())
+        self.assertEqual((Build.OS_VERSION, 'XP', 1), cursor.fetchone())
+
+    def test_fetch(self):
+        db = self.env.get_db_cnx()
+        cursor = db.cursor()
+        cursor.execute("INSERT INTO bitten_platform (config,name) "
+                       "VALUES (%s,%s)", ('test', 'Windows'))
+        id = db.get_last_id('bitten_platform')
+        platform = TargetPlatform(self.env, id)
+        assert platform.exists
+        self.assertEqual('test', platform.config)
+        self.assertEqual('Windows', platform.name)
+
+    def test_select(self):
+        db = self.env.get_db_cnx()
+        cursor = db.cursor()
+        cursor.executemany("INSERT INTO bitten_platform (config,name) "
+                           "VALUES (%s,%s)", [('test', 'Windows'),
+                           ('test', 'Mac OS X')])
+        platforms = list(TargetPlatform.select(self.env, config='test'))
+        self.assertEqual(2, len(platforms))
+
+
 class BuildTestCase(unittest.TestCase):
 
     def setUp(self):
         self.env = EnvironmentStub()
         db = self.env.get_db_cnx()
         cursor = db.cursor()
-        cursor.execute(db.to_sql(Build._table))
+        for table in Build._schema:
+            cursor.execute(db.to_sql(table))
         db.commit()
 
-    def test_new_build(self):
+    def test_new(self):
         build = Build(self.env)
         self.assertEqual(None, build.id)
         self.assertEqual(Build.PENDING, build.status)
         self.assertEqual(0, build.stopped)
         self.assertEqual(0, build.started)
 
-    def test_insert_build(self):
+    def test_insert(self):
         build = Build(self.env)
         build.config = 'test'
         build.rev = '42'
         build.rev_time = 12039
+        build.slave_info[Build.IP_ADDRESS] = '127.0.0.1'
+        build.slave_info[Build.MAINTAINER] = 'joe@example.org'
         build.insert()
 
         db = self.env.get_db_cnx()
@@ -84,7 +144,13 @@
                        "FROM bitten_build WHERE id=%s" % build.id)
         self.assertEqual(('test', '42', '', 0, 0, 'P'), cursor.fetchone())
 
-    def test_insert_build_no_config_or_rev_or_rev_time(self):
+        cursor.execute("SELECT propname,propvalue FROM bitten_slave")
+        expected = {Build.IP_ADDRESS: '127.0.0.1',
+                    Build.MAINTAINER: 'joe@example.org'}
+        for propname, propvalue in cursor:
+            self.assertEqual(expected[propname], propvalue)
+
+    def test_insert_no_config_or_rev_or_rev_time(self):
         build = Build(self.env)
         self.assertRaises(AssertionError, build.insert)
 
@@ -103,7 +169,7 @@
         build.rev = '42'
         self.assertRaises(AssertionError, build.insert)
 
-    def test_insert_build_no_slave(self):
+    def test_insert_no_slave(self):
         build = Build(self.env)
         build.config = 'test'
         build.rev = '42'
@@ -125,49 +191,29 @@
         build.status = 'DUNNO'
         self.assertRaises(AssertionError, build.insert)
 
-
-class SlaveInfoTestCase(unittest.TestCase):
-
-    def setUp(self):
-        self.env = EnvironmentStub()
+    def test_fetch(self):
         db = self.env.get_db_cnx()
         cursor = db.cursor()
-        cursor.execute(db.to_sql(SlaveInfo._table))
-        db.commit()
-
-    def test_insert_slave_info(self):
-        slave_info = SlaveInfo(self.env)
-        slave_info.build = 42
-        slave_info[SlaveInfo.IP_ADDRESS] = '127.0.0.1'
-        slave_info[SlaveInfo.MAINTAINER] = 'joe@example.org'
-        slave_info.insert()
+        cursor.execute("INSERT INTO bitten_build (config,rev,rev_time,slave,"
+                       "started,stopped,status) VALUES (%s,%s,%s,%s,%s,%s,%s)",
+                       ('test', '42', 12039, 'tehbox', 15006, 16007,
+                        Build.SUCCESS))
+        build_id = db.get_last_id('bitten_build')
+        cursor.executemany("INSERT INTO bitten_slave VALUES (%s,%s,%s)",
+                           [(build_id, Build.IP_ADDRESS, '127.0.0.1'),
+                            (build_id, Build.MAINTAINER, 'joe@example.org')])
 
-        db = self.env.get_db_cnx()
-        cursor = db.cursor()
-        cursor.execute("SELECT propname,propvalue FROM bitten_slave")
-        expected = {SlaveInfo.IP_ADDRESS: '127.0.0.1',
-                    SlaveInfo.MAINTAINER: 'joe@example.org'}
-        for propname, propvalue in cursor:
-            self.assertEqual(expected[propname], propvalue)
-
-    def test_fetch_slave_info(self):
-        db = self.env.get_db_cnx()
-        cursor = db.cursor()
-        cursor.executemany("INSERT INTO bitten_slave VALUES (42,%s,%s)",
-                           [(SlaveInfo.IP_ADDRESS, '127.0.0.1'),
-                            (SlaveInfo.MAINTAINER, 'joe@example.org')])
-
-        slave_info = SlaveInfo(self.env, 42)
-        self.assertEquals(42, slave_info.build)
-        self.assertEquals('127.0.0.1', slave_info[SlaveInfo.IP_ADDRESS])
-        self.assertEquals('joe@example.org', slave_info[SlaveInfo.MAINTAINER])
+        build = Build(self.env, build_id)
+        self.assertEquals(build_id, build.id)
+        self.assertEquals('127.0.0.1', build.slave_info[Build.IP_ADDRESS])
+        self.assertEquals('joe@example.org', build.slave_info[Build.MAINTAINER])
 
 
 def suite():
     suite = unittest.TestSuite()
     suite.addTest(unittest.makeSuite(BuildConfigTestCase, 'test'))
+    suite.addTest(unittest.makeSuite(TargetPlatformTestCase, 'test'))
     suite.addTest(unittest.makeSuite(BuildTestCase, 'test'))
-    suite.addTest(unittest.makeSuite(SlaveInfoTestCase, 'test'))
     return suite
 
 if __name__ == '__main__':
--- a/bitten/trac_ext/main.py
+++ b/bitten/trac_ext/main.py
@@ -23,7 +23,7 @@
 from trac.core import *
 from trac.env import IEnvironmentSetupParticipant
 from trac.perm import IPermissionRequestor
-from bitten.model import Build, BuildConfig, SlaveInfo, schema_version
+from bitten.model import *
 from bitten.trac_ext import web_ui
 
 class BuildSystem(Component):
@@ -36,7 +36,8 @@
         # Create the required tables
         db = self.env.get_db_cnx()
         cursor = db.cursor()
-        for table in [Build._table, BuildConfig._table, SlaveInfo._table]:
+        for table in [Build._table, BuildConfig._table, SlaveInfo._table] \
+                   + TargetPlatform._schema:
             cursor.execute(db.to_sql(table))
 
         tarballs_dir = os.path.join(self.env.path, 'snapshots')
new file mode 100644
--- /dev/null
+++ b/bitten/trac_ext/tests/__init__.py
@@ -0,0 +1,31 @@
+# -*- coding: iso8859-1 -*-
+#
+# Copyright (C) 2005 Christopher Lenz <cmlenz@gmx.de>
+#
+# Bitten is free software; you can redistribute it and/or
+# modify it under the terms of the GNU General Public License as
+# published by the Free Software Foundation; either version 2 of the
+# License, or (at your option) any later version.
+#
+# Trac is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+# General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program; if not, write to the Free Software
+# Foundation, Inc., 675 Mass Ave, Cambridge, MA 02139, USA.
+#
+# Author: Christopher Lenz <cmlenz@gmx.de>
+
+import unittest
+
+from bitten.trac_ext.tests import web_ui
+
+def suite():
+    suite = unittest.TestSuite()
+    suite.addTest(web_ui.suite())
+    return suite
+
+if __name__ == '__main__':
+    unittest.main(defaultTest='suite')
new file mode 100644
--- /dev/null
+++ b/bitten/trac_ext/tests/web_ui.py
@@ -0,0 +1,337 @@
+import unittest
+
+from trac.perm import PermissionCache, PermissionSystem
+from trac.test import EnvironmentStub, Mock
+from trac.versioncontrol import Repository
+from trac.web.clearsilver import HDFWrapper
+from trac.web.main import Request, RequestDone
+from bitten.model import BuildConfig, TargetPlatform, Build, schema
+from bitten.trac_ext.main import BuildSystem
+from bitten.trac_ext.web_ui import BuildModule
+
+
+class BuildModuleTestCase(unittest.TestCase):
+
+    def setUp(self):
+        self.env = EnvironmentStub()
+
+        # Create tables
+        db = self.env.get_db_cnx()
+        cursor = db.cursor()
+        for table in schema:
+            cursor.execute(db.to_sql(table))
+
+        # Set up permissions
+        self.env.config.set('trac', 'permission_store',
+                            'DefaultPermissionStore')
+
+        # Hook up a dummy repository
+        repos = Mock(get_node=lambda path: Mock(get_history=lambda: []))
+        self.env.get_repository = lambda x: repos
+
+    def test_overview(self):
+        PermissionSystem(self.env).grant_permission('joe', 'BUILD_VIEW')
+        req = Mock(Request, path_info='/build', args={}, hdf=HDFWrapper(),
+                   perm=PermissionCache(self.env, 'joe'))
+
+        module = BuildModule(self.env)
+        assert module.match_request(req)
+        module.process_request(req)
+
+        self.assertEqual('overview', req.hdf['build.mode'])
+        self.assertEqual('0', req.hdf.get('build.can_create', '0'))
+
+    def test_overview_admin(self):
+        PermissionSystem(self.env).grant_permission('joe', 'BUILD_ADMIN')
+        req = Mock(Request, path_info='/build', args={}, hdf=HDFWrapper(),
+                   perm=PermissionCache(self.env, 'joe'))
+
+        module = BuildModule(self.env)
+        assert module.match_request(req)
+        module.process_request(req)
+
+        self.assertEqual('1', req.hdf.get('build.can_create'))
+
+    def test_view_config(self):
+        config = BuildConfig(self.env)
+        config.name = 'test'
+        config.insert()
+
+        PermissionSystem(self.env).grant_permission('joe', 'BUILD_VIEW')
+        req = Mock(Request, path_info='/build/test', args={}, hdf=HDFWrapper(),
+                   perm=PermissionCache(self.env, 'joe'))
+
+        module = BuildModule(self.env)
+        assert module.match_request(req)
+        module.process_request(req)
+
+        self.assertEqual('view_config', req.hdf['build.mode'])
+        self.assertEqual('0', req.hdf.get('build.config.can_modify', '0'))
+
+    def test_view_config_admin(self):
+        config = BuildConfig(self.env)
+        config.name = 'test'
+        config.insert()
+
+        PermissionSystem(self.env).grant_permission('joe', 'BUILD_ADMIN')
+        req = Mock(Request, path_info='/build/test', args={}, hdf=HDFWrapper(),
+                   perm=PermissionCache(self.env, 'joe'))
+
+        module = BuildModule(self.env)
+        assert module.match_request(req)
+        module.process_request(req)
+
+        self.assertEqual('1', req.hdf.get('build.config.can_modify'))
+
+    def test_new_config(self):
+        PermissionSystem(self.env).grant_permission('joe', 'BUILD_ADMIN')
+        req = Mock(Request, path_info='/build', args={'action': 'new'},
+                   hdf=HDFWrapper(), perm=PermissionCache(self.env, 'joe'))
+
+        module = BuildModule(self.env)
+        assert module.match_request(req)
+        module.process_request(req)
+
+        self.assertEqual('edit_config', req.hdf['build.mode'])
+
+    def test_new_config_submit(self):
+        PermissionSystem(self.env).grant_permission('joe', 'BUILD_ADMIN')
+        redirected_to = []
+        def redirect(url):
+            redirected_to.append(url)
+            raise RequestDone
+        req = Mock(Request, method='POST', path_info='/build',
+                   redirect=redirect, hdf=HDFWrapper(),
+                   perm=PermissionCache(self.env, 'joe'),
+                   args={'action': 'new', 'name': 'test', 'active': 'on',
+                         'label': 'Test', 'path': 'test/trunk',
+                         'description': 'Bla bla'})
+
+        module = BuildModule(self.env)
+        assert module.match_request(req)
+        self.assertRaises(RequestDone, module.process_request, req)
+        self.assertEqual('/trac.cgi/build/test', redirected_to[0])
+
+        build = BuildConfig(self.env, 'test')
+        assert build.exists
+        assert build.active
+        self.assertEqual('Test', build.label)
+        self.assertEqual('test/trunk', build.path)
+        self.assertEqual('Bla bla', build.description)
+
+    def test_new_config_cancel(self):
+        PermissionSystem(self.env).grant_permission('joe', 'BUILD_ADMIN')
+        redirected_to = []
+        def redirect(url):
+            redirected_to.append(url)
+            raise RequestDone
+        req = Mock(Request, method='POST', path_info='/build',
+                   redirect=redirect, hdf=HDFWrapper(),
+                   perm=PermissionCache(self.env, 'joe'),
+                   args={'action': 'new', 'cancel': '1', 'name': 'test'})
+
+        module = BuildModule(self.env)
+        assert module.match_request(req)
+        self.assertRaises(RequestDone, module.process_request, req)
+        self.assertEqual('/trac.cgi/build', redirected_to[0])
+
+        self.assertRaises(Exception, BuildConfig, self.env, 'test')
+
+    def test_edit_config(self):
+        config = BuildConfig(self.env)
+        config.name = 'test'
+        config.insert()
+
+        PermissionSystem(self.env).grant_permission('joe', 'BUILD_ADMIN')
+        req = Mock(Request, path_info='/build/test', args={'action': 'edit'},
+                   hdf=HDFWrapper(), perm=PermissionCache(self.env, 'joe'))
+
+        module = BuildModule(self.env)
+        assert module.match_request(req)
+        module.process_request(req)
+
+        self.assertEqual('edit_config', req.hdf['build.mode'])
+
+    def test_edit_config_submit(self):
+        config = BuildConfig(self.env)
+        config.name = 'test'
+        config.insert()
+
+        PermissionSystem(self.env).grant_permission('joe', 'BUILD_ADMIN')
+        redirected_to = []
+        def redirect(url):
+            redirected_to.append(url)
+            raise RequestDone
+        req = Mock(Request, method='POST', path_info='/build/test',
+                   redirect=redirect, hdf=HDFWrapper(),
+                   perm=PermissionCache(self.env, 'joe'),
+                   args={'action': 'edit', 'name': 'foo', 'active': 'on',
+                         'label': 'Test', 'path': 'test/trunk',
+                         'description': 'Bla bla'})
+
+        module = BuildModule(self.env)
+        assert module.match_request(req)
+        self.assertRaises(RequestDone, module.process_request, req)
+        self.assertEqual('/trac.cgi/build/foo', redirected_to[0])
+
+        self.assertRaises(Exception, BuildConfig, self.env, 'test')
+
+        build = BuildConfig(self.env, 'foo')
+        assert build.exists
+        assert build.active
+        self.assertEqual('Test', build.label)
+        self.assertEqual('test/trunk', build.path)
+        self.assertEqual('Bla bla', build.description)
+
+    def test_edit_config_cancel(self):
+        config = BuildConfig(self.env)
+        config.name = 'test'
+        config.insert()
+
+        PermissionSystem(self.env).grant_permission('joe', 'BUILD_ADMIN')
+        redirected_to = []
+        def redirect(url):
+            redirected_to.append(url)
+            raise RequestDone
+        req = Mock(Request, method='POST', path_info='/build/test',
+                   redirect=redirect, hdf=HDFWrapper(),
+                   perm=PermissionCache(self.env, 'joe'),
+                   args={'action': 'edit', 'cancel': '1'})
+
+        module = BuildModule(self.env)
+        assert module.match_request(req)
+        self.assertRaises(RequestDone, module.process_request, req)
+        self.assertEqual('/trac.cgi/build/test', redirected_to[0])
+
+    def test_new_platform(self):
+        config = BuildConfig(self.env)
+        config.name = 'test'
+        config.insert()
+
+        PermissionSystem(self.env).grant_permission('joe', 'BUILD_ADMIN')
+        req = Mock(Request, path_info='/build/test', args={'action': 'new'},
+                   hdf=HDFWrapper(), perm=PermissionCache(self.env, 'joe'))
+
+        module = BuildModule(self.env)
+        assert module.match_request(req)
+        module.process_request(req)
+
+        self.assertEqual('edit_platform', req.hdf['build.mode'])
+
+    def test_new_platform_submit(self):
+        config = BuildConfig(self.env)
+        config.name = 'test'
+        config.insert()
+
+        PermissionSystem(self.env).grant_permission('joe', 'BUILD_ADMIN')
+        redirected_to = []
+        def redirect(url):
+            redirected_to.append(url)
+            raise RequestDone
+        req = Mock(Request, method='POST', path_info='/build/test',
+                   redirect=redirect, args={'action': 'new', 'name': 'Test'},
+                   hdf=HDFWrapper(), perm=PermissionCache(self.env, 'joe'))
+
+        module = BuildModule(self.env)
+        assert module.match_request(req)
+        self.assertRaises(RequestDone, module.process_request, req)
+        self.assertEqual('/trac.cgi/build/test?action=edit', redirected_to[0])
+
+    def test_new_platform_cancel(self):
+        config = BuildConfig(self.env)
+        config.name = 'test'
+        config.insert()
+
+        PermissionSystem(self.env).grant_permission('joe', 'BUILD_ADMIN')
+        redirected_to = []
+        def redirect(url):
+            redirected_to.append(url)
+            raise RequestDone
+        req = Mock(Request, method='POST', path_info='/build/test',
+                   redirect=redirect, args={'action': 'new', 'cancel': '1'},
+                   hdf=HDFWrapper(), perm=PermissionCache(self.env, 'joe'))
+
+        module = BuildModule(self.env)
+        assert module.match_request(req)
+        self.assertRaises(RequestDone, module.process_request, req)
+        self.assertEqual('/trac.cgi/build/test?action=edit', redirected_to[0])
+
+    def test_edit_platform(self):
+        config = BuildConfig(self.env)
+        config.name = 'test'
+        config.insert()
+        platform = TargetPlatform(self.env)
+        platform.config = 'test'
+        platform.name = 'linux'
+        platform.rules.append(('os', 'linux?'))
+        platform.insert()
+
+        PermissionSystem(self.env).grant_permission('joe', 'BUILD_ADMIN')
+        req = Mock(Request, path_info='/build/test',
+                   args={'action': 'edit', 'platform': platform.id},
+                   hdf=HDFWrapper(), perm=PermissionCache(self.env, 'joe'))
+
+        module = BuildModule(self.env)
+        assert module.match_request(req)
+        module.process_request(req)
+
+        self.assertEqual('edit_platform', req.hdf['build.mode'])
+
+    def test_edit_platform_submit(self):
+        config = BuildConfig(self.env)
+        config.name = 'test'
+        config.insert()
+        platform = TargetPlatform(self.env)
+        platform.config = 'test'
+        platform.name = 'linux'
+        platform.rules.append(('os', 'linux?'))
+        platform.insert()
+
+        PermissionSystem(self.env).grant_permission('joe', 'BUILD_ADMIN')
+        redirected_to = []
+        def redirect(url):
+            redirected_to.append(url)
+            raise RequestDone
+        req = Mock(Request, method='POST', path_info='/build/test',
+                   args={'action': 'edit', 'platform': platform.id,
+                         'name': 'Test'},
+                   redirect=redirect, hdf=HDFWrapper(),
+                   perm=PermissionCache(self.env, 'joe'))
+
+        module = BuildModule(self.env)
+        assert module.match_request(req)
+        self.assertRaises(RequestDone, module.process_request, req)
+        self.assertEqual('/trac.cgi/build/test?action=edit', redirected_to[0])
+
+    def test_edit_platform_cancel(self):
+        config = BuildConfig(self.env)
+        config.name = 'test'
+        config.insert()
+        platform = TargetPlatform(self.env)
+        platform.config = 'test'
+        platform.name = 'linux'
+        platform.rules.append(('os', 'linux'))
+        platform.insert()
+
+        PermissionSystem(self.env).grant_permission('joe', 'BUILD_ADMIN')
+        redirected_to = []
+        def redirect(url):
+            redirected_to.append(url)
+            raise RequestDone
+        req = Mock(Request, method='POST', path_info='/build/test',
+                   args={'action': 'edit', 'platform': platform.id,
+                         'cancel': '1'},
+                   redirect=redirect, hdf=HDFWrapper(),
+                   perm=PermissionCache(self.env, 'joe'))
+
+        module = BuildModule(self.env)
+        assert module.match_request(req)
+        self.assertRaises(RequestDone, module.process_request, req)
+        self.assertEqual('/trac.cgi/build/test?action=edit', redirected_to[0])
+
+
+def suite():
+    return unittest.makeSuite(BuildModuleTestCase, 'test')
+
+if __name__ == '__main__':
+    unittest.main(defaultTest='suite')
--- a/bitten/trac_ext/web_ui.py
+++ b/bitten/trac_ext/web_ui.py
@@ -24,10 +24,10 @@
 from trac.core import *
 from trac.Timeline import ITimelineEventProvider
 from trac.util import escape, pretty_timedelta
-from trac.web.chrome import INavigationContributor
+from trac.web.chrome import INavigationContributor, add_link
 from trac.web.main import IRequestHandler
 from trac.wiki import wiki_to_html
-from bitten.model import Build, BuildConfig, SlaveInfo
+from bitten.model import Build, BuildConfig, TargetPlatform
 
 
 class BuildModule(Component):
@@ -50,34 +50,38 @@
    if:build.can_create ?><div class="buttons">
     <form method="get" action=""><div>
      <input type="hidden" name="action" value="new" />
-     <input type="submit" value="Add new configuration" />
-    </div></form><?cs
+     <input type="submit" value="Add configuration" />
+    </div></form></div><?cs
    /if ?></div><?cs
 
   elif:build.mode == 'edit_config' ?>
-   <form method="post" action="">
-    <div class="field"><label>Name:<br />
-     <input type="text" name="name" value="<?cs var:build.config.name ?>" />
-    </label></div>
-    <div class="field"><label>Label (for display):<br />
-     <input type="text" name="label" size="32" value="<?cs
-       var:build.config.label ?>" />
-    </label></div>
-    <div class="field"><label><input type="checkbox" name="active"<?cs
-      if:build.config.active ?> checked="checked" <?cs /if ?>/> Active
-    </label></div>
-    <div class="field"><label>Repository path:<br />
-     <input type="text" name="path" size="48" value="<?cs
-       var:build.config.path ?>" />
-    </label></div>
-    <div class="field"><fieldset class="iefix">
-     <label for="description">Description (you may use <a tabindex="42" href="<?cs
-       var:trac.href.wiki ?>/WikiFormatting">WikiFormatting</a> here):</label>
-     <p><textarea id="description" name="description" class="wikitext" rows="10" cols="78"><?cs
-       var:build.config.description ?></textarea></p>
-     <script type="text/javascript" src="<?cs
-       var:htdocs_location ?>js/wikitoolbar.js"></script>
-    </fieldset></div>
+   <form class="config" method="post" action="">
+    <table><tr>
+     <td class="name"><label>Name:<br />
+      <input type="text" name="name" value="<?cs var:build.config.name ?>" />
+     </label></td>
+     <td class="label"><label>Label (for display):<br />
+      <input type="text" name="label" size="32" value="<?cs
+        var:build.config.label ?>" />
+     </label></td>
+    </tr><tr>
+     <td class="active"><label><input type="checkbox" name="active"<?cs
+       if:build.config.active ?> checked="checked" <?cs /if ?>/> Active
+     </label></td>
+     <td class="path"><label>Repository path:<br />
+      <input type="text" name="path" size="48" value="<?cs
+        var:build.config.path ?>" />
+     </label></td>
+    </tr><tr>
+     <td colspan="2"><fieldset class="iefix">
+      <label for="description">Description (you may use <a tabindex="42" href="<?cs
+        var:trac.href.wiki ?>/WikiFormatting">WikiFormatting</a> here):</label>
+      <p><textarea id="description" name="description" class="wikitext" rows="5" cols="78"><?cs
+        var:build.config.description ?></textarea></p>
+      <script type="text/javascript" src="<?cs
+        var:htdocs_location ?>js/wikitoolbar.js"></script>
+     </fieldset></td>
+    </tr></table>
     <div class="buttons">
      <input type="hidden" name="action" value="<?cs
        if:build.config.exists ?>edit<?cs else ?>new<?cs /if ?>" />
@@ -86,6 +90,21 @@
        if:build.config.exists ?>Save changes<?cs else ?>Create<?cs /if ?>" />
     </div>
    </form><?cs
+   if:build.config.exists ?><div class="platforms">
+    <h2>Target Platforms</h2><?cs
+     if:len(build.platforms) ?><ul><?cs
+      each:platform = build.platforms ?><li><a href="<?cs
+       var:platform.href ?>"><?cs var:platform.name ?></a></li><?cs
+      /each ?></ul><?cs
+     /if ?>
+    <div class="buttons">
+     <form method="get" action=""><div>
+      <input type="hidden" name="action" value="new" />
+      <input type="submit" value="Add target platform" />
+     </div></form>
+    </div>
+   </div><?cs
+   /if ?><?cs
 
   elif:build.mode == 'view_config' ?><ul>
    <li>Active: <?cs if:build.config.active ?>yes<?cs else ?>no<?cs /if ?></li>
@@ -94,20 +113,56 @@
      var:build.config.path ?></a></li><?cs /if ?></ul><?cs
    if:build.config.description ?><div class="description"><?cs
      var:build.config.description ?></div><?cs /if ?><?cs
-   if:build.can_modify ?><div class="buttons">
+   if:build.config.can_modify ?><div class="buttons">
     <form method="get" action=""><div>
      <input type="hidden" name="action" value="edit" />
      <input type="submit" value="Edit configuration" />
     </div></form><?cs
    /if ?></div><?cs
 
+  elif:build.mode == 'edit_platform' ?>
+   <form class="platform" method="post" action="">
+    <div class="field"><label>Name:<br />
+     <input type="text" name="name" value="<?cs var:build.platform.name ?>" />
+    </label></div>
+    <h2>Rules</h2>
+    <table><thead><tr>
+     <th>Property name</th><th>Match pattern</th>
+    </tr></thead><tbody><?cs
+     each:rule = build.platform.rules ?><tr>
+      <td><input type="text" name="property_<?cs var:name(rule) ?>" value="<?cs
+       var:rule.property ?>" /></td>
+      <td><input type="text" name="pattern_<?cs var:name(rule) ?>" value="<?cs
+       var:rule.pattern ?>" /></td>
+      <td><input type="submit" name="rm_rule_<?cs
+        var:name(rule) ?>" value="-" /><input type="submit" name="add_rule_<?cs
+        var:name(rule) ?>" value="+" />
+      </td>
+     </tr><?cs /each ?>
+    </tbody></table>
+    <div class="buttons">
+     <form method="get" action=""><div>
+     <input type="hidden" name="action" value="<?cs
+       if:build.platform.exists ?>edit<?cs else ?>new<?cs /if ?>" />
+      <input type="hidden" name="platform" value="<?cs
+       var:build.platform.id ?>" />
+      <input type="submit" name="cancel" value="Cancel" />
+      <input type="submit" value="<?cs
+       if:build.platform.exists ?>Save changes<?cs else ?>Add platform<?cs
+       /if ?>" />
+     </div></form>
+    </div>
+   </form><?cs
+
   elif:build.mode == 'view_build' ?>
    <p class="trigger">Triggered by: Changeset <a href="<?cs
      var:build.chgset_href ?>">[<?cs var:build.rev ?>]</a> of <a href="<?cs
      var:build.config.href ?>"><?cs var:build.config.name ?></a></p>
-   <p class="slave">Built by: <strong><?cs
-     var:build.slave.name ?></strong> (<?cs var:build.slave.os ?> <?cs
-     var:build.slave.os.version ?> on <?cs var:build.slave.machine ?>)</p>
+   <p class="slave">Built by: <strong title="<?cs
+     var:build.slave.ip_address ?>"><?cs var:build.slave.name ?></strong> (<?cs
+     var:build.slave.os ?> <?cs var:build.slave.os.version ?><?cs
+     if:build.slave.machien ?> on <?cs var:build.slave.machine ?><?cs
+     /if ?>)</p>
    <p class="time">Completed: <?cs var:build.started ?> (<?cs
      var:build.started_delta ?> ago)<br />Took: <?cs var:build.duration ?></p><?cs
   /if ?>
@@ -135,7 +190,7 @@
     # IRequestHandler methods
 
     def match_request(self, req):
-        match = re.match(r'/build(?:/([\w.-]+))?(?:/([\w.-]+))?', req.path_info)
+        match = re.match(r'/build(?:/([\w.-]+))?(?:/([\d]+))?', req.path_info)
         if match:
             if match.group(1):
                 req.args['config'] = match.group(1)
@@ -151,23 +206,41 @@
         id = req.args.get('id')
 
         if req.method == 'POST':
-            if not config and action == 'new':
-                self._do_create(req)
-            elif config and action == 'edit':
-                self._do_save(req, config)
+            if config:
+                if action == 'new':
+                    self._do_create_platform(req, config)
+                else:
+                    platform_id = req.args.get('platform')
+                    if platform_id:
+                        if action == 'edit':
+                            self._do_save_platform(req, config, platform_id)
+                    else:
+                        self._do_save_config(req, config)
+            else:
+                if action == 'new':
+                    self._do_create_config(req)
         else:
-            if not config:
+            if id:
+                self._render_build(req, id)
+            elif config:
+                if action == 'edit':
+                    platform_id = req.args.get('platform')
+                    if platform_id:
+                        platform = TargetPlatform(self.env, int(platform_id))
+                        self._render_platform_form(req, platform)
+                    else:
+                        self._render_config_form(req, config)
+                elif action == 'new':
+                    platform = TargetPlatform(self.env)
+                    platform.config = config
+                    self._render_platform_form(req, platform)
+                else:
+                    self._render_config(req, config)
+            else:
                 if action == 'new':
                     self._render_config_form(req)
                 else:
                     self._render_overview(req)
-            elif not id:
-                if action == 'edit':
-                    self._render_config_form(req, config)
-                else:
-                    self._render_config(req, config)
-            else:
-                self._render_build(req, config, id)
 
         return req.hdf.parse(self.build_cs), None
 
@@ -198,7 +271,7 @@
 
     # Internal methods
 
-    def _do_create(self, req):
+    def _do_create_config(self, req):
         """Create a new build configuration."""
         req.perm.assert_permission('BUILD_CREATE')
 
@@ -215,7 +288,7 @@
 
         req.redirect(self.env.href.build(config.name))
 
-    def _do_save(self, req, config_name):
+    def _do_save_config(self, req, config_name):
         """Save changes to a build configuration."""
         req.perm.assert_permission('BUILD_MODIFY')
 
@@ -232,6 +305,81 @@
 
         req.redirect(self.env.href.build(config.name))
 
+    def _do_create_platform(self, req, config_name):
+        """Create a new target platform."""
+        req.perm.assert_permission('BUILD_MODIFY')
+
+        if 'cancel' in req.args.keys():
+            req.redirect(self.env.href.build(config_name, action='edit'))
+
+        platform = TargetPlatform(self.env)
+        platform.config = config_name
+        platform.name = req.args.get('name')
+
+        properties = [int(key[9:]) for key in req.args.keys()
+                      if key.startswith('property_')]
+        properties.sort()
+        patterns = [int(key[8:]) for key in req.args.keys()
+                    if key.startswith('pattern_')]
+        patterns.sort()
+        platform.rules = [(req.args.get('property_%d' % property),
+                           req.args.get('pattern_%d' % pattern))
+                          for property, pattern in zip(properties, patterns)]
+
+        add_rules = [int(key[9:]) for key in req.args.keys()
+                     if key.startswith('add_rule_')]
+        if add_rules:
+            platform.rules.insert(add_rules[0] + 1, ('', ''))
+            self._render_platform_form(req, platform)
+            return
+        rm_rules = [int(key[8:]) for key in req.args.keys()
+                     if key.startswith('rm_rule_')]
+        if rm_rules:
+            del platform.rules[rm_rules[0]]
+            self._render_platform_form(req, platform)
+            return
+
+        platform.insert()
+
+        req.redirect(self.env.href.build(config_name, action='edit'))
+
+    def _do_save_platform(self, req, config_name, platform_id):
+        """Save changes to a target platform."""
+        req.perm.assert_permission('BUILD_MODIFY')
+
+        if 'cancel' in req.args.keys():
+            req.redirect(self.env.href.build(config_name, action='edit'))
+
+        platform = TargetPlatform(self.env, platform_id)
+        platform.name = req.args.get('name')
+
+        properties = [int(key[9:]) for key in req.args.keys()
+                      if key.startswith('property_')]
+        properties.sort()
+        patterns = [int(key[8:]) for key in req.args.keys()
+                    if key.startswith('pattern_')]
+        patterns.sort()
+        platform.rules = [(req.args.get('property_%d' % property),
+                           req.args.get('pattern_%d' % pattern))
+                          for property, pattern in zip(properties, patterns)]
+
+        add_rules = [int(key[9:]) for key in req.args.keys()
+                     if key.startswith('add_rule_')]
+        if add_rules:
+            platform.rules.insert(add_rules[0] + 1, ('', ''))
+            self._render_platform_form(req, platform)
+            return
+        rm_rules = [int(key[8:]) for key in req.args.keys()
+                     if key.startswith('rm_rule_')]
+        if rm_rules:
+            del platform.rules[rm_rules[0]]
+            self._render_platform_form(req, platform)
+            return
+
+        platform.update()
+
+        req.redirect(self.env.href.build(config_name, action='edit'))
+
     def _render_overview(self, req):
         req.hdf['title'] = 'Build Status'
         configurations = BuildConfig.select(self.env, include_inactive=True)
@@ -251,37 +399,73 @@
         config = BuildConfig(self.env, config_name)
         req.hdf['title'] = 'Build Configuration "%s"' \
                            % escape(config.label or config.name)
+        add_link(req, 'up', self.env.href.build(), 'Build Status')
         description = config.description
         if description:
             description = wiki_to_html(description, self.env, req)
         req.hdf['build.config'] = {
             'name': config.name, 'label': config.label, 'path': config.path,
             'active': config.active, 'description': description,
-            'browser_href': self.env.href.browser(config.path)
+            'browser_href': self.env.href.browser(config.path),
+            'can_modify': req.perm.has_permission('BUILD_MODIFY')
         }
+        req.hdf['build.mode'] = 'view_config'
 
-        req.hdf['build.mode'] = 'view_config'
-        req.hdf['build.can_modify'] = req.perm.has_permission('BUILD_MODIFY')
+        repos = self.env.get_repository(req.authname)
+        root = repos.get_node(config.path)
+        num = 0
+        for idx, (path, rev, chg) in enumerate(root.get_history()):
+            prefix = 'build.config.builds.%d' % rev
+            for build in Build.select(self.env, config=config.name, rev=rev):
+                req.hdf[prefix + '.' + build.slave] = self._build_to_hdf(build)
+            if idx > 5:
+                break
 
     def _render_config_form(self, req, config_name=None):
         config = BuildConfig(self.env, config_name)
         if config.exists:
             req.perm.assert_permission('BUILD_MODIFY')
-            req.hdf['title'] = 'Edit Build Configuration "%s"' \
-                               % escape(config.label or config.name)
             req.hdf['build.config'] = {
                 'name': config.name, 'label': config.label, 'path': config.path,
                 'active': config.active, 'description': config.description,
                 'exists': config.exists
             }
+
+            if 'new' in req.args.keys() or 'platform' in req.args.keys():
+                self._render_platform_form(req, config_name,
+                                           req.args.get('platform'))
+                return
+
+            req.hdf['title'] = 'Edit Build Configuration "%s"' \
+                               % escape(config.label or config.name)
+            for idx, platform in enumerate(TargetPlatform.select(self.env,
+                                                                 config_name)):
+                req.hdf['build.platforms.%d' % idx] = {
+                    'id': platform.id, 'name': platform.name,
+                    'href': self.env.href.build(config_name, action='edit',
+                                                platform=platform.id)
+                }
         else:
             req.perm.assert_permission('BUILD_CREATE')
             req.hdf['title'] = 'Create Build Configuration'
         req.hdf['build.mode'] = 'edit_config'
 
-    def _render_build(self, req, config_name, build_id):
+    def _render_platform_form(self, req, platform):
+        req.perm.assert_permission('BUILD_MODIFY')
+        req.hdf['title'] = 'Edit Target Platform "%s"' \
+                           % escape(platform.name)
+        req.hdf['build.platform'] = {
+            'name': platform.name, 'id': platform.id, 'exists': platform.exists,
+            'rules': [{'property': propname, 'pattern': pattern}
+                      for propname, pattern in platform.rules]
+        }
+        req.hdf['build.mode'] = 'edit_platform'
+
+    def _render_build(self, req, build_id):
         build = Build(self.env, build_id)
         assert build.exists
+        add_link(req, 'up', self.env.href.build(build.config),
+                 'Build Configuration')
         status2title = {Build.SUCCESS: 'Success', Build.FAILURE: 'Failure'}
         req.hdf['title'] = 'Build %s - %s' % (build_id,
                                               status2title[build.status])
@@ -294,17 +478,6 @@
             'href': self.env.href.build(config.name)
         }
 
-        slave_info = SlaveInfo(self.env, build.id)
-        req.hdf['build.slave'] = {
-            'name': build.slave,
-            'ip_address': slave_info.properties.get(SlaveInfo.IP_ADDRESS),
-            'os': slave_info.properties.get(SlaveInfo.OS_NAME),
-            'os.family': slave_info.properties.get(SlaveInfo.OS_FAMILY),
-            'os.version': slave_info.properties.get(SlaveInfo.OS_VERSION),
-            'machine': slave_info.properties.get(SlaveInfo.MACHINE),
-            'processor': slave_info.properties.get(SlaveInfo.PROCESSOR)
-        }
-
     def _build_to_hdf(self, build):
         hdf = {'name': build.slave, 'status': self._status_label[build.status],
                'rev': build.rev,
@@ -316,4 +489,13 @@
             hdf['stopped'] = strftime('%x %X', localtime(build.stopped))
             hdf['stopped_delta'] = pretty_timedelta(build.stopped)
             hdf['duration'] = pretty_timedelta(build.stopped, build.started)
+        hdf['slave'] = {
+            'name': build.slave,
+            'ip_address': build.slave_info.get(Build.IP_ADDRESS),
+            'os': build.slave_info.get(Build.OS_NAME),
+            'os.family': build.slave_info.get(Build.OS_FAMILY),
+            'os.version': build.slave_info.get(Build.OS_VERSION),
+            'machine': build.slave_info.get(Build.MACHINE),
+            'processor': build.slave_info.get(Build.PROCESSOR)
+        }
         return hdf
Copyright (C) 2012-2017 Edgewall Software