Initial checkin of new TKO interface.



git-svn-id: http://test.kernel.org/svn/autotest/trunk@1959 592f7852-d20e-0410-864c-8624ca9c26a4
diff --git a/frontend/client/TkoClient-compile b/frontend/client/TkoClient-compile
new file mode 100644
index 0000000..46e6074
--- /dev/null
+++ b/frontend/client/TkoClient-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" "$@" autotest.TkoClient
diff --git a/frontend/client/TkoClient-shell b/frontend/client/TkoClient-shell
new file mode 100644
index 0000000..dd70842
--- /dev/null
+++ b/frontend/client/TkoClient-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/new_tko/server/autotest.TkoClient/TkoClient.html;
diff --git a/frontend/client/TkoClient.launch b/frontend/client/TkoClient.launch
new file mode 100644
index 0000000..15fd9c6
--- /dev/null
+++ b/frontend/client/TkoClient.launch
@@ -0,0 +1,21 @@
+<?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/new_tko/server/autotest.TkoClient/TkoClient.html"/>
+<stringAttribute key="org.eclipse.jdt.launching.PROJECT_ATTR" value="AfeClient"/>
+<stringAttribute key="org.eclipse.jdt.launching.VM_ARGUMENTS" value="-Xmx256M"/>
+</launchConfiguration>
diff --git a/frontend/client/src/autotest/TkoClient.gwt.xml b/frontend/client/src/autotest/TkoClient.gwt.xml
new file mode 100644
index 0000000..44a2a9b
--- /dev/null
+++ b/frontend/client/src/autotest/TkoClient.gwt.xml
@@ -0,0 +1,13 @@
+<module>
+  <inherits name='com.google.gwt.user.User'/>
+  <inherits name='com.google.gwt.json.JSON'/>
+  <inherits name='com.google.gwt.http.HTTP'/>
+
+  <source path="tko"/>
+  <source path="common"/>
+  <entry-point class='autotest.tko.TkoClient'/>
+  
+  <stylesheet src='standard.css'/>
+  <stylesheet src='afeclient.css'/>
+  <stylesheet src='tkoclient.css'/>
+</module>
diff --git a/frontend/client/src/autotest/afe/JobDetailView.java b/frontend/client/src/autotest/afe/JobDetailView.java
index 26026e9..fdd5952 100644
--- a/frontend/client/src/autotest/afe/JobDetailView.java
+++ b/frontend/client/src/autotest/afe/JobDetailView.java
@@ -68,7 +68,7 @@
 
     @Override
     protected void fetchData() {
-        pointToResults(NO_URL, NO_URL);
+        pointToResults(NO_URL, NO_URL, NO_URL);
         JSONObject params = new JSONObject();
         params.put("id", new JSONNumber(jobId));
         rpcProxy.rpcCall("get_jobs_summary", params, new JsonRpcCallback() {
@@ -103,7 +103,8 @@
                 abortButton.setVisible(!allFinishedCounts(counts));
                 
                 String jobLogsId = jobId + "-" + owner;
-                pointToResults(getResultsURL(jobId), getLogsURL(jobLogsId));
+                pointToResults(getResultsURL(jobId), getLogsURL(jobLogsId), 
+                               getOldResultsUrl(jobId));
                 
                 String jobTitle = "Job: " + name + " (" + jobLogsId + ")";
                 displayObjectData(jobTitle);
@@ -213,7 +214,12 @@
         });
     }
     
-    protected String getResultsURL(int jobId) {
+    private String getResultsURL(int jobId) {
+        return "/new_tko/#tab_id=spreadsheet_view&row=hostname&column=test_name&" +
+               "condition=job_tag+LIKE+'" + Integer.toString(jobId) + "-%2525'";
+    }
+    
+    private String getOldResultsUrl(int jobId) {
         return "/tko/compose_query.cgi?" +
                "columns=test&rows=hostname&condition=tag%7E%27" + 
                Integer.toString(jobId) + "-%25%27&title=Report";
@@ -227,18 +233,17 @@
         return Utils.getLogsURL(jobLogsId);
     }
     
-    protected void pointToResults(String resultsUrl, String logsUrl) {
-        DOM.setElementProperty(DOM.getElementById("results_link"),
-                               "href", resultsUrl);
-        DOM.setElementProperty(DOM.getElementById("raw_results_link"),
-                               "href", logsUrl);
+    protected void pointToResults(String resultsUrl, String logsUrl, String oldResultsUrl) {
+        DOM.getElementById("results_link").setAttribute("href", resultsUrl);
+        DOM.getElementById("old_results_link").setAttribute("href", oldResultsUrl);
+        DOM.getElementById("raw_results_link").setAttribute("href", logsUrl);
         if (resultsUrl.equals(NO_URL)) {
             tkoResultsHtml.setHTML("");
             return;
         }
 
         RequestBuilder requestBuilder =
-            new RequestBuilder(RequestBuilder.GET, resultsUrl + "&brief=1");
+            new RequestBuilder(RequestBuilder.GET, oldResultsUrl + "&brief=1");
         try {
             requestBuilder.sendRequest("", new RequestCallback() {
                 public void onError(Request request, Throwable exception) {
diff --git a/frontend/client/src/autotest/public/AfeClient.html b/frontend/client/src/autotest/public/AfeClient.html
index 1813b6f..d291838 100644
--- a/frontend/client/src/autotest/public/AfeClient.html
+++ b/frontend/client/src/autotest/public/AfeClient.html
@@ -7,21 +7,22 @@
 
   <body>
     <!-- gwt history support -->
-    <iframe src="javascript:''" id="__gwt_historyFrame" 
+    <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="/new_tko">New results interface</a><br>
       <a href="http://test.kernel.org/autotest">Documentation</a><br>
       <a href="server/feeds/jobs/completed">Completed Jobs Feed</a><br>
       <a href="server/feeds/jobs/failed">Failed Jobs Feed</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>
@@ -32,7 +33,7 @@
         <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>
@@ -52,25 +53,26 @@
           <span id="view_timeout"></span> hours<br>
           <span class="field-name">Status:</span>
           <span id="view_status"></span><br>
-          
+
            <span class="field-name">
-             <span id="view_control_type"></span> control file 
+             <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="old_results_link" target="_blank">(open in new window)</a>
+            <a id="results_link" target="_blank">(new results interface)</a>
             <a id="raw_results_link" target="_blank">(raw results logs)</a><br>
           </span>
-          
+
           <span id="tko_results"></span><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>
@@ -124,11 +126,11 @@
           </tr>
         </table>
       </div>
-      
+
       <div id="hosts" title="Host List">
         <div id="hosts_list"></div>
       </div>
-      
+
       <div id="view_host" title="View Host">
         <span id="view_host_fetch" class="box-full">Fetch host:
           <span id="view_host_fetch_controls"></span>
@@ -153,7 +155,7 @@
     </div>
     <br>
     <div id="error_log"></div>
-    
+
     <!--  for debugging only -->
     <div id="error_display"></div>
   </body>
diff --git a/frontend/client/src/autotest/public/TkoClient.html b/frontend/client/src/autotest/public/TkoClient.html
new file mode 100644
index 0000000..acaf655
--- /dev/null
+++ b/frontend/client/src/autotest/public/TkoClient.html
@@ -0,0 +1,106 @@
+<html>
+  <head>
+    <meta http-equiv="content-type" content="text/html; charset=UTF-8">
+    <title>Autotest Frontend</title>
+    <script type="text/javascript" language='javascript' 
+      src='autotest.TkoClient.nocache.js'>
+    </script>
+  </head>
+
+  <body>
+    <!-- gwt history support -->
+    <iframe src="javascript:''" id="__gwt_historyFrame" tabIndex='-1' 
+      style="position:absolute;width:0;height:0;border:0"></iframe>
+
+    
+    <span class="links-box" style="float: right;">
+      <b>Links</b><br>
+      <a href="/afe">Autotest Frontend</a><br>
+      <a href="server/admin">Admin interface</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="common_panel">
+        <!-- Not a tab, but the common panel shown for all tabs -->
+        <div id="common_condition_div">
+          <span class="field-name">SQL filtering condition:</span>
+          <a href="http://test.kernel.org/autotest/TkoHowTo#filtering" target="_blank">Help</a>
+          <span id="common_quick_reference"></span><br>
+          <span id="common_sql_input"></span><br>
+        </div>
+      </div>
+      
+      <div id="spreadsheet_view" title="Spreadsheet">
+        <table><tr valign="top">
+          <td class="field-name">Rows:</td>
+          <td id="ss_row_select"></td>
+          <td class="field-name">Columns:</td>
+          <td id="ss_column_select"></td>
+          <td id="ss_swap"></td>
+        </tr></table>
+        <span id="ss_query_controls"></span><br>
+        
+        <span id="ss_job_completion"></span>
+        
+        <!-- table is necessary here to get layout right -->
+        <table><tr>
+          <td id="ss_actions"></td>
+        </tr></table>
+        <div id="ss_spreadsheet"></div>
+      </div>
+
+      <div id="table_view" title="Table">
+        <span class="field-name">Columns:</span><br>
+        <span id="table_column_select"></span><br>
+        <span id="table_actions"></span>
+        <span id="table_query_controls"></span>
+        
+        <div id="table_table"></div>
+      </div>
+
+      <div id="graphing_view" title="Graphing">
+        <span id="graphing_frontend"></span>
+      </div>
+      
+      <div id="test_detail_view" title="Test details">
+        <span id="td_fetch" class="box-full">Fetch test by ID:
+          <span id="td_fetch_controls"></span>
+        </span><br><br>
+        <div id="td_title" class="title"></div><br>
+        <div id="td_data">
+	        <span class="field-name">Test:</span>
+	        <span id="td_test"></span><br>
+	        <span class="field-name">Job tag:</span>
+	        <span id="td_job_tag"></span><br>
+	        <span class="field-name">Job name:</span>
+	        <span id="td_job_name"></span><br>
+	        <span class="field-name">Status:</span>
+	        <span id="td_status"></span><br>
+	        <span class="field-name">Reason:</span>
+	        <span id="td_reason"></span><br>
+	        <span class="field-name">Test started:</span>
+	        <span id="td_test_started"></span><br>
+	        <span class="field-name">Test finished:</span>
+	        <span id="td_test_finished"></span><br>
+	        <span class="field-name">Host:</span>
+	        <span id="td_hostname"></span><br>
+	        <span class="field-name">Platform:</span>
+	        <span id="td_platform"></span><br>
+	        <span class="field-name">Kernel:</span>
+	        <span id="td_kernel"></span><br>
+	        <br>
+	        
+	        <span class="field-name">Key log files:</span>
+	        <a id="td_view_logs_link" target="_blank">(view all logs)</a>
+	        <div id="td_log_files"></div>
+	      </div>
+      </div>
+
+    </div>
+    <br>
+    <div id="error_log"></div>
+  </body>
+</html>
diff --git a/frontend/client/src/autotest/public/tkoclient.css b/frontend/client/src/autotest/public/tkoclient.css
new file mode 100644
index 0000000..989443a
--- /dev/null
+++ b/frontend/client/src/autotest/public/tkoclient.css
@@ -0,0 +1,53 @@
+/* this combination of styles makes a div clip it's contents */
+div.clipper {
+  position: relative;
+  overflow: hidden;
+}
+
+/* necessary to get row headers aligned with table */
+table.spreadsheet-parent td {
+  vertical-align: top;
+}
+
+/* allow headers to scroll with table */
+table.spreadsheet-headers {
+  position: relative;
+}
+
+.spreadsheet-headers td, .spreadsheet-data td {
+  border-bottom: 1px solid gray;
+  border-right: 1px solid gray;
+}
+
+.spreadsheet-headers td {
+  background: #CCDDFF;
+}
+
+/* don't wrap cells automatically */
+.spreadsheet-data td {
+  white-space: pre;
+}
+
+.spreadsheet-data div, .spreadsheet-headers div {
+  padding: 2px;
+  cursor: pointer;
+}
+
+/* allow empty cells to override the clickable cursor */
+div.spreadsheet-cell-nonclickable {
+  cursor: default;
+}
+
+.highlighted {
+  background-color: #FFFFBB;
+}
+
+.log-file-panel {
+  width: 100%;
+  margin-bottom: 2px;
+}
+
+.log-file-text {
+  white-space: pre;
+  font-family: monospace;
+}
diff --git a/frontend/client/src/autotest/tko/CommonPanel.java b/frontend/client/src/autotest/tko/CommonPanel.java
new file mode 100644
index 0000000..f25c8b0
--- /dev/null
+++ b/frontend/client/src/autotest/tko/CommonPanel.java
@@ -0,0 +1,142 @@
+package autotest.tko;
+
+import autotest.common.Utils;
+import autotest.common.ui.ElementWidget;
+import autotest.common.ui.SimpleHyperlink;
+import autotest.tko.TkoUtils.FieldInfo;
+
+import com.google.gwt.user.client.DOM;
+import com.google.gwt.user.client.ui.ClickListener;
+import com.google.gwt.user.client.ui.Composite;
+import com.google.gwt.user.client.ui.FlexTable;
+import com.google.gwt.user.client.ui.PopupPanel;
+import com.google.gwt.user.client.ui.RootPanel;
+import com.google.gwt.user.client.ui.TextArea;
+import com.google.gwt.user.client.ui.Widget;
+import com.google.gwt.user.client.ui.PopupPanel.PositionCallback;
+
+import java.util.Map;
+
+class CommonPanel extends Composite implements ClickListener, PositionCallback {
+    private static final String SHOW_QUICK_REFERENCE = "Show quick reference";
+    private static final String HIDE_QUICK_REFERENCE = "Hide quick reference";
+    private static CommonPanel theInstance = new CommonPanel();
+    
+    private TextArea customSqlBox = new TextArea();
+    private SimpleHyperlink quickReferenceLink = new SimpleHyperlink(SHOW_QUICK_REFERENCE);
+    private PopupPanel quickReferencePopup;
+    private String currentCondition = "";
+    
+    private CommonPanel() {
+        ElementWidget panelElement = new ElementWidget(DOM.getElementById("common_panel"));
+        panelElement.removeFromDocument();
+        initWidget(panelElement);
+    }
+    
+    public void initialize() {
+        customSqlBox.setSize("50em", "5em");
+        quickReferenceLink.addClickListener(this);
+        RootPanel.get("common_sql_input").add(customSqlBox);
+        RootPanel.get("common_quick_reference").add(quickReferenceLink);
+        
+        generateQuickReferencePopup();
+    }
+    
+    public static CommonPanel getPanel() {
+        return theInstance;
+    }
+    
+    /**
+     * For testability.
+     */
+    public static void setInstance(CommonPanel panel) {
+        theInstance = panel;
+    }
+    
+    public void setConditionVisible(boolean visible) {
+        RootPanel.get("common_condition_div").setVisible(visible);
+    }
+    
+    public String getSqlCondition() {
+        return customSqlBox.getText().trim();
+    }
+    
+    public void setSqlCondition(String text) {
+        customSqlBox.setText(text);
+        saveSqlCondition();
+    }
+    
+    public void saveSqlCondition() {
+        currentCondition = getSqlCondition();
+    }
+    
+    public String getSavedCondition() {
+        return currentCondition;
+    }
+
+    public String getRefinedCondition(TestSet tests) {
+        String newCondition = tests.getCondition();
+        if (newCondition.equals("") || newCondition.equals(currentCondition)) {
+            return currentCondition;
+        }
+        return appendCondition(currentCondition, "(" + newCondition + ")");
+    }
+
+    public void refineCondition(TestSet tests) {
+        setSqlCondition(getRefinedCondition(tests));
+    }
+
+    private static String appendCondition(String condition, String toAppend) {
+        if (!condition.equals(""))
+            condition += " AND ";
+        return condition + toAppend;
+    }
+
+    public void handleHistoryArguments(Map<String, String> arguments) {
+        setSqlCondition(arguments.get("condition"));
+    }
+    
+    public void addHistoryArguments(Map<String, String> arguments) {
+        arguments.put("condition", getSavedCondition());
+    }
+
+    public void fillDefaultHistoryValues(Map<String, String> arguments) {
+        Utils.setDefaultValue(arguments, "condition", "");
+    }
+
+    public void onClick(Widget sender) {
+        assert sender == quickReferenceLink;
+        if (isQuickReferenceShowing()) {
+            quickReferencePopup.hide();
+            quickReferenceLink.setText(SHOW_QUICK_REFERENCE);
+        } else {
+            quickReferencePopup.setPopupPositionAndShow(this);
+            quickReferenceLink.setText(HIDE_QUICK_REFERENCE);
+        }
+    }
+
+    private boolean isQuickReferenceShowing() {
+        return quickReferenceLink.getText().equals(HIDE_QUICK_REFERENCE);
+    }
+
+    private void generateQuickReferencePopup() {
+        FlexTable fieldTable = new FlexTable();
+        fieldTable.setText(0, 0, "Name");
+        fieldTable.setText(0, 1, "Field");
+        fieldTable.getRowFormatter().setStyleName(0, "data-row-header");
+        int row = 1;
+        for (FieldInfo fieldInfo : TkoUtils.getFieldList("all_fields")) {
+            fieldTable.setText(row, 0, fieldInfo.name);
+            fieldTable.setText(row, 1, fieldInfo.field);
+            row++;
+        }
+        quickReferencePopup = new PopupPanel(false);
+        quickReferencePopup.add(fieldTable);
+    }
+
+    public void setPosition(int offsetWidth, int offsetHeight) {
+        quickReferencePopup.setPopupPosition(
+             customSqlBox.getAbsoluteLeft() + customSqlBox.getOffsetWidth(), 
+             customSqlBox.getAbsoluteTop());
+    }
+}
diff --git a/frontend/client/src/autotest/tko/CompositeTestSet.java b/frontend/client/src/autotest/tko/CompositeTestSet.java
new file mode 100644
index 0000000..60abf65
--- /dev/null
+++ b/frontend/client/src/autotest/tko/CompositeTestSet.java
@@ -0,0 +1,26 @@
+package autotest.tko;
+
+import autotest.common.Utils;
+
+import java.util.ArrayList;
+import java.util.List;
+
+class CompositeTestSet implements TestSet {
+    private List<TestSet> testSets = new ArrayList<TestSet>();
+    
+    public void add(TestSet tests) {
+        testSets.add(tests);
+    }
+
+    public String getCondition() {
+        List<String> conditionParts = new ArrayList<String>();
+        for(TestSet testSet : testSets) {
+            conditionParts.add("(" + testSet.getCondition() + ")");
+        }
+        return Utils.joinStrings(" OR ", conditionParts);
+    }
+
+    public boolean isSingleTest() {
+        return testSets.size() == 1 && testSets.get(0).isSingleTest();
+    }
+}
diff --git a/frontend/client/src/autotest/tko/ConditionTabView.java b/frontend/client/src/autotest/tko/ConditionTabView.java
new file mode 100644
index 0000000..823be1b
--- /dev/null
+++ b/frontend/client/src/autotest/tko/ConditionTabView.java
@@ -0,0 +1,52 @@
+package autotest.tko;
+
+import autotest.common.ui.TabView;
+
+import java.util.Map;
+
+abstract class ConditionTabView extends TabView {
+    protected static CommonPanel commonPanel = CommonPanel.getPanel();
+    private String lastCondition = "";
+    private boolean needsRefresh;
+    
+    protected void checkForHistoryChanges(Map<String, String> newParameters) {
+        if (!getHistoryArguments().equals(newParameters)) {
+            needsRefresh = true;
+        }
+    }
+    
+    protected void setNeedsRefresh(boolean needsRefresh) {
+        this.needsRefresh = needsRefresh;
+    }
+    
+    protected abstract boolean hasFirstQueryOccurred();
+    
+    @Override
+    public void display() {
+        ensureInitialized();
+        commonPanel.setConditionVisible(true);
+        if (needsRefresh || !commonPanel.getSavedCondition().equals(lastCondition)) {
+            saveCondition();
+            refresh();
+            needsRefresh = false;
+        }
+    }
+
+    protected void saveCondition() {
+        commonPanel.saveSqlCondition();
+        lastCondition = commonPanel.getSavedCondition();
+    }
+
+    @Override
+    public void handleHistoryArguments(Map<String, String> arguments) {
+        if (!hasFirstQueryOccurred()) {
+            needsRefresh = true;
+        }
+        commonPanel.fillDefaultHistoryValues(arguments);
+        fillDefaultHistoryValues(arguments);
+        checkForHistoryChanges(arguments);
+        commonPanel.handleHistoryArguments(arguments);
+    }
+
+    protected abstract void fillDefaultHistoryValues(Map<String, String> arguments);
+}
diff --git a/frontend/client/src/autotest/tko/ConditionTestSet.java b/frontend/client/src/autotest/tko/ConditionTestSet.java
new file mode 100644
index 0000000..0f85988
--- /dev/null
+++ b/frontend/client/src/autotest/tko/ConditionTestSet.java
@@ -0,0 +1,54 @@
+package autotest.tko;
+
+import autotest.common.Utils;
+
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+class ConditionTestSet implements TestSet {
+    private Map<String,String> fields = new HashMap<String,String>();
+    private boolean isSingleTest;
+    private String initialCondition = "";
+
+    public ConditionTestSet(boolean isSingleTest) {
+        this.isSingleTest = isSingleTest;
+    }
+    
+    public ConditionTestSet(boolean isSingleTest, String initialCondition) {
+        this(isSingleTest);
+        this.initialCondition = initialCondition;
+    }
+    
+    public void setField(String field, String value) {
+        fields.put(field, value);
+    }
+
+    public void setFields(List<String> fields, List<String> values) {
+        assert fields.size() == values.size();
+        for (int i = 0; i < fields.size(); i++) {
+            setField(fields.get(i), values.get(i));
+        }
+    }
+    
+    public String getCondition() {
+        ArrayList<String> parts = new ArrayList<String>();
+        for (Map.Entry<String, String> entry : fields.entrySet()) {
+            parts.add(entry.getKey() + " = '" + escapeQuotes(entry.getValue()) + "'");
+        }
+        if (!initialCondition.trim().equals("")) {
+            parts.add(initialCondition);
+        }
+        return Utils.joinStrings(" AND ", parts);
+    }
+
+    private String escapeQuotes(String value) {
+        // TODO: make this more sophisticated if it becomes necessary
+        return value.replace("'", "\\'");
+    }
+
+    public boolean isSingleTest() {
+        return isSingleTest;
+    }
+}
diff --git a/frontend/client/src/autotest/tko/ExistingGraphsFrontend.java b/frontend/client/src/autotest/tko/ExistingGraphsFrontend.java
new file mode 100644
index 0000000..1ad4575
--- /dev/null
+++ b/frontend/client/src/autotest/tko/ExistingGraphsFrontend.java
@@ -0,0 +1,205 @@
+package autotest.tko;
+
+import autotest.common.JsonRpcCallback;
+import autotest.common.JsonRpcProxy;
+import autotest.common.StaticDataRepository;
+import autotest.common.Utils;
+
+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.Window;
+import com.google.gwt.user.client.ui.Button;
+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.FlexTable;
+import com.google.gwt.user.client.ui.FocusListener;
+import com.google.gwt.user.client.ui.HasVerticalAlignment;
+import com.google.gwt.user.client.ui.ListBox;
+import com.google.gwt.user.client.ui.MultiWordSuggestOracle;
+import com.google.gwt.user.client.ui.SuggestBox;
+import com.google.gwt.user.client.ui.SuggestionEvent;
+import com.google.gwt.user.client.ui.SuggestionHandler;
+import com.google.gwt.user.client.ui.TextBox;
+import com.google.gwt.user.client.ui.Widget;
+
+import java.util.HashMap;
+import java.util.HashSet;
+
+public class ExistingGraphsFrontend extends Composite {
+    
+    private JsonRpcProxy rpcProxy = JsonRpcProxy.getProxy();
+    private CheckBox normalize = new CheckBox("Normalize Performance (allows multiple benchmarks" +
+                                              " on one graph)");
+    private MultiWordSuggestOracle oracle = new MultiWordSuggestOracle();
+    private TextBox hostname = new TextBox();
+    private SuggestBox hostnameSuggest = new SuggestBox(oracle, hostname);
+    private ListBox benchmark = new ListBox();
+    private TextBox kernel = new TextBox();
+    private JSONObject hostsAndTests = null;
+    private Button graphButton = new Button("Graph");
+    FlexTable table = new FlexTable();
+    
+    public ExistingGraphsFrontend() {
+        normalize.addClickListener(new ClickListener() {
+            public void onClick(Widget w) {
+                benchmark.setMultipleSelect(normalize.isChecked());
+                int selectedIndex = benchmark.getSelectedIndex();
+                for (int i = 0; i < benchmark.getItemCount(); i++) {
+                    benchmark.setItemSelected(i, i == selectedIndex);
+                }
+            }
+        });
+        
+        hostnameSuggest.addFocusListener(new FocusListener() {
+            public void onLostFocus(Widget w) {
+                refreshTests();
+            }
+            
+            public void onFocus(Widget w) {
+                // Don't do anything
+            }
+        });
+        hostnameSuggest.addEventHandler(new SuggestionHandler() {
+            public void onSuggestionSelected(SuggestionEvent s) {
+                refreshTests();
+            }
+        });
+        
+        graphButton.addClickListener(new ClickListener() {
+            public void onClick(Widget w) {
+                showGraph();
+            }
+        });
+
+        kernel.setText("all");
+        
+        table.setWidget(0, 0, normalize);
+        table.getFlexCellFormatter().setColSpan(0, 0, 2);
+        addControl("Hostname:", hostnameSuggest);
+        addControl("Benchmark:", benchmark);
+        addControl("Kernel:", kernel);
+        table.setWidget(table.getRowCount(), 1, graphButton);
+
+        table.getColumnFormatter().setWidth(0, "1px");
+        
+        initWidget(table);
+    }
+    
+    public void refresh() {
+        setEnabled(false);
+        rpcProxy.rpcCall("get_hosts_and_tests", new JSONObject(), new JsonRpcCallback() {
+            @Override
+            public void onSuccess(JSONValue result) {
+                hostsAndTests = result.isObject();
+                oracle.clear();
+                for (String host : hostsAndTests.keySet()) {
+                    oracle.add(host);
+                }
+                setEnabled(true);
+            }
+        });
+    }
+    
+    private void addControl(String text, Widget widget) {
+        int row = table.getRowCount();
+        table.setText(row, 0, text);
+        table.setWidget(row, 1, widget);
+        table.getFlexCellFormatter().setStylePrimaryName(row, 0, "field-name");
+        table.getFlexCellFormatter().setVerticalAlignment(row, 0, HasVerticalAlignment.ALIGN_TOP);
+    }
+    
+    private void setEnabled(boolean enabled) {
+        normalize.setEnabled(enabled);
+        hostname.setEnabled(enabled);
+        benchmark.setEnabled(enabled);
+        kernel.setEnabled(enabled);
+        graphButton.setEnabled(enabled);
+    }
+    
+    private void refreshTests() {
+        JSONValue value = hostsAndTests.get(hostnameSuggest.getText());
+        if (value == null) {
+            return;
+        }
+        
+        HashSet<String> selectedTests = new HashSet<String>();
+        for (int i = 0; i < benchmark.getItemCount(); i++) {
+            if (benchmark.isItemSelected(i)) {
+                selectedTests.add(benchmark.getValue(i));
+            }
+        }
+        
+        JSONArray tests = value.isObject().get("tests").isArray();
+        benchmark.clear();
+        for (int i = 0; i < tests.size(); i++) {
+            String test = tests.get(i).isString().stringValue();
+            benchmark.addItem(test);
+            if (selectedTests.contains(test)) {
+                benchmark.setItemSelected(i, true);
+            }
+        }
+    }
+    
+    private void showGraph() {
+        String hostnameStr = hostnameSuggest.getText();
+        
+        JSONValue value = hostsAndTests.get(hostnameStr);
+        if (value == null) {
+            return;
+        }
+        
+        String url;
+        HashMap<String, String> args = new HashMap<String, String>();
+        args.put("kernel", kernel.getText());
+        
+        if (normalize.isChecked()) {
+            url = "/tko/machine_aggr.cgi?";
+            final JSONArray tests = new JSONArray();
+            for (int i = 0; i < benchmark.getItemCount(); i++) {
+                if (benchmark.isItemSelected(i)) {
+                    tests.set(tests.size(), new JSONString(benchmark.getValue(i)));
+                }
+            }
+            
+            args.put("machine", hostnameStr);
+
+            StringBuilder arg = new StringBuilder();
+            for (int i = 0; i < tests.size(); i++) {
+                String test = tests.get(i).isString().stringValue();
+                String key = getKey(test);
+                if (i != 0) {
+                    arg.append(",");
+                }
+                arg.append(test);
+                arg.append(":");
+                arg.append(key);
+            }
+            args.put("benchmark_key", arg.toString());
+        } else {
+            int benchmarkIndex = benchmark.getSelectedIndex();
+            if (benchmarkIndex == -1) {
+                return;
+            }
+            
+            url = "/tko/machine_test_attribute_graph.cgi?";
+            
+            JSONObject hostObject = value.isObject();
+            int machine = (int) hostObject.get("id").isNumber().doubleValue();
+            String benchmarkStr = benchmark.getValue(benchmarkIndex);
+            
+            args.put("machine", String.valueOf(machine));
+            args.put("benchmark", benchmarkStr);
+            args.put("key", getKey(benchmarkStr));
+        }
+        Window.open(url + Utils.encodeUrlArguments(args), "_blank", "");
+    }
+    
+    private String getKey(String benchmark) {
+        JSONObject benchmarkKey =
+            StaticDataRepository.getRepository().getData("benchmark_key").isObject();
+        return benchmarkKey.get(benchmark.replaceAll("\\..*", "")).isString().stringValue();
+    }
+}
diff --git a/frontend/client/src/autotest/tko/FragmentedTable.java b/frontend/client/src/autotest/tko/FragmentedTable.java
new file mode 100644
index 0000000..a20e939
--- /dev/null
+++ b/frontend/client/src/autotest/tko/FragmentedTable.java
@@ -0,0 +1,137 @@
+package autotest.tko;
+
+import autotest.common.ui.RightClickTable;
+
+import com.google.gwt.user.client.DOM;
+import com.google.gwt.user.client.Element;
+import com.google.gwt.user.client.Event;
+import com.google.gwt.user.client.ui.HTMLTable;
+
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * Customized table class supporting multiple tbody elements.  It is modified to support input
+ * handling, getRowCount(), getCellCount(), and getCellFormatter().getElement().  getElement() 
+ * also works.  Calls to other methods aren't guaranteed to work.
+ */
+class FragmentedTable extends RightClickTable {
+    public class FragmentedCellFormatter extends HTMLTable.CellFormatter {
+        @Override
+        public Element getElement(int row, int column) {
+            checkCellBounds(row, column);
+            Element bodyElem = bodyElems.get(getFragmentIndex(row));
+            return getCellElement(bodyElem, getRowWithinFragment(row), column);
+        }
+        
+        /**
+         * Native method to efficiently get a td element from a tbody. Copied from GWT's 
+         * HTMLTable.java. 
+         */
+        private native Element getCellElement(Element tbody, int row, int col) /*-{
+            return tbody.rows[row].cells[col];
+        }-*/;
+    }
+    
+    private List<Element> bodyElems = new ArrayList<Element>();
+    private int totalRowCount;
+    private int rowsPerFragment;
+    
+    public FragmentedTable() {
+        super();
+        setCellFormatter(new FragmentedCellFormatter());
+    }
+    
+    /**
+     * This method must be called after added or removing tbody elements and before using other
+     * functionality (accessing cell elements, input handling, etc.).
+     */
+    public void updateBodyElems() {
+        totalRowCount = 0;
+        Element tbody = DOM.getFirstChild(getElement());
+        for(; tbody != null; tbody = DOM.getNextSibling(tbody)) {
+            assert tbody.getTagName().equalsIgnoreCase("tbody");
+            bodyElems.add(tbody);
+            totalRowCount += getRowCount(tbody);
+        }
+    }
+
+    public void reset() {
+        bodyElems.clear();
+        TkoUtils.clearDomChildren(getElement());
+    }
+
+    private int getRowWithinFragment(int row) {
+        return row % rowsPerFragment;
+    }
+
+    private int getFragmentIndex(int row) {
+        return row / rowsPerFragment;
+    }
+
+    @Override
+    protected RowColumn getCellPosition(Element td) {
+        Element tr = DOM.getParent(td);
+        Element body = DOM.getParent(tr);
+        int fragmentIndex = DOM.getChildIndex(getElement(), body);
+        int rowWithinFragment = DOM.getChildIndex(body, tr);
+        int row = fragmentIndex * rowsPerFragment + rowWithinFragment;
+        int column = DOM.getChildIndex(tr, td);
+        return new RowColumn(row, column);
+    }
+
+    /**
+     * This is a modified version of getEventTargetCell() from HTMLTable.java.
+     */
+    @Override
+    protected Element getEventTargetCell(Event event) {
+        Element td = DOM.eventGetTarget(event);
+        for (; td != null; td = DOM.getParent(td)) {
+            // If it's a TD, it might be the one we're looking for.
+            if (DOM.getElementProperty(td, "tagName").equalsIgnoreCase("td")) {
+                // Make sure it's directly a part of this table before returning
+                // it.
+                Element tr = DOM.getParent(td);
+                Element body = DOM.getParent(tr);
+                Element tableElem = DOM.getParent(body);
+                if (tableElem == getElement()) {
+                    return td;
+                }
+            }
+            // If we run into this table's element, we're out of options.
+            if (td == getElement()) {
+                return null;
+            }
+        }
+        return null;
+    }
+
+    @Override
+    public int getCellCount(int row) {
+        Element bodyElem = bodyElems.get(getFragmentIndex(row));
+        return getCellCount(bodyElem, getRowWithinFragment(row));
+    }
+
+    @Override
+    public int getRowCount() {
+        return totalRowCount;
+    }
+    
+    private native int getRowCount(Element tbody) /*-{
+        return tbody.rows.length;
+    }-*/;
+
+    private native int getCellCount(Element tbody, int row) /*-{
+        return tbody.rows[row].cells.length;
+    }-*/;
+
+    /**
+     * This must be called before using other functionality (accessing cell elements, input 
+     * handling, etc.).
+     * @param rowsPerFragment  The number of rows in each tbody.  The last tbody may have fewer 
+     * rows.  All others must have exactly this number of rows.
+     */
+    public void setRowsPerFragment(int rowsPerFragment) {
+        this.rowsPerFragment = rowsPerFragment;
+    }
+}
diff --git a/frontend/client/src/autotest/tko/GraphingView.java b/frontend/client/src/autotest/tko/GraphingView.java
new file mode 100644
index 0000000..a7b2c6d
--- /dev/null
+++ b/frontend/client/src/autotest/tko/GraphingView.java
@@ -0,0 +1,32 @@
+package autotest.tko;
+
+import autotest.common.ui.TabView;
+
+import com.google.gwt.user.client.ui.RootPanel;
+
+public class GraphingView extends TabView {
+    
+    private ExistingGraphsFrontend existingGraphsFrontend = new ExistingGraphsFrontend();
+    
+    @Override
+    public void initialize() {
+        RootPanel.get("graphing_frontend").add(existingGraphsFrontend);
+    }
+    
+    @Override
+    public String getElementId() {
+        return "graphing_view";
+    }
+    
+    @Override
+    public void refresh() {
+        super.refresh();
+        existingGraphsFrontend.refresh();
+    }
+
+    @Override
+    public void display() {
+        super.display();
+        CommonPanel.getPanel().setConditionVisible(false);
+    }
+}
diff --git a/frontend/client/src/autotest/tko/HeaderSelect.java b/frontend/client/src/autotest/tko/HeaderSelect.java
new file mode 100644
index 0000000..b018a1e
--- /dev/null
+++ b/frontend/client/src/autotest/tko/HeaderSelect.java
@@ -0,0 +1,103 @@
+package autotest.tko;
+
+import autotest.common.ui.DoubleListSelector;
+import autotest.common.ui.SimpleHyperlink;
+import autotest.common.ui.DoubleListSelector.Item;
+
+import com.google.gwt.user.client.ui.ClickListener;
+import com.google.gwt.user.client.ui.Composite;
+import com.google.gwt.user.client.ui.ListBox;
+import com.google.gwt.user.client.ui.Panel;
+import com.google.gwt.user.client.ui.StackPanel;
+import com.google.gwt.user.client.ui.VerticalPanel;
+import com.google.gwt.user.client.ui.Widget;
+
+import java.util.List;
+
+class HeaderSelect extends Composite implements ClickListener {
+    private final static String SWITCH_TO_MULTIPLE = "Switch to multiple";
+    private final static String SWITCH_TO_SINGLE = "Switch to single";
+    
+    private ListBox listBox = new ListBox();
+    private DoubleListSelector doubleList = new DoubleListSelector();
+    private StackPanel stack = new StackPanel();
+    private SimpleHyperlink switchLink = new SimpleHyperlink(SWITCH_TO_MULTIPLE);
+    
+    public HeaderSelect() {
+        stack.add(listBox);
+        stack.add(doubleList);
+        
+        Panel panel = new VerticalPanel();
+        panel.add(stack);
+        panel.add(switchLink);
+        initWidget(panel);
+        
+        switchLink.addClickListener(this);
+    }
+    
+    public void addItem(String name, String value) {
+        listBox.addItem(name, value);
+        doubleList.addItem(name, value);
+    }
+    
+    public List<Item> getSelectedItems() {
+        if (!isDoubleSelectActive()) {
+            copyListSelectionToDoubleList();
+        }
+        return doubleList.getSelectedItems();
+    }
+
+    private boolean isDoubleSelectActive() {
+        return switchLink.getText().equals(SWITCH_TO_SINGLE);
+    }
+    
+    public void selectItemsByValue(List<String> values) {
+        if (values.size() > 1 && !isDoubleSelectActive()) {
+            onClick(switchLink);
+        }
+        
+        if (isDoubleSelectActive()) {
+            doubleList.deselectAll();
+            for (String value : values) {
+                doubleList.selectItemByValue(value);
+            }
+        } else {
+            setListBoxSelection(values.get(0));
+        }
+    }
+
+    public void onClick(Widget sender) {
+        assert sender == switchLink;
+        if (isDoubleSelectActive()) {
+            if (doubleList.getSelectedItemCount() > 0) {
+                setListBoxSelection(doubleList.getSelectedItems().get(0).value);
+            }
+            stack.showStack(0);
+            switchLink.setText(SWITCH_TO_MULTIPLE);
+        } else {
+            copyListSelectionToDoubleList();
+            stack.showStack(1);
+            switchLink.setText(SWITCH_TO_SINGLE);
+        }
+    }
+
+    private void copyListSelectionToDoubleList() {
+        doubleList.deselectAll();
+        doubleList.selectItemByValue(getListBoxSelection());
+    }
+
+    private void setListBoxSelection(String value) {
+        for (int i = 0; i < listBox.getItemCount(); i++) {
+            if (listBox.getValue(i).equals(value)) {
+                listBox.setSelectedIndex(i);
+                return;
+            }
+        }
+        
+        throw new IllegalArgumentException("No item with value " + value);
+    }
+
+    private String getListBoxSelection() {
+        return listBox.getValue(listBox.getSelectedIndex());
+    }
+}
diff --git a/frontend/client/src/autotest/tko/SavedQueriesControl.java b/frontend/client/src/autotest/tko/SavedQueriesControl.java
new file mode 100644
index 0000000..6309cfe
--- /dev/null
+++ b/frontend/client/src/autotest/tko/SavedQueriesControl.java
@@ -0,0 +1,201 @@
+package autotest.tko;
+
+import autotest.common.CustomHistory;
+import autotest.common.JSONArrayList;
+import autotest.common.JsonRpcCallback;
+import autotest.common.JsonRpcProxy;
+import autotest.common.StaticDataRepository;
+import autotest.common.CustomHistory.CustomHistoryListener;
+import autotest.common.ui.NotifyManager;
+
+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.History;
+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.Composite;
+import com.google.gwt.user.client.ui.DialogBox;
+import com.google.gwt.user.client.ui.HorizontalPanel;
+import com.google.gwt.user.client.ui.Label;
+import com.google.gwt.user.client.ui.ListBox;
+import com.google.gwt.user.client.ui.Panel;
+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.Map;
+
+class SavedQueriesControl extends Composite 
+                          implements ChangeListener, ClickListener, CustomHistoryListener {
+    public static final String HISTORY_TOKEN = "saved_query";
+    
+    private static final String ADD_QUERY = "Save current...";
+    private static final String DELETE_QUERY = "Delete query...";
+    private static final String DEFAULT_ITEM = "Saved queries...";
+    
+    private static JsonRpcProxy rpcProxy = JsonRpcProxy.getProxy();
+    private static NotifyManager notifyManager = NotifyManager.getInstance();
+    
+    private ListBox queryList = new ListBox();
+    private QueryActionDialog<TextBox> addQueryDialog;
+    private QueryActionDialog<ListBox> deleteQueryDialog;
+    
+    private static class QueryActionDialog<T extends Widget> extends DialogBox {
+        public T widget;
+        public Button actionButton, cancelButton;
+        
+        public QueryActionDialog(T widget, String widgetString, String actionString) {
+            super(false, true);
+            this.widget = widget;
+            Panel dialogPanel = new VerticalPanel();
+            Panel widgetPanel = new HorizontalPanel();
+            widgetPanel.add(new Label(widgetString));
+            widgetPanel.add(widget);
+            dialogPanel.add(widgetPanel);
+            
+            Panel buttonPanel = new HorizontalPanel();
+            actionButton = new Button(actionString);
+            cancelButton = new Button("Cancel");
+            buttonPanel.add(actionButton);
+            buttonPanel.add(cancelButton);
+            dialogPanel.add(buttonPanel);
+            add(dialogPanel);
+            
+            cancelButton.addClickListener(new ClickListener() {
+                public void onClick(Widget sender) {
+                    hide();
+                }
+            });
+        }
+    }
+    
+    public SavedQueriesControl() {
+        queryList.addChangeListener(this);
+        populateMainList();
+        initWidget(queryList);
+        
+        addQueryDialog = new QueryActionDialog<TextBox>(new TextBox(),
+                                                        "Enter query name:", "Save query");
+        addQueryDialog.actionButton.addClickListener(this);
+        deleteQueryDialog = new QueryActionDialog<ListBox>(new ListBox(),
+                                                           "Select query:", "Delete query");
+        deleteQueryDialog.actionButton.addClickListener(this);
+        
+        CustomHistory.addHistoryListener(this);
+    }
+    
+    private void populateMainList() {
+        queryList.clear();
+        queryList.addItem(DEFAULT_ITEM);
+        queryList.addItem(ADD_QUERY);
+        queryList.addItem(DELETE_QUERY);
+        fillQueryList(queryList);
+    }
+
+    private void fillQueryList(final ListBox list) {
+        StaticDataRepository staticData = StaticDataRepository.getRepository();
+        JSONObject args = new JSONObject();
+        args.put("owner", staticData.getData("user_login"));
+        rpcProxy.rpcCall("get_saved_queries", args, new JsonRpcCallback() {
+            @Override
+            public void onSuccess(JSONValue result) {
+                for (JSONObject query : new JSONArrayList<JSONObject>(result.isArray())) {
+                    int id = (int) query.get("id").isNumber().doubleValue();
+                    list.addItem(query.get("name").isString().stringValue(), 
+                                 Integer.toString(id));
+                }
+            }
+        });
+    }
+
+    public void onChange(Widget sender) {
+        int selected = queryList.getSelectedIndex();
+        queryList.setSelectedIndex(0); // set it back to the default
+        
+        String queryName = queryList.getItemText(selected);
+        if (queryName.equals(DEFAULT_ITEM)) {
+            return;
+        }
+        if (queryName.equals(ADD_QUERY)) {
+            addQueryDialog.widget.setText("");
+            addQueryDialog.center();
+            addQueryDialog.widget.setFocus(true);
+            return;
+        }
+        if (queryName.equals(DELETE_QUERY)) {
+            deleteQueryDialog.widget.clear();
+            fillQueryList(deleteQueryDialog.widget);
+            deleteQueryDialog.center();
+            return;
+        }
+        
+        String idString = queryList.getValue(selected);
+        // don't use CustomHistory, since we want the token to be processed
+        History.newItem(HISTORY_TOKEN + "=" + idString);
+    }
+
+    public void onClick(Widget sender) {
+        if (sender == addQueryDialog.actionButton) {
+            addQueryDialog.hide();
+            JSONObject args = new JSONObject();
+            args.put("name", new JSONString(addQueryDialog.widget.getText()));
+            args.put("url_token", new JSONString(CustomHistory.getLastHistoryToken()));
+            rpcProxy.rpcCall("add_saved_query", args, new JsonRpcCallback() {
+                @Override
+                public void onSuccess(JSONValue result) {
+                    notifyManager.showMessage("Query saved");
+                    populateMainList();
+                }
+            });
+        } else {
+            assert sender == deleteQueryDialog.actionButton;
+            deleteQueryDialog.hide();
+            String idString = 
+                deleteQueryDialog.widget.getValue(deleteQueryDialog.widget.getSelectedIndex());
+            JSONObject args = new JSONObject();
+            JSONArray ids = new JSONArray();
+            ids.set(0, new JSONNumber(Integer.parseInt(idString)));
+            args.put("id_list", ids);
+            rpcProxy.rpcCall("delete_saved_queries", args, new JsonRpcCallback() {
+                @Override
+                public void onSuccess(JSONValue result) {
+                    notifyManager.showMessage("Query deleted");
+                    populateMainList();
+                } 
+            });
+        }
+    }
+
+    public void onHistoryChanged(Map<String, String> arguments) {
+        final String idString = arguments.get(HISTORY_TOKEN);
+        if (idString == null) {
+            return;
+        }
+        
+        JSONObject args = new JSONObject();
+        args.put("id", new JSONNumber(Integer.parseInt(idString)));
+        rpcProxy.rpcCall("get_saved_queries", args, new JsonRpcCallback() {
+            @Override
+            public void onSuccess(JSONValue result) {
+                JSONArray queries = result.isArray();
+                if (queries.size() == 0) {
+                    notifyManager.showError("No saved query with ID " + idString);
+                    return;
+                }
+                
+                assert queries.size() == 1;
+                JSONObject query = queries.get(0).isObject();
+                int queryId = (int) query.get("id").isNumber().doubleValue();
+                String token = query.get("url_token").isString().stringValue();
+                // since this is happening asynchronously, the history may have changed, so ensure
+                // it's set back to what it should be.
+                CustomHistory.newItem(HISTORY_TOKEN + "=" + Integer.toString(queryId));
+                CustomHistory.simulateHistoryToken(token);
+            }
+        });
+    }
+}
diff --git a/frontend/client/src/autotest/tko/Spreadsheet.java b/frontend/client/src/autotest/tko/Spreadsheet.java
new file mode 100644
index 0000000..e91423a
--- /dev/null
+++ b/frontend/client/src/autotest/tko/Spreadsheet.java
@@ -0,0 +1,544 @@
+package autotest.tko;
+
+import autotest.common.UnmodifiableSublistView;
+import autotest.common.Utils;
+import autotest.common.ui.RightClickTable;
+
+import com.google.gwt.dom.client.Element;
+import com.google.gwt.user.client.DeferredCommand;
+import com.google.gwt.user.client.IncrementalCommand;
+import com.google.gwt.user.client.Window;
+import com.google.gwt.user.client.ui.Composite;
+import com.google.gwt.user.client.ui.FlexTable;
+import com.google.gwt.user.client.ui.HTMLTable;
+import com.google.gwt.user.client.ui.Panel;
+import com.google.gwt.user.client.ui.ScrollListener;
+import com.google.gwt.user.client.ui.ScrollPanel;
+import com.google.gwt.user.client.ui.SimplePanel;
+import com.google.gwt.user.client.ui.SourcesTableEvents;
+import com.google.gwt.user.client.ui.TableListener;
+import com.google.gwt.user.client.ui.Widget;
+
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+public class Spreadsheet extends Composite implements ScrollListener, TableListener {
+    private static final int MIN_TABLE_SIZE_PX = 90;
+    private static final int WINDOW_BORDER_PX = 15;
+    private static final int SCROLLBAR_FUDGE = 16;
+    private static final String BLANK_STRING = "(empty)";
+    private static final int CELL_PADDING_PX = 2;
+    private static final int TD_BORDER_PX = 1;
+    private static final String HIGHLIGHTED_CLASS = "highlighted";
+    private static final int CELLS_PER_ITERATION = 1000;
+    
+    private Header rowFields, columnFields;
+    private List<Header> rowHeaderValues = new ArrayList<Header>();
+    private List<Header> columnHeaderValues = new ArrayList<Header>();
+    private Map<Header, Integer> rowHeaderMap = new HashMap<Header, Integer>();
+    private Map<Header, Integer> columnHeaderMap = new HashMap<Header, Integer>();
+    protected CellInfo[][] dataCells, rowHeaderCells, columnHeaderCells;
+    private RightClickTable rowHeaders = new RightClickTable();
+    private RightClickTable columnHeaders = new RightClickTable();
+    private FlexTable parentTable = new FlexTable();
+    private FragmentedTable dataTable = new FragmentedTable();
+    private int rowsPerIteration;
+    private Panel rowHeadersClipPanel, columnHeadersClipPanel;
+    private ScrollPanel scrollPanel = new ScrollPanel(dataTable);
+    private TableRenderer renderer = new TableRenderer();
+    
+    private SpreadsheetListener listener;
+    
+    public interface SpreadsheetListener {
+        public void onCellClicked(CellInfo cellInfo);
+    }
+    
+    public static interface Header extends List<String> {}
+    public static class HeaderImpl extends ArrayList<String> implements Header {
+        public HeaderImpl() {
+        }
+
+        public HeaderImpl(Collection<? extends String> arg0) {
+            super(arg0);
+        }
+
+        public static Header fromBaseType(List<String> baseType) {
+            return new HeaderImpl(baseType);
+        }
+    }
+    
+    public static class CellInfo {
+        public Header row, column;
+        public String contents;
+        public String color;
+        public Integer widthPx, heightPx;
+        public int rowSpan = 1, colSpan = 1;
+        public int testCount = 0;
+        
+        public CellInfo(Header row, Header column, String contents) {
+            this.row = row;
+            this.column = column;
+            this.contents = contents;
+        }
+        
+        public boolean isHeader() {
+            return !isEmpty() && (row == null || column == null);
+        }
+        
+        public boolean isEmpty() {
+            return row == null && column == null;
+        }
+    }
+    
+    private class RenderCommand implements IncrementalCommand {
+        private int state = 0;
+        private int rowIndex = 0;
+        private IncrementalCommand onFinished;
+        
+        public RenderCommand(IncrementalCommand onFinished) {
+            this.onFinished = onFinished;
+        }
+        
+        private void renderSomeRows() {
+            renderer.renderRowsAndAppend(dataTable, dataCells, 
+                                         rowIndex, rowsPerIteration, true);
+            rowIndex += rowsPerIteration;
+            if (rowIndex > dataCells.length) {
+                state++;
+            }
+        }
+        
+        public boolean execute() {
+            switch (state) {
+                case 0:
+                    computeRowsPerIteration();
+                    computeHeaderCells();
+                    break;
+                case 1:
+                    renderHeaders();
+                    expandRowHeaders();
+                    // set main table to match header sizes
+                    matchRowHeights(rowHeaders, dataCells);
+                    matchColumnWidths(columnHeaders, dataCells);
+                    dataTable.setVisible(false);
+                    break;
+                case 2:
+                    // resize everything to the max dimensions (the window size)
+                    fillWindow(false);
+                    break;
+                case 3:
+                    // render the main data table
+                    renderSomeRows();
+                    return true;
+                case 4:
+                    dataTable.updateBodyElems();
+                    dataTable.setVisible(true);
+                    break;
+                case 5:
+                    // now expand headers as necessary
+                    // this can be very slow, so put it in it's own cycle
+                    matchRowHeights(dataTable, rowHeaderCells);
+                    break;
+                case 6:
+                    matchColumnWidths(dataTable, columnHeaderCells);
+                    renderHeaders();
+                    break;
+                case 7:
+                    // shrink the scroller if the table ended up smaller than the window
+                    fillWindow(true);
+                    DeferredCommand.addCommand(onFinished);
+                    return false;
+            }
+            
+            state++;
+            return true;
+        }
+    }
+
+    public Spreadsheet() {
+        dataTable.setStyleName("spreadsheet-data");
+        killPaddingAndSpacing(dataTable);
+        
+        rowHeaders.setStyleName("spreadsheet-headers");
+        killPaddingAndSpacing(rowHeaders);
+        rowHeadersClipPanel = wrapWithClipper(rowHeaders);
+        
+        columnHeaders.setStyleName("spreadsheet-headers");
+        killPaddingAndSpacing(columnHeaders);
+        columnHeadersClipPanel = wrapWithClipper(columnHeaders);
+        
+        scrollPanel.setStyleName("spreadsheet-scroller");
+        scrollPanel.setAlwaysShowScrollBars(true);
+        scrollPanel.addScrollListener(this);
+        
+        parentTable.setStyleName("spreadsheet-parent");
+        killPaddingAndSpacing(parentTable);
+        parentTable.setWidget(0, 1, columnHeadersClipPanel);
+        parentTable.setWidget(1, 0, rowHeadersClipPanel);
+        parentTable.setWidget(1, 1, scrollPanel);
+        
+        setupTableInput(dataTable);
+        setupTableInput(rowHeaders);
+        setupTableInput(columnHeaders);
+        
+        initWidget(parentTable);
+    }
+
+    private void setupTableInput(RightClickTable table) {
+        table.sinkRightClickEvents();
+        table.addTableListener(this);
+    }
+
+    protected void killPaddingAndSpacing(HTMLTable table) {
+        table.setCellSpacing(0);
+        table.setCellPadding(0);
+    }
+    
+    /*
+     * Wrap a widget with a panel that will clip its contents rather than grow
+     * too much.
+     */
+    protected Panel wrapWithClipper(Widget w) {
+        SimplePanel wrapper = new SimplePanel();
+        wrapper.add(w);
+        wrapper.setStyleName("clipper");
+        return wrapper;
+    }
+    
+    public void setHeaderFields(Header rowFields, Header columnFields) {
+        this.rowFields = rowFields;
+        this.columnFields = columnFields;
+    }
+    
+    private void addHeader(List<Header> headerList, Map<Header, Integer> headerMap,
+                          List<String> header) {
+        Header headerObject = HeaderImpl.fromBaseType(header);
+        assert !headerMap.containsKey(headerObject);
+        headerList.add(headerObject);
+        headerMap.put(headerObject, headerMap.size());
+    }
+    
+    public void addRowHeader(List<String> header) {
+        addHeader(rowHeaderValues, rowHeaderMap, header);
+    }
+    
+    public void addColumnHeader(List<String> header) {
+        addHeader(columnHeaderValues, columnHeaderMap, header);
+    }
+    
+    private int getHeaderPosition(Map<Header, Integer> headerMap, Header header) {
+        assert headerMap.containsKey(header);
+        return headerMap.get(header);
+    }
+    
+    private int getRowPosition(Header rowHeader) {
+        return getHeaderPosition(rowHeaderMap, rowHeader);
+    }
+    
+    private int getColumnPosition(Header columnHeader) {
+        return getHeaderPosition(columnHeaderMap, columnHeader);
+    }
+    
+    /**
+     * Must be called after adding headers but before adding data
+     */
+    public void prepareForData() {
+        dataCells = new CellInfo[rowHeaderValues.size()][columnHeaderValues.size()];
+    }
+
+    public CellInfo getCellInfo(int row, int column) {
+        Header rowHeader = rowHeaderValues.get(row);
+        Header columnHeader = columnHeaderValues.get(column);
+        if (dataCells[row][column] == null) {
+            dataCells[row][column] = new CellInfo(rowHeader, columnHeader, "");
+        }
+        return dataCells[row][column];
+    }
+    
+    private CellInfo getCellInfo(CellInfo[][] cells, int row, int column) {
+        if (cells[row][column] == null) {
+            cells[row][column] = new CellInfo(null, null, " ");
+        }
+        return cells[row][column];
+    }
+    
+    /**
+     * Render the data into HTML tables.  Done through a deferred command.
+     */
+    public void render(IncrementalCommand onFinished) {
+        DeferredCommand.addCommand(new RenderCommand(onFinished));
+    }
+
+    private void renderHeaders() {
+        renderer.renderRows(rowHeaders, rowHeaderCells, false);
+        renderer.renderRows(columnHeaders, columnHeaderCells, false);
+    }
+    
+    public void computeRowsPerIteration() {
+        int cellsPerRow = columnHeaderValues.size();
+        rowsPerIteration = Math.max(CELLS_PER_ITERATION / cellsPerRow, 1);
+        dataTable.setRowsPerFragment(rowsPerIteration);
+    }
+    
+    private void computeHeaderCells() {
+        rowHeaderCells = new CellInfo[rowHeaderValues.size()][rowFields.size()];
+        fillHeaderCells(rowHeaderCells, rowFields, rowHeaderValues, true);
+        
+        columnHeaderCells = new CellInfo[columnFields.size()][columnHeaderValues.size()];
+        fillHeaderCells(columnHeaderCells, columnFields, columnHeaderValues, false);
+    }
+    
+    /**
+     * TODO (post-1.0) - this method needs good cleanup and documentation
+     */
+    private void fillHeaderCells(CellInfo[][] cells, Header fields, List<Header> headerValues, 
+                                 boolean isRows) {
+        int headerSize = fields.size();
+        String[] lastFieldValue = new String[headerSize];
+        CellInfo[] lastCellInfo = new CellInfo[headerSize];
+        int[] counter = new int[headerSize];
+        boolean newHeader;
+        for (int headerIndex = 0; headerIndex < headerValues.size(); headerIndex++) {
+            Header header = headerValues.get(headerIndex);
+            newHeader = false;
+            for (int fieldIndex = 0; fieldIndex < headerSize; fieldIndex++) {
+                String fieldValue = header.get(fieldIndex);
+                if (newHeader || !fieldValue.equals(lastFieldValue[fieldIndex])) {
+                    newHeader = true;
+                    Header currentHeader = getSubHeader(header, fieldIndex + 1);
+                    String cellContents = formatHeader(fields.get(fieldIndex), fieldValue);
+                    CellInfo cellInfo;
+                    if (isRows) {
+                        cellInfo = new CellInfo(currentHeader, null, cellContents);
+                        cells[headerIndex][fieldIndex] = cellInfo;
+                    } else {
+                        cellInfo = new CellInfo(null, currentHeader, cellContents);
+                        cells[fieldIndex][counter[fieldIndex]] = cellInfo;
+                        counter[fieldIndex]++;
+                    }
+                    lastFieldValue[fieldIndex] = fieldValue;
+                    lastCellInfo[fieldIndex] = cellInfo;
+                } else {
+                    incrementSpan(lastCellInfo[fieldIndex], isRows);
+                }
+            }
+        }
+    }
+    
+    private String formatHeader(String field, String value) {
+        if (value.equals("")) {
+            return BLANK_STRING;
+        }
+        value = Utils.escape(value);
+        if (field.equals("kernel")) {
+            // line break after each /, for long paths
+            value = value.replace("/", "/<br>").replace("/<br>/<br>", "//");
+        }
+        return value;
+    }
+
+    private void incrementSpan(CellInfo cellInfo, boolean isRows) {
+        if (isRows) {
+            cellInfo.rowSpan++;
+        } else {
+            cellInfo.colSpan++;
+        }
+    }
+
+    private Header getSubHeader(Header header, int length) {
+        if (length == header.size()) {
+            return header;
+        }
+        List<String> subHeader = new UnmodifiableSublistView<String>(header, 0, length);
+        return new HeaderImpl(subHeader);
+    }
+
+    private void matchRowHeights(HTMLTable from, CellInfo[][] to) {
+        int lastColumn = to[0].length - 1;
+        int rowCount = from.getRowCount();
+        for (int row = 0; row < rowCount; row++) {
+            int height = getRowHeight(from, row);
+            getCellInfo(to, row, lastColumn).heightPx = height - 2 * CELL_PADDING_PX;
+        }
+    }
+    
+    private void matchColumnWidths(HTMLTable from, CellInfo[][] to) {
+        int lastToRow = to.length - 1;
+        int lastFromRow = from.getRowCount() - 1;
+        for (int column = 0; column < from.getCellCount(lastFromRow); column++) {
+            int width = getColumnWidth(from, column);
+            getCellInfo(to, lastToRow, column).widthPx = width - 2 * CELL_PADDING_PX;
+        }
+    }
+    
+    protected String getTableCellText(HTMLTable table, int row, int column) {
+        Element td = table.getCellFormatter().getElement(row, column);
+        Element div = td.getFirstChildElement();
+        if (div == null)
+            return null;
+        String contents = Utils.unescape(div.getInnerHTML());
+        if (contents.equals(BLANK_STRING))
+            contents = "";
+        return contents;
+    }
+
+    public void clear() {
+        rowHeaderValues.clear();
+        columnHeaderValues.clear();
+        rowHeaderMap.clear();
+        columnHeaderMap.clear();
+        dataCells = rowHeaderCells = columnHeaderCells = null;
+        dataTable.reset();
+        
+        setRowHeadersOffset(0);
+        setColumnHeadersOffset(0);
+    }
+    
+    /**
+     * Make the spreadsheet fill the available window space to the right and bottom
+     * of its position.
+     */
+    public void fillWindow(boolean useTableSize) {
+        int newHeightPx = Window.getClientHeight() - (columnHeaders.getAbsoluteTop() + 
+                                                      columnHeaders.getOffsetHeight());
+        newHeightPx = adjustMaxDimension(newHeightPx);
+        int newWidthPx = Window.getClientWidth() - (rowHeaders.getAbsoluteLeft() + 
+                                                    rowHeaders.getOffsetWidth());
+        newWidthPx = adjustMaxDimension(newWidthPx);
+        if (useTableSize) {
+            newHeightPx = Math.min(newHeightPx, rowHeaders.getOffsetHeight());
+            newWidthPx = Math.min(newWidthPx, columnHeaders.getOffsetWidth());
+        }
+        
+        // apply the changes all together
+        rowHeadersClipPanel.setHeight(getSizePxString(newHeightPx));
+        columnHeadersClipPanel.setWidth(getSizePxString(newWidthPx));
+        scrollPanel.setSize(getSizePxString(newWidthPx + SCROLLBAR_FUDGE),
+                            getSizePxString(newHeightPx + SCROLLBAR_FUDGE));
+    }
+
+    /**
+     * Adjust a maximum table dimension to allow room for edge decoration and 
+     * always maintain a minimum height
+     */
+    protected int adjustMaxDimension(int maxDimensionPx) {
+        return Math.max(maxDimensionPx - WINDOW_BORDER_PX - SCROLLBAR_FUDGE, 
+                        MIN_TABLE_SIZE_PX);
+    }
+
+    protected String getSizePxString(int sizePx) {
+        return sizePx + "px";
+    }
+    
+    /**
+     * Ensure the row header clip panel allows the full width of the row headers
+     * to display.
+     */
+    protected void expandRowHeaders() {
+        int width = rowHeaders.getOffsetWidth();
+        rowHeadersClipPanel.setWidth(getSizePxString(width));
+    }
+    
+    private Element getCellElement(HTMLTable table, int row, int column) {
+        return table.getCellFormatter().getElement(row, column);
+    }
+    
+    private Element getCellElement(CellInfo cellInfo) {
+        assert cellInfo.row != null || cellInfo.column != null;
+        Element tdElement;
+        if (cellInfo.row == null) {
+            tdElement = getCellElement(columnHeaders, 0, getColumnPosition(cellInfo.column));
+        } else if (cellInfo.column == null) {
+            tdElement = getCellElement(rowHeaders, getRowPosition(cellInfo.row), 0);
+        } else {
+            tdElement = getCellElement(dataTable, getRowPosition(cellInfo.row), 
+                                                  getColumnPosition(cellInfo.column));
+        }
+        Element cellElement = tdElement.getFirstChildElement();
+        assert cellElement != null;
+        return cellElement;
+    }
+    
+    protected int getColumnWidth(HTMLTable table, int column) {
+        // using the column formatter doesn't seem to work
+        int numRows = table.getRowCount();
+        return table.getCellFormatter().getElement(numRows - 1, column).getOffsetWidth() - 
+               TD_BORDER_PX;
+    }
+    
+    protected int getRowHeight(HTMLTable table, int row) {
+        // see getColumnWidth()
+        int numCols = table.getCellCount(row);
+        return table.getCellFormatter().getElement(row, numCols - 1).getOffsetHeight() - 
+               TD_BORDER_PX;
+    }
+
+    /**
+     * Update floating headers.
+     */
+    public void onScroll(Widget widget, int scrollLeft, int scrollTop) {
+        setColumnHeadersOffset(-scrollLeft);
+        setRowHeadersOffset(-scrollTop);
+    }
+
+    protected void setRowHeadersOffset(int offset) {
+        rowHeaders.getElement().getStyle().setPropertyPx("top", offset);
+    }
+
+    protected void setColumnHeadersOffset(int offset) {
+        columnHeaders.getElement().getStyle().setPropertyPx("left", offset);
+    }
+
+    public void onCellClicked(SourcesTableEvents sender, int row, int column) {
+        if (listener == null)
+            return;
+        
+        CellInfo[][] cells;
+        if (sender == rowHeaders) {
+            cells = rowHeaderCells;
+            column = adjustRowHeaderColumnIndex(row, column);
+        }
+        else if (sender == columnHeaders) {
+            cells = columnHeaderCells;
+        }
+        else {
+            assert sender == dataTable;
+            cells = dataCells;
+        }
+        CellInfo cell = cells[row][column];
+        if (cell == null || cell.isEmpty())
+            return; // don't report clicks on empty cells
+        listener.onCellClicked(cell);
+    }
+
+    /**
+     * In HTMLTables, a cell with rowspan > 1 won't count in column indices for the extra rows it 
+     * spans, which will mess up column indices for other cells in those rows.  This method adjusts
+     * the column index passed to onCellClicked() to account for that.
+     */
+    private int adjustRowHeaderColumnIndex(int row, int column) {
+        for (int i = 0; i < rowFields.size(); i++) {
+            if (rowHeaderCells[row][i] != null) {
+                return i + column;
+            }
+        }
+        
+        throw new RuntimeException("Failed to find non-null cell");
+    }
+
+    public void setListener(SpreadsheetListener listener) {
+        this.listener = listener;
+    }
+
+    public void setHighlighted(CellInfo cell, boolean highlighted) {
+        Element cellElement = getCellElement(cell);
+        if (highlighted) {
+            cellElement.setClassName(HIGHLIGHTED_CLASS);
+        } else {
+            cellElement.setClassName("");
+        }
+    }
+}
diff --git a/frontend/client/src/autotest/tko/SpreadsheetDataProcessor.java b/frontend/client/src/autotest/tko/SpreadsheetDataProcessor.java
new file mode 100644
index 0000000..974d868
--- /dev/null
+++ b/frontend/client/src/autotest/tko/SpreadsheetDataProcessor.java
@@ -0,0 +1,179 @@
+package autotest.tko;
+
+import autotest.common.table.DataSource.DataCallback;
+import autotest.common.ui.NotifyManager;
+import autotest.tko.Spreadsheet.CellInfo;
+import autotest.tko.Spreadsheet.Header;
+
+import com.google.gwt.core.client.Duration;
+import com.google.gwt.json.client.JSONArray;
+import com.google.gwt.json.client.JSONObject;
+import com.google.gwt.user.client.Command;
+import com.google.gwt.user.client.DeferredCommand;
+import com.google.gwt.user.client.IncrementalCommand;
+
+import java.util.ArrayList;
+import java.util.List;
+
+public class SpreadsheetDataProcessor implements DataCallback {
+    private static final NotifyManager notifyManager = NotifyManager.getInstance();
+    private static final int MAX_CELL_COUNT = 500000;
+    private static final int ROWS_PROCESSED_PER_ITERATION = 1000;
+    
+    private Spreadsheet spreadsheet;
+    private TestGroupDataSource dataSource;
+    private int numTotalTests;
+
+    private Header rowFields;
+    private Header columnFields;
+    private Command onFinished;
+    private Duration timer;
+    
+    public class TooManyCellsError extends Exception {
+        public int cellCount;
+
+        public TooManyCellsError(int cellCount) {
+            super();
+            this.cellCount = cellCount;
+        }
+    }
+    
+    private class ProcessDataCommand implements IncrementalCommand {
+        private int state = 0;
+        private JSONArray counts;
+        private int currentRow = 0;
+        
+        public ProcessDataCommand(JSONArray counts) {
+            this.counts = counts;
+        }
+        
+        public void processSomeRows() {
+            for (int i = 0; i < ROWS_PROCESSED_PER_ITERATION; i++, currentRow++) {
+                if (currentRow == counts.size()) {
+                    state++;
+                    return;
+                }
+                processRow(counts.get(currentRow).isObject());
+            }
+        }
+        
+        public boolean execute() {
+            switch (state) {
+                case 0:
+                    notifyManager.setLoading(true);
+                    numTotalTests = 0;
+                    try {
+                        processHeaders();
+                    } catch (TooManyCellsError exc) {
+                        notifyManager.showError("Resulting spreadsheet contains " + exc.cellCount + " cells, " +
+                                                "exceeding maximum " + MAX_CELL_COUNT);
+                        finalizeCommand();
+                        return false;
+                    }
+                    break;
+                case 1:
+                    spreadsheet.prepareForData();
+                    break;
+                case 2:
+                    processSomeRows();
+                    return true;
+                case 3:
+                    // we must make the spreadsheet visible before rendering, or size computations 
+                    // won't work correctly
+                    spreadsheet.setVisible(true);
+                    break;
+                case 4:
+                    spreadsheet.render(this);
+                    state++;
+                    return false; // render will re-add us to the command queue
+                case 5:
+                    logTimer("Rendering");
+                    finalizeCommand();
+                    return false;
+            }
+            
+            state++;
+            return true;
+        }
+
+        private void finalizeCommand() {
+            notifyManager.setLoading(false);
+            onFinished.execute();
+        }
+    }
+    
+    public SpreadsheetDataProcessor(Spreadsheet spreadsheet, TestGroupDataSource dataSource) {
+        this.spreadsheet = spreadsheet;
+        this.dataSource = dataSource;
+    }
+
+    public void processHeaders() throws TooManyCellsError {
+        spreadsheet.setHeaderFields(rowFields, columnFields);
+        List<List<String>> rowHeaderValues = dataSource.getHeaderGroupValues(0);
+        List<List<String>> columnHeaderValues = dataSource.getHeaderGroupValues(1);
+        int totalCells = rowHeaderValues.size() * columnHeaderValues.size();
+        if (totalCells > MAX_CELL_COUNT) {
+            throw new TooManyCellsError(totalCells);
+        }
+        for (List<String> header : rowHeaderValues) {
+            spreadsheet.addRowHeader(header);
+        }
+        for (List<String> header : columnHeaderValues) {
+            spreadsheet.addColumnHeader(header);
+        }
+    }
+
+    private void processRow(JSONObject group) {
+        JSONArray headerIndices = group.get("header_indices").isArray();
+        int row = (int) headerIndices.get(0).isNumber().doubleValue();
+        int column = (int) headerIndices.get(1).isNumber().doubleValue();
+        CellInfo cellInfo = spreadsheet.getCellInfo(row, column);
+        StatusSummary statusSummary = StatusSummary.getStatusSummary(group);
+        numTotalTests += statusSummary.getTotal();
+        cellInfo.contents = statusSummary.formatStatusCounts();
+        cellInfo.color = statusSummary.getColor();
+        cellInfo.testCount = statusSummary.getTotal();
+    }
+    
+    public void refresh(Header rowFields, Header columnFields, String condition, 
+                        Command onFinished) {
+        timer = new Duration();
+        this.onFinished = onFinished;
+        this.rowFields = rowFields;
+        this.columnFields = columnFields;
+        List<List<String>> headerGroups = new ArrayList<List<String>>();
+        headerGroups.add(rowFields);
+        headerGroups.add(columnFields);
+        dataSource.setHeaderGroups(headerGroups);
+        JSONObject params = TkoUtils.getConditionParams(condition);
+        dataSource.updateData(params, this);
+    }
+    
+    public void onGotData(int totalCount) {
+        dataSource.getPage(null, null, null, this);
+    }
+
+    public void handlePage(JSONArray data) {
+        logTimer("Server response");
+        if (data.size() == 0) {
+            notifyManager.showMessage("No results for query");
+            onFinished.execute();
+            return;
+        }
+
+        DeferredCommand.addCommand(new ProcessDataCommand(data));
+    }
+    
+    private void logTimer(String message) {
+        notifyManager.log(message + ": " + timer.elapsedMillis() + " ms");
+        timer = new Duration();
+    }
+
+    public int getNumTotalTests() {
+        return numTotalTests;
+    }
+    
+    public void onError(JSONObject errorObject) {
+        onFinished.execute();
+    }
+}
diff --git a/frontend/client/src/autotest/tko/SpreadsheetSelectionManager.java b/frontend/client/src/autotest/tko/SpreadsheetSelectionManager.java
new file mode 100644
index 0000000..9f8e034
--- /dev/null
+++ b/frontend/client/src/autotest/tko/SpreadsheetSelectionManager.java
@@ -0,0 +1,91 @@
+package autotest.tko;
+
+import autotest.common.Utils;
+import autotest.tko.Spreadsheet.CellInfo;
+
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.HashSet;
+import java.util.List;
+
+// TODO: hopefully some of this could be combined with autotest.common.table.SelectionManager using
+// generics
+// TODO: get rid of header selection
+public class SpreadsheetSelectionManager {
+    private Spreadsheet spreadsheet;
+    private Collection<CellInfo> selectedCells = new HashSet<CellInfo>();
+    private SpreadsheetSelectionListener listener;
+    
+    public static interface SpreadsheetSelectionListener {
+        public void onCellsSelected(List<CellInfo> cells);
+        public void onCellsDeselected(List<CellInfo> cells);
+    }
+    
+    public SpreadsheetSelectionManager(Spreadsheet spreadsheet, 
+                                       SpreadsheetSelectionListener listener) {
+        this.spreadsheet = spreadsheet;
+        this.listener = listener;
+    }
+    
+    public void toggleSelected(CellInfo cell) {
+        if (selectedCells.contains(cell)) {
+            deselectCell(cell);
+            notifyDeselected(Utils.wrapObjectWithList(cell));
+        } else {
+            selectCell(cell);
+            notifySelected(Utils.wrapObjectWithList(cell));
+        }
+    }
+
+    private void selectCell(CellInfo cell) {
+        selectedCells.add(cell);
+        spreadsheet.setHighlighted(cell, true);
+    }
+
+    private void deselectCell(CellInfo cell) {
+        selectedCells.remove(cell);
+        spreadsheet.setHighlighted(cell, false);
+    }
+    
+    public List<CellInfo> getSelectedCells() {
+        return new ArrayList<CellInfo>(selectedCells);
+    }
+    
+    public boolean isEmpty() {
+        return selectedCells.isEmpty();
+    }
+    
+    public void clearSelection() {
+        List<CellInfo> cells = getSelectedCells();
+        for (CellInfo cell : cells) {
+            deselectCell(cell);
+        }
+        notifyDeselected(cells);
+    }
+
+    public void selectAll() {
+        List<CellInfo> selectedCells = new ArrayList<CellInfo>();
+        for (CellInfo[] row : spreadsheet.dataCells) {
+            for (CellInfo cell : row) {
+                if (cell == null || cell.isEmpty()) {
+                    continue;
+                }
+                selectCell(cell);
+                selectedCells.add(cell);
+            }
+        }
+        notifySelected(selectedCells);
+    }
+    
+    private void notifyDeselected(List<CellInfo> cells) {
+        if (listener != null) {
+            listener.onCellsDeselected(cells);
+        }
+    }
+
+    private void notifySelected(List<CellInfo> selectedCells) {
+        if (listener != null) {
+            listener.onCellsSelected(selectedCells);
+        }
+    }
+}
diff --git a/frontend/client/src/autotest/tko/SpreadsheetView.java b/frontend/client/src/autotest/tko/SpreadsheetView.java
new file mode 100644
index 0000000..8a141e3
--- /dev/null
+++ b/frontend/client/src/autotest/tko/SpreadsheetView.java
@@ -0,0 +1,485 @@
+package autotest.tko;
+
+import autotest.common.JsonRpcCallback;
+import autotest.common.JsonRpcProxy;
+import autotest.common.UnmodifiableSublistView;
+import autotest.common.Utils;
+import autotest.common.ui.ContextMenu;
+import autotest.common.ui.NotifyManager;
+import autotest.common.ui.RightClickTable;
+import autotest.common.ui.SimpleHyperlink;
+import autotest.common.ui.TableActionsPanel;
+import autotest.common.ui.DoubleListSelector.Item;
+import autotest.common.ui.TableActionsPanel.TableActionsListener;
+import autotest.tko.Spreadsheet.CellInfo;
+import autotest.tko.Spreadsheet.Header;
+import autotest.tko.Spreadsheet.HeaderImpl;
+import autotest.tko.Spreadsheet.SpreadsheetListener;
+import autotest.tko.TkoUtils.FieldInfo;
+
+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.Command;
+import com.google.gwt.user.client.Event;
+import com.google.gwt.user.client.Window;
+import com.google.gwt.user.client.WindowResizeListener;
+import com.google.gwt.user.client.ui.Button;
+import com.google.gwt.user.client.ui.CheckBox;
+import com.google.gwt.user.client.ui.ClickListener;
+import com.google.gwt.user.client.ui.HTML;
+import com.google.gwt.user.client.ui.MenuBar;
+import com.google.gwt.user.client.ui.Panel;
+import com.google.gwt.user.client.ui.RootPanel;
+import com.google.gwt.user.client.ui.VerticalPanel;
+import com.google.gwt.user.client.ui.Widget;
+
+import java.util.Arrays;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+public class SpreadsheetView extends ConditionTabView 
+                             implements SpreadsheetListener, TableActionsListener {
+    public static final String DEFAULT_ROW = "kernel";
+    public static final String DEFAULT_COLUMN = "platform";
+    
+    private static enum DrilldownType {DRILLDOWN_ROW, DRILLDOWN_COLUMN, DRILLDOWN_BOTH}
+    
+    private static JsonRpcProxy rpcProxy = JsonRpcProxy.getProxy();
+    private static JsonRpcProxy afeRpcProxy = JsonRpcProxy.getProxy(JsonRpcProxy.AFE_URL);
+    private SpreadsheetViewListener listener;
+    protected Header currentRowFields;
+    protected Header currentColumnFields;
+    protected Map<String,String[]> drilldownMap = new HashMap<String,String[]>();
+    
+    private HeaderSelect rowSelect = new HeaderSelect();
+    private HeaderSelect columnSelect = new HeaderSelect();
+    private CheckBox showIncomplete = new CheckBox("Show incomplete tests"); 
+    private Button queryButton = new Button("Query");
+    private TestGroupDataSource dataSource = TestGroupDataSource.getStatusCountDataSource();
+    private Spreadsheet spreadsheet = new Spreadsheet();
+    private SpreadsheetDataProcessor spreadsheetProcessor = 
+        new SpreadsheetDataProcessor(spreadsheet, dataSource);
+    private SpreadsheetSelectionManager selectionManager = 
+        new SpreadsheetSelectionManager(spreadsheet, null);
+    private TableActionsPanel actionsPanel = new TableActionsPanel(this, false);
+    private RootPanel jobCompletionPanel;
+    private boolean currentShowIncomplete;
+    private boolean notYetQueried = true;
+    
+    public static interface SpreadsheetViewListener extends TestSelectionListener {
+        public void onSwitchToTable(boolean isTriageView);
+    }
+    
+    public SpreadsheetView(SpreadsheetViewListener listener) {
+        this.listener = listener;
+    }
+    
+    @Override
+    public String getElementId() {
+        return "spreadsheet_view";
+    }
+
+    @Override
+    public void initialize() {
+        dataSource.setSkipNumResults(true);
+        
+        currentRowFields = new HeaderImpl();
+        currentRowFields.add(DEFAULT_ROW);
+        currentColumnFields = new HeaderImpl();
+        currentColumnFields.add(DEFAULT_COLUMN);
+        
+        for (FieldInfo fieldInfo : TkoUtils.getFieldList("group_fields")) {
+            rowSelect.addItem(fieldInfo.name, fieldInfo.field);
+            columnSelect.addItem(fieldInfo.name, fieldInfo.field);
+        }
+        updateWidgets();
+        
+        queryButton.addClickListener(new ClickListener() {
+            public void onClick(Widget sender) {
+                doQuery();
+                updateHistory();
+            } 
+        });
+        
+        spreadsheet.setVisible(false);
+        spreadsheet.setListener(this);
+        
+        SimpleHyperlink swapLink = new SimpleHyperlink("swap");
+        swapLink.addClickListener(new ClickListener() {
+            public void onClick(Widget sender) {
+                Header newRows = getSelectedHeader(columnSelect);
+                setSelectedHeader(columnSelect, getSelectedHeader(rowSelect));
+                setSelectedHeader(rowSelect, newRows);
+            } 
+        });
+        
+        Panel queryControlPanel = new VerticalPanel();
+        queryControlPanel.add(showIncomplete);
+        queryControlPanel.add(queryButton);
+        
+        RootPanel.get("ss_row_select").add(rowSelect);
+        RootPanel.get("ss_column_select").add(columnSelect);
+        RootPanel.get("ss_swap").add(swapLink);
+        RootPanel.get("ss_query_controls").add(queryControlPanel);
+        RootPanel.get("ss_actions").add(actionsPanel);
+        RootPanel.get("ss_spreadsheet").add(spreadsheet);
+        jobCompletionPanel = RootPanel.get("ss_job_completion");
+        
+        Window.addWindowResizeListener(new WindowResizeListener() {
+            public void onWindowResized(int width, int height) {
+                if(spreadsheet.isVisible())
+                    spreadsheet.fillWindow(true);
+            } 
+        });
+        
+        setupDrilldownMap();
+    }
+    
+    protected TestSet getWholeTableTestSet() {
+        boolean isSingleTest = spreadsheetProcessor.getNumTotalTests() == 1;
+        return new ConditionTestSet(isSingleTest, commonPanel.getSavedCondition());
+    }
+
+    protected void setupDrilldownMap() {
+        drilldownMap.put("platform", new String[] {"hostname", "test_name"});
+        drilldownMap.put("hostname", new String[] {"job_tag", "status"});
+        drilldownMap.put("job_tag", new String[] {"job_tag"});
+        
+        drilldownMap.put("kernel", new String[] {"test_name", "status"});
+        drilldownMap.put("test_name", new String[] {"job_name", "job_tag"});
+        
+        drilldownMap.put("status", new String[] {"reason", "job_tag"});
+        drilldownMap.put("reason", new String[] {"job_tag"});
+        
+        drilldownMap.put("job_owner", new String[] {"job_name", "job_tag"});
+        drilldownMap.put("job_name", new String[] {"job_tag"});
+        
+        drilldownMap.put("test_finished_time", new String[] {"status", "job_tag"});
+        drilldownMap.put("DATE(test_finished_time)", 
+                         new String[] {"test_finished_time", "job_tag"});
+    }
+    
+    private Header getSelectedHeader(HeaderSelect list) {
+        Header selectedFields = new HeaderImpl();
+        for (Item item : list.getSelectedItems()) {
+            selectedFields.add(item.value);
+        }
+        return selectedFields;
+    }
+    
+    protected void setSelectedHeader(HeaderSelect list, Header fields) {
+        list.selectItemsByValue(fields);
+    }
+
+    @Override
+    public void refresh() {
+        notYetQueried = false;
+        spreadsheet.setVisible(false);
+        selectionManager.clearSelection();
+        spreadsheet.clear();
+        setJobCompletionHtml("&nbsp");
+        
+        String condition = commonPanel.getSavedCondition();
+        if (!currentShowIncomplete) {
+            if (!condition.equals("")) {
+                condition = "(" + condition + ") AND ";
+            }
+            condition += "status != 'RUNNING'";
+        }
+        final String finalCondition = condition;
+        
+        setLoading(true);
+        spreadsheetProcessor.refresh(currentRowFields, currentColumnFields, finalCondition,
+                                     new Command() {
+            public void execute() {
+                if (isJobFilteringCondition(finalCondition)) {
+                    showCompletionPercentage(finalCondition);
+                } else {
+                    setLoading(false);
+                }
+            }
+        });
+    }
+
+    public void doQuery() {
+        Header rows = getSelectedHeader(rowSelect), columns = getSelectedHeader(columnSelect);
+        if (rows.isEmpty() || columns.isEmpty()) {
+            NotifyManager.getInstance().showError("You must select row and column fields");
+            return;
+        }
+        currentRowFields = getSelectedHeader(rowSelect);
+        currentColumnFields = getSelectedHeader(columnSelect);
+        currentShowIncomplete = showIncomplete.isChecked();
+        saveCondition();
+        refresh();
+    }
+
+    private void showCompletionPercentage(String condition) {
+        rpcProxy.rpcCall("get_job_ids", TkoUtils.getConditionParams(condition), 
+                         new JsonRpcCallback() {
+            @Override
+            public void onSuccess(JSONValue result) {
+                finishShowCompletionPercentage(result.isArray());
+                setLoading(false);
+            }
+
+            @Override
+            public void onError(JSONObject errorObject) {
+                super.onError(errorObject);
+                setLoading(false);
+            }
+        });
+    }
+
+    private void finishShowCompletionPercentage(JSONArray jobIds) {
+        final int jobCount = jobIds.size();
+        if (jobCount == 0) {
+            return;
+        }
+        
+        JSONObject args = new JSONObject();
+        args.put("job__id__in", jobIds);
+        afeRpcProxy.rpcCall("get_hqe_percentage_complete", args, new JsonRpcCallback() {
+            @Override
+            public void onSuccess(JSONValue result) {
+                int percentage = (int) (result.isNumber().doubleValue() * 100);
+                StringBuilder message = new StringBuilder("Matching ");
+                if (jobCount == 1) {
+                    message.append("job is ");
+                } else {
+                    message.append("jobs are ");
+                }
+                message.append(percentage);
+                message.append("% complete");
+                setJobCompletionHtml(message.toString());
+            }
+        });
+    }
+    
+    private void setJobCompletionHtml(String html) {
+        jobCompletionPanel.clear();
+        jobCompletionPanel.add(new HTML(html));
+    }
+
+    private boolean isJobFilteringCondition(String condition) {
+        return condition.indexOf("job_tag") != -1;
+    }
+
+    public void onCellClicked(CellInfo cellInfo) {
+        Event event = Event.getCurrentEvent();
+        TestSet testSet = getTestSet(cellInfo);
+        DrilldownType drilldownType = getDrilldownType(cellInfo);
+        if (RightClickTable.isRightClick(event)) {
+            if (!selectionManager.isEmpty()) {
+                testSet = getTestSet(selectionManager.getSelectedCells());
+                drilldownType = DrilldownType.DRILLDOWN_BOTH;
+            }
+            ContextMenu menu = getContextMenu(testSet, drilldownType);
+            menu.showAtWindow(event.getClientX(), event.getClientY());
+            return;
+        }
+        
+        if (event.getCtrlKey()) {
+            selectionManager.toggleSelected(cellInfo);
+            return;
+        }
+        
+        if (testSet.isSingleTest()) {
+            TkoUtils.getTestId(testSet, listener);
+            return;
+        }
+        
+        doDrilldown(testSet, 
+                    getDefaultDrilldownRow(drilldownType), 
+                    getDefaultDrilldownColumn(drilldownType));
+    }
+
+    private DrilldownType getDrilldownType(CellInfo cellInfo) {
+        if (cellInfo.row == null) {
+            // column header
+            return DrilldownType.DRILLDOWN_COLUMN;
+        }
+        if (cellInfo.column == null) {
+            // row header
+            return DrilldownType.DRILLDOWN_ROW;
+        }
+        return DrilldownType.DRILLDOWN_BOTH;
+    }
+
+    private TestSet getTestSet(CellInfo cellInfo) {
+        boolean isSingleTest = cellInfo.testCount == 1;
+        ConditionTestSet testSet = new ConditionTestSet(isSingleTest, 
+                                                        commonPanel.getSavedCondition());
+        
+        if (cellInfo.row != null) {
+            setSomeFields(testSet, currentRowFields, cellInfo.row);
+        }
+        if (cellInfo.column != null) {
+            setSomeFields(testSet, currentColumnFields, cellInfo.column);
+        }
+        return testSet;
+    }
+    
+    private void setSomeFields(ConditionTestSet testSet, Header allFields, Header values) {
+        List<String> usedFields = new UnmodifiableSublistView<String>(allFields, 0, values.size());
+        testSet.setFields(usedFields, values);
+    }
+    
+    private TestSet getTestSet(List<CellInfo> cells) {
+        CompositeTestSet tests = new CompositeTestSet();
+        for (CellInfo cell : cells) {
+            tests.add(getTestSet(cell));
+        }
+        return tests;
+    }
+
+    private void doDrilldown(TestSet tests, String newRowField, String newColumnField) {
+        commonPanel.refineCondition(tests);
+        currentRowFields = HeaderImpl.fromBaseType(Utils.wrapObjectWithList(newRowField));
+        currentColumnFields = HeaderImpl.fromBaseType(Utils.wrapObjectWithList(newColumnField));
+        updateWidgets();
+        doQuery();
+        updateHistory();
+    }
+
+    private String getDefaultDrilldownRow(DrilldownType type) {
+        return getDrilldownRows(type)[0];
+    }
+    
+    private String getDefaultDrilldownColumn(DrilldownType type) {
+        return getDrilldownColumns(type)[0];
+    }
+
+    private ContextMenu getContextMenu(final TestSet tests, DrilldownType drilldownType) {
+        TestContextMenu menu = new TestContextMenu(tests, listener);
+        
+        if (!menu.addViewDetailsIfSingleTest()) {
+            MenuBar drilldownMenu = menu.addSubMenuItem("Drill down");
+            fillDrilldownMenu(tests, drilldownType, drilldownMenu);
+        }
+        
+        menu.addItem("View in table", new Command() {
+            public void execute() {
+                switchToTable(tests, false);
+            }
+        });
+        menu.addItem("Triage failures", new Command() {
+            public void execute() {
+                switchToTable(tests, true);
+            }
+        });
+        
+        menu.addLabelItems();
+        return menu;
+    }
+
+    private void fillDrilldownMenu(final TestSet tests, DrilldownType drilldownType, MenuBar menu) {
+        for (final String rowField : getDrilldownRows(drilldownType)) {
+            for (final String columnField : getDrilldownColumns(drilldownType)) {
+                if (rowField.equals(columnField)) {
+                    continue;
+                }
+                menu.addItem(rowField + " vs. " + columnField, new Command() {
+                    public void execute() {
+                        doDrilldown(tests, rowField, columnField);
+                    }
+                });
+            }
+        }
+    }
+
+    private String[] getDrilldownFields(Header fields, DrilldownType type,
+                                        DrilldownType otherType) {
+        String lastField = fields.get(fields.size() - 1);
+        if (type == otherType) {
+            return new String[] {lastField};
+        } else {
+            return drilldownMap.get(lastField);
+        }
+    }
+
+    private String[] getDrilldownRows(DrilldownType type) {
+        return getDrilldownFields(currentRowFields, type, DrilldownType.DRILLDOWN_COLUMN);
+    }
+    
+    private String[] getDrilldownColumns(DrilldownType type) {
+        return getDrilldownFields(currentColumnFields, type, DrilldownType.DRILLDOWN_ROW);
+    }
+    
+    private void updateWidgets() {
+        setSelectedHeader(rowSelect, currentRowFields);
+        setSelectedHeader(columnSelect, currentColumnFields);
+        showIncomplete.setChecked(currentShowIncomplete);
+    }
+
+    @Override
+    protected Map<String, String> getHistoryArguments() {
+        Map<String, String> arguments = super.getHistoryArguments();
+        if (!notYetQueried) {
+            arguments.put("row", headerToString(currentRowFields));
+            arguments.put("column", headerToString(currentColumnFields));
+            arguments.put("show_incomplete", Boolean.toString(currentShowIncomplete));
+            commonPanel.addHistoryArguments(arguments);
+        }
+        return arguments;
+    }
+
+    private String headerToString(Header header) {
+        return Utils.joinStrings(",", header);
+    }
+    
+    @Override
+    public void handleHistoryArguments(Map<String, String> arguments) {
+        super.handleHistoryArguments(arguments);
+        commonPanel.handleHistoryArguments(arguments);
+        
+        String rows = arguments.get("row"), columns = arguments.get("column");
+        currentRowFields = HeaderImpl.fromBaseType(Arrays.asList(rows.split(",")));
+        currentColumnFields = HeaderImpl.fromBaseType(Arrays.asList(columns.split(",")));
+        currentShowIncomplete = Boolean.valueOf(arguments.get("show_incomplete"));
+        
+        updateWidgets();
+    }
+
+    @Override
+    protected void fillDefaultHistoryValues(Map<String, String> arguments) {
+        Utils.setDefaultValue(arguments, "row", DEFAULT_ROW);
+        Utils.setDefaultValue(arguments, "column", DEFAULT_COLUMN);
+        Utils.setDefaultValue(arguments, "show_incomplete", Boolean.toString(currentShowIncomplete));
+    }
+
+    private void switchToTable(final TestSet tests, boolean isTriageView) {
+        commonPanel.refineCondition(tests);
+        listener.onSwitchToTable(isTriageView);
+    }
+
+    public ContextMenu getActionMenu() {
+        TestSet tests;
+        if (selectionManager.isEmpty()) {
+            tests = getWholeTableTestSet();
+        } else {
+            tests = getTestSet(selectionManager.getSelectedCells());
+        }
+        return getContextMenu(tests, DrilldownType.DRILLDOWN_BOTH);
+    }
+
+    public void onSelectAll(boolean ignored) {
+        selectionManager.selectAll();
+    }
+
+    public void onSelectNone() {
+        selectionManager.clearSelection();
+    }
+
+    @Override
+    protected boolean hasFirstQueryOccurred() {
+        return !notYetQueried;
+    }
+
+    private void setLoading(boolean loading) {
+        queryButton.setEnabled(!loading);
+        NotifyManager.getInstance().setLoading(loading);
+    }
+}
diff --git a/frontend/client/src/autotest/tko/StatusSummary.java b/frontend/client/src/autotest/tko/StatusSummary.java
new file mode 100644
index 0000000..ea2ab74
--- /dev/null
+++ b/frontend/client/src/autotest/tko/StatusSummary.java
@@ -0,0 +1,84 @@
+// Copyright 2008 Google Inc. All Rights Reserved.
+
+package autotest.tko;
+
+import com.google.gwt.json.client.JSONObject;
+
+class StatusSummary {
+    static final String BLANK_COLOR = "#FFFFFF";
+    static final ColorMapping[] CELL_COLOR_MAP = {
+        // must be in descending order of percentage
+        new ColorMapping(95, "#32CD32"),
+        new ColorMapping(90, "#c0ff80"),
+        new ColorMapping(85, "#ffff00"),
+        new ColorMapping(75, "#ffc040"),
+        new ColorMapping(1, "#ff4040"),
+        new ColorMapping(0, "#d080d0"),
+    };
+    
+    public int passed = 0;
+    public int complete = 0;
+    public int incomplete = 0;
+    public int total = 0; // TEST_NA is included here, but not in any other
+    
+    /**
+     * Stores a color for pass rates and the minimum passing percentage required
+     * to have that color.
+     */
+    static class ColorMapping {
+        // store percentage as int so we can reprint it consistently
+        public int minPercent;
+        public String htmlColor;
+        
+        public ColorMapping(int minPercent, String htmlColor) {
+            this.minPercent = minPercent;
+            this.htmlColor = htmlColor;
+        }
+        
+        public boolean matches(double ratio) {
+            return ratio * 100 >= minPercent;
+        }
+    }
+    
+    public static StatusSummary getStatusSummary(JSONObject group) {
+        StatusSummary summary = new StatusSummary();
+        summary.passed = getField(group, TestGroupDataSource.PASS_COUNT_FIELD);
+        summary.complete = getField(group, TestGroupDataSource.COMPLETE_COUNT_FIELD);
+        summary.incomplete = getField(group, TestGroupDataSource.INCOMPLETE_COUNT_FIELD);
+        summary.total = getField(group, TestGroupDataSource.GROUP_COUNT_FIELD);
+        return summary;
+    }
+
+    private static int getField(JSONObject group, String field) {
+        return (int) group.get(field).isNumber().doubleValue();
+    }
+    
+    /**
+     * Force construction to go through getStatusSummary() factory method.
+     */
+    private StatusSummary() {}
+    
+    public int getTotal() {
+        return total;
+    }
+
+    public String formatStatusCounts() {
+        String text = passed + " / " + complete;
+        if (incomplete > 0) {
+            text += "<br>" + incomplete + " incomplete";
+        }
+        return text;
+    }
+
+    public String getColor() {
+        if (complete == 0) {
+            return BLANK_COLOR;
+        }
+        double ratio = (double) passed / complete;
+        for (ColorMapping mapping : CELL_COLOR_MAP) {
+            if (mapping.matches(ratio))
+                return mapping.htmlColor;
+        }
+        throw new RuntimeException("No color map match for ratio " + ratio);
+    }
+}
\ No newline at end of file
diff --git a/frontend/client/src/autotest/tko/TableRenderer.java b/frontend/client/src/autotest/tko/TableRenderer.java
new file mode 100644
index 0000000..deb94b8
--- /dev/null
+++ b/frontend/client/src/autotest/tko/TableRenderer.java
@@ -0,0 +1,101 @@
+package autotest.tko;
+
+import autotest.tko.Spreadsheet.CellInfo;
+
+import com.google.gwt.core.client.GWT;
+import com.google.gwt.dom.client.Element;
+import com.google.gwt.user.client.DOM;
+import com.google.gwt.user.client.ui.HTMLTable;
+
+
+public class TableRenderer {
+    // min-width/min-height aren't supported in the hosted mode browser
+    public static final String SIZE_PREFIX = GWT.isScript() ? "min-" : "";
+    private static final String NONCLICKABLE_CLASS = "spreadsheet-cell-nonclickable";
+    
+    protected String attributeString(String attribute, String value) {
+        if (value.equals(""))
+            return "";
+        return " " + attribute + "=\"" + value + "\"";
+    }
+    
+    public void renderRowsAndAppend(HTMLTable tableObject, CellInfo[][] rows, 
+                                    int startRow, int maxRows, boolean renderNull) {
+        StringBuffer htmlBuffer= new StringBuffer();
+        htmlBuffer.append("<table><tbody>");
+        for (int rowIndex = startRow; rowIndex < startRow + maxRows && rowIndex < rows.length;
+             rowIndex++) {
+            CellInfo[] row = rows[rowIndex];
+            htmlBuffer.append("<tr>");
+            for (CellInfo cell : row) {
+                if (cell == null && renderNull) {
+                    htmlBuffer.append("<td> </td>");
+                } else if (cell != null) {
+                    String tdAttributes = "", divAttributes = "", divStyle = "";
+                    if (cell.color != null) {
+                        tdAttributes += attributeString("style", 
+                                                       "background-color: " + cell.color + ";");
+                    }
+                    if (cell.rowSpan > 1) {
+                        tdAttributes += attributeString("rowspan", Integer.toString(cell.rowSpan)); 
+                    }
+                    if (cell.colSpan > 1) {
+                        tdAttributes += attributeString("colspan", Integer.toString(cell.colSpan)); 
+                    }
+                    
+                    if (cell.widthPx != null) {
+                        divStyle += SIZE_PREFIX + "width: " + cell.widthPx + "px; ";
+                    }
+                    if (cell.heightPx != null) {
+                        divStyle += SIZE_PREFIX + "height: " + cell.heightPx + "px; ";
+                    }
+                    if (!divStyle.equals("")) {
+                        divAttributes += attributeString("style", divStyle);
+                    }
+                    if (cell.isEmpty()) {
+                        divAttributes += attributeString("class", NONCLICKABLE_CLASS);
+                    }
+                    
+                    htmlBuffer.append("<td " + tdAttributes + ">");
+                    htmlBuffer.append("<div " + divAttributes + ">");
+                    htmlBuffer.append(cell.contents);
+                    htmlBuffer.append("</div></td>");
+                }
+            }
+            htmlBuffer.append("</tr>");
+        }
+        htmlBuffer.append("</tbody></table>");
+        
+        renderBody(tableObject, htmlBuffer.toString());
+    }
+    
+    public void renderRows(HTMLTable tableObject, CellInfo[][] rows, boolean renderNull) {
+        TkoUtils.clearDomChildren(tableObject.getElement()); // remove existing tbodies
+        renderRowsAndAppend(tableObject, rows, 0, rows.length, renderNull);
+    }
+    
+    public void renderRows(HTMLTable tableObject, CellInfo[][] rows) {
+        renderRows(tableObject, rows, true);
+    }
+
+    private void renderBody(HTMLTable tableObject, String html) {
+        // render the table within a DIV
+        Element tempDiv = DOM.createDiv();
+        tempDiv.setInnerHTML(html);
+        
+        // inject the new tbody into the existing table
+        Element newTable = tempDiv.getFirstChildElement();
+        Element newBody = newTable.getFirstChildElement();
+        tableObject.getElement().appendChild(newBody);
+        
+        setBodyElement(tableObject, newBody);
+    }
+    
+    /**
+     * A little hack to set the private member variable bodyElem of an HTMLTable.
+     */
+    protected native void setBodyElement(HTMLTable table, Element newBody) /*-{
+        table.@com.google.gwt.user.client.ui.HTMLTable::bodyElem = newBody;
+    }-*/;
+
+}
diff --git a/frontend/client/src/autotest/tko/TableView.java b/frontend/client/src/autotest/tko/TableView.java
new file mode 100644
index 0000000..fa86f35
--- /dev/null
+++ b/frontend/client/src/autotest/tko/TableView.java
@@ -0,0 +1,466 @@
+package autotest.tko;
+
+import autotest.common.Utils;
+import autotest.common.table.DataTable;
+import autotest.common.table.DynamicTable;
+import autotest.common.table.RpcDataSource;
+import autotest.common.table.SelectionManager;
+import autotest.common.table.SimpleFilter;
+import autotest.common.table.TableDecorator;
+import autotest.common.table.DataSource.SortDirection;
+import autotest.common.table.DataSource.SortSpec;
+import autotest.common.table.DataTable.TableWidgetFactory;
+import autotest.common.table.DynamicTable.DynamicTableListener;
+import autotest.common.table.SelectionManager.SelectionListener;
+import autotest.common.ui.ContextMenu;
+import autotest.common.ui.DoubleListSelector;
+import autotest.common.ui.NotifyManager;
+import autotest.common.ui.RightClickTable;
+import autotest.common.ui.TableActionsPanel;
+import autotest.common.ui.DoubleListSelector.Item;
+import autotest.common.ui.TableActionsPanel.TableActionsListener;
+import autotest.tko.TkoUtils.FieldInfo;
+
+import com.google.gwt.json.client.JSONObject;
+import com.google.gwt.json.client.JSONString;
+import com.google.gwt.user.client.Command;
+import com.google.gwt.user.client.Event;
+import com.google.gwt.user.client.ui.Button;
+import com.google.gwt.user.client.ui.CheckBox;
+import com.google.gwt.user.client.ui.ClickListener;
+import com.google.gwt.user.client.ui.HTML;
+import com.google.gwt.user.client.ui.Panel;
+import com.google.gwt.user.client.ui.RootPanel;
+import com.google.gwt.user.client.ui.SimplePanel;
+import com.google.gwt.user.client.ui.VerticalPanel;
+import com.google.gwt.user.client.ui.Widget;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collection;
+import java.util.HashMap;
+import java.util.Iterator;
+import java.util.List;
+import java.util.ListIterator;
+import java.util.Map;
+
+public class TableView extends ConditionTabView 
+                       implements DynamicTableListener, TableActionsListener, SelectionListener,
+                                  ClickListener, TableWidgetFactory {
+    private static final int ROWS_PER_PAGE = 30;
+    private static final String[] DEFAULT_COLUMNS = 
+        {"test_idx", "test_name", "job_tag", "hostname", "status"};
+    private static final String[] TRIAGE_GROUP_COLUMNS =
+        {"test_name", "status", "reason"};
+    private static final SortSpec[] TRIAGE_SORT_SPECS = {
+        new SortSpec("test_name", SortDirection.ASCENDING),
+        new SortSpec("status", SortDirection.ASCENDING),
+        new SortSpec("reason", SortDirection.ASCENDING),
+    };
+    private static final String COUNT_NAME = "Count in group";
+    private static final String STATUS_COUNTS_NAME = "Test pass rate";
+
+    private TestSelectionListener listener;
+    
+    private DynamicTable table;
+    private TableDecorator tableDecorator;
+    private SelectionManager selectionManager;
+    private SimpleFilter sqlConditionFilter = new SimpleFilter();
+    private RpcDataSource testDataSource = new TestViewDataSource();
+    private TestGroupDataSource groupDataSource = TestGroupDataSource.getTestGroupDataSource();
+    private DoubleListSelector columnSelect = new DoubleListSelector();
+    private CheckBox groupCheckbox = new CheckBox("Group by these columns and show counts");
+    private CheckBox statusGroupCheckbox = 
+        new CheckBox("Group by these columns and show pass rates");
+    private TableActionsPanel actionsPanel = new TableActionsPanel(this, true);
+    private Button queryButton = new Button("Query");
+    
+    private boolean isTestGroupingEnabled = false, isStatusCountEnabled = false;
+    private List<String> fields = new ArrayList<String>();
+    private List<SortSpec> tableSorts = new ArrayList<SortSpec>();
+    private Map<String, String> fieldNames = new HashMap<String, String>();
+    
+    public TableView(TestSelectionListener listener) {
+        this.listener = listener;
+    }
+
+    @Override
+    public String getElementId() {
+        return "table_view";
+    }
+
+    @Override
+    public void initialize() {
+        for (FieldInfo fieldInfo : TkoUtils.getFieldList("all_fields")) {
+            fieldNames.put(fieldInfo.field, fieldInfo.name);
+            columnSelect.addItem(fieldInfo.name, fieldInfo.field);
+        }
+        fieldNames.put(TestGroupDataSource.GROUP_COUNT_FIELD, COUNT_NAME);
+        fieldNames.put(DataTable.WIDGET_COLUMN, STATUS_COUNTS_NAME);
+        
+        selectColumns(DEFAULT_COLUMNS);
+        saveOptions(false);
+        
+        queryButton.addClickListener(this);
+        groupCheckbox.addClickListener(this);
+        statusGroupCheckbox.addClickListener(this);
+        
+        Panel columnPanel = new VerticalPanel();
+        columnPanel.add(columnSelect);
+        columnPanel.add(groupCheckbox);
+        columnPanel.add(statusGroupCheckbox);
+        
+        RootPanel.get("table_column_select").add(columnPanel);
+        RootPanel.get("table_actions").add(actionsPanel);
+        RootPanel.get("table_query_controls").add(queryButton);
+    }
+
+    private void selectColumns(String[] columns) {
+        columnSelect.deselectAll();
+        for(String field : columns) {
+            columnSelect.selectItemByValue(field);
+        }
+    }
+    
+    public void setupJobTriage() {
+        // easier if we ensure it's deselected and then select it
+        selectColumns(TRIAGE_GROUP_COLUMNS);
+        statusGroupCheckbox.setChecked(false);
+        groupCheckbox.setChecked(true);
+        updateCheckboxes();
+        
+        // need to copy it so we can mutate it
+        tableSorts = new ArrayList<SortSpec>(Arrays.asList(TRIAGE_SORT_SPECS));
+    }
+
+    private void createTable() {
+        int numColumns = fields.size();
+        String[][] columns = new String[numColumns][2];
+        for (int i = 0; i < numColumns; i++) {
+            String field = fields.get(i);
+            columns[i][0] = field;
+            columns[i][1] = fieldNames.get(field);
+        }
+        
+        RpcDataSource dataSource = testDataSource;
+        if (isAnyGroupingEnabled()) {
+            if (isTestGroupingEnabled) {
+                groupDataSource = TestGroupDataSource.getTestGroupDataSource();
+            } else {
+                groupDataSource = TestGroupDataSource.getStatusCountDataSource();
+            }
+
+            updateGroupColumns();
+            dataSource = groupDataSource;
+        } else {
+            dataSource = testDataSource;
+        }
+        
+        table = new DynamicTable(columns, dataSource);
+        table.addFilter(sqlConditionFilter);
+        table.setRowsPerPage(ROWS_PER_PAGE);
+        table.makeClientSortable();
+        table.setClickable(true);
+        table.sinkRightClickEvents();
+        table.addListener(this);
+        table.setWidgetFactory(this);
+        restoreTableSorting();
+        
+        tableDecorator = new TableDecorator(table);
+        tableDecorator.addPaginators();
+        Panel tablePanel = RootPanel.get("table_table");
+        tablePanel.clear();
+        tablePanel.add(tableDecorator);
+        
+        selectionManager = new SelectionManager(table, false);
+        selectionManager.addListener(this);
+    }
+
+    private void saveOptions(boolean doSaveCondition) {
+        if (doSaveCondition) {
+            saveCondition();
+        }
+        
+        fields.clear();
+        for (Item item : columnSelect.getSelectedItems()) {
+            fields.add(item.value);
+        }
+        
+        isTestGroupingEnabled = groupCheckbox.isChecked();
+        isStatusCountEnabled = statusGroupCheckbox.isChecked();
+    }
+
+    private void updateGroupColumns() {
+        List<String> groupFields = new ArrayList<String>();
+        for (String field : fields) {
+            if (!field.equals(TestGroupDataSource.GROUP_COUNT_FIELD) &&
+                !field.equals(DataTable.WIDGET_COLUMN)) {
+                groupFields.add(field);
+            }
+        }
+        groupDataSource.setGroupColumns(groupFields.toArray(new String[0]));
+    }
+    
+    private void saveTableSorting() {
+        if (table != null) {
+            // we need our own copy so we can modify it later
+            tableSorts = new ArrayList<SortSpec>(table.getSortSpecs());
+        }
+    }
+
+    private void restoreTableSorting() {
+        // remove sorts on columns that we no longer have
+        for (Iterator<SortSpec> i = tableSorts.iterator(); i.hasNext();) {
+            if (!fields.contains(i.next().getField())) {
+                i.remove();
+            }
+        }
+        if (tableSorts.isEmpty()) {
+            // default to sorting on the first column
+            SortSpec sortSpec = new SortSpec(fields.get(0), SortDirection.ASCENDING);
+            tableSorts = Arrays.asList(new SortSpec[] {sortSpec});
+        }
+        
+        for (ListIterator<SortSpec> i = tableSorts.listIterator(tableSorts.size()); 
+             i.hasPrevious();) {
+            SortSpec sortSpec = i.previous();
+            table.sortOnColumn(sortSpec.getField(), sortSpec.getDirection());
+        }
+    }
+
+    @Override
+    public void refresh() {
+        createTable();
+        String sqlCondition = commonPanel.getSavedCondition();
+        sqlConditionFilter.setParameter("extra_where", new JSONString(sqlCondition));
+        table.refresh();
+    }
+
+    public void doQuery() {
+        if (columnSelect.getSelectedItemCount() == 0) {
+            NotifyManager.getInstance().showError("You must select columns");
+            return;
+        }
+        saveOptions(true);
+        refresh();
+    }
+
+    public void onRowClicked(int rowIndex, JSONObject row) {
+        Event event = Event.getCurrentEvent();
+        TestSet testSet = getTestSet(row);
+        if (RightClickTable.isRightClick(event)) {
+            if (selectionManager.getSelectedObjects().size() > 0) {
+                testSet = getTestSet(selectionManager.getSelectedObjects());
+            }
+            ContextMenu menu = getContextMenu(testSet);
+            menu.showAtWindow(event.getClientX(), event.getClientY());
+            return;
+        }
+        
+        if (event.getCtrlKey()) {
+            selectionManager.toggleSelected(row);
+            return;
+        }
+        
+        if (isAnyGroupingEnabled()) {
+            doDrilldown(testSet);
+        } else {
+            TkoUtils.getTestId(testSet, listener);
+        }
+    }
+
+    private ContextMenu getContextMenu(final TestSet testSet) {
+        TestContextMenu menu = new TestContextMenu(testSet, listener);
+        
+        if (!menu.addViewDetailsIfSingleTest() && isAnyGroupingEnabled()) {
+            menu.addItem("Drill down", new Command() {
+                public void execute() {
+                    doDrilldown(testSet);
+                }
+            });
+        }
+        
+        menu.addLabelItems();
+        return menu;
+    }
+
+    private void doDrilldown(TestSet testSet) {
+        commonPanel.refineCondition(testSet);
+        uncheckBothCheckboxes();
+        updateCheckboxes();
+        selectColumns(DEFAULT_COLUMNS);
+        doQuery();
+        updateHistory();
+    }
+
+    private void uncheckBothCheckboxes() {
+        groupCheckbox.setChecked(false);
+        statusGroupCheckbox.setChecked(false);
+    }
+
+    private TestSet getTestSet(JSONObject row) {
+        ConditionTestSet testSet;
+        if (isAnyGroupingEnabled()) {
+            testSet = new ConditionTestSet(false, commonPanel.getSavedCondition());
+            for (String field : fields) {
+                if (field.equals(TestGroupDataSource.GROUP_COUNT_FIELD) ||
+                    field.equals(DataTable.WIDGET_COLUMN)) {
+                    continue;
+                }
+                testSet.setField(field, Utils.jsonToString(row.get(field)));
+            }
+        } else {
+            testSet = new ConditionTestSet(true);
+            testSet.setField("test_idx", 
+                             Utils.jsonToString(row.get("test_idx")));
+        }
+        return testSet;
+    }
+    
+    private TestSet getTestSet(Collection<JSONObject> selectedObjects) {
+        CompositeTestSet compositeSet = new CompositeTestSet();
+        for (JSONObject row : selectedObjects) {
+            compositeSet.add(getTestSet(row));
+        }
+        return compositeSet;
+    }
+
+    public void onTableRefreshed() {
+        selectionManager.refreshSelection();
+        
+        saveTableSorting();
+        updateHistory();
+    }
+
+    private void updateCheckboxes() {
+        ensureItemRemoved(COUNT_NAME);
+        ensureItemRemoved(STATUS_COUNTS_NAME);
+        groupCheckbox.setEnabled(true);
+        statusGroupCheckbox.setEnabled(true);
+        
+        if (groupCheckbox.isChecked()) {
+            columnSelect.addReadonlySelectedItem(COUNT_NAME, TestGroupDataSource.GROUP_COUNT_FIELD);
+            statusGroupCheckbox.setEnabled(false);
+        } else if (statusGroupCheckbox.isChecked()) {
+            columnSelect.addReadonlySelectedItem(STATUS_COUNTS_NAME, DataTable.WIDGET_COLUMN);
+            groupCheckbox.setEnabled(false);
+        }
+    }
+
+    private void ensureItemRemoved(String itemName) {
+        try {
+            columnSelect.removeItem(itemName);
+        } catch (IllegalArgumentException exc) {}
+    }
+
+    public ContextMenu getActionMenu() {
+        TestSet tests;
+        if (selectionManager.isEmpty()) {
+            tests = getWholeTableSet();
+        } else {
+            tests = getTestSet(selectionManager.getSelectedObjects());
+        }
+        return getContextMenu(tests);
+    }
+
+    private ConditionTestSet getWholeTableSet() {
+        return new ConditionTestSet(false, commonPanel.getSavedCondition());
+    }
+
+    public void onSelectAll(boolean visibleOnly) {
+        if (visibleOnly) {
+            selectionManager.selectVisible();
+        } else {
+            selectionManager.selectAll();
+        }
+    }
+
+    public void onSelectNone() {
+        selectionManager.deselectAll();
+    }
+
+    @Override
+    protected Map<String, String> getHistoryArguments() {
+        Map<String, String> arguments = super.getHistoryArguments();
+        if (table != null) {
+            arguments.put("columns", Utils.joinStrings(",", fields));
+            arguments.put("sort", Utils.joinStrings(",", tableSorts));
+            commonPanel.addHistoryArguments(arguments);
+        }
+        return arguments;
+    }
+
+    @Override
+    public void handleHistoryArguments(Map<String, String> arguments) {
+        super.handleHistoryArguments(arguments);
+        
+        handleSortString(arguments.get("sort"));
+        
+        columnSelect.deselectAll();
+        uncheckBothCheckboxes();
+        String[] columns = arguments.get("columns").split(",");
+        for (String column : columns) {
+            if (column.equals(TestGroupDataSource.GROUP_COUNT_FIELD)) {
+                groupCheckbox.setChecked(true);
+            } else if (column.equals(DataTable.WIDGET_COLUMN)) {
+                statusGroupCheckbox.setChecked(true);
+            } else {
+                columnSelect.selectItemByValue(column);
+            }
+        }
+        updateCheckboxes();
+        saveOptions(true);
+    }
+
+    @Override
+    protected void fillDefaultHistoryValues(Map<String, String> arguments) {
+        Utils.setDefaultValue(arguments, "sort", DEFAULT_COLUMNS[0]);
+        Utils.setDefaultValue(arguments, "columns", 
+                        Utils.joinStrings(",", Arrays.asList(DEFAULT_COLUMNS)));
+        
+    }
+
+    private void handleSortString(String sortString) {
+        tableSorts.clear();
+        String[] components = sortString.split(",");
+        for (String component : components) {
+            tableSorts.add(SortSpec.fromString(component));
+        }
+    }
+
+    public void onAdd(Collection<JSONObject> objects) {
+        selectionManager.refreshSelection();
+    }
+
+    public void onRemove(Collection<JSONObject> objects) {
+        selectionManager.refreshSelection();
+    }
+
+    public void onClick(Widget sender) {
+        if (sender == queryButton) {
+            doQuery();
+            updateHistory();
+        } else if (sender == groupCheckbox || sender == statusGroupCheckbox) {
+            updateCheckboxes();
+        }
+    }
+
+    private boolean isAnyGroupingEnabled() {
+        return isTestGroupingEnabled || isStatusCountEnabled;
+    }
+
+    public Widget createWidget(int row, int cell, JSONObject rowObject) {
+        assert isStatusCountEnabled;
+        StatusSummary statusSummary = StatusSummary.getStatusSummary(rowObject);
+        SimplePanel panel = new SimplePanel();
+        panel.add(new HTML(statusSummary.formatStatusCounts()));
+        panel.getElement().getStyle().setProperty("backgroundColor", 
+                                                  statusSummary.getColor());
+        return panel;
+    }
+
+    @Override
+    protected boolean hasFirstQueryOccurred() {
+        return table != null;
+    }
+}
diff --git a/frontend/client/src/autotest/tko/TestContextMenu.java b/frontend/client/src/autotest/tko/TestContextMenu.java
new file mode 100644
index 0000000..83185c7
--- /dev/null
+++ b/frontend/client/src/autotest/tko/TestContextMenu.java
@@ -0,0 +1,42 @@
+package autotest.tko;
+
+import autotest.common.ui.ContextMenu;
+
+import com.google.gwt.user.client.Command;
+
+public class TestContextMenu extends ContextMenu {
+    private static TestLabelManager labelManager = TestLabelManager.getManager();
+    private TestSet tests;
+    private TestSelectionListener listener;
+    
+    public TestContextMenu(TestSet tests, TestSelectionListener listener) {
+        this.tests = tests;
+        this.listener = listener;
+    }
+    
+    public boolean addViewDetailsIfSingleTest() {
+        if (!tests.isSingleTest()) {
+            return false;
+        }
+        
+        addItem("View test details", new Command() {
+            public void execute() {
+                TkoUtils.getTestId(tests, listener);
+            }
+        });
+        return true;
+    }
+    
+    public void addLabelItems() {
+        addItem("Add label", new Command() {
+            public void execute() {
+                labelManager.handleAddLabels(tests.getCondition());
+            }
+        });
+        addItem("Remove label", new Command() {
+            public void execute() {
+                labelManager.handleRemoveLabels(tests.getCondition());
+            }
+        });
+    }
+}
diff --git a/frontend/client/src/autotest/tko/TestDetailView.java b/frontend/client/src/autotest/tko/TestDetailView.java
new file mode 100644
index 0000000..3853a2b
--- /dev/null
+++ b/frontend/client/src/autotest/tko/TestDetailView.java
@@ -0,0 +1,220 @@
+package autotest.tko;
+
+import autotest.common.JsonRpcCallback;
+import autotest.common.Utils;
+import autotest.common.ui.DetailView;
+import autotest.common.ui.NotifyManager;
+
+import com.google.gwt.http.client.Request;
+import com.google.gwt.http.client.RequestBuilder;
+import com.google.gwt.http.client.RequestCallback;
+import com.google.gwt.http.client.RequestException;
+import com.google.gwt.http.client.Response;
+import com.google.gwt.json.client.JSONNumber;
+import com.google.gwt.json.client.JSONObject;
+import com.google.gwt.json.client.JSONValue;
+import com.google.gwt.user.client.DOM;
+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.FlowPanel;
+import com.google.gwt.user.client.ui.HTML;
+import com.google.gwt.user.client.ui.Label;
+import com.google.gwt.user.client.ui.Panel;
+import com.google.gwt.user.client.ui.RootPanel;
+
+import java.util.ArrayList;
+import java.util.List;
+
+class TestDetailView extends DetailView {
+    private static final int NO_TEST_ID = -1;
+    
+    private int testId = NO_TEST_ID;
+    private String jobTag;
+    private List<LogFileViewer> logFileViewers = new ArrayList<LogFileViewer>();
+
+    private Panel logPanel;
+    
+    private class LogFileViewer extends Composite implements DisclosureHandler, RequestCallback {
+        private static final String FAILED_TEXT = "Failed to retrieve log";
+        private DisclosurePanel panel;
+        private String logFilePath;
+        
+        public LogFileViewer(String logFilePath, String logFileName) {
+            this.logFilePath = logFilePath;
+            panel = new DisclosurePanel(logFileName);
+            panel.addEventHandler(this);
+            panel.addStyleName("log-file-panel");
+            initWidget(panel);
+        }
+        
+        public void onOpen(DisclosureEvent event) {
+            RequestBuilder builder = new RequestBuilder(RequestBuilder.GET, getLogUrl());
+            try {
+                builder.sendRequest("", this);
+                setStatusText("Loading...");
+            } catch (RequestException exc) {
+                onRequestFailure();
+            }
+        }
+
+        private String getLogUrl() {
+            return Utils.getLogsURL(jobTag + "/" + logFilePath);
+        }
+
+        private void setLogText(String text) {
+            panel.clear();
+            Label label = new Label(text);
+            label.setStyleName("log-file-text");
+            panel.add(label);
+        }
+        
+        private void setStatusText(String status) {
+            panel.clear();
+            panel.add(new HTML("<i>" + status + "</i>"));
+        }
+
+        public void onClose(DisclosureEvent event) {}
+
+        public void onError(Request request, Throwable exception) {
+            onRequestFailure();
+        }
+
+        private void onRequestFailure() {
+            setStatusText(FAILED_TEXT);
+        }
+
+        public void onResponseReceived(Request request, Response response) {
+            if (response.getStatusCode() != 200) {
+                onRequestFailure();
+                return;
+            }
+            
+            String logText = response.getText();
+            if (logText.equals("")) {
+                setStatusText("Log file is empty");
+            } else {
+                setLogText(logText);
+            }
+        }
+    }
+    
+    @Override
+    public void initialize() {
+        super.initialize();
+        
+        logPanel = new FlowPanel();
+        RootPanel.get("td_log_files").add(logPanel);
+    }
+
+    private void addLogViewers(String testName) {
+        logPanel.clear();
+        addLogFileViewer(testName + "/debug/stdout", "Test stdout");
+        addLogFileViewer(testName + "/debug/stderr", "Test stderr");
+        addLogFileViewer("status.log", "Job status log");
+        addLogFileViewer("debug/autoserv.stdout", "Job autoserv stdout");
+        addLogFileViewer("debug/autoserv.stderr", "Job autoserv stderr");
+    }
+
+    private void addLogFileViewer(String logPath, String logName) {
+        LogFileViewer viewer = new LogFileViewer(logPath, logName);
+        logFileViewers.add(viewer);
+        logPanel.add(viewer);
+    }
+
+    @Override
+    protected void fetchData() {
+        JSONObject params = new JSONObject();
+        params.put("test_idx", new JSONNumber(testId));
+        rpcProxy.rpcCall("get_test_views", params, new JsonRpcCallback() {
+            @Override
+            public void onSuccess(JSONValue result) {
+                JSONObject test;
+                try {
+                    test = Utils.getSingleValueFromArray(result.isArray()).isObject();
+                }
+                catch (IllegalArgumentException exc) {
+                    NotifyManager.getInstance().showError("No such job found");
+                    resetPage();
+                    return;
+                }
+                
+                String testName = test.get("test_name").isString().stringValue();
+                jobTag = test.get("job_tag").isString().stringValue();
+                
+                showText(testName, "td_test");
+                showText(jobTag, "td_job_tag");
+                showField(test, "job_name", "td_job_name");
+                showField(test, "status", "td_status");
+                showField(test, "reason", "td_reason");
+                showField(test, "test_started_time", "td_test_started");
+                showField(test, "test_finished_time", "td_test_finished");
+                showField(test, "hostname", "td_hostname");
+                showField(test, "platform", "td_platform");
+                showField(test, "kernel", "td_kernel");
+                
+                DOM.getElementById("td_view_logs_link").setAttribute("href", 
+                                                                     Utils.getLogsURL(jobTag));
+                addLogViewers(testName);
+                
+                displayObjectData("Test " + testName + " (job " + jobTag + ")");
+            }
+            
+            @Override
+            public void onError(JSONObject errorObject) {
+                super.onError(errorObject);
+                resetPage();
+            }
+        });
+    }
+    
+    @Override
+    protected void setObjectId(String id) {
+        try {
+            testId = Integer.parseInt(id);
+        }
+        catch (NumberFormatException exc) {
+            throw new IllegalArgumentException();
+        }
+    }
+    
+    @Override
+    protected String getObjectId() {
+        if (testId == NO_TEST_ID) {
+            return NO_OBJECT;
+        }
+        return Integer.toString(testId);
+    }
+
+    @Override
+    protected String getDataElementId() {
+        return "td_data";
+    }
+
+    @Override
+    protected String getFetchControlsElementId() {
+        return "td_fetch_controls";
+    }
+
+    @Override
+    protected String getNoObjectText() {
+        return "No test selected";
+    }
+
+    @Override
+    protected String getTitleElementId() {
+        return "td_title";
+    }
+
+    @Override
+    public String getElementId() {
+        return "test_detail_view";
+    }
+    
+    @Override
+    public void display() {
+        super.display();
+        CommonPanel.getPanel().setConditionVisible(false);
+    }
+}
diff --git a/frontend/client/src/autotest/tko/TestGroupDataSource.java b/frontend/client/src/autotest/tko/TestGroupDataSource.java
new file mode 100644
index 0000000..a16292e
--- /dev/null
+++ b/frontend/client/src/autotest/tko/TestGroupDataSource.java
@@ -0,0 +1,105 @@
+package autotest.tko;
+
+import autotest.common.JSONArrayList;
+import autotest.common.Utils;
+import autotest.common.table.RpcDataSource;
+
+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 java.util.ArrayList;
+import java.util.Arrays;
+import java.util.List;
+
+class TestGroupDataSource extends RpcDataSource {
+    private static final String NUM_GROUPS_RPC = "get_num_groups";
+    private static final String GROUP_COUNTS_RPC = "get_group_counts";
+    private static final String STATUS_COUNTS_RPC = "get_status_counts";
+    public static final String GROUP_COUNT_FIELD = "group_count";
+    public static final String PASS_COUNT_FIELD = "pass_count";
+    public static final String COMPLETE_COUNT_FIELD = "complete_count";
+    public static final String INCOMPLETE_COUNT_FIELD = "incomplete_count";
+    
+    private JSONArray groupByFields;
+    private JSONArray headerGroups;
+    private JSONArray headerGroupValues;
+    private boolean skipNumResults = false;
+    
+    public static TestGroupDataSource getTestGroupDataSource() {
+        return new TestGroupDataSource(GROUP_COUNTS_RPC);
+    }
+    
+    public static TestGroupDataSource getStatusCountDataSource() {
+        return new TestGroupDataSource(STATUS_COUNTS_RPC);
+    }
+    
+    // force construction to go through above factory methods
+    private TestGroupDataSource(String getDataMethod) {
+        super(getDataMethod, NUM_GROUPS_RPC);
+    }
+
+    @Override
+    public void updateData(JSONObject params, DataCallback callback) {
+        params = Utils.copyJSONObject(params);
+        params.put("group_by", groupByFields);
+        if (headerGroups != null) {
+            params.put("header_groups", headerGroups);
+        }
+        
+        if (skipNumResults) {
+            filterParams = params;
+            numResults = 0;
+            callback.onGotData(numResults);
+        } else {
+            super.updateData(params, callback);
+        }
+    }
+
+    @Override
+    /**
+     * Process the groups, which come simply as lists, into JSONObjects.
+     */
+    protected JSONArray handleJsonResult(JSONValue result) {
+        JSONObject resultObject = result.isObject();
+        headerGroupValues = resultObject.get("header_values").isArray();
+        return resultObject.get("groups").isArray();
+    }
+    
+    public void setGroupColumns(String[] columns) {
+        groupByFields = new JSONArray();
+        for (String field : columns) {
+            groupByFields.set(groupByFields.size(), new JSONString(field));
+        }
+        headerGroups = null;
+    }
+    
+    public void setHeaderGroups(List<List<String>> headerFieldGroups) {
+        groupByFields = new JSONArray();
+        headerGroups = new JSONArray();
+        for (List<String> headerGroup : headerFieldGroups) {
+            headerGroups.set(headerGroups.size(), Utils.stringsToJSON(headerGroup));
+            for(String field : headerGroup) {
+                groupByFields.set(groupByFields.size(), new JSONString(field));
+            }
+        }
+    }
+    
+    /**
+     * Get a list of values for the header group with the given index, as specified to 
+     * setHeaderGroups().
+     */
+    public List<List<String>> getHeaderGroupValues(int groupIndex) {
+        JSONArray headerList = headerGroupValues.get(groupIndex).isArray();
+        List<List<String>> headerValueList = new ArrayList<List<String>>();
+        for (JSONArray header : new JSONArrayList<JSONArray>(headerList)) {
+            headerValueList.add(Arrays.asList(Utils.JSONtoStrings(header)));
+        }
+        return headerValueList;
+    }
+
+    public void setSkipNumResults(boolean skipNumResults) {
+        this.skipNumResults = skipNumResults;
+    }
+}
diff --git a/frontend/client/src/autotest/tko/TestLabelManager.java b/frontend/client/src/autotest/tko/TestLabelManager.java
new file mode 100644
index 0000000..1ed4a31
--- /dev/null
+++ b/frontend/client/src/autotest/tko/TestLabelManager.java
@@ -0,0 +1,215 @@
+package autotest.tko;
+
+import autotest.common.JsonRpcCallback;
+import autotest.common.JsonRpcProxy;
+import autotest.common.SimpleCallback;
+import autotest.common.StaticDataRepository;
+import autotest.common.Utils;
+import autotest.common.ui.NotifyManager;
+import autotest.common.ui.SimpleHyperlink;
+
+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.ClickListener;
+import com.google.gwt.user.client.ui.DialogBox;
+import com.google.gwt.user.client.ui.HTML;
+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.StackPanel;
+import com.google.gwt.user.client.ui.TextBox;
+import com.google.gwt.user.client.ui.VerticalPanel;
+import com.google.gwt.user.client.ui.Widget;
+
+public class TestLabelManager implements ClickListener {
+    private static final String ADD_TEXT = "Add label";
+    private static final String REMOVE_TEXT = "Remove label";
+    private static final int STACK_SELECT = 0, STACK_CREATE = 1;
+    
+    private static final TestLabelManager theInstance = new TestLabelManager();
+    
+    private static final JsonRpcProxy rpcProxy = JsonRpcProxy.getProxy();
+    private static NotifyManager notifyManager = NotifyManager.getInstance();
+    private static StaticDataRepository staticData = StaticDataRepository.getRepository();
+    
+    private DialogBox selectLabelDialog = new DialogBox(false, true); // modal
+    private ListBox labelList = new ListBox();
+    private TextBox newLabelName = new TextBox();
+    private SimpleHyperlink createLabelLink, cancelCreateLink;
+    private StackPanel stack = new StackPanel();
+    private Button submitButton = new Button(), cancelButton = new Button("Cancel");
+    
+    private String currentTestCondition;
+    
+    
+    private TestLabelManager() {
+        createLabelLink = new SimpleHyperlink("Create new label");
+        cancelCreateLink = new SimpleHyperlink("Cancel create label");
+        ClickListener linkListener = new ClickListener() {
+            public void onClick(Widget sender) {
+                if (sender == createLabelLink) {
+                    stack.showStack(STACK_CREATE);
+                } else {
+                    stack.showStack(STACK_SELECT);
+                }
+            }  
+        };
+        createLabelLink.addClickListener(linkListener);
+        cancelCreateLink.addClickListener(linkListener);
+        
+        Panel selectPanel = new VerticalPanel();
+        selectPanel.add(new HTML("Select label:"));
+        selectPanel.add(labelList);
+        selectPanel.add(createLabelLink);
+        stack.add(selectPanel);
+        
+        Panel createPanel = new VerticalPanel();
+        createPanel.add(new HTML("Enter label name:"));
+        createPanel.add(newLabelName);
+        createPanel.add(cancelCreateLink);
+        stack.add(createPanel);
+        
+        Panel buttonPanel = new HorizontalPanel();
+        buttonPanel.add(submitButton);
+        buttonPanel.add(cancelButton);
+        
+        Panel dialogPanel = new VerticalPanel();
+        dialogPanel.add(stack);
+        dialogPanel.add(buttonPanel);
+        selectLabelDialog.add(dialogPanel);
+        
+        submitButton.addClickListener(this);
+        cancelButton.addClickListener(this);
+    }
+    
+    public static TestLabelManager getManager() {
+        return theInstance;
+    }
+    
+    private void setLinksVisible(boolean visible) {
+        createLabelLink.setVisible(visible);
+        cancelCreateLink.setVisible(visible);
+    }
+    
+    public void handleAddLabels(String testCondition) {
+        currentTestCondition = testCondition;
+        newLabelName.setText("");
+        
+        String[] labels = Utils.JSONObjectsToStrings(staticData.getData("test_labels").isArray(),
+                                                     "name");
+        if (labels.length == 0) {
+            setLinksVisible(false);
+            stack.showStack(STACK_CREATE);
+        } else {
+            setLinksVisible(true);
+            stack.showStack(STACK_SELECT);
+            populateLabelList(labels);
+        }
+        showDialog(ADD_TEXT);
+    }
+    
+    public void handleRemoveLabels(String testCondition) {
+        currentTestCondition = testCondition;
+        
+        JSONObject args = new JSONObject();
+        args.put("extra_where", new JSONString(currentTestCondition));
+        rpcProxy.rpcCall("get_test_labels_for_tests", args, new JsonRpcCallback() {
+            @Override
+            public void onSuccess(JSONValue result) {
+                String[] labels = Utils.JSONObjectsToStrings(result.isArray(), "name");
+                if (labels.length == 0) {
+                    notifyManager.showMessage("No labels on selected tests");
+                    return;
+                }
+                populateLabelList(labels);
+                setLinksVisible(false);
+                stack.showStack(STACK_SELECT);
+                showDialog(REMOVE_TEXT);
+            } 
+        });
+    }
+    
+    private void showDialog(String actionText) {
+        submitButton.setText(actionText);
+        selectLabelDialog.setText(actionText);
+        selectLabelDialog.center();
+    }
+    
+    private void populateLabelList(String[] labels) {
+        labelList.clear();
+        for (String label : labels) {
+            labelList.addItem(label);
+        }
+    }
+
+    public void onClick(Widget sender) {
+        selectLabelDialog.hide();
+        
+        if (sender == cancelButton) {
+            return;
+        }
+        
+        if (submitButton.getText().equals(ADD_TEXT)) {
+            SimpleCallback doAdd = new SimpleCallback() {
+                public void doCallback(Object source) {
+                    addOrRemoveLabel((String) source, true);
+                }
+            };
+            
+            if (stack.getSelectedIndex() == STACK_CREATE) {
+                addLabel(newLabelName.getText(), doAdd);
+            } else {
+                doAdd.doCallback(getSelectedLabel());
+            }
+        } else {
+            assert (submitButton.getText().equals(REMOVE_TEXT));
+            addOrRemoveLabel(getSelectedLabel(), false);
+        }
+    }
+
+    private String getSelectedLabel() {
+        return labelList.getItemText(labelList.getSelectedIndex());
+    }
+    
+    private void addLabel(final String name, final SimpleCallback onFinished) {
+        JSONObject args = new JSONObject();
+        args.put("name", new JSONString(name));
+        rpcProxy.rpcCall("add_test_label", args, new JsonRpcCallback() {
+            @Override
+            public void onSuccess(JSONValue result) {
+                onFinished.doCallback(name);
+            } 
+        });
+        updateLabels();
+    }
+    
+    private void addOrRemoveLabel(String label, boolean add) {
+        String rpcMethod;
+        if (add) {
+            rpcMethod = "test_label_add_tests";
+        } else {
+            rpcMethod = "test_label_remove_tests";
+        }
+        
+        JSONObject args = new JSONObject();
+        args.put("label_id", new JSONString(label));
+        args.put("extra_where", new JSONString(currentTestCondition));
+        rpcProxy.rpcCall(rpcMethod, args, new JsonRpcCallback() {
+            @Override
+            public void onSuccess(JSONValue result) {
+                notifyManager.showMessage("Labels modified successfully");
+            } 
+        });
+    }
+    
+    private void updateLabels() {
+        rpcProxy.rpcCall("get_test_labels", null, new JsonRpcCallback() {
+            @Override
+            public void onSuccess(JSONValue result) {
+                staticData.setData("test_labels", result);
+            } 
+        });
+    }
+}
diff --git a/frontend/client/src/autotest/tko/TestSelectionListener.java b/frontend/client/src/autotest/tko/TestSelectionListener.java
new file mode 100644
index 0000000..27f3b07
--- /dev/null
+++ b/frontend/client/src/autotest/tko/TestSelectionListener.java
@@ -0,0 +1,5 @@
+package autotest.tko;
+
+interface TestSelectionListener {
+    public void onSelectTest(int testId);
+}
diff --git a/frontend/client/src/autotest/tko/TestSet.java b/frontend/client/src/autotest/tko/TestSet.java
new file mode 100644
index 0000000..3e450f0
--- /dev/null
+++ b/frontend/client/src/autotest/tko/TestSet.java
@@ -0,0 +1,6 @@
+package autotest.tko;
+
+interface TestSet {
+    public String getCondition();
+    public boolean isSingleTest();
+}
diff --git a/frontend/client/src/autotest/tko/TestViewDataSource.java b/frontend/client/src/autotest/tko/TestViewDataSource.java
new file mode 100644
index 0000000..0e0e645
--- /dev/null
+++ b/frontend/client/src/autotest/tko/TestViewDataSource.java
@@ -0,0 +1,26 @@
+package autotest.tko;
+
+import com.google.gwt.json.client.JSONArray;
+import com.google.gwt.json.client.JSONObject;
+import com.google.gwt.json.client.JSONValue;
+
+import autotest.common.JSONArrayList;
+import autotest.common.table.RpcDataSource;
+
+class TestViewDataSource extends RpcDataSource {
+    public TestViewDataSource() {
+        super("get_test_views", "get_num_test_views");
+    }
+
+    /**
+     * Add 'id' field, needed by SelectionManager.
+     */
+    @Override
+    protected JSONArray handleJsonResult(JSONValue result) {
+        JSONArray objects = super.handleJsonResult(result);
+        for (JSONObject object : new JSONArrayList<JSONObject>(objects)) {
+            object.put("id", object.get("test_idx"));
+        }
+        return objects;
+    }
+}
diff --git a/frontend/client/src/autotest/tko/TkoClient.java b/frontend/client/src/autotest/tko/TkoClient.java
new file mode 100644
index 0000000..ba40551
--- /dev/null
+++ b/frontend/client/src/autotest/tko/TkoClient.java
@@ -0,0 +1,74 @@
+package autotest.tko;
+
+import autotest.common.CustomHistory;
+import autotest.common.JsonRpcProxy;
+import autotest.common.StaticDataRepository;
+import autotest.common.ui.CustomTabPanel;
+import autotest.common.ui.NotifyManager;
+import autotest.tko.SpreadsheetView.SpreadsheetViewListener;
+
+import com.google.gwt.core.client.EntryPoint;
+import com.google.gwt.user.client.ui.RootPanel;
+
+public class TkoClient implements EntryPoint, SpreadsheetViewListener {
+    private CommonPanel commonPanel;
+    private SpreadsheetView spreadsheetView;
+    private TableView tableView;
+    private GraphingView graphingView;
+    private TestDetailView detailView;
+    
+    private CustomTabPanel mainTabPanel = new CustomTabPanel();
+    private SavedQueriesControl savedQueriesControl;
+    
+    public void onModuleLoad() {
+        JsonRpcProxy.setDefaultUrl(JsonRpcProxy.TKO_URL);
+        
+        NotifyManager.getInstance().initialize();
+        
+        StaticDataRepository.getRepository().refresh(
+                                 new StaticDataRepository.FinishedCallback() {
+            public void onFinished() {
+                finishLoading();
+            }
+        });
+    }
+    
+    protected void finishLoading() {
+        commonPanel = CommonPanel.getPanel();
+        spreadsheetView = new SpreadsheetView(this);
+        tableView = new TableView(this);
+        graphingView = new GraphingView();
+        detailView = new TestDetailView();
+        
+        mainTabPanel.getCommonAreaPanel().add(commonPanel);
+        mainTabPanel.addTabView(spreadsheetView);
+        mainTabPanel.addTabView(tableView);
+        mainTabPanel.addTabView(graphingView);
+        mainTabPanel.addTabView(detailView);
+        
+        savedQueriesControl = new SavedQueriesControl();
+        mainTabPanel.getOtherWidgetsPanel().add(savedQueriesControl);
+        
+        final RootPanel tabsRoot = RootPanel.get("tabs");
+        tabsRoot.add(mainTabPanel);
+        CustomHistory.processInitialToken();
+        mainTabPanel.initialize();
+        commonPanel.initialize();
+        tabsRoot.removeStyleName("hidden");
+    }
+    
+    public void onSwitchToTable(boolean isTriageView) {
+        tableView.ensureInitialized();
+        if (isTriageView) {
+            tableView.setupJobTriage();
+        }
+        tableView.doQuery();
+        mainTabPanel.selectTabView(tableView);
+    }
+
+    public void onSelectTest(int testId) {
+        detailView.ensureInitialized();
+        detailView.updateObjectId(Integer.toString(testId));
+        mainTabPanel.selectTabView(detailView);
+    }
+}
diff --git a/frontend/client/src/autotest/tko/TkoUtils.java b/frontend/client/src/autotest/tko/TkoUtils.java
new file mode 100644
index 0000000..dc47911
--- /dev/null
+++ b/frontend/client/src/autotest/tko/TkoUtils.java
@@ -0,0 +1,70 @@
+package autotest.tko;
+
+import autotest.common.JSONArrayList;
+import autotest.common.JsonRpcCallback;
+import autotest.common.JsonRpcProxy;
+import autotest.common.StaticDataRepository;
+
+import com.google.gwt.dom.client.Element;
+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 java.util.ArrayList;
+import java.util.List;
+
+public class TkoUtils {
+    private static StaticDataRepository staticData = StaticDataRepository.getRepository();
+    private static JsonRpcProxy rpcProxy = JsonRpcProxy.getProxy();
+    
+    public static class FieldInfo {
+        public String field;
+        public String name;
+        
+        public FieldInfo(String field, String name) {
+            this.field = field;
+            this.name = name;
+        }
+    }
+    
+    public static List<FieldInfo> getFieldList(String listName) {
+        JSONArray fieldArray = staticData.getData(listName).isArray();
+        List<FieldInfo> fields = new ArrayList<FieldInfo>();
+        for (JSONArray fieldTuple : new JSONArrayList<JSONArray>(fieldArray)) {
+            String fieldName = fieldTuple.get(0).isString().stringValue();
+            String field = fieldTuple.get(1).isString().stringValue();
+            fields.add(new FieldInfo(field, fieldName));
+        }
+        return fields;
+    }
+
+    protected static JSONObject getConditionParams(String condition) {
+        JSONObject params = new JSONObject();
+        params.put("extra_where", new JSONString(condition));
+        return params;
+    }
+
+    protected static void getTestId(TestSet test, final TestSelectionListener listener) {
+        rpcProxy.rpcCall("get_test_views", getConditionParams(test.getCondition()), 
+                         new JsonRpcCallback() {
+            @Override
+            public void onSuccess(JSONValue result) {
+                // just take the first result (there could be more than one due to
+                // a rare and harmless race condition)
+                JSONObject testView = result.isArray().get(0).isObject();
+                int testId = (int) testView.get("test_idx").isNumber().doubleValue();
+                listener.onSelectTest(testId);
+            }
+        });
+    }
+
+    protected static void clearDomChildren(Element elem) {
+        Element child = elem.getFirstChildElement();
+        while (child != null) {
+            Element nextChild = child.getNextSiblingElement();
+            elem.removeChild(child);
+            child = nextChild;
+        }
+    }
+}