Perf dashboard should have UI to retry A/B testing
authorrniwa@webkit.org <rniwa@webkit.org@268f45cc-cd09-0410-ab3c-d52691b4dbfc>
Thu, 11 Feb 2016 22:17:55 +0000 (22:17 +0000)
committerrniwa@webkit.org <rniwa@webkit.org@268f45cc-cd09-0410-ab3c-d52691b4dbfc>
Thu, 11 Feb 2016 22:17:55 +0000 (22:17 +0000)
https://bugs.webkit.org/show_bug.cgi?id=154090

Reviewed by Chris Dumez.

Added a button to re-try an existing A/B testing group with a custom repetition count. The same button functions
as a way of confirming the progression/regression when there have been no A/B testing scheduled in the task.

Also fixed the bug that A/B testing groups that have been waiting for other test groups will be shown as "running".

* public/v3/components/results-table.js:
(ResultsTable.cssTemplate): Don't pad the list of extra repositories when it's empty.

* public/v3/components/test-group-results-table.js:
(TestGroupResultsTable.prototype.buildRowGroups): Use TestGroup.labelForRootSet instead of manually
computing the letter for each configuration set.

* public/v3/models/build-request.js:
(BuildRequest.prototype.hasStarted): Added.

* public/v3/models/data-model.js:
(DataModelObject.ensureSingleton): Added.
(DataModelObject.cachedFetch): Added noCache option. This is used when re-fetching the test groups after
creating one.

* public/v3/models/measurement-cluster.js:
(MeasurementCluster.prototype.startTime): Added.

* public/v3/models/measurement-set.js:
(MeasurementSet.prototype.hasFetchedRange): Added. Returns true only if there are no "holes" (cluster
yet to be fetched) between the specified time range. This was added to fix a bug in AnalysisTaskPage's
_didFetchMeasurement.

* public/v3/models/test-group.js:
(TestGroup): Added this._rootSetToLabel.
(TestGroup.prototype.addBuildRequest): Reset this._rootSetToLabel along with this._requestedRootSets.
(TestGroup.prototype.repetitionCount): Added. Returns the number of iterations executed per set. We assume that
every root set in the test group shares a single repetition count.
(TestGroup.prototype.requestedRootSets): Now populates this._rootSetToLabel for labelForRootSet.
(TestGroup.prototype.labelForRootSet): Added.
(TestGroup.prototype.hasStarted): Added.
(TestGroup.prototype.compareTestResults): Use 'running' and 'pending' to differentiate test groups that are waiting
for other groups to finish running from the ones that are actually running ('incomplete' before this patch).
(TestGroup.fetchByTask):
(TestGroup.createAndRefetchTestGroups): Added. Creates a new test group using the privileged-api/create-test-group
and fetches the list of test groups for the specified analysis task.
(TestGroup._createModelsFromFetchedTestGroups): Extracted from TestGroup.fetchByTask.

* public/v3/pages/analysis-task-page.js:
(AnalysisTaskPage): Initialize _renderedCurrentTestGroup to undefined so that we'd always can differentiate
the initial call to AnalysisTaskPage.render and subsequent calls in which it's identical to _currentTestGroup.
(AnalysisTaskPage.prototype._didFetchMeasurement): Fixed a bug that we don't exit early even when some
clusters in between startPoint and endPoint are still being fetched via newly added hasFetchedRange.
(AnalysisTaskPage.prototype.render): Update the default repetition count based on the current test group.
Also update the label of the button to "Confirm the change" if there is no A/B testing in this task.
(AnalysisTaskPage.prototype._retryCurrentTestGroup): Added. Re-triggers an existing A/B testing group or creates
the A/B testing for the entire range of the analysis task.
(AnalysisTaskPage.prototype._hasDuplicateTestGroupName): Added.
(AnalysisTaskPage.prototype._createRetryNameForTestGroup): Added.
(AnalysisTaskPage.htmlTemplate): Added form controls to re-trigger A/B testing.
(AnalysisTaskPage.cssTemplate): Updated the style.

git-svn-id: http://svn.webkit.org/repository/webkit/trunk@196440 268f45cc-cd09-0410-ab3c-d52691b4dbfc

Websites/perf.webkit.org/ChangeLog
Websites/perf.webkit.org/public/v3/components/results-table.js
Websites/perf.webkit.org/public/v3/components/test-group-results-table.js
Websites/perf.webkit.org/public/v3/models/build-request.js
Websites/perf.webkit.org/public/v3/models/data-model.js
Websites/perf.webkit.org/public/v3/models/measurement-cluster.js
Websites/perf.webkit.org/public/v3/models/measurement-set.js
Websites/perf.webkit.org/public/v3/models/test-group.js
Websites/perf.webkit.org/public/v3/pages/analysis-task-page.js

index 9aca8b0..bf2816e 100644 (file)
@@ -1,5 +1,69 @@
 2016-02-10  Ryosuke Niwa  <rniwa@webkit.org>
 
+        Perf dashboard should have UI to retry A/B testing
+        https://bugs.webkit.org/show_bug.cgi?id=154090
+
+        Reviewed by Chris Dumez.
+
+        Added a button to re-try an existing A/B testing group with a custom repetition count. The same button functions
+        as a way of confirming the progression/regression when there have been no A/B testing scheduled in the task.
+
+        Also fixed the bug that A/B testing groups that have been waiting for other test groups will be shown as "running".
+
+        * public/v3/components/results-table.js:
+        (ResultsTable.cssTemplate): Don't pad the list of extra repositories when it's empty.
+
+        * public/v3/components/test-group-results-table.js:
+        (TestGroupResultsTable.prototype.buildRowGroups): Use TestGroup.labelForRootSet instead of manually
+        computing the letter for each configuration set.
+
+        * public/v3/models/build-request.js:
+        (BuildRequest.prototype.hasStarted): Added.
+
+        * public/v3/models/data-model.js:
+        (DataModelObject.ensureSingleton): Added.
+        (DataModelObject.cachedFetch): Added noCache option. This is used when re-fetching the test groups after
+        creating one.
+
+        * public/v3/models/measurement-cluster.js:
+        (MeasurementCluster.prototype.startTime): Added.
+
+        * public/v3/models/measurement-set.js:
+        (MeasurementSet.prototype.hasFetchedRange): Added. Returns true only if there are no "holes" (cluster
+        yet to be fetched) between the specified time range. This was added to fix a bug in AnalysisTaskPage's
+        _didFetchMeasurement.
+
+        * public/v3/models/test-group.js:
+        (TestGroup): Added this._rootSetToLabel.
+        (TestGroup.prototype.addBuildRequest): Reset this._rootSetToLabel along with this._requestedRootSets. 
+        (TestGroup.prototype.repetitionCount): Added. Returns the number of iterations executed per set. We assume that
+        every root set in the test group shares a single repetition count.
+        (TestGroup.prototype.requestedRootSets): Now populates this._rootSetToLabel for labelForRootSet.
+        (TestGroup.prototype.labelForRootSet): Added.
+        (TestGroup.prototype.hasStarted): Added.
+        (TestGroup.prototype.compareTestResults): Use 'running' and 'pending' to differentiate test groups that are waiting
+        for other groups to finish running from the ones that are actually running ('incomplete' before this patch).
+        (TestGroup.fetchByTask):
+        (TestGroup.createAndRefetchTestGroups): Added. Creates a new test group using the privileged-api/create-test-group
+        and fetches the list of test groups for the specified analysis task.
+        (TestGroup._createModelsFromFetchedTestGroups): Extracted from TestGroup.fetchByTask.
+
+        * public/v3/pages/analysis-task-page.js:
+        (AnalysisTaskPage): Initialize _renderedCurrentTestGroup to undefined so that we'd always can differentiate
+        the initial call to AnalysisTaskPage.render and subsequent calls in which it's identical to _currentTestGroup.
+        (AnalysisTaskPage.prototype._didFetchMeasurement): Fixed a bug that we don't exit early even when some
+        clusters in between startPoint and endPoint are still being fetched via newly added hasFetchedRange.
+        (AnalysisTaskPage.prototype.render): Update the default repetition count based on the current test group.
+        Also update the label of the button to "Confirm the change" if there is no A/B testing in this task.
+        (AnalysisTaskPage.prototype._retryCurrentTestGroup): Added. Re-triggers an existing A/B testing group or creates
+        the A/B testing for the entire range of the analysis task.
+        (AnalysisTaskPage.prototype._hasDuplicateTestGroupName): Added.
+        (AnalysisTaskPage.prototype._createRetryNameForTestGroup): Added.
+        (AnalysisTaskPage.htmlTemplate): Added form controls to re-trigger A/B testing.
+        (AnalysisTaskPage.cssTemplate): Updated the style.
+
+2016-02-10  Ryosuke Niwa  <rniwa@webkit.org>
+
         Removed the duplicated definition of ChartPaneBase.
 
         * public/v3/components/chart-pane-base.js:
index 7ab183d..22a4dda 100644 (file)
@@ -226,6 +226,10 @@ class ResultsTable extends ComponentBase {
                 font-size: 0.8rem;
             }
 
+            .results-table-extra-repositories:empty {
+                padding: 0;
+            }
+
             .results-table-extra-repositories li {
                 display: inline;
             }
index 38bbb45..9f49220 100644 (file)
@@ -30,7 +30,7 @@ class TestGroupResultsTable extends ResultsTable {
             return [];
 
         var rootSets = this._testGroup.requestedRootSets();
-        var groups = rootSets.map(function (rootSet, setIndex) {
+        var groups = rootSets.map(function (rootSet) {
             var rows = [new ResultsTableRow('Mean', rootSet)];
             var results = [];
 
@@ -51,17 +51,17 @@ class TestGroupResultsTable extends ResultsTable {
             if (!isNaN(aggregatedResult.value))
                 rows[0].setResult(aggregatedResult);
 
-            return {heading: String.fromCharCode('A'.charCodeAt(0) + setIndex), rows:rows};
+            return {heading: testGroup.labelForRootSet(rootSet), rows:rows};
         });
 
         var comparisonRows = [];
         for (var i = 0; i < rootSets.length; i++) {
             for (var j = i + 1; j < rootSets.length; j++) {
-                var startConfig = String.fromCharCode('A'.charCodeAt(0) + i);
-                var endConfig = String.fromCharCode('A'.charCodeAt(0) + j);
+                var startConfig = testGroup.labelForRootSet(rootSets[i]);
+                var endConfig = testGroup.labelForRootSet(rootSets[j]);
 
                 var result = this._testGroup.compareTestResults(rootSets[i], rootSets[j]);
-                if (result.status == 'incomplete' || result.status == 'failed')
+                if (result.status == 'pending' || result.status == 'running' || result.status == 'failed')
                     continue;
 
                 var row = new ResultsTableRow(`${startConfig} to ${endConfig}`, null);
@@ -70,7 +70,7 @@ class TestGroupResultsTable extends ResultsTable {
             }
         }
 
-        groups.push({heading: '', rows: comparisonRows});
+        groups.unshift({heading: '', rows: comparisonRows});
 
         return groups;
     }
index 93973c1..52a8ede 100644 (file)
@@ -21,6 +21,7 @@ class BuildRequest extends DataModelObject {
     rootSet() { return this._rootSet; }
 
     hasCompleted() { return this._status == 'failed' || this._status == 'completed'; }
+    hasStarted() { return this._status != 'pending'; }
     statusLabel()
     {
         switch (this._status) {
index 9995a51..b5aa0e8 100644 (file)
@@ -7,6 +7,14 @@ class DataModelObject {
     }
     id() { return this._id; }
 
+    static ensureSingleton(id, object)
+    {
+        var singleton = this.findById(id);
+        if (singleton)
+            return singleton;
+        return new (this)(id, object);
+    }
+
     static namedStaticMap(name)
     {
         var staticMap = this[DataModelObject.StaticMapSymbol];
@@ -43,7 +51,7 @@ class DataModelObject {
         return list;
     }
 
-    static cachedFetch(path, params)
+    static cachedFetch(path, params, noCache)
     {
         var query = [];
         if (params) {
@@ -53,6 +61,9 @@ class DataModelObject {
         if (query.length)
             path += '?' + query.join('&');
 
+        if (noCache)
+            return getJSONWithStatus(path);
+
         var cacheMap = this.ensureNamedStaticMap(DataModelObject.CacheMapSymbol);
         if (!cacheMap[path])
             cacheMap[path] = getJSONWithStatus(path);
index 8bad89e..d438933 100644 (file)
@@ -7,6 +7,7 @@ class MeasurementCluster {
     }
 
     startTime() { return this._response['startTime']; }
+    endTime() { return this._response['endTime']; }
 
     addToSeries(series, configType, includeOutliers, idMap)
     {
index fb3cde9..d6c99cc 100644 (file)
@@ -191,6 +191,23 @@ class MeasurementSet {
         });
     }
 
+    hasFetchedRange(startTime, endTime)
+    {
+        console.assert(startTime < endTime);
+        var hasHole = false;
+        var previousEndTime = null;
+        for (var cluster of this._sortedClusters) {
+            if (cluster.startTime() < startTime && startTime < cluster.endTime())
+                hasHole = false;
+            if (previousEndTime !== null && previousEndTime != cluster.startTime())
+                hasHole = true;
+            if (cluster.startTime() < endTime && endTime < cluster.endTime())
+                break;
+            previousEndTime = cluster.endTime();
+        }
+        return !hasHole;
+    }
+
     fetchedTimeSeries(configType, includeOutliers, extendToFuture)
     {
         Instrumentation.startMeasuringTime('MeasurementSet', 'fetchedTimeSeries');
index fe87ed2..8c53586 100644 (file)
@@ -11,6 +11,7 @@ class TestGroup extends LabeledObject {
         this._requestsAreInOrder = false;
         this._repositories = null;
         this._requestedRootSets = null;
+        this._rootSetToLabel = new Map;
         this._allRootSets = null;
         console.assert(!object.platform || object.platform instanceof Platform);
         this._platform = object.platform;
@@ -23,6 +24,20 @@ class TestGroup extends LabeledObject {
         this._buildRequests.push(request);
         this._requestsAreInOrder = false;
         this._requestedRootSets = null;
+        this._rootSetToLabel = null;
+    }
+
+    repetitionCount()
+    {
+        if (!this._buildRequests.length)
+            return 0;
+        var rootSet = this._buildRequests[0].rootSet();
+        var count = 0;
+        for (var request of this._buildRequests) {
+            if (request.rootSet() == rootSet)
+                count++;
+        }
+        return count;
     }
 
     requestedRootSets()
@@ -36,6 +51,12 @@ class TestGroup extends LabeledObject {
                     this._requestedRootSets.push(set);
             }
             this._requestedRootSets.sort(function (a, b) { return a.latestCommitTime() - b.latestCommitTime(); });
+            var setIndex = 0;
+            for (var set of this._requestedRootSets) {
+                this._rootSetToLabel.set(set, String.fromCharCode('A'.charCodeAt(0) + setIndex));
+                setIndex++;
+            }
+
         }
         return this._requestedRootSets;
     }
@@ -46,6 +67,12 @@ class TestGroup extends LabeledObject {
         return this._buildRequests.filter(function (request) { return request.rootSet() == rootSet; });
     }
 
+    labelForRootSet(rootSet)
+    {
+        console.assert(this._requestedRootSets);
+        return this._rootSetToLabel.get(rootSet);
+    }
+
     _orderBuildRequests()
     {
         if (this._requestsAreInOrder)
@@ -64,6 +91,11 @@ class TestGroup extends LabeledObject {
         return this._buildRequests.every(function (request) { return request.hasCompleted(); });
     }
 
+    hasStarted()
+    {
+        return this._buildRequests.some(function (request) { return request.hasStarted(); });
+    }
+
     compareTestResults(rootSetA, rootSetB)
     {
         var beforeValues = this._valuesForRootSet(rootSetA);
@@ -85,9 +117,15 @@ class TestGroup extends LabeledObject {
         }
 
         if (!this.hasCompleted()) {
-            result.status = 'incomplete';
-            result.label = 'Running';
-            result.fullLabel = 'Running';
+            if (this.hasStarted()) {
+                result.status = 'running';
+                result.label = 'Running';
+                result.fullLabel = 'Running';
+            } else {
+                result.status = 'pending';
+                result.label = 'Pending';
+                result.fullLabel = 'Pending';
+            }
         } else if (result.changeType) {
             var significance = result.isStatisticallySignificant ? 'significant' : 'insignificant';
             result.fullLabel = `${result.label} (statistically ${significance})`;
@@ -107,32 +145,47 @@ class TestGroup extends LabeledObject {
         return values;
     }
 
-    static fetchByTask(taskId)
+    static createAndRefetchTestGroups(task, name, repetitionCount, rootSets)
     {
-        return this.cachedFetch('../api/test-groups', {task: taskId}).then(function (data) {
-            var testGroups = data['testGroups'].map(function (row) {
-                row.platform = Platform.findById(row.platform);
-                return new TestGroup(row.id, row);
-            });
-
-            var rootIdMap = {};
-            for (var root of data['roots'])
-                rootIdMap[root.id] = root;
-
-            var rootSets = data['rootSets'].map(function (row) {
-                row.roots = row.roots.map(function (rootId) { return rootIdMap[rootId]; });
-                row.testGroup = RootSet.findById(row.testGroup);
-                return new RootSet(row.id, row);
-            });
-
-            var buildRequests = data['buildRequests'].map(function (rawData) {
-                rawData.testGroup = TestGroup.findById(rawData.testGroup);
-                rawData.rootSet = RootSet.findById(rawData.rootSet);
-                return new BuildRequest(rawData.id, rawData);
-            });
-
-            return testGroups;
+        var self = this;
+        return PrivilegedAPI.sendRequest('create-test-group', {
+            task: task.id(),
+            name: name,
+            repetitionCount: repetitionCount,
+            rootSets: rootSets,
+        }).then(function (data) {
+            return self.cachedFetch('../api/test-groups', {task: task.id()}, true).then(self._createModelsFromFetchedTestGroups.bind(self));
         });
     }
 
+    static fetchByTask(taskId)
+    {
+        return this.cachedFetch('../api/test-groups', {task: taskId}).then(this._createModelsFromFetchedTestGroups.bind(this));
+    }
+
+    static _createModelsFromFetchedTestGroups(data)
+    {
+        var testGroups = data['testGroups'].map(function (row) {
+            row.platform = Platform.findById(row.platform);
+            return TestGroup.ensureSingleton(row.id, row);
+        });
+
+        var rootIdMap = {};
+        for (var root of data['roots'])
+            rootIdMap[root.id] = root;
+
+        var rootSets = data['rootSets'].map(function (row) {
+            row.roots = row.roots.map(function (rootId) { return rootIdMap[rootId]; });
+            row.testGroup = RootSet.findById(row.testGroup);
+            return RootSet.ensureSingleton(row.id, row);
+        });
+
+        var buildRequests = data['buildRequests'].map(function (rawData) {
+            rawData.testGroup = TestGroup.findById(rawData.testGroup);
+            rawData.rootSet = RootSet.findById(rawData.rootSet);
+            return BuildRequest.ensureSingleton(rawData.id, rawData);
+        });
+
+        return testGroups;
+    }
 }
index 6e9912b..ac5d5b2 100644 (file)
@@ -13,7 +13,7 @@ class AnalysisTaskPage extends PageWithHeading {
         this._task = null;
         this._testGroups = null;
         this._renderedTestGroups = null;
-        this._renderedCurrentTestGroup = null;
+        this._renderedCurrentTestGroup = undefined;
         this._analysisResults = null;
         this._measurementSet = null;
         this._startPoint = null;
@@ -24,6 +24,8 @@ class AnalysisTaskPage extends PageWithHeading {
         this._analysisResultsViewer = this.content().querySelector('analysis-results-viewer').component();
         this._analysisResultsViewer.setTestGroupCallback(this._showTestGroup.bind(this));
         this._testGroupResultsTable = this.content().querySelector('test-group-results-table').component();
+
+        this.content().querySelector('.test-group-retry-form').onsubmit = this._retryCurrentTestGroup.bind(this);
     }
 
     title() { return this._task ? this._task.label() : 'Analysis Task'; }
@@ -85,7 +87,7 @@ class AnalysisTaskPage extends PageWithHeading {
         var series = this._measurementSet.fetchedTimeSeries('current', false, false);
         var startPoint = series.findById(this._task.startMeasurementId());
         var endPoint = series.findById(this._task.endMeasurementId());
-        if (!startPoint || !endPoint)
+        if (!startPoint || !endPoint || !this._measurementSet.hasFetchedRange(startPoint.time, endPoint.time))
             return;
 
         this._analysisResultsViewer.setPoints(startPoint, endPoint);
@@ -139,7 +141,7 @@ class AnalysisTaskPage extends PageWithHeading {
 
         var v2URL = `/v2/#/analysis/task/${this._taskId}`;
         this.content().querySelector('.error-message').innerHTML +=
-            `<p>This page is read only for now. To schedule a new A/B testing job, use <a href="${v2URL}">v2 page</a>.</p>`;
+            `<p>To schedule a custom A/B testing, use <a href="${v2URL}">v2 UI</a>.</p>`;
 
          this._chartPane.render();
 
@@ -168,7 +170,8 @@ class AnalysisTaskPage extends PageWithHeading {
                 }));
             this._renderedCurrentTestGroup = null;
         }
-        if (this._renderedCurrentTestGroup != this._currentTestGroup) {
+
+        if (this._renderedCurrentTestGroup !== this._currentTestGroup) {
             if (this._renderedCurrentTestGroup) {
                 var element = this.content().querySelector('.test-group-list-' + this._renderedCurrentTestGroup.id());
                 if (element)
@@ -179,9 +182,18 @@ class AnalysisTaskPage extends PageWithHeading {
                 if (element)
                     element.classList.add('selected');
             }
+
+            this.content().querySelector('.test-group-retry-button').textContent = this._currentTestGroup ? 'Retry' : 'Confirm the change';
+
+            var repetitionCount = this._currentTestGroup ? this._currentTestGroup.repetitionCount() : 4;
+            var repetitionCountController = this.content().querySelector('.test-group-retry-repetition-count');
+            repetitionCountController.value = repetitionCount;
+
             this._renderedCurrentTestGroup = this._currentTestGroup;
         }
 
+        this.content().querySelector('.test-group-retry-button').disabled = !(this._currentTestGroup || this._startPoint);
+
         this._testGroupResultsTable.render();
 
         Instrumentation.endMeasuringTime('AnalysisTaskPage', 'render');
@@ -194,6 +206,86 @@ class AnalysisTaskPage extends PageWithHeading {
         this.render();
     }
 
+    _retryCurrentTestGroup(event)
+    {
+        event.preventDefault();
+        console.assert(this._currentTestGroup || this._startPoint);
+
+        var testGroupName;
+        var rootSetList;
+        var rootSetLabels;
+
+        if (this._currentTestGroup) {
+            var testGroup = this._currentTestGroup;
+            testGroupName = this._createRetryNameForTestGroup(testGroup.name());
+            rootSetList = testGroup.requestedRootSets();
+            rootSetLabels = rootSetList.map(function (rootSet) { return testGroup.labelForRootSet(rootSet); });
+        } else {
+            testGroupName = 'Confirming the change';
+            rootSetList = [this._startPoint.rootSet(), this._endPoint.rootSet()];
+            rootSetLabels = ['Point 0', `Point ${this._endPoint.seriesIndex - this._startPoint.seriesIndex}`];
+        }
+
+        var rootSetsByName = {};
+        for (var repository of rootSetList[0].repositories())
+            rootSetsByName[repository.name()] = [];
+
+        var setIndex = 0;
+        for (var rootSet of rootSetList) {
+            for (var repository of rootSet.repositories()) {
+                var list = rootSetsByName[repository.name()];
+                if (!list) {
+                    alert(`Set ${rootSetLabels[setIndex]} specifies ${repository.label()} but set ${rootSetLabels[0]} does not.`);
+                    return null;
+                }
+                list.push(rootSet.commitForRepository(repository).revision());
+            }
+            setIndex++;
+            for (var name in rootSetsByName) {
+                var list = rootSetsByName[name];
+                if (list.length < setIndex) {
+                    alert(`Set ${rootSetLabels[0]} specifies ${repository.label()} but set ${rootSetLabels[setIndex]} does not.`);
+                    return null;
+                }
+            }
+        }
+
+        var repetitionCount = this.content().querySelector('.test-group-retry-repetition-count').value;
+
+        TestGroup.createAndRefetchTestGroups(this._task, testGroupName, repetitionCount, rootSetsByName)
+            .then(this._didFetchTestGroups.bind(this), function (error) {
+            alert('Failed to create a new test group: ' + error);
+        });
+    }
+
+    _createRetryNameForTestGroup(name)
+    {
+        var nameWithNumberMatch = name.match(/(.+?)\s*\(\s*(\d+)\s*\)\s*$/);
+        var number = 1;
+        if (nameWithNumberMatch) {
+            name = nameWithNumberMatch[1];
+            number = parseInt(nameWithNumberMatch[2]);
+        }
+
+        var newName;
+        do {
+            number++;
+            newName = `${name} (${number})`;
+        } while (this._hasDuplicateTestGroupName(newName));
+
+        return newName;
+    }
+
+    _hasDuplicateTestGroupName(name)
+    {
+        console.assert(this._testGroups);
+        for (var group of this._testGroups) {
+            if (group.name() == name)
+                return true;
+        }
+        return false;
+    }
+
     static htmlTemplate()
     {
         return `
@@ -208,7 +300,26 @@ class AnalysisTaskPage extends PageWithHeading {
                 </section>
                 <section class="test-group-view">
                     <ul class="test-group-list"></ul>
-                    <div class="test-group-details"><test-group-results-table></test-group-results-table></div>
+                    <div class="test-group-details">
+                        <test-group-results-table></test-group-results-table>
+                        <form class="test-group-retry-form">
+                            <button class="test-group-retry-button" type="submit">Retry</button>
+                            with
+                            <select class="test-group-retry-repetition-count">
+                                <option>1</option>
+                                <option>2</option>
+                                <option>3</option>
+                                <option>4</option>
+                                <option>5</option>
+                                <option>6</option>
+                                <option>7</option>
+                                <option>8</option>
+                                <option>9</option>
+                                <option>10</option>
+                            </select>
+                            iterations per set
+                        </form>
+                    </div>
                 </section>
             </div>
         </div>
@@ -268,18 +379,23 @@ class AnalysisTaskPage extends PageWithHeading {
             .test-group-view {
                 display: table;
                 margin: 0 1rem;
+                margin-bottom: 2rem;
             }
 
             .test-group-details {
                 display: table-cell;
                 margin-bottom: 1rem;
+                padding: 0;
+                margin: 0;
             }
 
-            .test-group-list {
-                display: table-cell;
+            .test-group-retry-form {
+                padding: 0;
+                margin: 0.5rem;
             }
 
-            .test-group-list:not(:empty) {
+            .test-group-list {
+                display: table-cell;
                 margin: 0;
                 padding: 0.2rem 0;
                 list-style: none;
@@ -287,6 +403,12 @@ class AnalysisTaskPage extends PageWithHeading {
                 white-space: nowrap;
             }
 
+            .test-group-list:empty {
+                margin: 0;
+                padding: 0;
+                border-right: none;
+            }
+
             .test-group-list li {
                 display: block;
             }