rebaseline_server: add tabs, and ability to submit new baselines to the server

Tabs allow the user to divide the tests into groups:
hide these for now, approve these, etc.

(SkipBuildbotRuns)

R=borenet@google.com

Review URL: https://codereview.chromium.org/28903008

git-svn-id: http://skia.googlecode.com/svn/trunk@11915 2bbb7eff-a529-9590-31e7-b0007b416f81
diff --git a/gm/rebaseline_server/results.py b/gm/rebaseline_server/results.py
index ac71156..d3c8790 100755
--- a/gm/rebaseline_server/results.py
+++ b/gm/rebaseline_server/results.py
@@ -31,6 +31,8 @@
 import gm_json
 
 IMAGE_FILENAME_RE = re.compile(gm_json.IMAGE_FILENAME_PATTERN)
+IMAGE_FILENAME_FORMATTER = '%s_%s.png'  # pass in (testname, config)
+
 CATEGORIES_TO_SUMMARIZE = [
     'builder', 'test', 'config', 'resultType',
 ]
@@ -41,9 +43,9 @@
   """ Loads actual and expected results from all builders, supplying combined
   reports as requested.
 
-  Once this object has been constructed, the results are immutable.  If you
-  want to update the results based on updated JSON file contents, you will
-  need to create a new Results object."""
+  Once this object has been constructed, the results (in self._results[])
+  are immutable.  If you want to update the results based on updated JSON
+  file contents, you will need to create a new Results object."""
 
   def __init__(self, actuals_root, expected_root):
     """
@@ -51,9 +53,9 @@
       actuals_root: root directory containing all actual-results.json files
       expected_root: root directory containing all expected-results.json files
     """
-    self._actual_builder_dicts = Results._get_dicts_from_root(actuals_root)
-    self._expected_builder_dicts = Results._get_dicts_from_root(expected_root)
-    self._combine_actual_and_expected()
+    self._actuals_root = actuals_root
+    self._expected_root = expected_root
+    self._load_actual_and_expected()
     self._timestamp = int(time.time())
 
   def get_timestamp(self):
@@ -62,6 +64,51 @@
     """
     return self._timestamp
 
+  def edit_expectations(self, modifications):
+    """Edit the expectations stored within this object and write them back
+    to disk.
+
+    Note that this will NOT update the results stored in self._results[] ;
+    in order to see those updates, you must instantiate a new Results object
+    based on the (now updated) files on disk.
+
+    Args:
+      modifications: a list of dictionaries, one for each expectation to update:
+
+         [
+           {
+             'builder': 'Test-Mac10.6-MacMini4.1-GeForce320M-x86-Debug',
+             'test': 'bigmatrix',
+             'config': '8888',
+             'expectedHashType': 'bitmap-64bitMD5',
+             'expectedHashDigest': '10894408024079689926',
+           },
+           ...
+         ]
+
+    TODO(epoger): For now, this does not allow the caller to set any fields
+    other than expectedHashType/expectedHashDigest, and assumes that
+    ignore-failure should be set to False.  We need to add support
+    for other fields (notes, bugs, etc.) and ignore-failure=True.
+    """
+    expected_builder_dicts = Results._read_dicts_from_root(self._expected_root)
+    for mod in modifications:
+      image_name = IMAGE_FILENAME_FORMATTER % (mod['test'], mod['config'])
+      # TODO(epoger): assumes a single allowed digest per test
+      allowed_digests = [[mod['expectedHashType'],
+                          int(mod['expectedHashDigest'])]]
+      new_expectations = {
+          gm_json.JSONKEY_EXPECTEDRESULTS_ALLOWEDDIGESTS: allowed_digests,
+          gm_json.JSONKEY_EXPECTEDRESULTS_IGNOREFAILURE: False,
+      }
+      builder_dict = expected_builder_dicts[mod['builder']]
+      builder_expectations = builder_dict.get(gm_json.JSONKEY_EXPECTEDRESULTS)
+      if not builder_expectations:
+        builder_expectations = {}
+        builder_dict[gm_json.JSONKEY_EXPECTEDRESULTS] = builder_expectations
+      builder_expectations[image_name] = new_expectations
+    Results._write_dicts_to_root(expected_builder_dicts, self._expected_root)
+
   def get_results_of_type(self, type):
     """Return results of some/all tests (depending on 'type' parameter).
 
@@ -111,7 +158,7 @@
     return self._results[type]
 
   @staticmethod
-  def _get_dicts_from_root(root, pattern='*.json'):
+  def _read_dicts_from_root(root, pattern='*.json'):
     """Read all JSON dictionaries within a directory tree.
 
     Args:
@@ -131,17 +178,72 @@
     for dirpath, dirnames, filenames in os.walk(root):
       for matching_filename in fnmatch.filter(filenames, pattern):
         builder = os.path.basename(dirpath)
+        # If we are reading from the collection of actual results, skip over
+        # the Trybot results (we don't maintain baselines for them).
         if builder.endswith('-Trybot'):
           continue
         fullpath = os.path.join(dirpath, matching_filename)
         meta_dict[builder] = gm_json.LoadFromFile(fullpath)
     return meta_dict
 
-  def _combine_actual_and_expected(self):
-    """Gathers the results of all tests, across all builders (based on the
-    contents of self._actual_builder_dicts and self._expected_builder_dicts),
+  @staticmethod
+  def _write_dicts_to_root(meta_dict, root, pattern='*.json'):
+    """Write all per-builder dictionaries within meta_dict to files under
+    the root path.
+
+    Security note: this will only write to files that already exist within
+    the root path (as found by os.walk() within root), so we don't need to
+    worry about malformed content writing to disk outside of root.
+    However, the data written to those files is not double-checked, so it
+    could contain poisonous data.
+
+    Args:
+      meta_dict: a builder-keyed meta-dictionary containing all the JSON
+                 dictionaries we want to write out
+      root: path to root of directory tree within which to write files
+      pattern: which files to write within root (fnmatch-style pattern)
+
+    Raises:
+      IOError if root does not refer to an existing directory
+      KeyError if the set of per-builder dictionaries written out was
+               different than expected
+    """
+    if not os.path.isdir(root):
+      raise IOError('no directory found at path %s' % root)
+    actual_builders_written = []
+    for dirpath, dirnames, filenames in os.walk(root):
+      for matching_filename in fnmatch.filter(filenames, pattern):
+        builder = os.path.basename(dirpath)
+        # We should never encounter Trybot *expectations*, but if we are
+        # writing into the actual-results dir, skip the Trybot actuals.
+        # (I don't know why we would ever write into the actual-results dir,
+        # though.)
+        if builder.endswith('-Trybot'):
+          continue
+        per_builder_dict = meta_dict.get(builder)
+        if per_builder_dict:
+          fullpath = os.path.join(dirpath, matching_filename)
+          gm_json.WriteToFile(per_builder_dict, fullpath)
+          actual_builders_written.append(builder)
+
+    # Check: did we write out the set of per-builder dictionaries we
+    # expected to?
+    expected_builders_written = sorted(meta_dict.keys())
+    actual_builders_written.sort()
+    if expected_builders_written != actual_builders_written:
+      raise KeyError(
+          'expected to write dicts for builders %s, but actually wrote them '
+          'for builders %s' % (
+              expected_builders_written, actual_builders_written))
+
+  def _load_actual_and_expected(self):
+    """Loads the results of all tests, across all builders (based on the
+    files within self._actuals_root and self._expected_root),
     and stores them in self._results.
     """
+    actual_builder_dicts = Results._read_dicts_from_root(self._actuals_root)
+    expected_builder_dicts = Results._read_dicts_from_root(self._expected_root)
+
     categories_all = {}
     categories_failures = {}
     Results._ensure_included_in_category_dict(categories_all,
@@ -160,9 +262,9 @@
 
     data_all = []
     data_failures = []
-    for builder in sorted(self._actual_builder_dicts.keys()):
+    for builder in sorted(actual_builder_dicts.keys()):
       actual_results_for_this_builder = (
-          self._actual_builder_dicts[builder][gm_json.JSONKEY_ACTUALRESULTS])
+          actual_builder_dicts[builder][gm_json.JSONKEY_ACTUALRESULTS])
       for result_type in sorted(actual_results_for_this_builder.keys()):
         results_of_this_type = actual_results_for_this_builder[result_type]
         if not results_of_this_type:
@@ -172,7 +274,7 @@
           try:
             # TODO(epoger): assumes a single allowed digest per test
             expected_image = (
-                self._expected_builder_dicts
+                expected_builder_dicts
                     [builder][gm_json.JSONKEY_EXPECTEDRESULTS]
                     [image_name][gm_json.JSONKEY_EXPECTEDRESULTS_ALLOWEDDIGESTS]
                     [0])
diff --git a/gm/rebaseline_server/server.py b/gm/rebaseline_server/server.py
index a370379..a15b9b9 100755
--- a/gm/rebaseline_server/server.py
+++ b/gm/rebaseline_server/server.py
@@ -60,6 +60,9 @@
 DEFAULT_EXPECTATIONS_DIR = os.path.join(TRUNK_DIRECTORY, 'expectations', 'gm')
 DEFAULT_PORT = 8888
 
+_HTTP_HEADER_CONTENT_LENGTH = 'Content-Length'
+_HTTP_HEADER_CONTENT_TYPE = 'Content-Type'
+
 _SERVER = None   # This gets filled in by main()
 
 class Server(object):
@@ -103,41 +106,48 @@
     results. """
     return self._reload_seconds
 
-  def _update_results(self):
+  def update_results(self):
     """ Create or update self.results, based on the expectations in
     self._expectations_dir and the latest actuals from skia-autogen.
     """
-    logging.info('Updating actual GM results in %s from SVN repo %s ...' % (
-        self._actuals_dir, ACTUALS_SVN_REPO))
-    actuals_repo = svn.Svn(self._actuals_dir)
-    if not os.path.isdir(self._actuals_dir):
-      os.makedirs(self._actuals_dir)
-      actuals_repo.Checkout(ACTUALS_SVN_REPO, '.')
-    else:
-      actuals_repo.Update('.')
-
-    # We only update the expectations dir if the server was run with a nonzero
-    # --reload argument; otherwise, we expect the user to maintain her own
-    # expectations as she sees fit.
-    #
-    # TODO(epoger): Use git instead of svn to check out expectations, since
-    # the Skia repo is moving to git.
-    if self._reload_seconds:
-      logging.info('Updating expected GM results in %s from SVN repo %s ...' % (
-          self._expectations_dir, EXPECTATIONS_SVN_REPO))
-      expectations_repo = svn.Svn(self._expectations_dir)
-      if not os.path.isdir(self._expectations_dir):
-        os.makedirs(self._expectations_dir)
-        expectations_repo.Checkout(EXPECTATIONS_SVN_REPO, '.')
+    with self.results_lock:
+      # self.results_lock prevents us from updating the actual GM results
+      # in multiple threads simultaneously
+      logging.info('Updating actual GM results in %s from SVN repo %s ...' % (
+          self._actuals_dir, ACTUALS_SVN_REPO))
+      actuals_repo = svn.Svn(self._actuals_dir)
+      if not os.path.isdir(self._actuals_dir):
+        os.makedirs(self._actuals_dir)
+        actuals_repo.Checkout(ACTUALS_SVN_REPO, '.')
       else:
-        expectations_repo.Update('.')
+        actuals_repo.Update('.')
 
-    logging.info(
-        'Parsing results from actuals in %s and expectations in %s ...' % (
-        self._actuals_dir, self._expectations_dir))
-    self.results = results.Results(
-      actuals_root=self._actuals_dir,
-      expected_root=self._expectations_dir)
+      # We only update the expectations dir if the server was run with a
+      # nonzero --reload argument; otherwise, we expect the user to maintain
+      # her own expectations as she sees fit.
+      #
+      # self.results_lock prevents us from updating the expected GM results
+      # in multiple threads simultaneously
+      #
+      # TODO(epoger): Use git instead of svn to check out expectations, since
+      # the Skia repo is moving to git.
+      if self._reload_seconds:
+        logging.info(
+            'Updating expected GM results in %s from SVN repo %s ...' % (
+            self._expectations_dir, EXPECTATIONS_SVN_REPO))
+        expectations_repo = svn.Svn(self._expectations_dir)
+        if not os.path.isdir(self._expectations_dir):
+          os.makedirs(self._expectations_dir)
+          expectations_repo.Checkout(EXPECTATIONS_SVN_REPO, '.')
+        else:
+          expectations_repo.Update('.')
+
+      logging.info(
+          'Parsing results from actuals in %s and expectations in %s ...' % (
+          self._actuals_dir, self._expectations_dir))
+      self.results = results.Results(
+        actuals_root=self._actuals_dir,
+        expected_root=self._expectations_dir)
 
   def _result_reloader(self):
     """ If --reload argument was specified, reload results at the appropriate
@@ -145,12 +155,11 @@
     """
     while self._reload_seconds:
       time.sleep(self._reload_seconds)
-      with self.results_lock:
-        self._update_results()
+      self.update_results()
 
   def run(self):
-    self._update_results()
     self.results_lock = thread.allocate_lock()
+    self.update_results()
     thread.start_new_thread(self._result_reloader, ())
 
     if self._export:
@@ -224,6 +233,9 @@
             (time_updated+_SERVER.reload_seconds()) if _SERVER.reload_seconds()
             else None),
 
+        # The type we passed to get_results_of_type()
+        'type': type,
+
         # Hash of testData, which the client must return with any edits--
         # this ensures that the edits were made to a particular dataset.
         'dataHash': str(hash(repr(response_dict['testData']))),
@@ -261,6 +273,76 @@
           % (full_path, static_dir))
       self.send_error(404)
 
+  def do_POST(self):
+    """ Handles all POST requests, forwarding them to the appropriate
+        do_POST_* dispatcher. """
+    # All requests must be of this form:
+    #   /dispatcher
+    # where 'dispatcher' indicates which do_POST_* dispatcher to run.
+    normpath = posixpath.normpath(self.path)
+    dispatchers = {
+      '/edits': self.do_POST_edits,
+    }
+    try:
+      dispatcher = dispatchers[normpath]
+      dispatcher()
+      self.send_response(200)
+    except:
+      self.send_error(404)
+      raise
+
+  def do_POST_edits(self):
+    """ Handle a POST request with modifications to GM expectations, in this
+    format:
+
+    {
+      'oldResultsType': 'all',    # type of results that the client loaded
+                                  # and then made modifications to
+      'oldResultsHash': 39850913, # hash of results when the client loaded them
+                                  # (ensures that the client and server apply
+                                  # modifications to the same base)
+      'modifications': [
+        {
+          'builder': 'Test-Android-Nexus10-MaliT604-Arm7-Debug',
+          'test': 'strokerect',
+          'config': 'gpu',
+          'expectedHashType': 'bitmap-64bitMD5',
+          'expectedHashDigest': '1707359671708613629',
+        },
+        ...
+      ],
+    }
+
+    Raises an Exception if there were any problems.
+    """
+    if not _SERVER.is_editable():
+      raise Exception('this server is not running in --editable mode')
+
+    content_type = self.headers[_HTTP_HEADER_CONTENT_TYPE]
+    if content_type != 'application/json;charset=UTF-8':
+      raise Exception('unsupported %s [%s]' % (
+          _HTTP_HEADER_CONTENT_TYPE, content_type))
+
+    content_length = int(self.headers[_HTTP_HEADER_CONTENT_LENGTH])
+    json_data = self.rfile.read(content_length)
+    data = json.loads(json_data)
+    logging.debug('do_POST_edits: received new GM expectations data [%s]' %
+                  data)
+
+    with _SERVER.results_lock:
+      oldResultsType = data['oldResultsType']
+      oldResults = _SERVER.results.get_results_of_type(oldResultsType)
+      oldResultsHash = str(hash(repr(oldResults['testData'])))
+      if oldResultsHash != data['oldResultsHash']:
+        raise Exception('results of type "%s" changed while the client was '
+                        'making modifications. The client should reload the '
+                        'results and submit the modifications again.' %
+                        oldResultsType)
+      _SERVER.results.edit_expectations(data['modifications'])
+
+    # Now that the edits have been committed, update results to reflect them.
+    _SERVER.update_results()
+
   def redirect_to(self, url):
     """ Redirect the HTTP client to a different url.
 
@@ -318,8 +400,7 @@
                           'exist, it will be created. Defaults to %(default)s'),
                     default=DEFAULT_ACTUALS_DIR)
   parser.add_argument('--editable', action='store_true',
-                      help=('TODO(epoger): NOT YET IMPLEMENTED.  '
-                            'Allow HTTP clients to submit new baselines.'))
+                      help=('Allow HTTP clients to submit new baselines.'))
   parser.add_argument('--expectations-dir',
                     help=('Directory under which to find GM expectations; '
                           'defaults to %(default)s'),
diff --git a/gm/rebaseline_server/static/loader.js b/gm/rebaseline_server/static/loader.js
index 80f84c6..09b66d5 100644
--- a/gm/rebaseline_server/static/loader.js
+++ b/gm/rebaseline_server/static/loader.js
@@ -15,12 +15,14 @@
 Loader.filter(
   'removeHiddenItems',
   function() {
-    return function(unfilteredItems, hiddenResultTypes, hiddenConfigs) {
+    return function(unfilteredItems, hiddenResultTypes, hiddenConfigs,
+                    viewingTab) {
       var filteredItems = [];
       for (var i = 0; i < unfilteredItems.length; i++) {
         var item = unfilteredItems[i];
         if (!(true == hiddenResultTypes[item.resultType]) &&
-            !(true == hiddenConfigs[item.config])) {
+            !(true == hiddenConfigs[item.config]) &&
+            (viewingTab == item.tab)) {
           filteredItems.push(item);
         }
       }
@@ -45,10 +47,31 @@
         $scope.categories = data.categories;
         $scope.testData = data.testData;
         $scope.sortColumn = 'test';
-        $scope.showTodos = true;
+        $scope.showTodos = false;
 
+        // Create the list of tabs (lists into which the user can file each
+        // test).  This may vary, depending on isEditable.
+        $scope.tabs = [
+          'Unfiled', 'Hidden'
+        ];
+        if (data.header.isEditable) {
+          $scope.tabs = $scope.tabs.concat(
+              ['Pending Approval']);
+        }
+        $scope.defaultTab = $scope.tabs[0];
+        $scope.viewingTab = $scope.defaultTab;
+
+        // Track the number of results on each tab.
+        $scope.numResultsPerTab = {};
+        for (var i = 0; i < $scope.tabs.length; i++) {
+          $scope.numResultsPerTab[$scope.tabs[i]] = 0;
+        }
+        $scope.numResultsPerTab[$scope.defaultTab] = $scope.testData.length;
+
+        // Add index and tab fields to all records.
         for (var i = 0; i < $scope.testData.length; i++) {
           $scope.testData[i].index = i;
+          $scope.testData[i].tab = $scope.defaultTab;
         }
 
         $scope.hiddenResultTypes = {
@@ -57,7 +80,7 @@
           'succeeded': true,
         };
         $scope.hiddenConfigs = {};
-        $scope.selectedItems = {};
+        $scope.selectedItems = [];
 
         $scope.updateResults();
         $scope.loadingMessage = "";
@@ -72,13 +95,14 @@
     );
 
     $scope.isItemSelected = function(index) {
-      return (true == $scope.selectedItems[index]);
+      return (-1 != $scope.selectedItems.indexOf(index));
     }
     $scope.toggleItemSelected = function(index) {
-      if (true == $scope.selectedItems[index]) {
-        delete $scope.selectedItems[index];
+      var i = $scope.selectedItems.indexOf(index);
+      if (-1 == i) {
+        $scope.selectedItems.push(index);
       } else {
-        $scope.selectedItems[index] = true;
+        $scope.selectedItems.splice(i, 1);
       }
       // unlike other toggle methods below, does not set
       // $scope.areUpdatesPending = true;
@@ -113,27 +137,77 @@
       $scope.areUpdatesPending = true;
     }
 
+    $scope.setViewingTab = function(tab) {
+      $scope.viewingTab = tab;
+      $scope.updateResults();
+    }
+
     $scope.localTimeString = function(secondsPastEpoch) {
       var d = new Date(secondsPastEpoch * 1000);
       return d.toString();
     }
 
+    /**
+     * Move the items in $scope.selectedItems to a different tab,
+     * and then clear $scope.selectedItems.
+     *
+     * @param newTab (string): name of the tab to move the tests to
+     */
+    $scope.moveSelectedItemsToTab = function(newTab) {
+      $scope.moveItemsToTab($scope.selectedItems, newTab);
+      $scope.selectedItems = [];
+      $scope.updateResults();
+    }
+
+    /**
+     * Move a subset of $scope.testData to a different tab.
+     *
+     * @param itemIndices (array of ints): indices into $scope.testData
+     *        indicating which test results to move
+     * @param newTab (string): name of the tab to move the tests to
+     */
+    $scope.moveItemsToTab = function(itemIndices, newTab) {
+      var itemIndex;
+      var numItems = itemIndices.length;
+      for (var i = 0; i < numItems; i++) {
+        itemIndex = itemIndices[i];
+        $scope.numResultsPerTab[$scope.testData[itemIndex].tab]--;
+        $scope.testData[itemIndex].tab = newTab;
+      }
+      $scope.numResultsPerTab[newTab] += numItems;
+    }
+
     $scope.updateResults = function() {
       $scope.displayLimit = $scope.displayLimitPending;
       // TODO(epoger): Every time we apply a filter, AngularJS creates
       // another copy of the array.  Is there a way we can filter out
       // the items as they are displayed, rather than storing multiple
       // array copies?  (For better performance.)
-      $scope.filteredTestData =
-          $filter("orderBy")(
-              $filter("removeHiddenItems")(
-                  $scope.testData,
-                  $scope.hiddenResultTypes,
-                  $scope.hiddenConfigs
-              ),
-              $scope.sortColumn);
-      $scope.limitedTestData = $filter("limitTo")(
-          $scope.filteredTestData, $scope.displayLimit);
+
+      if ($scope.viewingTab == $scope.defaultTab) {
+        $scope.filteredTestData =
+            $filter("orderBy")(
+                $filter("removeHiddenItems")(
+                    $scope.testData,
+                    $scope.hiddenResultTypes,
+                    $scope.hiddenConfigs,
+                    $scope.viewingTab
+                ),
+                $scope.sortColumn);
+        $scope.limitedTestData = $filter("limitTo")(
+            $scope.filteredTestData, $scope.displayLimit);
+      } else {
+        $scope.filteredTestData =
+            $filter("orderBy")(
+                $filter("filter")(
+                    $scope.testData,
+                    {tab: $scope.viewingTab},
+                    true
+                ),
+                $scope.sortColumn);
+        $scope.limitedTestData = $filter("limitTo")(
+            $scope.filteredTestData, $scope.displayLimit);
+      }
       $scope.imageSize = $scope.imageSizePending;
       $scope.areUpdatesPending = false;
     }
@@ -142,5 +216,55 @@
       $scope.sortColumn = sortColumn;
       $scope.updateResults();
     }
+
+    /**
+     * Tell the server that the actual results of these particular tests
+     * are acceptable.
+     *
+     * @param testDataSubset an array of test results, most likely a subset of
+     *        $scope.testData (perhaps with some modifications)
+     */
+    $scope.submitApprovals = function(testDataSubset) {
+      $scope.submitPending = true;
+      var newResults = [];
+      for (var i = 0; i < testDataSubset.length; i++) {
+        var actualResult = testDataSubset[i];
+        var expectedResult = {
+          builder: actualResult['builder'],
+          test: actualResult['test'],
+          config: actualResult['config'],
+          expectedHashType: actualResult['actualHashType'],
+          expectedHashDigest: actualResult['actualHashDigest'],
+        };
+        newResults.push(expectedResult);
+      }
+      $http({
+        method: "POST",
+        url: "/edits",
+        data: {
+          oldResultsType: $scope.header.type,
+          oldResultsHash: $scope.header.dataHash,
+          modifications: newResults
+        }
+      }).success(function(data, status, headers, config) {
+        var itemIndicesToMove = [];
+        for (var i = 0; i < testDataSubset.length; i++) {
+          itemIndicesToMove.push(testDataSubset[i].index);
+        }
+        $scope.moveItemsToTab(itemIndicesToMove,
+                              "HackToMakeSureThisItemDisappears");
+        $scope.updateResults();
+        alert("New baselines submitted successfully!\n\n" +
+            "You still need to commit the updated expectations files on " +
+            "the server side to the Skia repo.\n\n" +
+            "Also: in order to see the complete updated data, or to submit " +
+            "more baselines, you will need to reload your client.");
+        $scope.submitPending = false;
+      }).error(function(data, status, headers, config) {
+        alert("There was an error submitting your baselines.\n\n" +
+            "Please see server-side log for details.");
+        $scope.submitPending = false;
+      });
+    }
   }
 );
diff --git a/gm/rebaseline_server/static/view.css b/gm/rebaseline_server/static/view.css
new file mode 100644
index 0000000..e4eeb0f
--- /dev/null
+++ b/gm/rebaseline_server/static/view.css
@@ -0,0 +1,6 @@
+.tab-true {
+    background-color: #ccccff;
+}
+.tab-false {
+    background-color: #8888ff;
+}
diff --git a/gm/rebaseline_server/static/view.html b/gm/rebaseline_server/static/view.html
index 9f0158d..9bb01fc 100644
--- a/gm/rebaseline_server/static/view.html
+++ b/gm/rebaseline_server/static/view.html
@@ -6,6 +6,7 @@
   <title ng-bind="windowTitle"></title>
   <script src="https://ajax.googleapis.com/ajax/libs/angularjs/1.1.5/angular.js"></script>
   <script src="loader.js"></script>
+  <link rel="stylesheet" href="view.css">
 </head>
 
 <body>
@@ -13,16 +14,69 @@
     {{loadingMessage}}
   </em>
 
-  <div ng-hide="!categories">
+  <div ng-hide="!categories"><!-- everything: hide until data is loaded -->
+
     <div ng-hide="!(header.isEditable && header.isExported)"
          style="background-color:#ffbb00">
       WARNING!  These results are editable and exported, so any user
       who can connect to this server over the network can modify them.
     </div>
+
+    <div style="background-color:#bbffbb"><!-- TODOs -->
+      <p>
+      TODO(epoger):
+      <input type="checkbox" name="showTodosCheckbox" value="true"
+             ng-checked="showTodos == true"
+             ng-click="showTodos = !showTodos">
+      show
+      <ul ng-hide="!showTodos">
+        <li>
+          If server was run with --reload flag, automatically check for
+          new results and tell the user when new results are available
+          (the user can reload the page if he wants to see them).
+        </li><li>
+          Add ability to filter builder and test names
+          (using a free-form text field, with partial string match)
+        </li><li>
+          Add more columns, such as pixel diffs, notes/bugs,
+          ignoreFailure boolean
+        </li><li>
+          Improve the column sorting, as per
+          <a href="http://jsfiddle.net/vojtajina/js64b/14/">
+            http://jsfiddle.net/vojtajina/js64b/14/
+          </a>
+        </li><li>
+          Right now, if you change which column is used to
+          sort the data, the column widths may fluctuate based on the
+          longest string <i>currently visible</i> within the top {{displayLimit}}
+          results.  Can we fix the column widths to be wide enough to hold
+          any result, even the currently hidden results?
+        </li>
+      </ul>
+    </div><!-- TODOs -->
+
     <div ng-hide="!(header.timeUpdated)">
       Results current as of {{localTimeString(header.timeUpdated)}}
     </div>
-  <table border="1">
+
+    <div style="font-size:20px"><!-- tabs -->
+      <div ng-repeat="tab in tabs"
+           style="display:inline-block">
+        <div class="tab-{{tab == viewingTab}}"
+             style="display:inline-block"
+             ng-click="setViewingTab(tab)">
+          &nbsp;{{tab}} ({{numResultsPerTab[tab]}})&nbsp;
+        </div>
+        <div style="display:inline-block">
+          &nbsp;
+        </div>
+      </div>
+    </div><!-- tabs -->
+
+    <div class="tab-true"><!-- display of current tab -->
+
+    <br>
+    <table ng-hide="viewingTab != defaultTab" border="1">
     <tr>
       <th colspan="2">
         Filters
@@ -80,49 +134,52 @@
     </tr>
   </table>
 
-    <p>
-      TODO(epoger):
-      <input type="checkbox" name="showTodosCheckbox" value="true"
-             ng-checked="showTodos == true"
-             ng-click="showTodos = !showTodos">
-      show
-      <ul ng-hide="!showTodos">
-        <li>
-          Implement editing of results (we have added the --editable
-          flag to the server, but it&#39;s not fully implemented yet).
-          <div ng-hide="!header.isEditable">
-            Currently selected items are: {{selectedItems}}
-          </div>
-        </li><li>
-          If server was run with --reload flag, automatically check for
-          new results and tell the user when new results are available
-          (the user can reload the page if he wants to see them).
-        </li><li>
-          Add ability to filter builder and test names
-          (using a free-form text field, with partial string match)
-        </li><li>
-          Add more columns, such as pixel diffs, notes/bugs,
-          ignoreFailure boolean
-        </li><li>
-          Improve the column sorting, as per
-          <a href="http://jsfiddle.net/vojtajina/js64b/14/">
-            http://jsfiddle.net/vojtajina/js64b/14/
-          </a>
-        </li><li>
-          Right now, if you change which column is used to
-          sort the data, the column widths may fluctuate based on the
-          longest string <i>currently visible</i> within the top {{displayLimit}}
-          results.  Can we fix the column widths to be wide enough to hold
-          any result, even the currently hidden results?
-        </li>
-      </ul>
       <p>
-      Found {{filteredTestData.length}} matches, and displaying the first
-      {{displayLimit}}: <br>
-      <!-- TODO(epoger): If (displayLimit <= filteredTestData.length),
-           modify this message to indicate that all results are shown. -->
-      (click on the column header radio buttons to re-sort by that column)
+
+      <div ng-hide="'Pending Approval' != viewingTab">
+        <div style="display:inline-block">
+          <button style="font-size:20px"
+                  ng-click="submitApprovals(filteredTestData)"
+                  ng-disabled="submitPending || (filteredTestData.length == 0)">
+            Update these {{filteredTestData.length}} expectations on the server
+          </button>
+        </div>
+        <div style="display:inline-block">
+          <div style="font-size:20px"
+               ng-hide="!submitPending">
+            Submitting, please wait...
+          </div>
+        </div>
+      </div>
+
+      <p>
+
+      <div>
+        <div style="float:left">
+          Found {{filteredTestData.length}} matches;
+          <span ng-hide="filteredTestData.length <= limitedTestData.length">
+            displaying the first {{limitedTestData.length}}
+          </span>
+          <span ng-hide="filteredTestData.length > limitedTestData.length">
+            displaying them all
+          </span>
+          <br>
+          (click on the column header radio buttons to re-sort by that column)
+        </div>
+        <div style="float:right">
+          <div ng-repeat="otherTab in tabs">
+            <button ng-click="moveSelectedItemsToTab(otherTab)"
+                    ng-disabled="selectedItems.length == 0"
+                    ng-hide="otherTab == viewingTab">
+              {{selectedItems.length}} move selected tests to {{otherTab}} tab
+            </button>
+          </div>
+        </div>
+        <div style="clear:both">
+        </div>
+      </div>
       <br>
+
       <table border="1">
         <tr>
           <th ng-repeat="categoryName in ['resultType', 'builder', 'test', 'config']">
@@ -149,7 +206,7 @@
                    ng-click="sortResultsBy('actualHashDigest')">
             actual image
           </th>
-          <th ng-hide="!header.isEditable">
+          <th>
             <!-- item-selection checkbox column -->
           </th>
         </tr>
@@ -168,7 +225,7 @@
               <img width="{{imageSize}}" src="http://chromium-skia-gm.commondatastorage.googleapis.com/gm/{{result.actualHashType}}/{{result.test}}/{{result.actualHashDigest}}.png"/>
             </a>
           </td>
-          <td ng-hide="!header.isEditable">
+          <td>
             <input type="checkbox"
                    name="rowSelect"
                    value="{{result.index}}"
@@ -176,7 +233,8 @@
                    ng-click="toggleItemSelected(result.index)">
         </tr>
       </table>
-  </div>
+  </div><!-- display of current tab -->
+  </div><!-- everything: hide until data is loaded -->
 
   <!-- TODO(epoger): Can we get the base URLs (commondatastorage and
        issues list) from