diff --git a/ui-v2/app/adapters/application.js b/ui-v2/app/adapters/application.js index c673abbb6..97e86bd55 100644 --- a/ui-v2/app/adapters/application.js +++ b/ui-v2/app/adapters/application.js @@ -1,10 +1,12 @@ import Adapter from 'ember-data/adapters/rest'; import { AbortError } from 'ember-data/adapters/errors'; import { inject as service } from '@ember/service'; +import { get } from '@ember/object'; import URL from 'url'; import createURL from 'consul-ui/utils/createURL'; import { FOREIGN_KEY as DATACENTER_KEY } from 'consul-ui/models/dc'; +import { HEADERS_SYMBOL as HTTP_HEADERS_SYMBOL } from 'consul-ui/utils/http/consul'; export const REQUEST_CREATE = 'createRecord'; export const REQUEST_READ = 'queryRecord'; @@ -14,10 +16,31 @@ export const REQUEST_DELETE = 'deleteRecord'; export const DATACENTER_QUERY_PARAM = 'dc'; -import { HEADERS_SYMBOL as HTTP_HEADERS_SYMBOL } from 'consul-ui/utils/http/consul'; export default Adapter.extend({ namespace: 'v1', repo: service('settings'), + client: service('client/http'), + manageConnection: function(options) { + const client = get(this, 'client'); + const complete = options.complete; + const beforeSend = options.beforeSend; + options.beforeSend = function(xhr) { + if (typeof beforeSend === 'function') { + beforeSend(...arguments); + } + options.id = client.request(options, xhr); + }; + options.complete = function(xhr, textStatus) { + client.complete(options.id); + if (typeof complete === 'function') { + complete(...arguments); + } + }; + return options; + }, + _ajaxRequest: function(options) { + return this._super(this.manageConnection(options)); + }, queryRecord: function() { return this._super(...arguments).catch(function(e) { if (e instanceof AbortError) { diff --git a/ui-v2/app/initializers/client.js b/ui-v2/app/initializers/client.js new file mode 100644 index 000000000..933290b9e --- /dev/null +++ b/ui-v2/app/initializers/client.js @@ -0,0 +1,15 @@ +const scripts = document.getElementsByTagName('script'); +const current = scripts[scripts.length - 1]; + +export function initialize(application) { + const Client = application.resolveRegistration('service:client/http'); + Client.reopen({ + isCurrent: function(src) { + return current.src === src; + }, + }); +} + +export default { + initialize, +}; diff --git a/ui-v2/app/services/client/http.js b/ui-v2/app/services/client/http.js new file mode 100644 index 000000000..1290d2be7 --- /dev/null +++ b/ui-v2/app/services/client/http.js @@ -0,0 +1,87 @@ +import Service, { inject as service } from '@ember/service'; +import { get, set } from '@ember/object'; +import { Promise } from 'rsvp'; + +import getObjectPool from 'consul-ui/utils/get-object-pool'; +import Request from 'consul-ui/utils/http/request'; + +const dispose = function(request) { + if (request.headers()['content-type'] === 'text/event-stream') { + const xhr = request.connection(); + // unsent and opened get aborted + // headers and loading means wait for it + // to finish for the moment + if (xhr.readyState) { + switch (xhr.readyState) { + case 0: + case 1: + xhr.abort(); + break; + } + } + } + return request; +}; +export default Service.extend({ + dom: service('dom'), + init: function() { + this._super(...arguments); + let protocol = 'http/1.1'; + try { + protocol = performance.getEntriesByType('resource').find(item => { + // isCurrent is added in initializers/client and is used + // to ensure we use the consul-ui.js src to sniff what the protocol + // is. Based on the assumption that whereever this script is it's + // likely to be the same as the xmlhttprequests + return item.initiatorType === 'script' && this.isCurrent(item.name); + }).nextHopProtocol; + } catch (e) { + // pass through + } + let maxConnections; + // http/2, http2+QUIC/39 and SPDY don't have connection limits + switch (true) { + case protocol.indexOf('h2') === 0: + case protocol.indexOf('hq') === 0: + case protocol.indexOf('spdy') === 0: + break; + default: + // generally 6 are available + // reserve 1 for traffic that we can't manage + maxConnections = 5; + break; + } + set(this, 'connections', getObjectPool(dispose, maxConnections)); + if (typeof maxConnections !== 'undefined') { + set(this, 'maxConnections', maxConnections); + const doc = get(this, 'dom').document(); + // when the user hides the tab, abort all connections + doc.addEventListener('visibilitychange', e => { + if (e.target.hidden) { + get(this, 'connections').purge(); + } + }); + } + }, + whenAvailable: function(e) { + const doc = get(this, 'dom').document(); + // if we are using a connection limited protocol and the user has hidden the tab (hidden browser/tab switch) + // any aborted errors should restart + if (typeof get(this, 'maxConnections') !== 'undefined' && doc.hidden) { + return new Promise(function(resolve) { + doc.addEventListener('visibilitychange', function listen(event) { + doc.removeEventListener('visibilitychange', listen); + resolve(e); + }); + }); + } + return Promise.resolve(e); + }, + request: function(options, xhr) { + const request = new Request(options.type, options.url, { body: options.data || {} }, xhr); + return get(this, 'connections').acquire(request, request.getId()); + }, + complete: function() { + return get(this, 'connections').release(...arguments); + }, +}); diff --git a/ui-v2/app/services/dom.js b/ui-v2/app/services/dom.js index d0790fb12..a3bda1c8c 100644 --- a/ui-v2/app/services/dom.js +++ b/ui-v2/app/services/dom.js @@ -50,7 +50,7 @@ export default Service.extend({ }, elementsByTagName: function(name, context) { context = typeof context === 'undefined' ? get(this, 'doc') : context; - return context.getElementByTagName(name); + return context.getElementsByTagName(name); }, elements: function(selector, context) { // don't ever be tempted to [...$$()] here diff --git a/ui-v2/app/utils/get-object-pool.js b/ui-v2/app/utils/get-object-pool.js new file mode 100644 index 000000000..ad9f0ebe8 --- /dev/null +++ b/ui-v2/app/utils/get-object-pool.js @@ -0,0 +1,52 @@ +export default function(dispose = function() {}, max, objects = []) { + return { + acquire: function(obj, id) { + // TODO: what should happen if an ID already exists + // should we ignore and release both? Or prevent from acquiring? Or generate a unique ID? + // what happens if we can't get an id via getId or .id? + // could potentially use Set + objects.push(obj); + if (typeof max !== 'undefined') { + if (objects.length > max) { + return dispose(objects.shift()); + } + } + return id; + }, + // release releases the obj from the pool but **doesn't** dispose it + release: function(obj) { + let index = -1; + let id; + if (typeof obj === 'string') { + id = obj; + } else { + id = obj.id; + } + objects.forEach(function(item, i) { + let itemId; + if (typeof item.getId === 'function') { + itemId = item.getId(); + } else { + itemId = item.id; + } + if (itemId === id) { + index = i; + } + }); + if (index !== -1) { + return objects.splice(index, 1)[0]; + } + }, + purge: function() { + let obj; + const objs = []; + while ((obj = objects.shift())) { + objs.push(dispose(obj)); + } + return objs; + }, + dispose: function(id) { + return dispose(this.release(id)); + }, + }; +} diff --git a/ui-v2/app/utils/http/request.js b/ui-v2/app/utils/http/request.js new file mode 100644 index 000000000..1a9643d51 --- /dev/null +++ b/ui-v2/app/utils/http/request.js @@ -0,0 +1,29 @@ +export default class { + constructor(method, url, headers, xhr) { + this._xhr = xhr; + this._url = url; + this._method = method; + this._headers = headers; + this._headers = { + ...headers, + 'content-type': 'application/json', + 'x-request-id': `${this._method} ${this._url}?${JSON.stringify(headers.body)}`, + }; + if (typeof this._headers.body.index !== 'undefined') { + // this should probably be in a response + this._headers['content-type'] = 'text/event-stream'; + } + } + headers() { + return this._headers; + } + getId() { + return this._headers['x-request-id']; + } + abort() { + this._xhr.abort(); + } + connection() { + return this._xhr; + } +} diff --git a/ui-v2/tests/integration/services/repository/intention-test.js b/ui-v2/tests/integration/services/repository/intention-test.js index bedfbb9f2..59797baa4 100644 --- a/ui-v2/tests/integration/services/repository/intention-test.js +++ b/ui-v2/tests/integration/services/repository/intention-test.js @@ -2,14 +2,7 @@ import { moduleFor, test } from 'ember-qunit'; import repo from 'consul-ui/tests/helpers/repo'; const NAME = 'intention'; moduleFor(`service:repository/${NAME}`, `Integration | Service | ${NAME}`, { - // Specify the other units that are required for this test. - needs: [ - 'service:settings', - 'service:store', - `adapter:${NAME}`, - `serializer:${NAME}`, - `model:${NAME}`, - ], + integration: true, }); const dc = 'dc-1'; diff --git a/ui-v2/tests/unit/services/client/http-test.js b/ui-v2/tests/unit/services/client/http-test.js new file mode 100644 index 000000000..98ff23ff4 --- /dev/null +++ b/ui-v2/tests/unit/services/client/http-test.js @@ -0,0 +1,12 @@ +import { moduleFor, test } from 'ember-qunit'; + +moduleFor('service:client/http', 'Unit | Service | client/http', { + // Specify the other units that are required for this test. + needs: ['service:dom'], +}); + +// Replace this with your real tests. +test('it exists', function(assert) { + let service = this.subject(); + assert.ok(service); +}); diff --git a/ui-v2/tests/unit/utils/get-object-pool-test.js b/ui-v2/tests/unit/utils/get-object-pool-test.js new file mode 100644 index 000000000..9a8203946 --- /dev/null +++ b/ui-v2/tests/unit/utils/get-object-pool-test.js @@ -0,0 +1,98 @@ +import getObjectPool from 'consul-ui/utils/get-object-pool'; +import { module, skip } from 'qunit'; +import test from 'ember-sinon-qunit/test-support/test'; + +module('Unit | Utility | get object pool'); + +skip('Decide what to do if you add 2 objects with the same id'); +test('acquire adds objects', function(assert) { + const actual = []; + const expected = { + hi: 'there', + id: 'hi-there-123', + }; + const expected2 = { + hi: 'there', + id: 'hi-there-456', + }; + const pool = getObjectPool(function() {}, 10, actual); + pool.acquire(expected, expected.id); + assert.deepEqual(actual[0], expected); + pool.acquire(expected2, expected2.id); + assert.deepEqual(actual[1], expected2); +}); +test('acquire adds objects and returns the id', function(assert) { + const arr = []; + const expected = 'hi-there-123'; + const obj = { + hi: 'there', + id: expected, + }; + const pool = getObjectPool(function() {}, 10, arr); + const actual = pool.acquire(obj, expected); + assert.equal(actual, expected); +}); +test('acquire adds objects, and disposes when there is no room', function(assert) { + const actual = []; + const expected = { + hi: 'there', + id: 'hi-there-123', + }; + const expected2 = { + hi: 'there', + id: 'hi-there-456', + }; + const dispose = this.stub() + .withArgs(expected) + .returnsArg(0); + const pool = getObjectPool(dispose, 1, actual); + pool.acquire(expected, expected.id); + assert.deepEqual(actual[0], expected); + pool.acquire(expected2, expected2.id); + assert.deepEqual(actual[0], expected2); + assert.ok(dispose.calledOnce); +}); +test('it disposes', function(assert) { + const arr = []; + const expected = { + hi: 'there', + id: 'hi-there-123', + }; + const expected2 = { + hi: 'there', + id: 'hi-there-456', + }; + const dispose = this.stub().returnsArg(0); + const pool = getObjectPool(dispose, 2, arr); + const id = pool.acquire(expected, expected.id); + assert.deepEqual(arr[0], expected); + pool.acquire(expected2, expected2.id); + assert.deepEqual(arr[1], expected2); + const actual = pool.dispose(id); + assert.ok(dispose.calledOnce); + assert.equal(arr.length, 1, 'object was removed from array'); + assert.deepEqual(actual, expected, 'returned object is expected object'); + assert.deepEqual(arr[0], expected2, 'object in the pool is expected object'); +}); +test('it purges', function(assert) { + const arr = []; + const expected = { + hi: 'there', + id: 'hi-there-123', + }; + const expected2 = { + hi: 'there', + id: 'hi-there-456', + }; + const dispose = this.stub().returnsArg(0); + const pool = getObjectPool(dispose, 2, arr); + pool.acquire(expected, expected.id); + assert.deepEqual(arr[0], expected); + pool.acquire(expected2, expected2.id); + assert.deepEqual(arr[1], expected2); + const actual = pool.purge(); + assert.ok(dispose.calledTwice, 'dispose was called on everything'); + assert.equal(arr.length, 0, 'the pool is empty'); + assert.deepEqual(actual[0], expected, 'the first purged object is correct'); + assert.deepEqual(actual[1], expected2, 'the second purged object is correct'); +}); diff --git a/ui-v2/tests/unit/utils/http/request-test.js b/ui-v2/tests/unit/utils/http/request-test.js new file mode 100644 index 000000000..c10dcbd89 --- /dev/null +++ b/ui-v2/tests/unit/utils/http/request-test.js @@ -0,0 +1,10 @@ +import httpRequest from 'consul-ui/utils/http/request'; +import { module, test } from 'qunit'; + +module('Unit | Utility | http/request'); + +// Replace this with your real tests. +test('it works', function(assert) { + const actual = httpRequest; + assert.ok(typeof actual === 'function'); +});