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)">
+ {{tab}} ({{numResultsPerTab[tab]}})
+ </div>
+ <div style="display:inline-block">
+
+ </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'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