Add frontend and scheduler for Autotest

The attached tarball includes the new Autotest web frontend for creating 
and monitoring jobs and the new scheduler for executing jobs.  We've 
been working hard to get these complete and stabilized, and although 
they still have a long, long way to go in terms of features, we believe 
they are now stable and powerful enough to be useful.
 
The frontend consists of two parts, the server and the client.  The 
server is implemented in Python using the Django web framework, and is 
contained under frontend/ and frontend/afe.  The client is implemented 
using Google Web Toolkit and is contained under frontend/client.  We 
tried to use Dojo initially, as was generally agreed on, but it proved 
to be too young and poorly documented of a framework, and developing in 
it was just too inefficient.

The scheduler is contained entirely in the scheduler/monitor_db Python 
file.  It looks at the database used by the web frontend and spawns 
autoserv processes to run jobs, monitoring them and recording status.  
The scheduler was developed by Paul Turner, who will be sending out some 
detailed documentation of it soon.
 
I've also included the DB migrations code for which I recently submitted 
a patch to the mailing list.  I've included this code because it is 
necessary to create the frontend DB, but it will (hopefully) soon be 
part of the SVN repository.

Lastly, there is an apache directory containing configuration files for 
running the web server through Apache.
 
I've put instructions for installing a full Autotest server, including 
the web frontend, the scheduler, and the existing "tko" results backend, 
at http://test.kernel.org/autotest/AutotestServerInstall.  I've also 
created a brief guide to using the web frontend, with plenty of 
screenshots, at http://test.kernel.org/autotest/WebFrontendHowTo.  
Please let me know if you find any problems with either of these pages.

Take a look, try it out, send me comments, complaints, and bugs, and I 
hope you find it useful!

Steve Howard, and the rest of the Google Autotest team

From: Steve Howard <showard@google.com>




git-svn-id: http://test.kernel.org/svn/autotest/trunk@1242 592f7852-d20e-0410-864c-8624ca9c26a4
diff --git a/frontend/client/.classpath b/frontend/client/.classpath
new file mode 100644
index 0000000..1e9bac0
--- /dev/null
+++ b/frontend/client/.classpath
@@ -0,0 +1,9 @@
+<?xml version="1.0" encoding="utf-8" ?>
+<classpath>
+   <classpathentry kind="src" path="src"/>
+   <classpathentry kind="src" path="test"/>
+   <classpathentry kind="con" path="org.eclipse.jdt.launching.JRE_CONTAINER"/>
+   <classpathentry kind="lib" path="/usr/local/lib/gwt/gwt-user.jar"/>
+   <classpathentry kind="var" path="JUNIT_HOME/junit.jar"/>
+   <classpathentry kind="output" path="bin"/>
+</classpath>
diff --git a/frontend/client/.project b/frontend/client/.project
new file mode 100644
index 0000000..0113a02
--- /dev/null
+++ b/frontend/client/.project
@@ -0,0 +1,15 @@
+<?xml version="1.0" encoding="utf-8" ?>
+<projectDescription>
+   <name>AfeClient</name>
+   <comment>AfeClient project</comment>
+   <projects/>
+   <buildSpec>
+       <buildCommand>
+           <name>org.eclipse.jdt.core.javabuilder</name>
+           <arguments/>
+       </buildCommand>
+   </buildSpec>
+   <natures>
+       <nature>org.eclipse.jdt.core.javanature</nature>
+   </natures>
+</projectDescription>
diff --git a/frontend/client/ClientMain-compile b/frontend/client/ClientMain-compile
new file mode 100755
index 0000000..ef8d64c
--- /dev/null
+++ b/frontend/client/ClientMain-compile
@@ -0,0 +1,7 @@
+#!/bin/sh
+APPDIR=`dirname $0`;
+GWTDIR=`$APPDIR/gwt_dir`;
+java  \
+  -cp "$APPDIR/src:$APPDIR/bin:$GWTDIR/gwt-user.jar:$GWTDIR/gwt-dev-linux.jar" \
+  -Djava.awt.headless=true \
+  com.google.gwt.dev.GWTCompiler -out "$APPDIR/www" "$@" afeclient.ClientMain
diff --git a/frontend/client/ClientMain-shell b/frontend/client/ClientMain-shell
new file mode 100755
index 0000000..39dae72
--- /dev/null
+++ b/frontend/client/ClientMain-shell
@@ -0,0 +1,4 @@
+#!/bin/sh
+APPDIR=`dirname $0`;
+GWTDIR=`$APPDIR/gwt_dir`;
+java  -cp "$APPDIR/src:$APPDIR/bin:$GWTDIR/gwt-user.jar:$GWTDIR/gwt-dev-linux.jar" com.google.gwt.dev.GWTShell -out "$APPDIR/www" "$@" http://localhost:8000/afe/server/afeclient.ClientMain/ClientMain.html;
diff --git a/frontend/client/ClientMain.launch b/frontend/client/ClientMain.launch
new file mode 100644
index 0000000..b5346be
--- /dev/null
+++ b/frontend/client/ClientMain.launch
@@ -0,0 +1,20 @@
+<?xml version="1.0" encoding="UTF-8" standalone="no"?>
+<launchConfiguration type="org.eclipse.jdt.launching.localJavaApplication">
+<listAttribute key="org.eclipse.debug.core.MAPPED_RESOURCE_PATHS">
+<listEntry value="/AfeClient"/>
+</listAttribute>
+<listAttribute key="org.eclipse.debug.core.MAPPED_RESOURCE_TYPES">
+<listEntry value="4"/>
+</listAttribute>
+<booleanAttribute key="org.eclipse.debug.core.appendEnvironmentVariables" value="true"/>
+<listAttribute key="org.eclipse.jdt.launching.CLASSPATH">
+<listEntry value="&lt;?xml version=&quot;1.0&quot; encoding=&quot;UTF-8&quot;?&gt;&#13;&#10;&lt;runtimeClasspathEntry containerPath=&quot;org.eclipse.jdt.launching.JRE_CONTAINER&quot; javaProject=&quot;AfeClient&quot; path=&quot;1&quot; type=&quot;4&quot;/&gt;&#13;&#10;"/>
+<listEntry value="&lt;?xml version=&quot;1.0&quot; encoding=&quot;UTF-8&quot;?&gt;&#13;&#10;&lt;runtimeClasspathEntry internalArchive=&quot;/AfeClient/src&quot; path=&quot;3&quot; type=&quot;2&quot;/&gt;&#13;&#10;"/>
+<listEntry value="&lt;?xml version=&quot;1.0&quot; encoding=&quot;UTF-8&quot;?&gt;&#13;&#10;&lt;runtimeClasspathEntry id=&quot;org.eclipse.jdt.launching.classpathentry.defaultClasspath&quot;&gt;&#13;&#10;&lt;memento project=&quot;AfeClient&quot;/&gt;&#13;&#10;&lt;/runtimeClasspathEntry&gt;&#13;&#10;"/>
+<listEntry value="&lt;?xml version=&quot;1.0&quot; encoding=&quot;UTF-8&quot;?&gt;&#13;&#10;&lt;runtimeClasspathEntry externalArchive=&quot;/usr/local/lib/gwt/gwt-dev-linux.jar&quot; path=&quot;3&quot; type=&quot;2&quot;/&gt;&#13;&#10;"/>
+</listAttribute>
+<booleanAttribute key="org.eclipse.jdt.launching.DEFAULT_CLASSPATH" value="false"/>
+<stringAttribute key="org.eclipse.jdt.launching.MAIN_TYPE" value="com.google.gwt.dev.GWTShell"/>
+<stringAttribute key="org.eclipse.jdt.launching.PROGRAM_ARGUMENTS" value="-out www http://localhost:8000/afe/server/afeclient.ClientMain/ClientMain.html"/>
+<stringAttribute key="org.eclipse.jdt.launching.PROJECT_ATTR" value="AfeClient"/>
+</launchConfiguration>
diff --git a/frontend/client/generate-javadoc b/frontend/client/generate-javadoc
new file mode 100755
index 0000000..d1cca6b
--- /dev/null
+++ b/frontend/client/generate-javadoc
@@ -0,0 +1,5 @@
+#!/bin/sh
+APPDIR=`dirname $0`;
+GWTDIR=`$APPDIR/gwt_dir`;
+javadoc -classpath "$APPDIR/src:$GWTDIR/gwt-user.jar" -d doc -public \
+  afeclient.client
diff --git a/frontend/client/gwt_dir b/frontend/client/gwt_dir
new file mode 100755
index 0000000..7336500
--- /dev/null
+++ b/frontend/client/gwt_dir
@@ -0,0 +1,2 @@
+#!/bin/sh
+echo /usr/local/lib/gwt
diff --git a/frontend/client/src/afeclient/ClientMain.gwt.xml b/frontend/client/src/afeclient/ClientMain.gwt.xml
new file mode 100644
index 0000000..f7ead6c
--- /dev/null
+++ b/frontend/client/src/afeclient/ClientMain.gwt.xml
@@ -0,0 +1,8 @@
+<module>
+	<inherits name='com.google.gwt.user.User'/>
+	<inherits name='com.google.gwt.json.JSON'/>
+
+	<entry-point class='afeclient.client.ClientMain'/>
+	
+	<stylesheet src='afeclient.css'/>
+</module>
diff --git a/frontend/client/src/afeclient/client/ClassFactory.java b/frontend/client/src/afeclient/client/ClassFactory.java
new file mode 100644
index 0000000..f654692
--- /dev/null
+++ b/frontend/client/src/afeclient/client/ClassFactory.java
@@ -0,0 +1,9 @@
+package afeclient.client;
+
+import afeclient.client.CreateJobView.JobCreateListener;
+
+public class ClassFactory {
+    public CreateJobView getCreateJobView(JobCreateListener listener) {
+        return new CreateJobView(listener);
+    }
+}
diff --git a/frontend/client/src/afeclient/client/ClientMain.java b/frontend/client/src/afeclient/client/ClientMain.java
new file mode 100644
index 0000000..d62efe8
--- /dev/null
+++ b/frontend/client/src/afeclient/client/ClientMain.java
@@ -0,0 +1,129 @@
+package afeclient.client;
+
+import com.google.gwt.core.client.EntryPoint;
+import com.google.gwt.user.client.History;
+import com.google.gwt.user.client.HistoryListener;
+import com.google.gwt.user.client.ui.RootPanel;
+import com.google.gwt.user.client.ui.SourcesTabEvents;
+import com.google.gwt.user.client.ui.TabListener;
+import com.google.gwt.user.client.ui.TabPanel;
+
+import afeclient.client.CreateJobView.JobCreateListener;
+import afeclient.client.JobTable.JobTableListener;
+
+public class ClientMain implements EntryPoint, HistoryListener {
+    static final String RPC_URL = "/afe/server/rpc/";
+
+    protected JobListView jobList;
+    protected JobDetailView jobDetail;
+    protected CreateJobView createJob;
+    protected HostListView hostListView;
+    
+    protected TabView[] tabViews;
+
+    protected TabPanel mainTabPanel;
+
+    /**
+     * Application entry point.
+     */
+    public void onModuleLoad() {
+        JsonRpcProxy.getProxy().setUrl(RPC_URL);
+        
+        jobList = new JobListView(new JobTableListener() {
+            public void onJobClicked(int jobId) {
+                showJob(jobId);
+            }
+        });
+        jobDetail = new JobDetailView();
+        createJob = Utils.factory.getCreateJobView(new JobCreateListener() {
+            public void onJobCreated(int jobId) {
+                showJob(jobId);
+            }
+        });
+        hostListView = new HostListView();
+        
+        tabViews = new TabView[4];
+        tabViews[0] = jobList;
+        tabViews[1] = jobDetail;
+        tabViews[2] = createJob;
+        tabViews[3] = hostListView;
+        
+        mainTabPanel = new TabPanel();
+        for(int i = 0; i < tabViews.length; i++) {
+            mainTabPanel.add(tabViews[i], tabViews[i].getTitle());
+        }
+        mainTabPanel.addTabListener(new TabListener() {
+            public boolean onBeforeTabSelected(SourcesTabEvents sender,
+                                               int tabIndex) {
+                // do nothing if the user clicks the selected tab
+                if (mainTabPanel.getTabBar().getSelectedTab() == tabIndex)
+                    return false;
+                tabViews[tabIndex].ensureInitialized();
+                tabViews[tabIndex].updateHistory();
+                // let onHistoryChanged() call TabView.display()
+                return true;
+            }
+            public void onTabSelected(SourcesTabEvents sender, int tabIndex) {}
+        });
+        
+        // initialize static data, and don't show main UI until that's done
+        StaticDataRepository.getRepository().refresh(
+                                 new StaticDataRepository.FinishedCallback() {
+            public void onFinished() {
+                finishLoading();
+            }
+        });
+        
+        NotifyManager.getInstance().initialize();
+    }
+    
+    protected void finishLoading() {
+        final RootPanel tabsRoot = RootPanel.get("tabs");
+        tabsRoot.add(mainTabPanel);
+        
+        History.addHistoryListener(this);
+        String initialToken = History.getToken();
+        if (!initialToken.equals("")) {
+            onHistoryChanged(initialToken);
+        }
+        
+        // if the history token didn't provide a selected tab, default to the 
+        // first tab
+        if (mainTabPanel.getTabBar().getSelectedTab() == -1)
+            mainTabPanel.selectTab(0);
+        
+        RootPanel.get("tabs").setStyleName("");
+    }
+    
+    protected void showJob(int jobId) {
+        jobDetail.ensureInitialized();
+        jobDetail.setJobID(jobId);
+        mainTabPanel.selectTab(1);
+    }
+
+    public void onHistoryChanged(String historyToken) {
+        if (!historyToken.startsWith(TabView.HISTORY_PREFIX))
+            return;
+        
+        // remove prefix
+        historyToken = historyToken.substring(TabView.HISTORY_PREFIX.length());
+        for (int i = 0; i < tabViews.length; i++) {
+            String tabId = tabViews[i].getElementId();
+            if (historyToken.startsWith(tabId)) {
+                tabViews[i].ensureInitialized();
+                
+                int prefixLength = tabId.length() + 1;
+                if (historyToken.length() > prefixLength) {
+                    String restOfToken = historyToken.substring(prefixLength);
+                    tabViews[i].handleHistoryToken(restOfToken);
+                }
+                
+                tabViews[i].display();
+                if (mainTabPanel.getTabBar().getSelectedTab() != i)
+                    mainTabPanel.selectTab(i);
+                
+                return;
+            }
+        }
+    }
+}
diff --git a/frontend/client/src/afeclient/client/CreateJobView.java b/frontend/client/src/afeclient/client/CreateJobView.java
new file mode 100644
index 0000000..64766f3
--- /dev/null
+++ b/frontend/client/src/afeclient/client/CreateJobView.java
@@ -0,0 +1,509 @@
+package afeclient.client;
+
+import com.google.gwt.json.client.JSONArray;
+import com.google.gwt.json.client.JSONBoolean;
+import com.google.gwt.json.client.JSONNumber;
+import com.google.gwt.json.client.JSONObject;
+import com.google.gwt.json.client.JSONString;
+import com.google.gwt.json.client.JSONValue;
+import com.google.gwt.user.client.DOM;
+import com.google.gwt.user.client.Window;
+import com.google.gwt.user.client.ui.Button;
+import com.google.gwt.user.client.ui.ChangeListener;
+import com.google.gwt.user.client.ui.CheckBox;
+import com.google.gwt.user.client.ui.ClickListener;
+import com.google.gwt.user.client.ui.Composite;
+import com.google.gwt.user.client.ui.DisclosureEvent;
+import com.google.gwt.user.client.ui.DisclosureHandler;
+import com.google.gwt.user.client.ui.DisclosurePanel;
+import com.google.gwt.user.client.ui.FlexTable;
+import com.google.gwt.user.client.ui.FocusListener;
+import com.google.gwt.user.client.ui.HorizontalPanel;
+import com.google.gwt.user.client.ui.Hyperlink;
+import com.google.gwt.user.client.ui.KeyboardListener;
+import com.google.gwt.user.client.ui.ListBox;
+import com.google.gwt.user.client.ui.Panel;
+import com.google.gwt.user.client.ui.RadioButton;
+import com.google.gwt.user.client.ui.RootPanel;
+import com.google.gwt.user.client.ui.TextArea;
+import com.google.gwt.user.client.ui.TextBox;
+import com.google.gwt.user.client.ui.VerticalPanel;
+import com.google.gwt.user.client.ui.Widget;
+
+import java.util.ArrayList;
+import java.util.Iterator;
+import java.util.List;
+
+public class CreateJobView extends TabView {
+    public static final int TEST_COLUMNS = 5;
+    
+    // control file types
+    protected static final String CLIENT_TYPE = "Client";
+    protected static final String SERVER_TYPE = "Server";
+    
+    protected static final String EDIT_CONTROL_STRING = "Edit control file";
+    protected static final String UNEDIT_CONTROL_STRING= "Revert changes";
+    protected static final String VIEW_CONTROL_STRING = "View control file";
+    protected static final String HIDE_CONTROL_STRING = "Hide control file";
+    
+    public interface JobCreateListener {
+        public void onJobCreated(int jobId);
+    }
+
+    protected JsonRpcProxy rpcProxy = JsonRpcProxy.getProxy();
+    protected JobCreateListener listener;
+    
+    protected class TestCheckBox extends CheckBox {
+        protected int id;
+        protected String testType;
+        
+        public TestCheckBox(JSONObject test) {
+            super(test.get("name").isString().stringValue());
+            id = (int) test.get("id").isNumber().getValue();
+            testType = test.get("test_type").isString().stringValue();
+            String description = test.get("description").isString().stringValue();
+            if (description.equals(""))
+                description = "No description";
+            setTitle(description);
+        }
+        
+        public int getId() {
+            return id;
+        }
+
+        public String getTestType() {
+            return testType;
+        }
+    }
+    
+    protected class TestPanel extends Composite {
+        protected int numColumns;
+        protected FlexTable table = new FlexTable();
+        protected List testBoxes = new ArrayList(); // List<TestCheckBox>
+        String testType = null;
+        
+        public TestPanel(String testType, int columns) {
+            this.testType = testType; 
+            numColumns = columns;
+            initWidget(table);
+        }
+        
+        public void addTest(TestCheckBox checkBox) {
+            if (!checkBox.getTestType().equals(testType))
+                throw new RuntimeException(
+                    "Inconsistent test type for test " + checkBox.getText());
+            int row = testBoxes.size() / numColumns;
+            int col = testBoxes.size() % numColumns;
+            table.setWidget(row, col, checkBox);
+            testBoxes.add(checkBox);
+        }
+        
+        public List getChecked() {
+            List result = new ArrayList();
+            for(Iterator i = testBoxes.iterator(); i.hasNext(); ) {
+                TestCheckBox test = (TestCheckBox) i.next();
+                if (test.isChecked())
+                    result.add(test);
+            }
+            return result;
+        }
+        
+        public void setEnabled(boolean enabled) {
+            for(Iterator i = testBoxes.iterator(); i.hasNext(); ) {
+                ((TestCheckBox) i.next()).setEnabled(enabled);
+            }
+        }
+        
+        public void reset() {
+            for(Iterator i = testBoxes.iterator(); i.hasNext(); ) {
+                ((TestCheckBox) i.next()).setChecked(false);
+            }
+        }
+
+        public String getTestType() {
+            return testType;
+        }
+    }
+    
+    protected class ControlTypeSelect extends Composite {
+        public static final String RADIO_GROUP = "controlTypeGroup";
+        protected String clientType, serverType;
+        protected RadioButton client, server;
+        protected Panel panel = new HorizontalPanel();
+        
+        public ControlTypeSelect() {
+            client = new RadioButton(RADIO_GROUP, CLIENT_TYPE);
+            server = new RadioButton(RADIO_GROUP, SERVER_TYPE);
+            panel.add(client);
+            panel.add(server);
+            client.setChecked(true); // client is default
+            initWidget(panel);
+            
+            client.addClickListener(new ClickListener() {
+                public void onClick(Widget sender) {
+                    onChanged();
+                }
+            });
+            server.addClickListener(new ClickListener() {
+                public void onClick(Widget sender) {
+                    onChanged();
+                }
+            });
+        }
+        
+        public String getControlType() {
+            if (client.isChecked())
+                return client.getText();
+            return server.getText();
+        }
+        
+        public void setControlType(String type) {
+            if (client.getText().equals(type))
+                client.setChecked(true);
+            else if (server.getText().equals(type))
+                server.setChecked(true);
+            else
+                throw new IllegalArgumentException("Invalid control type");
+            onChanged();
+        }
+        
+        public void setEnabled(boolean enabled) {
+            client.setEnabled(enabled);
+            server.setEnabled(enabled);
+        }
+        
+        protected void onChanged() {
+            setRunSynchronous();
+        }
+    }
+    
+    protected StaticDataRepository staticData = StaticDataRepository.getRepository();
+    
+    protected TextBox jobName = new TextBox();
+    protected ListBox priorityList = new ListBox();
+    protected TextBox kernel = new TextBox();
+    protected TestPanel clientTestsPanel = new TestPanel(CLIENT_TYPE, TEST_COLUMNS), 
+                        serverTestsPanel = new TestPanel(SERVER_TYPE, TEST_COLUMNS);
+    protected TextArea controlFile = new TextArea();
+    protected DisclosurePanel controlFilePanel = new DisclosurePanel();
+    protected ControlTypeSelect controlTypeSelect;
+    protected CheckBox runSynchronous = new CheckBox("Synchronous");
+    protected Button editControlButton = new Button(EDIT_CONTROL_STRING);
+    protected HostSelector hostSelector;
+    protected Button submitJobButton = new Button("Submit Job");
+    
+    protected boolean controlEdited = false;
+    protected boolean kernelPushed = false;
+    
+    public CreateJobView(JobCreateListener listener) {
+        this.listener = listener;
+    }
+
+    public String getElementId() {
+        return "create_job";
+    }
+    
+    protected void populatePriorities(JSONArray priorities) {
+        for(int i = 0; i < priorities.size(); i++) {
+            JSONArray priorityData = priorities.get(i).isArray();
+            String priority = priorityData.get(1).isString().stringValue();
+            priorityList.addItem(priority);
+        }
+        
+        resetPriorityToDefault();
+    }
+    
+    protected void resetPriorityToDefault() {
+        JSONValue defaultValue = staticData.getData("default_priority");
+        String defaultPriority = defaultValue.isString().stringValue();
+        for(int i = 0; i < priorityList.getItemCount(); i++) {
+            if (priorityList.getItemText(i).equals(defaultPriority))
+                priorityList.setSelectedIndex(i);
+        }
+    }
+    
+    protected void populateTests() {
+        StaticDataRepository staticData = StaticDataRepository.getRepository();
+        JSONArray tests = staticData.getData("tests").isArray();
+        
+        for(int i = 0; i < tests.size(); i++) {
+            JSONObject test = tests.get(i).isObject();
+            TestCheckBox checkbox = new TestCheckBox(test);
+            checkbox.addClickListener(new ClickListener() {
+                public void onClick(Widget sender) {
+                    generateControlFile(false);
+                    setInputsEnabled();
+                    updateControlType();
+                }
+            });
+            String type = test.get("test_type").isString().stringValue();
+            if (type.equals("Client"))
+                clientTestsPanel.addTest(checkbox);
+            else if (type.equals("Server"))
+                serverTestsPanel.addTest(checkbox);
+            else
+                throw new RuntimeException("Invalid control type: " + type);
+        }
+    }
+    
+    protected JSONObject getControlFileParams(boolean pushKernel) {
+        JSONObject params = new JSONObject();
+        JSONArray tests = new JSONArray();
+        List checkedTests = serverTestsPanel.getChecked();
+        String kernelString = ""; 
+        if (checkedTests.isEmpty()) {
+            checkedTests = clientTestsPanel.getChecked();
+            kernelString = kernel.getText();
+        }
+        if (!kernelString.equals("")) {
+            params.put("kernel", new JSONString(kernelString));
+            params.put("do_push_kernel", JSONBoolean.getInstance(pushKernel));
+        }
+        
+        for (int i = 0; i < checkedTests.size(); i++) {
+            TestCheckBox test = (TestCheckBox) checkedTests.get(i);
+            tests.set(i, new JSONNumber(test.getId()));
+        }
+        params.put("tests", tests);
+        return params;
+    }
+    
+    protected void generateControlFile(final boolean pushKernel, 
+                                       final SimpleCallback finishedCallback,
+                                       final SimpleCallback errorCallback) {
+        JSONObject params = getControlFileParams(pushKernel);
+        rpcProxy.rpcCall("generate_control_file", params, new JsonRpcCallback() {
+            public void onSuccess(JSONValue result) {
+                controlFile.setText(result.isString().stringValue());
+                kernelPushed = pushKernel;
+                if (finishedCallback != null)
+                    finishedCallback.doCallback();
+            }
+
+            public void onError(JSONObject errorObject) {
+                super.onError(errorObject);
+                if (errorCallback != null)
+                    errorCallback.doCallback();
+            }
+        });
+    }
+    
+    protected void generateControlFile(boolean pushKernel) {
+        generateControlFile(pushKernel, null, null);
+    }
+    
+    protected void setInputsEnabled() {
+        if (!clientTestsPanel.getChecked().isEmpty()) {
+            clientTestsPanel.setEnabled(true);
+            serverTestsPanel.setEnabled(false);
+            kernel.setEnabled(true);
+        }
+        else if (!serverTestsPanel.getChecked().isEmpty()) {
+            clientTestsPanel.setEnabled(false);
+            serverTestsPanel.setEnabled(true);
+            kernel.setEnabled(false);
+        }
+        else {
+            clientTestsPanel.setEnabled(true);
+            serverTestsPanel.setEnabled(true);
+            kernel.setEnabled(true);
+        }
+    }
+    
+    protected void disableInputs() {
+        clientTestsPanel.setEnabled(false);
+        serverTestsPanel.setEnabled(false);
+        kernel.setEnabled(false);
+    }
+    
+    public void initialize() {
+        StaticDataRepository staticData = StaticDataRepository.getRepository();
+        populatePriorities(staticData.getData("priorities").isArray());
+        
+        kernel.addFocusListener(new FocusListener() {
+            public void onFocus(Widget sender) {}
+            public void onLostFocus(Widget sender) {
+                generateControlFile(false);
+            }
+        });
+        kernel.addKeyboardListener(new KeyboardListener() {
+            public void onKeyDown(Widget sender, char keyCode, int modifiers) {}
+            public void onKeyUp(Widget sender, char keyCode, int modifiers) {}
+            public void onKeyPress(Widget sender, char keyCode, int modifiers) {
+                if (keyCode == KEY_ENTER)
+                    generateControlFile(false);
+            }
+        });
+
+        populateTests();
+        
+        controlFile.setSize("50em", "30em");
+        controlTypeSelect = new ControlTypeSelect();
+        Panel controlOptionsPanel = new HorizontalPanel();
+        controlOptionsPanel.add(controlTypeSelect);
+        controlOptionsPanel.add(runSynchronous);
+        runSynchronous.addStyleName("extra-space-left");
+        Panel controlEditPanel = new VerticalPanel();
+        controlEditPanel.add(controlOptionsPanel);
+        controlEditPanel.add(controlFile);
+        
+        Panel controlHeaderPanel = new HorizontalPanel();
+        final Hyperlink viewLink = new SimpleHyperlink(VIEW_CONTROL_STRING);
+        controlHeaderPanel.add(viewLink);
+        controlHeaderPanel.add(editControlButton);
+        
+        controlFilePanel.setHeader(controlHeaderPanel);
+        controlFilePanel.add(controlEditPanel);
+        
+        editControlButton.addClickListener(new ClickListener() {
+            public void onClick(Widget sender) {
+                DOM.eventCancelBubble(DOM.eventGetCurrentEvent(), true);
+                
+                if (editControlButton.getText().equals(EDIT_CONTROL_STRING)) {
+                    generateControlFile(true);
+                    controlFile.setReadOnly(false);
+                    disableInputs();
+                    editControlButton.setText(UNEDIT_CONTROL_STRING);
+                    controlFilePanel.setOpen(true);
+                    controlTypeSelect.setEnabled(true);
+                }
+                else {
+                    if (controlEdited && 
+                        !Window.confirm("Are you sure you want to revert your" +
+                                        " changes?"))
+                        return;
+                    generateControlFile(false);
+                    controlFile.setReadOnly(true);
+                    setInputsEnabled();
+                    editControlButton.setText(EDIT_CONTROL_STRING);
+                    controlTypeSelect.setEnabled(false);
+                    updateControlType();
+                    controlEdited = false;
+                }
+                setRunSynchronous();
+            }
+        });
+        
+        controlFile.addChangeListener(new ChangeListener() {
+            public void onChange(Widget sender) {
+                controlEdited = true;
+            } 
+        });
+        
+        controlFilePanel.addEventHandler(new DisclosureHandler() {
+            public void onClose(DisclosureEvent event) {
+                viewLink.setText(VIEW_CONTROL_STRING);
+            }
+
+            public void onOpen(DisclosureEvent event) {
+                viewLink.setText(HIDE_CONTROL_STRING);
+            }
+        });
+        
+        hostSelector = new HostSelector();
+        
+        submitJobButton.addClickListener(new ClickListener() {
+            public void onClick(Widget sender) {
+                submitJob();
+            }
+        });
+        
+        reset();
+        
+        RootPanel.get("create_job_name").add(jobName);
+        RootPanel.get("create_kernel").add(kernel);
+        RootPanel.get("create_priority").add(priorityList);
+        RootPanel.get("create_client_tests").add(clientTestsPanel);
+        RootPanel.get("create_server_tests").add(serverTestsPanel);
+        RootPanel.get("create_edit_control").add(controlFilePanel);
+        RootPanel.get("create_submit").add(submitJobButton);
+    }
+    
+    public void reset() {
+        jobName.setText("");
+        resetPriorityToDefault();
+        kernel.setText("");
+        clientTestsPanel.reset();
+        serverTestsPanel.reset();
+        setInputsEnabled();
+        updateControlType();
+        controlTypeSelect.setEnabled(false);
+        runSynchronous.setEnabled(false);
+        runSynchronous.setChecked(false);
+        controlFile.setText("");
+        controlFile.setReadOnly(true);
+        controlEdited = false;
+        editControlButton.setText(EDIT_CONTROL_STRING);
+        hostSelector.reset();
+    }
+    
+    protected void updateControlType() {
+        String type = clientTestsPanel.getTestType(); // the default
+        if (!serverTestsPanel.getChecked().isEmpty())
+            type = serverTestsPanel.getTestType();
+        controlTypeSelect.setControlType(type);
+    }
+    
+    protected void setRunSynchronous() {
+        boolean isServer = controlTypeSelect.getControlType().equals("Server");
+        runSynchronous.setChecked(isServer);
+        boolean enabled = isServer &&
+            editControlButton.getText().equals(UNEDIT_CONTROL_STRING);
+        runSynchronous.setEnabled(enabled);
+    }
+    
+    protected void submitJob() {
+        // disallow accidentally clicking submit twice
+        submitJobButton.setEnabled(false);
+        
+        final SimpleCallback doSubmit = new SimpleCallback() {
+            public void doCallback() {
+                JSONObject args = new JSONObject();
+                args.put("name", new JSONString(jobName.getText()));
+                String priority = priorityList.getItemText(
+                                               priorityList.getSelectedIndex());
+                args.put("priority", new JSONString(priority));
+                args.put("control_file", new JSONString(controlFile.getText()));
+                args.put("control_type", 
+                         new JSONString(controlTypeSelect.getControlType()));
+                args.put("is_synchronous", 
+                         JSONBoolean.getInstance(runSynchronous.isChecked()));
+                
+                HostSelector.HostSelection hosts = hostSelector.getSelectedHosts();
+                args.put("hosts", Utils.stringsToJSON(hosts.hosts));
+                args.put("meta_hosts", Utils.stringsToJSON(hosts.metaHosts));
+                
+                boolean success =
+                    rpcProxy.rpcCall("create_job", args, new JsonRpcCallback() {
+                    public void onSuccess(JSONValue result) {
+                        int id = (int) result.isNumber().getValue();
+                        NotifyManager.getInstance().showMessage(
+                                    "Job " + Integer.toString(id) + " created");
+                        reset();
+                        if (listener != null)
+                            listener.onJobCreated(id);
+                        submitJobButton.setEnabled(true);
+                    }
+
+                    public void onError(JSONObject errorObject) {
+                        super.onError(errorObject);
+                        submitJobButton.setEnabled(true);
+                    }
+                });
+                
+                if (!success)
+                    submitJobButton.setEnabled(true);
+            }
+        };
+        
+        // ensure kernel is pushed before submitting
+        if (!kernelPushed)
+            generateControlFile(true, doSubmit, new SimpleCallback() {
+                public void doCallback() {
+                    submitJobButton.setEnabled(true);
+                }
+            });
+        else
+            doSubmit.doCallback();
+    }
+}
diff --git a/frontend/client/src/afeclient/client/DataTable.java b/frontend/client/src/afeclient/client/DataTable.java
new file mode 100644
index 0000000..582d04f
--- /dev/null
+++ b/frontend/client/src/afeclient/client/DataTable.java
@@ -0,0 +1,188 @@
+package afeclient.client;
+
+
+
+import com.google.gwt.json.client.JSONArray;
+import com.google.gwt.json.client.JSONObject;
+import com.google.gwt.json.client.JSONValue;
+import com.google.gwt.user.client.ui.Composite;
+import com.google.gwt.user.client.ui.FlexTable;
+import com.google.gwt.user.client.ui.Widget;
+
+/**
+ * A table to display data from JSONObjects.  Each row displays data from one 
+ * JSONObject.  A header row with column titles is automatically generated, and
+ * support is included for adding other arbitrary header rows.
+ * <br><br>
+ * Styles:
+ * <ul>
+ * <li>.data-table - the entire table
+ * <li>.data-row-header - the column title row
+ * <li>.data-row-one/.data-row-two - data row styles.  These two are alternated.
+ * </ul>
+ */
+public class DataTable extends Composite {
+    public static final String HEADER_STYLE = "data-row-header";
+    public static final String CLICKABLE_STYLE = "data-row-clickable";
+    
+    protected FlexTable table;
+    
+    protected String[][] columns;
+    protected int headerRow = 0;
+    protected boolean clickable = false;
+
+    /**
+     * @param columns An array specifying the name of each column and the field
+     * to which it corresponds.  The array should have the form
+     * {{'field_name1', 'Column Title 1'}, 
+     *  {'field_name2', 'Column Title 2'}, ...}.
+     */ 
+    public DataTable(String[][] columns) {
+        this.columns = columns;
+        table = new FlexTable();
+        initWidget(table);
+        
+        table.setCellSpacing(0);
+        table.setCellPadding(0);
+        table.setStyleName("data-table");
+
+        for (int i = 0; i < columns.length; i++) {
+            table.setText(0, i, columns[i][1]);
+        }
+
+        table.getRowFormatter().setStylePrimaryName(0, HEADER_STYLE);
+    }
+
+    protected void setRowStyle(int row) {
+        table.getRowFormatter().setStyleName(row, "data-row");
+        if ((row & 1) == 0) {
+            table.getRowFormatter().addStyleName(row, "data-row-alternate");
+        }
+        if (clickable) {
+            table.getRowFormatter().addStyleName(row, CLICKABLE_STYLE);
+        }
+    }
+    
+    public void setClickable(boolean clickable) {
+        this.clickable = clickable;
+        for(int i = headerRow + 1; i < table.getRowCount(); i++)
+            setRowStyle(i);
+    }
+
+    /**
+     * Clear all data rows from the table.  Leaves the header rows intact.
+     */
+    public void clear() {
+        while (getRowCount() > 0) {
+            removeRow(0);
+        }
+    }
+    
+    /**
+     * This gets called for every JSONObject that gets added to the table using
+     * addRow().  This allows subclasses to customize objects before they are 
+     * added to the table, for example to reformat fields or generate new 
+     * fields from the existing data.
+     * @param row The row object about to be added to the table.
+     */
+    protected void preprocessRow(JSONObject row) {}
+    
+    protected String getTextForValue(JSONValue value) {
+        if (value.isNumber() != null)
+            return Integer.toString((int) value.isNumber().getValue());
+        else if (value.isString() != null)
+            return  value.isString().stringValue();
+        else if (value.isNull() != null)
+            return "";
+        else
+            throw new IllegalArgumentException(value.toString());
+    }
+    
+    protected String[] getRowText(JSONObject row) {
+        String[] rowText = new String[columns.length];
+        for (int i = 0; i < columns.length; i++) {
+            String columnKey = columns[i][0];
+            JSONValue columnValue = row.get(columnKey);
+            rowText[i] = getTextForValue(columnValue);
+        }
+        return rowText;
+    }
+    
+    /**
+     * Add a row from an array of Strings, one String for each column.
+     * @param rowData Data for each column, in left-to-right column order.
+     */
+    public void addRowFromData(String[] rowData) {
+        int row = table.getRowCount();
+        for(int i = 0; i < columns.length; i++)
+            table.setHTML(row, i, rowData[i]);
+        setRowStyle(row);
+    }
+
+    /**
+     * Add a row from a JSONObject.  Columns will be populated by pulling fields
+     * from the objects, as dictated by the columns information passed into the
+     * DataTable constructor.
+     */
+    public void addRow(JSONObject row) {
+        preprocessRow(row);
+        addRowFromData(getRowText(row));
+    }
+    
+    /**
+     * Add all objects in a JSONArray.
+     * @param rows An array of JSONObjects
+     * @throws IllegalArgumentException if any other type of JSONValue is in the
+     * array.
+     */
+    public void addRows(JSONArray rows) {
+        for (int i = 0; i < rows.size(); i++) {
+            JSONObject row = rows.get(i).isObject();
+            if (row == null)
+                throw new IllegalArgumentException("rows must be JSONObjects");
+            addRow(row);
+        }
+    }
+
+    /**
+     * Remove a data row from the table.
+     * @param row The index of the row, where the first data row is indexed 0.
+     * Header rows are ignored.
+     */
+    public void removeRow(int row) {
+        int realRow = row + getHeaderRowCount();
+        table.removeRow(realRow);
+        for(int i = realRow; i < table.getRowCount(); i++)
+            setRowStyle(i);
+    }
+    
+    /**
+     * Returns the number of data rows in the table.  The actual number of 
+     * visible table rows is more than this, due to the header rows.
+     */
+    public int getRowCount() {
+        return table.getRowCount() - getHeaderRowCount();
+    }
+
+    /**
+     * Adds a header row to the table.  This is an extra row that is added above
+     * the row of column titles and below any other header rows that have been
+     * added.  The row consists of a single cell.
+     * @param widget A widget to add to the cell.
+     * @return The row index of the new header row.
+     */
+    public int addHeaderRow(Widget widget) {
+        int row = table.insertRow(headerRow);
+        headerRow++;
+        table.getFlexCellFormatter().setColSpan(row, 0, columns.length);
+        table.setWidget(row, 0, widget);
+        return row;
+    }
+    
+    /**
+     * Returns the number of header rows, including the column title row.
+     */
+    public int getHeaderRowCount() {
+        return headerRow + 1;
+    }
+}
diff --git a/frontend/client/src/afeclient/client/DynamicTable.java b/frontend/client/src/afeclient/client/DynamicTable.java
new file mode 100644
index 0000000..6bceca5
--- /dev/null
+++ b/frontend/client/src/afeclient/client/DynamicTable.java
@@ -0,0 +1,534 @@
+package afeclient.client;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.Comparator;
+import java.util.HashSet;
+import java.util.Iterator;
+import java.util.List;
+import java.util.Set;
+import java.util.Vector;
+
+import com.google.gwt.user.client.ui.ChangeListener;
+import com.google.gwt.user.client.ui.Composite;
+import com.google.gwt.user.client.ui.HTMLPanel;
+import com.google.gwt.user.client.ui.HorizontalPanel;
+import com.google.gwt.user.client.ui.Image;
+import com.google.gwt.user.client.ui.KeyboardListener;
+import com.google.gwt.user.client.ui.Label;
+import com.google.gwt.user.client.ui.ListBox;
+import com.google.gwt.user.client.ui.SourcesTableEvents;
+import com.google.gwt.user.client.ui.TableListener;
+import com.google.gwt.user.client.ui.TextBox;
+import com.google.gwt.user.client.ui.Widget;
+
+/**
+ * Extended DataTable supporting client-side sorting, searching, filtering, and 
+ * pagination.
+ */
+public class DynamicTable extends DataTable {
+    public static final int NO_COLUMN = -1;
+    public static final int ASCENDING = 1, DESCENDING = -1;
+    public static final String ALL_VALUES = "All values";
+    public static final String SORT_UP_IMAGE = "arrow_up.png",
+                               SORT_DOWN_IMAGE = "arrow_down.png";
+    
+    interface DynamicTableListener {
+        public void onRowClicked(int dataRow, int column);
+    }
+    
+    interface Filter {
+        public boolean isActive();
+        public boolean acceptRow(String[] row);
+        public void update();
+    }
+    
+    class ColumnFilter implements Filter {
+        public int column;
+        public ListBox select = new ListBox();
+        public boolean isManualChoices = false;
+        
+        public ColumnFilter(int column) {
+            this.column = column;
+            select.setStylePrimaryName("filter-box");
+        }
+        
+        public String getSelectedValue() {
+            return select.getItemText(select.getSelectedIndex()); 
+        }
+        
+        public boolean isActive() {
+            return !getSelectedValue().equals(ALL_VALUES);
+        }
+        
+        public boolean acceptRow(String[] row) {
+            return row[column].equals(getSelectedValue());
+        }
+        
+        public void setChoices(String[] choices) {
+            String selectedValue = null;
+            if (select.getSelectedIndex() != -1)
+                selectedValue = getSelectedValue();
+            
+            select.clear();
+            select.addItem(ALL_VALUES);
+            for (int i = 0; i < choices.length; i++)
+                select.addItem(choices[i]);
+            
+            if (selectedValue != null) {
+                setChoice(selectedValue);
+            }
+        }
+        
+        public void update() {
+            if (!isManualChoices)
+                setChoices(gatherChoices(column));
+        }
+        
+        public void setChoice(String choice) {
+            for(int i = 0; i < select.getItemCount(); i++) {
+                if(select.getItemText(i).equals(choice)) {
+                    select.setSelectedIndex(i);
+                    return;
+                }
+            }
+            
+            select.addItem(choice);
+            select.setSelectedIndex(select.getItemCount() - 1);
+        }
+    }
+    
+    class SearchFilter implements Filter {
+        public int[] searchColumns;
+        public TextBox searchBox = new TextBox();
+        
+        public SearchFilter(int[] searchColumns) {
+            this.searchColumns = searchColumns;
+            searchBox.setStylePrimaryName("filter-box");
+        }
+
+        public boolean acceptRow(String[] row) {
+            String query = searchBox.getText();
+            for (int i = 0; i < searchColumns.length; i++) {
+                if (row[searchColumns[i]].indexOf(query) != -1) {
+                    return true;
+                }
+            }
+            return false;
+        }
+
+        public boolean isActive() {
+            return !searchBox.getText().equals("");
+        }
+        
+        public void update() {}
+    }
+    
+    class SortIndicator extends Composite {
+        protected Image image = new Image();
+        
+        public SortIndicator() {
+            initWidget(image);
+            setVisible(false);
+        }
+        
+        public void sortOn(boolean up) {
+            image.setUrl(up ? SORT_UP_IMAGE : SORT_DOWN_IMAGE);
+            setVisible(true);
+        }
+        
+        public void sortOff() {
+            setVisible(false);
+        }
+    }
+    
+    protected List allData = new ArrayList(); // ArrayList<String[]>
+    protected List filteredData = new ArrayList();
+    
+    protected int sortDirection = ASCENDING;
+    protected boolean clientSortable = false;
+    protected SortIndicator[] sortIndicators;
+    protected int sortedOn = NO_COLUMN;
+    
+    protected Vector filters = new Vector();
+    protected ColumnFilter[] columnFilters;
+    protected Paginator paginator = null;
+    
+    protected DynamicTableListener listener;
+    
+    public DynamicTable(String[][] columns) {
+        super(columns);
+        columnFilters = new ColumnFilter[columns.length];
+    }
+    
+    // SORTING
+    
+    /**
+     * Makes the table client sortable, that is, sortable by the user by 
+     * clicking on column headers. 
+     */
+    public void makeClientSortable() {
+        this.clientSortable = true;
+        table.getRowFormatter().addStyleName(0, DataTable.HEADER_STYLE + "-sortable");
+        
+        sortIndicators = new SortIndicator[columns.length];
+        for(int i = 0; i < columns.length; i++) {
+            sortIndicators[i] = new SortIndicator();
+            
+            // we have to use an HTMLPanel here to preserve styles correctly and
+            // not break hover
+            // we add a <span> with a unique ID to hold the sort indicator
+            String name = columns[i][1];
+            String id = HTMLPanel.createUniqueId();
+            HTMLPanel panel = new HTMLPanel(name + 
+                                            " <span id=\"" + id + "\"></span>");
+            panel.add(sortIndicators[i], id);
+            table.setWidget(0, i, panel);
+        }
+        
+        table.addTableListener(new TableListener() {
+            public void onCellClicked(SourcesTableEvents sender, int row, int cell) {
+                if (row == headerRow) {
+                    sortOnColumn(cell);
+                    doAllFilters();
+                }
+            }
+        });
+    }
+    
+    protected Comparator getRowComparator() {
+        return new Comparator() {
+            public int compare(Object arg0, Object arg1) {
+                String[] row0 = (String[]) arg0;
+                String[] row1 = (String[]) arg1;
+                return row0[sortedOn].compareTo(row1[sortedOn]) * sortDirection;
+            }
+        };
+    }
+    
+    protected void sortData(List data) {
+        if (sortedOn == NO_COLUMN)
+            return;
+        Collections.sort(data, getRowComparator());
+    }
+    
+    /**
+     * Set column on which data is sorted.  You must call <code>updateData()
+     * </code> after this to display the results.
+     * @param column index of the column to sort on
+     */
+    public void sortOnColumn(int column) {
+        if (column == sortedOn)
+            sortDirection *= -1;
+        else
+            sortDirection = ASCENDING;
+        
+        if(clientSortable) {
+            if (sortedOn != NO_COLUMN)
+                sortIndicators[sortedOn].sortOff();
+            sortIndicators[column].sortOn(sortDirection == ASCENDING);
+        }
+        sortedOn = column;
+        
+        sortData(allData);
+    }
+    
+    // PAGINATION
+    
+    /**
+     * Add client-side pagination to this table.
+     * @param rowsPerPage size of each page
+     */
+    public void addPaginator(int rowsPerPage) {
+        paginator = new Paginator(rowsPerPage, 
+                                  new Paginator.PaginatorCallback() {
+            public void doRequest(int start) {
+                refreshDisplay();
+            }
+        });
+        
+        addHeaderRow(paginator);
+        updatePaginator();
+    }
+    
+    protected void updatePaginator() {
+        if(paginator != null)
+            paginator.setNumTotalResults(filteredData.size());
+    }
+    
+    /**
+     * Reset paginator back to first page.  You must call 
+     * <code>updateData()</code> after this to display the results.
+     */
+    public void resetPaginator() {
+        if(paginator != null)
+            paginator.setStart(0);
+    }
+    
+    /**
+     * Get the index of the first row currently displayed.
+     */
+    public int getVisibleStart() {
+        return paginator.getStart();
+    }
+    
+    /**
+     * Get the number of rows currently displayed.
+     */
+    public int getVisibleCount() {
+        return getRowCount();
+    }
+    
+    // DATA MANIPULATION
+
+    public void addRowFromData(String[] rowData) {
+        int insertPosition = allData.size();
+        if (sortedOn != NO_COLUMN) {
+            insertPosition = Collections.binarySearch(allData, rowData, 
+                                                      getRowComparator());
+            if (insertPosition < 0)
+                insertPosition = -insertPosition - 1; // see binarySearch() docs
+        }
+        allData.add(insertPosition, rowData);
+    }
+
+    /**
+     * Remove a row from the table.
+     * @param dataRow the index of the row (indexed into the table's filtered 
+     * data, not into the currently visible rows)
+     * @return the column data for the removed row
+     */
+    public String[] removeDataRow(int dataRow) {
+        String[] rowText = (String[]) filteredData.remove(dataRow);
+        allData.remove(rowText);
+        return rowText;
+    }
+
+    public void clear() {
+        super.clear();
+        allData.clear();
+        filteredData.clear();
+    }
+    
+    /**
+     * This method should be called after any changes to the table's data. It
+     * recomputes choices for column filters, runs all filters to compute 
+     * the filtered data set, and updates the display.
+     */
+    public void updateData() {
+        updateFilters();
+        doAllFilters();
+    }
+    
+    /**
+     * Get the number of rows in the currently filtered data set.
+     */
+    public int getFilteredRowCount() {
+        return filteredData.size();
+    }
+    
+    /**
+     * Get a row in the currently filtered data set.
+     * @param dataRow the index (into the filtered data set) of the row to 
+     * retrieve
+     * @return the column data for the row
+     */
+    public String[] getDataRow(int dataRow) {
+        return (String[]) filteredData.get(dataRow);
+    }
+    
+    // DISPLAY
+    
+    protected void displayRows(List rows, int start, int end) {
+        super.clear();
+        for (int i = start; i < end; i++) {
+            super.addRowFromData((String[]) rows.get(i));
+        }
+    }
+    
+    protected void displayRows(List rows) {
+        displayRows(rows, 0, rows.size());
+    }
+    
+    /**
+     * Update the table display.
+     */
+    public void refreshDisplay() {
+        if(paginator != null)
+            displayRows(filteredData, paginator.getStart(), paginator.getEnd());
+        else
+            displayRows(filteredData);
+    }
+    
+    // INPUT
+    
+    protected int visibleRowToFilteredDataRow(int visibleRow) {
+        if (visibleRow <= headerRow)
+            return -1;
+        
+        int start = 0;
+        if (paginator != null)
+            start = paginator.getStart();
+        return visibleRow - getHeaderRowCount() + start;
+    }
+    
+    /**
+     * Set a DynamicTableListener.  This differs from a normal TableListener 
+     * because the row index passed is an index into the filtered data set, 
+     * rather than an index into the visible table, which would be relatively 
+     * useless.
+     */
+    public void setListener(final DynamicTableListener listener) {
+        table.addTableListener(new TableListener() {
+            public void onCellClicked(SourcesTableEvents sender, int row, int cell) {
+                int dataRow = visibleRowToFilteredDataRow(row);
+                if (dataRow != -1)
+                    listener.onRowClicked(dataRow, cell);
+            }
+        });
+    }
+    
+    // FILTERING
+    
+    protected List getActiveFilters() {
+        List activeFilters = new ArrayList();
+        for (Iterator i = filters.iterator(); i.hasNext(); ) {
+            Filter filter = (Filter) i.next();
+            if (filter.isActive())
+                activeFilters.add(filter);
+        }
+        return activeFilters;
+    }
+    
+    protected boolean acceptRow(String[] row, List filters) {
+        for (Iterator i = filters.iterator(); i.hasNext(); ) {
+            Filter filter = (Filter) i.next();
+            if(!filter.acceptRow(row)) {
+                return false;
+            }
+        }
+        return true;
+    }
+    
+    protected void doAllFilters() {
+        filteredData.clear();
+        List activeFilters = getActiveFilters();
+        for (Iterator i = allData.iterator(); i.hasNext(); ) {
+            String[] row = (String[]) i.next();
+            if (acceptRow(row, activeFilters))
+                filteredData.add(row);
+        }
+        
+        updatePaginator();
+        refreshDisplay();
+    }
+    
+    protected int columnNameToIndex(String column) {
+        for(int col = 0; col < columns.length; col++) {
+            if (columns[col][1].equals(column))
+                    return col;
+        }
+        
+        return -1;
+    }
+    
+    /**
+     * Add an incremental search filter.  This appears as a text box which 
+     * performs a substring search on the given columns.
+     * @param searchColumns the titles of columns to perform the search on.
+     */
+    public void addSearchBox(String[] searchColumns, String label) {
+        int[] searchColumnIndices = new int[searchColumns.length];
+        for(int i = 0; i < searchColumns.length; i++) {
+            searchColumnIndices[i] = columnNameToIndex(searchColumns[i]);
+        }
+        SearchFilter searchFilter = new SearchFilter(searchColumnIndices);
+        filters.add(searchFilter);
+        
+        final HorizontalPanel searchPanel = new HorizontalPanel();
+        final Label searchLabel = new Label(label);
+        searchPanel.add(searchLabel);
+        searchPanel.add(searchFilter.searchBox);
+        addHeaderRow(searchPanel);
+        searchFilter.searchBox.addKeyboardListener(new KeyboardListener() {
+            public void onKeyPress(Widget sender, char keyCode, int modifiers) {}
+            public void onKeyDown(Widget sender, char keyCode, int modifiers) {}
+            public void onKeyUp(Widget sender, char keyCode, int modifiers) {
+                doAllFilters();
+            }
+        });
+    }
+    
+    protected String[] gatherChoices(int column) {
+        Set choices = new HashSet();
+        for(Iterator i = allData.iterator(); i.hasNext(); ) {
+            String[] row = (String[]) i.next();
+            choices.add(row[column]);
+        }
+        
+        if (choices.isEmpty())
+            return new String[0];
+        List sortedChoices = new ArrayList(choices);
+        Collections.sort(sortedChoices);
+        return (String[]) sortedChoices.toArray(new String[1]);
+    }
+    
+    /**
+     * Add a column filter.  This presents choices for the value of the given
+     * column in a list box, and filters based on the user's choice.  This
+     * method allows the caller to specify the choices for the list box.
+     * @param column the title of the column to filter on
+     * @param choices the choices for the filter box (not includes "All values")
+     */
+    public void addColumnFilter(String column, String[] choices) {
+        final ColumnFilter filter = new ColumnFilter(columnNameToIndex(column));
+        if (choices != null) {
+            filter.setChoices(choices);
+            filter.isManualChoices = true;
+        }
+        filter.select.addChangeListener(new ChangeListener() {
+            public void onChange(Widget sender) {
+                doAllFilters();
+            }
+        });
+        filters.add(filter);
+        columnFilters[columnNameToIndex(column)] = filter;
+        
+        Label filterLabel = new Label(column + ":");
+        HorizontalPanel filterPanel = new HorizontalPanel();
+        filterPanel.add(filterLabel);
+        filterPanel.add(filter.select);
+        
+        addHeaderRow(filterPanel);
+    }
+    
+    /**
+     * Add a column filter without specifying choices.  Choices will 
+     * automatically be determined by gathering all values for the given column
+     * present in the table.
+     */
+    public void addColumnFilter(String column) {
+        addColumnFilter(column, null);
+    }
+    
+    /**
+     * Set the selected choice for the column filter of the given column.
+     * @param columnName title of the column for which the filter should be set
+     * @param choice value to select in the filter
+     */
+    public void setColumnFilterChoice(String columnName, String choice) {
+        int column = columnNameToIndex(columnName);
+        ColumnFilter filter = columnFilters[column];
+        if (filter == null)
+            throw new IllegalArgumentException(
+                                           "No filter on column " + columnName);
+        filter.setChoice(choice);
+    }
+    
+    protected void updateFilters() {
+        for(Iterator i = filters.iterator(); i.hasNext(); ) {
+            Filter filter = (Filter) i.next();
+            filter.update();
+        }
+    }
+}
diff --git a/frontend/client/src/afeclient/client/ElementWidget.java b/frontend/client/src/afeclient/client/ElementWidget.java
new file mode 100644
index 0000000..c46fab4
--- /dev/null
+++ b/frontend/client/src/afeclient/client/ElementWidget.java
@@ -0,0 +1,28 @@
+package afeclient.client;
+
+import com.google.gwt.user.client.DOM;
+import com.google.gwt.user.client.Element;
+import com.google.gwt.user.client.ui.Widget;
+
+/**
+ * A simple widget that wraps an HTML element.  This allows the element to be
+ * removed from the document and added to a Panel.
+ */
+public class ElementWidget extends Widget {
+    protected Element element;
+
+    /**
+     * @param element the HTML element to wrap
+     */
+    public ElementWidget(Element element) {
+        this.element = element;
+        setElement(element);
+    }
+
+    /**
+     * Remove the wrapped element from the document.
+     */
+    public void removeFromDocument() {
+        DOM.removeChild(DOM.getParent(element), element);
+    }
+}
diff --git a/frontend/client/src/afeclient/client/HostListView.java b/frontend/client/src/afeclient/client/HostListView.java
new file mode 100644
index 0000000..c8dbc3e
--- /dev/null
+++ b/frontend/client/src/afeclient/client/HostListView.java
@@ -0,0 +1,18 @@
+package afeclient.client;
+
+import com.google.gwt.user.client.ui.RootPanel;
+
+public class HostListView extends TabView {
+    protected static final int HOSTS_PER_PAGE = 30;
+    
+    public String getElementId() {
+        return "hosts";
+    }
+    
+    protected HostTable hostTable = new HostTable(HOSTS_PER_PAGE);
+    
+    public void initialize() {
+        hostTable.getHosts();
+        RootPanel.get("hosts_list").add(hostTable);
+    }
+}
diff --git a/frontend/client/src/afeclient/client/HostSelector.java b/frontend/client/src/afeclient/client/HostSelector.java
new file mode 100644
index 0000000..cb56230
--- /dev/null
+++ b/frontend/client/src/afeclient/client/HostSelector.java
@@ -0,0 +1,200 @@
+package afeclient.client;
+
+import java.util.ArrayList;
+import java.util.List;
+
+import com.google.gwt.json.client.JSONArray;
+import com.google.gwt.user.client.ui.Button;
+import com.google.gwt.user.client.ui.ClickListener;
+import com.google.gwt.user.client.ui.HorizontalPanel;
+import com.google.gwt.user.client.ui.ListBox;
+import com.google.gwt.user.client.ui.Panel;
+import com.google.gwt.user.client.ui.RootPanel;
+import com.google.gwt.user.client.ui.TextBox;
+import com.google.gwt.user.client.ui.Widget;
+
+/**
+ * A widget to facilitate selection of a group of hosts for running a job.  The
+ * widget displays two side-by-side tables; the left table is a normal 
+ * {@link HostTable} displaying available, unselected hosts, and the right table 
+ * displays selected hosts.  Click on a host in either table moves it to the 
+ * other (i.e. selects or deselects a host).  The widget provides several 
+ * convenience controls (such as one to remove all selected hosts) and a special
+ * section for adding meta-host entries.
+ */
+public class HostSelector {
+    public static final int TABLE_SIZE = 10;
+    public static final String META_PREFIX = "Any ";
+    
+    class HostSelection {
+        public List hosts = new ArrayList();
+        public List metaHosts = new ArrayList();
+    }
+    
+    protected HostTable availableTable = new HostTable(TABLE_SIZE);
+    protected DynamicTable selectedTable = 
+        new DynamicTable(HostTable.HOST_COLUMNS);
+    
+    public HostSelector() {
+        selectedTable.setClickable(true);
+        selectedTable.addPaginator(TABLE_SIZE);
+        selectedTable.sortOnColumn(0);
+        
+        availableTable.setClickable(true);
+        availableTable.getHosts();
+        
+        availableTable.setListener(new DynamicTable.DynamicTableListener() {
+            public void onRowClicked(int dataRow, int column) {
+                selectRow(dataRow);
+            }
+        });
+        selectedTable.setListener(new DynamicTable.DynamicTableListener() {
+            public void onRowClicked(int dataRow, int column) {
+                deselectRow(dataRow);
+            }
+        });
+        
+        RootPanel.get("create_available_table").add(availableTable);
+        RootPanel.get("create_selected_table").add(selectedTable);
+        
+        Button addVisibleButton = new Button("Add currently displayed");
+        addVisibleButton.addClickListener(new ClickListener() {
+            public void onClick(Widget sender) {
+                addVisible();
+            }
+        });
+        Button addFilteredButton = new Button("Add all filtered");
+        addFilteredButton.addClickListener(new ClickListener() {
+            public void onClick(Widget sender) {
+                moveAll(availableTable, selectedTable);
+            }
+        });
+        Button removeAllButton = new Button("Remove all");
+        removeAllButton.addClickListener(new ClickListener() {
+            public void onClick(Widget sender) {
+                moveAll(selectedTable, availableTable);
+            }
+        });
+        
+        Panel availableControls = new HorizontalPanel();
+        availableControls.add(addVisibleButton);
+        availableControls.add(addFilteredButton);
+        RootPanel.get("create_available_controls").add(availableControls);
+        RootPanel.get("create_selected_controls").add(removeAllButton);
+        
+        final ListBox metaLabelSelect = new ListBox();
+        populateLabels(metaLabelSelect);
+        final TextBox metaNumber = new TextBox();
+        metaNumber.setVisibleLength(4);
+        final Button metaButton = new Button("Add");
+        metaButton.addClickListener(new ClickListener() {
+            public void onClick(Widget sender) {
+                int selected = metaLabelSelect.getSelectedIndex();
+                String label = metaLabelSelect.getItemText(selected);
+                String number = metaNumber.getText();
+                try {
+                    Integer.parseInt(number);
+                }
+                catch (NumberFormatException exc) {
+                    String error = "Invalid number " + number;
+                    NotifyManager.getInstance().showError(error);
+                    return;
+                }
+                String[] rowData = new String[4];
+                rowData[0] = META_PREFIX + number;
+                rowData[1] = label;
+                rowData[2] = rowData[3] = "";
+                selectedTable.addRowFromData(rowData);
+                selectedTable.updateData();
+            }
+        });
+        RootPanel.get("create_meta_select").add(metaLabelSelect);
+        RootPanel.get("create_meta_number").add(metaNumber);
+        RootPanel.get("create_meta_button").add(metaButton);
+    }
+    
+    protected void moveRow(int row, DynamicTable from, DataTable to) {
+        String[] rowData = from.removeDataRow(row);
+        if(isMetaEntry(rowData))
+            return;
+        to.addRowFromData(rowData);
+    }
+    
+    protected void updateAfterMove(DynamicTable from, DynamicTable to) {
+        from.updateData();
+        to.updateData();
+    }
+    
+    protected void selectRow(int row) {
+        moveRow(row, availableTable, selectedTable);
+        updateAfterMove(availableTable, selectedTable);
+    }
+    
+    protected void deselectRow(int row) {
+        moveRow(row, selectedTable, availableTable);
+        updateAfterMove(selectedTable, availableTable);
+    }
+    
+    protected void addVisible() {
+        int start = availableTable.getVisibleStart();
+        int count = availableTable.getVisibleCount();
+        for (int i = 0; i < count; i++) {
+            moveRow(start, availableTable, selectedTable);
+        }
+        updateAfterMove(availableTable, selectedTable);
+    }
+    
+    protected void moveAll(DynamicTable from, DynamicTable to) {
+        int total = from.getFilteredRowCount();
+        for (int i = 0; i < total; i++) {
+            moveRow(0, from, to);
+        }
+        updateAfterMove(from, to);
+    }
+    
+    protected void populateLabels(ListBox list) {
+        StaticDataRepository staticData = StaticDataRepository.getRepository();
+        JSONArray labels = staticData.getData("labels").isArray();
+        for(int i = 0; i < labels.size(); i++) {
+            list.addItem(labels.get(i).isString().stringValue());
+        }
+    }
+    
+    protected boolean isMetaEntry(String[] row) {
+        return row[0].startsWith(META_PREFIX);
+    }
+    
+    protected int getMetaNumber(String[] row) {
+        return Integer.parseInt(row[0].substring(META_PREFIX.length()));
+    }
+    
+    /**
+     * Retrieve the set of selected hosts.
+     */
+    public HostSelection getSelectedHosts() {
+        HostSelection selection = new HostSelection();
+        for(int i = 0; i < selectedTable.getFilteredRowCount(); i++) {
+            String[] row = selectedTable.getDataRow(i);
+            if (isMetaEntry(row)) {
+                int count =  getMetaNumber(row);
+                String platform = row[1];
+                for(int counter = 0; counter < count; counter++) {
+                    selection.metaHosts.add(platform);
+                }
+            }
+            else {
+                String hostname = row[0];
+                selection.hosts.add(hostname);
+            }
+        }
+        
+        return selection;
+    }
+    
+    /**
+     * Reset the widget (deselect all hosts).
+     */
+    public void reset() {
+        moveAll(selectedTable, availableTable);
+    }
+}
diff --git a/frontend/client/src/afeclient/client/HostTable.java b/frontend/client/src/afeclient/client/HostTable.java
new file mode 100644
index 0000000..ac1776b
--- /dev/null
+++ b/frontend/client/src/afeclient/client/HostTable.java
@@ -0,0 +1,58 @@
+package afeclient.client;
+
+import com.google.gwt.json.client.JSONArray;
+import com.google.gwt.json.client.JSONObject;
+import com.google.gwt.json.client.JSONString;
+import com.google.gwt.json.client.JSONValue;
+
+/**
+ * A table to display hosts.
+ */
+public class HostTable extends DynamicTable {
+    protected static final String LOCKED_TEXT = "locked_text";
+    public static final String[][] HOST_COLUMNS = {
+        {"hostname", "Hostname"}, {"platform", "Platform"}, 
+        {"status", "Status"}, {LOCKED_TEXT, "Locked"}
+    };
+    
+    protected static JSONArray hosts = null;
+    
+    protected static final JsonRpcProxy rpcProxy = JsonRpcProxy.getProxy();
+    
+    public HostTable(int hostsPerPage) {
+        super(HOST_COLUMNS);
+        makeClientSortable();
+        
+        String[] searchColumns = {"Hostname"};
+        addSearchBox(searchColumns, "Hostname:");
+        addColumnFilter("Platform");
+        addColumnFilter("Status");
+        addColumnFilter("Locked");
+        
+        addPaginator(hostsPerPage);
+    }
+    
+    protected void preprocessRow(JSONObject row) {
+        super.preprocessRow(row);
+        boolean locked = row.get("locked").isNumber().getValue() > 0;
+        String lockedText = locked ? "Yes" : "No";
+        row.put(LOCKED_TEXT, new JSONString(lockedText));
+    }
+    
+    public void getHosts() {
+        clear();
+        JsonRpcCallback handleHosts = new JsonRpcCallback() {
+            public void onSuccess(JSONValue result) {
+                hosts = result.isArray();
+                addRows(hosts);
+                sortOnColumn(0);
+                updateData();
+            }
+        };
+        
+        if(hosts == null)
+            rpcProxy.rpcCall("get_hosts", null, handleHosts);
+        else
+            handleHosts.onSuccess(hosts);
+    }
+}
diff --git a/frontend/client/src/afeclient/client/JobDetailView.java b/frontend/client/src/afeclient/client/JobDetailView.java
new file mode 100644
index 0000000..35fdda1
--- /dev/null
+++ b/frontend/client/src/afeclient/client/JobDetailView.java
@@ -0,0 +1,230 @@
+package afeclient.client;
+
+import com.google.gwt.json.client.JSONArray;
+import com.google.gwt.json.client.JSONNumber;
+import com.google.gwt.json.client.JSONObject;
+import com.google.gwt.json.client.JSONString;
+import com.google.gwt.json.client.JSONValue;
+import com.google.gwt.user.client.DOM;
+import com.google.gwt.user.client.ui.Button;
+import com.google.gwt.user.client.ui.ClickListener;
+import com.google.gwt.user.client.ui.KeyboardListener;
+import com.google.gwt.user.client.ui.RootPanel;
+import com.google.gwt.user.client.ui.TextBox;
+import com.google.gwt.user.client.ui.Widget;
+
+import java.util.Iterator;
+import java.util.Set;
+
+public class JobDetailView extends TabView {
+    public static final String NO_URL = "about:blank";
+    public static final String NO_JOB = "No job selected";
+    public static final String GO_TEXT = "Go";
+    public static final String REFRESH_TEXT = "Refresh";
+    public static final int NO_JOB_ID = -1;
+    
+    public String getElementId() {
+        return "view_job";
+    }
+    
+    protected JsonRpcProxy rpcProxy = JsonRpcProxy.getProxy();
+
+    protected int jobId = NO_JOB_ID;
+
+    protected RootPanel allJobData;
+    
+    protected JobHostsTable hostsTable;
+    protected TextBox idInput = new TextBox();
+    protected Button idFetchButton = new Button(GO_TEXT);
+    protected Button abortButton = new Button("Abort job");
+    
+    protected void showText(String text, String elementId) {
+        DOM.setInnerText(RootPanel.get(elementId).getElement(), text);
+    }
+
+    protected void showField(JSONObject job, String field, String elementId) {
+        JSONString jsonString = job.get(field).isString();
+        String value = "";
+        if (jsonString != null)
+            value = jsonString.stringValue();
+        showText(value, elementId);
+    }
+
+    public void setJobID(int id) {
+        this.jobId = id;
+        idInput.setText(Integer.toString(id));
+        idFetchButton.setText(REFRESH_TEXT);
+        refresh();
+    }
+
+    public void resetPage() {
+        showText(NO_JOB, "view_title");
+        allJobData.setVisible(false);
+    }
+
+    public void refresh() {
+        pointToResults(NO_URL, NO_URL);
+        JSONObject params = new JSONObject();
+        params.put("id", new JSONNumber(jobId));
+        rpcProxy.rpcCall("get_jobs_summary", params, new JsonRpcCallback() {
+            public void onSuccess(JSONValue result) {
+                JSONArray resultArray = result.isArray();
+                if(resultArray.size() == 0) {
+                    NotifyManager.getInstance().showError("No such job found");
+                    resetPage();
+                    return;
+                }
+                JSONObject jobObject = resultArray.get(0).isObject();
+                String name = jobObject.get("name").isString().stringValue();
+                String owner = jobObject.get("owner").isString().stringValue();
+                String jobLogsId = jobId + "-" + owner;
+                String title = "Job: " + name + " (" + jobLogsId + ")";
+                showText(title, "view_title");
+                
+                showText(name, "view_label");
+                showText(owner, "view_owner");
+                showField(jobObject, "priority", "view_priority");
+                showField(jobObject, "created_on", "view_created");
+                showField(jobObject, "control_type", "view_control_type");
+                showField(jobObject, "control_file", "view_control_file");
+                
+                String synchType = jobObject.get("synch_type").isString().stringValue();
+                showText(synchType.toLowerCase(), "view_synch_type");
+                
+                JSONObject counts = jobObject.get("status_counts").isObject();
+                String countString = Utils.formatStatusCounts(counts, ", ");
+                showText(countString, "view_status");
+                abortButton.setVisible(!allFinishedCounts(counts));
+                
+                pointToResults(getResultsURL(jobId), getLogsURL(jobLogsId));
+                
+                allJobData.setVisible(true);
+                
+                hostsTable.getHosts(jobId);
+            }
+
+            public void onError(JSONObject errorObject) {
+                super.onError(errorObject);
+                resetPage();
+            }
+        });
+    }
+    
+    protected boolean allFinishedCounts(JSONObject statusCounts) {
+        Set keys = statusCounts.keySet();
+        for (Iterator i = keys.iterator(); i.hasNext(); ) {
+            String key = (String) i.next();
+            if (!(key.equals("Completed") || 
+                  key.equals("Failed") ||
+                  key.equals("Aborted") ||
+                  key.equals("Stopped")))
+                return false;
+        }
+        return true;
+    }
+    
+    public void fetchById() {
+        String id = idInput.getText();
+        try {
+            setJobID(Integer.parseInt(id));
+            updateHistory();
+        }
+        catch (NumberFormatException exc) {
+            String error = "Invalid job ID " + id;
+            NotifyManager.getInstance().showError(error);
+        }
+    }
+
+    public void initialize() {
+        allJobData = RootPanel.get("view_data");
+        
+        resetPage();
+        
+        RootPanel.get("job_id_fetch_controls").add(idInput);
+        RootPanel.get("job_id_fetch_controls").add(idFetchButton);
+        idInput.setVisibleLength(5);
+        idInput.addKeyboardListener(new KeyboardListener() {
+            public void onKeyPress(Widget sender, char keyCode, int modifiers) {
+                if (keyCode == (char) KEY_ENTER)
+                    fetchById();
+            }
+
+            public void onKeyDown(Widget sender, char keyCode, int modifiers) {}
+            public void onKeyUp(Widget sender, char keyCode, int modifiers) {}
+        });
+        idFetchButton.addClickListener(new ClickListener() {
+            public void onClick(Widget sender) {
+                fetchById();
+            }
+        });
+        idInput.addKeyboardListener(new KeyboardListener() {
+            public void onKeyPress(Widget sender, char keyCode, int modifiers) {
+                idFetchButton.setText(GO_TEXT);
+            }
+            public void onKeyDown(Widget sender, char keyCode, int modifiers) {}
+            public void onKeyUp(Widget sender, char keyCode, int modifiers) {} 
+        });
+        
+        hostsTable = new JobHostsTable(rpcProxy);
+        RootPanel.get("job_hosts_table").add(hostsTable);
+        
+        abortButton.addClickListener(new ClickListener() {
+            public void onClick(Widget sender) {
+                abortJob();
+            }
+        });
+        RootPanel.get("view_abort").add(abortButton);
+    }
+    
+    protected void abortJob() {
+        JSONObject params = new JSONObject();
+        params.put("id", new JSONNumber(jobId));
+        rpcProxy.rpcCall("abort_job", params, new JsonRpcCallback() {
+            public void onSuccess(JSONValue result) {
+                refresh();
+            }
+        });
+    }
+    
+    protected String getResultsURL(int jobId) {
+        return "/tko/compose_query.cgi?" +
+               "columns=test&rows=hostname&condition=tag%7E%27" + 
+               Integer.toString(jobId) + "-%25%27&title=Report";
+    }
+    
+    /**
+     * Get the path for a job's raw result files.
+     * @param jobLogsId id-owner, e.g. "172-showard"
+     */
+    protected String getLogsURL(String jobLogsId) {
+        return "/results/" + jobLogsId;
+    }
+    
+    protected void pointToResults(String resultsUrl, String logsUrl) {
+        DOM.setElementProperty(DOM.getElementById("results_link"),
+                               "href", resultsUrl);
+        DOM.setElementProperty(DOM.getElementById("results_iframe"),
+                               "src", resultsUrl);
+        DOM.setElementProperty(DOM.getElementById("raw_results_link"),
+                               "href", logsUrl);
+    }
+    
+    public String getHistoryToken() {
+        String token = super.getHistoryToken();
+        if (jobId != NO_JOB_ID)
+            token += "_" + jobId;
+        return token;
+    }
+
+    public void handleHistoryToken(String token) {
+        int newJobId;
+        try {
+            newJobId = Integer.parseInt(token);
+        }
+        catch (NumberFormatException exc) {
+            return;
+        }
+        if (newJobId != jobId)
+            setJobID(newJobId);
+    }
+}
diff --git a/frontend/client/src/afeclient/client/JobHostsTable.java b/frontend/client/src/afeclient/client/JobHostsTable.java
new file mode 100644
index 0000000..08e7944
--- /dev/null
+++ b/frontend/client/src/afeclient/client/JobHostsTable.java
@@ -0,0 +1,65 @@
+package afeclient.client;
+
+import java.util.Iterator;
+import java.util.Set;
+
+import com.google.gwt.json.client.JSONNumber;
+import com.google.gwt.json.client.JSONObject;
+import com.google.gwt.json.client.JSONString;
+import com.google.gwt.json.client.JSONValue;
+
+/**
+ * A table to display host queue entries associated with a job.
+ */
+public class JobHostsTable extends DynamicTable {
+    public static final int HOSTS_PER_PAGE = 30;
+    
+    protected JsonRpcProxy rpcProxy;
+    
+    public static final String[][] JOB_HOSTS_COLUMNS = {
+        {"hostname", "Host"}, {"status", "Status"}
+    };
+    
+    public JobHostsTable(JsonRpcProxy proxy) {
+        super(JOB_HOSTS_COLUMNS);
+        this.rpcProxy = proxy;
+        
+        makeClientSortable();
+        
+        String[] searchColumns = {"Host"};
+        addSearchBox(searchColumns, "Hostname:");
+        addColumnFilter("Status");
+        addPaginator(HOSTS_PER_PAGE);
+    }
+    
+    public void getHosts(int jobId) {
+        clear();
+        JSONObject params = new JSONObject();
+        params.put("id", new JSONNumber(jobId));
+        rpcProxy.rpcCall("job_status", params, new JsonRpcCallback() {
+            public void onSuccess(JSONValue result) {
+                JSONObject resultObj = result.isObject();
+                Set hostnames = resultObj.keySet();
+                for(Iterator i = hostnames.iterator(); i.hasNext(); ) {
+                    String host = (String) i.next();
+                    JSONObject hostData = resultObj.get(host).isObject();
+                    String status = hostData.get("status").isString().stringValue();
+                    JSONValue metaCountValue = hostData.get("meta_count");
+                    if (metaCountValue.isNull() == null) {
+                        int metaCount = (int) metaCountValue.isNumber().getValue();
+                        host += " (label)";
+                        status = Integer.toString(metaCount) + " unassigned";
+                    }
+                    
+                    JSONObject row = new JSONObject();
+                    row.put("hostname", new JSONString(host));
+                    row.put("status", new JSONString(status));
+                    addRow(row);
+                }
+                
+                sortOnColumn(0);
+                updateData();
+            }
+        });
+    }
+}
diff --git a/frontend/client/src/afeclient/client/JobListView.java b/frontend/client/src/afeclient/client/JobListView.java
new file mode 100644
index 0000000..870fb64
--- /dev/null
+++ b/frontend/client/src/afeclient/client/JobListView.java
@@ -0,0 +1,236 @@
+package afeclient.client;
+
+import com.google.gwt.json.client.JSONArray;
+import com.google.gwt.json.client.JSONBoolean;
+import com.google.gwt.json.client.JSONNull;
+import com.google.gwt.json.client.JSONNumber;
+import com.google.gwt.json.client.JSONObject;
+import com.google.gwt.json.client.JSONString;
+import com.google.gwt.json.client.JSONValue;
+import com.google.gwt.user.client.ui.Button;
+import com.google.gwt.user.client.ui.ChangeListener;
+import com.google.gwt.user.client.ui.ClickListener;
+import com.google.gwt.user.client.ui.HorizontalPanel;
+import com.google.gwt.user.client.ui.Hyperlink;
+import com.google.gwt.user.client.ui.ListBox;
+import com.google.gwt.user.client.ui.RootPanel;
+import com.google.gwt.user.client.ui.Widget;
+
+public class JobListView extends TabView {
+    protected static final String ALL_USERS = "All Users";
+    protected static final String SELECTED_LINK_STYLE = "selected-link";
+    protected static final int JOBS_PER_PAGE = 30;
+    protected static final String QUEUED_TOKEN = "queued", 
+        RUNNING_TOKEN = "running", FINISHED_TOKEN = "finished", 
+        ALL_TOKEN = "all";
+    
+    public String getElementId() {
+        return "job_list";
+    }
+
+    protected JsonRpcProxy rpcProxy = JsonRpcProxy.getProxy();
+    protected JSONObject jobFilterArgs = new JSONObject();
+    protected JobTable.JobTableListener selectListener;
+
+    protected JobTable jobTable;
+    protected Hyperlink jobQueueLink, jobRunningLink, jobHistoryLink, 
+                        allJobsLink, selectedLink = null;
+    protected Button refreshButton;
+    protected ListBox userList;
+    protected Paginator paginator;
+
+    protected Hyperlink nextLink, prevLink;
+
+    protected void addCommonParams() {
+        jobFilterArgs.put("query_limit", new JSONNumber(JOBS_PER_PAGE));
+        jobFilterArgs.put("sort_by", new JSONString("-id"));
+    }
+
+    protected void addUserParam() {
+        String user = userList.getValue(userList.getSelectedIndex());
+        JSONValue value = JSONNull.getInstance();
+        if (!user.equals(ALL_USERS))
+            value = new JSONString(user);
+        jobFilterArgs.put("owner", value);
+    }
+    
+    protected void onFiltersChanged() {
+        addUserParam();
+        addCommonParams();
+        resetPagination();
+    }
+
+    protected void selectQueued() {
+        selectLink(jobQueueLink);
+        jobFilterArgs = new JSONObject();
+        jobFilterArgs.put("not_yet_run", JSONBoolean.getInstance(true));
+        onFiltersChanged();
+    }
+    
+    protected void selectRunning() {
+        selectLink(jobRunningLink);
+        jobFilterArgs = new JSONObject();
+        jobFilterArgs.put("running", JSONBoolean.getInstance(true));
+        onFiltersChanged();
+    }
+
+    protected void selectHistory() {
+        selectLink(jobHistoryLink);
+        jobFilterArgs = new JSONObject();
+        jobFilterArgs.put("finished", JSONBoolean.getInstance(true));
+        onFiltersChanged();
+    }
+    
+    protected void selectAll() {
+        selectLink(allJobsLink);
+        jobFilterArgs = new JSONObject();
+        onFiltersChanged();
+    }
+
+    protected void refresh() {
+        updateNumJobs();
+        jobTable.getJobs(jobFilterArgs);
+    }
+
+    protected void updateNumJobs() {
+        rpcProxy.rpcCall("get_num_jobs", jobFilterArgs, new JsonRpcCallback() {
+            public void onSuccess(JSONValue result) {
+                int numJobs = (int) result.isNumber().getValue();
+                paginator.setNumTotalResults(numJobs);
+            }
+        });
+    }
+
+    protected void populateUsers() {
+        userList.addItem(ALL_USERS);
+        
+        StaticDataRepository staticData = StaticDataRepository.getRepository();
+        JSONArray userArray = staticData.getData("users").isArray();
+        String currentUser = staticData.getData("user_login").isString().stringValue();
+        int numUsers = userArray.size();
+        for (int i = 0; i < numUsers; i++) {
+            String name = userArray.get(i).isString().stringValue();
+            userList.addItem(name);
+            if (name.equals(currentUser))
+                userList.setSelectedIndex(i + 1); // +1 for "All users" at top
+        }
+    }
+
+    protected void selectLink(Hyperlink link) {
+        jobQueueLink.removeStyleName(SELECTED_LINK_STYLE);
+        jobRunningLink.removeStyleName(SELECTED_LINK_STYLE);
+        jobHistoryLink.removeStyleName(SELECTED_LINK_STYLE);
+        allJobsLink.removeStyleName(SELECTED_LINK_STYLE);
+        link.addStyleName(SELECTED_LINK_STYLE);
+        selectedLink = link;
+    }
+    
+    protected void resetPagination() {
+        jobFilterArgs.put("query_start", new JSONNumber(0));
+        paginator.setStart(0);
+    }
+
+    public JobListView(JobTable.JobTableListener listener) {
+        selectListener = listener;
+    }
+    
+    public void initialize() {
+        jobTable = new JobTable(selectListener);
+        jobTable.setClickable(true);
+        RootPanel.get("job_table").add(jobTable);
+        
+        paginator = new Paginator(JOBS_PER_PAGE, new Paginator.PaginatorCallback() {
+            public void doRequest(int start) {
+                jobFilterArgs.put("query_start", new JSONNumber(start));
+                refresh();
+            }
+        });
+        RootPanel.get("job_pagination").add(paginator);
+        
+        ClickListener linkListener = new ClickListener() {
+            public void onClick(Widget sender) {
+                Hyperlink senderLink = (Hyperlink) sender;
+                String fullToken = senderLink.getTargetHistoryToken();
+                int prefixLength = JobListView.super.getHistoryToken().length();
+                String linkToken = fullToken.substring(prefixLength + 1); // +1 for underscore
+                handleHistoryToken(linkToken);
+            }
+        };
+
+        jobQueueLink = new Hyperlink("Queued Jobs", 
+                                     super.getHistoryToken() + "_" + QUEUED_TOKEN);
+        jobQueueLink.addClickListener(linkListener);
+        jobQueueLink.setStyleName("job-filter-link");
+        
+        jobRunningLink = new Hyperlink("Running Jobs",
+                                       super.getHistoryToken() + "_" + RUNNING_TOKEN);
+        jobRunningLink.addClickListener(linkListener);
+        jobRunningLink.setStyleName("job-filter-link");
+
+        jobHistoryLink = new Hyperlink("Finished Jobs",
+                                       super.getHistoryToken() + "_" + FINISHED_TOKEN);
+        jobHistoryLink.addClickListener(linkListener);
+        jobHistoryLink.setStyleName("job-filter-link");
+        
+        allJobsLink = new Hyperlink("All Jobs",
+                                    super.getHistoryToken() + "_" + ALL_TOKEN);
+        allJobsLink.addClickListener(linkListener);
+        allJobsLink.setStyleName("job-filter-link");
+
+        HorizontalPanel jobLinks = new HorizontalPanel();
+        RootPanel.get("job_control_links").add(jobLinks);
+        jobLinks.add(jobQueueLink);
+        jobLinks.add(jobRunningLink);
+        jobLinks.add(jobHistoryLink);
+        jobLinks.add(allJobsLink);
+        
+        refreshButton = new Button("Refresh");
+        refreshButton.addClickListener(new ClickListener() {
+            public void onClick(Widget sender) {
+                refresh();
+            }
+        });
+        jobLinks.add(refreshButton);
+
+        userList = new ListBox();
+        userList.addChangeListener(new ChangeListener() {
+            public void onChange(Widget sender) {
+                addUserParam();
+                resetPagination();
+                refresh();
+            }
+        });
+        populateUsers();
+        RootPanel.get("user_list").add(userList);
+
+        // all jobs is selected by default
+        selectAll();
+    }
+
+    public String getHistoryToken() {
+        return selectedLink.getTargetHistoryToken();
+    }
+    
+    public void handleHistoryToken(String token) {
+        if (token.equals(QUEUED_TOKEN)) {
+            selectQueued();
+        }
+        else if (token.equals(RUNNING_TOKEN)) {
+            selectRunning();
+        }
+        else if (token.equals(FINISHED_TOKEN)) {
+            selectHistory();
+        }
+        else if (token.equals(ALL_TOKEN)) {
+            selectAll();
+        }
+    }
+
+    /**
+     * Override to refresh the list every time the tab is displayed.
+     */
+    public void display() {
+        super.display();
+        refresh();
+    }
+}
diff --git a/frontend/client/src/afeclient/client/JobTable.java b/frontend/client/src/afeclient/client/JobTable.java
new file mode 100644
index 0000000..675b9e5
--- /dev/null
+++ b/frontend/client/src/afeclient/client/JobTable.java
@@ -0,0 +1,65 @@
+package afeclient.client;
+
+import com.google.gwt.json.client.JSONArray;
+import com.google.gwt.json.client.JSONObject;
+import com.google.gwt.json.client.JSONString;
+import com.google.gwt.json.client.JSONValue;
+import com.google.gwt.user.client.ui.SourcesTableEvents;
+import com.google.gwt.user.client.ui.TableListener;
+
+/**
+ * A table to display jobs, including a summary of host queue entries.
+ */
+public class JobTable extends DataTable {
+    public static final String HOSTS_SUMMARY = "hosts_summary";
+    public static final String CREATED_TEXT = "created_text";
+    
+    interface JobTableListener {
+        public void onJobClicked(int jobId);
+    }
+
+    protected JsonRpcProxy rpcProxy = JsonRpcProxy.getProxy();
+
+    public static final String[][] JOB_COLUMNS = { { "id", "ID" },
+            { "owner", "Owner" }, { "name", "Name" },
+            { "priority", "Priority" }, { "control_type", "Client/Server" },
+            { CREATED_TEXT, "Created" }, { HOSTS_SUMMARY, "Status" } };
+
+    public JobTable(final JobTableListener listener) {
+        super(JOB_COLUMNS);
+        
+        if (listener != null) {
+            table.addTableListener(new TableListener() {
+                public void onCellClicked(SourcesTableEvents sender, int row, int cell) {
+                    int jobId = Integer.parseInt(table.getHTML(row, 0));
+                    listener.onJobClicked(jobId);
+                }
+            });
+        }
+    }
+
+    protected void preprocessRow(JSONObject row) {
+        JSONObject counts = row.get("status_counts").isObject();
+        String countString = Utils.formatStatusCounts(counts, "<br>");
+        row.put(HOSTS_SUMMARY, new JSONString(countString));
+        
+        // remove seconds from created time
+        JSONValue createdValue = row.get("created_on");
+        String created = "";
+        if (createdValue.isNull() == null) {
+            created = createdValue.isString().stringValue();
+            created = created.substring(0, created.length() - 3);
+        }
+        row.put(CREATED_TEXT, new JSONString(created));
+    }
+
+    public void getJobs(JSONObject filterInfo) {
+        rpcProxy.rpcCall("get_jobs_summary", filterInfo, new JsonRpcCallback() {
+            public void onSuccess(JSONValue result) {
+                clear();
+                JSONArray jobs = result.isArray();
+                addRows(jobs);
+            }
+        });
+    }
+}
diff --git a/frontend/client/src/afeclient/client/JsonRpcCallback.java b/frontend/client/src/afeclient/client/JsonRpcCallback.java
new file mode 100644
index 0000000..f0c0c92
--- /dev/null
+++ b/frontend/client/src/afeclient/client/JsonRpcCallback.java
@@ -0,0 +1,14 @@
+package afeclient.client;
+
+import com.google.gwt.json.client.JSONObject;
+import com.google.gwt.json.client.JSONValue;
+
+abstract class JsonRpcCallback {
+    public abstract void onSuccess(JSONValue result);
+    public void onError(JSONObject errorObject) {
+        String name = errorObject.get("name").isString().stringValue();
+        String message = errorObject.get("message").isString().stringValue();
+        String errorString =  name + ": " + message;
+        NotifyManager.getInstance().showError(errorString);
+    }
+}
diff --git a/frontend/client/src/afeclient/client/JsonRpcProxy.java b/frontend/client/src/afeclient/client/JsonRpcProxy.java
new file mode 100644
index 0000000..93825a8
--- /dev/null
+++ b/frontend/client/src/afeclient/client/JsonRpcProxy.java
@@ -0,0 +1,109 @@
+package afeclient.client;
+
+import com.google.gwt.json.client.JSONArray;
+import com.google.gwt.json.client.JSONException;
+import com.google.gwt.json.client.JSONNull;
+import com.google.gwt.json.client.JSONNumber;
+import com.google.gwt.json.client.JSONObject;
+import com.google.gwt.json.client.JSONParser;
+import com.google.gwt.json.client.JSONString;
+import com.google.gwt.json.client.JSONValue;
+import com.google.gwt.user.client.HTTPRequest;
+import com.google.gwt.user.client.ResponseTextHandler;
+
+import java.util.Iterator;
+import java.util.Set;
+
+/**
+ * A singleton class to facilitate RPC calls to the server.
+ */
+public class JsonRpcProxy {
+    public static final JsonRpcProxy theInstance = new JsonRpcProxy();
+    
+    protected NotifyManager notify = NotifyManager.getInstance();
+    
+    protected String url;
+    
+    // singleton
+    private JsonRpcProxy() {}
+    
+    public static JsonRpcProxy getProxy() {
+        return theInstance;
+    }
+    
+    /**
+     * Set the URL to which requests are sent.
+     */
+    public void setUrl(String url) {
+        this.url = url;
+    }
+
+    protected JSONArray processParams(JSONObject params) {
+        JSONArray result = new JSONArray();
+        JSONObject newParams = new JSONObject();
+        if (params != null) {
+            Set keys = params.keySet();
+            for (Iterator i = keys.iterator(); i.hasNext(); ) {
+                String key = (String) i.next();
+                if (params.get(key) != JSONNull.getInstance())
+                    newParams.put(key, params.get(key));
+            }
+        }
+        result.set(0, newParams);
+        return result;
+    }
+
+    /**
+     * Make an RPC call.
+     * @param method name of the method to call
+     * @param params dictionary of parameters to pass
+     * @param callback callback to be notified of RPC call results
+     * @return true if the call was successfully initiated
+     */
+    public boolean rpcCall(String method, JSONObject params,
+                           final JsonRpcCallback callback) {
+        //GWT.log("RPC " + method, null);
+        //GWT.log("args: " + params, null);
+        JSONObject request = new JSONObject();
+        request.put("method", new JSONString(method));
+        request.put("params", processParams(params));
+        request.put("id", new JSONNumber(0));
+        
+        notify.setLoading(true);
+
+        boolean success = HTTPRequest.asyncPost(url, 
+                                                request.toString(),
+                                                new ResponseTextHandler() {
+            public void onCompletion(String responseText) {
+                //GWT.log("Response: " + responseText, null);
+                
+                notify.setLoading(false);
+                
+                JSONValue responseValue = null;
+                try {
+                    responseValue = JSONParser.parse(responseText);
+                }
+                catch (JSONException exc) {
+                    notify.showError(exc.toString());
+                    return;
+                }
+                
+                JSONObject responseObject = responseValue.isObject();
+                JSONObject error = responseObject.get("error").isObject();
+                if (error != null) {
+                    callback.onError(error);
+                    return;
+                }
+
+                JSONValue result = responseObject.get("result");
+                callback.onSuccess(result);
+
+                // Element error_iframe =
+                // DOM.getElementById("error_iframe");
+                // DOM.setInnerHTML(error_iframe,
+                // responseText);
+                }
+            });
+        return success;
+    }
+}
diff --git a/frontend/client/src/afeclient/client/NotifyManager.java b/frontend/client/src/afeclient/client/NotifyManager.java
new file mode 100644
index 0000000..3663101
--- /dev/null
+++ b/frontend/client/src/afeclient/client/NotifyManager.java
@@ -0,0 +1,86 @@
+package afeclient.client;
+
+import com.google.gwt.user.client.ui.Label;
+import com.google.gwt.user.client.ui.PopupPanel;
+
+/**
+ * A singleton class to manage popup notifications, including error messages and
+ * the "loading..." box.
+ */
+public class NotifyManager {
+    // singleton
+    public static final NotifyManager theInstance = new NotifyManager();
+    
+    class NotifyBox {
+        protected PopupPanel panel;
+        protected Label message = new Label();
+        
+        public NotifyBox(boolean autoHide) {
+            message.setStyleName("notify");
+            panel = new PopupPanel(autoHide);
+            panel.add(message);
+        }
+        
+        public void addStyle(String style) {
+            message.addStyleName(style);
+        }
+        
+        public void hide() {
+            panel.hide();
+        }
+        
+        public void show() {
+            panel.setPopupPosition(0, 0);
+            panel.show();
+        }
+        
+        public void showMessage(String messageString) {
+            message.setText(messageString);
+            show();
+        }
+    }
+    
+    protected NotifyBox errorNotify = new NotifyBox(true);
+    protected NotifyBox messageNotify = new NotifyBox(true);
+    protected NotifyBox loadingNotify = new NotifyBox(false);
+    
+    private NotifyManager() {
+        errorNotify.addStyle("error");
+    }
+    
+    /**
+     * Should be called a page loading time.
+     */
+    public void initialize() {
+        errorNotify.hide();
+        messageNotify.hide();
+    }
+    
+    public static NotifyManager getInstance() {
+        return theInstance;
+    }
+    
+    /**
+     * Show an error message.
+     */
+    public void showError(String error) {
+        errorNotify.showMessage(error);
+    }
+    
+    /**
+     * Show a notification message.
+     */
+    public void showMessage(String message) {
+        messageNotify.showMessage(message);
+    }
+    
+    /**
+     * Set whether the loading box is displayed or not.
+     */
+    public void setLoading(boolean visible) {
+        if (visible)
+            loadingNotify.showMessage("Loading...");
+        else
+            loadingNotify.hide();
+    }
+}
diff --git a/frontend/client/src/afeclient/client/Paginator.java b/frontend/client/src/afeclient/client/Paginator.java
new file mode 100644
index 0000000..6b40c1f
--- /dev/null
+++ b/frontend/client/src/afeclient/client/Paginator.java
@@ -0,0 +1,171 @@
+package afeclient.client;
+
+import com.google.gwt.user.client.ui.ClickListener;
+import com.google.gwt.user.client.ui.Composite;
+import com.google.gwt.user.client.ui.FlowPanel;
+import com.google.gwt.user.client.ui.HorizontalPanel;
+import com.google.gwt.user.client.ui.Hyperlink;
+import com.google.gwt.user.client.ui.Label;
+import com.google.gwt.user.client.ui.Panel;
+import com.google.gwt.user.client.ui.Widget;
+
+/**
+ * A widget to faciliate pagination of tables.  Shows currently displayed rows, 
+ * total row count, and buttons for moving among pages.
+ */
+public class Paginator extends Composite {
+
+    public interface PaginatorCallback {
+        public void doRequest(int start);
+    }
+    
+    class LinkWithDisable extends Composite {
+        protected Panel panel = new FlowPanel();
+        protected Label label;
+        protected Hyperlink link;
+        
+        public LinkWithDisable(String text) {
+            label = new Label(text);
+            link = new SimpleHyperlink(text);
+            panel.add(link);
+            panel.add(label);
+            link.setStyleName("paginator-link");
+            label.setStyleName("paginator-link");
+            initWidget(panel);
+        }
+        
+        public void setEnabled(boolean enabled) {
+            link.setVisible(enabled);
+            label.setVisible(!enabled);
+        }
+
+        public void addClickListener(ClickListener listener) {
+            link.addClickListener(listener);
+        }
+
+        public void removeClickListener(ClickListener listener) {
+            link.removeClickListener(listener);
+        }
+    }
+
+    protected int resultsPerPage, numTotalResults;
+    protected PaginatorCallback callback;
+    protected int currentStart = 0;
+
+    protected HorizontalPanel mainPanel = new HorizontalPanel();
+    protected LinkWithDisable nextControl, prevControl, 
+                              firstControl, lastControl;
+    protected Label statusLabel = new Label();
+
+    public Paginator(int resultsPerPage, PaginatorCallback callback) {
+        this.resultsPerPage = resultsPerPage;
+        this.callback = callback;
+
+        prevControl = new LinkWithDisable("< Previous");
+        prevControl.addClickListener(new ClickListener() {
+            public void onClick(Widget sender) {
+                currentStart -= Paginator.this.resultsPerPage;
+                Paginator.this.callback.doRequest(currentStart);
+                update();
+            }
+        });
+        nextControl = new LinkWithDisable("Next >");
+        nextControl.addClickListener(new ClickListener() {
+            public void onClick(Widget sender) {
+                currentStart += Paginator.this.resultsPerPage;
+                Paginator.this.callback.doRequest(currentStart);
+                update();
+            }
+        });
+        firstControl = new LinkWithDisable("<< First");
+        firstControl.addClickListener(new ClickListener() {
+            public void onClick(Widget sender) {
+                currentStart = 0;
+                Paginator.this.callback.doRequest(currentStart);
+                update();
+            } 
+        });
+        lastControl = new LinkWithDisable("Last >>");
+        lastControl.addClickListener(new ClickListener() {
+            public void onClick(Widget sender) {
+                currentStart = getLastPageStart();
+                Paginator.this.callback.doRequest(currentStart);
+                update();
+            } 
+        });
+        
+        statusLabel.setWidth("10em");
+        statusLabel.setHorizontalAlignment(Label.ALIGN_CENTER);
+        
+        mainPanel.setVerticalAlignment(HorizontalPanel.ALIGN_MIDDLE);
+        mainPanel.add(firstControl);
+        mainPanel.add(prevControl);
+        mainPanel.add(statusLabel);
+        mainPanel.add(nextControl);
+        mainPanel.add(lastControl);
+        
+        initWidget(mainPanel);
+    }
+    
+    /**
+     * Get the current starting row index.
+     */
+    public int getStart() {
+        return currentStart;
+    }
+    
+    /**
+     * Get the current ending row index (one past the last currently displayed 
+     * row).
+     */
+    public int getEnd() {
+        int end = currentStart + resultsPerPage;
+        if (end < numTotalResults)
+            return end;
+        return numTotalResults;
+    }
+    
+    /**
+     * Get the size of each page.
+     */
+    public int getResultsPerPage() {
+        return resultsPerPage;
+    }
+
+    /**
+     * Set the total number of results in the current working set.
+     */
+    public void setNumTotalResults(int numResults) {
+        this.numTotalResults = numResults;
+        if (currentStart >= numResults)
+            currentStart = getLastPageStart();
+        update();
+    }
+    
+    /**
+     * Set the current starting index.
+     */
+    public void setStart(int start) {
+        this.currentStart = start;
+        update();
+    }
+    
+    protected int getLastPageStart() {
+        // compute start of last page using truncation
+        return ((numTotalResults - 1) / resultsPerPage) * resultsPerPage;
+    }
+
+    protected void update() {
+        boolean prevEnabled = !(currentStart == 0);
+        boolean nextEnabled = currentStart + resultsPerPage < numTotalResults;
+        firstControl.setEnabled(prevEnabled);
+        prevControl.setEnabled(prevEnabled);
+        nextControl.setEnabled(nextEnabled);
+        lastControl.setEnabled(nextEnabled);
+        int displayStart = getStart() + 1;
+        if(numTotalResults == 0)
+            displayStart = 0;
+        statusLabel.setText(displayStart + "-" + getEnd() + 
+                            " of " + numTotalResults); 
+    }
+}
diff --git a/frontend/client/src/afeclient/client/SimpleCallback.java b/frontend/client/src/afeclient/client/SimpleCallback.java
new file mode 100644
index 0000000..0f51bab
--- /dev/null
+++ b/frontend/client/src/afeclient/client/SimpleCallback.java
@@ -0,0 +1,5 @@
+package afeclient.client;
+
+public interface SimpleCallback {
+    public void doCallback();
+}
diff --git a/frontend/client/src/afeclient/client/SimpleHyperlink.java b/frontend/client/src/afeclient/client/SimpleHyperlink.java
new file mode 100644
index 0000000..8b9c74b
--- /dev/null
+++ b/frontend/client/src/afeclient/client/SimpleHyperlink.java
@@ -0,0 +1,43 @@
+package afeclient.client;
+
+import com.google.gwt.user.client.DOM;
+import com.google.gwt.user.client.Event;
+import com.google.gwt.user.client.ui.ClickListener;
+import com.google.gwt.user.client.ui.ClickListenerCollection;
+import com.google.gwt.user.client.ui.Hyperlink;
+
+/**
+ * Hyperlink widget that doesn't mess with browser history.  Most of this code
+ * is copied from gwt.user.client.ui.Hyperlink; unfortunately, due to the way 
+ * that class is built, we can't get rid of it.
+ *
+ */
+public class SimpleHyperlink extends Hyperlink {
+    private ClickListenerCollection clickListeners;
+    
+    public SimpleHyperlink(String text) {
+        super(text, "");
+    }
+
+    public void onBrowserEvent(Event event) {
+        if (DOM.eventGetType(event) == Event.ONCLICK) {
+            if (clickListeners != null) {
+                clickListeners.fireClick(this);
+            }
+            DOM.eventPreventDefault(event);
+        }
+    }
+
+    public void addClickListener(ClickListener listener) {
+        if (clickListeners == null) {
+            clickListeners = new ClickListenerCollection();
+        }
+        clickListeners.add(listener);
+    }
+
+    public void removeClickListener(ClickListener listener) {
+        if (clickListeners != null) {
+            clickListeners.remove(listener);
+        }
+    }
+}
diff --git a/frontend/client/src/afeclient/client/SiteClassFactory.java b/frontend/client/src/afeclient/client/SiteClassFactory.java
new file mode 100644
index 0000000..a63ba5a
--- /dev/null
+++ b/frontend/client/src/afeclient/client/SiteClassFactory.java
@@ -0,0 +1,6 @@
+package afeclient.client;
+
+import afeclient.client.CreateJobView.JobCreateListener;
+
+public class SiteClassFactory extends ClassFactory {
+}
diff --git a/frontend/client/src/afeclient/client/StaticDataRepository.java b/frontend/client/src/afeclient/client/StaticDataRepository.java
new file mode 100644
index 0000000..380bec5
--- /dev/null
+++ b/frontend/client/src/afeclient/client/StaticDataRepository.java
@@ -0,0 +1,47 @@
+package afeclient.client;
+
+import com.google.gwt.json.client.JSONObject;
+import com.google.gwt.json.client.JSONValue;
+
+/**
+ * A singleton class to manage a set of static data, such as the list of users.
+ * The data will most likely be retrieved once at the beginning of program
+ * execution.  Other classes can then retrieve the data from this shared
+ * storage.
+ */
+public class StaticDataRepository {
+    interface FinishedCallback {
+        public void onFinished();
+    }
+    // singleton
+    public static final StaticDataRepository theInstance = new StaticDataRepository();
+    
+    protected JSONObject dataObject = null;
+    
+    private StaticDataRepository() {}
+    
+    public static StaticDataRepository getRepository() {
+        return theInstance;
+    }
+    
+    /**
+     * Update the local copy of the static data from the server.
+     * @param finished callback to be notified once data has been retrieved
+     */
+    public void refresh(final FinishedCallback finished) {
+        JsonRpcProxy.getProxy().rpcCall("get_static_data", null, 
+                                        new JsonRpcCallback() {
+            public void onSuccess(JSONValue result) {
+                dataObject = result.isObject();
+                finished.onFinished();
+            }
+        });
+    }
+    
+    /**
+     * Get a value from the static data object.
+     */
+    public JSONValue getData(String key) {
+        return dataObject.get(key);
+    }
+}
diff --git a/frontend/client/src/afeclient/client/TabView.java b/frontend/client/src/afeclient/client/TabView.java
new file mode 100644
index 0000000..1e25410
--- /dev/null
+++ b/frontend/client/src/afeclient/client/TabView.java
@@ -0,0 +1,57 @@
+package afeclient.client;
+
+import com.google.gwt.user.client.DOM;
+import com.google.gwt.user.client.Element;
+import com.google.gwt.user.client.History;
+import com.google.gwt.user.client.ui.Composite;
+
+/**
+ * A widget to facilitate building a tab panel from elements present in the 
+ * static HTML document.  Each <code>TabView</code> grabs a certain HTML
+ * element, removes it from the document, and wraps it.  It can then be added
+ * to a TabPanel.  The <code>getTitle()</code> method retrieves a title for the
+ * tab from the "title" attribute of the HTML element.  This class also supports
+ * lazy initialization of the tab by waiting until the tab is first displayed.
+ */
+public abstract class TabView extends Composite {
+    public static final String HISTORY_PREFIX = "h_";
+    protected boolean initialized = false;
+    protected String title;
+    
+    public TabView() {
+        Element thisTabElement = DOM.getElementById(getElementId());
+        ElementWidget thisTab = new ElementWidget(thisTabElement);
+        thisTab.removeFromDocument();
+        initWidget(thisTab);
+        title = DOM.getElementAttribute(thisTabElement, "title");
+    }
+    
+    public void ensureInitialized() {
+        if (!initialized) {
+            initialize();
+            initialized = true;
+        }
+    }
+    
+    // primarily for subclasses to override
+    public void display() {
+        ensureInitialized();
+    }
+    
+    public String getTitle() {
+        return title;
+    }
+    
+    public void updateHistory() {
+        History.newItem(getHistoryToken());
+    }
+    
+    public String getHistoryToken() {
+        return HISTORY_PREFIX + getElementId();
+    }
+    
+    public void handleHistoryToken(String token) {}
+    
+    public abstract void initialize();
+    public abstract String getElementId();
+}
diff --git a/frontend/client/src/afeclient/client/Utils.java b/frontend/client/src/afeclient/client/Utils.java
new file mode 100644
index 0000000..c1a0ddf
--- /dev/null
+++ b/frontend/client/src/afeclient/client/Utils.java
@@ -0,0 +1,42 @@
+package afeclient.client;
+
+import com.google.gwt.json.client.JSONArray;
+import com.google.gwt.json.client.JSONObject;
+import com.google.gwt.json.client.JSONString;
+
+import java.util.Collection;
+import java.util.Iterator;
+import java.util.Set;
+
+/**
+ * Utility methods.
+ */
+public class Utils {
+    public static final ClassFactory factory = new SiteClassFactory();
+    
+    /**
+     * Converts a collection of Java <code>String</code>s into a <code>JSONArray
+     * </code> of <code>JSONString</code>s.
+     */
+    public static JSONArray stringsToJSON(Collection strings) {
+        JSONArray result = new JSONArray();
+        for(Iterator i = strings.iterator(); i.hasNext(); ) {
+            String s = (String) i.next();
+            result.set(result.size(), new JSONString(s));
+        }
+        return result;
+    }
+    
+    public static String formatStatusCounts(JSONObject counts, String joinWith) {
+        String result = "";
+        Set statusSet = counts.keySet();
+        for (Iterator i = statusSet.iterator(); i.hasNext();) {
+            String status = (String) i.next();
+            int count = (int) counts.get(status).isNumber().getValue();
+            result += Integer.toString(count) + " " + status;
+            if (i.hasNext())
+                result += joinWith;
+        }
+        return result;
+    }
+}
diff --git a/frontend/client/src/afeclient/public/ClientMain.html b/frontend/client/src/afeclient/public/ClientMain.html
new file mode 100644
index 0000000..d81ef4c
--- /dev/null
+++ b/frontend/client/src/afeclient/public/ClientMain.html
@@ -0,0 +1,118 @@
+<html>
+  <head>
+    <title>Autotest Frontend</title>
+    <script language='javascript' src='afeclient.ClientMain.nocache.js'>
+    </script>
+  </head>
+
+  <body>
+    <!-- gwt history support -->
+    <iframe src="javascript:''" id="__gwt_historyFrame" 
+            style="width:0;height:0;border:0"></iframe>
+
+    
+    <span class="links-box" style="float: right;">
+      <b>Links</b><br>
+      <a href="server/admin">Admin interface</a><br>
+      <a href="/tko">Results database</a><br>
+      <a href="http://test.kernel.org/autotest">Documentation</a><br>
+    </span>
+    <img src="header.png" />
+    <br /><br />
+    
+    <div id="tabs" class="hidden">
+      <div id="job_list" title="Job List">
+        <div id="job_control_links" class="job-control"></div>
+        <div class="job-control">
+          View jobs for user: <span id="user_list"></span>
+        </div>
+        <div id="job_pagination"></div>
+        <div id="job_table"></div>
+      </div>
+      
+      <div id="view_job"  title="View Job">
+        <span id="job_id_fetch" class="box-full">Fetch job by ID:
+          <span id="job_id_fetch_controls"></span>
+        </span><br><br>
+        <div id="view_title" class="title"></div><br>
+        <div id="view_data">
+          <span class="field-name">Label:</span>
+          <span id="view_label"></span><br>
+          <span class="field-name">Owner:</span>
+          <span id="view_owner"></span><br>
+          <span class="field-name">Priority:</span>
+          <span id="view_priority"></span><br>
+          <span class="field-name">Created:</span>
+          <span id="view_created"></span><br>
+          <span class="field-name">Status:</span>
+          <span id="view_status"></span>
+          <span id="view_abort"></span><br>
+          
+           <span class="field-name">
+             <span id="view_control_type"></span> control file 
+             (<span id="view_synch_type"></span>):
+           </span>
+          <pre><div id="view_control_file" class="box"></div></pre>
+          <span class="field-name">
+            Full results
+            <a id="results_link" target="_blank">(open in new window)</a>
+            <a id="raw_results_link" target="_blank">(raw results logs)</a><br>
+          </span>
+          
+          <iframe src="javascript:''" id="results_iframe" class="results-frame">
+          </iframe><br>
+          
+          <span class="field-name">Hosts</span>
+          <div id="job_hosts_table"></div>
+        </div>
+      </div>
+      
+      <div id="create_job"  title="Create Job">
+        <table class="form-table">
+          <tr><td class="field-name">Job name:</td>
+              <td id="create_job_name"></td><td></td></tr>
+          <tr><td class="field-name">Priority:</td>
+              <td id="create_priority"></td><td></td></tr>
+          <tr><td class="field-name">Kernel:</td>
+              <td id="create_kernel"></td><td></td></tr>
+          <tr><td class="field-name">Client tests:</td>
+              <td id="create_client_tests" colspan="2"></td></tr>
+          <tr><td class="field-name">Server tests:</td>
+              <td id="create_server_tests" colspan="2"></td></tr>
+          <tr><td colspan="3" id="create_edit_control"></td></tr>
+          <tr>
+            <td colspan="3" class="box">
+              <table><tr>
+                <td>
+                  <div class="box-full">
+                    Run on any <span id="create_meta_select"></span><br>
+                    Number: <span id="create_meta_number"></span>
+                    <span id="create_meta_button"></span>
+                  </div>
+                  <span class="field-name">Available hosts: </span>
+                  <span class="help">(Click on a host to add it)</span>
+                  <div id="create_available_table"></div>
+                  <div id="create_available_controls"></div>
+                </td>
+                <td>
+                  <span class="field-name">Selected hosts:</span>
+                  <span class="help">(Click on a host to remove it)</span>
+                  <div id="create_selected_table"></div>
+                  <div id="create_selected_controls"></div>
+                </td>
+              </tr></table>
+            </td>
+          </tr>
+          <tr><td colspan="3" id="create_submit"></td></tr>
+        </table>
+      </div>
+      
+      <div id="hosts" title="Hosts">
+        <div id="hosts_list"></div>
+      </div>
+    </div>
+    
+    <!--  for debugging only -->
+    <div id="error_display"></div>
+  </body>
+</html>
diff --git a/frontend/client/src/afeclient/public/afeclient.css b/frontend/client/src/afeclient/public/afeclient.css
new file mode 100644
index 0000000..7534f26
--- /dev/null
+++ b/frontend/client/src/afeclient/public/afeclient.css
@@ -0,0 +1,204 @@
+body {
+  margin-top: 0px;
+}
+
+.data-table {
+  border: 2px solid #A0A0A0;
+}
+
+.data-table td {
+  margin: 0;
+  padding: 0.2em 0.4em;
+  white-space: nowrap;
+}
+
+.data-row-header {
+  background: #AACCFF;
+  font-weight: bold;
+}
+
+.data-row-header-sortable {
+  cursor: pointer;
+}
+
+.data-row-header-sortable td:hover {
+  background: #6699FF;
+}
+
+.data-row {
+  background: white;
+}
+
+.data-row-alternate {
+  background: #CCDDFF;
+}
+
+tr.data-row-clickable {
+  cursor: pointer;
+}
+
+tr.data-row-clickable:hover {
+  background: #FFFFBB;
+}
+
+.gwt-TabBar .gwt-TabBarItem {
+  background: #CCDDFF;
+  margin-right: 2px;
+  padding: 0.2em;
+  padding-left: 0.4em;
+  padding-right: 0.4em;
+  font-weight: bold;
+  cursor: pointer;
+}
+
+.gwt-TabBar .gwt-TabBarItem-selected {
+  background: #003399;
+  color: white;
+  cursor: default;
+}
+
+.gwt-TabPanelBottom {
+  border: 2px solid #6699FF;
+  padding: 5px;
+}
+
+.job-control {
+  margin-bottom: 0.5em;
+}
+
+.job-filter-link {
+  padding: 0.3em;
+  margin-right: 1em;
+}
+
+.selected-link {
+  background-color: #AACCFF;
+}
+
+.box {
+  border: 1px solid #CCDDFF;
+  padding: 0.2em;
+  margin: 0.2em;
+}
+
+.box-full {
+  background: #CCDDFF;
+  padding: 0.2em;
+  margin: 0.2em;
+}
+
+.title {
+  font-weight: bold;
+  font-size: larger;
+}
+
+#job_id_fetch {
+  padding: 0.2em 0.2em 0.4em 0.2em;
+  margin: 0.1em;
+}
+
+.form-table {
+}
+
+.form-table td {
+  vertical-align: top;
+}
+
+.gwt-CheckBox {
+  margin-right: 1em;
+}
+
+.field-name {
+  font-weight: bold;
+  white-space: nowrap;
+}
+
+.gwt-DisclosurePanel {
+  border: 1px solid gray;
+  padding: 0.2em;
+}
+
+.gwt-DisclosurePanel .header {
+  cursor: pointer;
+}
+
+.gwt-DisclosurePanel a.header {
+  text-decoration: none;
+}
+
+.gwt-DisclosurePanel .gwt-Label {
+  text-decoration: underline;
+}
+
+.filter-box {
+  margin-left: 1em;
+}
+
+.gwt-Button {
+  margin: 0.2em;
+}
+
+.results-frame {
+  width: 700px;
+  height: 500px;
+  margin-bottom: 0.2em;
+  border: 2px solid gray;
+}
+
+.gwt-DialogBox {
+  background: white;
+  border: 2px solid #AACCFF;
+  width: 30em;
+  text-align: center;
+  cursor: default;
+}
+
+.gwt-DialogBox .Caption {
+  background: #AACCFF;
+  font-weight: bold;
+  text-align: center;
+}
+
+.notify {
+  background: #CCDDFF;
+  padding: 0.2em;
+  border: 1px solid black;
+  position: fixed;
+}
+
+.error {
+  color: red;
+}
+
+.gwt-TextArea {
+  border: 1px solid black;
+}
+
+.gwt-TextArea-readonly {
+  background: #F0F0F0;
+}
+
+.help {
+  font-style: italic;
+  text-align: left;
+  width: 100%;
+}
+
+.hidden {
+  display: none;
+}
+
+.paginator-link {
+  padding: 0 0.2em;
+}
+
+.links-box {
+  border: 2px solid #AACCFF;
+  background: #CCDDFF;
+  font-size: larger;
+  padding: 0.2em;
+}
+
+.extra-space-left {
+  margin-left: 1em;
+}
diff --git a/frontend/client/src/afeclient/public/arrow_down.png b/frontend/client/src/afeclient/public/arrow_down.png
new file mode 100644
index 0000000..0b108ae
--- /dev/null
+++ b/frontend/client/src/afeclient/public/arrow_down.png
Binary files differ
diff --git a/frontend/client/src/afeclient/public/arrow_up.png b/frontend/client/src/afeclient/public/arrow_up.png
new file mode 100644
index 0000000..8a3e8b5
--- /dev/null
+++ b/frontend/client/src/afeclient/public/arrow_up.png
Binary files differ
diff --git a/frontend/client/src/afeclient/public/header.png b/frontend/client/src/afeclient/public/header.png
new file mode 100644
index 0000000..3611e3d
--- /dev/null
+++ b/frontend/client/src/afeclient/public/header.png
Binary files differ