Merge ~mpontillo/maas:enable-ping--js--bug-1802325 into maas:master

Proposed by Mike Pontillo
Status: Merged
Approved by: Mike Pontillo
Approved revision: 1d2b6342e292e7d619704807dcb8e8e85e87e4df
Merge reported by: MAAS Lander
Merged at revision: not available
Proposed branch: ~mpontillo/maas:enable-ping--js--bug-1802325
Merge into: maas:master
Prerequisite: ~mpontillo/maas:add-websocket-ping-ability--bug-1802325
Diff against target: 978 lines (+394/-174)
11 files modified
.gitattributes (+5/-0)
src/maasserver/static/js/angular/directives/tests/test_boot_images.js (+5/-0)
src/maasserver/static/js/angular/directives/tests/test_ipranges.js (+4/-0)
src/maasserver/static/js/angular/directives/tests/test_notifications.js (+5/-1)
src/maasserver/static/js/angular/directives/tests/test_pod_parameters.js (+4/-0)
src/maasserver/static/js/angular/directives/tests/test_ssh_keys.js (+4/-0)
src/maasserver/static/js/angular/factories/region.js (+232/-123)
src/maasserver/static/js/angular/factories/tests/test_region.js (+101/-41)
src/maasserver/static/js/angular/services/log.js (+1/-1)
src/maasserver/static/js/angular/testing/websocket.js (+32/-8)
webpack.config.js (+1/-0)
Reviewer Review Type Date Requested Status
Blake Rouse (community) Approve
MAAS Lander Approve
Review via email: mp+358889@code.launchpad.net

Commit message

Add connectivity check and retry behavior to web UI.

 * Periodically send WebSocket requests to ensure connectivity.
 * Attempt to retry connection if requests are not answered in a timely manner.
 * Fix tests to ensure real WebSocket connections are not attempted.
 * Change .gitattributes to ensure built JavaScript and bundles and CSS are not included in text-based diff output.
 * Enable cache to make webpack builds faster.

Partial fix for bug #1802325.

To post a comment you must log in.
Revision history for this message
MAAS Lander (maas-lander) wrote :

UNIT TESTS
-b enable-ping--js--bug-1802325 lp:~mpontillo/maas/+git/maas into -b master lp:~maas-committers/maas

STATUS: SUCCESS
COMMIT: a354b3b71ad82561206dab15cf455cca55ce3430

review: Approve
Revision history for this message
Blake Rouse (blake-rouse) wrote :

Looks good. Like the ES6 class, so much nicer than that old JS crap.

review: Approve
e9712be... by Mike Pontillo

Remove excessive debug logging.

1d2b634... by Mike Pontillo

Clear out event handlers so we don't get events from stale websockets.

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1diff --git a/.gitattributes b/.gitattributes
2index 5c54628..7d16138 100644
3--- a/.gitattributes
4+++ b/.gitattributes
5@@ -7,3 +7,8 @@
6 /services export-ignore
7 .gitignore export-ignore
8 .gitattributes export-ignore
9+
10+# Don't include generated JavaScript or CSS in diff output.
11+# (They will still show up as binary files that differ.)
12+/src/maasserver/static/js/bundle/** -diff
13+/src/maasserver/static/css/build.css -diff
14diff --git a/src/maasserver/static/js/angular/directives/tests/test_boot_images.js b/src/maasserver/static/js/angular/directives/tests/test_boot_images.js
15index c569220..91471c8 100644
16--- a/src/maasserver/static/js/angular/directives/tests/test_boot_images.js
17+++ b/src/maasserver/static/js/angular/directives/tests/test_boot_images.js
18@@ -24,6 +24,11 @@ describe("maasBootImages", function() {
19 BootResourcesManager = $injector.get('BootResourcesManager');
20 UsersManager = $injector.get('UsersManager');
21 ManagerHelperService = $injector.get('ManagerHelperService');
22+ // Mock buildSocket so an actual connection is not made.
23+ let RegionConnection = $injector.get("RegionConnection");
24+ let webSocket = new MockWebSocket();
25+ spyOn(RegionConnection, "buildSocket").and.returnValue(webSocket);
26+
27 }));
28
29 // Create a new scope before each test.
30diff --git a/src/maasserver/static/js/angular/directives/tests/test_ipranges.js b/src/maasserver/static/js/angular/directives/tests/test_ipranges.js
31index db8f812..327f3f8 100644
32--- a/src/maasserver/static/js/angular/directives/tests/test_ipranges.js
33+++ b/src/maasserver/static/js/angular/directives/tests/test_ipranges.js
34@@ -24,6 +24,10 @@ describe("maasIPRanges", function() {
35 IPRangesManager = $injector.get('IPRangesManager');
36 UsersManager = $injector.get('UsersManager');
37 ManagerHelperService = $injector.get('ManagerHelperService');
38+ // Mock buildSocket so an actual connection is not made.
39+ let RegionConnection = $injector.get("RegionConnection");
40+ let webSocket = new MockWebSocket();
41+ spyOn(RegionConnection, "buildSocket").and.returnValue(webSocket);
42 }));
43
44 // Create a new scope before each test.
45diff --git a/src/maasserver/static/js/angular/directives/tests/test_notifications.js b/src/maasserver/static/js/angular/directives/tests/test_notifications.js
46index 22c7532..e7704b8 100644
47--- a/src/maasserver/static/js/angular/directives/tests/test_notifications.js
48+++ b/src/maasserver/static/js/angular/directives/tests/test_notifications.js
49@@ -62,9 +62,13 @@ describe("maasNotifications", function() {
50 var theNotificationsManager;
51 var $scope;
52
53- beforeEach(inject(function($rootScope, NotificationsManager) {
54+ beforeEach(inject(function($rootScope, NotificationsManager, $injector) {
55 theNotificationsManager = NotificationsManager;
56 $scope = $rootScope.$new();
57+ // Mock buildSocket so an actual connection is not made.
58+ let RegionConnection = $injector.get("RegionConnection");
59+ let webSocket = new MockWebSocket();
60+ spyOn(RegionConnection, "buildSocket").and.returnValue(webSocket);
61 }));
62
63 describe("maas-notifications", function() {
64diff --git a/src/maasserver/static/js/angular/directives/tests/test_pod_parameters.js b/src/maasserver/static/js/angular/directives/tests/test_pod_parameters.js
65index 618bb5c..e1f1774 100644
66--- a/src/maasserver/static/js/angular/directives/tests/test_pod_parameters.js
67+++ b/src/maasserver/static/js/angular/directives/tests/test_pod_parameters.js
68@@ -15,6 +15,10 @@ describe("maasPodParameters", function() {
69 PodsManager = $injector.get("PodsManager");
70 GeneralManager = $injector.get("GeneralManager");
71 ManagerHelperService = $injector.get("ManagerHelperService");
72+ // Mock buildSocket so an actual connection is not made.
73+ let RegionConnection = $injector.get("RegionConnection");
74+ let webSocket = new MockWebSocket();
75+ spyOn(RegionConnection, "buildSocket").and.returnValue(webSocket);
76 }));
77
78 // Create a new scope before each test.
79diff --git a/src/maasserver/static/js/angular/directives/tests/test_ssh_keys.js b/src/maasserver/static/js/angular/directives/tests/test_ssh_keys.js
80index 8783f61..d886f5d 100644
81--- a/src/maasserver/static/js/angular/directives/tests/test_ssh_keys.js
82+++ b/src/maasserver/static/js/angular/directives/tests/test_ssh_keys.js
83@@ -25,6 +25,10 @@ describe("maasSshKeys", function() {
84 SSHKeysManager = $injector.get('SSHKeysManager');
85 JSONService = $injector.get('JSONService');
86 ManagerHelperService = $injector.get('ManagerHelperService');
87+ // Mock buildSocket so an actual connection is not made.
88+ let RegionConnection = $injector.get("RegionConnection");
89+ let webSocket = new MockWebSocket();
90+ spyOn(RegionConnection, "buildSocket").and.returnValue(webSocket);
91 }));
92
93 // Create a new scope before each test.
94diff --git a/src/maasserver/static/js/angular/factories/region.js b/src/maasserver/static/js/angular/factories/region.js
95index 9e1e8a9..eaa78ca 100644
96--- a/src/maasserver/static/js/angular/factories/region.js
97+++ b/src/maasserver/static/js/angular/factories/region.js
98@@ -1,4 +1,4 @@
99-/* Copyright 2015-2016 Canonical Ltd. This software is licensed under the
100+/* Copyright 2015-2018 Canonical Ltd. This software is licensed under the
101 * GNU Affero General Public License version 3 (see the file LICENSE).
102 *
103 * MAAS Region Connection
104@@ -7,34 +7,58 @@
105 * service.
106 */
107
108+// Message types
109+const MSG_TYPE = {
110+ REQUEST: 0,
111+ RESPONSE: 1,
112+ NOTIFY: 2,
113+ PING: 3,
114+ PING_REPLY: 4
115+};
116+
117+// Response types
118+const RESPONSE_TYPE = {
119+ SUCCESS: 0,
120+ ERROR: 1
121+};
122+
123+const REGION_STATE = {
124+ DOWN: 0,
125+ UP: 1,
126+ RETRY: 2
127+};
128+
129 angular.module('MAAS').factory(
130- 'RegionConnection',
131- ['$q', '$rootScope', '$timeout', '$window', '$cookies', function(
132- $q, $rootScope, $timeout, $window, $cookies) {
133-
134- // Message types
135- var MSG_TYPE = {
136- REQUEST: 0,
137- RESPONSE: 1,
138- NOTIFY: 2
139- };
140-
141- // Response types
142- var RESPONSE_TYPE = {
143- SUCCESS: 0,
144- ERROR: 1
145- };
146-
147- // Constructor
148- function RegionConnection() {
149+ 'RegionConnection', [
150+ '$q', '$rootScope', '$timeout', '$window', '$cookies', 'LogService',
151+ function() { return new RegionConnection(...arguments); }
152+ ]
153+);
154+
155+ class RegionConnection {
156+
157+ constructor($q, $rootScope, $timeout, $window, $cookies, LogService) {
158+ this.$q = $q;
159+ this.$rootScope = $rootScope;
160+ this.$timeout = $timeout;
161+ this.$window = $window;
162+ this.$cookies = $cookies;
163+ this.log = LogService;
164+
165+ // Expose this constant so the tests can access it.
166+ this.STATE = REGION_STATE;
167+
168 this.callbacks = {};
169 this.requests = {};
170+ this.pingsInFlight = new Set();
171 this.requestId = 0;
172 this.url = null;
173 this.websocket = null;
174- this.connected = false;
175- this.autoReconnect = true;
176- this.retryTimeout = 5000;
177+ this.state = REGION_STATE.DOWN;
178+ this.ensureConnectionPromise = null;
179+ this.connectionCheckInterval = 5000;
180+ this.maxMissedPings = 5;
181+ this.maxPatience = 5;
182 this.error = null;
183
184 // Defer used for defaultConnect. If defaultConnect is called
185@@ -58,13 +82,13 @@ angular.module('MAAS').factory(
186 }
187
188 // Return a new request id.
189- RegionConnection.prototype.newRequestId = function() {
190+ newRequestId() {
191 this.requestId += 1;
192 return this.requestId;
193- };
194+ }
195
196 // Register event handler.
197- RegionConnection.prototype.registerHandler = function (name, func) {
198+ registerHandler(name, func) {
199 if(!angular.isDefined(this.handlers[name])) {
200 throw new Error("Invalid handler: " + name);
201 }
202@@ -72,21 +96,21 @@ angular.module('MAAS').factory(
203 throw new Error("Requires a function to register a handler.");
204 }
205 this.handlers[name].push(func);
206- };
207+ }
208
209 // Unregister event handler.
210- RegionConnection.prototype.unregisterHandler = function (name, func) {
211+ unregisterHandler(name, func) {
212 if(!angular.isDefined(this.handlers[name])) {
213 throw new Error("Invalid handler: " + name);
214 }
215- var idx = this.handlers[name].indexOf(func);
216+ let idx = this.handlers[name].indexOf(func);
217 if(idx >= 0) {
218 this.handlers[name].splice(idx, 1);
219 }
220- };
221+ }
222
223 // Register notification handler.
224- RegionConnection.prototype.registerNotifier = function(name, func) {
225+ registerNotifier(name, func) {
226 if(!angular.isFunction(func)) {
227 throw new Error("Requires a function to register a notifier.");
228 }
229@@ -94,96 +118,173 @@ angular.module('MAAS').factory(
230 this.notifiers[name] = [];
231 }
232 this.notifiers[name].push(func);
233- };
234+ }
235
236 // Unregister notification handler.
237- RegionConnection.prototype.unregisterNotifier = function(name, func) {
238+ unregisterNotifier(name, func) {
239 if(angular.isUndefined(this.notifiers[name])) {
240 return;
241 }
242- var idx = this.notifiers[name].indexOf(func);
243+ let idx = this.notifiers[name].indexOf(func);
244 if(idx >= 0) {
245 this.notifiers[name].splice(idx, 1);
246 }
247- };
248+ }
249
250 // Return True if currently connected to region.
251- RegionConnection.prototype.isConnected = function() {
252- return this.connected;
253- };
254+ isConnected() {
255+ return this.state === REGION_STATE.UP;
256+ }
257
258 // Builds the websocket connection.
259- RegionConnection.prototype.buildSocket = function(url) {
260+ buildSocket(url) {
261 return new WebSocket(url);
262- };
263+ }
264+
265+ send(request) {
266+ if(this.state !== REGION_STATE.UP) {
267+ this.log.warn(
268+ "Sent request while region connection not available:",
269+ request);
270+ }
271+ // XXX mpontillo 2018-11-19: really we should handle errors here
272+ // in a more robust way, but that's a bigger change.
273+ this.websocket.send(angular.toJson(request));
274+ }
275+
276+ ping() {
277+ let request_id = this.newRequestId();
278+ let request = {
279+ type: MSG_TYPE.PING,
280+ request_id: request_id,
281+ };
282+ this.pingsInFlight.add(request_id);
283+ this.send(request);
284+ }
285+
286+ scheduleEnsureConnection() {
287+ if(this.ensureConnectionPromise) {
288+ this.$timeout.cancel(this.ensureConnectionPromise);
289+ this.ensureConnectionPromise = null;
290+ }
291+ this.ensureConnectionPromise = this.$timeout(
292+ this.ensureConnection.bind(this),
293+ this.connectionCheckInterval);
294+ }
295+
296+ ensureConnection() {
297+ this.scheduleEnsureConnection();
298+ if(this.state === REGION_STATE.UP) {
299+ let missedPings = this.pingsInFlight.size;
300+ let outstandingRequests = Object.keys(this.requests).length;
301+ let impatienceFactor = missedPings + outstandingRequests;
302+ if(missedPings > 0) {
303+ this.log.debug(
304+ "Still waiting for ping replies: ",
305+ this.pingsInFlight);
306+ }
307+ if(missedPings >= this.maxMissedPings) {
308+ // Assume the connection has timed out.
309+ this.log.warn("Closed connection: ping timeout.");
310+ this.retry();
311+ } else if(missedPings > 0 &&
312+ impatienceFactor >= this.maxPatience) {
313+ this.log.warn(
314+ "Closed connection: impatience factor exceeded.");
315+ this.retry();
316+ }
317+ else {
318+ this.ping();
319+ }
320+ } else if(this.state === REGION_STATE.RETRY) {
321+ // We know we're already in RETRY state, but call retry()
322+ // again to ensure we don't continue to create additional
323+ // WebSocket objects (without closing them).
324+ this.retry();
325+ this.log.debug("Attempting to reconnect...");
326+ this.connect();
327+ }
328+ }
329
330 // Opens the websocket connection.
331- RegionConnection.prototype.connect = function() {
332+ connect() {
333 this.url = this._buildUrl();
334- this.autoReconnect = true;
335 this.websocket = this.buildSocket(this.url);
336
337- var self = this;
338- this.websocket.onopen = function(evt) {
339- self.connected = true;
340- angular.forEach(self.handlers.open, function(func) {
341+ this.websocket.onopen = (evt) => {
342+ this.state = REGION_STATE.UP;
343+ this.pingsInFlight.clear();
344+ this.scheduleEnsureConnection();
345+ angular.forEach(this.handlers.open, (func) => {
346 func(evt);
347 });
348+ this.log.debug("WebSocket connection established.");
349 };
350- this.websocket.onerror = function(evt) {
351- angular.forEach(self.handlers.error, function(func) {
352+ this.websocket.onerror = (evt) => {
353+ this.log.error("WebSocket error: ", evt);
354+ angular.forEach(this.handlers.error, (func) => {
355 func(evt);
356 });
357 };
358- this.websocket.onclose = function(evt) {
359- self.connected = false;
360- self.error = "Unable to connect to: " + self.url.split("?")[0];
361- angular.forEach(self.handlers.close, function(func) {
362+ this.websocket.onclose = (evt) => {
363+ let url = this.url.split("?")[0];
364+ this.log.warn("WebSocket connection closed: " + url);
365+ angular.forEach(this.handlers.close, (func) => {
366 func(evt);
367 });
368- if(self.autoReconnect) {
369- $timeout(function() {
370- self.connect();
371- }, self.retryTimeout);
372- }
373+ this.websocket = null;
374+ this.retry();
375 };
376- this.websocket.onmessage = function(evt) {
377- self.onMessage(angular.fromJson(evt.data));
378+ this.websocket.onmessage = (evt) => {
379+ this.onMessage(angular.fromJson(evt.data));
380 };
381- };
382+ }
383
384- // Closes the websocket connection.
385- RegionConnection.prototype.close = function() {
386- this.autoReconnect = false;
387- this.websocket.close();
388- this.websocket = null;
389- };
390+ // Closes the websocket connection and begin to retry the connection.
391+ retry() {
392+ // Ensure the socket is closed, if applicable. If the WebSocket
393+ // is already null (such as what might happen if this method is
394+ // called from the onclose() hanlder), no need to close it again.
395+ if(this.websocket) {
396+ // Clear out event handlers. By now we already know the
397+ // websocket is closed; we don't want stray events from the
398+ // now-dead websocket to affect the existing connection.
399+ this.websocket.onopen = null;
400+ this.websocket.onerror = null;
401+ this.websocket.onclose = null;
402+ this.websocket.close();
403+ this.websocket = null;
404+ }
405+ // Set the state to RETRY so that ensureConnection will attempt
406+ // to reconnect the next time it runs.
407+ this.state = REGION_STATE.RETRY;
408+ }
409
410 // Return the protocol used for the websocket connection.
411- RegionConnection.prototype._getProtocol = function() {
412- return $window.location.protocol;
413- };
414+ _getProtocol() {
415+ return this.$window.location.protocol;
416+ }
417
418 // Return connection url to websocket from current location and
419 // html options.
420- RegionConnection.prototype._buildUrl = function() {
421- var host = $window.location.hostname;
422- var port = $window.location.port;
423- var path = $window.location.pathname;
424- var proto = 'ws';
425+ _buildUrl() {
426+ let host = this.$window.location.hostname;
427+ let port = this.$window.location.port;
428+ let path = this.$window.location.pathname;
429+ let proto = 'ws';
430 if (this._getProtocol() === 'https:') {
431 proto = 'wss';
432 }
433
434 // Path and port can be overridden by href and data-websocket-port
435 // in the base element respectively.
436- var base = angular.element("base");
437+ let base = angular.element("base");
438 if(angular.isDefined(base)) {
439- var newPath = base.attr("href");
440+ let newPath = base.attr("href");
441 if(angular.isDefined(newPath)) {
442 path = newPath;
443 }
444- var newPort = base.data("websocket-port");
445+ let newPort = base.data("websocket-port");
446 if(angular.isDefined(newPort)) {
447 port = newPort;
448 }
449@@ -195,122 +296,130 @@ angular.module('MAAS').factory(
450 }
451
452 // Build the URL. Include the :port only if it has a value.
453- url = proto + "://" + host;
454- if(angular.isString(port) && port.length > 0){
455+ let url = proto + "://" + host;
456+ if(angular.isString(port) && port.length > 0) {
457 url += ":" + port;
458 }
459 url += path + "ws";
460
461 // Include the csrftoken in the URL if it's defined.
462- var csrftoken;
463- if(angular.isFunction($cookies.get)) {
464- csrftoken = $cookies.get('csrftoken');
465+ let csrftoken;
466+ if(angular.isFunction(this.$cookies.get)) {
467+ csrftoken = this.$cookies.get('csrftoken');
468 } else {
469- csrftoken = $cookies.csrftoken;
470+ csrftoken = this.$cookies.csrftoken;
471 }
472 if(angular.isDefined(csrftoken)) {
473 url += '?csrftoken=' + encodeURIComponent(csrftoken);
474 }
475
476 return url;
477- };
478+ }
479
480 // Opens the default websocket connection.
481- RegionConnection.prototype.defaultConnect = function() {
482+ defaultConnect() {
483 // Already been called but the connection has not been completed.
484 if(angular.isObject(this.defaultConnectDefer)) {
485 return this.defaultConnectDefer.promise;
486 }
487
488 // Already connected.
489- var defer;
490+ let defer;
491 if(this.isConnected()) {
492 // Create a new defer as the defaultConnectDefer would
493 // have already been resolved.
494- defer = $q.defer();
495+ defer = this.$q.defer();
496
497 // Cannot resolve the defer inline as it hasn't been given
498 // back to the caller. It will be called in the next loop.
499- $timeout(defer.resolve);
500+ this.$timeout(defer.resolve);
501 return defer.promise;
502 }
503
504 // Start the connection.
505- var self = this, opened, errored;
506- defer = this.defaultConnectDefer = $q.defer();
507- opened = function(evt) {
508+ defer = this.defaultConnectDefer = this.$q.defer();
509+ let opened = (evt) => {
510 this.defaultConnectDefer = null;
511- self.unregisterHandler("open", opened);
512- self.unregisterHandler("error", errored);
513- $rootScope.$apply(defer.resolve(evt));
514+ this.unregisterHandler("open", opened);
515+ this.unregisterHandler("error", errored);
516+ this.$rootScope.$apply(defer.resolve(evt));
517 };
518- errored = function(evt) {
519+ let errored = (evt) => {
520 this.defaultConnectDefer = null;
521- self.unregisterHandler("open", opened);
522- self.unregisterHandler("error", errored);
523- $rootScope.$apply(defer.reject(evt));
524+ this.unregisterHandler("open", opened);
525+ this.unregisterHandler("error", errored);
526+ this.$rootScope.$apply(defer.reject(evt));
527 };
528 this.registerHandler("open", opened);
529 this.registerHandler("error", errored);
530 this.connect();
531 return defer.promise;
532- };
533+ }
534
535 // Called when a message is received.
536- RegionConnection.prototype.onMessage = function(msg) {
537- // Response
538+ onMessage(msg) {
539+ // Synchronous response
540 if(msg.type === MSG_TYPE.RESPONSE) {
541 this.onResponse(msg);
542- // Notify
543+ // Asynchronous notification
544 } else if(msg.type === MSG_TYPE.NOTIFY) {
545 this.onNotify(msg);
546+ // Reply to connectivity check
547+ } else if(msg.type === MSG_TYPE.PING_REPLY) {
548+ this.onPingReply(msg);
549 }
550- };
551+ }
552
553 // Called when a response message is recieved.
554- RegionConnection.prototype.onResponse = function(msg) {
555+ onResponse(msg) {
556 // Grab the registered defer from the callbacks list.
557- var defer = this.callbacks[msg.request_id];
558- var remembered_request = this.requests[msg.request_id];
559+ let defer = this.callbacks[msg.request_id];
560+ let remembered_request = this.requests[msg.request_id];
561 if(angular.isDefined(defer)) {
562 if(msg.rtype === RESPONSE_TYPE.SUCCESS) {
563 // Resolve the defer inside of the digest cycle, so any
564 // update to an object or collection will trigger a
565 // watcher.
566- $rootScope.$apply(defer.resolve(msg.result));
567+ this.$rootScope.$apply(defer.resolve(msg.result));
568 } else if(msg.rtype === RESPONSE_TYPE.ERROR) {
569 // Reject the defer since an error occurred.
570 if(angular.isObject(remembered_request)) {
571- $rootScope.$apply(defer.reject({
572+ this.$rootScope.$apply(defer.reject({
573 "error": msg.error,
574 "request": remembered_request
575 }));
576 } else {
577- $rootScope.$apply(defer.reject(msg.error));
578+ this.$rootScope.$apply(defer.reject(msg.error));
579 }
580 }
581 // Remove the defer from the callback list.
582 delete this.callbacks[msg.request_id];
583 delete this.requests[msg.request_id];
584 }
585- };
586+ }
587
588 // Called when a notify response is recieved.
589- RegionConnection.prototype.onNotify = function(msg) {
590- var handlers = this.notifiers[msg.name];
591+ onNotify(msg) {
592+ let handlers = this.notifiers[msg.name];
593 if(angular.isArray(handlers)) {
594 angular.forEach(handlers, function(handler) {
595 handler(msg.action, msg.data);
596 });
597 }
598- };
599+ }
600+
601+ onPingReply(msg) {
602+ // Note: The msg.result at this point contains the last sequence
603+ // number received, but it isn't really relevant for us. It could
604+ // be helpful for debug logging, if necessary.
605+ this.pingsInFlight.delete(msg.request_id);
606+ }
607
608 // Call method on the region.
609- RegionConnection.prototype.callMethod = function(
610- method, params, remember) {
611- var defer = $q.defer();
612- var request_id = this.newRequestId();
613- var request = {
614+ callMethod(method, params, remember) {
615+ let defer = this.$q.defer();
616+ let request_id = this.newRequestId();
617+ let request = {
618 type: MSG_TYPE.REQUEST,
619 request_id: request_id,
620 method: method,
621@@ -322,9 +431,9 @@ angular.module('MAAS').factory(
622 if (remember) {
623 this.requests[request_id] = request;
624 }
625- this.websocket.send(angular.toJson(request));
626+ this.send(request);
627+ // Uncomment this to log every WebSocket method call.
628+ // this.log.debug("callMethod(): ", request, remember);
629 return defer.promise;
630- };
631-
632- return new RegionConnection();
633- }]);
634+ }
635+ }
636diff --git a/src/maasserver/static/js/angular/factories/tests/test_region.js b/src/maasserver/static/js/angular/factories/tests/test_region.js
637index c5e6d8c..57db53a 100644
638--- a/src/maasserver/static/js/angular/factories/tests/test_region.js
639+++ b/src/maasserver/static/js/angular/factories/tests/test_region.js
640@@ -20,7 +20,7 @@ describe("RegionConnection", function() {
641 }));
642
643 // Load the RegionConnection factory.
644- var RegionConnection, webSocket;
645+ let RegionConnection, webSocket;
646 beforeEach(inject(function($injector) {
647 RegionConnection = $injector.get("RegionConnection");
648
649@@ -186,17 +186,93 @@ describe("RegionConnection", function() {
650
651 describe("isConnected", function() {
652
653- it("returns true", function() {
654- RegionConnection.connected = true;
655+ it("returns true in STATE.UP", function() {
656+ RegionConnection.state = RegionConnection.STATE.UP;
657 expect(RegionConnection.isConnected()).toBe(true);
658 });
659
660- it("returns false", function() {
661- RegionConnection.connected = false;
662+ it("returns false in STATE.DOWN", function() {
663+ RegionConnection.state = RegionConnection.STATE.DOWN;
664+ expect(RegionConnection.isConnected()).toBe(false);
665+ });
666+
667+ it("returns false in STATE.RETRY", function() {
668+ RegionConnection.state = RegionConnection.STATE.RETRY;
669 expect(RegionConnection.isConnected()).toBe(false);
670 });
671 });
672
673+ describe("scheduleEnsureConnection", function() {
674+
675+ it("schedules ensureConnection to run", function() {
676+ spyOn(RegionConnection, "ensureConnection");
677+ RegionConnection.scheduleEnsureConnection();
678+ $timeout.flush();
679+ expect(RegionConnection.ensureConnection).toHaveBeenCalled();
680+ });
681+
682+ it("does not schedule itself multiple times", function() {
683+ spyOn(RegionConnection, "ensureConnection");
684+ RegionConnection.scheduleEnsureConnection();
685+ RegionConnection.scheduleEnsureConnection();
686+ RegionConnection.scheduleEnsureConnection();
687+ RegionConnection.scheduleEnsureConnection();
688+ RegionConnection.scheduleEnsureConnection();
689+ $timeout.flush();
690+ expect(RegionConnection.ensureConnection).toHaveBeenCalledTimes(1);
691+ });
692+
693+ });
694+
695+ describe("ensureConnection", function() {
696+
697+ beforeEach(function() {
698+ spyOn(RegionConnection, "scheduleEnsureConnection");
699+ spyOn(RegionConnection, "retry");
700+ spyOn(RegionConnection, "connect");
701+ spyOn(RegionConnection, "ping");
702+ });
703+
704+ it("schedules itself to run again later", function() {
705+ RegionConnection.ensureConnection();
706+ expect(
707+ RegionConnection.scheduleEnsureConnection).toHaveBeenCalled();
708+ });
709+
710+ it("calls retry() if in UP state with missed pings", function() {
711+ RegionConnection.state = RegionConnection.STATE.UP;
712+ for(let i = 0; i < RegionConnection.maxMissedPings ; i++) {
713+ RegionConnection.pingsInFlight.add(i);
714+ }
715+ RegionConnection.ensureConnection();
716+ expect(RegionConnection.retry).toHaveBeenCalled();
717+ });
718+
719+ it("calls retry() if UP with missed requests and pings", function() {
720+ RegionConnection.state = RegionConnection.STATE.UP;
721+ RegionConnection.pingsInFlight.add(1);
722+ for(let i = 0; i < RegionConnection.maxPatience - 1 ; i++) {
723+ RegionConnection.requests[i] = {};
724+ }
725+ RegionConnection.ensureConnection();
726+ expect(RegionConnection.retry).toHaveBeenCalled();
727+ });
728+
729+ it("calls ping() if in UP state", function() {
730+ RegionConnection.state = RegionConnection.STATE.UP;
731+ RegionConnection.ensureConnection();
732+ expect(RegionConnection.ping).toHaveBeenCalled();
733+ });
734+
735+ it("calls retry() and connect() if in RETRY state", function() {
736+ RegionConnection.state = RegionConnection.STATE.RETRY;
737+ RegionConnection.ensureConnection();
738+ expect(RegionConnection.retry).toHaveBeenCalled();
739+ expect(RegionConnection.connect).toHaveBeenCalled();
740+ });
741+
742+ });
743+
744 describe("connect", function() {
745
746 var url = "http://test-url", buildUrlSpy;
747@@ -210,12 +286,6 @@ describe("RegionConnection", function() {
748 expect(RegionConnection.url).toBe(url);
749 });
750
751- it("sets autoReconnect to true", function() {
752- RegionConnection.autoReconnect = false;
753- RegionConnection.connect();
754- expect(RegionConnection.autoReconnect).toBe(true);
755- });
756-
757 it("calls buildSocket with url", function() {
758 RegionConnection.connect();
759 expect(RegionConnection.buildSocket).toHaveBeenCalledWith(url);
760@@ -231,7 +301,7 @@ describe("RegionConnection", function() {
761 it("sets connect to true when onopen called", function() {
762 RegionConnection.connect();
763 webSocket.onopen({});
764- expect(RegionConnection.connected).toBe(true);
765+ expect(RegionConnection.isConnected()).toBe(true);
766 });
767
768 it("calls error handler when onerror called", function(done) {
769@@ -244,39 +314,20 @@ describe("RegionConnection", function() {
770 webSocket.onerror(evt_obj);
771 });
772
773- it("sets connect to false when onclose called", function() {
774- RegionConnection.autoReconnect = false;
775+ it("isConnected() is false when onclose called", function() {
776 RegionConnection.connect();
777 webSocket.onclose({});
778- expect(RegionConnection.connected).toBe(false);
779+ expect(RegionConnection.isConnected()).toBe(false);
780 });
781
782 it("calls close handler when onclose called", function(done) {
783 var evt_obj = {};
784- RegionConnection.autoReconnect = false;
785- RegionConnection.connect();
786 RegionConnection.registerHandler("close", function(evt) {
787 expect(evt).toBe(evt_obj);
788 done();
789 });
790- webSocket.onclose(evt_obj);
791- });
792-
793- it("onclose calls connect when autoReconnect is true", function() {
794- RegionConnection.connect();
795- var new_url = "http://new-url";
796- buildUrlSpy.and.returnValue(new_url);
797- webSocket.onclose({});
798- $timeout.flush();
799- expect(RegionConnection.url).toBe(new_url);
800- });
801-
802- it("onclose sets error", function() {
803 RegionConnection.connect();
804- webSocket.onclose();
805- $timeout.flush();
806- expect(RegionConnection.error).toBe(
807- "Unable to connect to: " + url);
808+ webSocket.onclose(evt_obj);
809 });
810
811 it("calls onMessage when onmessage called", function() {
812@@ -289,29 +340,38 @@ describe("RegionConnection", function() {
813 });
814 });
815
816- describe("close", function() {
817+ describe("retry", function() {
818
819 beforeEach(function() {
820 spyOn(webSocket, "close");
821 });
822
823- it("sets autoReconnect to false", function() {
824+ it("sets state to RETRY", function() {
825 RegionConnection.connect("");
826- RegionConnection.close();
827- expect(RegionConnection.autoReconnect).toBe(false);
828+ RegionConnection.retry();
829+ expect(RegionConnection.state).toBe(RegionConnection.STATE.RETRY);
830 });
831
832 it("calls close on websocket", function() {
833 RegionConnection.connect("");
834- RegionConnection.close();
835+ RegionConnection.retry();
836 expect(webSocket.close).toHaveBeenCalled();
837 });
838
839 it("sets websocket to null", function() {
840 RegionConnection.connect("");
841- RegionConnection.close();
842+ RegionConnection.retry();
843 expect(RegionConnection.websocket).toBeNull();
844 });
845+
846+ it("clears out event handlers", function() {
847+ RegionConnection.connect("");
848+ let ws = RegionConnection.websocket;
849+ RegionConnection.retry();
850+ expect(ws.onopen).toBeNull();
851+ expect(ws.onerror).toBeNull();
852+ expect(ws.onclose).toBeNull();
853+ });
854 });
855
856 describe("_getProtocol", function() {
857@@ -413,7 +473,7 @@ describe("RegionConnection", function() {
858 describe("defaultConnect", function() {
859
860 it("resolve defer if already connected", function(done) {
861- RegionConnection.connected = true;
862+ RegionConnection.state = RegionConnection.STATE.UP;
863 RegionConnection.defaultConnect().then(function() {
864 done();
865 });
866diff --git a/src/maasserver/static/js/angular/services/log.js b/src/maasserver/static/js/angular/services/log.js
867index f2378ab..894ae03 100644
868--- a/src/maasserver/static/js/angular/services/log.js
869+++ b/src/maasserver/static/js/angular/services/log.js
870@@ -51,7 +51,7 @@ angular.module('MAAS').service('LogService', [
871 // Add time index to the beginning of the log.
872 Array.prototype.unshift.call(args,
873 "[" + self.formatMilliseconds(self.now()) + "]");
874- destination.apply(self, args);
875+ destination.apply(console, args);
876 }
877 };
878
879diff --git a/src/maasserver/static/js/angular/testing/websocket.js b/src/maasserver/static/js/angular/testing/websocket.js
880index f32fd42..54aeeb1 100644
881--- a/src/maasserver/static/js/angular/testing/websocket.js
882+++ b/src/maasserver/static/js/angular/testing/websocket.js
883@@ -13,9 +13,13 @@ var READY_STATES = {
884 CLOSED: 3
885 };
886
887-var MSG_TYPE = {
888+// Message types (copied from region.js)
889+const MSG_TYPE = {
890 REQUEST: 0,
891- RESPONSE: 1
892+ RESPONSE: 1,
893+ NOTIFY: 2,
894+ PING: 3,
895+ PING_REPLY: 4
896 };
897
898 function MockWebSocket(url) {
899@@ -26,12 +30,16 @@ function MockWebSocket(url) {
900 this.onmessage = null;
901 this.onopen = null;
902
903+ this.replyToPing = true;
904+
905 this.sentData = [];
906 this.returnData = [];
907 this.receivedData = [];
908
909+ this.sequenceNum = 0;
910+
911 // Simulate the connection opening.
912- var self = this;
913+ let self = this;
914 setTimeout(function() {
915 self.readyState = READY_STATES.OPEN;
916 if(angular.isFunction(self.onopen)) {
917@@ -44,7 +52,7 @@ MockWebSocket.prototype.close = function() {
918 this.readyState = READY_STATES.CLOSING;
919
920 // Simulate the connection closing.
921- var self = this;
922+ let self = this;
923 setTimeout(function() {
924 self.readyState = READY_STATES.CLOSED;
925 if(angular.isFunction(self.onclose)) {
926@@ -54,6 +62,26 @@ MockWebSocket.prototype.close = function() {
927 };
928
929 MockWebSocket.prototype.send = function(data) {
930+ let sentObject = angular.fromJson(data);
931+ let sentType = sentObject.type;
932+ let sentId = sentObject.request_id;
933+ let self = this;
934+
935+ // Special case for PING type; just reply and return.
936+ if(self.replyToPing && angular.isNumber(sentType) &&
937+ sentType === MSG_TYPE.PING) {
938+ setTimeout(function() {
939+ self.sequenceNum += 1;
940+ let pingResultData = angular.toJson({
941+ type: MSG_TYPE.PING_REPLY,
942+ request_id: sentId,
943+ result: self.sequenceNum
944+ });
945+ self.onmessage({ data: pingResultData });
946+ });
947+ return;
948+ }
949+
950 this.sentData.push(data);
951
952 // Exit early if no fake data to return.
953@@ -68,13 +96,9 @@ MockWebSocket.prototype.send = function(data) {
954 receivedData = [receivedData];
955 }
956
957- var self = this;
958
959 // Send the response
960 setTimeout(function() {
961- var sentObject = angular.fromJson(data);
962- var sentType = sentObject.type;
963- var sentId = sentObject.request_id;
964 if(angular.isNumber(sentType) && angular.isNumber(sentId)) {
965 // Patch the request_id so the response is the
966 // same as the request.
967diff --git a/webpack.config.js b/webpack.config.js
968index 9880aff..b02d7ce 100644
969--- a/webpack.config.js
970+++ b/webpack.config.js
971@@ -27,6 +27,7 @@ module.exports = {
972 plugins: [
973 new UglifyJSPlugin({
974 parallel: true,
975+ cache: true,
976 sourceMap: true,
977 uglifyOptions: {
978 // Using the 'mangle' option breaks the Angular injector.

Subscribers

People subscribed via source and target branches