← Back to team overview

launchpad-reviewers team mailing list archive

[Merge] lp:~maxiberta/launchpad/snap-update-build-status-ui into lp:launchpad

 

Maximiliano Bertacchini has proposed merging lp:~maxiberta/launchpad/snap-update-build-status-ui into lp:launchpad.

Commit message:
Update latest snap builds table via AJAX.

Requested reviews:
  Colin Watson (cjwatson)
  Launchpad code reviewers (launchpad-reviewers)

For more details, see:
https://code.launchpad.net/~maxiberta/launchpad/snap-update-build-status-ui/+merge/294403

Update latest snap builds table via AJAX.

Note: this is a WIP. Missing tests. See https://pastebin.canonical.com/156265/ for example of API usage.
-- 
Your team Launchpad code reviewers is requested to review the proposed merge of lp:~maxiberta/launchpad/snap-update-build-status-ui into lp:launchpad.
=== modified file 'lib/lp/buildmaster/model/buildqueue.py'
--- lib/lp/buildmaster/model/buildqueue.py	2015-07-08 16:05:11 +0000
+++ lib/lp/buildmaster/model/buildqueue.py	2016-05-14 00:27:07 +0000
@@ -260,7 +260,6 @@
     def preloadForBuildFarmJobs(self, builds):
         """See `IBuildQueueSet`."""
         from lp.buildmaster.model.builder import Builder
-        prefetched_data = dict()
         bqs = list(IStore(BuildQueue).find(
             BuildQueue,
             BuildQueue._build_farm_job_id.is_in(

=== modified file 'lib/lp/snappy/browser/snapbuild.py'
--- lib/lp/snappy/browser/snapbuild.py	2015-08-07 10:12:38 +0000
+++ lib/lp/snappy/browser/snapbuild.py	2016-05-14 00:27:07 +0000
@@ -16,7 +16,6 @@
     action,
     LaunchpadFormView,
     )
-from lp.buildmaster.enums import BuildQueueStatus
 from lp.services.librarian.browser import (
     FileNavigationMixin,
     ProxiedLibraryFileAlias,
@@ -70,39 +69,6 @@
     page_title = label
 
     @cachedproperty
-    def eta(self):
-        """The datetime when the build job is estimated to complete.
-
-        This is the BuildQueue.estimated_duration plus the
-        Job.date_started or BuildQueue.getEstimatedJobStartTime.
-        """
-        if self.context.buildqueue_record is None:
-            return None
-        queue_record = self.context.buildqueue_record
-        if queue_record.status == BuildQueueStatus.WAITING:
-            start_time = queue_record.getEstimatedJobStartTime()
-        else:
-            start_time = queue_record.date_started
-        if start_time is None:
-            return None
-        duration = queue_record.estimated_duration
-        return start_time + duration
-
-    @cachedproperty
-    def estimate(self):
-        """If true, the date value is an estimate."""
-        if self.context.date_finished is not None:
-            return False
-        return self.eta is not None
-
-    @cachedproperty
-    def date(self):
-        """The date when the build completed or is estimated to complete."""
-        if self.estimate:
-            return self.eta
-        return self.context.date_finished
-
-    @cachedproperty
     def files(self):
         """Return `LibraryFileAlias`es for files produced by this build."""
         if not self.context.was_built:

=== modified file 'lib/lp/snappy/browser/tests/test_snapbuild.py'
--- lib/lp/snappy/browser/tests/test_snapbuild.py	2015-08-07 10:12:38 +0000
+++ lib/lp/snappy/browser/tests/test_snapbuild.py	2016-05-14 00:27:07 +0000
@@ -81,25 +81,6 @@
         build_view = create_initialized_view(build, "+index")
         self.assertEqual([], build_view.files)
 
-    def test_eta(self):
-        # SnapBuildView.eta returns a non-None value when it should, or None
-        # when there's no start time.
-        build = self.factory.makeSnapBuild()
-        build.queueBuild()
-        self.assertIsNone(create_initialized_view(build, "+index").eta)
-        self.factory.makeBuilder(processors=[build.processor])
-        self.assertIsNotNone(create_initialized_view(build, "+index").eta)
-
-    def test_estimate(self):
-        # SnapBuildView.estimate returns True until the job is completed.
-        build = self.factory.makeSnapBuild()
-        build.queueBuild()
-        self.factory.makeBuilder(processors=[build.processor])
-        build.updateStatus(BuildStatus.BUILDING)
-        self.assertTrue(create_initialized_view(build, "+index").estimate)
-        build.updateStatus(BuildStatus.FULLYBUILT)
-        self.assertFalse(create_initialized_view(build, "+index").estimate)
-
 
 class TestSnapBuildOperations(BrowserTestCase):
 

=== modified file 'lib/lp/snappy/interfaces/snap.py'
--- lib/lp/snappy/interfaces/snap.py	2016-05-11 00:00:47 +0000
+++ lib/lp/snappy/interfaces/snap.py	2016-05-14 00:27:07 +0000
@@ -271,6 +271,21 @@
         :return: `ISnapBuild`.
         """
 
+    @operation_parameters(
+        snap_build_ids=List(
+            title=_("A list of snap build ids."),
+            value_type=Int()))
+    @export_read_operation()
+    @operation_for_version("devel")
+    def getBuildSummariesForSnapBuildIds(snap_build_ids):
+        """Return a dictionary containing a summary of the build statuses.
+
+        :param snap_build_ids: A list of snap build ids.
+        :type source_ids: ``list``
+        :return: A dict consisting of the overall status summaries for the
+            given snap builds.
+        """
+
     builds = exported(doNotSnapshot(CollectionField(
         title=_("All builds of this snap package."),
         description=_(

=== modified file 'lib/lp/snappy/interfaces/snapbuild.py'
--- lib/lp/snappy/interfaces/snapbuild.py	2016-05-06 16:34:21 +0000
+++ lib/lp/snappy/interfaces/snapbuild.py	2016-05-14 00:27:07 +0000
@@ -29,6 +29,7 @@
 from zope.schema import (
     Bool,
     Choice,
+    Datetime,
     Int,
     )
 
@@ -106,6 +107,17 @@
         required=True, readonly=True,
         description=_("Whether this build record can be cancelled.")))
 
+    eta = Datetime(
+        title=_("The datetime when the build job is estimated to complete."),
+        readonly=True)
+
+    estimate = Bool(
+        title=_("If true, the date value is an estimate."), readonly=True)
+
+    date = Datetime(
+        title=_("The date when the build completed or is estimated to "
+            "complete."), readonly=True)
+
     store_upload_jobs = CollectionField(
         title=_("Store upload jobs for this build."),
         # Really ISnapStoreUploadJob.

=== added file 'lib/lp/snappy/javascript/snap.update_build_statuses.js'
--- lib/lp/snappy/javascript/snap.update_build_statuses.js	1970-01-01 00:00:00 +0000
+++ lib/lp/snappy/javascript/snap.update_build_statuses.js	2016-05-14 00:27:07 +0000
@@ -0,0 +1,140 @@
+/* Copyright 2016 Canonical Ltd.  This software is licensed under the
+ * GNU Affero General Public License version 3 (see the file LICENSE).
+ *
+ * The lp.snappy.snap.update_build_statuses module uses the
+ * LP DynamicDomUpdater plugin for updating the latest builds table of a snap.
+ *
+ * @module Y.lp.snappy.snap.update_build_statuses
+ * @requires anim, node, lp.anim, lp.soyuz.dynamic_dom_updater
+ */
+YUI.add('lp.snappy.snap.update_build_statuses', function(Y) {
+    Y.log('loading lp.snappy.snap.update_build_statuses');
+    var module = Y.namespace('lp.snappy.snap.update_build_statuses');
+
+    module.pending_states = [
+        "NEEDSBUILD", "BUILDING", "UPLOADING", "CANCELLING"];
+
+    module.domUpdate = function(table, data_object) {
+        Y.each(data_object, function(build_summary, build_id) {
+            var ui_changed = false;
+
+            var tr_elem = Y.one("tr#build-" + build_id);
+            if (tr_elem === null) {
+                return;
+            }
+
+            var td_build_status = tr_elem.one("td.build_status");
+            var td_datebuilt = tr_elem.one("td.datebuilt");
+
+            if (td_build_status === null || td_datebuilt === null) {
+                return;
+            }
+
+            var link_node = td_build_status.one("a");
+            var img_node = td_build_status.one("img");
+
+            if (link_node === null || img_node === null) {
+                return;
+            }
+
+            if (!td_build_status.hasClass(build_summary.status)) {
+                ui_changed = true;
+                var new_src = null;
+                switch(build_summary.status) {
+                case 'BUILDING':
+                case 'UPLOADING':
+                    new_src = '/@@/processing';
+                    break;
+                case 'NEEDSBUILD':
+                    new_src = '/@@/build-needed';
+                    break;
+                case 'FAILEDTOBUILD':
+                    new_src = '/@@/build-failed';
+                    break;
+                case 'FULLYBUILT_PENDING':
+                    new_src = '/@@/build-success-publishing';
+                    break;
+                default:
+                    new_src = '/@@/build-success';
+                }
+
+                td_build_status.setAttribute("class", "build_status");
+                td_build_status.addClass(build_summary.status);
+                link_node.set("innerHTML", build_summary.buildstate);
+                img_node.setAttribute("src", new_src);
+                img_node.setAttribute("title", build_summary.buildstate);
+                img_node.setAttribute("alt", "[" + build_summary.status + "]");
+            }
+
+            if (build_summary.when_complete !== null) {
+                ui_changed = true;
+                td_datebuilt.set("innerHTML", build_summary.when_complete);
+                if (build_summary.when_complete_estimate) {
+                    td_datebuilt.appendChild(
+                        document.createTextNode(' (estimated)'));
+                }
+                if (build_summary.build_log_url !== null) {
+                    var new_link = Y.Node.create(
+                        '<a class="sprite download">buildlog</a>');
+                    new_link.setAttribute(
+                        'href', build_summary.build_log_url);
+                    td_datebuilt.appendChild(document.createTextNode(' '));
+                    td_datebuilt.appendChild(new_link);
+                    if (build_summary.build_log_size !== null) {
+                        td_datebuilt.appendChild(
+                            document.createTextNode(' '));
+                        td_datebuilt.append(
+                            "(" + build_summary.build_log_size + " bytes)");
+                    }
+                }
+            }
+
+            if (ui_changed) {
+                var anim = Y.lp.anim.green_flash({node: tr_elem});
+                anim.run();
+            }
+        });
+    };
+
+    module.parameterEvaluator = function(table_node) {
+        var td_list = table_node.all('td.build_status');
+        var pending = td_list.filter("." + module.pending_states.join(",."));
+        if (pending.size() === 0) {
+            return null;
+        }
+
+        var snap_build_ids = [];
+        Y.each(pending, function(node) {
+            var elem_id = node.ancestor().get('id');
+            var snap_build_id = elem_id.replace('build-', '');
+            snap_build_ids.push(snap_build_id);
+        });
+
+        return {snap_build_ids: snap_build_ids};
+    };
+
+    module.stopUpdatesCheck = function(table_node) {
+        // Stop updating when there aren't any builds to update
+        var td_list = table_node.all('td.build_status');
+        var pending = td_list.filter("." + module.pending_states.join(",."));
+        return (pending.size() === 0);
+    };
+
+    module.config = {
+        uri: null,
+        api_method_name: 'getBuildSummariesForSnapBuildIds',
+        lp_client: null,
+        domUpdateFunction: module.domUpdate,
+        parameterEvaluatorFunction: module.parameterEvaluator,
+        stopUpdatesCheckFunction: module.stopUpdatesCheck
+    };
+
+    module.setup = function(node, uri) {
+        module.config.uri = uri;
+        node.plug(Y.lp.soyuz.dynamic_dom_updater.DynamicDomUpdater,
+                  module.config);
+    };
+}, "0.1", {"requires":["anim",
+                       "node",
+                       "lp.anim",
+                       "lp.soyuz.dynamic_dom_updater"]});

=== added file 'lib/lp/snappy/javascript/tests/test_snap.update_build_statuses.html'
--- lib/lp/snappy/javascript/tests/test_snap.update_build_statuses.html	1970-01-01 00:00:00 +0000
+++ lib/lp/snappy/javascript/tests/test_snap.update_build_statuses.html	2016-05-14 00:27:07 +0000
@@ -0,0 +1,99 @@
+<!DOCTYPE html>
+<!--
+Copyright 2016 Canonical Ltd.  This software is licensed under the
+GNU Affero General Public License version 3 (see the file LICENSE).
+-->
+
+<html>
+  <head>
+      <title>lp.snappy.snap.update_build_statuses Tests</title>
+
+      <!-- YUI and test setup -->
+      <script type="text/javascript"
+              src="../../../../../build/js/yui/yui/yui.js">
+      </script>
+      <link rel="stylesheet"
+      href="../../../../../build/js/yui/console/assets/console-core.css" />
+      <link rel="stylesheet"
+      href="../../../../../build/js/yui/test-console/assets/skins/sam/test-console.css" />
+      <link rel="stylesheet"
+      href="../../../../../build/js/yui/test/assets/skins/sam/test.css" />
+
+      <script type="text/javascript"
+              src="../../../../../build/js/lp/app/testing/testrunner.js"></script>
+      <script type="text/javascript"
+              src="../../../../../build/js/lp/app/testing/helpers.js"></script>
+
+      <link rel="stylesheet" href="../../../app/javascript/testing/test.css" />
+
+      <!-- Dependencies -->
+      <script type="text/javascript"
+          src="../../../../../build/js/lp/app/client.js"></script>
+      <script type="text/javascript"
+          src="../../../../../build/js/lp/app/anim/anim.js"></script>
+      <script type="text/javascript"
+          src="../../../../../build/js/lp/app/extras/extras.js"></script>
+      <script type="text/javascript"
+          src="../../../../../build/js/lp/soyuz/lp_dynamic_dom_updater.js"></script>
+      <script type="text/javascript"
+          src="../../../../../build/js/lp/app/testing/assert.js"></script>
+
+      <!-- The module under test. -->
+      <script type="text/javascript" src="../snap.update_build_statuses.js"></script>
+
+      <!-- The test suite -->
+      <script type="text/javascript" src="test_snap.update_build_statuses.js"></script>
+
+    </head>
+    <body class="yui3-skin-sam">
+      <ul id="suites">
+        <li>lp.snappy.snap.update_build_statuses.test</li>
+      </ul>
+
+      <table id="latest-builds-listing" class="listing">
+        <thead>
+          <tr>
+            <th>Status</th>
+            <th>When complete</th>
+            <th>Architecture</th>
+            <th>Archive</th>
+          </tr>
+        </thead>
+        <tbody>
+            <tr id="build-1">
+              <td class="build_status NEEDSBUILD">
+                <img width="14" height="14" alt="[NEEDSBUILD]" title="Needs building" src="/@@/build-needed" />
+                <a href="snap/+build/1">Needs building</a>
+              </td>
+              <td class="datebuilt">
+                in 1 minute (estimated)
+              </td>
+              <td>
+                <a class="sprite distribution" href="/ubuntu/hoary/i386">i386</a>
+              </td>
+              <td>
+                <a href="/ubuntu" class="sprite distribution">Primary Archive for Ubuntu Linux</a>
+              </td>
+            </tr>
+            <tr id="build-2">
+              <td class="build_status FAILEDTOBUILD">
+                <img width="16" height="14" alt="[FAILEDTOBUILD]" title="Failed to build" src="/@@/build-failed" />
+                <a href="/~max/+snap/project3-snapo/+build/2">Failed to build</a>
+              </td>
+              <td class="datebuilt">
+                on 2016-05-10
+                <a class="sprite download" href="snap/+build/2/+files/build2.txt.gz">buildlog</a>
+                (23456 bytes)
+              </td>
+              <td>
+                <a class="sprite distribution" href="/ubuntu/hoary/i386">i386</a>
+              </td>
+              <td>
+                <a href="/ubuntu" class="sprite distribution">Primary Archive for Ubuntu Linux</a>
+              </td>
+            </tr>
+        </tbody>
+      </table>
+
+    </body>
+</html>

=== added file 'lib/lp/snappy/javascript/tests/test_snap.update_build_statuses.js'
--- lib/lp/snappy/javascript/tests/test_snap.update_build_statuses.js	1970-01-01 00:00:00 +0000
+++ lib/lp/snappy/javascript/tests/test_snap.update_build_statuses.js	2016-05-14 00:27:07 +0000
@@ -0,0 +1,116 @@
+/* Copyright 2016 Canonical Ltd.  This software is licensed under the
+ * GNU Affero General Public License version 3 (see the file LICENSE). */
+
+YUI.add('lp.snappy.snap.update_build_statuses.test', function (Y) {
+    var tests = Y.namespace('lp.snappy.snap.update_build_statuses.test');
+    var module = Y.lp.snappy.snap.update_build_statuses;
+    tests.suite = new Y.Test.Suite('lp.snappy.snap.update_build_status Tests');
+
+    tests.suite.add(new Y.Test.Case({
+        name: 'lp.snappy.snap.update_build_statuses_tests',
+
+        setUp: function () {
+            this.table = Y.one('table#latest-builds-listing');
+            this.tr_build_1 = Y.one('tr#build-1');
+            this.td_status = this.tr_build_1.one('td.build_status');
+            this.td_datebuilt = this.tr_build_1.one("td.datebuilt");
+            this.td_status_class = this.td_status.getAttribute("class");
+            this.td_status_img = this.td_status.one("img");
+            this.td_status_a = this.td_status.one("a");
+        },
+
+        test_dom_updater_plugin_attached: function() {
+            Y.Assert.isUndefined(this.table._plugins.updater);
+            module.setup(this.table);
+            updater = Y.lp.soyuz.dynamic_dom_updater.DynamicDomUpdater;
+            Y.Assert.areEqual(updater, this.table._plugins.updater);
+            // Unplug plugin to prevent DOM autorefresh during testing.
+            // DOM autorefresh should be tested in DynamicDomUpdater testsuite.
+            this.table.unplug(updater);
+            Y.Assert.isUndefined(this.table._plugins.updater);
+        },
+
+        test_parameter_evaluator: function() {
+            // parameterEvaluator should return an object with the ids of
+            // builds in pending states.
+            params = module.parameterEvaluator(this.table);
+            Y.lp.testing.assert.assert_equal_structure(
+                {snap_build_ids: ["1"]}, params);
+        },
+
+        test_parameter_evaluator_empty: function() {
+            // parameterEvaluator should return empty if no builds remaining
+            // in pending states.
+            this.td_status.setAttribute("class", "build_status FULLYBUILT");
+            params = module.parameterEvaluator(this.table);
+            Y.Assert.isNull(params);
+            // reset td class to the original value
+            this.td_status.setAttribute("class", this.td_status_class);
+        },
+
+        test_stop_updates_check: function() {
+            // stopUpdatesCheck should return false if pending builds exist.
+            Y.Assert.isFalse(module.stopUpdatesCheck(this.table));
+            // stopUpdatesCheck should return true if no pending builds exist.
+            this.td_status.setAttribute("class", "build_status FULLYBUILT");
+            Y.Assert.isTrue(module.stopUpdatesCheck(this.table));
+            for (i = 0; i < module.pending_states.length; i++) {
+                this.td_status.setAttribute(
+                    "class", "build_status " + module.pending_states[i]);
+                Y.Assert.isFalse(module.stopUpdatesCheck(this.table));
+            }
+            // reset td class to the original value
+            this.td_status.setAttribute("class", this.td_status_class);
+        },
+
+        test_update_build_status_dom: function() {
+            var original_a_href = this.td_status_a.get("href");
+            data = {"1": {
+                    "status": "BUILDING",
+                    "build_log_url": null,
+                    "when_complete_estimate": true,
+                    "buildstate": "Currently building",
+                    "build_log_size": null,
+                    "when_complete": "in 1 minute"
+                }};
+            module.domUpdate(this.table, data);
+            Y.Assert.areEqual(
+                "build_status BUILDING", this.td_status.getAttribute("class"));
+            Y.Assert.areEqual(
+                "Currently building", this.td_status.get("text").trim());
+            Y.Assert.areEqual("[BUILDING]", this.td_status_img.get("alt"));
+            Y.Assert.areEqual(
+                "Currently building", this.td_status_img.get("title"));
+            Y.Assert.areEqual(
+                "file:///@@/processing", this.td_status_img.get("src"));
+            Y.Assert.areEqual(original_a_href, this.td_status_a.get("href"));
+        },
+
+        test_update_build_date_dom: function() {
+            data = {"1": {
+                    "status": "NEEDSBUILD",
+                    "build_log_url": "/+build/1/+files/build1.txt.gz",
+                    "when_complete_estimate": true,
+                    "buildstate": "Needs building",
+                    "build_log_size": 12345,
+                    "when_complete": "in 30 seconds"
+                }};
+            module.domUpdate(this.table, data);
+            Y.Assert.areEqual(
+                "in 30 seconds (estimated) buildlog (12345 bytes)",
+                this.td_datebuilt.get("text").trim());
+            var td_datebuilt_a = this.td_datebuilt.one("a");
+            Y.Assert.isNotNull(td_datebuilt_a);
+            Y.Assert.areEqual("buildlog", td_datebuilt_a.get("text").trim());
+            Y.Assert.areEqual(
+                "sprite download", td_datebuilt_a.getAttribute("class"));
+            Y.Assert.areEqual(
+                "file://" + data["1"].build_log_url,
+                td_datebuilt_a.get("href"));
+        }
+    }));
+
+}, '0.1', {
+    requires: ['test', 'console', 'lp.testing.assert',
+               'lp.snappy.snap.update_build_statuses']
+});

=== modified file 'lib/lp/snappy/model/snap.py'
--- lib/lp/snappy/model/snap.py	2016-05-11 00:00:47 +0000
+++ lib/lp/snappy/model/snap.py	2016-05-14 00:27:07 +0000
@@ -27,10 +27,13 @@
 from zope.security.interfaces import Unauthorized
 from zope.security.proxy import removeSecurityProxy
 
+
+from lp.app.browser.tales import DateTimeFormatterAPI
 from lp.app.enums import PRIVATE_INFORMATION_TYPES
 from lp.app.interfaces.security import IAuthorization
 from lp.buildmaster.enums import BuildStatus
 from lp.buildmaster.interfaces.processor import IProcessorSet
+from lp.buildmaster.interfaces.buildqueue import IBuildQueueSet
 from lp.buildmaster.model.processor import Processor
 from lp.code.interfaces.branch import IBranch
 from lp.code.interfaces.branchcollection import (
@@ -338,6 +341,39 @@
         result.order_by(order_by)
         return result
 
+    def getBuildSummariesForSnapBuildIds(self, snap_build_ids):
+        """See `ISnap`."""
+        result = {}
+        if snap_build_ids is None:
+            return result
+        filter_term = SnapBuild.id.is_in(snap_build_ids)
+        order_by = Desc(SnapBuild.id)
+        builds = self._getBuilds(filter_term, order_by)
+
+        # Prefetch data to keep DB query count constant
+        getUtility(IBuildQueueSet).preloadForBuildFarmJobs(builds)
+
+        for build in builds:
+            if build.date is not None:
+                when_complete = DateTimeFormatterAPI(build.date).displaydate()
+            else:
+                when_complete = None
+
+            if build.log:
+                build_log_size = build.log.content.filesize
+            else:
+                build_log_size = None
+
+            result[build.id] = {}
+            result[build.id]["status"] = build.status.name
+            result[build.id]["buildstate"] = build.status
+            result[build.id]["when_complete"] = when_complete
+            result[build.id]["when_complete_estimate"] = build.estimate
+            result[build.id]["build_log_url"] = build.log_url
+            result[build.id]["build_log_size"] = build_log_size
+
+        return result
+
     @property
     def builds(self):
         """See `ISnap`."""
@@ -569,14 +605,8 @@
         """See `ISnapSet`."""
         snaps = [removeSecurityProxy(snap) for snap in snaps]
 
-        branch_ids = set()
-        git_repository_ids = set()
         person_ids = set()
         for snap in snaps:
-            if snap.branch_id is not None:
-                branch_ids.add(snap.branch_id)
-            if snap.git_repository_id is not None:
-                git_repository_ids.add(snap.git_repository_id)
             person_ids.add(snap.registrant_id)
             person_ids.add(snap.owner_id)
 

=== modified file 'lib/lp/snappy/model/snapbuild.py'
--- lib/lp/snappy/model/snapbuild.py	2016-05-06 16:34:21 +0000
+++ lib/lp/snappy/model/snapbuild.py	2016-05-14 00:27:07 +0000
@@ -29,6 +29,7 @@
 from lp.app.errors import NotFoundError
 from lp.buildmaster.enums import (
     BuildFarmJobType,
+    BuildQueueStatus,
     BuildStatus,
     )
 from lp.buildmaster.interfaces.buildfarmjob import IBuildFarmJobSource
@@ -352,6 +353,39 @@
         return [self.lfaUrl(lfa) for _, lfa, _ in self.getFiles()]
 
     @property
+    def eta(self):
+        """The datetime when the build job is estimated to complete.
+
+        This is the BuildQueue.estimated_duration plus the
+        Job.date_started or BuildQueue.getEstimatedJobStartTime.
+        """
+        if self.buildqueue_record is None:
+            return None
+        queue_record = self.buildqueue_record
+        if queue_record.status == BuildQueueStatus.WAITING:
+            start_time = queue_record.getEstimatedJobStartTime()
+        else:
+            start_time = queue_record.date_started
+        if start_time is None:
+            return None
+        duration = queue_record.estimated_duration
+        return start_time + duration
+
+    @property
+    def estimate(self):
+        """If true, the date value is an estimate."""
+        if self.date_finished is not None:
+            return False
+        return self.eta is not None
+
+    @property
+    def date(self):
+        """The date when the build completed or is estimated to complete."""
+        if self.estimate:
+            return self.eta
+        return self.date_finished
+
+    @property
     def store_upload_jobs(self):
         jobs = Store.of(self).find(
             SnapBuildJob,

=== modified file 'lib/lp/snappy/templates/snap-index.pt'
--- lib/lp/snappy/templates/snap-index.pt	2016-01-27 12:43:00 +0000
+++ lib/lp/snappy/templates/snap-index.pt	2016-05-14 00:27:07 +0000
@@ -8,6 +8,18 @@
 >
 
 <body>
+  <metal:block fill-slot="head_epilogue">
+    <script type="text/javascript" id="snap-update-build-statuses">
+      LPJS.use('lp.snappy.snap.update_build_statuses', function(Y) {
+        Y.on('domready', function(e) {
+          var table = Y.one('table#latest-builds-listing');
+          var uri = LP.cache.context.self_link;
+          Y.lp.snappy.snap.update_build_statuses.setup(table, uri);
+        });
+      });
+    </script>
+  </metal:block>
+
   <metal:registering fill-slot="registering">
     Created by
       <tal:registrant replace="structure context/registrant/fmt:link"/>
@@ -64,36 +76,34 @@
       </thead>
       <tbody>
         <tal:snap-builds repeat="build view/builds">
-          <tal:build-view define="buildview nocall:build/@@+index">
-            <tr tal:attributes="id string:build-${build/id}">
-              <td>
-                <span tal:replace="structure build/image:icon"/>
-                <a tal:content="build/status/title"
-                   tal:attributes="href build/fmt:url"/>
-              </td>
-              <td>
-                <tal:date replace="buildview/date/fmt:displaydate"/>
-                <tal:estimate condition="buildview/estimate">
-                  (estimated)
-                </tal:estimate>
+          <tr tal:attributes="id string:build-${build/id}">
+            <td tal:attributes="class string:build_status ${build/status/name}">
+              <span tal:replace="structure build/image:icon"/>
+              <a tal:content="build/status/title"
+                 tal:attributes="href build/fmt:url"/>
+            </td>
+            <td class="datebuilt">
+              <tal:date replace="build/date/fmt:displaydate"/>
+              <tal:estimate condition="build/estimate">
+                (estimated)
+              </tal:estimate>
 
-                <tal:build-log define="file build/log" tal:condition="file">
-                  <a class="sprite download"
-                     tal:attributes="href build/log_url">buildlog</a>
-                  (<span tal:replace="file/content/filesize/fmt:bytes"/>)
-                </tal:build-log>
-              </td>
-              <td>
-                <a class="sprite distribution"
-                   tal:define="archseries build/distro_arch_series"
-                   tal:attributes="href archseries/fmt:url"
-                   tal:content="archseries/architecturetag"/>
-              </td>
-              <td>
-                <tal:archive replace="structure build/archive/fmt:link"/>
-              </td>
-            </tr>
-          </tal:build-view>
+              <tal:build-log define="file build/log" tal:condition="file">
+                <a class="sprite download"
+                   tal:attributes="href build/log_url">buildlog</a>
+                (<span tal:replace="file/content/filesize/fmt:bytes"/>)
+              </tal:build-log>
+            </td>
+            <td>
+              <a class="sprite distribution"
+                 tal:define="archseries build/distro_arch_series"
+                 tal:attributes="href archseries/fmt:url"
+                 tal:content="archseries/architecturetag"/>
+            </td>
+            <td>
+              <tal:archive replace="structure build/archive/fmt:link"/>
+            </td>
+          </tr>
         </tal:snap-builds>
       </tbody>
     </table>

=== modified file 'lib/lp/snappy/templates/snapbuild-index.pt'
--- lib/lp/snappy/templates/snapbuild-index.pt	2015-08-07 10:12:38 +0000
+++ lib/lp/snappy/templates/snapbuild-index.pt	2016-05-14 00:27:07 +0000
@@ -134,7 +134,7 @@
         </li>
       </tal:started>
       <tal:finish condition="not: context/date_finished">
-        <li tal:define="eta view/eta" tal:condition="view/eta">
+        <li tal:define="eta context/eta" tal:condition="context/eta">
           Estimated finish <tal:eta replace="eta/fmt:approximatedate"/>
         </li>
       </tal:finish>

=== modified file 'lib/lp/snappy/tests/test_snap.py'
--- lib/lp/snappy/tests/test_snap.py	2016-05-06 09:45:45 +0000
+++ lib/lp/snappy/tests/test_snap.py	2016-05-14 00:27:07 +0000
@@ -64,6 +64,7 @@
     login,
     logout,
     person_logged_in,
+    record_two_runs,
     set_feature_flag,
     StormStatementRecorder,
     TestCaseWithFactory,
@@ -364,6 +365,70 @@
             snap.destroySelf()
         self.assertFalse(getUtility(ISnapSet).exists(owner, u"condemned"))
 
+    def test_getBuildSummariesForSnapBuildIds(self):
+        snap1 = self.factory.makeSnap()
+        snap2 = self.factory.makeSnap()
+        build11 = self.factory.makeSnapBuild(snap=snap1)
+        build12 = self.factory.makeSnapBuild(snap=snap1)
+        build2 = self.factory.makeSnapBuild(snap=snap2)
+        build3 = self.factory.makeSnapBuild()
+        summary1 = snap1.getBuildSummariesForSnapBuildIds(
+            [build11.id, build12.id])
+        summary2 = snap2.getBuildSummariesForSnapBuildIds([build2.id])
+        self.assertEqual([build11.id, build12.id], summary1.keys())
+        self.assertEqual([build2.id], summary2.keys())
+
+    def test_getBuildSummariesForSnapBuildIds_empty_input(self):
+        snap = self.factory.makeSnap()
+        self.factory.makeSnapBuild(snap=snap)
+        self.assertEqual({}, snap.getBuildSummariesForSnapBuildIds(None))
+        self.assertEqual({}, snap.getBuildSummariesForSnapBuildIds([]))
+        self.assertEqual({}, snap.getBuildSummariesForSnapBuildIds(()))
+        self.assertEqual({}, snap.getBuildSummariesForSnapBuildIds([None]))
+
+    def test_getBuildSummariesForSnapBuildIds_not_matching_snap(self):
+        # Should not return build summaries of other snaps.
+        snap1 = self.factory.makeSnap()
+        snap2 = self.factory.makeSnap()
+        build1 = self.factory.makeSnapBuild(snap=snap1)
+        build2 = self.factory.makeSnapBuild(snap=snap2)
+        summary1 = snap1.getBuildSummariesForSnapBuildIds([build2.id])
+        self.assertEqual({}, summary1)
+
+    def test_getBuildSummariesForSnapBuildIds_when_complete_field(self):
+        # Summary "when_complete" should be None unless estimate date or
+        # finish date is available.
+        snap = self.factory.makeSnap()
+        build = self.factory.makeSnapBuild(snap=snap)
+        self.assertIsNone(build.date)
+        summary = snap.getBuildSummariesForSnapBuildIds([build.id])
+        self.assertIsNone(summary[build.id]["when_complete"])
+        removeSecurityProxy(build).date_finished = UTC_NOW
+        summary = snap.getBuildSummariesForSnapBuildIds([build.id])
+        self.assertEqual("a moment ago", summary[build.id]["when_complete"])
+
+    def test_getBuildSummariesForSnapBuildIds_log_size_field(self):
+        # Summary "build_log_size" should be None unless the build has a log.
+        snap = self.factory.makeSnap()
+        build = self.factory.makeSnapBuild(snap=snap)
+        self.assertIsNone(build.log)
+        summary = snap.getBuildSummariesForSnapBuildIds([build.id])
+        self.assertIsNone(summary[build.id]["build_log_size"])
+        removeSecurityProxy(build).log = self.factory.makeLibraryFileAlias(
+            content='x' * 12345, db_only=True)
+        summary = snap.getBuildSummariesForSnapBuildIds([build.id])
+        self.assertEqual(12345, summary[build.id]["build_log_size"])
+
+    def test_getBuildSummariesForSnapBuildIds_query_count(self):
+        # DB query count should remain constant regardless of number of builds.
+        snap = self.factory.makeSnap()
+        recorder1, recorder2 = record_two_runs(
+            lambda: snap.getBuildSummariesForSnapBuildIds(
+                build.id for build in snap.builds),
+            lambda: self.factory.makeSnapBuild(snap=snap),
+            1, 5)
+        self.assertThat(recorder2, HasQueryCount.byEquality(recorder1))
+
 
 class TestSnapDeleteWithBuilds(TestCaseWithFactory):
 

=== modified file 'lib/lp/snappy/tests/test_snapbuild.py'
--- lib/lp/snappy/tests/test_snapbuild.py	2016-05-13 17:51:34 +0000
+++ lib/lp/snappy/tests/test_snapbuild.py	2016-05-14 00:27:07 +0000
@@ -336,6 +336,23 @@
                 self.build.id),
             self.build.log_url)
 
+    def test_eta(self):
+        # SnapBuild.eta returns a non-None value when it should, or None
+        # when there's no start time.
+        self.build.queueBuild()
+        self.assertIsNone(self.build.eta)
+        self.factory.makeBuilder(processors=[self.build.processor])
+        self.assertIsNotNone(self.build.eta)
+
+    def test_estimate(self):
+        # SnapBuild.estimate returns True until the job is completed.
+        self.build.queueBuild()
+        self.factory.makeBuilder(processors=[self.build.processor])
+        self.build.updateStatus(BuildStatus.BUILDING)
+        self.assertTrue(self.build.estimate)
+        self.build.updateStatus(BuildStatus.FULLYBUILT)
+        self.assertFalse(self.build.estimate)
+
 
 class TestSnapBuildSet(TestCaseWithFactory):
 


Follow ups