[autotest] AFE child jobs table on parent job.

Add a table to list all the child jobs of a parent job on Job View tab.
One can click on and navigate between child and parent.

BUG=chromium:379959
DEPLOY=afe,apache
TEST=ran afe, viewed a job, navigated between parent and child jobs

Change-Id: Id70c41c8f7cee40bd71a206e1f3e08a68efe054f
Reviewed-on: https://chromium-review.googlesource.com/202579
Reviewed-by: Simran Basi <[email protected]>
Commit-Queue: Jiaxi Luo <[email protected]>
Tested-by: Jiaxi Luo <[email protected]>
diff --git a/frontend/afe/doctests/001_rpc_test.txt b/frontend/afe/doctests/001_rpc_test.txt
index 8442802..57f4497 100644
--- a/frontend/afe/doctests/001_rpc_test.txt
+++ b/frontend/afe/doctests/001_rpc_test.txt
@@ -34,6 +34,12 @@
 >>> if drone_set:
 ...     _ = models.DroneSet.objects.create(name=drone_set)
 
+# mock up tko rpc_interface
+>>> from autotest_lib.client.common_lib.test_utils import mock
+>>> mock.mock_god().stub_function_to_return(rpc_interface.tko_rpc_interface,
+...                                         'get_status_counts',
+...                                         None)
+
 # basic interface test
 ######################
 
diff --git a/frontend/afe/rpc_interface.py b/frontend/afe/rpc_interface.py
index 7a376aa..bd68425 100644
--- a/frontend/afe/rpc_interface.py
+++ b/frontend/afe/rpc_interface.py
@@ -37,6 +37,7 @@
 from autotest_lib.frontend.afe import models, model_logic, model_attributes
 from autotest_lib.frontend.afe import control_file, rpc_utils
 from autotest_lib.frontend.afe import site_rpc_interface
+from autotest_lib.frontend.tko import rpc_interface as tko_rpc_interface
 from autotest_lib.server.cros.dynamic_suite import tools
 
 def get_parameterized_autoupdate_image_url(job):
@@ -711,15 +712,23 @@
 
 def get_jobs_summary(**filter_data):
     """\
-    Like get_jobs(), but adds a 'status_counts' field, which is a dictionary
-    mapping status strings to the number of hosts currently with that
-    status, i.e. {'Queued' : 4, 'Running' : 2}.
+    Like get_jobs(), but adds 'status_counts' and 'result_counts' field.
+
+    'status_counts' filed is a dictionary mapping status strings to the number
+    of hosts currently with that status, i.e. {'Queued' : 4, 'Running' : 2}.
+
+    'result_counts' field is piped to tko's rpc_interface and has the return
+    format specified under get_group_counts.
     """
     jobs = get_jobs(**filter_data)
     ids = [job['id'] for job in jobs]
     all_status_counts = models.Job.objects.get_status_counts(ids)
     for job in jobs:
         job['status_counts'] = all_status_counts[job['id']]
+        job['result_counts'] = tko_rpc_interface.get_status_counts(
+                ['afe_job_id', 'afe_job_id'],
+                header_groups=[['afe_job_id'], ['afe_job_id']],
+                **{'afe_job_id': job['id']})
     return rpc_utils.prepare_for_serialization(jobs)
 
 
diff --git a/frontend/afe/rpc_interface_unittest.py b/frontend/afe/rpc_interface_unittest.py
index 6ce76eb..89af961 100755
--- a/frontend/afe/rpc_interface_unittest.py
+++ b/frontend/afe/rpc_interface_unittest.py
@@ -1,7 +1,7 @@
 #pylint: disable-msg=C0111
 #!/usr/bin/python
 
-import datetime, unittest
+import datetime
 import common
 
 from autotest_lib.frontend import setup_django_environment
@@ -12,6 +12,8 @@
 from autotest_lib.client.common_lib import control_data
 from autotest_lib.client.common_lib import error
 from autotest_lib.client.common_lib import priorities
+from autotest_lib.client.common_lib.test_utils import mock
+from autotest_lib.client.common_lib.test_utils import unittest
 
 CLIENT = control_data.CONTROL_TYPE_NAMES.CLIENT
 SERVER = control_data.CONTROL_TYPE_NAMES.SERVER
@@ -23,9 +25,11 @@
                        frontend_test_utils.FrontendTestMixin):
     def setUp(self):
         self._frontend_common_setup()
+        self.god = mock.mock_god()
 
 
     def tearDown(self):
+        self.god.unstub_all()
         self._frontend_common_teardown()
 
 
@@ -138,6 +142,11 @@
         entries[2].aborted = True
         entries[2].save()
 
+        # Mock up tko_rpc_interface.get_status_counts.
+        self.god.stub_function_to_return(rpc_interface.tko_rpc_interface,
+                                         'get_status_counts',
+                                         None)
+
         job_summaries = rpc_interface.get_jobs_summary(id=job.id)
         self.assertEquals(len(job_summaries), 1)
         summary = job_summaries[0]
diff --git a/frontend/client/src/autotest/afe/JobDetailView.java b/frontend/client/src/autotest/afe/JobDetailView.java
index 7100822..71d6aef 100644
--- a/frontend/client/src/autotest/afe/JobDetailView.java
+++ b/frontend/client/src/autotest/afe/JobDetailView.java
@@ -7,6 +7,7 @@
 import autotest.common.table.DataTable;
 import autotest.common.table.DynamicTable;
 import autotest.common.table.ListFilter;
+import autotest.common.table.RpcDataSource;
 import autotest.common.table.SearchFilter;
 import autotest.common.table.SelectionManager;
 import autotest.common.table.SimpleFilter;
@@ -37,7 +38,7 @@
 
 import java.util.Set;
 
-public class JobDetailView extends DetailView implements TableWidgetFactory, TableActionsListener {
+public class JobDetailView extends DetailView implements TableWidgetFactory {
     private static final String[][] JOB_HOSTS_COLUMNS = {
         {DataTable.CLICKABLE_WIDGET_COLUMN, ""}, // selection checkbox
         {"hostname", "Host"}, {"full_status", "Status"},
@@ -45,9 +46,15 @@
         // columns for status log and debug log links
         {DataTable.CLICKABLE_WIDGET_COLUMN, ""}, {DataTable.CLICKABLE_WIDGET_COLUMN, ""}
     };
+    private static final String[][] CHILD_JOBS_COLUMNS = {
+        { "id", "ID" }, { "name", "Name" }, { "priority", "Priority" },
+        { "control_type", "Client/Server" }, { JobTable.HOSTS_SUMMARY, "Status" },
+        { JobTable.RESULTS_SUMMARY, "Passed Tests" }
+    };
     public static final String NO_URL = "about:blank";
     public static final int NO_JOB_ID = -1;
     public static final int HOSTS_PER_PAGE = 30;
+    public static final int CHILD_JOBS_PER_PAGE = 30;
     public static final String RESULTS_MAX_WIDTH = "700px";
     public static final String RESULTS_MAX_HEIGHT = "500px";
 
@@ -57,11 +64,20 @@
         public void onCreateRecurringJob(int id);
     }
 
+    protected class ChildJobsListener {
+        public void onJobSelected(int id) {
+            fetchById(Integer.toString(id));
+        }
+    }
+
     protected int jobId = NO_JOB_ID;
 
     private JobStatusDataSource jobStatusDataSource = new JobStatusDataSource();
+    protected JobTable childJobsTable = new JobTable(CHILD_JOBS_COLUMNS);
+    protected TableDecorator childJobsTableDecorator = new TableDecorator(childJobsTable);
+    protected SimpleFilter parentJobIdFliter = new SimpleFilter();
     protected DynamicTable hostsTable = new DynamicTable(JOB_HOSTS_COLUMNS, jobStatusDataSource);
-    protected TableDecorator tableDecorator = new TableDecorator(hostsTable);
+    protected TableDecorator hostsTableDecorator = new TableDecorator(hostsTable);
     protected SimpleFilter jobFilter = new SimpleFilter();
     protected Button abortButton = new Button("Abort job");
     protected Button cloneButton = new Button("Clone job");
@@ -69,7 +85,9 @@
     protected Frame tkoResultsFrame = new Frame();
 
     protected JobDetailListener listener;
-    private SelectionManager selectionManager;
+    protected ChildJobsListener childJobsListener = new ChildJobsListener();
+    private SelectionManager hostsSelectionManager;
+    private SelectionManager childJobsSelectionManager;
 
     private Label controlFile = new Label();
     private DisclosurePanel controlFilePanel = new DisclosurePanel("");
@@ -172,6 +190,9 @@
 
                 jobFilter.setParameter("job", new JSONNumber(jobId));
                 hostsTable.refresh();
+
+                parentJobIdFliter.setParameter("parent_job", new JSONNumber(jobId));
+                childJobsTable.refresh();
             }
 
 
@@ -202,6 +223,22 @@
 
         idInput.setVisibleLength(5);
 
+        childJobsTable.setRowsPerPage(CHILD_JOBS_PER_PAGE);
+        childJobsTable.setClickable(true);
+        childJobsTable.addListener(new DynamicTableListener() {
+            public void onRowClicked(int rowIndex, JSONObject row, boolean isRightClick) {
+                int jobId = (int) row.get("id").isNumber().doubleValue();
+                childJobsListener.onJobSelected(jobId);
+            }
+
+            public void onTableRefreshed() {}
+        });
+
+        childJobsTableDecorator.addPaginators();
+        childJobsSelectionManager = childJobsTableDecorator.addSelectionManager(false);
+        childJobsTable.setWidgetFactory(childJobsSelectionManager);
+        addWidget(childJobsTableDecorator, "child_jobs_table");
+
         hostsTable.setRowsPerPage(HOSTS_PER_PAGE);
         hostsTable.setClickable(true);
         hostsTable.addListener(new DynamicTableListener() {
@@ -215,11 +252,29 @@
         });
         hostsTable.setWidgetFactory(this);
 
-        tableDecorator.addPaginators();
+        hostsTableDecorator.addPaginators();
         addTableFilters();
-        selectionManager = tableDecorator.addSelectionManager(false);
-        tableDecorator.addTableActionsPanel(this, true);
-        addWidget(tableDecorator, "job_hosts_table");
+        hostsSelectionManager = hostsTableDecorator.addSelectionManager(false);
+        hostsTableDecorator.addTableActionsPanel(new TableActionsListener() {
+            public ContextMenu getActionMenu() {
+                ContextMenu menu = new ContextMenu();
+
+                menu.addItem("Abort hosts", new Command() {
+                    public void execute() {
+                        abortSelectedHosts();
+                    }
+                });
+
+                menu.addItem("Clone job on selected hosts", new Command() {
+                    public void execute() {
+                        cloneJobOnSelectedHosts();
+                    }
+                });
+
+                return menu;
+            }
+        }, true);
+        addWidget(hostsTableDecorator, "job_hosts_table");
 
         abortButton.addClickHandler(new ClickHandler() {
             public void onClick(ClickEvent event) {
@@ -256,6 +311,7 @@
 
     protected void addTableFilters() {
         hostsTable.addFilter(jobFilter);
+        childJobsTable.addFilter(parentJobIdFliter);
 
         SearchFilter hostnameFilter = new SearchFilter("host__hostname", true);
         ListFilter statusFilter = new ListFilter("status");
@@ -263,8 +319,8 @@
         JSONArray statuses = staticData.getData("job_statuses").isArray();
         statusFilter.setChoices(Utils.JSONtoStrings(statuses));
 
-        tableDecorator.addFilter("Hostname", hostnameFilter);
-        tableDecorator.addFilter("Status", statusFilter);
+        hostsTableDecorator.addFilter("Hostname", hostnameFilter);
+        hostsTableDecorator.addFilter("Status", statusFilter);
     }
 
     private void abortJob() {
@@ -278,7 +334,8 @@
     }
 
     private void abortSelectedHosts() {
-        AfeUtils.abortHostQueueEntries(selectionManager.getSelectedObjects(), new SimpleCallback() {
+        AfeUtils.abortHostQueueEntries(hostsSelectionManager.getSelectedObjects(),
+                                       new SimpleCallback() {
             public void doCallback(Object source) {
                 refresh();
             }
@@ -313,7 +370,7 @@
     }
 
     private void cloneJobOnSelectedHosts() {
-        Set<JSONObject> hostsQueueEntries = selectionManager.getSelectedObjects();
+        Set<JSONObject> hostsQueueEntries = hostsSelectionManager.getSelectedObjects();
         JSONArray queueEntryIds = new JSONArray();
         for (JSONObject queueEntry : hostsQueueEntries) {
           queueEntryIds.set(queueEntryIds.size(), queueEntry.get("id"));
@@ -453,7 +510,7 @@
 
     public Widget createWidget(int row, int cell, JSONObject hostQueueEntry) {
         if (cell == 0) {
-            return selectionManager.createWidget(row, cell, hostQueueEntry);
+            return hostsSelectionManager.createWidget(row, cell, hostQueueEntry);
         }
 
         String executionSubdir = Utils.jsonToString(hostQueueEntry.get("execution_subdir"));
@@ -477,22 +534,4 @@
         url = Utils.getRetrieveLogsUrl(url);
         return "<a target=\"_blank\" href=\"" + url + "\">" + text + "</a>";
     }
-
-    public ContextMenu getActionMenu() {
-        ContextMenu menu = new ContextMenu();
-
-        menu.addItem("Abort hosts", new Command() {
-            public void execute() {
-                abortSelectedHosts();
-            }
-        });
-
-        menu.addItem("Clone job on selected hosts", new Command() {
-            public void execute() {
-                cloneJobOnSelectedHosts();
-            }
-        });
-
-        return menu;
-    }
 }
diff --git a/frontend/client/src/autotest/afe/JobTable.java b/frontend/client/src/autotest/afe/JobTable.java
index c311557..ce9137c 100644
--- a/frontend/client/src/autotest/afe/JobTable.java
+++ b/frontend/client/src/autotest/afe/JobTable.java
@@ -1,6 +1,7 @@
 package autotest.afe;
 
 import autotest.common.StaticDataRepository;
+import autotest.common.StatusSummary;
 import autotest.common.table.DynamicTable;
 import autotest.common.table.RpcDataSource;
 import autotest.common.table.DataSource.SortDirection;
@@ -15,25 +16,50 @@
  */
 public class JobTable extends DynamicTable {
     public static final String HOSTS_SUMMARY = "hosts_summary";
+    public static final String RESULTS_SUMMARY = "results_summary";
     public static final String CREATED_TEXT = "created_text";
 
     protected StaticDataRepository staticData = StaticDataRepository.getRepository();
 
-    private static final String[][] JOB_COLUMNS = { {CLICKABLE_WIDGET_COLUMN, "Select"}, 
-            { "id", "ID" }, { "owner", "Owner" }, { "name", "Name" },
-            { "priority", "Priority" }, { "control_type", "Client/Server" },
-            { CREATED_TEXT, "Created" }, { HOSTS_SUMMARY, "Status" } };
+    private static final String GROUP_COUNT_FIELD = "group_count";
+    private static final String PASS_COUNT_FIELD = "pass_count";
+    private static final String COMPLETE_COUNT_FIELD = "complete_count";
+    private static final String INCOMPLETE_COUNT_FIELD = "incomplete_count";
+    private static final String[][] DEFAULT_JOB_COLUMNS = {
+        {CLICKABLE_WIDGET_COLUMN, "Select"},
+        { "id", "ID" }, { "owner", "Owner" }, { "name", "Name" },
+        { "priority", "Priority" }, { "control_type", "Client/Server" },
+        { CREATED_TEXT, "Created" }, { HOSTS_SUMMARY, "Status" }
+    };
+
+    private static String[][] jobColumns;
 
     public JobTable() {
-        super(JOB_COLUMNS, new RpcDataSource("get_jobs_summary", "get_num_jobs"));
+        this(DEFAULT_JOB_COLUMNS);
+    }
+
+    public JobTable(String[][] jobColumns) {
+        super(jobColumns, new RpcDataSource("get_jobs_summary", "get_num_jobs"));
+        this.jobColumns = jobColumns;
         sortOnColumn("id", SortDirection.DESCENDING);
     }
 
     @Override
     protected void preprocessRow(JSONObject row) {
-        JSONObject counts = row.get("status_counts").isObject();
-        String countString = AfeUtils.formatStatusCounts(counts, "\n");
-        row.put(HOSTS_SUMMARY, new JSONString(countString));
+        JSONObject status_counts = row.get("status_counts").isObject();
+        String statusCountString = AfeUtils.formatStatusCounts(status_counts, "\n");
+        row.put(HOSTS_SUMMARY, new JSONString(statusCountString));
+
+        JSONArray result_counts = row.get("result_counts").isObject().get("groups").isArray();
+        if (result_counts.size() > 0) {
+            StatusSummary statusSummary = StatusSummary.getStatusSummary(
+                result_counts.get(0).isObject(), PASS_COUNT_FIELD,
+                COMPLETE_COUNT_FIELD, INCOMPLETE_COUNT_FIELD,
+                GROUP_COUNT_FIELD);
+            String resultCountString = statusSummary.formatContents();
+            row.put(RESULTS_SUMMARY, new JSONString(resultCountString));
+        }
+
         Double priorityValue = row.get("priority").isNumber().getValue();
         String priorityName = staticData.getPriorityName(priorityValue);
         row.put("priority", new JSONString(priorityName));
diff --git a/frontend/client/src/autotest/tko/StatusSummary.java b/frontend/client/src/autotest/common/StatusSummary.java
similarity index 72%
rename from frontend/client/src/autotest/tko/StatusSummary.java
rename to frontend/client/src/autotest/common/StatusSummary.java
index 5d93956..cedc6ad 100644
--- a/frontend/client/src/autotest/tko/StatusSummary.java
+++ b/frontend/client/src/autotest/common/StatusSummary.java
@@ -1,15 +1,12 @@
 // Copyright 2008 Google Inc. All Rights Reserved.
 
-package autotest.tko;
-
-import autotest.common.AbstractStatusSummary;
-import autotest.common.Utils;
+package autotest.common;
 
 import com.google.gwt.json.client.JSONObject;
 
 import java.util.Arrays;
 
-class StatusSummary extends AbstractStatusSummary {
+public class StatusSummary extends AbstractStatusSummary {
     public int passed = 0;
     public int complete = 0;
     public int incomplete = 0;
@@ -17,12 +14,14 @@
 
     private String[] contents = null;
 
-    public static StatusSummary getStatusSummary(JSONObject group) {
+    public static StatusSummary getStatusSummary(JSONObject group, String passCountField,
+                                                String completeCountField, String incompleteCountField,
+                                                String groupCountField) {
         StatusSummary summary = new StatusSummary();
-        summary.passed = getField(group, TestGroupDataSource.PASS_COUNT_FIELD);
-        summary.complete = getField(group, TestGroupDataSource.COMPLETE_COUNT_FIELD);
-        summary.incomplete = getField(group, TestGroupDataSource.INCOMPLETE_COUNT_FIELD);
-        summary.total = getField(group, TestGroupDataSource.GROUP_COUNT_FIELD);
+        summary.passed = getField(group, passCountField);
+        summary.complete = getField(group, completeCountField);
+        summary.incomplete = getField(group, incompleteCountField);
+        summary.total = getField(group, groupCountField);
 
         if (group.containsKey("extra_info")) {
             summary.contents = Utils.JSONtoStrings(group.get("extra_info").isArray());
diff --git a/frontend/client/src/autotest/public/AfeClient.html b/frontend/client/src/autotest/public/AfeClient.html
index f74125c..a13cc5d 100644
--- a/frontend/client/src/autotest/public/AfeClient.html
+++ b/frontend/client/src/autotest/public/AfeClient.html
@@ -89,7 +89,6 @@
           <span id="view_synch_count"></span><br>
           <span class="field-name">Status:</span>
           <span id="view_status"></span><br>
-
           <div id="view_control_file"></div><br>
           <span class="field-name">
             Full results
@@ -103,6 +102,9 @@
 
           <span class="field-name">Hosts</span>
           <div id="job_hosts_table"></div>
+
+          <span class="field-name">Child jobs</span>
+          <div id="child_jobs_table"></div><br>
         </div>
       </div>
 
diff --git a/frontend/client/src/autotest/tko/SpreadsheetDataProcessor.java b/frontend/client/src/autotest/tko/SpreadsheetDataProcessor.java
index 66445f8..fc0b9fd 100644
--- a/frontend/client/src/autotest/tko/SpreadsheetDataProcessor.java
+++ b/frontend/client/src/autotest/tko/SpreadsheetDataProcessor.java
@@ -1,5 +1,6 @@
 package autotest.tko;
 
+import autotest.common.StatusSummary;
 import autotest.common.spreadsheet.Spreadsheet;
 import autotest.common.spreadsheet.Spreadsheet.CellInfo;
 import autotest.common.spreadsheet.Spreadsheet.Header;
@@ -131,7 +132,12 @@
         int row = (int) headerIndices.get(0).isNumber().doubleValue();
         int column = (int) headerIndices.get(1).isNumber().doubleValue();
         CellInfo cellInfo = spreadsheet.getCellInfo(row, column);
-        StatusSummary statusSummary = StatusSummary.getStatusSummary(group);
+        StatusSummary statusSummary = StatusSummary.getStatusSummary(
+            group,
+            TestGroupDataSource.PASS_COUNT_FIELD,
+            TestGroupDataSource.COMPLETE_COUNT_FIELD,
+            TestGroupDataSource.INCOMPLETE_COUNT_FIELD,
+            TestGroupDataSource.GROUP_COUNT_FIELD);
         numTotalTests += statusSummary.getTotal();
         cellInfo.contents = statusSummary.formatContents();
         cellInfo.cssClass = statusSummary.getCssClass();
diff --git a/frontend/client/src/autotest/tko/TableView.java b/frontend/client/src/autotest/tko/TableView.java
index c3d04b0..27d65bf 100644
--- a/frontend/client/src/autotest/tko/TableView.java
+++ b/frontend/client/src/autotest/tko/TableView.java
@@ -1,5 +1,6 @@
 package autotest.tko;
 
+import autotest.common.StatusSummary;
 import autotest.common.Utils;
 import autotest.common.CustomHistory.HistoryToken;
 import autotest.common.table.DataTable;
@@ -537,7 +538,12 @@
 
     public Widget createWidget(int row, int cell, JSONObject rowObject) {
         assert getActiveGrouping() == GroupingType.STATUS_COUNTS;
-        StatusSummary statusSummary = StatusSummary.getStatusSummary(rowObject);
+        StatusSummary statusSummary = StatusSummary.getStatusSummary(
+            rowObject,
+            TestGroupDataSource.PASS_COUNT_FIELD,
+            TestGroupDataSource.COMPLETE_COUNT_FIELD,
+            TestGroupDataSource.INCOMPLETE_COUNT_FIELD,
+            TestGroupDataSource.GROUP_COUNT_FIELD);
         SimplePanel panel = new SimplePanel();
         panel.add(new HTML(statusSummary.formatContents()));
         panel.getElement().addClassName(statusSummary.getCssClass());
diff --git a/frontend/tko/rpc_interface_unittest_fixme.py b/frontend/tko/rpc_interface_unittest_fixme.py
index 6649a63..cb65d88 100755
--- a/frontend/tko/rpc_interface_unittest_fixme.py
+++ b/frontend/tko/rpc_interface_unittest_fixme.py
@@ -20,6 +20,8 @@
         tko_tests.status AS status_idx,
         tko_tests.reason AS reason,
         tko_tests.machine_idx AS machine_idx,
+        tko_tests.invalid AS invalid,
+        tko_tests.invalidates_test_idx AS invalidates_test_idx,
         tko_tests.started_time AS test_started_time,
         tko_tests.finished_time AS test_finished_time,
         tko_jobs.tag AS job_tag,