← Back to team overview

launchpad-reviewers team mailing list archive

[Merge] lp:~wgrant/launchpad/webhook-deliveries-ui into lp:launchpad

 

William Grant has proposed merging lp:~wgrant/launchpad/webhook-deliveries-ui into lp:launchpad.

Commit message:
Add delivery management to Webhook:+index.

Requested reviews:
  Launchpad code reviewers (launchpad-reviewers)

For more details, see:
https://code.launchpad.net/~wgrant/launchpad/webhook-deliveries-ui/+merge/270516

This branch adds basic JavaScript-only delivery management to Webhook:+index. It uses the same listing_navigator as +bugs and +sharing to pull batches from LP, but renders the content using a custom widget with the ability to get details and retry failed deliveries.

The expanded view is rather sparse in this iteration. It will later contain expandable views of the payload and request and response headers, and update automatically when pending deliveries occur.

The main JS logic is reasonably tested, but some edge cases and the retry action need significant refactoring before they can be.
-- 
Your team Launchpad code reviewers is requested to review the proposed merge of lp:~wgrant/launchpad/webhook-deliveries-ui into lp:launchpad.
=== modified file 'Makefile'
--- Makefile	2015-08-21 13:09:09 +0000
+++ Makefile	2015-09-09 13:04:04 +0000
@@ -176,7 +176,8 @@
 
 $(LP_JS_BUILD): | $(JS_BUILD_DIR)
 	-mkdir $@
-	for jsdir in lib/lp/*/javascript; do \
+	-mkdir $@/services
+	for jsdir in lib/lp/*/javascript lib/lp/services/*/javascript; do \
 		app=$$(echo $$jsdir | sed -e 's,lib/lp/\(.*\)/javascript,\1,'); \
 		cp -a $$jsdir $@/$$app; \
 	done

=== modified file 'lib/canonical/launchpad/icing/style.css'
--- lib/canonical/launchpad/icing/style.css	2015-08-13 07:05:21 +0000
+++ lib/canonical/launchpad/icing/style.css	2015-09-09 13:04:04 +0000
@@ -871,6 +871,13 @@
     }
 
 
+/* === Webhooks === */
+
+table.listing tr.webhook-delivery:hover {
+    text-decoration: underline;
+    cursor: pointer;
+}
+
 
 /* ====== Content area styles ====== */
 

=== modified file 'lib/lp/app/javascript/date.js'
--- lib/lp/app/javascript/date.js	2014-07-08 15:25:12 +0000
+++ lib/lp/app/javascript/date.js	2015-09-09 13:04:04 +0000
@@ -8,7 +8,9 @@
 namespace.parse_date = function(str) {
     // Parse an ISO-8601 date
     var re = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})(?:\.(\d+))?(Z|\+00:00)$/;
-    var bits = re.exec(str).slice(1, 8).map(Number);
+    // Milliseconds may be missing and are boring, so only take year to
+    // seconds.
+    var bits = re.exec(str).slice(1, 7).map(Number);
     // Adjusting for the fact that Date.UTC uses 0-11 for months
     bits[1] -= 1;
     return new Date(Date.UTC.apply(null, bits));
@@ -19,11 +21,19 @@
     // day ago.
     var now = (new Date).valueOf();
     var timedelta = now - date;
+    var unit = "";
+    var prefix = "";
+    var suffix = "";
+    if (timedelta >= 0) {
+        suffix = " ago";
+    } else {
+        prefix = "in ";
+        timedelta = -timedelta;
+    }
     var days = timedelta / 86400000;
     var hours = timedelta / 3600000;
     var minutes = timedelta / 60000;
     var amount = 0;
-    var unit = "";
     if (days > 1) {
         return 'on ' + Y.Date.format(
             new Date(date), {format: '%Y-%m-%d'});
@@ -35,12 +45,12 @@
             amount = minutes;
             unit = "minute";
         } else {
-            return "a moment ago";
+            return prefix + "a moment" + suffix;
         }
         if (Math.floor(amount) > 1) {
             unit = unit + 's';
         }
-        return Math.floor(amount) + ' ' + unit + ' ago';
+        return prefix + Math.floor(amount) + ' ' + unit + suffix;
     }
 };
 }, "0.1", {'requires': ['datatype-date']});

=== modified file 'lib/lp/app/javascript/tests/test_date.js'
--- lib/lp/app/javascript/tests/test_date.js	2014-07-08 15:25:12 +0000
+++ lib/lp/app/javascript/tests/test_date.js	2015-09-09 13:04:04 +0000
@@ -12,24 +12,38 @@
                 'a moment ago',
                 Y.lp.app.date.approximatedate(new Date(now - 150)));
         },
-        
+
         test_return_minute_ago: function () {
             Y.Assert.areEqual(
                 '1 minute ago',
                 Y.lp.app.date.approximatedate(new Date(now - 65000)));
         },
-        
+
         test_return_hours_ago: function () {
             Y.Assert.areEqual(
                 '3 hours ago',
                 Y.lp.app.date.approximatedate(new Date(now - 12600000)));
         },
+
         test_return_days_ago: function () {
             Y.Assert.areEqual(
                 'on 2012-08-12', Y.lp.app.date.approximatedate(
                     Y.lp.app.date.parse_date(
                         '2012-08-12T10:00:00.00001+00:00')));
         },
+
+        test_return_in_moment: function () {
+            Y.Assert.areEqual(
+                'in a moment',
+                Y.lp.app.date.approximatedate(new Date(now + 15000)));
+        },
+
+        test_return_in_hours: function () {
+            Y.Assert.areEqual(
+                'in 3 hours',
+                Y.lp.app.date.approximatedate(new Date(now + 12600000)));
+        }
+
     }));
 
 }, '0.1', {

=== modified file 'lib/lp/bugs/browser/buglisting.py'
--- lib/lp/bugs/browser/buglisting.py	2015-07-09 20:06:17 +0000
+++ lib/lp/bugs/browser/buglisting.py	2015-09-09 13:04:04 +0000
@@ -140,7 +140,10 @@
     NavigationMenu,
     )
 from lp.services.webapp.authorization import check_permission
-from lp.services.webapp.batching import TableBatchNavigator
+from lp.services.webapp.batching import (
+    get_batch_properties_for_json_cache,
+    TableBatchNavigator,
+    )
 from lp.services.webapp.interfaces import ILaunchBag
 
 
@@ -1077,6 +1080,8 @@
             cache.objects['view_name'] = view_names.pop()
             batch_navigator = self.search()
             cache.objects['mustache_model'] = batch_navigator.model
+            cache.objects.update(
+                get_batch_properties_for_json_cache(self, batch_navigator))
             cache.objects['field_visibility'] = (
                 batch_navigator.field_visibility)
             cache.objects['field_visibility_defaults'] = (
@@ -1084,24 +1089,8 @@
             cache.objects['cbl_cookie_name'] = (
                 batch_navigator.getCookieName())
 
-            def _getBatchInfo(batch):
-                if batch is None:
-                    return None
-                return {'memo': batch.range_memo,
-                        'start': batch.startNumber() - 1}
-
-            next_batch = batch_navigator.batch.nextBatch()
-            cache.objects['next'] = _getBatchInfo(next_batch)
-            prev_batch = batch_navigator.batch.prevBatch()
-            cache.objects['prev'] = _getBatchInfo(prev_batch)
-            cache.objects['total'] = batch_navigator.batch.total()
             cache.objects['order_by'] = ','.join(
                 get_sortorder_from_request(self.request))
-            cache.objects['forwards'] = (
-                batch_navigator.batch.range_forwards)
-            last_batch = batch_navigator.batch.lastBatch()
-            cache.objects['last_start'] = last_batch.startNumber() - 1
-            cache.objects.update(_getBatchInfo(batch_navigator.batch))
             cache.objects['sort_keys'] = SORT_KEYS
 
     @property

=== modified file 'lib/lp/registry/browser/pillar.py'
--- lib/lp/registry/browser/pillar.py	2015-07-08 16:05:11 +0000
+++ lib/lp/registry/browser/pillar.py	2015-09-09 13:04:04 +0000
@@ -33,7 +33,6 @@
     )
 from zope.traversing.browser.absoluteurl import absoluteURL
 
-from lp.app.browser.launchpad import iter_view_registrations
 from lp.app.browser.lazrjs import vocabulary_to_choice_edit_items
 from lp.app.browser.tales import MenuAPI
 from lp.app.browser.vocabulary import vocabulary_filters
@@ -61,6 +60,7 @@
 from lp.services.webapp.authorization import check_permission
 from lp.services.webapp.batching import (
     BatchNavigator,
+    get_batch_properties_for_json_cache,
     StormRangeFactory,
     )
 from lp.services.webapp.breadcrumb import (
@@ -375,14 +375,11 @@
             self.specification_sharing_policies)
         cache.objects['has_edit_permission'] = check_permission(
             "launchpad.Edit", self.context)
-        view_names = set(reg.name for reg in
-                         iter_view_registrations(self.__class__))
-        if len(view_names) != 1:
-            raise AssertionError("Ambiguous view name.")
-        cache.objects['view_name'] = view_names.pop()
         batch_navigator = self.grantees()
         cache.objects['grantee_data'] = (
             self._getSharingService().jsonGranteeData(batch_navigator.batch))
+        cache.objects.update(
+            get_batch_properties_for_json_cache(self, batch_navigator))
 
         grant_counts = (
             self._getSharingService().getAccessPolicyGrantCounts(self.context))
@@ -390,22 +387,6 @@
             count_info[0].title for count_info in grant_counts
             if count_info[1] == 0]
 
-        def _getBatchInfo(batch):
-            if batch is None:
-                return None
-            return {'memo': batch.range_memo,
-                    'start': batch.startNumber() - 1}
-
-        next_batch = batch_navigator.batch.nextBatch()
-        cache.objects['next'] = _getBatchInfo(next_batch)
-        prev_batch = batch_navigator.batch.prevBatch()
-        cache.objects['prev'] = _getBatchInfo(prev_batch)
-        cache.objects['total'] = batch_navigator.batch.total()
-        cache.objects['forwards'] = batch_navigator.batch.range_forwards
-        last_batch = batch_navigator.batch.lastBatch()
-        cache.objects['last_start'] = last_batch.startNumber() - 1
-        cache.objects.update(_getBatchInfo(batch_navigator.batch))
-
 
 class PillarPersonSharingView(LaunchpadView):
 

=== modified file 'lib/lp/services/webapp/batching.py'
--- lib/lp/services/webapp/batching.py	2015-07-09 12:18:51 +0000
+++ lib/lp/services/webapp/batching.py	2015-09-09 13:04:04 +0000
@@ -34,6 +34,7 @@
     removeSecurityProxy,
     )
 
+from lp.app.browser.launchpad import iter_view_registrations
 from lp.services.config import config
 from lp.services.database.decoratedresultset import DecoratedResultSet
 from lp.services.database.interfaces import ISlaveStore
@@ -49,6 +50,33 @@
 from lp.services.webapp.publisher import LaunchpadView
 
 
+def get_batch_properties_for_json_cache(view, batchnav):
+    """Get values to insert into `IJSONRequestCache` for JS batchnavs."""
+    properties = {}
+    view_names = set(
+        reg.name for reg in iter_view_registrations(view.__class__))
+    if len(view_names) != 1:
+        raise AssertionError("Ambiguous view name.")
+    properties['view_name'] = view_names.pop()
+
+    def _getBatchInfo(batch):
+        if batch is None:
+            return None
+        return {'memo': batch.range_memo,
+                'start': batch.startNumber() - 1}
+
+    next_batch = batchnav.batch.nextBatch()
+    properties['next'] = _getBatchInfo(next_batch)
+    prev_batch = batchnav.batch.prevBatch()
+    properties['prev'] = _getBatchInfo(prev_batch)
+    properties['total'] = batchnav.batch.total()
+    properties['forwards'] = batchnav.batch.range_forwards
+    last_batch = batchnav.batch.lastBatch()
+    properties['last_start'] = last_batch.startNumber() - 1
+    properties.update(_getBatchInfo(batchnav.batch))
+    return properties
+
+
 @adapter(IResultSet)
 @implementer(IFiniteSequence)
 class FiniteSequenceAdapter:

=== modified file 'lib/lp/services/webhooks/browser.py'
--- lib/lp/services/webhooks/browser.py	2015-08-10 06:39:16 +0000
+++ lib/lp/services/webhooks/browser.py	2015-09-09 13:04:04 +0000
@@ -11,6 +11,7 @@
     ]
 
 from lazr.restful.interface import use_template
+from lazr.restful.interfaces import IJSONRequestCache
 from zope.component import getUtility
 from zope.interface import Interface
 
@@ -28,7 +29,11 @@
     Navigation,
     stepthrough,
     )
-from lp.services.webapp.batching import BatchNavigator
+from lp.services.webapp.batching import (
+    BatchNavigator,
+    get_batch_properties_for_json_cache,
+    StormRangeFactory,
+    )
 from lp.services.webapp.breadcrumb import Breadcrumb
 from lp.services.webhooks.interfaces import (
     IWebhook,
@@ -143,6 +148,19 @@
     schema = WebhookEditSchema
     custom_widget('event_types', LabeledMultiCheckBoxWidget)
 
+    def initialize(self):
+        super(WebhookView, self).initialize()
+        cache = IJSONRequestCache(self.request)
+        cache.objects['deliveries'] = list(self.deliveries.batch)
+        cache.objects.update(
+            get_batch_properties_for_json_cache(self, self.deliveries))
+
+    @cachedproperty
+    def deliveries(self):
+        return BatchNavigator(
+            self.context.deliveries, self.request, hide_counts=True,
+            range_factory=StormRangeFactory(self.context.deliveries))
+
     @property
     def next_url(self):
         # The edit form is the default view, so the URL doesn't need the

=== modified file 'lib/lp/services/webhooks/interfaces.py'
--- lib/lp/services/webhooks/interfaces.py	2015-08-10 08:09:09 +0000
+++ lib/lp/services/webhooks/interfaces.py	2015-09-09 13:04:04 +0000
@@ -258,6 +258,11 @@
     date_created = exported(Datetime(
         title=_("Date created"), required=True, readonly=True))
 
+    date_scheduled = exported(Datetime(
+        title=_("Date scheduled"),
+        description=_("Timestamp of the next delivery attempt."),
+        required=False, readonly=True))
+
     date_first_sent = exported(Datetime(
         title=_("Date first sent"),
         description=_("Timestamp of the first delivery attempt."),

=== added directory 'lib/lp/services/webhooks/javascript'
=== added file 'lib/lp/services/webhooks/javascript/deliveries.js'
--- lib/lp/services/webhooks/javascript/deliveries.js	1970-01-01 00:00:00 +0000
+++ lib/lp/services/webhooks/javascript/deliveries.js	2015-09-09 13:04:04 +0000
@@ -0,0 +1,297 @@
+/* Copyright 2015 Canonical Ltd.  This software is licensed under the
+ * GNU Affero General Public License version 3 (see the file LICENSE).
+ *
+ * Webhook delivery widgets.
+ *
+ * @module lp.services.webhooks.deliveries
+ */
+
+YUI.add("lp.services.webhooks.deliveries", function(Y) {
+
+var namespace = Y.namespace("lp.services.webhooks.deliveries");
+
+function WebhookDeliveriesListingNavigator(config) {
+    WebhookDeliveriesListingNavigator.superclass.constructor.apply(
+        this, arguments);
+}
+
+WebhookDeliveriesListingNavigator.NAME =
+    'webhook-deliveries-listing-navigator';
+
+WebhookDeliveriesListingNavigator.UPDATE_CONTENT = 'updateContent';
+
+Y.extend(WebhookDeliveriesListingNavigator,
+         Y.lp.app.listing_navigator.ListingNavigator, {
+
+    initializer: function(config) {
+        this.publish(
+            namespace.WebhookDeliveriesListingNavigator.UPDATE_CONTENT);
+    },
+
+    render_content: function() {
+        this.fire(
+            namespace.WebhookDeliveriesListingNavigator.UPDATE_CONTENT,
+            this.get_current_batch().deliveries);
+    },
+
+    _batch_size: function(batch) {
+        return batch.deliveries.length;
+    }
+
+});
+
+namespace.WebhookDeliveriesListingNavigator =
+    WebhookDeliveriesListingNavigator;
+
+function WebhookDeliveries(config) {
+    WebhookDeliveries.superclass.constructor.apply(this, arguments);
+}
+
+WebhookDeliveries.NAME = "webhook-deliveries";
+
+WebhookDeliveries.ATTRS = {
+
+    deliveries: {
+        value: [],
+        setter: function(deliveries) {
+            var self = this;
+            // Objects in LP.cache.deliveries are not wrapped on page
+            // load, but the objects we get from batch navigation are.
+            // Ensure we're always dealing with wrapped ones.
+            return Y.Array.map(deliveries, function(delivery) {
+                if (delivery.get === undefined) {
+                    return self.lp_client.wrap_resource(null, delivery);
+                } else {
+                    return delivery;
+                }
+            });
+        }
+    }
+
+};
+
+Y.extend(WebhookDeliveries, Y.Widget, {
+
+    initializer: function(config) {
+        this.lp_client = new Y.lp.client.Launchpad();
+        this.delivery_info = {};
+
+        var self = this;
+        this.after("deliveriesChange", function(e) {
+            // Populate the delivery_info map for any deliveries we
+            // haven't seen before, and refresh any that we have.
+            Y.Array.each(self.get("deliveries"), function(res) {
+                if (!Y.Object.owns(self.delivery_info, res.get("self_link"))) {
+                    self.delivery_info[res.get("self_link")] = {
+                        resource: res, expanded: false,
+                        requesting_retry: false};
+                } else {
+                    self.delivery_info[res.get("self_link")].resource = res;
+                }
+            });
+            // Update the list of delivery URLs to display.
+            self.deliveries_displayed = Y.Array.map(
+                self.get("deliveries"),
+                function(res) {return res.get("self_link");});
+            if (self.get("rendered")) {
+                self.syncUI();
+            }
+        });
+    },
+
+    bindUI: function() {
+        var table = this.get("contentBox").one("#webhook-deliveries-table");
+        table.delegate("click", this._toggle_detail.bind(this), "tbody > tr");
+        table.delegate(
+            "click", this._trigger_retry.bind(this), ".delivery-retry");
+    },
+
+    syncUI: function() {
+        var table = this.get("contentBox").one("#webhook-deliveries-table");
+        var new_tbody = Y.Node.create("<tbody></tbody>");
+        var self = this;
+        Y.Array.each(this.deliveries_displayed, function(delivery_url) {
+            var delivery = self.delivery_info[delivery_url];
+            var resource = delivery.resource;
+            var delivery_node = self._render_delivery(delivery);
+            new_tbody.append(delivery_node);
+            if (delivery.expanded) {
+                var detail_node = self._render_detail(delivery, delivery_node);
+                delivery_node.setData('delivery-detail-tr', detail_node);
+                new_tbody.append(detail_node);
+                var date_scheduled =
+                    resource.get("date_scheduled") !== null
+                    ? Y.lp.app.date.parse_date(resource.get("date_scheduled"))
+                    : null;
+                var retrying_now =
+                    delivery.requesting_retry || (
+                        resource.get("pending") && (
+                            date_scheduled === null
+                            || date_scheduled < new Date()));
+                if (retrying_now) {
+                    // Retrying already, or we're currently requesting one.
+                    detail_node.one(".delivery-retry-notice").addClass("hidden");
+                    detail_node.one(".delivery-retry").addClass("hidden");
+                    detail_node.one(".delivery-delivering-notice")
+                        .removeClass("hidden");
+                } else if (resource.get("pending")) {
+                    // Retry scheduled for the future.
+                    var retrying_text =
+                        Y.lp.app.date.approximatedate(date_scheduled);
+                    detail_node.one(".delivery-retry-notice").set(
+                        "text", "Retrying " + retrying_text + ".");
+                    detail_node.one(".delivery-retry").set("text", "Retry now");
+                    detail_node.one(".delivery-retry-notice").removeClass("hidden");
+                    detail_node.one(".delivery-retry").removeClass("hidden");
+                    detail_node.one(".delivery-delivering-notice").addClass("hidden");
+                } else {
+                    var retry_text = resource.successful ? "Redeliver" : "Retry";
+                    detail_node.one(".delivery-retry").set("text", retry_text);
+                    detail_node.one(".delivery-retry-notice").addClass("hidden");
+                    detail_node.one(".delivery-delivering-notice").addClass("hidden");
+                    detail_node.one(".delivery-retry").removeClass("hidden");
+                }
+            }
+        });
+        table.one("tbody").replace(new_tbody);
+    },
+
+    _pick_sprite: function(delivery) {
+        if (delivery.resource.get("pending")
+                && delivery.resource.get("successful") === null) {
+            return "milestone";
+        } else if (delivery.resource.get("successful")) {
+            return "yes";
+        } else if (delivery.resource.get("pending")
+                       || delivery.requesting_retry) {
+            return "warning-icon";
+        } else {
+            return "no";
+        }
+    },
+
+    _render_delivery: function(delivery) {
+        var row_template = [
+            '<tr class="webhook-delivery">',
+            '<td><span class="sprite {{sprite}}" /></td>',
+            '<td>{{date}}</td>',
+            '<td>{{event_type}}</td>',
+            '<td>{{status}}</td>',
+            '</tr>'].join(' ');
+        context = {
+            sprite: this._pick_sprite(delivery),
+            date: Y.lp.app.date.approximatedate(Y.lp.app.date.parse_date(
+                delivery.resource.get("date_created"))),
+            event_type: delivery.resource.get("event_type"),
+            status: delivery.resource.get("error_message")};
+        var new_row = Y.Node.create(Y.lp.mustache.to_html(
+            row_template, context));
+        new_row.setData("delivery-url", delivery.resource.get("self_link"));
+        return new_row;
+    },
+
+    _format_date: function(iso8601) {
+        // The ISO8601 timestamp is in UTC, but JS converts it in local
+        // time. LP generally gives timestamps in the user's profile
+        // timezone, but the browser timezone may differ, so let's use
+        // UTC and be explicit about it.
+        // Using the browser's local timezone, mangle it to UTC
+        // masquerading as local time and format it nicely.
+        if (iso8601 === null) {
+            return "unknown";
+        }
+        var local_date = Y.lp.app.date.parse_date(iso8601);
+        return Y.Date.format(
+            new Date(
+                local_date.getTime()
+                + local_date.getTimezoneOffset() * 60 * 1000),
+            {format: "%Y-%m-%d %H:%M:%S UTC"});
+    },
+
+    _render_detail: function(delivery, delivery_node) {
+        var detail_node = Y.Node.create([
+            '<tr class="webhook-delivery-detail">',
+            '<td></td>',
+            '<td colspan="3" class="webhook-delivery-detail">',
+            '<span class="text"></span>',
+            '<div>',
+            '<span class="delivery-retry-notice hidden"></span> ',
+            '<a class="js-action delivery-retry">Retry</a>',
+            '<span class="delivery-delivering-notice hidden">',
+            '  <img src="/@@/spinner" /> Delivering...',
+            '</span>',
+            '</div>',
+            '</td>',
+            '</tr>'].join(''));
+        detail_node.setData('delivery-tr', delivery_node);
+        var date_sent = this._format_date(delivery.resource.get("date_sent"));
+        var status_text = null;
+        if (delivery.resource.get("successful") === true) {
+            status_text = "Delivered at " + date_sent + ".";
+        } else if (delivery.resource.get("successful") === false) {
+            if (delivery.resource.get("date_first_sent")) {
+                var date_first_sent = this._format_date(
+                    delivery.resource.get("date_first_sent"));
+                status_text =
+                    "Tried since " + date_first_sent + ", last failed at "
+                    + date_sent + ".";
+            } else {
+                status_text = "Failed at " + date_sent + ".";
+            }
+        }
+        detail_node.one('span.text').set('text', status_text);
+        return detail_node;
+    },
+
+    _toggle_detail: function(e) {
+        var delivery_url = e.currentTarget.getData('delivery-url');
+        if (delivery_url === undefined) {
+            // Not actually a delivery row.
+            return;
+        }
+        var delivery = this.delivery_info[delivery_url];
+        delivery.expanded = !delivery.expanded;
+        this.syncUI();
+    },
+
+    _trigger_retry: function(e) {
+        var detail_node = e.currentTarget.ancestor('tr', false);
+        var delivery_url =
+            detail_node.getData('delivery-tr').getData('delivery-url');
+        var delivery = this.delivery_info[delivery_url];
+        if (delivery.requesting_retry) {
+            // Already in progress.
+            return;
+        }
+        delivery.requesting_retry = true;
+        this.syncUI();
+        // XXX wgrant 2015-09-09: This should fire an event to an
+        // external model object, which forwards the retry to the server,
+        // refreshes our copy, then fires an event back to update the
+        // widget.
+        var self = this;
+        var config = {
+            on: {
+                success: function() {
+                    // TODO: Refresh the object and unset requesting_retry.
+                    // For now this will leave "Delivering" spinner in
+                    // place, which is the most likely result anyway.
+                    self.syncUI();
+                },
+                failure: function() {
+                    // TODO: Display error popup.
+                    delivery.requesting_retry = false;
+                    self.syncUI();
+                }
+            }
+        };
+        this.lp_client.named_post(delivery_url, 'retry', config);
+    }
+
+});
+
+namespace.WebhookDeliveries = WebhookDeliveries;
+
+}, "0.1", {"requires": ["event", "node", "widget", "lp.app.date",
+                        "lp.app.listing_navigator", "lp.client",
+                        "lp.mustache"]});

=== added directory 'lib/lp/services/webhooks/javascript/tests'
=== added file 'lib/lp/services/webhooks/javascript/tests/test_deliveries.html'
--- lib/lp/services/webhooks/javascript/tests/test_deliveries.html	1970-01-01 00:00:00 +0000
+++ lib/lp/services/webhooks/javascript/tests/test_deliveries.html	2015-09-09 13:04:04 +0000
@@ -0,0 +1,73 @@
+<!DOCTYPE html>
+<!--
+Copyright 2015 Canonical Ltd.  This software is licensed under the
+GNU Affero General Public License version 3 (see the file LICENSE).
+-->
+
+<html>
+  <head>
+      <title>Webhook deliveries widget 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>
+
+      <link rel="stylesheet" href="../../../../app/javascript/testing/test.css" />
+
+      <!-- Dependencies -->
+      <script type="text/javascript"
+          src="../../../../../../build/js/lp/app/testing/mockio.js"></script>
+      <script type="text/javascript"
+          src="../../../../../../build/js/lp/app/client.js"></script>
+      <script type="text/javascript"
+          src="../../../../../../build/js/lp/app/date.js"></script>
+      <script type="text/javascript"
+          src="../../../../../../build/js/lp/app/errors.js"></script>
+      <script type="text/javascript"
+          src="../../../../../../build/js/lp/app/lp.js"></script>
+      <script type="text/javascript"
+          src="../../../../../../build/js/lp/app/mustache.js"></script>
+      <script type="text/javascript"
+          src="../../../../../../build/js/lp/app/listing_navigator.js"></script>
+
+      <!-- The module under test. -->
+      <script type="text/javascript" src="../deliveries.js"></script>
+
+      <!-- The test suite -->
+      <script type="text/javascript" src="test_deliveries.js"></script>
+
+      <script id="fixture-template" type="text/x-template">
+        <div id="webhook-deliveries">
+          <table id="webhook-deliveries-table" class="listing">
+            <colgroup>
+              <col style="width: 18px" />
+            </colgroup>
+            <tbody>
+              <tr id='webhook-deliveries-table-loading'>
+                <td colspan="3" style="padding-left: 0.25em">
+                  <img class="spinner" src="/@@/spinner" alt="Loading..." />
+                  Loading...
+                </td>
+              </tr>
+            </tbody>
+          </table>
+        </div>
+      </script>
+    </head>
+    <body class="yui3-skin-sam">
+      <ul id="suites">
+        <li>lp.services.webhooks.deliveries.test</li>
+      </ul>
+      <div id="fixture"></div>
+    </body>
+</html>

=== added file 'lib/lp/services/webhooks/javascript/tests/test_deliveries.js'
--- lib/lp/services/webhooks/javascript/tests/test_deliveries.js	1970-01-01 00:00:00 +0000
+++ lib/lp/services/webhooks/javascript/tests/test_deliveries.js	2015-09-09 13:04:04 +0000
@@ -0,0 +1,214 @@
+/* Copyright 2015 Canonical Ltd.  This software is licensed under the
+ * GNU Affero General Public License version 3 (see the file LICENSE). */
+
+YUI.add('lp.services.webhooks.deliveries.test', function (Y) {
+
+    var tests = Y.namespace('lp.services.webhooks.deliveries.test');
+    tests.suite = new Y.Test.Suite(
+        'lp.services.webhooks.deliveries Tests');
+
+    var DELIVERY_PENDING = {
+        "event_type": "ping", "successful": null, "error_message": null,
+        "date_sent": null, "self_link": "http://example.com/delivery/1";,
+        "date_created": "2014-09-08T01:19:16+00:00", "date_scheduled": null,
+        "pending": true, "resource_type_link": "#webhook_delivery"};
+
+    var DELIVERY_SUCCESSFUL = {
+        "event_type": "ping", "successful": true,
+        "error_message": null,
+        "date_sent": "2014-09-08T01:19:16+00:00",
+        "self_link": "http://example.com/delivery/2";,
+        "date_created": "2014-09-08T01:19:16+00:00",
+        "date_scheduled": null, "pending": false,
+        "resource_type_link": "#webhook_delivery"};
+
+    var DELIVERY_SUCCESSFUL_RETRY_NOW = {
+        "event_type": "ping", "successful": true,
+        "error_message": null,
+        "date_sent": "2014-09-08T01:19:16+00:00",
+        "self_link": "http://example.com/delivery/2";,
+        "date_created": "2014-09-08T01:19:16+00:00",
+        "date_scheduled": "2014-09-08T01:19:16+00:00",
+        "pending": true, "resource_type_link": "#webhook_delivery"};
+
+    var DELIVERY_FAILED = {
+        "event_type": "ping", "successful": false,
+        "error_message": "Bad HTTP response: 404",
+        "date_sent": "2014-09-08T01:19:16+00:00",
+        "self_link": "http://example.com/delivery/2";,
+        "date_created": "2014-09-08T01:19:16+00:00",
+        "date_scheduled": null, "pending": false,
+        "resource_type_link": "#webhook_delivery"};
+
+    var DELIVERY_FAILED_RETRY_SCHEDULED = {
+        "event_type": "ping", "successful": false,
+        "error_message": "Bad HTTP response: 404",
+        "date_sent": "2014-09-08T01:19:16+00:00",
+        "self_link": "http://example.com/delivery/2";,
+        "date_created": "2014-09-08T01:19:16+00:00",
+        "date_scheduled": "2034-09-08T01:19:16+00:00", "pending": true,
+        "resource_type_link": "#webhook_delivery"};
+
+    var common_test_methods = {
+
+        setUp: function() {
+            Y.one("#fixture").append(
+                Y.Node.create(Y.one("#fixture-template").getContent()));
+            this.widget = this.createWidget();
+        },
+
+        tearDown: function() {
+            this.widget.destroy();
+            Y.one("#fixture").empty();
+        },
+
+        createWidget: function(cfg) {
+            var config = Y.merge(cfg, {
+                srcNode: "#webhook-deliveries",
+            });
+            var ns = Y.lp.services.webhooks.deliveries;
+            return new ns.WebhookDeliveries(config);
+        }
+
+    };
+
+    tests.suite.add(new Y.Test.Case(Y.merge(common_test_methods, {
+        name: 'lp.services.webhooks.deliveries_tests',
+
+        test_library_exists: function () {
+            Y.Assert.isObject(Y.lp.services.webhooks.deliveries,
+                "Could not locate the " +
+                "lp.services.webhooks.deliveries module");
+        },
+
+        test_widget_can_be_instantiated: function() {
+            Y.Assert.isInstanceOf(
+                Y.lp.services.webhooks.deliveries.WebhookDeliveries,
+                this.widget, "Widget failed to be instantiated");
+        },
+
+        test_render: function() {
+            Y.Assert.isFalse(Y.all("#webhook-deliveries tr").isEmpty());
+            Y.Assert.isNotNull(Y.one("#webhook-deliveries-table-loading"));
+            this.widget.render();
+            Y.Assert.isTrue(Y.all("#webhook-deliveries tr").isEmpty());
+            Y.Assert.isNull(Y.one("#webhook-deliveries-table-loading"));
+            Y.ArrayAssert.itemsAreEqual(this.widget.get("deliveries"), []);
+        },
+
+        test_expand_detail: function() {
+            this.widget.render();
+            Y.Assert.areEqual(Y.all("#webhook-deliveries tr").size(), 0);
+            this.widget.set("deliveries", [DELIVERY_PENDING]);
+            Y.Assert.areEqual(Y.all("#webhook-deliveries tr").size(), 1);
+            // Clicking a row adds a new one immediately below with details.
+            Y.one("#webhook-deliveries tr:nth-child(1)").simulate("click");
+            Y.Assert.areEqual(Y.all("#webhook-deliveries tr").size(), 2);
+            // Clicking on the new row does nothing.
+            Y.one("#webhook-deliveries tr:nth-child(2)").simulate("click");
+            Y.Assert.areEqual(Y.all("#webhook-deliveries tr").size(), 2);
+
+            // Adding and clicking another row expands it as well.
+            this.widget.set("deliveries", [DELIVERY_PENDING, DELIVERY_FAILED]);
+            Y.Assert.areEqual(Y.all("#webhook-deliveries tr").size(), 3);
+            Y.one("#webhook-deliveries tr:nth-child(3)").simulate("click");
+            Y.Assert.areEqual(Y.all("#webhook-deliveries tr").size(), 4);
+            // Clicking the main row again collapses it.
+            Y.one("#webhook-deliveries tr:nth-child(1)").simulate("click");
+            Y.Assert.areEqual(Y.all("#webhook-deliveries tr").size(), 3);
+
+            // The expanded state is remembered even if the deliveries
+            // disappear.
+            this.widget.set("deliveries", []);
+            Y.Assert.areEqual(Y.all("#webhook-deliveries tr").size(), 0);
+            this.widget.set("deliveries", [DELIVERY_PENDING, DELIVERY_FAILED]);
+            Y.Assert.areEqual(Y.all("#webhook-deliveries tr").size(), 3);
+        },
+
+        dump_row_state: function(node) {
+            // XXX wgrant 2015-09-09: Should get the detail row in a
+            // nicer way.
+            Y.Assert.isNull(Y.one("#webhook-deliveries tr:nth-child(2)"));
+            // Expand the detail section.
+            node.simulate("click");
+            var detail_node = Y.one("#webhook-deliveries tr:nth-child(2)");
+            Y.Assert.isObject(detail_node);
+            var delivering_notice = detail_node.one(
+                ".delivery-delivering-notice");
+            Y.Assert.isNotNull(delivering_notice);
+            var retry_notice = detail_node.one(".delivery-retry-notice");
+            var retry = detail_node.one(".delivery-retry");
+            return {
+                sprite: node.one("td span.sprite").get("className"),
+                delivering: !delivering_notice.hasClass("hidden"),
+                retry_notice: retry_notice.hasClass("hidden")
+                    ? null : retry_notice.get("text"),
+                retry: retry.hasClass("hidden") ? null : retry.get("text")
+                };
+        },
+
+        test_delivery_pending: function() {
+            this.widget.set("deliveries", [DELIVERY_PENDING]);
+            this.widget.render();
+            Y.Assert.areEqual(Y.all("#webhook-deliveries tr").size(), 1);
+            var state = this.dump_row_state(Y.one("#webhook-deliveries tr"));
+            // Of the retry widgets, only the "Delivering" spinner is shown.
+            Y.Assert.areEqual(state.sprite, "sprite milestone");
+            Y.Assert.isTrue(state.delivering);
+            Y.Assert.isNull(state.retry_notice);
+            Y.Assert.isNull(state.retry);
+        },
+
+        test_delivery_successful: function() {
+            this.widget.set("deliveries", [DELIVERY_SUCCESSFUL]);
+            this.widget.render();
+            Y.Assert.areEqual(Y.all("#webhook-deliveries tr").size(), 1);
+            var state = this.dump_row_state(Y.one("#webhook-deliveries tr"));
+            // The only visible retry widget is the "Retry" link.
+            Y.Assert.areEqual(state.sprite, "sprite yes");
+            Y.Assert.isFalse(state.delivering);
+            Y.Assert.isNull(state.retry_notice);
+            Y.Assert.areEqual(state.retry, "Retry");
+        },
+
+        test_delivery_successful_retry_now: function() {
+            this.widget.set("deliveries", [DELIVERY_SUCCESSFUL_RETRY_NOW]);
+            this.widget.render();
+            Y.Assert.areEqual(Y.all("#webhook-deliveries tr").size(), 1);
+            var state = this.dump_row_state(Y.one("#webhook-deliveries tr"));
+            // The "Delivering" spinner is visible.
+            Y.Assert.areEqual(state.sprite, "sprite yes");
+            Y.Assert.isTrue(state.delivering);
+            Y.Assert.isNull(state.retry_notice);
+            Y.Assert.isNull(state.retry);
+        },
+
+        test_delivery_failed: function() {
+            this.widget.set("deliveries", [DELIVERY_FAILED]);
+            this.widget.render();
+            Y.Assert.areEqual(Y.all("#webhook-deliveries tr").size(), 1);
+            var state = this.dump_row_state(Y.one("#webhook-deliveries tr"));
+            // The only visible retry widget is the "Retry" link.
+            Y.Assert.areEqual(state.sprite, "sprite no");
+            Y.Assert.isFalse(state.delivering);
+            Y.Assert.isNull(state.retry_notice);
+            Y.Assert.areEqual(state.retry, "Retry");
+        },
+
+        test_delivery_failed_retry_scheduled: function() {
+            this.widget.set("deliveries", [DELIVERY_FAILED_RETRY_SCHEDULED]);
+            this.widget.render();
+            Y.Assert.areEqual(Y.all("#webhook-deliveries tr").size(), 1);
+            var state = this.dump_row_state(Y.one("#webhook-deliveries tr"));
+            // The visible retry widgets are the schedule notice and a
+            // "Retry now" link.
+            Y.Assert.areEqual(state.sprite, "sprite warning-icon");
+            Y.Assert.isFalse(state.delivering);
+            Y.Assert.areEqual(state.retry_notice, "Retrying on 2034-09-08.");
+            Y.Assert.areEqual(state.retry, "Retry now");
+        }
+
+    })));
+
+}, '0.1', {'requires': ['test', 'test-console', 'event', 'node-event-simulate',
+        'lp.testing.mockio', 'lp.services.webhooks.deliveries']});

=== modified file 'lib/lp/services/webhooks/model.py'
--- lib/lp/services/webhooks/model.py	2015-08-10 08:09:09 +0000
+++ lib/lp/services/webhooks/model.py	2015-09-09 13:04:04 +0000
@@ -22,6 +22,7 @@
     DBItem,
     )
 from pytz import utc
+from storm.expr import Desc
 from storm.properties import (
     Bool,
     DateTime,
@@ -116,7 +117,7 @@
             WebhookJob,
             WebhookJob.webhook == self,
             WebhookJob.job_type == WebhookJobType.DELIVERY,
-            ).order_by(WebhookJob.job_id)
+            ).order_by(Desc(WebhookJob.job_id))
 
         def preload_jobs(rows):
             load_related(Job, rows, ['job_id'])
@@ -361,6 +362,10 @@
         return 'Bad HTTP response: %d' % status_code
 
     @property
+    def date_scheduled(self):
+        return self.scheduled_start
+
+    @property
     def date_first_sent(self):
         if 'date_first_sent' not in self.json_data:
             return None

=== modified file 'lib/lp/services/webhooks/templates/webhook-index.pt'
--- lib/lp/services/webhooks/templates/webhook-index.pt	2015-08-04 08:50:29 +0000
+++ lib/lp/services/webhooks/templates/webhook-index.pt	2015-09-09 13:04:04 +0000
@@ -5,6 +5,46 @@
   xmlns:i18n="http://xml.zope.org/namespaces/i18n";
   metal:use-macro="view/macro:page/main_only"
   i18n:domain="launchpad">
+<head>
+  <metal:block fill-slot="head_epilogue">
+    <script tal:content="structure string:
+      LPJS.use('base', 'node', 'event', 'lp.services.webhooks.deliveries',
+          function(Y) {
+              Y.on('domready', function() {
+                  var ns = Y.lp.services.webhooks.deliveries;
+                  var deliveries_widget = new ns.WebhookDeliveries({
+                    srcNode: '#webhook-deliveries'});
+
+                  // Set up the batch navigation controls.
+                  var container = Y.one('#webhook-deliveries');
+                  var navigator = new ns.WebhookDeliveriesListingNavigator({
+                      current_url: window.location,
+                      cache: LP.cache,
+                      target: Y.one('#webhook-deliveries-table'),
+                      container: container,
+                  });
+                  navigator.set('backwards_navigation',
+                                container.all('.first,.previous'));
+                  navigator.set('forwards_navigation',
+                                container.all('.last,.next'));
+                  navigator.clickAction('.first', navigator.first_batch);
+                  navigator.clickAction('.next', navigator.next_batch);
+                  navigator.clickAction('.previous', navigator.prev_batch);
+                  navigator.clickAction('.last', navigator.last_batch);
+                  navigator.update_navigation_links();
+                  navigator.subscribe(
+                      ns.WebhookDeliveriesListingNavigator.UPDATE_CONTENT,
+                      function(e) {
+                          deliveries_widget.set('deliveries', e.details[0]);
+                      });
+
+                  deliveries_widget.set('deliveries', LP.cache.deliveries);
+                  deliveries_widget.render();
+              });
+          });
+    "/>
+  </metal:block>
+</head>
 <body>
   <div metal:fill-slot="main">
     <div metal:use-macro="context/@@launchpad_form/form">
@@ -17,6 +57,30 @@
         <a tal:attributes="href context/fmt:url/+delete">Delete webhook</a>
       </div>
     </div>
+    <h2>Recent deliveries</h2>
+    <div id="webhook-deliveries">
+      <div class="lesser"
+          tal:content="structure view/deliveries/@@+navigation-links-upper" />
+
+      <table id="webhook-deliveries-table" class="listing">
+        <colgroup>
+          <col style="width: 18px" />
+          <col style="width: 10em" />
+          <col style="width: 5em" />
+        </colgroup>
+        <tbody>
+          <tr id='webhook-deliveries-table-loading'>
+            <td colspan="3" style="padding-left: 0.25em">
+              <img class="spinner" src="/@@/spinner" alt="Loading..." />
+              Loading...
+            </td>
+          </tr>
+        </tbody>
+      </table>
+
+      <div class="lesser"
+          tal:content="structure view/deliveries/@@+navigation-links-lower" />
+    </div>
   </div>
 </body>
 </html>

=== modified file 'lib/lp/services/webhooks/tests/test_webservice.py'
--- lib/lp/services/webhooks/tests/test_webservice.py	2015-08-10 08:01:22 +0000
+++ lib/lp/services/webhooks/tests/test_webservice.py	2015-09-09 13:04:04 +0000
@@ -215,9 +215,9 @@
             representation,
             MatchesAll(
                 KeysEqual(
-                    'date_created', 'date_first_sent', 'date_sent',
-                    'error_message', 'event_type', 'http_etag', 'payload',
-                    'pending', 'resource_type_link', 'self_link',
+                    'date_created', 'date_first_sent', 'date_scheduled',
+                    'date_sent', 'error_message', 'event_type', 'http_etag',
+                    'payload', 'pending', 'resource_type_link', 'self_link',
                     'successful', 'web_link', 'webhook_link'),
                 ContainsDict({
                     'event_type': Equals('ping'),
@@ -225,6 +225,7 @@
                     'pending': Equals(True),
                     'successful': Is(None),
                     'date_created': Not(Is(None)),
+                    'date_scheduled': Is(None),
                     'date_sent': Is(None),
                     'error_message': Is(None),
                     })))

=== added file 'lib/lp/services/webhooks/tests/test_yuitests.py'
--- lib/lp/services/webhooks/tests/test_yuitests.py	1970-01-01 00:00:00 +0000
+++ lib/lp/services/webhooks/tests/test_yuitests.py	2015-09-09 13:04:04 +0000
@@ -0,0 +1,26 @@
+# Copyright 2011-2015 Canonical Ltd.  This software is licensed under the
+# GNU Affero General Public License version 3 (see the file LICENSE).
+
+"""Run YUI.test tests."""
+
+__metaclass__ = type
+__all__ = []
+
+from lp.testing import (
+    build_yui_unittest_suite,
+    YUIUnitTestCase,
+    )
+from lp.testing.layers import YUITestLayer
+
+
+class WebhooksYUIUnitTestCase(YUIUnitTestCase):
+
+    layer = YUITestLayer
+    suite_name = 'WebhooksYUIUnitTests'
+
+
+def test_suite():
+    app_testing_path = 'lp/services/webhooks'
+    return build_yui_unittest_suite(
+            app_testing_path,
+            WebhooksYUIUnitTestCase)


Follow ups