Adding the GWT framework for the Test Planner.

This adds a new application to the GWT frontend, but does not link to it
from the other frontends yet. I don't anticipate anyone to be using this
just yet. Once the project reaches the point where I can release a working
prototype, I will create user documentation for it and send an announcement.

Signed-off-by: James Ren <jamesren@google.com>


git-svn-id: http://test.kernel.org/svn/autotest/trunk@4378 592f7852-d20e-0410-864c-8624ca9c26a4
diff --git a/frontend/client/src/autotest/common/DomUtils.java b/frontend/client/src/autotest/common/DomUtils.java
new file mode 100644
index 0000000..288a290
--- /dev/null
+++ b/frontend/client/src/autotest/common/DomUtils.java
@@ -0,0 +1,14 @@
+package autotest.common;
+
+import com.google.gwt.dom.client.Element;
+
+public class DomUtils {
+    public static void clearDomChildren(Element elem) {
+        Element child = elem.getFirstChildElement();
+        while (child != null) {
+            Element nextChild = child.getNextSiblingElement();
+            elem.removeChild(child);
+            child = nextChild;
+        }
+    }
+}
diff --git a/frontend/client/src/autotest/common/JsonRpcProxy.java b/frontend/client/src/autotest/common/JsonRpcProxy.java
index a148adf..4d16b7c 100644
--- a/frontend/client/src/autotest/common/JsonRpcProxy.java
+++ b/frontend/client/src/autotest/common/JsonRpcProxy.java
@@ -16,6 +16,7 @@
 public abstract class JsonRpcProxy {
     public static final String AFE_BASE_URL = "/afe/server/";
     public static final String TKO_BASE_URL = "/new_tko/server/";
+    public static final String PLANNER_BASE_URL = "/planner/server/";
     private static final String RPC_URL_SUFFIX = "rpc/";
     private static final String JSON_RPC_URL_SUFFIX = "jsonp_rpc/";
 
diff --git a/frontend/client/src/autotest/common/spreadsheet/Spreadsheet.java b/frontend/client/src/autotest/common/spreadsheet/Spreadsheet.java
new file mode 100644
index 0000000..822ac22
--- /dev/null
+++ b/frontend/client/src/autotest/common/spreadsheet/Spreadsheet.java
@@ -0,0 +1,589 @@
+package autotest.common.spreadsheet;
+
+import autotest.common.UnmodifiableSublistView;
+import autotest.common.Utils;
+import autotest.common.table.FragmentedTable;
+import autotest.common.table.TableRenderer;
+import autotest.common.ui.RightClickTable;
+
+import com.google.gwt.dom.client.Element;
+import com.google.gwt.event.dom.client.ClickEvent;
+import com.google.gwt.event.dom.client.ClickHandler;
+import com.google.gwt.event.dom.client.ContextMenuEvent;
+import com.google.gwt.event.dom.client.ContextMenuHandler;
+import com.google.gwt.event.dom.client.DomEvent;
+import com.google.gwt.event.dom.client.ScrollEvent;
+import com.google.gwt.event.dom.client.ScrollHandler;
+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.ScrollPanel;
+import com.google.gwt.user.client.ui.SimplePanel;
+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 ScrollHandler, ClickHandler, ContextMenuHandler {
+    
+    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, boolean isRightClick);
+    }
+    
+    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 int testIndex;
+        
+        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();
+                    break;
+                case 2:
+                    // resize everything to the max dimensions (the window size)
+                    fillWindow(false);
+                    break;
+                case 3:
+                    // set main table to match header sizes
+                    matchRowHeights(rowHeaders, dataCells);
+                    matchColumnWidths(columnHeaders, dataCells);
+                    dataTable.setVisible(false);
+                    break;
+                case 4:
+                    // render the main data table
+                    renderSomeRows();
+                    return true;
+                case 5:
+                    dataTable.updateBodyElems();
+                    dataTable.setVisible(true);
+                    break;
+                case 6:
+                    // now expand headers as necessary
+                    // this can be very slow, so put it in it's own cycle
+                    matchRowHeights(dataTable, rowHeaderCells);
+                    break;
+                case 7:
+                    matchColumnWidths(dataTable, columnHeaderCells);
+                    renderHeaders();
+                    break;
+                case 8:
+                    // 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.addScrollHandler(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.addContextMenuHandler(this);
+        table.addClickHandler(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.
+     */
+    @Override
+    public void onScroll(ScrollEvent event) {
+        int scrollLeft = scrollPanel.getHorizontalScrollPosition();
+        int scrollTop = scrollPanel.getScrollPosition();
+        
+        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);
+    }
+    
+    @Override
+    public void onClick(ClickEvent event) {
+        handleEvent(event, false);
+    }
+    
+    @Override
+    public void onContextMenu(ContextMenuEvent event) {
+        handleEvent(event, true);
+    }
+    
+    private void handleEvent(DomEvent<?> event, boolean isRightClick) {
+        if (listener == null)
+            return;
+        
+        assert event.getSource() instanceof RightClickTable;
+        HTMLTable.Cell tableCell = ((RightClickTable) event.getSource()).getCellForDomEvent(event);
+        int row = tableCell.getRowIndex();
+        int column = tableCell.getCellIndex();
+        
+        CellInfo[][] cells;
+        if (event.getSource() == rowHeaders) {
+            cells = rowHeaderCells;
+            column = adjustRowHeaderColumnIndex(row, column);
+        }
+        else if (event.getSource() == columnHeaders) {
+            cells = columnHeaderCells;
+        }
+        else {
+            assert event.getSource() == dataTable;
+            cells = dataCells;
+        }
+        CellInfo cell = cells[row][column];
+        if (cell == null || cell.isEmpty())
+            return; // don't report clicks on empty cells
+        
+        listener.onCellClicked(cell, isRightClick);
+    }
+
+    /**
+     * 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("");
+        }
+    }
+    
+    public List<Integer> getAllTestIndices() {
+        List<Integer> testIndices = new ArrayList<Integer>();
+
+        for (CellInfo[] row : dataCells) {
+            for (CellInfo cellInfo : row) {
+                if (cellInfo != null && !cellInfo.isEmpty()) {
+                    testIndices.add(cellInfo.testIndex);
+                }
+            }
+        }
+
+        return testIndices;
+    }
+}
diff --git a/frontend/client/src/autotest/common/spreadsheet/SpreadsheetSelectionManager.java b/frontend/client/src/autotest/common/spreadsheet/SpreadsheetSelectionManager.java
new file mode 100644
index 0000000..2dd5c2f
--- /dev/null
+++ b/frontend/client/src/autotest/common/spreadsheet/SpreadsheetSelectionManager.java
@@ -0,0 +1,91 @@
+package autotest.common.spreadsheet;
+
+import autotest.common.Utils;
+import autotest.common.spreadsheet.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/common/table/FragmentedTable.java b/frontend/client/src/autotest/common/table/FragmentedTable.java
new file mode 100644
index 0000000..53a30b3
--- /dev/null
+++ b/frontend/client/src/autotest/common/table/FragmentedTable.java
@@ -0,0 +1,148 @@
+package autotest.common.table;
+
+import autotest.common.DomUtils;
+import autotest.common.ui.RightClickTable;
+
+import com.google.gwt.event.dom.client.ClickEvent;
+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.
+ */
+public 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());
+        
+        // Reset the FragmentedTable to clear out elements that were added by the HTMLTable and
+        // FlexTable constructors
+        reset();
+    }
+    
+    /**
+     * 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();
+        DomUtils.clearDomChildren(getElement());
+    }
+
+    private int getRowWithinFragment(int row) {
+        return row % rowsPerFragment;
+    }
+
+    private int getFragmentIndex(int row) {
+        return row / rowsPerFragment;
+    }
+    
+    @Override
+    public HTMLTable.Cell getCellForEvent(ClickEvent event) {
+        return getCellForDomEvent(event);
+    }
+    
+    @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/common/table/TableRenderer.java b/frontend/client/src/autotest/common/table/TableRenderer.java
new file mode 100644
index 0000000..1641dfb
--- /dev/null
+++ b/frontend/client/src/autotest/common/table/TableRenderer.java
@@ -0,0 +1,102 @@
+package autotest.common.table;
+
+import autotest.common.DomUtils;
+import autotest.common.spreadsheet.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) {
+        DomUtils.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/planner/AutoprocessedTab.java b/frontend/client/src/autotest/planner/AutoprocessedTab.java
new file mode 100644
index 0000000..76d2a51
--- /dev/null
+++ b/frontend/client/src/autotest/planner/AutoprocessedTab.java
@@ -0,0 +1,18 @@
+package autotest.planner;
+
+
+public class AutoprocessedTab {
+    
+    public static interface Display {
+        // Not yet implemented
+    }
+    
+    
+    @SuppressWarnings("unused")
+    private Display display;
+    
+    public void bindDisplay(Display display) {
+        this.display = display;
+    }
+    
+}
diff --git a/frontend/client/src/autotest/planner/AutoprocessedTabDisplay.java b/frontend/client/src/autotest/planner/AutoprocessedTabDisplay.java
new file mode 100644
index 0000000..f3c61d9
--- /dev/null
+++ b/frontend/client/src/autotest/planner/AutoprocessedTabDisplay.java
@@ -0,0 +1,13 @@
+package autotest.planner;
+
+import autotest.common.ui.TabView;
+
+
+public class AutoprocessedTabDisplay extends TabView implements AutoprocessedTab.Display {
+    
+    @Override
+    public String getElementId() {
+        return "autoprocessed";
+    }
+    
+}
diff --git a/frontend/client/src/autotest/planner/HistoryTab.java b/frontend/client/src/autotest/planner/HistoryTab.java
new file mode 100644
index 0000000..a476359
--- /dev/null
+++ b/frontend/client/src/autotest/planner/HistoryTab.java
@@ -0,0 +1,18 @@
+package autotest.planner;
+
+
+public class HistoryTab {
+    
+    public static interface Display {
+        // Not yet implemented
+    }
+    
+    
+    @SuppressWarnings("unused")
+    private Display display;
+    
+    public void bindDisplay(Display display) {
+        this.display = display;
+    }
+    
+}
diff --git a/frontend/client/src/autotest/planner/HistoryTabDisplay.java b/frontend/client/src/autotest/planner/HistoryTabDisplay.java
new file mode 100644
index 0000000..588804d
--- /dev/null
+++ b/frontend/client/src/autotest/planner/HistoryTabDisplay.java
@@ -0,0 +1,13 @@
+package autotest.planner;
+
+import autotest.common.ui.TabView;
+
+
+public class HistoryTabDisplay extends TabView implements HistoryTab.Display {
+    
+    @Override
+    public String getElementId() {
+        return "history";
+    }
+    
+}
diff --git a/frontend/client/src/autotest/planner/MachineViewTab.java b/frontend/client/src/autotest/planner/MachineViewTab.java
new file mode 100644
index 0000000..c0da926
--- /dev/null
+++ b/frontend/client/src/autotest/planner/MachineViewTab.java
@@ -0,0 +1,17 @@
+package autotest.planner;
+
+
+public class MachineViewTab {
+    
+    public static interface Display {
+        // Not yet implemented
+    }
+
+    @SuppressWarnings("unused")
+    private Display display;
+    
+    public void bindDisplay(Display display) {
+        this.display = display;
+    }
+    
+}
diff --git a/frontend/client/src/autotest/planner/MachineViewTabDisplay.java b/frontend/client/src/autotest/planner/MachineViewTabDisplay.java
new file mode 100644
index 0000000..b542891
--- /dev/null
+++ b/frontend/client/src/autotest/planner/MachineViewTabDisplay.java
@@ -0,0 +1,13 @@
+package autotest.planner;
+
+import autotest.common.ui.TabView;
+
+
+public class MachineViewTabDisplay extends TabView implements MachineViewTab.Display {
+    
+    @Override
+    public String getElementId() {
+        return "machine_view";
+    }
+    
+}
diff --git a/frontend/client/src/autotest/planner/OverviewTab.java b/frontend/client/src/autotest/planner/OverviewTab.java
new file mode 100644
index 0000000..a8054f0
--- /dev/null
+++ b/frontend/client/src/autotest/planner/OverviewTab.java
@@ -0,0 +1,17 @@
+package autotest.planner;
+
+
+public class OverviewTab {
+  
+    public static interface Display {
+        // Not yet implemented
+    }
+    
+    @SuppressWarnings("unused")
+    private Display display;
+    
+    public void bindDisplay(Display display) {
+        this.display = display;
+    }
+    
+}
diff --git a/frontend/client/src/autotest/planner/OverviewTabDisplay.java b/frontend/client/src/autotest/planner/OverviewTabDisplay.java
new file mode 100644
index 0000000..ac2841c
--- /dev/null
+++ b/frontend/client/src/autotest/planner/OverviewTabDisplay.java
@@ -0,0 +1,13 @@
+package autotest.planner;
+
+import autotest.common.ui.TabView;
+
+
+public class OverviewTabDisplay extends TabView implements OverviewTab.Display {
+  
+    @Override
+    public String getElementId() {
+        return "overview";
+    }
+    
+}
diff --git a/frontend/client/src/autotest/planner/TestPlanSelector.java b/frontend/client/src/autotest/planner/TestPlanSelector.java
new file mode 100644
index 0000000..6eabc5e
--- /dev/null
+++ b/frontend/client/src/autotest/planner/TestPlanSelector.java
@@ -0,0 +1,49 @@
+package autotest.planner;
+
+import com.google.gwt.event.dom.client.ClickEvent;
+import com.google.gwt.event.dom.client.ClickHandler;
+import com.google.gwt.event.dom.client.HasClickHandlers;
+import com.google.gwt.event.dom.client.HasKeyPressHandlers;
+import com.google.gwt.event.dom.client.KeyCodes;
+import com.google.gwt.event.dom.client.KeyPressEvent;
+import com.google.gwt.event.dom.client.KeyPressHandler;
+import com.google.gwt.user.client.ui.HasText;
+
+public class TestPlanSelector implements ClickHandler, KeyPressHandler {
+    
+    public static interface Display {
+        public HasText getInputText();
+        public HasClickHandlers getShowButton();
+        public HasKeyPressHandlers getInputField();
+    }
+    
+    
+    private Display display;
+    private String selectedPlan;
+    
+    public void bindDisplay(Display display) {
+        this.display = display;
+        display.getShowButton().addClickHandler(this);
+        display.getInputField().addKeyPressHandler(this);
+    }
+    
+    @Override
+    public void onClick(ClickEvent event) {
+        selectPlan();
+    }
+    
+    @Override
+    public void onKeyPress(KeyPressEvent event) {
+        if (event.getCharCode() == KeyCodes.KEY_ENTER) {
+            selectPlan();
+        }
+    }
+    
+    private void selectPlan() {
+        selectedPlan = display.getInputText().getText();
+    }
+    
+    public String getSelectedPlan() {
+        return selectedPlan;
+    }
+}
diff --git a/frontend/client/src/autotest/planner/TestPlanSelectorDisplay.java b/frontend/client/src/autotest/planner/TestPlanSelectorDisplay.java
new file mode 100644
index 0000000..3da5527
--- /dev/null
+++ b/frontend/client/src/autotest/planner/TestPlanSelectorDisplay.java
@@ -0,0 +1,41 @@
+package autotest.planner;
+
+import autotest.common.Utils;
+
+import com.google.gwt.event.dom.client.HasClickHandlers;
+import com.google.gwt.event.dom.client.HasKeyPressHandlers;
+import com.google.gwt.user.client.ui.Button;
+import com.google.gwt.user.client.ui.Composite;
+import com.google.gwt.user.client.ui.HTMLPanel;
+import com.google.gwt.user.client.ui.HasText;
+import com.google.gwt.user.client.ui.TextBox;
+
+public class TestPlanSelectorDisplay extends Composite implements TestPlanSelector.Display {
+    
+    private TextBox inputField;
+    private Button show;
+        
+    public void initialize() {
+        HTMLPanel panel = Utils.divToPanel("test_plan_selector");
+        
+        inputField = new TextBox();
+        panel.add(inputField, "test_plan_selector_input");
+        
+        show = new Button("show");
+        panel.add(show, "test_plan_selector_button");
+        
+        initWidget(panel);
+    }
+    
+    public HasText getInputText() {
+        return inputField;
+    }
+    
+    public HasKeyPressHandlers getInputField() {
+        return inputField;
+    }
+    
+    public HasClickHandlers getShowButton() {
+        return show;
+    }
+}
diff --git a/frontend/client/src/autotest/planner/TestPlannerClient.java b/frontend/client/src/autotest/planner/TestPlannerClient.java
new file mode 100644
index 0000000..51e078c
--- /dev/null
+++ b/frontend/client/src/autotest/planner/TestPlannerClient.java
@@ -0,0 +1,77 @@
+package autotest.planner;
+
+import autotest.common.CustomHistory;
+import autotest.common.JsonRpcProxy;
+import autotest.common.SiteCommonClassFactory;
+import autotest.common.StaticDataRepository;
+import autotest.common.ui.CustomTabPanel;
+import autotest.common.ui.NotifyManager;
+import autotest.planner.triage.TriageViewTab;
+
+import com.google.gwt.core.client.EntryPoint;
+import com.google.gwt.user.client.ui.RootPanel;
+
+public class TestPlannerClient implements EntryPoint {
+    
+    private TestPlanSelector planSelector = new TestPlanSelector();
+    private TestPlanSelectorDisplay planSelectorView = new TestPlanSelectorDisplay();
+    
+    private OverviewTab overviewTab = new OverviewTab();
+    private OverviewTabDisplay overviewTabDisplay = new OverviewTabDisplay();
+    
+    private MachineViewTab machineViewTab = new MachineViewTab();
+    private MachineViewTabDisplay machineViewTabDisplay = new MachineViewTabDisplay();
+    
+    private TestViewTab testViewTab = new TestViewTab();
+    private TestViewTabDisplay testViewTabDisplay = new TestViewTabDisplay();
+    
+    private TriageViewTab triageViewTab = new TriageViewTab(planSelector);
+    
+    private AutoprocessedTab autoprocessedTab = new AutoprocessedTab();
+    private AutoprocessedTabDisplay autoprocessedTabDisplay = new AutoprocessedTabDisplay();
+    
+    private HistoryTab historyTab = new HistoryTab();
+    private HistoryTabDisplay historyTabDisplay = new HistoryTabDisplay();
+    
+    private CustomTabPanel mainTabPanel = new CustomTabPanel();
+    
+    public void onModuleLoad() {
+        JsonRpcProxy.setDefaultBaseUrl(JsonRpcProxy.PLANNER_BASE_URL);
+        
+        NotifyManager.getInstance().initialize();
+        
+        StaticDataRepository.getRepository().refresh(
+                                 new StaticDataRepository.FinishedCallback() {
+            public void onFinished() {
+                finishLoading();
+            }
+        });
+    }
+    
+    private void finishLoading() {
+        SiteCommonClassFactory.globalInitialize();
+        
+        overviewTab.bindDisplay(overviewTabDisplay);
+        machineViewTab.bindDisplay(machineViewTabDisplay);
+        testViewTab.bindDisplay(testViewTabDisplay);
+        autoprocessedTab.bindDisplay(autoprocessedTabDisplay);
+        historyTab.bindDisplay(historyTabDisplay);
+        
+        planSelectorView.initialize();
+        planSelector.bindDisplay(planSelectorView);
+        mainTabPanel.getCommonAreaPanel().add(planSelectorView);
+        
+        mainTabPanel.addTabView(overviewTabDisplay);
+        mainTabPanel.addTabView(machineViewTabDisplay);
+        mainTabPanel.addTabView(testViewTabDisplay);
+        mainTabPanel.addTabView(triageViewTab);
+        mainTabPanel.addTabView(autoprocessedTabDisplay);
+        mainTabPanel.addTabView(historyTabDisplay);
+        
+        final RootPanel tabsRoot = RootPanel.get("tabs");
+        tabsRoot.add(mainTabPanel);
+        CustomHistory.processInitialToken();
+        mainTabPanel.initialize();
+        tabsRoot.removeStyleName("hidden");
+    }
+}
diff --git a/frontend/client/src/autotest/planner/TestViewTab.java b/frontend/client/src/autotest/planner/TestViewTab.java
new file mode 100644
index 0000000..0afcb37
--- /dev/null
+++ b/frontend/client/src/autotest/planner/TestViewTab.java
@@ -0,0 +1,17 @@
+package autotest.planner;
+
+
+public class TestViewTab {
+    
+    public static interface Display {
+        // Not yet implemented
+    }
+    
+    @SuppressWarnings("unused")
+    private Display display;
+    
+    public void bindDisplay(Display display) {
+        this.display = display;
+    }
+    
+}
diff --git a/frontend/client/src/autotest/planner/TestViewTabDisplay.java b/frontend/client/src/autotest/planner/TestViewTabDisplay.java
new file mode 100644
index 0000000..15f9d83
--- /dev/null
+++ b/frontend/client/src/autotest/planner/TestViewTabDisplay.java
@@ -0,0 +1,13 @@
+package autotest.planner;
+
+import autotest.common.ui.TabView;
+
+
+public class TestViewTabDisplay extends TabView implements TestViewTab.Display {
+    
+    @Override
+    public String getElementId() {
+        return "test_view";
+    }
+    
+}
diff --git a/frontend/client/src/autotest/planner/triage/FailureTable.java b/frontend/client/src/autotest/planner/triage/FailureTable.java
new file mode 100644
index 0000000..d39e1d1
--- /dev/null
+++ b/frontend/client/src/autotest/planner/triage/FailureTable.java
@@ -0,0 +1,142 @@
+package autotest.planner.triage;
+
+import autotest.common.spreadsheet.Spreadsheet;
+import autotest.common.spreadsheet.Spreadsheet.CellInfo;
+import autotest.common.spreadsheet.Spreadsheet.Header;
+import autotest.common.spreadsheet.Spreadsheet.HeaderImpl;
+import autotest.common.spreadsheet.Spreadsheet.SpreadsheetListener;
+
+import com.google.gwt.json.client.JSONObject;
+import com.google.gwt.user.client.IncrementalCommand;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.LinkedList;
+import java.util.List;
+
+class FailureTable implements SpreadsheetListener {
+    
+    public interface Display {
+        public Spreadsheet getSpreadsheet();
+    }
+    
+    private static class Failure {
+        int id;
+        String machine;
+        boolean blocked;
+        String testName;
+        String reason;
+        boolean seen;
+        
+        private Failure(int id, String machine, boolean blocked,
+                String testName, String reason, boolean seen) {
+            this.id = id;
+            this.machine = machine;
+            this.blocked = blocked;
+            this.testName = testName;
+            this.reason = reason;
+            this.seen = seen;
+        }
+        
+        public static Failure fromJsonObject(JSONObject failureObj) {
+            return new Failure((int) failureObj.get("id").isNumber().doubleValue(),
+                failureObj.get("machine").isString().stringValue(),
+                failureObj.get("blocked").isBoolean().booleanValue(),
+                failureObj.get("test_name").isString().stringValue(),
+                failureObj.get("reason").isString().stringValue(),
+                failureObj.get("seen").isBoolean().booleanValue());
+        }
+    }
+    
+    private Display display;
+    private String group;
+    private LinkedList<Failure> failures = new LinkedList<Failure>();
+    private boolean rendered;
+    
+    public FailureTable(String group) {
+        this.group = group;
+    }
+    
+    public void bindDisplay(Display display) {
+        this.display = display;
+    }
+    
+    public void addFailure(JSONObject failureObj) {
+        rendered = false;
+        
+        Failure failure = Failure.fromJsonObject(failureObj);
+        
+        if (failure.seen) {
+            failures.addLast(failure);
+        } else {
+            failures.addFirst(failure);
+        }
+    }
+    
+    public Display getDisplay() {
+        if (!rendered) {
+            renderDisplay();
+        }
+        
+        return display;
+    }
+    
+    public void renderDisplay() {
+        Spreadsheet spreadsheet = display.getSpreadsheet();
+        
+        Header rowFields = HeaderImpl.fromBaseType(Collections.singletonList("machine"));
+        Header columnFields = new HeaderImpl();
+        columnFields.add("group");
+        columnFields.add("failure");
+        spreadsheet.setHeaderFields(rowFields, columnFields);
+        
+        for (int i = 0; i < failures.size(); i++) {
+            Failure failure = failures.get(i);
+            String machine = (i+1) + ": " + failure.machine;
+            if (failure.blocked) {
+                machine += " (blocked)";
+            }
+            spreadsheet.addRowHeader(Collections.singletonList(machine));
+        }
+        spreadsheet.addColumnHeader(createHeaderGroup("Test"));
+        spreadsheet.addColumnHeader(createHeaderGroup("Reason"));
+        
+        spreadsheet.prepareForData();
+        
+        for (int row = 0; row < failures.size(); row++) {
+            CellInfo test = spreadsheet.getCellInfo(row, 0);
+            CellInfo reason = spreadsheet.getCellInfo(row, 1);
+            Failure failure = failures.get(row);
+            
+            test.contents = failure.testName;
+            reason.contents = failure.reason;
+            
+            if (!failure.seen) {
+                test.contents = "<b>" + test.contents + "</b>";
+                reason.contents = "<b>" + reason.contents + "</b>";
+            }
+        }
+        
+        spreadsheet.setVisible(true);
+        
+        spreadsheet.render(new IncrementalCommand() {
+            @Override
+            public boolean execute() {
+              rendered = true;
+              return false;
+            }
+        });
+    }
+    
+    private List<String> createHeaderGroup(String label) {
+        List<String> header = new ArrayList<String>();
+        header.add(group);
+        header.add(label);
+        return header;
+    }
+
+    @Override
+    public void onCellClicked(CellInfo cellInfo, boolean isRightClick) {
+        //TODO: handle row clicks (pop up the triage panel)
+    }
+}
diff --git a/frontend/client/src/autotest/planner/triage/FailureTableDisplay.java b/frontend/client/src/autotest/planner/triage/FailureTableDisplay.java
new file mode 100644
index 0000000..6b91267
--- /dev/null
+++ b/frontend/client/src/autotest/planner/triage/FailureTableDisplay.java
@@ -0,0 +1,18 @@
+package autotest.planner.triage;
+
+import autotest.common.spreadsheet.Spreadsheet;
+
+import com.google.gwt.user.client.ui.Composite;
+
+public class FailureTableDisplay extends Composite implements FailureTable.Display {
+    
+    private Spreadsheet spreadsheet = new Spreadsheet();
+    
+    public FailureTableDisplay() {
+        initWidget(spreadsheet);
+    }
+    
+    public Spreadsheet getSpreadsheet() {
+        return spreadsheet;
+    }
+}
diff --git a/frontend/client/src/autotest/planner/triage/TriageViewDisplay.java b/frontend/client/src/autotest/planner/triage/TriageViewDisplay.java
new file mode 100644
index 0000000..f8b2276
--- /dev/null
+++ b/frontend/client/src/autotest/planner/triage/TriageViewDisplay.java
@@ -0,0 +1,35 @@
+package autotest.planner.triage;
+
+import autotest.common.ui.NotifyManager;
+
+import com.google.gwt.user.client.ui.HTMLPanel;
+import com.google.gwt.user.client.ui.Panel;
+import com.google.gwt.user.client.ui.VerticalPanel;
+
+
+public class TriageViewDisplay implements TriageViewPresenter.Display {
+    
+    private Panel container = new VerticalPanel();
+    
+    public void initialize(HTMLPanel htmlPanel) {
+        htmlPanel.add(container, "triage_failure_tables");
+    }
+    
+    @Override
+    public void setLoading(boolean loading) {
+        NotifyManager.getInstance().setLoading(loading);
+        container.setVisible(!loading);
+    }
+    
+    @Override
+    public FailureTable.Display generateFailureTable() {
+        FailureTableDisplay display = new FailureTableDisplay();
+        container.add(display);
+        return display;
+    }
+
+    @Override
+    public void clearAllFailureTables() {
+        container.clear();
+    }
+}
diff --git a/frontend/client/src/autotest/planner/triage/TriageViewPresenter.java b/frontend/client/src/autotest/planner/triage/TriageViewPresenter.java
new file mode 100644
index 0000000..521220b
--- /dev/null
+++ b/frontend/client/src/autotest/planner/triage/TriageViewPresenter.java
@@ -0,0 +1,67 @@
+package autotest.planner.triage;
+
+import autotest.common.JsonRpcCallback;
+import autotest.common.JsonRpcProxy;
+import autotest.planner.TestPlanSelector;
+
+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;
+
+public class TriageViewPresenter {
+    
+    public interface Display {
+        public void setLoading(boolean loading);
+        public void clearAllFailureTables();
+        public FailureTable.Display generateFailureTable();
+    }
+    
+    private TestPlanSelector selector;
+    private Display display;
+    
+    public TriageViewPresenter(TestPlanSelector selector) {
+        this.selector = selector;
+    }
+    
+    public void bindDisplay(Display display) {
+        this.display = display;
+    }
+    
+    public void refresh() {
+        String planId = selector.getSelectedPlan();
+        if (planId == null) {
+            return;
+        }
+        
+        display.setLoading(true);
+        
+        JSONObject params = new JSONObject();
+        params.put("plan_id", new JSONString(planId));
+        
+        JsonRpcProxy.getProxy().rpcCall("get_failures", params, new JsonRpcCallback() {
+            @Override
+            public void onSuccess(JSONValue result) {
+                display.clearAllFailureTables();
+                generateFailureTables(result.isObject());
+                display.setLoading(false);
+            }
+        });
+    }
+    
+    private void generateFailureTables(JSONObject failures) {        
+        for (String group : failures.keySet()) {
+            FailureTable table = new FailureTable(group);
+            FailureTable.Display tableDisplay = display.generateFailureTable();
+            table.bindDisplay(tableDisplay);
+            
+            JSONArray groupFailures = failures.get(group).isArray();
+            
+            for (int i = 0; i < groupFailures.size(); i++) {
+                table.addFailure(groupFailures.get(i).isObject());
+            }
+            
+            table.renderDisplay();
+        }
+    }
+}
diff --git a/frontend/client/src/autotest/planner/triage/TriageViewTab.java b/frontend/client/src/autotest/planner/triage/TriageViewTab.java
new file mode 100644
index 0000000..61f0840
--- /dev/null
+++ b/frontend/client/src/autotest/planner/triage/TriageViewTab.java
@@ -0,0 +1,35 @@
+package autotest.planner.triage;
+
+import autotest.common.ui.TabView;
+import autotest.planner.TestPlanSelector;
+
+import com.google.gwt.user.client.ui.HTMLPanel;
+
+
+public class TriageViewTab extends TabView {
+    
+    private TriageViewPresenter presenter;
+    private TriageViewDisplay display = new TriageViewDisplay();
+    
+    public TriageViewTab(TestPlanSelector selector) {
+        presenter = new TriageViewPresenter(selector);
+    }
+    
+    @Override
+    public String getElementId() {
+        return "triage_view";
+    }
+    
+    @Override
+    public void initialize() {
+        super.initialize();
+        display.initialize((HTMLPanel) getWidget());
+        presenter.bindDisplay(display);
+    }
+    
+    @Override
+    public void refresh() {
+        super.refresh();
+        presenter.refresh();
+    }
+}
diff --git a/frontend/client/src/autotest/public/TestPlannerClient.html b/frontend/client/src/autotest/public/TestPlannerClient.html
new file mode 100644
index 0000000..5fdfa5b
--- /dev/null
+++ b/frontend/client/src/autotest/public/TestPlannerClient.html
@@ -0,0 +1,63 @@
+<!DOCTYPE html>
+<html>
+  <head>
+    <meta http-equiv="Content-Type" content="text/html; charset=utf-8">
+    <title>Autotest Frontend</title>
+    <script type='text/javascript' src='autotest.TestPlannerClient.nocache.js'>
+    </script>
+  </head>
+
+  <body>
+    <!-- gwt history support -->
+    <iframe src="javascript:''" id="__gwt_historyFrame"
+            style="width:0;height:0;border:0"></iframe>
+
+
+    <div class="links-box" style="float: right;">
+      <span id="report_issues"></span> |
+      <a href="server/afe">Frontend</a> |
+      <a href="server/admin">Admin</a> |
+      <a href="/new_tko">Results</a>
+      (<a href="/tko"><b>Old</b> TKO</a>) |
+      Test Planner! |
+      <a href="http://wiki/Main/Autotest">Documentation</a> |
+      <a href="/g4sync.log">Synced CLs</a>
+      <div id="motd" class="motd"></div>
+    </div>
+
+    <img src="header.png" />
+    <br /><br />
+
+    <div id="tabs" class="hidden">
+      <div id="test_plan_selector">
+        Enter a test plan:
+        <span id="test_plan_selector_input"></span>
+        <span id="test_plan_selector_button"></span>
+      </div>
+
+      <div id="overview" title="Overview">
+      </div>
+
+      <div id="machine_view" title="Machine View">
+      </div>
+
+      <div id="test_view"  title="Test View">
+      </div>
+
+      <div id="triage_view"  title="Triage View">
+        <div id="triage_failure_tables"></div>
+      </div>
+
+      <div id="autoprocessed" title="Auto-processed">
+      </div>
+
+      <div id="history" title="History">
+      </div>
+    </div>
+    <br>
+    <div id="error_log"></div>
+
+    <!--  for debugging only -->
+    <div id="error_display"></div>
+  </body>
+</html>
diff --git a/frontend/client/src/autotest/tko/FragmentedTable.java b/frontend/client/src/autotest/tko/FragmentedTable.java
index 987e4c3..e69de29 100644
--- a/frontend/client/src/autotest/tko/FragmentedTable.java
+++ b/frontend/client/src/autotest/tko/FragmentedTable.java
@@ -1,143 +0,0 @@
-package autotest.tko;
-
-import autotest.common.ui.RightClickTable;
-
-import com.google.gwt.event.dom.client.ClickEvent;
-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
-    public HTMLTable.Cell getCellForEvent(ClickEvent event) {
-        return getCellForDomEvent(event);
-    }
-    
-    @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/Spreadsheet.java b/frontend/client/src/autotest/tko/Spreadsheet.java
index 4d25130..e69de29 100644
--- a/frontend/client/src/autotest/tko/Spreadsheet.java
+++ b/frontend/client/src/autotest/tko/Spreadsheet.java
@@ -1,587 +0,0 @@
-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.event.dom.client.ClickEvent;
-import com.google.gwt.event.dom.client.ClickHandler;
-import com.google.gwt.event.dom.client.ContextMenuEvent;
-import com.google.gwt.event.dom.client.ContextMenuHandler;
-import com.google.gwt.event.dom.client.DomEvent;
-import com.google.gwt.event.dom.client.ScrollEvent;
-import com.google.gwt.event.dom.client.ScrollHandler;
-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.ScrollPanel;
-import com.google.gwt.user.client.ui.SimplePanel;
-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 ScrollHandler, ClickHandler, ContextMenuHandler {
-    
-    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, boolean isRightClick);
-    }
-    
-    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 int testIndex;
-        
-        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();
-                    break;
-                case 2:
-                    // resize everything to the max dimensions (the window size)
-                    fillWindow(false);
-                    break;
-                case 3:
-                    // set main table to match header sizes
-                    matchRowHeights(rowHeaders, dataCells);
-                    matchColumnWidths(columnHeaders, dataCells);
-                    dataTable.setVisible(false);
-                    break;
-                case 4:
-                    // render the main data table
-                    renderSomeRows();
-                    return true;
-                case 5:
-                    dataTable.updateBodyElems();
-                    dataTable.setVisible(true);
-                    break;
-                case 6:
-                    // now expand headers as necessary
-                    // this can be very slow, so put it in it's own cycle
-                    matchRowHeights(dataTable, rowHeaderCells);
-                    break;
-                case 7:
-                    matchColumnWidths(dataTable, columnHeaderCells);
-                    renderHeaders();
-                    break;
-                case 8:
-                    // 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.addScrollHandler(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.addContextMenuHandler(this);
-        table.addClickHandler(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.
-     */
-    @Override
-    public void onScroll(ScrollEvent event) {
-        int scrollLeft = scrollPanel.getHorizontalScrollPosition();
-        int scrollTop = scrollPanel.getScrollPosition();
-        
-        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);
-    }
-    
-    @Override
-    public void onClick(ClickEvent event) {
-        handleEvent(event, false);
-    }
-    
-    @Override
-    public void onContextMenu(ContextMenuEvent event) {
-        handleEvent(event, true);
-    }
-    
-    private void handleEvent(DomEvent<?> event, boolean isRightClick) {
-        if (listener == null)
-            return;
-        
-        assert event.getSource() instanceof RightClickTable;
-        HTMLTable.Cell tableCell = ((RightClickTable) event.getSource()).getCellForDomEvent(event);
-        int row = tableCell.getRowIndex();
-        int column = tableCell.getCellIndex();
-        
-        CellInfo[][] cells;
-        if (event.getSource() == rowHeaders) {
-            cells = rowHeaderCells;
-            column = adjustRowHeaderColumnIndex(row, column);
-        }
-        else if (event.getSource() == columnHeaders) {
-            cells = columnHeaderCells;
-        }
-        else {
-            assert event.getSource() == dataTable;
-            cells = dataCells;
-        }
-        CellInfo cell = cells[row][column];
-        if (cell == null || cell.isEmpty())
-            return; // don't report clicks on empty cells
-        
-        listener.onCellClicked(cell, isRightClick);
-    }
-
-    /**
-     * 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("");
-        }
-    }
-    
-    public List<Integer> getAllTestIndices() {
-        List<Integer> testIndices = new ArrayList<Integer>();
-
-        for (CellInfo[] row : dataCells) {
-            for (CellInfo cellInfo : row) {
-                if (cellInfo != null && !cellInfo.isEmpty()) {
-                    testIndices.add(cellInfo.testIndex);
-                }
-            }
-        }
-
-        return testIndices;
-    }
-}
diff --git a/frontend/client/src/autotest/tko/SpreadsheetDataProcessor.java b/frontend/client/src/autotest/tko/SpreadsheetDataProcessor.java
index 147f695..bae2f06 100644
--- a/frontend/client/src/autotest/tko/SpreadsheetDataProcessor.java
+++ b/frontend/client/src/autotest/tko/SpreadsheetDataProcessor.java
@@ -1,11 +1,12 @@
 package autotest.tko;
 
+import autotest.common.spreadsheet.Spreadsheet;
+import autotest.common.spreadsheet.Spreadsheet.CellInfo;
+import autotest.common.spreadsheet.Spreadsheet.Header;
+import autotest.common.spreadsheet.Spreadsheet.HeaderImpl;
 import autotest.common.table.DataSource.DataCallback;
 import autotest.common.table.DataSource.Query;
 import autotest.common.ui.NotifyManager;
-import autotest.tko.Spreadsheet.CellInfo;
-import autotest.tko.Spreadsheet.Header;
-import autotest.tko.Spreadsheet.HeaderImpl;
 
 import com.google.gwt.core.client.Duration;
 import com.google.gwt.json.client.JSONArray;
diff --git a/frontend/client/src/autotest/tko/SpreadsheetSelectionManager.java b/frontend/client/src/autotest/tko/SpreadsheetSelectionManager.java
index 9f8e034..e69de29 100644
--- a/frontend/client/src/autotest/tko/SpreadsheetSelectionManager.java
+++ b/frontend/client/src/autotest/tko/SpreadsheetSelectionManager.java
@@ -1,91 +0,0 @@
-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
index 6a7a6bf..11d3b6e 100644
--- a/frontend/client/src/autotest/tko/SpreadsheetView.java
+++ b/frontend/client/src/autotest/tko/SpreadsheetView.java
@@ -4,6 +4,11 @@
 import autotest.common.JsonRpcProxy;
 import autotest.common.Utils;
 import autotest.common.CustomHistory.HistoryToken;
+import autotest.common.spreadsheet.Spreadsheet;
+import autotest.common.spreadsheet.SpreadsheetSelectionManager;
+import autotest.common.spreadsheet.Spreadsheet.CellInfo;
+import autotest.common.spreadsheet.Spreadsheet.Header;
+import autotest.common.spreadsheet.Spreadsheet.SpreadsheetListener;
 import autotest.common.ui.ContextMenu;
 import autotest.common.ui.NotifyManager;
 import autotest.common.ui.SimpleHyperlink;
@@ -11,9 +16,6 @@
 import autotest.common.ui.TableActionsPanel.TableActionsWithExportCsvListener;
 import autotest.common.ui.TableSelectionPanel.SelectionPanelListener;
 import autotest.tko.CommonPanel.CommonPanelListener;
-import autotest.tko.Spreadsheet.CellInfo;
-import autotest.tko.Spreadsheet.Header;
-import autotest.tko.Spreadsheet.SpreadsheetListener;
 import autotest.tko.TableView.TableSwitchListener;
 import autotest.tko.TableView.TableViewConfig;
 
diff --git a/frontend/client/src/autotest/tko/TableRenderer.java b/frontend/client/src/autotest/tko/TableRenderer.java
index deb94b8..e69de29 100644
--- a/frontend/client/src/autotest/tko/TableRenderer.java
+++ b/frontend/client/src/autotest/tko/TableRenderer.java
@@ -1,101 +0,0 @@
-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/TkoUtils.java b/frontend/client/src/autotest/tko/TkoUtils.java
index 174c014..2c9529c 100644
--- a/frontend/client/src/autotest/tko/TkoUtils.java
+++ b/frontend/client/src/autotest/tko/TkoUtils.java
@@ -7,7 +7,6 @@
 import autotest.common.table.RpcDataSource;
 import autotest.common.table.DataSource.Query;
 
-import com.google.gwt.dom.client.Element;
 import com.google.gwt.http.client.URL;
 import com.google.gwt.json.client.JSONArray;
 import com.google.gwt.json.client.JSONObject;
@@ -52,15 +51,6 @@
         return params;
     }
 
-    protected static void clearDomChildren(Element elem) {
-        Element child = elem.getFirstChildElement();
-        while (child != null) {
-            Element nextChild = child.getNextSiblingElement();
-            elem.removeChild(child);
-            child = nextChild;
-        }
-    }
-
     static void setElementVisible(String elementId, boolean visible) {
         DOM.getElementById(elementId).getStyle().setProperty("display", visible ? "" : "none");
     }
diff --git a/frontend/planner/models.py b/frontend/planner/models.py
index f69aa28..48824d3 100644
--- a/frontend/planner/models.py
+++ b/frontend/planner/models.py
@@ -127,7 +127,7 @@
 
 
 class TestConfig(ModelWithPlan, model_logic.ModelExtensions):
-    """A planned test
+    """A configuration for a planned test
 
     Required:
         alias: The name to give this test within the plan. Unique with plan id
diff --git a/frontend/planner/rpc_interface.py b/frontend/planner/rpc_interface.py
index 219436f..ff04b2f 100644
--- a/frontend/planner/rpc_interface.py
+++ b/frontend/planner/rpc_interface.py
@@ -12,7 +12,7 @@
 from autotest_lib.frontend.afe import model_logic, models as afe_models
 from autotest_lib.frontend.afe import rpc_utils as afe_rpc_utils
 from autotest_lib.frontend.tko import models as tko_models
-from autotest_lib.frontend.planner import models, rpc_utils
+from autotest_lib.frontend.planner import models, rpc_utils, model_attributes
 from autotest_lib.client.common_lib import utils
 
 # basic getter/setter calls
@@ -235,3 +235,47 @@
                                     'hostname': hostname})
 
     return updated
+
+
+def get_failures(plan_id):
+    """
+    Gets a list of the untriaged failures associated with this plan
+
+    @return a list of dictionaries:
+                id: the failure ID, for passing back to triage the failure
+                group: the group for the failure. Normally the same as the
+                       reason, but can be different for custom queries
+                machine: the failed machine
+                blocked: True if the failure caused the machine to block
+                test_name: Concatenation of the Planner alias and the TKO test
+                           name for the failed test
+                reason: test failure reason
+                seen: True if the failure is marked as "seen"
+    """
+    plan = models.Plan.smart_get(plan_id)
+    result = {}
+
+    failures = plan.testrun_set.filter(
+            finalized=True, triaged=False,
+            status=model_attributes.TestRunStatus.FAILED)
+    failures = failures.select_related('test_job__test', 'host__host',
+                                       'tko_test')
+    for failure in failures:
+        test_name = '%s:%s' % (
+                failure.test_job.test_config.alias, failure.tko_test.test)
+
+        group_failures = result.setdefault(failure.tko_test.reason, [])
+        failure_dict = {'id': failure.id,
+                        'machine': failure.host.host.hostname,
+                        'blocked': bool(failure.host.blocked),
+                        'test_name': test_name,
+                        'reason': failure.tko_test.reason,
+                        'seen': bool(failure.seen)}
+        group_failures.append(failure_dict)
+
+    return result
+
+
+def get_static_data():
+    result = {'motd': afe_rpc_utils.get_motd()}
+    return result