From 65ef2969c7baa40bdd7d1641d8cbaf5bf7c6d9e7 Mon Sep 17 00:00:00 2001 From: John Cowen Date: Tue, 6 Nov 2018 09:10:20 +0000 Subject: [PATCH 01/52] ui: Async Search (#4859) This does several things to make improving the search experience easier moving forwards: 1. Separate searching off from filtering. 'Searching' can be thought of as specifically 'text searching' whilst filtering is more of a boolean/flag search. 2. Decouple the actual searching functionality to almost pure, isolated / unit testable units and unit test. (I still import embers get which, once I upgrade to 3.5, I shouldn't need) 3. Searching rules are now configurable from the outside, i.e. not wrapped in Controllers or Components. 4. General searching itself now can use an asynchronous approach based on events. This prepares for future possibilities of handing off the searching to a web worker or elsewhere, which should aid in large scale searching and prepares the way for other searching methods. 5. Adds the possibility of have multiple searches in one template/route/page. Additionally, this adds a WithSearching mixin to aid linking the searching to ember in an ember-like way in a single place. Plus a WithListeners mixin to aid with cleaning up of event listeners on Controller/Component destruction. Post-initial work I slightly changed the API of create listeners: Returning the handler from a `remover` means you can re-add it again if you want to, this avoids having to save a reference to the handler elsewhere to do the same. The `remove` method itself now returns an array of handlers, again you might want to use these again or something, and its also more useful then just returning an empty array. The more I look at this the more I doubt that you'll ever use `remove` to remove individual handlers, you may aswell just use the `remover` returned from add. I've added some comments to reflect this, but they'll likely be removed once I'm absolutely sure of this. I also added some comments for WithSearching to explain possible further work re: moving `searchParams` so it can be `hung` off the controller object --- ui-v2/app/components/changeable-set.js | 19 ++ ui-v2/app/components/freetext-filter.js | 12 +- .../app/controllers/dc/acls/policies/index.js | 26 +- ui-v2/app/controllers/dc/acls/tokens/index.js | 32 +-- ui-v2/app/controllers/dc/intentions/index.js | 26 +- ui-v2/app/controllers/dc/kv/index.js | 22 +- ui-v2/app/controllers/dc/nodes/index.js | 24 +- ui-v2/app/controllers/dc/nodes/show.js | 35 ++- ui-v2/app/controllers/dc/services/index.js | 25 +- ui-v2/app/controllers/dc/services/show.js | 29 ++- ui-v2/app/initializers/search.js | 37 +++ ui-v2/app/mixins/with-listeners.js | 27 ++ ui-v2/app/mixins/with-searching.js | 32 +++ ui-v2/app/search/filters/intention.js | 15 ++ ui-v2/app/search/filters/kv.js | 10 + ui-v2/app/search/filters/node.js | 11 + ui-v2/app/search/filters/node/service.js | 21 ++ ui-v2/app/search/filters/policy.js | 14 ++ ui-v2/app/search/filters/service.js | 14 ++ ui-v2/app/search/filters/service/node.js | 14 ++ ui-v2/app/search/filters/token.js | 21 ++ ui-v2/app/services/dom.js | 2 + ui-v2/app/services/search.js | 2 + .../templates/components/catalog-filter.hbs | 2 +- .../templates/components/changeable-set.hbs | 6 + .../templates/components/intention-filter.hbs | 2 +- .../app/templates/dc/acls/policies/index.hbs | 107 ++++---- ui-v2/app/templates/dc/acls/tokens/index.hbs | 230 +++++++++--------- ui-v2/app/templates/dc/intentions/index.hbs | 127 +++++----- ui-v2/app/templates/dc/kv/index.hbs | 77 +++--- ui-v2/app/templates/dc/nodes/-services.hbs | 77 +++--- ui-v2/app/templates/dc/nodes/index.hbs | 62 +++-- ui-v2/app/templates/dc/services/index.hbs | 98 ++++---- ui-v2/app/templates/dc/services/show.hbs | 60 +++-- ui-v2/app/utils/dom/create-listeners.js | 32 +++ ui-v2/app/utils/search/filterable.js | 38 +++ .../components/changeable-set-test.js | 33 +++ .../dc/acls/policies/create-test.js | 2 +- .../dc/acls/policies/index-test.js | 2 +- .../controllers/dc/acls/tokens/index-test.js | 2 +- .../controllers/dc/intentions/index-test.js | 2 +- .../unit/controllers/dc/kv/folder-test.js | 2 +- .../unit/controllers/dc/kv/index-test.js | 2 +- .../unit/controllers/dc/nodes/index-test.js | 2 +- .../unit/controllers/dc/nodes/show-test.js | 2 +- .../controllers/dc/services/index-test.js | 2 +- .../unit/controllers/dc/services/show-test.js | 2 +- .../tests/unit/mixins/with-listeners-test.js | 21 ++ .../tests/unit/mixins/with-searching-test.js | 21 ++ .../unit/search/filters/intention-test.js | 72 ++++++ ui-v2/tests/unit/search/filters/kv-test.js | 36 +++ ui-v2/tests/unit/search/filters/node-test.js | 30 +++ .../unit/search/filters/node/service-test.js | 94 +++++++ .../tests/unit/search/filters/policy-test.js | 36 +++ .../tests/unit/search/filters/service-test.js | 59 +++++ .../unit/search/filters/service/node-test.js | 56 +++++ ui-v2/tests/unit/search/filters/token-test.js | 86 +++++++ ui-v2/tests/unit/services/search-test.js | 12 + .../unit/utils/dom/create-listeners-test.js | 79 ++++++ .../unit/utils/search/filterable-test.js | 10 + 60 files changed, 1543 insertions(+), 510 deletions(-) create mode 100644 ui-v2/app/components/changeable-set.js create mode 100644 ui-v2/app/initializers/search.js create mode 100644 ui-v2/app/mixins/with-listeners.js create mode 100644 ui-v2/app/mixins/with-searching.js create mode 100644 ui-v2/app/search/filters/intention.js create mode 100644 ui-v2/app/search/filters/kv.js create mode 100644 ui-v2/app/search/filters/node.js create mode 100644 ui-v2/app/search/filters/node/service.js create mode 100644 ui-v2/app/search/filters/policy.js create mode 100644 ui-v2/app/search/filters/service.js create mode 100644 ui-v2/app/search/filters/service/node.js create mode 100644 ui-v2/app/search/filters/token.js create mode 100644 ui-v2/app/services/search.js create mode 100644 ui-v2/app/templates/components/changeable-set.hbs create mode 100644 ui-v2/app/utils/dom/create-listeners.js create mode 100644 ui-v2/app/utils/search/filterable.js create mode 100644 ui-v2/tests/integration/components/changeable-set-test.js create mode 100644 ui-v2/tests/unit/mixins/with-listeners-test.js create mode 100644 ui-v2/tests/unit/mixins/with-searching-test.js create mode 100644 ui-v2/tests/unit/search/filters/intention-test.js create mode 100644 ui-v2/tests/unit/search/filters/kv-test.js create mode 100644 ui-v2/tests/unit/search/filters/node-test.js create mode 100644 ui-v2/tests/unit/search/filters/node/service-test.js create mode 100644 ui-v2/tests/unit/search/filters/policy-test.js create mode 100644 ui-v2/tests/unit/search/filters/service-test.js create mode 100644 ui-v2/tests/unit/search/filters/service/node-test.js create mode 100644 ui-v2/tests/unit/search/filters/token-test.js create mode 100644 ui-v2/tests/unit/services/search-test.js create mode 100644 ui-v2/tests/unit/utils/dom/create-listeners-test.js create mode 100644 ui-v2/tests/unit/utils/search/filterable-test.js diff --git a/ui-v2/app/components/changeable-set.js b/ui-v2/app/components/changeable-set.js new file mode 100644 index 0000000000..f9d88d91c4 --- /dev/null +++ b/ui-v2/app/components/changeable-set.js @@ -0,0 +1,19 @@ +import Component from '@ember/component'; +import { get, set } from '@ember/object'; +import SlotsMixin from 'ember-block-slots'; +import WithListeners from 'consul-ui/mixins/with-listeners'; + +export default Component.extend(WithListeners, SlotsMixin, { + tagName: '', + didReceiveAttrs: function() { + this._super(...arguments); + this.removeListeners(); + const dispatcher = get(this, 'dispatcher'); + if (dispatcher) { + this.listen(dispatcher, 'change', e => { + set(this, 'items', e.target.data); + }); + set(this, 'items', get(dispatcher, 'data')); + } + }, +}); diff --git a/ui-v2/app/components/freetext-filter.js b/ui-v2/app/components/freetext-filter.js index b5fc2d39ef..d776119ecf 100644 --- a/ui-v2/app/components/freetext-filter.js +++ b/ui-v2/app/components/freetext-filter.js @@ -1,7 +1,15 @@ import Component from '@ember/component'; - +import { get } from '@ember/object'; export default Component.extend({ tagName: 'fieldset', classNames: ['freetext-filter'], - onchange: function(){} + onchange: function(e) { + let searchable = get(this, 'searchable'); + if (!Array.isArray(searchable)) { + searchable = [searchable]; + } + searchable.forEach(function(item) { + item.search(e.target.value); + }); + }, }); diff --git a/ui-v2/app/controllers/dc/acls/policies/index.js b/ui-v2/app/controllers/dc/acls/policies/index.js index 534a82cda2..ea6142dbd7 100644 --- a/ui-v2/app/controllers/dc/acls/policies/index.js +++ b/ui-v2/app/controllers/dc/acls/policies/index.js @@ -1,23 +1,23 @@ import Controller from '@ember/controller'; -import { get } from '@ember/object'; -import WithFiltering from 'consul-ui/mixins/with-filtering'; -export default Controller.extend(WithFiltering, { +import { get, computed } from '@ember/object'; +import WithSearching from 'consul-ui/mixins/with-searching'; +export default Controller.extend(WithSearching, { queryParams: { s: { as: 'filter', replace: true, }, }, - filter: function(item, { s = '', type = '' }) { - const sLower = s.toLowerCase(); - return ( - get(item, 'Name') - .toLowerCase() - .indexOf(sLower) !== -1 || - get(item, 'Description') - .toLowerCase() - .indexOf(sLower) !== -1 - ); + init: function() { + this.searchParams = { + policy: 's', + }; + this._super(...arguments); }, + searchable: computed('items', function() { + return get(this, 'searchables.policy') + .add(get(this, 'items')) + .search(get(this, this.searchParams.policy)); + }), actions: {}, }); diff --git a/ui-v2/app/controllers/dc/acls/tokens/index.js b/ui-v2/app/controllers/dc/acls/tokens/index.js index 729664c72c..4c65831667 100644 --- a/ui-v2/app/controllers/dc/acls/tokens/index.js +++ b/ui-v2/app/controllers/dc/acls/tokens/index.js @@ -1,30 +1,24 @@ import Controller from '@ember/controller'; -import { get } from '@ember/object'; -import WithFiltering from 'consul-ui/mixins/with-filtering'; -export default Controller.extend(WithFiltering, { +import { computed, get } from '@ember/object'; +import WithSearching from 'consul-ui/mixins/with-searching'; +export default Controller.extend(WithSearching, { queryParams: { s: { as: 'filter', replace: true, }, }, - filter: function(item, { s = '', type = '' }) { - const sLower = s.toLowerCase(); - return ( - get(item, 'AccessorID') - .toLowerCase() - .indexOf(sLower) !== -1 || - get(item, 'Name') - .toLowerCase() - .indexOf(sLower) !== -1 || - get(item, 'Description') - .toLowerCase() - .indexOf(sLower) !== -1 || - (get(item, 'Policies') || []).some(function(item) { - return item.Name.toLowerCase().indexOf(sLower) !== -1; - }) - ); + init: function() { + this.searchParams = { + token: 's', + }; + this._super(...arguments); }, + searchable: computed('items', function() { + return get(this, 'searchables.token') + .add(get(this, 'items')) + .search(get(this, this.searchParams.token)); + }), actions: { sendClone: function(item) { this.send('clone', item); diff --git a/ui-v2/app/controllers/dc/intentions/index.js b/ui-v2/app/controllers/dc/intentions/index.js index 0ff73e7cdf..88c0b8e72d 100644 --- a/ui-v2/app/controllers/dc/intentions/index.js +++ b/ui-v2/app/controllers/dc/intentions/index.js @@ -1,6 +1,7 @@ import Controller from '@ember/controller'; import { computed, get } from '@ember/object'; import WithFiltering from 'consul-ui/mixins/with-filtering'; +import WithSearching from 'consul-ui/mixins/with-searching'; import ucfirst from 'consul-ui/utils/ucfirst'; // TODO: DRY out in acls at least const createCounter = function(prop) { @@ -9,7 +10,7 @@ const createCounter = function(prop) { }; }; const countAction = createCounter('Action'); -export default Controller.extend(WithFiltering, { +export default Controller.extend(WithSearching, WithFiltering, { queryParams: { action: { as: 'action', @@ -19,6 +20,17 @@ export default Controller.extend(WithFiltering, { replace: true, }, }, + init: function() { + this.searchParams = { + intention: 's', + }; + this._super(...arguments); + }, + searchable: computed('filtered', function() { + return get(this, 'searchables.intention') + .add(get(this, 'filtered')) + .search(get(this, this.searchParams.intention)); + }), actionFilters: computed('items', function() { const items = get(this, 'items'); return ['', 'allow', 'deny'].map(function(item) { @@ -32,16 +44,6 @@ export default Controller.extend(WithFiltering, { }); }), filter: function(item, { s = '', action = '' }) { - const source = get(item, 'SourceName').toLowerCase(); - const destination = get(item, 'DestinationName').toLowerCase(); - const sLower = s.toLowerCase(); - const allLabel = 'All Services (*)'.toLowerCase(); - return ( - (source.indexOf(sLower) !== -1 || - destination.indexOf(sLower) !== -1 || - (source === '*' && allLabel.indexOf(sLower) !== -1) || - (destination === '*' && allLabel.indexOf(sLower) !== -1)) && - (action === '' || get(item, 'Action') === action) - ); + return action === '' || get(item, 'Action') === action; }, }); diff --git a/ui-v2/app/controllers/dc/kv/index.js b/ui-v2/app/controllers/dc/kv/index.js index 20a2399058..ac19fabfd5 100644 --- a/ui-v2/app/controllers/dc/kv/index.js +++ b/ui-v2/app/controllers/dc/kv/index.js @@ -1,18 +1,22 @@ import Controller from '@ember/controller'; -import { get } from '@ember/object'; -import WithFiltering from 'consul-ui/mixins/with-filtering'; -import rightTrim from 'consul-ui/utils/right-trim'; -export default Controller.extend(WithFiltering, { +import { get, computed } from '@ember/object'; +import WithSearching from 'consul-ui/mixins/with-searching'; +export default Controller.extend(WithSearching, { queryParams: { s: { as: 'filter', replace: true, }, }, - filter: function(item, { s = '' }) { - const key = rightTrim(get(item, 'Key'), '/') - .split('/') - .pop(); - return key.toLowerCase().indexOf(s.toLowerCase()) !== -1; + init: function() { + this.searchParams = { + kv: 's', + }; + this._super(...arguments); }, + searchable: computed('items', function() { + return get(this, 'searchables.kv') + .add(get(this, 'items')) + .search(get(this, this.searchParams.kv)); + }), }); diff --git a/ui-v2/app/controllers/dc/nodes/index.js b/ui-v2/app/controllers/dc/nodes/index.js index 2202b73da1..cbbd7b90d9 100644 --- a/ui-v2/app/controllers/dc/nodes/index.js +++ b/ui-v2/app/controllers/dc/nodes/index.js @@ -1,12 +1,26 @@ import Controller from '@ember/controller'; import { computed } from '@ember/object'; import WithHealthFiltering from 'consul-ui/mixins/with-health-filtering'; +import WithSearching from 'consul-ui/mixins/with-searching'; import { get } from '@ember/object'; -export default Controller.extend(WithHealthFiltering, { +export default Controller.extend(WithSearching, WithHealthFiltering, { init: function() { + this.searchParams = { + healthyNode: 's', + unhealthyNode: 's', + }; this._super(...arguments); - this.columns = [25, 25, 25, 25]; }, + searchableHealthy: computed('healthy', function() { + return get(this, 'searchables.healthyNode') + .add(get(this, 'healthy')) + .search(get(this, this.searchParams.healthyNode)); + }), + searchableUnhealthy: computed('unhealthy', function() { + return get(this, 'searchables.unhealthyNode') + .add(get(this, 'unhealthy')) + .search(get(this, this.searchParams.unhealthyNode)); + }), unhealthy: computed('filtered', function() { return get(this, 'filtered').filter(function(item) { return get(item, 'isUnhealthy'); @@ -18,10 +32,6 @@ export default Controller.extend(WithHealthFiltering, { }); }), filter: function(item, { s = '', status = '' }) { - return ( - get(item, 'Node') - .toLowerCase() - .indexOf(s.toLowerCase()) !== -1 && item.hasStatus(status) - ); + return item.hasStatus(status); }, }); diff --git a/ui-v2/app/controllers/dc/nodes/show.js b/ui-v2/app/controllers/dc/nodes/show.js index 1eb34e6bec..8c900df163 100644 --- a/ui-v2/app/controllers/dc/nodes/show.js +++ b/ui-v2/app/controllers/dc/nodes/show.js @@ -1,18 +1,29 @@ import Controller from '@ember/controller'; -import { get, set } from '@ember/object'; +import { get, set, computed } from '@ember/object'; import { getOwner } from '@ember/application'; -import WithFiltering from 'consul-ui/mixins/with-filtering'; +import WithSearching from 'consul-ui/mixins/with-searching'; import qsaFactory from 'consul-ui/utils/dom/qsa-factory'; import getComponentFactory from 'consul-ui/utils/get-component-factory'; const $$ = qsaFactory(); -export default Controller.extend(WithFiltering, { +export default Controller.extend(WithSearching, { queryParams: { s: { as: 'filter', replace: true, }, }, + init: function() { + this.searchParams = { + nodeservice: 's', + }; + this._super(...arguments); + }, + searchable: computed('items', function() { + return get(this, 'searchables.nodeservice') + .add(get(this, 'items')) + .search(get(this, this.searchParams.nodeservice)); + }), setProperties: function() { this._super(...arguments); // the default selected tab depends on whether you have any healthchecks or not @@ -22,24 +33,6 @@ export default Controller.extend(WithFiltering, { // need this variable set(this, 'selectedTab', get(this.item, 'Checks.length') > 0 ? 'health-checks' : 'services'); }, - filter: function(item, { s = '' }) { - const term = s.toLowerCase(); - return ( - get(item, 'Service') - .toLowerCase() - .indexOf(term) !== -1 || - get(item, 'ID') - .toLowerCase() - .indexOf(term) !== -1 || - (get(item, 'Tags') || []).some(function(item) { - return item.toLowerCase().indexOf(term) !== -1; - }) || - get(item, 'Port') - .toString() - .toLowerCase() - .indexOf(term) !== -1 - ); - }, actions: { change: function(e) { set(this, 'selectedTab', e.target.value); diff --git a/ui-v2/app/controllers/dc/services/index.js b/ui-v2/app/controllers/dc/services/index.js index a0cc0bd399..1a81a10c01 100644 --- a/ui-v2/app/controllers/dc/services/index.js +++ b/ui-v2/app/controllers/dc/services/index.js @@ -2,6 +2,7 @@ import Controller from '@ember/controller'; import { get, computed } from '@ember/object'; import { htmlSafe } from '@ember/string'; import WithHealthFiltering from 'consul-ui/mixins/with-health-filtering'; +import WithSearching from 'consul-ui/mixins/with-searching'; const max = function(arr, prop) { return arr.reduce(function(prev, item) { return Math.max(prev, get(item, prop)); @@ -24,18 +25,20 @@ const width = function(num) { const widthDeclaration = function(num) { return htmlSafe(`width: ${num}px`); }; -export default Controller.extend(WithHealthFiltering, { +export default Controller.extend(WithSearching, WithHealthFiltering, { + init: function() { + this.searchParams = { + service: 's', + }; + this._super(...arguments); + }, + searchable: computed('filtered', function() { + return get(this, 'searchables.service') + .add(get(this, 'filtered')) + .search(get(this, this.searchParams.service)); + }), filter: function(item, { s = '', status = '' }) { - const term = s.toLowerCase(); - return ( - (get(item, 'Name') - .toLowerCase() - .indexOf(term) !== -1 || - (get(item, 'Tags') || []).some(function(item) { - return item.toLowerCase().indexOf(term) !== -1; - })) && - item.hasStatus(status) - ); + return item.hasStatus(status); }, maxWidth: computed('{maxPassing,maxWarning,maxCritical}', function() { const PADDING = 32 * 3 + 13; diff --git a/ui-v2/app/controllers/dc/services/show.js b/ui-v2/app/controllers/dc/services/show.js index 5a08b008ae..fd3153661f 100644 --- a/ui-v2/app/controllers/dc/services/show.js +++ b/ui-v2/app/controllers/dc/services/show.js @@ -4,10 +4,25 @@ import { computed } from '@ember/object'; import sumOfUnhealthy from 'consul-ui/utils/sumOfUnhealthy'; import hasStatus from 'consul-ui/utils/hasStatus'; import WithHealthFiltering from 'consul-ui/mixins/with-health-filtering'; -export default Controller.extend(WithHealthFiltering, { +import WithSearching from 'consul-ui/mixins/with-searching'; +export default Controller.extend(WithSearching, WithHealthFiltering, { init: function() { + this.searchParams = { + healthyServiceNode: 's', + unhealthyServiceNode: 's', + }; this._super(...arguments); }, + searchableHealthy: computed('healthy', function() { + return get(this, 'searchables.healthyServiceNode') + .add(get(this, 'healthy')) + .search(get(this, this.searchParams.healthyServiceNode)); + }), + searchableUnhealthy: computed('unhealthy', function() { + return get(this, 'searchables.unhealthyServiceNode') + .add(get(this, 'unhealthy')) + .search(get(this, this.searchParams.unhealthyServiceNode)); + }), unhealthy: computed('filtered', function() { return get(this, 'filtered').filter(function(item) { return sumOfUnhealthy(item.Checks) > 0; @@ -19,16 +34,6 @@ export default Controller.extend(WithHealthFiltering, { }); }), filter: function(item, { s = '', status = '' }) { - const term = s.toLowerCase(); - - return ( - get(item, 'Node.Node') - .toLowerCase() - .indexOf(term) !== -1 || - (get(item, 'Service.ID') - .toLowerCase() - .indexOf(term) !== -1 && - hasStatus(get(item, 'Checks'), status)) - ); + return hasStatus(get(item, 'Checks'), status); }, }); diff --git a/ui-v2/app/initializers/search.js b/ui-v2/app/initializers/search.js new file mode 100644 index 0000000000..455c9ad17b --- /dev/null +++ b/ui-v2/app/initializers/search.js @@ -0,0 +1,37 @@ +import intention from 'consul-ui/search/filters/intention'; +import token from 'consul-ui/search/filters/token'; +import policy from 'consul-ui/search/filters/policy'; +import kv from 'consul-ui/search/filters/kv'; +import node from 'consul-ui/search/filters/node'; +// service instance +import nodeService from 'consul-ui/search/filters/node/service'; +import serviceNode from 'consul-ui/search/filters/service/node'; +import service from 'consul-ui/search/filters/service'; + +import filterableFactory from 'consul-ui/utils/search/filterable'; +const filterable = filterableFactory(); +export function initialize(application) { + // Service-less injection using private properties at a per-project level + const Builder = application.resolveRegistration('service:search'); + const searchables = { + intention: intention(filterable), + token: token(filterable), + policy: policy(filterable), + kv: kv(filterable), + healthyNode: node(filterable), + unhealthyNode: node(filterable), + healthyServiceNode: serviceNode(filterable), + unhealthyServiceNode: serviceNode(filterable), + nodeservice: nodeService(filterable), + service: service(filterable), + }; + Builder.reopen({ + searchable: function(name) { + return searchables[name]; + }, + }); +} + +export default { + initialize, +}; diff --git a/ui-v2/app/mixins/with-listeners.js b/ui-v2/app/mixins/with-listeners.js new file mode 100644 index 0000000000..a1ef188b80 --- /dev/null +++ b/ui-v2/app/mixins/with-listeners.js @@ -0,0 +1,27 @@ +import Component from '@ember/component'; +import Mixin from '@ember/object/mixin'; +import { inject as service } from '@ember/service'; +import { get } from '@ember/object'; + +export default Mixin.create({ + dom: service('dom'), + init: function() { + this._super(...arguments); + this._listeners = get(this, 'dom').listeners(); + let method = 'willDestroy'; + if (this instanceof Component) { + method = 'willDestroyElement'; + } + const destroy = this[method]; + this[method] = function() { + destroy(...arguments); + this.removeListeners(); + }; + }, + listen: function(target, event, handler) { + return this._listeners.add(...arguments); + }, + removeListeners: function() { + return this._listeners.remove(...arguments); + }, +}); diff --git a/ui-v2/app/mixins/with-searching.js b/ui-v2/app/mixins/with-searching.js new file mode 100644 index 0000000000..2a18a75601 --- /dev/null +++ b/ui-v2/app/mixins/with-searching.js @@ -0,0 +1,32 @@ +import Mixin from '@ember/object/mixin'; +import { inject as service } from '@ember/service'; +import { get, set } from '@ember/object'; +import WithListeners from 'consul-ui/mixins/with-listeners'; +/** + * WithSearching mostly depends on a `searchParams` object which must be set + * inside the `init` function. The naming and usage of this is modelled on + * `queryParams` but in contrast cannot _yet_ be 'hung' of the Controller + * object, it MUST be set in the `init` method. + * Reasons: As well as producing a eslint error, it can also be 'shared' amongst + * child Classes of the component. It is not clear _yet_ whether mixing this in + * avoids this and is something to be looked at in future to slightly improve DX + * Please also see: + * https://emberjs.com/api/ember/2.12/classes/Ember.Object/properties?anchor=mergedProperties + * + */ +export default Mixin.create(WithListeners, { + builder: service('search'), + init: function() { + this._super(...arguments); + const params = this.searchParams || {}; + this.searchables = {}; + Object.keys(params).forEach(type => { + const key = params[type]; + this.searchables[type] = get(this, 'builder').searchable(type); + this.listen(this.searchables[type], 'change', e => { + const value = e.target.value; + set(this, key, value === '' ? null : value); + }); + }); + }, +}); diff --git a/ui-v2/app/search/filters/intention.js b/ui-v2/app/search/filters/intention.js new file mode 100644 index 0000000000..cf71a6d86c --- /dev/null +++ b/ui-v2/app/search/filters/intention.js @@ -0,0 +1,15 @@ +import { get } from '@ember/object'; +export default function(filterable) { + return filterable(function(item, { s = '' }) { + const source = get(item, 'SourceName').toLowerCase(); + const destination = get(item, 'DestinationName').toLowerCase(); + const sLower = s.toLowerCase(); + const allLabel = 'All Services (*)'.toLowerCase(); + return ( + source.indexOf(sLower) !== -1 || + destination.indexOf(sLower) !== -1 || + (source === '*' && allLabel.indexOf(sLower) !== -1) || + (destination === '*' && allLabel.indexOf(sLower) !== -1) + ); + }); +} diff --git a/ui-v2/app/search/filters/kv.js b/ui-v2/app/search/filters/kv.js new file mode 100644 index 0000000000..ffef18f3c9 --- /dev/null +++ b/ui-v2/app/search/filters/kv.js @@ -0,0 +1,10 @@ +import { get } from '@ember/object'; +import rightTrim from 'consul-ui/utils/right-trim'; +export default function(filterable) { + return filterable(function(item, { s = '' }) { + const key = rightTrim(get(item, 'Key'), '/') + .split('/') + .pop(); + return key.toLowerCase().indexOf(s.toLowerCase()) !== -1; + }); +} diff --git a/ui-v2/app/search/filters/node.js b/ui-v2/app/search/filters/node.js new file mode 100644 index 0000000000..6ac9c302f6 --- /dev/null +++ b/ui-v2/app/search/filters/node.js @@ -0,0 +1,11 @@ +import { get } from '@ember/object'; +export default function(filterable) { + return filterable(function(item, { s = '' }) { + const sLower = s.toLowerCase(); + return ( + get(item, 'Node') + .toLowerCase() + .indexOf(sLower) !== -1 + ); + }); +} diff --git a/ui-v2/app/search/filters/node/service.js b/ui-v2/app/search/filters/node/service.js new file mode 100644 index 0000000000..255c238eaf --- /dev/null +++ b/ui-v2/app/search/filters/node/service.js @@ -0,0 +1,21 @@ +import { get } from '@ember/object'; +export default function(filterable) { + return filterable(function(item, { s = '' }) { + const term = s.toLowerCase(); + return ( + get(item, 'Service') + .toLowerCase() + .indexOf(term) !== -1 || + get(item, 'ID') + .toLowerCase() + .indexOf(term) !== -1 || + (get(item, 'Tags') || []).some(function(item) { + return item.toLowerCase().indexOf(term) !== -1; + }) || + get(item, 'Port') + .toString() + .toLowerCase() + .indexOf(term) !== -1 + ); + }); +} diff --git a/ui-v2/app/search/filters/policy.js b/ui-v2/app/search/filters/policy.js new file mode 100644 index 0000000000..3144ac6e26 --- /dev/null +++ b/ui-v2/app/search/filters/policy.js @@ -0,0 +1,14 @@ +import { get } from '@ember/object'; +export default function(filterable) { + return filterable(function(item, { s = '' }) { + const sLower = s.toLowerCase(); + return ( + get(item, 'Name') + .toLowerCase() + .indexOf(sLower) !== -1 || + get(item, 'Description') + .toLowerCase() + .indexOf(sLower) !== -1 + ); + }); +} diff --git a/ui-v2/app/search/filters/service.js b/ui-v2/app/search/filters/service.js new file mode 100644 index 0000000000..c1fcc0daaf --- /dev/null +++ b/ui-v2/app/search/filters/service.js @@ -0,0 +1,14 @@ +import { get } from '@ember/object'; +export default function(filterable) { + return filterable(function(item, { s = '' }) { + const term = s.toLowerCase(); + return ( + get(item, 'Name') + .toLowerCase() + .indexOf(term) !== -1 || + (get(item, 'Tags') || []).some(function(item) { + return item.toLowerCase().indexOf(term) !== -1; + }) + ); + }); +} diff --git a/ui-v2/app/search/filters/service/node.js b/ui-v2/app/search/filters/service/node.js new file mode 100644 index 0000000000..5f12724218 --- /dev/null +++ b/ui-v2/app/search/filters/service/node.js @@ -0,0 +1,14 @@ +import { get } from '@ember/object'; +export default function(filterable) { + return filterable(function(item, { s = '' }) { + const term = s.toLowerCase(); + return ( + get(item, 'Node.Node') + .toLowerCase() + .indexOf(term) !== -1 || + get(item, 'Service.ID') + .toLowerCase() + .indexOf(term) !== -1 + ); + }); +} diff --git a/ui-v2/app/search/filters/token.js b/ui-v2/app/search/filters/token.js new file mode 100644 index 0000000000..f8f5f1c3ef --- /dev/null +++ b/ui-v2/app/search/filters/token.js @@ -0,0 +1,21 @@ +import { get } from '@ember/object'; +export default function(filterable) { + return filterable(function(item, { s = '' }) { + const sLower = s.toLowerCase(); + return ( + get(item, 'AccessorID') + .toLowerCase() + .indexOf(sLower) !== -1 || + // TODO: Check if Name can go, it was just for legacy + get(item, 'Name') + .toLowerCase() + .indexOf(sLower) !== -1 || + get(item, 'Description') + .toLowerCase() + .indexOf(sLower) !== -1 || + (get(item, 'Policies') || []).some(function(item) { + return item.Name.toLowerCase().indexOf(sLower) !== -1; + }) + ); + }); +} diff --git a/ui-v2/app/services/dom.js b/ui-v2/app/services/dom.js index 4c59831da5..6b84f21035 100644 --- a/ui-v2/app/services/dom.js +++ b/ui-v2/app/services/dom.js @@ -6,6 +6,7 @@ import qsaFactory from 'consul-ui/utils/dom/qsa-factory'; // TODO: Move to utils/dom import getComponentFactory from 'consul-ui/utils/get-component-factory'; import normalizeEvent from 'consul-ui/utils/dom/normalize-event'; +import createListeners from 'consul-ui/utils/dom/create-listeners'; // ember-eslint doesn't like you using a single $ so use double // use $_ for components @@ -20,6 +21,7 @@ export default Service.extend({ normalizeEvent: function() { return normalizeEvent(...arguments); }, + listeners: createListeners, root: function() { return get(this, 'doc').documentElement; }, diff --git a/ui-v2/app/services/search.js b/ui-v2/app/services/search.js new file mode 100644 index 0000000000..5a1c491cc1 --- /dev/null +++ b/ui-v2/app/services/search.js @@ -0,0 +1,2 @@ +import Service from '@ember/service'; +export default Service.extend({}); diff --git a/ui-v2/app/templates/components/catalog-filter.hbs b/ui-v2/app/templates/components/catalog-filter.hbs index d69ecbad0a..9d95acc258 100644 --- a/ui-v2/app/templates/components/catalog-filter.hbs +++ b/ui-v2/app/templates/components/catalog-filter.hbs @@ -1,4 +1,4 @@ {{!
}} - {{freetext-filter value=search placeholder="Search by name" onchange=(action onchange)}} + {{freetext-filter searchable=searchable value=search placeholder="Search by name"}} {{radio-group name="status" value=status items=filters onchange=(action onchange)}} {{!
}} diff --git a/ui-v2/app/templates/components/changeable-set.hbs b/ui-v2/app/templates/components/changeable-set.hbs new file mode 100644 index 0000000000..c074009d5f --- /dev/null +++ b/ui-v2/app/templates/components/changeable-set.hbs @@ -0,0 +1,6 @@ +{{yield}} +{{#if (gt items.length 0)}} + {{#yield-slot 'set' (block-params items)}}{{yield}}{{/yield-slot}} +{{else}} + {{#yield-slot 'empty'}}{{yield}}{{/yield-slot}} +{{/if}} \ No newline at end of file diff --git a/ui-v2/app/templates/components/intention-filter.hbs b/ui-v2/app/templates/components/intention-filter.hbs index 39b815e44e..cb7c999171 100644 --- a/ui-v2/app/templates/components/intention-filter.hbs +++ b/ui-v2/app/templates/components/intention-filter.hbs @@ -1,4 +1,4 @@ {{!
}} - {{freetext-filter onchange=(action onchange) value=search placeholder="Search by Source or Destination"}} + {{freetext-filter searchable=searchable value=search placeholder="Search by Source or Destination"}} {{radio-group name="action" value=action items=filters onchange=(action onchange)}} {{!
}} \ No newline at end of file diff --git a/ui-v2/app/templates/dc/acls/policies/index.hbs b/ui-v2/app/templates/dc/acls/policies/index.hbs index 5d1e4d8718..0f5aac4824 100644 --- a/ui-v2/app/templates/dc/acls/policies/index.hbs +++ b/ui-v2/app/templates/dc/acls/policies/index.hbs @@ -22,60 +22,63 @@ {{#block-slot 'content'}} {{#if (gt items.length 0) }}
- {{freetext-filter onchange=(action 'filter') value=filter.s placeholder="Search"}} + {{freetext-filter searchable=searchable value=s placeholder="Search"}}
{{/if}} -{{#if (gt filtered.length 0)}} - {{#tabular-collection - items=(sort-by 'CreateIndex:desc' 'Name:asc' filtered) as |item index| - }} - {{#block-slot 'header'}} - Name - Datacenters - Description - {{/block-slot}} - {{#block-slot 'row' }} - - {{item.Name}} - - - {{join ', ' (policy/datacenters item)}} - - - {{item.Description}} - - {{/block-slot}} - {{#block-slot 'actions' as |index change checked|}} - {{#confirmation-dialog confirming=false index=index message="Are you sure you want to delete this Policy?"}} - {{#block-slot 'action' as |confirm|}} - {{#action-group index=index onchange=(action change) checked=(if (eq checked index) 'checked')}} - + {{/action-group}} + {{/block-slot}} + {{#block-slot 'dialog' as |execute cancel message name|}} + {{delete-confirmation message=message execute=execute cancel=cancel}} + {{/block-slot}} + {{/confirmation-dialog}} + {{/block-slot}} + {{/tabular-collection}} + {{/block-slot}} + {{#block-slot 'empty'}} +

+ There are no Policies. +

+ {{/block-slot}} + {{/changeable-set}} {{/block-slot}} {{/app-view}} \ No newline at end of file diff --git a/ui-v2/app/templates/dc/acls/tokens/index.hbs b/ui-v2/app/templates/dc/acls/tokens/index.hbs index 4dad5c7e92..16eb04b9f8 100644 --- a/ui-v2/app/templates/dc/acls/tokens/index.hbs +++ b/ui-v2/app/templates/dc/acls/tokens/index.hbs @@ -1,131 +1,133 @@ {{#app-view class=(concat 'token ' (if (and isEnabled (not isAuthorized)) 'edit' 'list')) loading=isLoading authorized=isAuthorized enabled=isEnabled}} - {{#block-slot 'notification' as |status type subject|}} - {{partial 'dc/acls/tokens/notifications'}} - {{/block-slot}} - {{#block-slot 'header'}} -

- Access Controls -

- {{#if isAuthorized }} - {{partial 'dc/acls/nav'}} - {{/if}} - {{/block-slot}} - {{#block-slot 'disabled'}} - {{partial 'dc/acls/disabled'}} - {{/block-slot}} - {{#block-slot 'authorization'}} - {{partial 'dc/acls/authorization'}} - {{/block-slot}} - {{#block-slot 'actions'}} - Create - {{/block-slot}} - {{#block-slot 'content'}} -{{#if (gt items.length 0) }} -
- {{freetext-filter onchange=(action 'filter') value=filter.s placeholder="Search"}} -
+ {{#block-slot 'notification' as |status type subject|}} + {{partial 'dc/acls/tokens/notifications'}} + {{/block-slot}} + {{#block-slot 'header'}} +

+ Access Controls +

+{{#if isAuthorized }} + {{partial 'dc/acls/nav'}} {{/if}} - {{#if (token/is-legacy items)}} + {{/block-slot}} + {{#block-slot 'disabled'}} + {{partial 'dc/acls/disabled'}} + {{/block-slot}} + {{#block-slot 'authorization'}} + {{partial 'dc/acls/authorization'}} + {{/block-slot}} + {{#block-slot 'actions'}} + Create + {{/block-slot}} + {{#block-slot 'content'}} +{{#if (gt items.length 0) }} +
+ {{freetext-filter searchable=searchable value=s placeholder="Search"}} +
+{{/if}} +{{#if (token/is-legacy items)}}

Update. We have upgraded our ACL System to allow the creation of reusable policies that can be applied to tokens. Read more about the changes and how to upgrade legacy tokens in our documentation.

- {{/if}} -{{#if (gt filtered.length 0)}} +{{/if}} + {{#changeable-set dispatcher=searchable}} + {{#block-slot 'set' as |filtered|}} {{#tabular-collection items=(sort-by 'CreateTime:desc' filtered) as |item index| }} - {{#block-slot 'header'}} - Accessor ID - Scope - Description - Policies -   - {{/block-slot}} - {{#block-slot 'row'}} - - {{truncate item.AccessorID 8 false}} - - - {{if item.Local 'local' 'global' }} - - - {{default item.Description item.Name}} - - - {{#if (token/is-legacy item) }} - Legacy tokens have embedded policies. - {{ else }} - {{#each item.Policies as |item|}} - {{item.Name}} - {{/each}} - {{/if}} - - {{#if (eq item.AccessorID token.AccessorID)}} - Your token - {{/if}} - {{/block-slot}} - {{#block-slot 'actions' as |index change checked|}} - {{#confirmation-dialog confirming=false index=index message="Are you sure you want to delete this Token?"}} - {{#block-slot 'action' as |confirm|}} - {{#action-group index=index onchange=(action change) checked=(if (eq checked index) 'checked')}} - + {{/action-group}} + {{/block-slot}} + {{#block-slot 'dialog' as |execute cancel message name|}} +

+ {{#if (eq name 'delete')}} + {{message}} + {{else if (eq name 'logout')}} + Are you sure you want to stop using this ACL token? This will log you out. + {{else if (eq name 'use')}} + Are you sure you want to use this ACL token? + {{/if}} +

+ + + {{/block-slot}} + {{/confirmation-dialog}} + {{/block-slot}} {{/tabular-collection}} -{{else}} + {{/block-slot}} + {{#block-slot 'empty'}}

- There are no Tokens. + There are no Tokens.

-{{/if}} - {{/block-slot}} + {{/block-slot}} + {{/changeable-set}} + {{/block-slot}} {{/app-view}} diff --git a/ui-v2/app/templates/dc/intentions/index.hbs b/ui-v2/app/templates/dc/intentions/index.hbs index bd509d85a8..ed103d92d2 100644 --- a/ui-v2/app/templates/dc/intentions/index.hbs +++ b/ui-v2/app/templates/dc/intentions/index.hbs @@ -13,70 +13,73 @@ {{/block-slot}} {{#block-slot 'toolbar'}} {{#if (gt items.length 0) }} - {{intention-filter filters=actionFilters search=filters.s type=filters.action onchange=(action 'filter')}} + {{intention-filter searchable=searchable filters=actionFilters search=filters.s type=filters.action onchange=(action 'filter')}} {{/if}} {{/block-slot}} {{#block-slot 'content'}} -{{#if (gt filtered.length 0) }} - {{#tabular-collection - route='dc.intentions.edit' - key='SourceName' - items=filtered as |item index| - }} - {{#block-slot 'header'}} - Source -   - Destination - Precedence - {{/block-slot}} - {{#block-slot 'row'}} - - - {{#if (eq item.SourceName '*') }} - All Services (*) - {{else}} - {{item.SourceName}} - {{/if}} - - - - {{item.Action}} - - - {{#if (eq item.DestinationName '*') }} - All Services (*) - {{else}} - {{item.DestinationName}} - {{/if}} - - - {{item.Precedence}} - - {{/block-slot}} - {{#block-slot 'actions' as |index change checked|}} - {{#confirmation-dialog confirming=false index=index message='Are you sure you want to delete this intention?'}} - {{#block-slot 'action' as |confirm|}} - {{#action-group index=index onchange=(action change) checked=(if (eq checked index) 'checked')}} - - {{/action-group}} - {{/block-slot}} - {{#block-slot 'dialog' as |execute cancel message|}} - {{delete-confirmation message=message execute=execute cancel=cancel}} - {{/block-slot}} - {{/confirmation-dialog}} - {{/block-slot}} - {{/tabular-collection}} -{{else}} -

- There are no intentions. -

-{{/if}} + {{#changeable-set dispatcher=searchable}} + {{#block-slot 'set' as |filtered|}} + {{#tabular-collection + route='dc.intentions.edit' + key='SourceName' + items=filtered as |item index| + }} + {{#block-slot 'header'}} + Source +   + Destination + Precedence + {{/block-slot}} + {{#block-slot 'row'}} + + + {{#if (eq item.SourceName '*') }} + All Services (*) + {{else}} + {{item.SourceName}} + {{/if}} + + + + {{item.Action}} + + + {{#if (eq item.DestinationName '*') }} + All Services (*) + {{else}} + {{item.DestinationName}} + {{/if}} + + + {{item.Precedence}} + + {{/block-slot}} + {{#block-slot 'actions' as |index change checked|}} + {{#confirmation-dialog confirming=false index=index message='Are you sure you want to delete this intention?'}} + {{#block-slot 'action' as |confirm|}} + {{#action-group index=index onchange=(action change) checked=(if (eq checked index) 'checked')}} + + {{/action-group}} + {{/block-slot}} + {{#block-slot 'dialog' as |execute cancel message|}} + {{delete-confirmation message=message execute=execute cancel=cancel}} + {{/block-slot}} + {{/confirmation-dialog}} + {{/block-slot}} + {{/tabular-collection}} + {{/block-slot}} + {{#block-slot 'empty'}} +

+ There are no intentions. +

+ {{/block-slot}} + {{/changeable-set}} {{/block-slot}} {{/app-view}} \ No newline at end of file diff --git a/ui-v2/app/templates/dc/kv/index.hbs b/ui-v2/app/templates/dc/kv/index.hbs index ecb6ac2ef7..a9d10cd824 100644 --- a/ui-v2/app/templates/dc/kv/index.hbs +++ b/ui-v2/app/templates/dc/kv/index.hbs @@ -25,7 +25,7 @@ {{#block-slot 'toolbar'}} {{#if (gt items.length 0) }}
- {{freetext-filter onchange=(action 'filter') value=filter.s placeholder="Search by name"}} + {{freetext-filter searchable=searchable value=s placeholder="Search by name"}}
{{/if}} {{/block-slot}} @@ -37,40 +37,45 @@ {{/if}} {{/block-slot}} {{#block-slot 'content'}} -{{#if (gt filtered.length 0)}} - {{#tabular-collection - items=(sort-by 'isFolder:desc' 'Key:asc' filtered) as |item index| - }} - {{#block-slot 'header'}} - Name - {{/block-slot}} - {{#block-slot 'row'}} - - {{right-trim (left-trim item.Key parent.Key) '/'}} - - {{/block-slot}} - {{#block-slot 'actions' as |index change checked|}} - {{#confirmation-dialog confirming=false index=index message='Are you sure you want to delete this key?'}} - {{#block-slot 'action' as |confirm|}} - {{#action-group index=index onchange=(action change) checked=(if (eq checked index) 'checked')}} - - {{/action-group}} - {{/block-slot}} - {{#block-slot 'dialog' as |execute cancel message|}} - {{delete-confirmation message=message execute=execute cancel=cancel}} - {{/block-slot}} - {{/confirmation-dialog}} - {{/block-slot}} - {{/tabular-collection}} -{{else}} -

There are no Key / Value pairs.

-{{/if}} + {{#changeable-set dispatcher=searchable}} + {{#block-slot 'set' as |filtered|}} + {{#tabular-collection + items=(sort-by 'isFolder:desc' 'Key:asc' filtered) as |item index| + }} + {{#block-slot 'header'}} + Name + {{/block-slot}} + {{#block-slot 'row'}} + + {{right-trim (left-trim item.Key parent.Key) '/'}} + + {{/block-slot}} + {{#block-slot 'actions' as |index change checked|}} + {{#confirmation-dialog confirming=false index=index message='Are you sure you want to delete this key?'}} + {{#block-slot 'action' as |confirm|}} + {{#action-group index=index onchange=(action change) checked=(if (eq checked index) 'checked')}} + + {{/action-group}} + {{/block-slot}} + {{#block-slot 'dialog' as |execute cancel message|}} + {{delete-confirmation message=message execute=execute cancel=cancel}} + {{/block-slot}} + {{/confirmation-dialog}} + {{/block-slot}} + {{/tabular-collection}} + {{/block-slot}} + {{#block-slot 'empty'}} +

+ There are no Key / Value pairs. +

+ {{/block-slot}} + {{/changeable-set}} {{/block-slot}} {{/app-view}} \ No newline at end of file diff --git a/ui-v2/app/templates/dc/nodes/-services.hbs b/ui-v2/app/templates/dc/nodes/-services.hbs index cf809393bd..856e51b172 100644 --- a/ui-v2/app/templates/dc/nodes/-services.hbs +++ b/ui-v2/app/templates/dc/nodes/-services.hbs @@ -1,40 +1,43 @@ {{#if (gt items.length 0) }} - -
- {{freetext-filter onchange=(action 'filter') value=filter.s placeholder="Search by name/port"}} -
+ +
+ {{freetext-filter searchable=searchable value=s placeholder="Search by name/port"}} +
{{/if}} -{{#if (gt filtered.length 0)}} - {{#tabular-collection - data-test-services - items=filtered as |item index| - }} - {{#block-slot 'header'}} - Service - Port - Tags - {{/block-slot}} - {{#block-slot 'row'}} - - - - {{item.Service}}{{#if (not-eq item.ID item.Service) }}({{item.ID}}){{/if}} - - - - {{item.Port}} - - - {{#if (gt item.Tags.length 0)}} - {{#each item.Tags as |item|}} - {{item}} - {{/each}} - {{/if}} - - {{/block-slot}} - {{/tabular-collection}} -{{else}} -

+ {{#changeable-set dispatcher=searchable}} + {{#block-slot 'set' as |filtered|}} + {{#tabular-collection + data-test-services + items=filtered as |item index| + }} + {{#block-slot 'header'}} + Service + Port + Tags + {{/block-slot}} + {{#block-slot 'row'}} + + + + {{item.Service}}{{#if (not-eq item.ID item.Service) }}({{item.ID}}){{/if}} + + + + {{item.Port}} + + + {{#if (gt item.Tags.length 0)}} + {{#each item.Tags as |item|}} + {{item}} + {{/each}} + {{/if}} + + {{/block-slot}} + {{/tabular-collection}} + {{/block-slot}} + {{#block-slot 'empty'}} +

There are no services. -

-{{/if}} +

+ {{/block-slot}} + {{/changeable-set}} diff --git a/ui-v2/app/templates/dc/nodes/index.hbs b/ui-v2/app/templates/dc/nodes/index.hbs index b49368aef4..0d9ca57ed4 100644 --- a/ui-v2/app/templates/dc/nodes/index.hbs +++ b/ui-v2/app/templates/dc/nodes/index.hbs @@ -7,7 +7,7 @@ {{/block-slot}} {{#block-slot 'toolbar'}} {{#if (gt items.length 0) }} - {{catalog-filter filters=healthFilters search=filters.s status=filters.status onchange=(action 'filter')}} + {{catalog-filter searchable=(array searchableHealthy searchableUnhealthy) filters=healthFilters search=s status=filters.status onchange=(action 'filter')}} {{/if}} {{/block-slot}} {{#block-slot 'content'}} @@ -16,33 +16,51 @@

Unhealthy Nodes

{{! think about 2 differing views here }} -
    - {{#each unhealthy as |item|}} - {{healthchecked-resource - tagName='li' - data-test-node=item.Node - href=(href-to 'dc.nodes.show' item.Node) - name=item.Node - address=item.Address - checks=item.Checks - }} - {{/each}} -
+
{{/if}} {{#if (gt healthy.length 0) }}

Healthy Nodes

- {{#list-collection cellHeight=92 items=healthy as |item index|}} - {{healthchecked-resource - data-test-node=item.Node - href=(href-to 'dc.nodes.show' item.Node) - name=item.Node - address=item.Address - status=item.Checks.[0].Status - }} - {{/list-collection}} + {{#changeable-set dispatcher=searchableHealthy}} + {{#block-slot 'set' as |healthy|}} + {{#list-collection cellHeight=92 items=healthy as |item index|}} + {{healthchecked-resource + data-test-node=item.Node + href=(href-to 'dc.nodes.show' item.Node) + name=item.Node + address=item.Address + status=item.Checks.[0].Status + }} + {{/list-collection}} + {{/block-slot}} + {{#block-slot 'empty'}} +

+ There are no healthy nodes for that search. +

+ {{/block-slot}} + {{/changeable-set}}
{{/if}} {{#if (and (eq healthy.length 0) (eq unhealthy.length 0)) }} diff --git a/ui-v2/app/templates/dc/services/index.hbs b/ui-v2/app/templates/dc/services/index.hbs index f3029d046f..aee20a9718 100644 --- a/ui-v2/app/templates/dc/services/index.hbs +++ b/ui-v2/app/templates/dc/services/index.hbs @@ -11,57 +11,59 @@ {{/block-slot}} {{#block-slot 'toolbar'}} {{#if (gt items.length 0) }} - {{catalog-filter filters=healthFilters search=filters.s status=filters.status onchange=(action 'filter')}} + {{catalog-filter searchable=searchable filters=healthFilters search=filters.s status=filters.status onchange=(action 'filter')}} {{/if}} {{/block-slot}} {{#block-slot 'content'}} -{{#if (gt filtered.length 0) }} - {{#tabular-collection - route='dc.services.show' - key='Name' - items=filtered as |item index| - }} - {{#block-slot 'header'}} - Service - Health ChecksThe number of health checks for the service on all nodes - Tags - {{/block-slot}} - {{#block-slot 'row'}} - - - - - {{item.Name}} - - - -{{#if (and (lt item.ChecksPassing 1) (lt item.ChecksWarning 1) (lt item.ChecksCritical 1) )}} - 0 -{{else}} -
-
Healthchecks Passing
-
{{format-number item.ChecksPassing}}
-
Healthchecks Warning
-
{{format-number item.ChecksWarning}}
-
Healthchecks Critical
-
{{format-number item.ChecksCritical}}
-
-{{/if}} - - - {{#if (gt item.Tags.length 0)}} - {{#each item.Tags as |item|}} - {{item}} - {{/each}} - {{/if}} - - {{/block-slot}} - {{/tabular-collection}} -{{else}} -

- There are no services. -

-{{/if}} + {{#changeable-set dispatcher=searchable}} + {{#block-slot 'set' as |filtered|}} + {{#tabular-collection + route='dc.services.show' + key='Name' + items=filtered as |item index| + }} + {{#block-slot 'header'}} + Service + Health ChecksThe number of health checks for the service on all nodes + Tags + {{/block-slot}} + {{#block-slot 'row'}} + + + + {{item.Name}} + + + + {{#if (and (lt item.ChecksPassing 1) (lt item.ChecksWarning 1) (lt item.ChecksCritical 1) )}} + 0 + {{else}} +
+
Healthchecks Passing
+
{{format-number item.ChecksPassing}}
+
Healthchecks Warning
+
{{format-number item.ChecksWarning}}
+
Healthchecks Critical
+
{{format-number item.ChecksCritical}}
+
+ {{/if}} + + + {{#if (gt item.Tags.length 0)}} + {{#each item.Tags as |item|}} + {{item}} + {{/each}} + {{/if}} + + {{/block-slot}} + {{/tabular-collection}} + {{/block-slot}} + {{#block-slot 'empty'}} +

+ There are no services. +

+ {{/block-slot}} + {{/changeable-set}} {{/block-slot}} {{/app-view}} diff --git a/ui-v2/app/templates/dc/services/show.hbs b/ui-v2/app/templates/dc/services/show.hbs index 130c18ce80..5a75f0055f 100644 --- a/ui-v2/app/templates/dc/services/show.hbs +++ b/ui-v2/app/templates/dc/services/show.hbs @@ -18,7 +18,7 @@ {{/block-slot}} {{#block-slot 'toolbar'}} {{#if (gt items.length 0) }} - {{catalog-filter filters=healthFilters search=filters.s status=filters.status onchange=(action 'filter')}} + {{catalog-filter searchable=(array searchableHealthy searchableUnhealthy) filters=healthFilters search=s status=filters.status onchange=(action 'filter')}} {{/if}} {{/block-slot}} {{#block-slot 'content'}} @@ -37,17 +37,26 @@

Unhealthy Nodes

@@ -55,17 +64,26 @@ {{#if (gt healthy.length 0) }}

Healthy Nodes

- {{#list-collection cellHeight=113 items=healthy as |item index|}} - {{healthchecked-resource - href=(href-to 'dc.nodes.show' item.Node.Node) - data-test-node=item.Node.Node - name=item.Node.Node - service=item.Service.ID - address=(concat (default item.Service.Address item.Node.Address) ':' item.Service.Port) - checks=item.Checks - status=item.Checks.[0].Status - }} - {{/list-collection}} + {{#changeable-set dispatcher=searchableHealthy}} + {{#block-slot 'set' as |healthy|}} + {{#list-collection cellHeight=113 items=healthy as |item index|}} + {{healthchecked-resource + href=(href-to 'dc.nodes.show' item.Node.Node) + data-test-node=item.Node.Node + name=item.Node.Node + service=item.Service.ID + address=(concat (default item.Service.Address item.Node.Address) ':' item.Service.Port) + checks=item.Checks + status=item.Checks.[0].Status + }} + {{/list-collection}} + {{/block-slot}} + {{#block-slot 'empty'}} +

+ There are no healthy nodes for that search. +

+ {{/block-slot}} + {{/changeable-set}}
{{/if}} {{/block-slot}} diff --git a/ui-v2/app/utils/dom/create-listeners.js b/ui-v2/app/utils/dom/create-listeners.js new file mode 100644 index 0000000000..623046198f --- /dev/null +++ b/ui-v2/app/utils/dom/create-listeners.js @@ -0,0 +1,32 @@ +export default function(listeners = []) { + const add = function(target, event, handler) { + let addEventListener = 'addEventListener'; + let removeEventListener = 'removeEventListener'; + if (typeof target[addEventListener] === 'undefined') { + addEventListener = 'on'; + removeEventListener = 'off'; + } + target[addEventListener](event, handler); + const remove = function() { + target[removeEventListener](event, handler); + return handler; + }; + listeners.push(remove); + return remove; + }; + // TODO: Allow passing of a 'listener remove' in here + // call it, find in the array and remove + // Post-thoughts, pretty sure this is covered now by returning the remove + // function above, use-case for wanting to use this method to remove individual + // listeners is probably pretty limited, this method itself could be easily implemented + // from the outside also, but I suppose its handy to keep here + const remove = function() { + const handlers = listeners.map(item => item()); + listeners.splice(0, listeners.length); + return handlers; + }; + return { + add: add, + remove: remove, + }; +} diff --git a/ui-v2/app/utils/search/filterable.js b/ui-v2/app/utils/search/filterable.js new file mode 100644 index 0000000000..f14d0139ed --- /dev/null +++ b/ui-v2/app/utils/search/filterable.js @@ -0,0 +1,38 @@ +import RSVP, { Promise } from 'rsvp'; +export default function(EventTarget = RSVP.EventTarget, P = Promise) { + // TODO: Class-ify + return function(filter) { + return EventTarget.mixin({ + value: '', + add: function(data) { + this.data = data; + return this; + }, + search: function(term = '') { + this.value = term === null ? '' : term.trim(); + // specifically no return here we return `this` instead + // right now filtering is sync but we introduce an async + // flow now for later on + P.resolve( + this.value !== '' + ? this.data.filter(item => { + return filter(item, { s: term }); + }) + : this.data + ).then(data => { + // TODO: For the moment, lets just fake a target + this.trigger('change', { + target: { + value: this.value, + // TODO: selectedOptions is what {{/if}} diff --git a/ui-v2/tests/unit/controllers/dc/acls/create-test.js b/ui-v2/tests/unit/controllers/dc/acls/create-test.js index 88fab0190b..5f16b636b2 100644 --- a/ui-v2/tests/unit/controllers/dc/acls/create-test.js +++ b/ui-v2/tests/unit/controllers/dc/acls/create-test.js @@ -2,7 +2,7 @@ import { moduleFor, test } from 'ember-qunit'; moduleFor('controller:dc/acls/create', 'Unit | Controller | dc/acls/create', { // Specify the other units that are required for this test. - // needs: ['controller:foo'] + needs: ['service:dom', 'service:form'], }); // Replace this with your real tests. diff --git a/ui-v2/tests/unit/controllers/dc/acls/edit-test.js b/ui-v2/tests/unit/controllers/dc/acls/edit-test.js index 6a6fb1edcf..fab61bee65 100644 --- a/ui-v2/tests/unit/controllers/dc/acls/edit-test.js +++ b/ui-v2/tests/unit/controllers/dc/acls/edit-test.js @@ -2,7 +2,7 @@ import { moduleFor, test } from 'ember-qunit'; moduleFor('controller:dc/acls/edit', 'Unit | Controller | dc/acls/edit', { // Specify the other units that are required for this test. - // needs: ['controller:foo'] + needs: ['service:dom', 'service:form'], }); // Replace this with your real tests. diff --git a/ui-v2/tests/unit/controllers/dc/intentions/create-test.js b/ui-v2/tests/unit/controllers/dc/intentions/create-test.js index 1d9330ebd0..c4c4a9bcc2 100644 --- a/ui-v2/tests/unit/controllers/dc/intentions/create-test.js +++ b/ui-v2/tests/unit/controllers/dc/intentions/create-test.js @@ -2,7 +2,7 @@ import { moduleFor, test } from 'ember-qunit'; moduleFor('controller:dc/intentions/create', 'Unit | Controller | dc/intentions/create', { // Specify the other units that are required for this test. - // needs: ['controller:foo'] + needs: ['service:dom', 'service:form'], }); // Replace this with your real tests. diff --git a/ui-v2/tests/unit/controllers/dc/intentions/edit-test.js b/ui-v2/tests/unit/controllers/dc/intentions/edit-test.js index a442f66951..3b439be037 100644 --- a/ui-v2/tests/unit/controllers/dc/intentions/edit-test.js +++ b/ui-v2/tests/unit/controllers/dc/intentions/edit-test.js @@ -2,7 +2,7 @@ import { moduleFor, test } from 'ember-qunit'; moduleFor('controller:dc/intentions/edit', 'Unit | Controller | dc/intentions/edit', { // Specify the other units that are required for this test. - // needs: ['controller:foo'] + needs: ['service:dom', 'service:form'], }); // Replace this with your real tests. diff --git a/ui-v2/tests/unit/controllers/dc/kv/create-test.js b/ui-v2/tests/unit/controllers/dc/kv/create-test.js index 723ab126a0..c756a6ab5b 100644 --- a/ui-v2/tests/unit/controllers/dc/kv/create-test.js +++ b/ui-v2/tests/unit/controllers/dc/kv/create-test.js @@ -2,7 +2,7 @@ import { moduleFor, test } from 'ember-qunit'; moduleFor('controller:dc/kv/create', 'Unit | Controller | dc/kv/create', { // Specify the other units that are required for this test. - needs: ['service:btoa'], + needs: ['service:btoa', 'service:dom', 'service:form'], }); // Replace this with your real tests. diff --git a/ui-v2/tests/unit/controllers/dc/kv/edit-test.js b/ui-v2/tests/unit/controllers/dc/kv/edit-test.js index 02c0b816f0..d92867d2e9 100644 --- a/ui-v2/tests/unit/controllers/dc/kv/edit-test.js +++ b/ui-v2/tests/unit/controllers/dc/kv/edit-test.js @@ -2,7 +2,7 @@ import { moduleFor, test } from 'ember-qunit'; moduleFor('controller:dc/kv/edit', 'Unit | Controller | dc/kv/edit', { // Specify the other units that are required for this test. - needs: ['service:btoa'], + needs: ['service:btoa', 'service:dom', 'service:form'], }); // Replace this with your real tests. diff --git a/ui-v2/tests/unit/controllers/dc/kv/root-create-test.js b/ui-v2/tests/unit/controllers/dc/kv/root-create-test.js index 845c1ed475..92cb701b24 100644 --- a/ui-v2/tests/unit/controllers/dc/kv/root-create-test.js +++ b/ui-v2/tests/unit/controllers/dc/kv/root-create-test.js @@ -2,7 +2,7 @@ import { moduleFor, test } from 'ember-qunit'; moduleFor('controller:dc/kv/root-create', 'Unit | Controller | dc/kv/root-create', { // Specify the other units that are required for this test. - needs: ['service:btoa'], + needs: ['service:btoa', 'service:dom', 'service:form'], }); // Replace this with your real tests. From 3780622df0ffbd2fd85e5c0d62df7a672922b766 Mon Sep 17 00:00:00 2001 From: John Cowen Date: Mon, 19 Nov 2018 14:54:40 +0000 Subject: [PATCH 09/52] ui: Move intention source/destination menus use text from the template (#4938) --- ui-v2/app/controllers/dc/intentions/edit.js | 5 ++--- ui-v2/app/templates/dc/intentions/-form.hbs | 4 ++-- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/ui-v2/app/controllers/dc/intentions/edit.js b/ui-v2/app/controllers/dc/intentions/edit.js index 1999614a74..0d622d4858 100644 --- a/ui-v2/app/controllers/dc/intentions/edit.js +++ b/ui-v2/app/controllers/dc/intentions/edit.js @@ -1,7 +1,6 @@ import Controller from '@ember/controller'; import { inject as service } from '@ember/service'; import { get, set } from '@ember/object'; - export default Controller.extend({ dom: service('dom'), builder: service('form'), @@ -32,8 +31,8 @@ export default Controller.extend({ }); }, actions: { - createNewLabel: function(term) { - return `Use a future Consul Service called '${term}'`; + createNewLabel: function(template, term) { + return template.replace(/{{term}}/g, term); }, isUnique: function(term) { return !get(this, 'items').findBy('Name', term); diff --git a/ui-v2/app/templates/dc/intentions/-form.hbs b/ui-v2/app/templates/dc/intentions/-form.hbs index 6a1123cbfd..f85fb8e3ab 100644 --- a/ui-v2/app/templates/dc/intentions/-form.hbs +++ b/ui-v2/app/templates/dc/intentions/-form.hbs @@ -7,7 +7,7 @@ searchField='Name' selected=SourceName searchPlaceholder='Type service name' - buildSuggestion=(action 'createNewLabel') + buildSuggestion=(action 'createNewLabel' "Use a future Consul Service called '{{term}}'") showCreateWhen=(action "isUnique") oncreate=(action 'change' 'SourceName') onchange=(action 'change' 'SourceName') as |service search| @@ -27,7 +27,7 @@ searchField='Name' selected=DestinationName searchPlaceholder='Type service name' - buildSuggestion=(action 'createNewLabel') + buildSuggestion=(action 'createNewLabel' "Use a future Consul Service called '{{term}}'") showCreateWhen=(action "isUnique") oncreate=(action 'change' 'DestinationName') onchange=(action 'change' 'DestinationName') as |service| From 9ec7f3851be1daae82e275f16fd02d1d4c832285 Mon Sep 17 00:00:00 2001 From: John Cowen Date: Mon, 19 Nov 2018 14:57:22 +0000 Subject: [PATCH 10/52] UI: Use buttons instead of anchors where appropriate (#4939) Use buttons instead of anchors where appropriate --- .../components/action-group/layout.scss | 12 +++-- .../styles/components/action-group/skin.scss | 8 +++- ui-v2/app/styles/components/buttons.scss | 5 +- ui-v2/app/styles/core/typography.scss | 3 +- ui-v2/app/templates/dc/acls/index.hbs | 42 ++++++++-------- .../app/templates/dc/acls/policies/index.hbs | 2 +- ui-v2/app/templates/dc/acls/tokens/index.hbs | 48 ++++++++++--------- ui-v2/app/templates/dc/intentions/index.hbs | 2 +- ui-v2/app/templates/dc/kv/index.hbs | 2 +- 9 files changed, 69 insertions(+), 55 deletions(-) diff --git a/ui-v2/app/styles/components/action-group/layout.scss b/ui-v2/app/styles/components/action-group/layout.scss index 06a47fb2ab..b5f0549321 100644 --- a/ui-v2/app/styles/components/action-group/layout.scss +++ b/ui-v2/app/styles/components/action-group/layout.scss @@ -1,6 +1,14 @@ %action-group label span { display: none; } +%action-group-action { + width: 170px; + padding: 10px 10px; + text-align: left; +} +%action-group li > * { + @extend %action-group-action; +} %action-group::before { margin-left: -1px; } @@ -59,10 +67,6 @@ position: relative; z-index: 1; } -%action-group li a { - width: 170px; - padding: 10px 10px; -} %action-group input[type='radio'], %action-group input[type='radio'] ~ ul, %action-group input[type='radio'] ~ .with-confirmation > ul { diff --git a/ui-v2/app/styles/components/action-group/skin.scss b/ui-v2/app/styles/components/action-group/skin.scss index 73e72a2a9c..7ef8d43773 100644 --- a/ui-v2/app/styles/components/action-group/skin.scss +++ b/ui-v2/app/styles/components/action-group/skin.scss @@ -5,7 +5,8 @@ /* frame-gray */ background-color: $gray-050; } -%action-group label { +%action-group label, +%action-group-action { cursor: pointer; } %action-group label::after, @@ -26,7 +27,10 @@ %action-group ul::before { border-color: $color-action; } -%action-group li a:hover { +%action-group-action { + background-color: $white; +} +%action-group-action:hover { @extend %frame-blue-800; } %action-group ul, diff --git a/ui-v2/app/styles/components/buttons.scss b/ui-v2/app/styles/components/buttons.scss index 977dc0897e..9047a583db 100644 --- a/ui-v2/app/styles/components/buttons.scss +++ b/ui-v2/app/styles/components/buttons.scss @@ -3,12 +3,13 @@ button[type='submit'], a.type-create { @extend %primary-button; } +// the :not(li)'s here avoid styling action-group buttons button[type='reset'], -button[type='button']:not(.copy-btn):not(.type-delete), +:not(li) > button[type='button']:not(.copy-btn):not(.type-delete), html.template-error div > a { @extend %secondary-button; } -button.type-delete { +:not(li) > button.type-delete { @extend %dangerous-button; } button.copy-btn { diff --git a/ui-v2/app/styles/core/typography.scss b/ui-v2/app/styles/core/typography.scss index 4107ebf9f2..2331c49c90 100644 --- a/ui-v2/app/styles/core/typography.scss +++ b/ui-v2/app/styles/core/typography.scss @@ -52,7 +52,7 @@ caption { } th, %breadcrumbs a, -%action-group a, +%action-group-action, %tab-nav, %tooltip-bubble { font-weight: $typo-weight-medium; @@ -80,6 +80,7 @@ h2, font-size: $typo-size-500; } body, +%action-group-action, pre code, input, textarea, diff --git a/ui-v2/app/templates/dc/acls/index.hbs b/ui-v2/app/templates/dc/acls/index.hbs index 57f94c8946..3617db3c93 100644 --- a/ui-v2/app/templates/dc/acls/index.hbs +++ b/ui-v2/app/templates/dc/acls/index.hbs @@ -43,28 +43,28 @@ {{#block-slot 'action' as |confirm|}} {{#action-group index=index onchange=(action change) checked=(if (eq checked index) 'checked')}}
    -
  • - Edit -
  • - {{#if (eq item.ID token.SecretID) }} -
  • - Stop using -
  • - {{else}} +
  • + Edit +
  • +{{#if (eq item.ID token.SecretID) }} +
  • + +
  • +{{else}} -
  • - Use -
  • - {{/if}} -
  • - Clone -
  • - {{# if (not-eq item.ID 'anonymous') }} -
  • - Delete -
  • - {{/if}} -
+
  • + +
  • +{{/if}} +
  • + +
  • +{{# if (not-eq item.ID 'anonymous') }} +
  • + +
  • +{{/if}} + {{/action-group}} {{/block-slot}} {{#block-slot 'dialog' as |execute cancel message name|}} diff --git a/ui-v2/app/templates/dc/acls/policies/index.hbs b/ui-v2/app/templates/dc/acls/policies/index.hbs index 0f5aac4824..e5bf59aca1 100644 --- a/ui-v2/app/templates/dc/acls/policies/index.hbs +++ b/ui-v2/app/templates/dc/acls/policies/index.hbs @@ -61,7 +61,7 @@ Edit
  • - Delete +
  • {{/if}} diff --git a/ui-v2/app/templates/dc/acls/tokens/index.hbs b/ui-v2/app/templates/dc/acls/tokens/index.hbs index 16eb04b9f8..c4b5f13c23 100644 --- a/ui-v2/app/templates/dc/acls/tokens/index.hbs +++ b/ui-v2/app/templates/dc/acls/tokens/index.hbs @@ -26,7 +26,7 @@ {{/if}} {{#if (token/is-legacy items)}} -

    Update. We have upgraded our ACL System to allow the creation of reusable policies that can be applied to tokens. Read more about the changes and how to upgrade legacy tokens in our documentation.

    +

    Update. We have upgraded our ACL System to allow the creation of reusable policies that can be applied to tokens. Read more about the changes and how to upgrade legacy tokens in our documentation.

    {{/if}} {{#changeable-set dispatcher=searchable}} {{#block-slot 'set' as |filtered|}} @@ -68,32 +68,33 @@ {{#block-slot 'action' as |confirm|}} {{#action-group index=index onchange=(action change) checked=(if (eq checked index) 'checked')}}
      - {{#if false}} -
    • - {{#copy-button-feedback title="Copy AccessorID to the clipboard" copy=item.AccessorID name="AccessorID"}}Copy AccessorID{{/copy-button-feedback}} -
    • - {{/if}} -
    • - Edit -
    • +{{#if false}} +
    • + {{#copy-button-feedback title="Copy AccessorID to the clipboard" copy=item.AccessorID name="AccessorID"}}Copy AccessorID{{/copy-button-feedback}} +
    • +{{/if}} +
    • + Edit +
    • {{#if (not (token/is-legacy item))}} -
    • - Duplicate -
    • +
    • + +
    • {{/if}} {{#if (eq item.AccessorID token.AccessorID) }} -
    • - Stop using -
    • +
    • + +
    • {{else}} -
    • - Use -
    • + +
    • + +
    • {{/if}} -{{#unless (or (token/is-anonymous item) (eq item.AccessorID token.AccessorID)) }} -
    • - Delete -
    • +{{#unless (token/is-anonymous item) }} +
    • + +
    • {{/unless}}
    {{/action-group}} @@ -102,6 +103,9 @@

    {{#if (eq name 'delete')}} {{message}} +{{#if (eq item.AccessorID token.AccessorID)}} + Warning: This is the token you are currently using! +{{/if}} {{else if (eq name 'logout')}} Are you sure you want to stop using this ACL token? This will log you out. {{else if (eq name 'use')}} diff --git a/ui-v2/app/templates/dc/intentions/index.hbs b/ui-v2/app/templates/dc/intentions/index.hbs index ed103d92d2..db4540389f 100644 --- a/ui-v2/app/templates/dc/intentions/index.hbs +++ b/ui-v2/app/templates/dc/intentions/index.hbs @@ -63,7 +63,7 @@ Edit

  • - Delete +
  • {{/action-group}} diff --git a/ui-v2/app/templates/dc/kv/index.hbs b/ui-v2/app/templates/dc/kv/index.hbs index a9d10cd824..50aca95063 100644 --- a/ui-v2/app/templates/dc/kv/index.hbs +++ b/ui-v2/app/templates/dc/kv/index.hbs @@ -59,7 +59,7 @@ {{if item.isFolder 'View' 'Edit'}}
  • - Delete +
  • {{/action-group}} From f7d2651ac3d6ca170230adbc9e75595ad5823a41 Mon Sep 17 00:00:00 2001 From: John Cowen Date: Mon, 19 Nov 2018 14:58:08 +0000 Subject: [PATCH 11/52] ui: Maintain http headers as JSON-API meta for all API requests (#4946) --- ui-v2/app/adapters/application.js | 19 ++++++++++++ ui-v2/app/serializers/application.js | 44 ++++++++++++++++++++++++++-- ui-v2/app/utils/http/consul.js | 3 ++ 3 files changed, 64 insertions(+), 2 deletions(-) create mode 100644 ui-v2/app/utils/http/consul.js diff --git a/ui-v2/app/adapters/application.js b/ui-v2/app/adapters/application.js index b28ed0a861..07532cc0ed 100644 --- a/ui-v2/app/adapters/application.js +++ b/ui-v2/app/adapters/application.js @@ -13,6 +13,7 @@ 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'), @@ -22,6 +23,24 @@ export default Adapter.extend({ ...this._super(...arguments), }; }, + handleResponse: function(status, headers, response, requestData) { + // The ember-data RESTAdapter drops the headers after this call, + // and there is no where else to get to these + // save them to response[HTTP_HEADERS_SYMBOL] for the moment + // so we can save them as meta in the serializer... + if ( + (typeof response == 'object' && response.constructor == Object) || + Array.isArray(response) + ) { + // lowercase everything incase we get browser inconsistencies + const lower = {}; + Object.keys(headers).forEach(function(key) { + lower[key.toLowerCase()] = headers[key]; + }); + response[HTTP_HEADERS_SYMBOL] = lower; + } + return this._super(status, headers, response, requestData); + }, handleBooleanResponse: function(url, response, primary, slug) { return { // consider a check for a boolean, also for future me, diff --git a/ui-v2/app/serializers/application.js b/ui-v2/app/serializers/application.js index d7468ee8f3..e75c96559b 100644 --- a/ui-v2/app/serializers/application.js +++ b/ui-v2/app/serializers/application.js @@ -1,19 +1,59 @@ import Serializer from 'ember-data/serializers/rest'; +import { get } from '@ember/object'; +import { + HEADERS_SYMBOL as HTTP_HEADERS_SYMBOL, + HEADERS_INDEX as HTTP_HEADERS_INDEX, + HEADERS_DIGEST as HTTP_HEADERS_DIGEST, +} from 'consul-ui/utils/http/consul'; export default Serializer.extend({ // this could get confusing if you tried to override // say `normalizeQueryResponse` // TODO: consider creating a method for each one of the `normalize...Response` family normalizeResponse: function(store, primaryModelClass, payload, id, requestType) { - return this._super( + // Pick the meta/headers back off the payload and cleanup + // before we go through serializing + const headers = payload[HTTP_HEADERS_SYMBOL] || {}; + delete payload[HTTP_HEADERS_SYMBOL]; + const normalizedPayload = this.normalizePayload(payload, id, requestType); + const response = this._super( store, primaryModelClass, { - [primaryModelClass.modelName]: this.normalizePayload(payload, id, requestType), + [primaryModelClass.modelName]: normalizedPayload, }, id, requestType ); + // put the meta onto the response, here this is ok + // as JSON-API allows this and our specific data is now in + // response[primaryModelClass.modelName] + // so we aren't in danger of overwriting anything + // (which was the reason for the Symbol-like property earlier) + // use a method modelled on ember-data methods so we have the opportunity to + // do this on a per-model level + response.meta = this.normalizeMeta( + store, + primaryModelClass, + headers, + normalizedPayload, + id, + requestType + ); + return response; + }, + normalizeMeta: function(store, primaryModelClass, headers, payload, id, requestType) { + const meta = { + index: headers[HTTP_HEADERS_INDEX], + digest: headers[HTTP_HEADERS_DIGEST], + date: headers['date'], + }; + if (requestType === 'query') { + meta.ids = payload.map(item => { + return get(item, this.primaryKey); + }); + } + return meta; }, normalizePayload: function(payload, id, requestType) { return payload; diff --git a/ui-v2/app/utils/http/consul.js b/ui-v2/app/utils/http/consul.js new file mode 100644 index 0000000000..f81e47d366 --- /dev/null +++ b/ui-v2/app/utils/http/consul.js @@ -0,0 +1,3 @@ +export const HEADERS_SYMBOL = '__consul_ui_http_headers__'; +export const HEADERS_INDEX = 'x-consul-index'; +export const HEADERS_DIGEST = 'x-consul-contenthash'; From 7dc7d325dc7f9ac0e82e845c6125ede7272d182f Mon Sep 17 00:00:00 2001 From: John Cowen Date: Mon, 26 Nov 2018 14:29:48 +0000 Subject: [PATCH 12/52] ui: Fixup tests to expect the new `meta` property on listings (#4990) --- ui-v2/tests/integration/adapters/acl/response-test.js | 2 ++ ui-v2/tests/integration/adapters/intention/response-test.js | 2 ++ ui-v2/tests/integration/adapters/kv/response-test.js | 2 ++ ui-v2/tests/integration/adapters/node/response-test.js | 2 ++ ui-v2/tests/integration/adapters/policy/response-test.js | 2 ++ ui-v2/tests/integration/adapters/service/response-test.js | 2 ++ ui-v2/tests/integration/adapters/session/response-test.js | 2 ++ ui-v2/tests/integration/adapters/token/response-test.js | 2 ++ 8 files changed, 16 insertions(+) diff --git a/ui-v2/tests/integration/adapters/acl/response-test.js b/ui-v2/tests/integration/adapters/acl/response-test.js index f1dc795aa5..5da7808da1 100644 --- a/ui-v2/tests/integration/adapters/acl/response-test.js +++ b/ui-v2/tests/integration/adapters/acl/response-test.js @@ -1,6 +1,7 @@ import { module, test } from 'qunit'; import { setupTest } from 'ember-qunit'; import { get } from 'consul-ui/tests/helpers/api'; +import { HEADERS_SYMBOL as META } from 'consul-ui/utils/http/consul'; module('Integration | Adapter | acl | response', function(hooks) { setupTest(hooks); const dc = 'dc-1'; @@ -29,6 +30,7 @@ module('Integration | Adapter | acl | response', function(hooks) { return get(request.url).then(function(payload) { const expected = Object.assign({}, payload[0], { Datacenter: dc, + [META]: {}, uid: `["${dc}","${id}"]`, }); const actual = adapter.handleResponse(200, {}, payload, request); diff --git a/ui-v2/tests/integration/adapters/intention/response-test.js b/ui-v2/tests/integration/adapters/intention/response-test.js index 48944273a6..62c583cb6a 100644 --- a/ui-v2/tests/integration/adapters/intention/response-test.js +++ b/ui-v2/tests/integration/adapters/intention/response-test.js @@ -1,6 +1,7 @@ import { module, test } from 'qunit'; import { setupTest } from 'ember-qunit'; import { get } from 'consul-ui/tests/helpers/api'; +import { HEADERS_SYMBOL as META } from 'consul-ui/utils/http/consul'; module('Integration | Adapter | intention | response', function(hooks) { setupTest(hooks); const dc = 'dc-1'; @@ -31,6 +32,7 @@ module('Integration | Adapter | intention | response', function(hooks) { return get(request.url).then(function(payload) { const expected = Object.assign({}, payload, { Datacenter: dc, + [META]: {}, uid: `["${dc}","${id}"]`, }); const actual = adapter.handleResponse(200, {}, payload, request); diff --git a/ui-v2/tests/integration/adapters/kv/response-test.js b/ui-v2/tests/integration/adapters/kv/response-test.js index 8b5dd8e729..db6e5def7f 100644 --- a/ui-v2/tests/integration/adapters/kv/response-test.js +++ b/ui-v2/tests/integration/adapters/kv/response-test.js @@ -1,6 +1,7 @@ import { module, test } from 'qunit'; import { setupTest } from 'ember-qunit'; import { get } from 'consul-ui/tests/helpers/api'; +import { HEADERS_SYMBOL as META } from 'consul-ui/utils/http/consul'; module('Integration | Adapter | kv | response', function(hooks) { setupTest(hooks); const dc = 'dc-1'; @@ -35,6 +36,7 @@ module('Integration | Adapter | kv | response', function(hooks) { return get(request.url).then(function(payload) { const expected = Object.assign({}, payload[0], { Datacenter: dc, + [META]: {}, uid: `["${dc}","${id}"]`, }); const actual = adapter.handleResponse(200, {}, payload, request); diff --git a/ui-v2/tests/integration/adapters/node/response-test.js b/ui-v2/tests/integration/adapters/node/response-test.js index dcddc2359a..c3a3ebf49a 100644 --- a/ui-v2/tests/integration/adapters/node/response-test.js +++ b/ui-v2/tests/integration/adapters/node/response-test.js @@ -1,6 +1,7 @@ import { module, test } from 'qunit'; import { setupTest } from 'ember-qunit'; import { get } from 'consul-ui/tests/helpers/api'; +import { HEADERS_SYMBOL as META } from 'consul-ui/utils/http/consul'; module('Integration | Adapter | node | response', function(hooks) { setupTest(hooks); test('handleResponse returns the correct data for list endpoint', function(assert) { @@ -30,6 +31,7 @@ module('Integration | Adapter | node | response', function(hooks) { return get(request.url).then(function(payload) { const expected = Object.assign({}, payload, { Datacenter: dc, + [META]: {}, uid: `["${dc}","${id}"]`, }); const actual = adapter.handleResponse(200, {}, payload, request); diff --git a/ui-v2/tests/integration/adapters/policy/response-test.js b/ui-v2/tests/integration/adapters/policy/response-test.js index dc90fa1079..03ef04ea06 100644 --- a/ui-v2/tests/integration/adapters/policy/response-test.js +++ b/ui-v2/tests/integration/adapters/policy/response-test.js @@ -1,6 +1,7 @@ import { module, test } from 'qunit'; import { setupTest } from 'ember-qunit'; import { get } from 'consul-ui/tests/helpers/api'; +import { HEADERS_SYMBOL as META } from 'consul-ui/utils/http/consul'; module('Integration | Adapter | policy | response', function(hooks) { setupTest(hooks); const dc = 'dc-1'; @@ -29,6 +30,7 @@ module('Integration | Adapter | policy | response', function(hooks) { return get(request.url).then(function(payload) { const expected = Object.assign({}, payload, { Datacenter: dc, + [META]: {}, uid: `["${dc}","${id}"]`, }); const actual = adapter.handleResponse(200, {}, payload, request); diff --git a/ui-v2/tests/integration/adapters/service/response-test.js b/ui-v2/tests/integration/adapters/service/response-test.js index a9641b6687..827dc5c72e 100644 --- a/ui-v2/tests/integration/adapters/service/response-test.js +++ b/ui-v2/tests/integration/adapters/service/response-test.js @@ -1,6 +1,7 @@ import { module, test } from 'qunit'; import { setupTest } from 'ember-qunit'; import { get } from 'consul-ui/tests/helpers/api'; +import { HEADERS_SYMBOL as META } from 'consul-ui/utils/http/consul'; module('Integration | Adapter | service | response', function(hooks) { setupTest(hooks); test('handleResponse returns the correct data for list endpoint', function(assert) { @@ -30,6 +31,7 @@ module('Integration | Adapter | service | response', function(hooks) { return get(request.url).then(function(payload) { const expected = { Datacenter: dc, + [META]: {}, uid: `["${dc}","${id}"]`, Nodes: payload, }; diff --git a/ui-v2/tests/integration/adapters/session/response-test.js b/ui-v2/tests/integration/adapters/session/response-test.js index 9af7fde388..9626bc5517 100644 --- a/ui-v2/tests/integration/adapters/session/response-test.js +++ b/ui-v2/tests/integration/adapters/session/response-test.js @@ -1,6 +1,7 @@ import { module, test } from 'qunit'; import { setupTest } from 'ember-qunit'; import { get } from 'consul-ui/tests/helpers/api'; +import { HEADERS_SYMBOL as META } from 'consul-ui/utils/http/consul'; module('Integration | Adapter | session | response', function(hooks) { setupTest(hooks); const dc = 'dc-1'; @@ -30,6 +31,7 @@ module('Integration | Adapter | session | response', function(hooks) { return get(request.url).then(function(payload) { const expected = Object.assign({}, payload[0], { Datacenter: dc, + [META]: {}, uid: `["${dc}","${id}"]`, }); const actual = adapter.handleResponse(200, {}, payload, request); diff --git a/ui-v2/tests/integration/adapters/token/response-test.js b/ui-v2/tests/integration/adapters/token/response-test.js index 0a60965b8d..573ef05b57 100644 --- a/ui-v2/tests/integration/adapters/token/response-test.js +++ b/ui-v2/tests/integration/adapters/token/response-test.js @@ -1,6 +1,7 @@ import { module, test } from 'qunit'; import { setupTest } from 'ember-qunit'; import { get } from 'consul-ui/tests/helpers/api'; +import { HEADERS_SYMBOL as META } from 'consul-ui/utils/http/consul'; module('Integration | Adapter | token | response', function(hooks) { setupTest(hooks); const dc = 'dc-1'; @@ -29,6 +30,7 @@ module('Integration | Adapter | token | response', function(hooks) { return get(request.url).then(function(payload) { const expected = Object.assign({}, payload, { Datacenter: dc, + [META]: {}, uid: `["${dc}","${id}"]`, }); const actual = adapter.handleResponse(200, {}, payload, request); From 7fc59af7e2709a2a9179977e829c811a59b81859 Mon Sep 17 00:00:00 2001 From: John Cowen Date: Mon, 26 Nov 2018 16:37:25 +0000 Subject: [PATCH 13/52] ui: Pass GET data and clean certain values for KV's ensuring consistency (#4991) --- ui-v2/app/adapters/application.js | 3 +++ ui-v2/app/adapters/kv.js | 1 + 2 files changed, 4 insertions(+) diff --git a/ui-v2/app/adapters/application.js b/ui-v2/app/adapters/application.js index 07532cc0ed..aa3555059d 100644 --- a/ui-v2/app/adapters/application.js +++ b/ui-v2/app/adapters/application.js @@ -71,6 +71,9 @@ export default Adapter.extend({ delete _query.id; } const query = { ..._query }; + if (typeof query.separator !== 'undefined') { + delete query.separator; + } delete _query[DATACENTER_QUERY_PARAM]; return query; }, diff --git a/ui-v2/app/adapters/kv.js b/ui-v2/app/adapters/kv.js index 509b1440f0..b99422abba 100644 --- a/ui-v2/app/adapters/kv.js +++ b/ui-v2/app/adapters/kv.js @@ -136,6 +136,7 @@ export default Adapter.extend({ } return null; } + return data; }, methodForRequest: function(params) { switch (params.requestType) { From 845081e871afe1e099ae14493b42f1abc7a1efe6 Mon Sep 17 00:00:00 2001 From: John Cowen Date: Tue, 4 Dec 2018 17:01:59 +0000 Subject: [PATCH 14/52] ui: Resolve bad rebase and add in KV test for change of GET for KVs (#5000) --- ui-v2/app/routes/application.js | 6 +++++- ui-v2/tests/unit/adapters/kv-test.js | 4 ++-- ui-v2/tests/unit/routes/application-test.js | 9 ++++++++- 3 files changed, 15 insertions(+), 4 deletions(-) diff --git a/ui-v2/app/routes/application.js b/ui-v2/app/routes/application.js index 7645d51c75..61f78c2274 100644 --- a/ui-v2/app/routes/application.js +++ b/ui-v2/app/routes/application.js @@ -3,10 +3,14 @@ import { inject as service } from '@ember/service'; import { hash } from 'rsvp'; import { get } from '@ember/object'; import { next } from '@ember/runloop'; +import { Promise } from 'rsvp'; + +import WithBlockingActions from 'consul-ui/mixins/with-blocking-actions'; + const removeLoading = function($from) { return $from.classList.remove('ember-loading'); }; -export default Route.extend({ +export default Route.extend(WithBlockingActions, { dom: service('dom'), init: function() { this._super(...arguments); diff --git a/ui-v2/tests/unit/adapters/kv-test.js b/ui-v2/tests/unit/adapters/kv-test.js index 82efbe1b37..07d7e8868f 100644 --- a/ui-v2/tests/unit/adapters/kv-test.js +++ b/ui-v2/tests/unit/adapters/kv-test.js @@ -85,11 +85,11 @@ module('Unit | Adapter | kv', function(hooks) { }); }); // not included in the above forEach as it's a slightly different concept - it('returns string KV object when calling queryRecord (or anything else) record', function() { + it('returns string KV object when calling queryRecord (or anything else) record', function(message) { const actual = adapter.dataForRequest({ requestType: 'queryRecord', }); - assert.equal(actual, null); + assert.deepEqual(actual, deep); }); }); test('methodForRequest returns the correct method', function(assert) { diff --git a/ui-v2/tests/unit/routes/application-test.js b/ui-v2/tests/unit/routes/application-test.js index e5bf49c9d4..185737a913 100644 --- a/ui-v2/tests/unit/routes/application-test.js +++ b/ui-v2/tests/unit/routes/application-test.js @@ -2,7 +2,14 @@ import { moduleFor, test } from 'ember-qunit'; moduleFor('route:application', 'Unit | Route | application', { // Specify the other units that are required for this test. - needs: ['service:repository/dc', 'service:settings', 'service:dom'], + needs: [ + 'service:repository/dc', + 'service:settings', + 'service:feedback', + 'service:flashMessages', + 'service:logger', + 'service:dom', + ], }); test('it exists', function(assert) { From acd121f988d6e0d1d36726296305161f1c1475f1 Mon Sep 17 00:00:00 2001 From: John Cowen Date: Tue, 4 Dec 2018 17:02:31 +0000 Subject: [PATCH 15/52] ui: Rename extended Component variables, tweak some comments (#5021) --- ui-v2/app/components/modal-dialog.js | 4 ++-- ui-v2/app/components/modal-layer.js | 4 ++-- ui-v2/app/components/tabular-collection.js | 4 ++-- ui-v2/app/utils/form/builder.js | 2 +- ui-v2/ember-cli-build.js | 2 +- 5 files changed, 8 insertions(+), 8 deletions(-) diff --git a/ui-v2/app/components/modal-dialog.js b/ui-v2/app/components/modal-dialog.js index 787f996ae6..f34c78a096 100644 --- a/ui-v2/app/components/modal-dialog.js +++ b/ui-v2/app/components/modal-dialog.js @@ -1,11 +1,11 @@ import { get, set } from '@ember/object'; import { inject as service } from '@ember/service'; -import Component from 'consul-ui/components/dom-buffer'; +import DomBufferComponent from 'consul-ui/components/dom-buffer'; import SlotsMixin from 'block-slots'; import WithResizing from 'consul-ui/mixins/with-resizing'; import templatize from 'consul-ui/utils/templatize'; -export default Component.extend(SlotsMixin, WithResizing, { +export default DomBufferComponent.extend(SlotsMixin, WithResizing, { dom: service('dom'), checked: true, height: null, diff --git a/ui-v2/app/components/modal-layer.js b/ui-v2/app/components/modal-layer.js index 66e760e563..74a2887c31 100644 --- a/ui-v2/app/components/modal-layer.js +++ b/ui-v2/app/components/modal-layer.js @@ -1,8 +1,8 @@ -import Component from 'consul-ui/components/dom-buffer-flush'; +import DomBufferFlushComponent from 'consul-ui/components/dom-buffer-flush'; import { inject as service } from '@ember/service'; import { get } from '@ember/object'; -export default Component.extend({ +export default DomBufferFlushComponent.extend({ dom: service('dom'), actions: { change: function(e) { diff --git a/ui-v2/app/components/tabular-collection.js b/ui-v2/app/components/tabular-collection.js index 3705703344..fd331aa868 100644 --- a/ui-v2/app/components/tabular-collection.js +++ b/ui-v2/app/components/tabular-collection.js @@ -1,4 +1,4 @@ -import Component from 'ember-collection/components/ember-collection'; +import CollectionComponent from 'ember-collection/components/ember-collection'; import needsRevalidate from 'ember-collection/utils/needs-revalidate'; import identity from 'ember-collection/utils/identity'; import Grid from 'ember-collection/layouts/grid'; @@ -108,7 +108,7 @@ const change = function(e) { } } }; -export default Component.extend(SlotsMixin, WithResizing, { +export default CollectionComponent.extend(SlotsMixin, WithResizing, { tagName: 'table', classNames: ['dom-recycling'], attributeBindings: ['style'], diff --git a/ui-v2/app/utils/form/builder.js b/ui-v2/app/utils/form/builder.js index b7a8e8c5fd..2047f0e355 100644 --- a/ui-v2/app/utils/form/builder.js +++ b/ui-v2/app/utils/form/builder.js @@ -14,7 +14,7 @@ const defaultChangeset = function(data, validators) { return changeset; }; /** - * Form builder/Form factory (WIP) + * Form builder/Form factory * Deals with handling (generally change) events and updating data in response to the change * in a typical data down event up manner * validations are included currently using ember-changeset-validations diff --git a/ui-v2/ember-cli-build.js b/ui-v2/ember-cli-build.js index 3ec0935578..ce6d212f6b 100644 --- a/ui-v2/ember-cli-build.js +++ b/ui-v2/ember-cli-build.js @@ -5,7 +5,7 @@ module.exports = function(defaults) { const env = EmberApp.env(); const prodlike = ['production', 'staging']; const isProd = env === 'production'; - // leave this in for now for when I start a proper staging env + // if we ever need a 'prodlike' staging environment with staging settings // const isProdLike = prodlike.indexOf(env) > -1; const sourcemaps = !isProd; let app = new EmberApp( From 23a236ae95efe349ca75349e67ce6136fdb620eb Mon Sep 17 00:00:00 2001 From: John Cowen Date: Tue, 4 Dec 2018 17:03:23 +0000 Subject: [PATCH 16/52] ui: Adds warning flash messages (yellow with warning icon) (#5033) --- ui-v2/app/styles/components/flash-message/skin.scss | 6 ++++++ ui-v2/app/templates/components/app-view.hbs | 7 ++++++- 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/ui-v2/app/styles/components/flash-message/skin.scss b/ui-v2/app/styles/components/flash-message/skin.scss index 0c4f82f238..b06698fed8 100644 --- a/ui-v2/app/styles/components/flash-message/skin.scss +++ b/ui-v2/app/styles/components/flash-message/skin.scss @@ -5,12 +5,18 @@ %flash-message p.success strong { @extend %with-passing; } +%flash-message p.warning strong { + @extend %with-warning; +} %flash-message p.error strong { @extend %with-critical; } %flash-message p.success { @extend %frame-green-500; } +%flash-message p.warning { + @extend %frame-yellow-500; +} %flash-message p.error { @extend %frame-red-500; } diff --git a/ui-v2/app/templates/components/app-view.hbs b/ui-v2/app/templates/components/app-view.hbs index 52bcfc61fc..9ac7c7106b 100644 --- a/ui-v2/app/templates/components/app-view.hbs +++ b/ui-v2/app/templates/components/app-view.hbs @@ -5,7 +5,12 @@ {{#flash-message flash=flash as |component flash|}} {{! flashes automatically ucfirst the type }} -

    {{if (eq component.flashType 'Success') 'Success!' 'Error!'}} {{#yield-slot 'notification' (block-params (lowercase component.flashType) (lowercase flash.action) flash.item )}}{{yield}}{{/yield-slot}}

    +

    + + {{component.flashType}}! + + {{#yield-slot 'notification' (block-params (lowercase component.flashType) (lowercase flash.action) flash.item )}}{{yield}}{{/yield-slot}} +

    {{/flash-message}} {{/each}}
    From 338527cc2e5fa6cfeccdd4df4bfb7de62ee89e0f Mon Sep 17 00:00:00 2001 From: John Cowen Date: Tue, 4 Dec 2018 17:04:01 +0000 Subject: [PATCH 17/52] ui: Adds better error passthrough, disable/unauthorize properly on error (#5041) 1. Ensure any unexpected developer errors are passed through/shown 2. Previously when errors where returns/resolved the special isEnabled/isAuthorized would never get resolved. This was fine as they were set to false to start with anyway, but this resolves them again to false for completeness 3. Improved unit testing coverage --- ui-v2/app/utils/acls-status.js | 47 +++++++----- ui-v2/tests/unit/utils/acls-status-test.js | 89 ++++++++++++++++++++-- 2 files changed, 111 insertions(+), 25 deletions(-) diff --git a/ui-v2/app/utils/acls-status.js b/ui-v2/app/utils/acls-status.js index 9712e5820c..acc34dc596 100644 --- a/ui-v2/app/utils/acls-status.js +++ b/ui-v2/app/utils/acls-status.js @@ -23,29 +23,36 @@ export default function(isValidServerError, P = Promise) { }), [propName]: p .catch(function(e) { - switch (e.errors[0].status) { - case '500': - if (isValidServerError(e)) { + if (e.errors && e.errors[0]) { + switch (e.errors[0].status) { + case '500': + if (isValidServerError(e)) { + enable(true); + authorize(false); + } else { + enable(false); + authorize(false); + return P.reject(e); + } + break; + case '403': enable(true); authorize(false); - } else { - return P.reject(e); - } - break; - case '403': - enable(true); - authorize(false); - break; - case '401': - enable(false); - authorize(false); - break; - default: - enable(false); - authorize(false); - throw e; + break; + case '401': + enable(false); + authorize(false); + break; + default: + enable(false); + authorize(false); + throw e; + } + return []; } - return []; + enable(false); + authorize(false); + throw e; }) .then(function(res) { enable(true); diff --git a/ui-v2/tests/unit/utils/acls-status-test.js b/ui-v2/tests/unit/utils/acls-status-test.js index 19766d2a44..4504dfac8b 100644 --- a/ui-v2/tests/unit/utils/acls-status-test.js +++ b/ui-v2/tests/unit/utils/acls-status-test.js @@ -1,10 +1,89 @@ +import { module } from 'ember-qunit'; +import test from 'ember-sinon-qunit/test-support/test'; import aclsStatus from 'consul-ui/utils/acls-status'; -import { module, test } from 'qunit'; module('Unit | Utility | acls status'); -// Replace this with your real tests. -test('it works', function(assert) { - let result = aclsStatus(); - assert.ok(result); +test('it rejects and nothing is enabled or authorized', function(assert) { + const isValidServerError = this.stub().returns(false); + const status = aclsStatus(isValidServerError); + [ + this.stub().rejects(), + this.stub().rejects({ errors: [] }), + this.stub().rejects({ errors: [{ status: '404' }] }), + ].forEach(function(reject) { + const actual = status({ + response: reject(), + }); + assert.rejects(actual.response); + ['isAuthorized', 'isEnabled'].forEach(function(prop) { + actual[prop].then(function(actual) { + assert.notOk(actual); + }); + }); + }); +}); +test('with a 401 it resolves with an empty array and nothing is enabled or authorized', function(assert) { + assert.expect(3); + const isValidServerError = this.stub().returns(false); + const status = aclsStatus(isValidServerError); + const actual = status({ + response: this.stub().rejects({ errors: [{ status: '401' }] })(), + }); + actual.response.then(function(actual) { + assert.deepEqual(actual, []); + }); + ['isAuthorized', 'isEnabled'].forEach(function(prop) { + actual[prop].then(function(actual) { + assert.notOk(actual); + }); + }); +}); +test("with a 403 it resolves with an empty array and it's enabled but not authorized", function(assert) { + assert.expect(3); + const isValidServerError = this.stub().returns(false); + const status = aclsStatus(isValidServerError); + const actual = status({ + response: this.stub().rejects({ errors: [{ status: '403' }] })(), + }); + actual.response.then(function(actual) { + assert.deepEqual(actual, []); + }); + actual.isEnabled.then(function(actual) { + assert.ok(actual); + }); + actual.isAuthorized.then(function(actual) { + assert.notOk(actual); + }); +}); +test("with a 500 (but not a 'valid' error) it rejects and nothing is enabled or authorized", function(assert) { + assert.expect(3); + const isValidServerError = this.stub().returns(false); + const status = aclsStatus(isValidServerError); + const actual = status({ + response: this.stub().rejects({ errors: [{ status: '500' }] })(), + }); + assert.rejects(actual.response); + ['isAuthorized', 'isEnabled'].forEach(function(prop) { + actual[prop].then(function(actual) { + assert.notOk(actual); + }); + }); +}); +test("with a 500 and a 'valid' error, it resolves with an empty array and it's enabled but not authorized", function(assert) { + assert.expect(3); + const isValidServerError = this.stub().returns(true); + const status = aclsStatus(isValidServerError); + const actual = status({ + response: this.stub().rejects({ errors: [{ status: '500' }] })(), + }); + actual.response.then(function(actual) { + assert.deepEqual(actual, []); + }); + actual.isEnabled.then(function(actual) { + assert.ok(actual); + }); + actual.isAuthorized.then(function(actual) { + assert.notOk(actual); + }); }); From bf50beff42d07d0ff3fe94389f0d17f35dc9ac8d Mon Sep 17 00:00:00 2001 From: John Cowen Date: Tue, 4 Dec 2018 17:04:44 +0000 Subject: [PATCH 18/52] ui: Prefer `cursor` over `index`, add `configuration` option to repos (#5042) --- ui-v2/app/serializers/application.js | 4 +--- ui-v2/app/services/repository.js | 22 +++++++++++++------- ui-v2/app/services/repository/kv.js | 26 ++++++++++++++++-------- ui-v2/app/services/repository/session.js | 10 ++++++--- 4 files changed, 40 insertions(+), 22 deletions(-) diff --git a/ui-v2/app/serializers/application.js b/ui-v2/app/serializers/application.js index e75c96559b..8150179254 100644 --- a/ui-v2/app/serializers/application.js +++ b/ui-v2/app/serializers/application.js @@ -4,7 +4,6 @@ import { get } from '@ember/object'; import { HEADERS_SYMBOL as HTTP_HEADERS_SYMBOL, HEADERS_INDEX as HTTP_HEADERS_INDEX, - HEADERS_DIGEST as HTTP_HEADERS_DIGEST, } from 'consul-ui/utils/http/consul'; export default Serializer.extend({ // this could get confusing if you tried to override @@ -44,8 +43,7 @@ export default Serializer.extend({ }, normalizeMeta: function(store, primaryModelClass, headers, payload, id, requestType) { const meta = { - index: headers[HTTP_HEADERS_INDEX], - digest: headers[HTTP_HEADERS_DIGEST], + cursor: headers[HTTP_HEADERS_INDEX], date: headers['date'], }; if (requestType === 'query') { diff --git a/ui-v2/app/services/repository.js b/ui-v2/app/services/repository.js index f7fe5ac0e1..13eaf7f780 100644 --- a/ui-v2/app/services/repository.js +++ b/ui-v2/app/services/repository.js @@ -14,16 +14,24 @@ export default Service.extend({ }, // store: service('store'), - findAllByDatacenter: function(dc) { - return get(this, 'store').query(this.getModelName(), { + findAllByDatacenter: function(dc, configuration = {}) { + const query = { dc: dc, - }); + }; + if (typeof configuration.cursor !== 'undefined') { + query.index = configuration.cursor; + } + return get(this, 'store').query(this.getModelName(), query); }, - findBySlug: function(slug, dc) { - return get(this, 'store').queryRecord(this.getModelName(), { - id: slug, + findBySlug: function(slug, dc, configuration = {}) { + const query = { dc: dc, - }); + id: slug, + }; + if (typeof configuration.cursor !== 'undefined') { + query.index = configuration.cursor; + } + return get(this, 'store').queryRecord(this.getModelName(), query); }, create: function(obj) { // TODO: This should probably return a Promise diff --git a/ui-v2/app/services/repository/kv.js b/ui-v2/app/services/repository/kv.js index 6185c9022e..c3c4b1900d 100644 --- a/ui-v2/app/services/repository/kv.js +++ b/ui-v2/app/services/repository/kv.js @@ -13,7 +13,7 @@ export default RepositoryService.extend({ return PRIMARY_KEY; }, // this one gives you the full object so key,values and meta - findBySlug: function(key, dc) { + findBySlug: function(key, dc, configuration = {}) { if (isFolder(key)) { const id = JSON.stringify([dc, key]); let item = get(this, 'store').peekRecord(this.getModelName(), id); @@ -24,23 +24,31 @@ export default RepositoryService.extend({ } return Promise.resolve(item); } - return get(this, 'store').queryRecord(this.getModelName(), { + const query = { id: key, dc: dc, - }); + }; + if (typeof configuration.cursor !== 'undefined') { + query.index = configuration.cursor; + } + return get(this, 'store').queryRecord(this.getModelName(), query); }, // this one only gives you keys // https://www.consul.io/api/kv.html - findAllBySlug: function(key, dc) { + findAllBySlug: function(key, dc, configuration = {}) { if (key === '/') { key = ''; } + const query = { + id: key, + dc: dc, + separator: '/', + }; + if (typeof configuration.cursor !== 'undefined') { + query.index = configuration.cursor; + } return this.get('store') - .query(this.getModelName(), { - id: key, - dc: dc, - separator: '/', - }) + .query(this.getModelName(), query) .then(function(items) { return items.filter(function(item) { return key !== get(item, 'Key'); diff --git a/ui-v2/app/services/repository/session.js b/ui-v2/app/services/repository/session.js index 9dace862dd..aba309469f 100644 --- a/ui-v2/app/services/repository/session.js +++ b/ui-v2/app/services/repository/session.js @@ -8,11 +8,15 @@ export default RepositoryService.extend({ getModelName: function() { return modelName; }, - findByNode: function(node, dc) { - return get(this, 'store').query(this.getModelName(), { + findByNode: function(node, dc, configuration = {}) { + const query = { id: node, dc: dc, - }); + }; + if (typeof configuration.cursor !== 'undefined') { + query.index = configuration.cursor; + } + return get(this, 'store').query(this.getModelName(), query); }, // TODO: Why Key? Probably should be findBySlug like the others findByKey: function(slug, dc) { From ebfddaaace0a3b545d868bcf42840fb0bf11b6b4 Mon Sep 17 00:00:00 2001 From: John Cowen Date: Tue, 4 Dec 2018 17:05:03 +0000 Subject: [PATCH 19/52] ui: ember-data AbortError should have a `0` status (#5048) --- ui-v2/app/adapters/application.js | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/ui-v2/app/adapters/application.js b/ui-v2/app/adapters/application.js index aa3555059d..c673abbb6a 100644 --- a/ui-v2/app/adapters/application.js +++ b/ui-v2/app/adapters/application.js @@ -1,4 +1,5 @@ import Adapter from 'ember-data/adapters/rest'; +import { AbortError } from 'ember-data/adapters/errors'; import { inject as service } from '@ember/service'; import URL from 'url'; @@ -17,6 +18,22 @@ import { HEADERS_SYMBOL as HTTP_HEADERS_SYMBOL } from 'consul-ui/utils/http/cons export default Adapter.extend({ namespace: 'v1', repo: service('settings'), + queryRecord: function() { + return this._super(...arguments).catch(function(e) { + if (e instanceof AbortError) { + e.errors[0].status = '0'; + } + throw e; + }); + }, + query: function() { + return this._super(...arguments).catch(function(e) { + if (e instanceof AbortError) { + e.errors[0].status = '0'; + } + throw e; + }); + }, headersForRequest: function(params) { return { ...this.get('repo').findHeaders(), From 2920f73ddd81deb11fa1ea6e21c8a3ab2ea0ebb1 Mon Sep 17 00:00:00 2001 From: John Cowen Date: Wed, 5 Dec 2018 09:07:16 +0000 Subject: [PATCH 20/52] ui: Adds `document` and `viewport` methods to the dom service (#5052) `window` and `document` are easily injected anyhow, but this primarily this keeps everything dom related in the same place. Included here are changes to make all ember related objects use the dom service `document` and `viewport` instead of just `document` and `window`. Quote from a previous PR (#4924) which explains the thinking around this: > Now I have all these things in the dom service, it would make sense to get window from there also. I was thinking of making a viewport method, which would be a nice word whether window was a browser window, an iframe (not really a window) like when ember testing, or anything else. To me the viewport is what we are actually talking about here. --- ui-v2/app/components/hashicorp-consul.js | 5 ++--- ui-v2/app/mixins/click-outside.js | 16 +++++++++++----- ui-v2/app/mixins/with-resizing.js | 13 +++++++++---- ui-v2/app/services/dom.js | 7 +++++++ .../integration/mixins/with-resizing-test.js | 7 ++++++- 5 files changed, 35 insertions(+), 13 deletions(-) diff --git a/ui-v2/app/components/hashicorp-consul.js b/ui-v2/app/components/hashicorp-consul.js index 93a3064bc9..2935d043b3 100644 --- a/ui-v2/app/components/hashicorp-consul.js +++ b/ui-v2/app/components/hashicorp-consul.js @@ -3,8 +3,6 @@ import { get, set } from '@ember/object'; import { inject as service } from '@ember/service'; export default Component.extend({ dom: service('dom'), - // TODO: could this be dom.viewport() ? - win: window, isDropdownVisible: false, didInsertElement: function() { get(this, 'dom') @@ -19,11 +17,12 @@ export default Component.extend({ }, change: function(e) { const dom = get(this, 'dom'); + const win = dom.viewport(); const $root = dom.root(); const $body = dom.element('body'); if (e.target.checked) { $root.classList.add('template-with-vertical-menu'); - $body.style.height = $root.style.height = get(this, 'win').innerHeight + 'px'; + $body.style.height = $root.style.height = win.innerHeight + 'px'; } else { $root.classList.remove('template-with-vertical-menu'); $body.style.height = $root.style.height = null; diff --git a/ui-v2/app/mixins/click-outside.js b/ui-v2/app/mixins/click-outside.js index 92c2d89639..b21ec55071 100644 --- a/ui-v2/app/mixins/click-outside.js +++ b/ui-v2/app/mixins/click-outside.js @@ -1,16 +1,19 @@ import Mixin from '@ember/object/mixin'; - +import { inject as service } from '@ember/service'; import { next } from '@ember/runloop'; import { get } from '@ember/object'; -const isOutside = function(element, e) { + +// TODO: Potentially move this to dom service +const isOutside = function(element, e, doc = document) { if (element) { - const isRemoved = !e.target || !document.contains(e.target); + const isRemoved = !e.target || !doc.contains(e.target); const isInside = element === e.target || element.contains(e.target); return !isRemoved && !isInside; } else { return false; } }; + const handler = function(e) { const el = get(this, 'element'); if (isOutside(el, e)) { @@ -18,6 +21,7 @@ const handler = function(e) { } }; export default Mixin.create({ + dom: service('dom'), init: function() { this._super(...arguments); this.handler = handler.bind(this); @@ -26,12 +30,14 @@ export default Mixin.create({ onblur: function() {}, didInsertElement: function() { this._super(...arguments); + const doc = get(this, 'dom').document(); next(this, () => { - document.addEventListener('click', this.handler); + doc.addEventListener('click', this.handler); }); }, willDestroyElement: function() { this._super(...arguments); - document.removeEventListener('click', this.handler); + const doc = get(this, 'dom').document(); + doc.removeEventListener('click', this.handler); }, }); diff --git a/ui-v2/app/mixins/with-resizing.js b/ui-v2/app/mixins/with-resizing.js index 473306844e..d35aaac8f4 100644 --- a/ui-v2/app/mixins/with-resizing.js +++ b/ui-v2/app/mixins/with-resizing.js @@ -1,11 +1,12 @@ import Mixin from '@ember/object/mixin'; +import { inject as service } from '@ember/service'; import { get } from '@ember/object'; import { assert } from '@ember/debug'; export default Mixin.create({ + dom: service('dom'), resize: function(e) { assert('with-resizing.resize needs to be overridden', false); }, - win: window, init: function() { this._super(...arguments); this.handler = e => { @@ -17,14 +18,18 @@ export default Mixin.create({ }, didInsertElement: function() { this._super(...arguments); - get(this, 'win').addEventListener('resize', this.handler, false); + get(this, 'dom') + .viewport() + .addEventListener('resize', this.handler, false); this.didAppear(); }, didAppear: function() { - this.handler({ target: get(this, 'win') }); + this.handler({ target: get(this, 'dom').viewport() }); }, willDestroyElement: function() { - get(this, 'win').removeEventListener('resize', this.handler, false); + get(this, 'dom') + .viewport() + .removeEventListener('resize', this.handler, false); this._super(...arguments); }, }); diff --git a/ui-v2/app/services/dom.js b/ui-v2/app/services/dom.js index ae88e0f5a0..d0790fb126 100644 --- a/ui-v2/app/services/dom.js +++ b/ui-v2/app/services/dom.js @@ -23,10 +23,17 @@ let $_; const clickFirstAnchor = clickFirstAnchorFactory(closest); export default Service.extend({ doc: document, + win: window, init: function() { this._super(...arguments); $_ = getComponentFactory(getOwner(this)); }, + document: function() { + return get(this, 'doc'); + }, + viewport: function() { + return get(this, 'win'); + }, // TODO: should this be here? Needs a better name at least clickFirstAnchor: clickFirstAnchor, closest: closest, diff --git a/ui-v2/tests/integration/mixins/with-resizing-test.js b/ui-v2/tests/integration/mixins/with-resizing-test.js index 8ebbee85ac..213b151b13 100644 --- a/ui-v2/tests/integration/mixins/with-resizing-test.js +++ b/ui-v2/tests/integration/mixins/with-resizing-test.js @@ -12,8 +12,13 @@ module('Integration | Mixin | with-resizing', function(hooks) { addEventListener: this.stub(), removeEventListener: this.stub(), }; + const dom = { + viewport: function() { + return win; + }, + }; const subject = EmberObject.extend(Mixin, { - win: win, + dom: dom, }).create(); const resize = this.stub(subject, 'resize'); subject.didInsertElement(); From c71f718bc711ca5a512bfe5c5344be5e8da0858c Mon Sep 17 00:00:00 2001 From: John Cowen Date: Tue, 11 Dec 2018 12:34:57 +0000 Subject: [PATCH 21/52] ui: Adds controller lifecycle `reset` hook (#5056) --- .../app/initializers/controller-lifecycle.js | 23 +++++++++++++++++++ ui-v2/app/mixins/with-listeners.js | 23 +++++++++++++------ 2 files changed, 39 insertions(+), 7 deletions(-) create mode 100644 ui-v2/app/initializers/controller-lifecycle.js diff --git a/ui-v2/app/initializers/controller-lifecycle.js b/ui-v2/app/initializers/controller-lifecycle.js new file mode 100644 index 0000000000..bdfee585b6 --- /dev/null +++ b/ui-v2/app/initializers/controller-lifecycle.js @@ -0,0 +1,23 @@ +import Route from '@ember/routing/route'; +/** + * This initializer is very similar to: + * https://github.com/kellyselden/ember-controller-lifecycle + * + * Why is this included here: + * 1. Make sure lifecycle functions are functions, not just truthy. + * 2. Right now we don't want a setup function (at least until we are definitely decided that we want one) + * This is possibly a very personal opinion so it makes sense to just include this file here. + */ +Route.reopen({ + resetController(controller, exiting, transition) { + this._super(...arguments); + if (typeof controller.reset === 'function') { + controller.reset(exiting); + } + }, +}); +export function initialize() {} + +export default { + initialize, +}; diff --git a/ui-v2/app/mixins/with-listeners.js b/ui-v2/app/mixins/with-listeners.js index a1ef188b80..4a3ac20196 100644 --- a/ui-v2/app/mixins/with-listeners.js +++ b/ui-v2/app/mixins/with-listeners.js @@ -1,3 +1,4 @@ +import Controller from '@ember/controller'; import Component from '@ember/component'; import Mixin from '@ember/object/mixin'; import { inject as service } from '@ember/service'; @@ -8,15 +9,23 @@ export default Mixin.create({ init: function() { this._super(...arguments); this._listeners = get(this, 'dom').listeners(); - let method = 'willDestroy'; + let teardown = ['willDestroy']; if (this instanceof Component) { - method = 'willDestroyElement'; + teardown = ['willDestroyElement']; + } else if (this instanceof Controller) { + if (typeof this.reset === 'function') { + teardown.push('reset'); + } } - const destroy = this[method]; - this[method] = function() { - destroy(...arguments); - this.removeListeners(); - }; + teardown.forEach(method => { + const destroy = this[method]; + this[method] = function() { + if (typeof destroy === 'function') { + destroy.apply(this, arguments); + } + this.removeListeners(); + }; + }); }, listen: function(target, event, handler) { return this._listeners.add(...arguments); From 4d183ef743e0071b695a8746ba54775bfe5130ff Mon Sep 17 00:00:00 2001 From: John Cowen Date: Fri, 25 Jan 2019 12:27:42 +0000 Subject: [PATCH 22/52] ui: Adds 2 computed utilities, a factory and a purify (#5079) 1. The factory is taken from the ember source, but makes it more reusable 2. Purify converts conventional ember `computed` into a pure version This commit only adds new files that could be used further down the line --- ui-v2/app/utils/computed/factory.js | 18 +++++++++++++ ui-v2/app/utils/computed/purify.js | 42 +++++++++++++++++++++++++++++ 2 files changed, 60 insertions(+) create mode 100644 ui-v2/app/utils/computed/factory.js create mode 100644 ui-v2/app/utils/computed/purify.js diff --git a/ui-v2/app/utils/computed/factory.js b/ui-v2/app/utils/computed/factory.js new file mode 100644 index 0000000000..72539972ee --- /dev/null +++ b/ui-v2/app/utils/computed/factory.js @@ -0,0 +1,18 @@ +/** + * Gives you factory function to create a specified type of ComputedProperty + * Largely taken from https://github.com/emberjs/ember.js/blob/v2.18.2/packages/ember-metal/lib/computed.js#L529 + * but configurable from the outside (IoC) so its reuseable + * + * @param {Class} ComputedProperty - ComputedProperty to use for the factory + * @returns {function} - Ember-like `computed` function (see https://www.emberjs.com/api/ember/2.18/classes/ComputedProperty) + */ +export default function(ComputedProperty) { + return function() { + const args = [...arguments]; + const cp = new ComputedProperty(args.pop()); + if (args.length > 0) { + cp.property(...args); + } + return cp; + }; +} diff --git a/ui-v2/app/utils/computed/purify.js b/ui-v2/app/utils/computed/purify.js new file mode 100644 index 0000000000..3c9eba3410 --- /dev/null +++ b/ui-v2/app/utils/computed/purify.js @@ -0,0 +1,42 @@ +import { get } from '@ember/object'; + +/** + * Converts a conventional non-pure Ember `computed` function into a pure one + * (see https://github.com/emberjs/rfcs/blob/be351b059f08ac0fe709bc7697860d5064717a7f/text/0000-tracked-properties.md#avoiding-dependency-hell) + * + * @param {function} computed - a computed function to 'purify' (convert to a pure function) + * @param {function} filter - Optional string filter function to pre-process the names of computed properties + * @returns {function} - A pure `computed` function + */ + +export default function(computed, filter) { + return function() { + let args = [...arguments]; + let success = function(value) { + return value; + }; + // pop the user function off the end + if (typeof args[args.length - 1] === 'function') { + success = args.pop(); + } + if (typeof filter === 'function') { + args = filter(args); + } + // this is the 'conventional' `computed` + const cb = function(name) { + return success.apply( + this, + args.map(item => { + // Right now this just takes the first part of the path so: + // `items.[]` or `items.@each.prop` etc + // gives you `items` which is 'probably' what you expect + // it won't work with something like `item.objects.[]` + // it could potentially be made to do so, but we don't need that right now at least + return get(this, item.split('.')[0]); + }) + ); + }; + // concat/push the user function back on + return computed(...args.concat([cb])); + }; +} From 516610eb0bbc6e54756762e9c7b1922e5cb64797 Mon Sep 17 00:00:00 2001 From: John Cowen Date: Fri, 25 Jan 2019 12:30:51 +0000 Subject: [PATCH 23/52] ui: Adds XHR connection management to HTTP/1.1 installs (#5083) Adds xhr connection managment to http/1.1 installs This includes various things: 1. An object pool to 'acquire', 'release' and 'dispose' of objects, also a 'purge' to completely empty it 2. A `Request` data object, mainly for reasoning about the object better 3. A pseudo http 'client' which doens't actually control the request itself but does help to manage the connections An initializer is used to detect the script element of the consul-ui sourcecode which we use later to sniff the protocol that we are most likely using for API access --- ui-v2/app/adapters/application.js | 25 ++++- ui-v2/app/initializers/client.js | 15 +++ ui-v2/app/services/client/http.js | 87 ++++++++++++++++ ui-v2/app/services/dom.js | 2 +- ui-v2/app/utils/get-object-pool.js | 52 ++++++++++ ui-v2/app/utils/http/request.js | 29 ++++++ .../services/repository/intention-test.js | 9 +- ui-v2/tests/unit/services/client/http-test.js | 12 +++ .../tests/unit/utils/get-object-pool-test.js | 98 +++++++++++++++++++ ui-v2/tests/unit/utils/http/request-test.js | 10 ++ 10 files changed, 329 insertions(+), 10 deletions(-) create mode 100644 ui-v2/app/initializers/client.js create mode 100644 ui-v2/app/services/client/http.js create mode 100644 ui-v2/app/utils/get-object-pool.js create mode 100644 ui-v2/app/utils/http/request.js create mode 100644 ui-v2/tests/unit/services/client/http-test.js create mode 100644 ui-v2/tests/unit/utils/get-object-pool-test.js create mode 100644 ui-v2/tests/unit/utils/http/request-test.js diff --git a/ui-v2/app/adapters/application.js b/ui-v2/app/adapters/application.js index c673abbb6a..97e86bd55c 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 0000000000..933290b9e9 --- /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 0000000000..1290d2be72 --- /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 d0790fb126..a3bda1c8c8 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 0000000000..ad9f0ebe83 --- /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 0000000000..1a9643d514 --- /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 bedfbb9f2b..59797baa41 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 0000000000..98ff23ff4d --- /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 0000000000..9a82039462 --- /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 0000000000..c10dcbd899 --- /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'); +}); From f225da36f1b7d798431fdab707a0d59f36f37a85 Mon Sep 17 00:00:00 2001 From: John Cowen Date: Tue, 29 Jan 2019 10:19:52 +0000 Subject: [PATCH 24/52] ui: Correctly rebase own token/no delete change --- ui-v2/app/templates/dc/acls/tokens/index.hbs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ui-v2/app/templates/dc/acls/tokens/index.hbs b/ui-v2/app/templates/dc/acls/tokens/index.hbs index c4b5f13c23..8d59c72337 100644 --- a/ui-v2/app/templates/dc/acls/tokens/index.hbs +++ b/ui-v2/app/templates/dc/acls/tokens/index.hbs @@ -91,7 +91,7 @@ {{/if}} -{{#unless (token/is-anonymous item) }} +{{#unless (or (token/is-anonymous item) (eq item.AccessorID token.AccessorID)) }}
  • From cb0c5309c97cb974765c0b655de44705eadc41c0 Mon Sep 17 00:00:00 2001 From: John Cowen Date: Thu, 21 Feb 2019 10:36:15 +0000 Subject: [PATCH 25/52] UI: Add EventSource ready for implementing blocking queries (#5070) - Maintain http headers as JSON-API meta for all API requests (#4946) - Add EventSource ready for implementing blocking queries - EventSource project implementation to enable blocking queries for service and node listings (#5267) - Add setting to enable/disable blocking queries (#5352) --- ui-v2/.eslintignore | 1 + ui-v2/app/adapters/application.js | 3 + ui-v2/app/controllers/dc/nodes/index.js | 3 +- ui-v2/app/controllers/dc/services/index.js | 15 +- ui-v2/app/controllers/settings.js | 29 ++ .../app/instance-initializers/event-source.js | 61 +++ ui-v2/app/mixins/with-event-source.js | 16 + ui-v2/app/mixins/with-filtering.js | 2 +- ui-v2/app/mixins/with-health-filtering.js | 2 +- ui-v2/app/router.js | 6 +- ui-v2/app/routes/settings.js | 16 +- ui-v2/app/services/client/http.js | 3 + ui-v2/app/services/lazy-proxy.js | 27 + .../services/repository/type/event-source.js | 92 ++++ .../templates/components/hashicorp-consul.hbs | 2 - ui-v2/app/templates/settings.hbs | 27 +- ui-v2/app/utils/dom/event-source/blocking.js | 92 ++++ ui-v2/app/utils/dom/event-source/cache.js | 31 ++ ui-v2/app/utils/dom/event-source/callable.js | 70 +++ ui-v2/app/utils/dom/event-source/index.js | 36 ++ ui-v2/app/utils/dom/event-source/proxy.js | 50 ++ .../app/utils/dom/event-source/reopenable.js | 23 + ui-v2/app/utils/dom/event-source/resolver.js | 29 ++ ui-v2/app/utils/dom/event-source/storage.js | 40 ++ .../event-target/event-target-shim/LICENSE | 22 + .../event-target/event-target-shim/event.js | 470 ++++++++++++++++++ ui-v2/app/utils/dom/event-target/rsvp.js | 63 +++ ui-v2/app/utils/form/builder.js | 4 + .../tests/acceptance/dc/list-blocking.feature | 34 ++ .../tests/acceptance/page-navigation.feature | 4 +- .../steps/dc/list-blocking-steps.js | 10 + .../utils/dom/event-source/callable-test.js | 84 ++++ ui-v2/tests/steps.js | 38 +- ui-v2/tests/unit/controllers/settings-test.js | 12 + ui-v2/tests/unit/routes/settings-test.js | 1 + .../utils/dom/event-source/blocking-test.js | 87 ++++ .../unit/utils/dom/event-source/cache-test.js | 145 ++++++ .../utils/dom/event-source/callable-test.js | 65 +++ .../unit/utils/dom/event-source/index-test.js | 28 ++ .../unit/utils/dom/event-source/proxy-test.js | 10 + .../utils/dom/event-source/reopenable-test.js | 46 ++ .../utils/dom/event-source/resolver-test.js | 10 + .../utils/dom/event-source/storage-test.js | 10 + .../unit/utils/dom/event-target/rsvp-test.js | 13 + ui-v2/yarn.lock | 102 +++- 45 files changed, 1878 insertions(+), 56 deletions(-) create mode 100644 ui-v2/.eslintignore create mode 100644 ui-v2/app/controllers/settings.js create mode 100644 ui-v2/app/instance-initializers/event-source.js create mode 100644 ui-v2/app/mixins/with-event-source.js create mode 100644 ui-v2/app/services/lazy-proxy.js create mode 100644 ui-v2/app/services/repository/type/event-source.js create mode 100644 ui-v2/app/utils/dom/event-source/blocking.js create mode 100644 ui-v2/app/utils/dom/event-source/cache.js create mode 100644 ui-v2/app/utils/dom/event-source/callable.js create mode 100644 ui-v2/app/utils/dom/event-source/index.js create mode 100644 ui-v2/app/utils/dom/event-source/proxy.js create mode 100644 ui-v2/app/utils/dom/event-source/reopenable.js create mode 100644 ui-v2/app/utils/dom/event-source/resolver.js create mode 100644 ui-v2/app/utils/dom/event-source/storage.js create mode 100644 ui-v2/app/utils/dom/event-target/event-target-shim/LICENSE create mode 100644 ui-v2/app/utils/dom/event-target/event-target-shim/event.js create mode 100644 ui-v2/app/utils/dom/event-target/rsvp.js create mode 100644 ui-v2/tests/acceptance/dc/list-blocking.feature create mode 100644 ui-v2/tests/acceptance/steps/dc/list-blocking-steps.js create mode 100644 ui-v2/tests/integration/utils/dom/event-source/callable-test.js create mode 100644 ui-v2/tests/unit/controllers/settings-test.js create mode 100644 ui-v2/tests/unit/utils/dom/event-source/blocking-test.js create mode 100644 ui-v2/tests/unit/utils/dom/event-source/cache-test.js create mode 100644 ui-v2/tests/unit/utils/dom/event-source/callable-test.js create mode 100644 ui-v2/tests/unit/utils/dom/event-source/index-test.js create mode 100644 ui-v2/tests/unit/utils/dom/event-source/proxy-test.js create mode 100644 ui-v2/tests/unit/utils/dom/event-source/reopenable-test.js create mode 100644 ui-v2/tests/unit/utils/dom/event-source/resolver-test.js create mode 100644 ui-v2/tests/unit/utils/dom/event-source/storage-test.js create mode 100644 ui-v2/tests/unit/utils/dom/event-target/rsvp-test.js diff --git a/ui-v2/.eslintignore b/ui-v2/.eslintignore new file mode 100644 index 0000000000..06978bbca0 --- /dev/null +++ b/ui-v2/.eslintignore @@ -0,0 +1 @@ +app/utils/dom/event-target/event-target-shim/event.js diff --git a/ui-v2/app/adapters/application.js b/ui-v2/app/adapters/application.js index 97e86bd55c..d5f073972a 100644 --- a/ui-v2/app/adapters/application.js +++ b/ui-v2/app/adapters/application.js @@ -114,6 +114,9 @@ export default Adapter.extend({ if (typeof query.separator !== 'undefined') { delete query.separator; } + if (typeof query.index !== 'undefined') { + delete query.index; + } delete _query[DATACENTER_QUERY_PARAM]; return query; }, diff --git a/ui-v2/app/controllers/dc/nodes/index.js b/ui-v2/app/controllers/dc/nodes/index.js index cbbd7b90d9..8265570612 100644 --- a/ui-v2/app/controllers/dc/nodes/index.js +++ b/ui-v2/app/controllers/dc/nodes/index.js @@ -1,9 +1,10 @@ import Controller from '@ember/controller'; import { computed } from '@ember/object'; +import WithEventSource from 'consul-ui/mixins/with-event-source'; import WithHealthFiltering from 'consul-ui/mixins/with-health-filtering'; import WithSearching from 'consul-ui/mixins/with-searching'; import { get } from '@ember/object'; -export default Controller.extend(WithSearching, WithHealthFiltering, { +export default Controller.extend(WithEventSource, WithSearching, WithHealthFiltering, { init: function() { this.searchParams = { healthyNode: 's', diff --git a/ui-v2/app/controllers/dc/services/index.js b/ui-v2/app/controllers/dc/services/index.js index 1a81a10c01..6da28085fc 100644 --- a/ui-v2/app/controllers/dc/services/index.js +++ b/ui-v2/app/controllers/dc/services/index.js @@ -1,6 +1,7 @@ import Controller from '@ember/controller'; import { get, computed } from '@ember/object'; import { htmlSafe } from '@ember/string'; +import WithEventSource from 'consul-ui/mixins/with-event-source'; import WithHealthFiltering from 'consul-ui/mixins/with-health-filtering'; import WithSearching from 'consul-ui/mixins/with-searching'; const max = function(arr, prop) { @@ -25,7 +26,7 @@ const width = function(num) { const widthDeclaration = function(num) { return htmlSafe(`width: ${num}px`); }; -export default Controller.extend(WithSearching, WithHealthFiltering, { +export default Controller.extend(WithEventSource, WithSearching, WithHealthFiltering, { init: function() { this.searchParams = { service: 's', @@ -52,14 +53,14 @@ export default Controller.extend(WithSearching, WithHealthFiltering, { remainingWidth: computed('maxWidth', function() { return htmlSafe(`width: calc(50% - ${Math.round(get(this, 'maxWidth') / 2)}px)`); }), - maxPassing: computed('items', function() { - return max(get(this, 'items'), 'ChecksPassing'); + maxPassing: computed('filtered', function() { + return max(get(this, 'filtered'), 'ChecksPassing'); }), - maxWarning: computed('items', function() { - return max(get(this, 'items'), 'ChecksWarning'); + maxWarning: computed('filtered', function() { + return max(get(this, 'filtered'), 'ChecksWarning'); }), - maxCritical: computed('items', function() { - return max(get(this, 'items'), 'ChecksCritical'); + maxCritical: computed('filtered', function() { + return max(get(this, 'filtered'), 'ChecksCritical'); }), passingWidth: computed('maxPassing', function() { return widthDeclaration(width(get(this, 'maxPassing'))); diff --git a/ui-v2/app/controllers/settings.js b/ui-v2/app/controllers/settings.js new file mode 100644 index 0000000000..d70fc372fd --- /dev/null +++ b/ui-v2/app/controllers/settings.js @@ -0,0 +1,29 @@ +import Controller from '@ember/controller'; +import { get, set } from '@ember/object'; +import { inject as service } from '@ember/service'; + +export default Controller.extend({ + repo: service('settings'), + dom: service('dom'), + actions: { + change: function(e, value, item) { + const event = get(this, 'dom').normalizeEvent(e, value); + // TODO: Switch to using forms like the rest of the app + // setting utils/form/builder for things to be done before we + // can do that. For the moment just do things normally its a simple + // enough form at the moment + + const target = event.target; + const blocking = get(this, 'item.client.blocking'); + switch (target.name) { + case 'client[blocking]': + if (typeof blocking === 'undefined') { + set(this, 'item.client', {}); + } + set(this, 'item.client.blocking', !blocking); + this.send('update', get(this, 'item')); + break; + } + }, + }, +}); diff --git a/ui-v2/app/instance-initializers/event-source.js b/ui-v2/app/instance-initializers/event-source.js new file mode 100644 index 0000000000..705accdd5b --- /dev/null +++ b/ui-v2/app/instance-initializers/event-source.js @@ -0,0 +1,61 @@ +import config from '../config/environment'; + +const enabled = 'CONSUL_UI_DISABLE_REALTIME'; +export function initialize(container) { + if (config[enabled] || window.localStorage.getItem(enabled) !== null) { + return; + } + ['node', 'service'] + .map(function(item) { + // create repositories that return a promise resolving to an EventSource + return { + service: `repository/${item}/event-source`, + extend: 'repository/type/event-source', + // Inject our original respository that is used by this class + // within the callable of the EventSource + services: { + content: `repository/${item}`, + }, + }; + }) + .concat([ + // These are the routes where we overwrite the 'default' + // repo service. Default repos are repos that return a promise resovlving to + // an ember-data record or recordset + { + route: 'dc/nodes/index', + services: { + repo: 'repository/node/event-source', + }, + }, + { + route: 'dc/services/index', + services: { + repo: 'repository/service/event-source', + }, + }, + ]) + .forEach(function(definition) { + if (typeof definition.extend !== 'undefined') { + // Create the class instances that we need + container.register( + `service:${definition.service}`, + container.resolveRegistration(`service:${definition.extend}`).extend({}) + ); + } + Object.keys(definition.services).forEach(function(name) { + const servicePath = definition.services[name]; + // inject its dependencies, this could probably detect the type + // but hardcode this for the moment + if (typeof definition.route !== 'undefined') { + container.inject(`route:${definition.route}`, name, `service:${servicePath}`); + } else { + container.inject(`service:${definition.service}`, name, `service:${servicePath}`); + } + }); + }); +} + +export default { + initialize, +}; diff --git a/ui-v2/app/mixins/with-event-source.js b/ui-v2/app/mixins/with-event-source.js new file mode 100644 index 0000000000..1fccd1dc83 --- /dev/null +++ b/ui-v2/app/mixins/with-event-source.js @@ -0,0 +1,16 @@ +import Mixin from '@ember/object/mixin'; + +export default Mixin.create({ + reset: function(exiting) { + if (exiting) { + Object.keys(this).forEach(prop => { + if (this[prop] && typeof this[prop].close === 'function') { + this[prop].close(); + // ember doesn't delete on 'resetController' by default + delete this[prop]; + } + }); + } + return this._super(...arguments); + }, +}); diff --git a/ui-v2/app/mixins/with-filtering.js b/ui-v2/app/mixins/with-filtering.js index 42dc8c888d..26d08940d9 100644 --- a/ui-v2/app/mixins/with-filtering.js +++ b/ui-v2/app/mixins/with-filtering.js @@ -15,7 +15,7 @@ const toKeyValue = function(el) { }; export default Mixin.create({ filters: {}, - filtered: computed('items', 'filters', function() { + filtered: computed('items.[]', 'filters', function() { const filters = get(this, 'filters'); return get(this, 'items').filter(item => { return this.filter(item, filters); diff --git a/ui-v2/app/mixins/with-health-filtering.js b/ui-v2/app/mixins/with-health-filtering.js index e18bab7d21..06ad378261 100644 --- a/ui-v2/app/mixins/with-health-filtering.js +++ b/ui-v2/app/mixins/with-health-filtering.js @@ -29,7 +29,7 @@ export default Mixin.create(WithFiltering, { as: 'filter', }, }, - healthFilters: computed('items', function() { + healthFilters: computed('items.[]', function() { const items = get(this, 'items'); const objs = ['', 'passing', 'warning', 'critical'].map(function(item) { const count = countStatus(items, item); diff --git a/ui-v2/app/router.js b/ui-v2/app/router.js index 0a7a3bef82..628a713c7a 100644 --- a/ui-v2/app/router.js +++ b/ui-v2/app/router.js @@ -88,9 +88,9 @@ export const routes = { _options: { path: '/' }, }, // The settings page is global. - // settings: { - // _options: { path: '/setting' }, - // }, + settings: { + _options: { path: '/setting' }, + }, notfound: { _options: { path: '/*path' }, }, diff --git a/ui-v2/app/routes/settings.js b/ui-v2/app/routes/settings.js index c5f9701b61..0dc59dc02c 100644 --- a/ui-v2/app/routes/settings.js +++ b/ui-v2/app/routes/settings.js @@ -3,8 +3,8 @@ import { inject as service } from '@ember/service'; import { hash } from 'rsvp'; import { get } from '@ember/object'; -import WithBlockingActions from 'consul-ui/mixins/with-blocking-actions'; -export default Route.extend(WithBlockingActions, { +export default Route.extend({ + client: service('client/http'), repo: service('settings'), dcRepo: service('repository/dc'), model: function(params) { @@ -24,8 +24,12 @@ export default Route.extend(WithBlockingActions, { this._super(...arguments); controller.setProperties(model); }, - // overwrite afterUpdate and afterDelete hooks - // to avoid the default 'return to listing page' - afterUpdate: function() {}, - afterDelete: function() {}, + actions: { + update: function(item) { + if (!get(item, 'client.blocking')) { + get(this, 'client').abort(); + } + get(this, 'repo').persist(item); + }, + }, }); diff --git a/ui-v2/app/services/client/http.js b/ui-v2/app/services/client/http.js index 1290d2be72..fa452d5009 100644 --- a/ui-v2/app/services/client/http.js +++ b/ui-v2/app/services/client/http.js @@ -63,6 +63,9 @@ export default Service.extend({ }); } }, + abort: function(id = null) { + 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) diff --git a/ui-v2/app/services/lazy-proxy.js b/ui-v2/app/services/lazy-proxy.js new file mode 100644 index 0000000000..18b8a86560 --- /dev/null +++ b/ui-v2/app/services/lazy-proxy.js @@ -0,0 +1,27 @@ +import Service from '@ember/service'; +import { get } from '@ember/object'; + +export default Service.extend({ + shouldProxy: function(content, method) { + return false; + }, + init: function() { + this._super(...arguments); + const content = get(this, 'content'); + for (let prop in content) { + if (typeof content[prop] === 'function') { + if (this.shouldProxy(content, prop)) { + this[prop] = function() { + return this.execute(content, prop).then(method => { + return method.apply(this, arguments); + }); + }; + } else if (typeof this[prop] !== 'function') { + this[prop] = function() { + return content[prop](...arguments); + }; + } + } + } + }, +}); diff --git a/ui-v2/app/services/repository/type/event-source.js b/ui-v2/app/services/repository/type/event-source.js new file mode 100644 index 0000000000..2305756062 --- /dev/null +++ b/ui-v2/app/services/repository/type/event-source.js @@ -0,0 +1,92 @@ +import { inject as service } from '@ember/service'; +import { get } from '@ember/object'; + +import LazyProxyService from 'consul-ui/services/lazy-proxy'; + +import { cache as createCache, BlockingEventSource } from 'consul-ui/utils/dom/event-source'; + +const createProxy = function(repo, find, settings, cache, serialize = JSON.stringify) { + // proxied find*..(id, dc) + const throttle = get(this, 'wait').execute; + const client = get(this, 'client'); + return function() { + const key = `${repo.getModelName()}.${find}.${serialize([...arguments])}`; + const _args = arguments; + const newPromisedEventSource = cache; + return newPromisedEventSource( + function(configuration) { + // take a copy of the original arguments + // this means we don't have any configuration object on it + let args = [..._args]; + if (configuration.settings.enabled) { + // ...and only add our current cursor/configuration if we are blocking + args = args.concat([configuration]); + } + // save a callback so we can conditionally throttle + const cb = () => { + // original find... with configuration now added + return repo[find](...args) + .then(res => { + if (!configuration.settings.enabled) { + // blocking isn't enabled, immediately close + this.close(); + } + return res; + }) + .catch(function(e) { + // setup the aborted connection restarting + // this should happen here to avoid cache deletion + const status = get(e, 'errors.firstObject.status'); + if (status === '0') { + // Any '0' errors (abort) should possibly try again, depending upon the circumstances + // whenAvailable returns a Promise that resolves when the client is available + // again + return client.whenAvailable(e); + } + throw e; + }); + }; + // if we have a cursor (which means its at least the second call) + // and we have a throttle setting, wait for so many ms + if (typeof configuration.cursor !== 'undefined' && configuration.settings.throttle) { + return throttle(configuration.settings.throttle).then(cb); + } + return cb(); + }, + { + key: key, + type: BlockingEventSource, + settings: { + enabled: settings.blocking, + throttle: settings.throttle, + }, + } + ); + }; +}; +let cache = null; +export default LazyProxyService.extend({ + store: service('store'), + settings: service('settings'), + wait: service('timeout'), + client: service('client/http'), + init: function() { + this._super(...arguments); + if (cache === null) { + cache = createCache({}); + } + }, + willDestroy: function() { + cache = null; + }, + shouldProxy: function(content, method) { + return method.indexOf('find') === 0; + }, + execute: function(repo, find) { + return get(this, 'settings') + .findBySlug('client') + .then(settings => { + return createProxy.bind(this)(repo, find, settings, cache); + }); + }, +}); diff --git a/ui-v2/app/templates/components/hashicorp-consul.hbs b/ui-v2/app/templates/components/hashicorp-consul.hbs index 916d69343c..32161b51e6 100644 --- a/ui-v2/app/templates/components/hashicorp-consul.hbs +++ b/ui-v2/app/templates/components/hashicorp-consul.hbs @@ -44,11 +44,9 @@
  • Documentation
  • -{{#if false }}
  • Settings
  • -{{/if}}
    diff --git a/ui-v2/app/templates/settings.hbs b/ui-v2/app/templates/settings.hbs index 97390b6a6f..f38dfe069a 100644 --- a/ui-v2/app/templates/settings.hbs +++ b/ui-v2/app/templates/settings.hbs @@ -1,20 +1,5 @@ {{#hashicorp-consul id="wrapper" dcs=dcs dc=dc}} {{#app-view class="settings show"}} - {{#block-slot 'notification' as |status type|}} - {{#if (eq type 'update')}} - {{#if (eq status 'success') }} - Your settings were saved. - {{else}} - There was an error saving your settings. - {{/if}} - {{ else if (eq type 'delete')}} - {{#if (eq status 'success') }} - You settings have been reset. - {{else}} - There was an error resetting your settings. - {{/if}} - {{/if}} - {{/block-slot}} {{#block-slot 'header'}}

    Settings @@ -26,13 +11,13 @@

    - +
    + +
    -
    {{/block-slot}} {{/app-view}} diff --git a/ui-v2/app/utils/dom/event-source/blocking.js b/ui-v2/app/utils/dom/event-source/blocking.js new file mode 100644 index 0000000000..d5f8968f61 --- /dev/null +++ b/ui-v2/app/utils/dom/event-source/blocking.js @@ -0,0 +1,92 @@ +import { get } from '@ember/object'; +import { Promise } from 'rsvp'; + +// native EventSource retry is ~3s wait +export const create5xxBackoff = function(ms = 3000, P = Promise, wait = setTimeout) { + // This expects an ember-data like error + return function(err) { + const status = get(err, 'errors.firstObject.status'); + if (typeof status !== 'undefined') { + switch (true) { + // Any '5xx' (not 500) errors should back off and try again + case status.indexOf('5') === 0 && status.length === 3 && status !== '500': + return new P(function(resolve) { + wait(function() { + resolve(err); + }, ms); + }); + } + } + // any other errors should throw to be picked up by an error listener/catch + throw err; + }; +}; +const defaultCreateEvent = function(result, configuration) { + return { + type: 'message', + data: result, + }; +}; +/** + * Wraps an EventSource with functionality to add native EventSource-like functionality + * + * @param {Class} [CallableEventSource] - CallableEventSource Class + * @param {Function} [backoff] - Default backoff function for all instances, defaults to create5xxBackoff + */ +export default function(EventSource, backoff = create5xxBackoff()) { + /** + * An EventSource implementation to add native EventSource-like functionality with just callbacks (`cursor` and 5xx backoff) + * + * This includes: + * 1. 5xx backoff support (uses a 3 second reconnect like native implementations). You can add to this via `Promise.catch` + * 2. A `cursor` configuration value. Current `cursor` is taken from the `meta` property of the event (i.e. `event.data.meta.cursor`) + * 3. Event data can be customized by adding a `configuration.createEvent` + * + * @param {Function} [source] - Promise returning function that resolves your data + * @param {Object} [configuration] - Plain configuration object: + * `cursor` - Cursor position of the EventSource + * `createEvent` - A data filter, giving you the opportunity to filter or replace the event data, such as removing/replacing records + */ + return class extends EventSource { + constructor(source, configuration = {}) { + super(configuration => { + const { createEvent, ...superConfiguration } = configuration; + return source + .apply(this, [superConfiguration]) + .catch(backoff) + .then(result => { + if (!(result instanceof Error)) { + const _createEvent = + typeof createEvent === 'function' ? createEvent : defaultCreateEvent; + let event = _createEvent(result, configuration); + // allow custom types, but make a default of `message`, ideally this would check for CustomEvent + // but keep this flexible for the moment + if (!event.type) { + event = { + type: 'message', + data: event, + }; + } + // meta is also configurable by using createEvent + const meta = get(event.data || {}, 'meta'); + if (meta) { + // pick off the `cursor` from the meta and add it to configuration + configuration.cursor = meta.cursor; + } + this.currentEvent = event; + this.dispatchEvent(this.currentEvent); + this.previousEvent = this.currentEvent; + } + return result; + }); + }, configuration); + } + // if we are having these props, at least make getters + getCurrentEvent() { + return this.currentEvent; + } + getPreviousEvent() { + return this.previousEvent; + } + }; +} diff --git a/ui-v2/app/utils/dom/event-source/cache.js b/ui-v2/app/utils/dom/event-source/cache.js new file mode 100644 index 0000000000..4e7d9dee58 --- /dev/null +++ b/ui-v2/app/utils/dom/event-source/cache.js @@ -0,0 +1,31 @@ +export default function(source, DefaultEventSource, P = Promise) { + return function(sources) { + return function(cb, configuration) { + const key = configuration.key; + if (typeof sources[key] !== 'undefined' && configuration.settings.enabled) { + if (typeof sources[key].configuration === 'undefined') { + sources[key].configuration = {}; + } + sources[key].configuration.settings = configuration.settings; + return source(sources[key]); + } else { + const EventSource = configuration.type || DefaultEventSource; + const eventSource = (sources[key] = new EventSource(cb, configuration)); + return source(eventSource) + .catch(function(e) { + // any errors, delete from the cache for next time + delete sources[key]; + return P.reject(e); + }) + .then(function(eventSource) { + // make sure we cancel everything out if there is no cursor + if (typeof eventSource.configuration.cursor === 'undefined') { + eventSource.close(); + delete sources[key]; + } + return eventSource; + }); + } + }; + }; +} diff --git a/ui-v2/app/utils/dom/event-source/callable.js b/ui-v2/app/utils/dom/event-source/callable.js new file mode 100644 index 0000000000..cc266940ae --- /dev/null +++ b/ui-v2/app/utils/dom/event-source/callable.js @@ -0,0 +1,70 @@ +export const defaultRunner = function(target, configuration, isClosed) { + if (isClosed(target)) { + return; + } + // TODO Consider wrapping this is a promise for none thenable returns + return target.source + .bind(target)(configuration) + .then(function(res) { + return defaultRunner(target, configuration, isClosed); + }); +}; +const errorEvent = function(e) { + return new ErrorEvent('error', { + error: e, + message: e.message, + }); +}; +const isClosed = function(target) { + switch (target.readyState) { + case 2: // CLOSED + case 3: // CLOSING + return true; + } + return false; +}; +export default function( + EventTarget, + P = Promise, + run = defaultRunner, + createErrorEvent = errorEvent +) { + return class extends EventTarget { + constructor(source, configuration = {}) { + super(); + this.readyState = 2; + this.source = + typeof source !== 'function' + ? function(configuration) { + this.close(); + return P.resolve(); + } + : source; + this.readyState = 0; // connecting + P.resolve() + .then(() => { + this.readyState = 1; // open + // ...that the connection _was just_ opened + this.dispatchEvent({ type: 'open' }); + return run(this, configuration, isClosed); + }) + .catch(e => { + this.dispatchEvent(createErrorEvent(e)); + // close after the dispatch so we can tell if it was an error whilst closed or not + // but make sure its before the promise tick + this.readyState = 2; // CLOSE + }) + .then(() => { + // This only gets called when the promise chain completely finishes + // so only when its completely closed. + this.readyState = 2; // CLOSE + }); + } + close() { + // additional readyState 3 = CLOSING + if (this.readyState !== 2) { + this.readyState = 3; + } + } + }; +} diff --git a/ui-v2/app/utils/dom/event-source/index.js b/ui-v2/app/utils/dom/event-source/index.js new file mode 100644 index 0000000000..6c8de125f8 --- /dev/null +++ b/ui-v2/app/utils/dom/event-source/index.js @@ -0,0 +1,36 @@ +import ObjectProxy from '@ember/object/proxy'; +import ArrayProxy from '@ember/array/proxy'; +import { Promise } from 'rsvp'; + +import createListeners from 'consul-ui/utils/dom/create-listeners'; + +import EventTarget from 'consul-ui/utils/dom/event-target/rsvp'; + +import cacheFactory from 'consul-ui/utils/dom/event-source/cache'; +import proxyFactory from 'consul-ui/utils/dom/event-source/proxy'; +import firstResolverFactory from 'consul-ui/utils/dom/event-source/resolver'; + +import CallableEventSourceFactory from 'consul-ui/utils/dom/event-source/callable'; +import ReopenableEventSourceFactory from 'consul-ui/utils/dom/event-source/reopenable'; +import BlockingEventSourceFactory from 'consul-ui/utils/dom/event-source/blocking'; +import StorageEventSourceFactory from 'consul-ui/utils/dom/event-source/storage'; + +// All The EventSource-i +export const CallableEventSource = CallableEventSourceFactory(EventTarget, Promise); +export const ReopenableEventSource = ReopenableEventSourceFactory(CallableEventSource); +export const BlockingEventSource = BlockingEventSourceFactory(ReopenableEventSource); +export const StorageEventSource = StorageEventSourceFactory(EventTarget, Promise); + +// various utils +export const proxy = proxyFactory(ObjectProxy, ArrayProxy); +export const resolve = firstResolverFactory(Promise); + +export const source = function(source) { + // create API needed for conventional promise blocked, loading, Routes + // i.e. resolve/reject on first response + return resolve(source, createListeners()).then(function(data) { + // create API needed for conventional DD/computed and Controllers + return proxy(data, source, createListeners()); + }); +}; +export const cache = cacheFactory(source, BlockingEventSource, Promise); diff --git a/ui-v2/app/utils/dom/event-source/proxy.js b/ui-v2/app/utils/dom/event-source/proxy.js new file mode 100644 index 0000000000..c36de9aac1 --- /dev/null +++ b/ui-v2/app/utils/dom/event-source/proxy.js @@ -0,0 +1,50 @@ +import { get, set } from '@ember/object'; + +export default function(ObjProxy, ArrProxy) { + return function(data, source, listeners) { + let Proxy = ObjProxy; + if (typeof data !== 'string' && typeof get(data, 'length') !== 'undefined') { + data = data.filter(function(item) { + return !get(item, 'isDestroyed') && !get(item, 'isDeleted') && get(item, 'isLoaded'); + }); + } + if (typeof data !== 'string' && typeof get(data, 'length') !== 'undefined') { + Proxy = ArrProxy; + } + const proxy = Proxy.create({ + content: data, + init: function() { + this.listeners = listeners; + this.listeners.add(source, 'message', e => { + set(this, 'content', e.data); + }); + }, + configuration: source.configuration, + addEventListener: function(type, handler) { + // Force use of computed for messages + if (type !== 'message') { + this.listeners.add(source, type, handler); + } + }, + getCurrentEvent: function() { + return source.getCurrentEvent(...arguments); + }, + removeEventListener: function() { + return source.removeEventListener(...arguments); + }, + dispatchEvent: function() { + return source.dispatchEvent(...arguments); + }, + close: function() { + return source.close(...arguments); + }, + reopen: function() { + return source.reopen(...arguments); + }, + willDestroy: function() { + this.listeners.remove(); + }, + }); + return proxy; + }; +} diff --git a/ui-v2/app/utils/dom/event-source/reopenable.js b/ui-v2/app/utils/dom/event-source/reopenable.js new file mode 100644 index 0000000000..c1f362c5de --- /dev/null +++ b/ui-v2/app/utils/dom/event-source/reopenable.js @@ -0,0 +1,23 @@ +/** + * Wraps an EventSource so that you can `close` and `reopen` + * + * @param {Class} eventSource - EventSource class to extend from + */ +export default function(eventSource = EventSource) { + return class extends eventSource { + constructor(source, configuration) { + super(...arguments); + this.configuration = configuration; + } + reopen() { + switch (this.readyState) { + case 3: // CLOSING + this.readyState = 1; + break; + case 2: // CLOSED + eventSource.apply(this, [this.source, this.configuration]); + break; + } + } + }; +} diff --git a/ui-v2/app/utils/dom/event-source/resolver.js b/ui-v2/app/utils/dom/event-source/resolver.js new file mode 100644 index 0000000000..a5984295aa --- /dev/null +++ b/ui-v2/app/utils/dom/event-source/resolver.js @@ -0,0 +1,29 @@ +export default function(P = Promise) { + return function(source, listeners) { + let current; + if (typeof source.getCurrentEvent === 'function') { + current = source.getCurrentEvent(); + } + if (current != null) { + // immediately resolve if we have previous cached data + return P.resolve(current.data).then(function(cached) { + source.reopen(); + return cached; + }); + } + // if we have no previously cached data, listen for the first response + return new P(function(resolve, reject) { + // close, cleanup and reject if we get an error + listeners.add(source, 'error', function(e) { + listeners.remove(); + e.target.close(); + reject(e.error); + }); + // ...or cleanup and respond with the first lot of data + listeners.add(source, 'message', function(e) { + listeners.remove(); + resolve(e.data); + }); + }); + }; +} diff --git a/ui-v2/app/utils/dom/event-source/storage.js b/ui-v2/app/utils/dom/event-source/storage.js new file mode 100644 index 0000000000..b5cba3b618 --- /dev/null +++ b/ui-v2/app/utils/dom/event-source/storage.js @@ -0,0 +1,40 @@ +export default function(EventTarget, P = Promise) { + const handler = function(e) { + if (e.key === this.configuration.key) { + P.resolve(this.getCurrentEvent()).then(event => { + this.configuration.cursor++; + this.dispatchEvent(event); + }); + } + }; + return class extends EventTarget { + constructor(cb, configuration) { + super(...arguments); + this.source = cb; + this.handler = handler.bind(this); + this.configuration = configuration; + this.configuration.cursor = 1; + this.dispatcher = configuration.dispatcher; + this.reopen(); + } + dispatchEvent() { + if (this.readyState === 1) { + return super.dispatchEvent(...arguments); + } + } + close() { + this.dispatcher.removeEventListener('storage', this.handler); + this.readyState = 2; + } + reopen() { + this.dispatcher.addEventListener('storage', this.handler); + this.readyState = 1; + } + getCurrentEvent() { + return { + type: 'message', + data: this.source(this.configuration), + }; + } + }; +} diff --git a/ui-v2/app/utils/dom/event-target/event-target-shim/LICENSE b/ui-v2/app/utils/dom/event-target/event-target-shim/LICENSE new file mode 100644 index 0000000000..c39e6949ed --- /dev/null +++ b/ui-v2/app/utils/dom/event-target/event-target-shim/LICENSE @@ -0,0 +1,22 @@ +The MIT License (MIT) + +Copyright (c) 2015 Toru Nagashima + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + diff --git a/ui-v2/app/utils/dom/event-target/event-target-shim/event.js b/ui-v2/app/utils/dom/event-target/event-target-shim/event.js new file mode 100644 index 0000000000..13a46fd5a1 --- /dev/null +++ b/ui-v2/app/utils/dom/event-target/event-target-shim/event.js @@ -0,0 +1,470 @@ +/** + * @typedef {object} PrivateData + * @property {EventTarget} eventTarget The event target. + * @property {{type:string}} event The original event object. + * @property {number} eventPhase The current event phase. + * @property {EventTarget|null} currentTarget The current event target. + * @property {boolean} canceled The flag to prevent default. + * @property {boolean} stopped The flag to stop propagation. + * @property {boolean} immediateStopped The flag to stop propagation immediately. + * @property {Function|null} passiveListener The listener if the current listener is passive. Otherwise this is null. + * @property {number} timeStamp The unix time. + * @private + */ + +/** + * Private data for event wrappers. + * @type {WeakMap} + * @private + */ +const privateData = new WeakMap(); + +/** + * Cache for wrapper classes. + * @type {WeakMap} + * @private + */ +const wrappers = new WeakMap(); + +/** + * Get private data. + * @param {Event} event The event object to get private data. + * @returns {PrivateData} The private data of the event. + * @private + */ +function pd(event) { + const retv = privateData.get(event); + console.assert(retv != null, "'this' is expected an Event object, but got", event); + return retv; +} + +/** + * https://dom.spec.whatwg.org/#set-the-canceled-flag + * @param data {PrivateData} private data. + */ +function setCancelFlag(data) { + if (data.passiveListener != null) { + if (typeof console !== 'undefined' && typeof console.error === 'function') { + console.error( + 'Unable to preventDefault inside passive event listener invocation.', + data.passiveListener + ); + } + return; + } + if (!data.event.cancelable) { + return; + } + + data.canceled = true; + if (typeof data.event.preventDefault === 'function') { + data.event.preventDefault(); + } +} + +/** + * @see https://dom.spec.whatwg.org/#interface-event + * @private + */ +/** + * The event wrapper. + * @constructor + * @param {EventTarget} eventTarget The event target of this dispatching. + * @param {Event|{type:string}} event The original event to wrap. + */ +function Event(eventTarget, event) { + privateData.set(this, { + eventTarget, + event, + eventPhase: 2, + currentTarget: eventTarget, + canceled: false, + stopped: false, + immediateStopped: false, + passiveListener: null, + timeStamp: event.timeStamp || Date.now(), + }); + + // https://heycam.github.io/webidl/#Unforgeable + Object.defineProperty(this, 'isTrusted', { value: false, enumerable: true }); + + // Define accessors + const keys = Object.keys(event); + for (let i = 0; i < keys.length; ++i) { + const key = keys[i]; + if (!(key in this)) { + Object.defineProperty(this, key, defineRedirectDescriptor(key)); + } + } +} + +// Should be enumerable, but class methods are not enumerable. +Event.prototype = { + /** + * The type of this event. + * @type {string} + */ + get type() { + return pd(this).event.type; + }, + + /** + * The target of this event. + * @type {EventTarget} + */ + get target() { + return pd(this).eventTarget; + }, + + /** + * The target of this event. + * @type {EventTarget} + */ + get currentTarget() { + return pd(this).currentTarget; + }, + + /** + * @returns {EventTarget[]} The composed path of this event. + */ + composedPath() { + const currentTarget = pd(this).currentTarget; + if (currentTarget == null) { + return []; + } + return [currentTarget]; + }, + + /** + * Constant of NONE. + * @type {number} + */ + get NONE() { + return 0; + }, + + /** + * Constant of CAPTURING_PHASE. + * @type {number} + */ + get CAPTURING_PHASE() { + return 1; + }, + + /** + * Constant of AT_TARGET. + * @type {number} + */ + get AT_TARGET() { + return 2; + }, + + /** + * Constant of BUBBLING_PHASE. + * @type {number} + */ + get BUBBLING_PHASE() { + return 3; + }, + + /** + * The target of this event. + * @type {number} + */ + get eventPhase() { + return pd(this).eventPhase; + }, + + /** + * Stop event bubbling. + * @returns {void} + */ + stopPropagation() { + const data = pd(this); + + data.stopped = true; + if (typeof data.event.stopPropagation === 'function') { + data.event.stopPropagation(); + } + }, + + /** + * Stop event bubbling. + * @returns {void} + */ + stopImmediatePropagation() { + const data = pd(this); + + data.stopped = true; + data.immediateStopped = true; + if (typeof data.event.stopImmediatePropagation === 'function') { + data.event.stopImmediatePropagation(); + } + }, + + /** + * The flag to be bubbling. + * @type {boolean} + */ + get bubbles() { + return Boolean(pd(this).event.bubbles); + }, + + /** + * The flag to be cancelable. + * @type {boolean} + */ + get cancelable() { + return Boolean(pd(this).event.cancelable); + }, + + /** + * Cancel this event. + * @returns {void} + */ + preventDefault() { + setCancelFlag(pd(this)); + }, + + /** + * The flag to indicate cancellation state. + * @type {boolean} + */ + get defaultPrevented() { + return pd(this).canceled; + }, + + /** + * The flag to be composed. + * @type {boolean} + */ + get composed() { + return Boolean(pd(this).event.composed); + }, + + /** + * The unix time of this event. + * @type {number} + */ + get timeStamp() { + return pd(this).timeStamp; + }, + + /** + * The target of this event. + * @type {EventTarget} + * @deprecated + */ + get srcElement() { + return pd(this).eventTarget; + }, + + /** + * The flag to stop event bubbling. + * @type {boolean} + * @deprecated + */ + get cancelBubble() { + return pd(this).stopped; + }, + set cancelBubble(value) { + if (!value) { + return; + } + const data = pd(this); + + data.stopped = true; + if (typeof data.event.cancelBubble === 'boolean') { + data.event.cancelBubble = true; + } + }, + + /** + * The flag to indicate cancellation state. + * @type {boolean} + * @deprecated + */ + get returnValue() { + return !pd(this).canceled; + }, + set returnValue(value) { + if (!value) { + setCancelFlag(pd(this)); + } + }, + + /** + * Initialize this event object. But do nothing under event dispatching. + * @param {string} type The event type. + * @param {boolean} [bubbles=false] The flag to be possible to bubble up. + * @param {boolean} [cancelable=false] The flag to be possible to cancel. + * @deprecated + */ + initEvent() { + // Do nothing. + }, +}; + +// `constructor` is not enumerable. +Object.defineProperty(Event.prototype, 'constructor', { + value: Event, + configurable: true, + writable: true, +}); + +// Ensure `event instanceof window.Event` is `true`. +if (typeof window !== 'undefined' && typeof window.Event !== 'undefined') { + Object.setPrototypeOf(Event.prototype, window.Event.prototype); + + // Make association for wrappers. + wrappers.set(window.Event.prototype, Event); +} + +/** + * Get the property descriptor to redirect a given property. + * @param {string} key Property name to define property descriptor. + * @returns {PropertyDescriptor} The property descriptor to redirect the property. + * @private + */ +function defineRedirectDescriptor(key) { + return { + get() { + return pd(this).event[key]; + }, + set(value) { + pd(this).event[key] = value; + }, + configurable: true, + enumerable: true, + }; +} + +/** + * Get the property descriptor to call a given method property. + * @param {string} key Property name to define property descriptor. + * @returns {PropertyDescriptor} The property descriptor to call the method property. + * @private + */ +function defineCallDescriptor(key) { + return { + value() { + const event = pd(this).event; + return event[key].apply(event, arguments); + }, + configurable: true, + enumerable: true, + }; +} + +/** + * Define new wrapper class. + * @param {Function} BaseEvent The base wrapper class. + * @param {Object} proto The prototype of the original event. + * @returns {Function} The defined wrapper class. + * @private + */ +function defineWrapper(BaseEvent, proto) { + const keys = Object.keys(proto); + if (keys.length === 0) { + return BaseEvent; + } + + /** CustomEvent */ + function CustomEvent(eventTarget, event) { + BaseEvent.call(this, eventTarget, event); + } + + CustomEvent.prototype = Object.create(BaseEvent.prototype, { + constructor: { value: CustomEvent, configurable: true, writable: true }, + }); + + // Define accessors. + for (let i = 0; i < keys.length; ++i) { + const key = keys[i]; + if (!(key in BaseEvent.prototype)) { + const descriptor = Object.getOwnPropertyDescriptor(proto, key); + const isFunc = typeof descriptor.value === 'function'; + Object.defineProperty( + CustomEvent.prototype, + key, + isFunc ? defineCallDescriptor(key) : defineRedirectDescriptor(key) + ); + } + } + + return CustomEvent; +} + +/** + * Get the wrapper class of a given prototype. + * @param {Object} proto The prototype of the original event to get its wrapper. + * @returns {Function} The wrapper class. + * @private + */ +function getWrapper(proto) { + if (proto == null || proto === Object.prototype) { + return Event; + } + + let wrapper = wrappers.get(proto); + if (wrapper == null) { + wrapper = defineWrapper(getWrapper(Object.getPrototypeOf(proto)), proto); + wrappers.set(proto, wrapper); + } + return wrapper; +} + +/** + * Wrap a given event to management a dispatching. + * @param {EventTarget} eventTarget The event target of this dispatching. + * @param {Object} event The event to wrap. + * @returns {Event} The wrapper instance. + * @private + */ +export function wrapEvent(eventTarget, event) { + const Wrapper = getWrapper(Object.getPrototypeOf(event)); + return new Wrapper(eventTarget, event); +} + +/** + * Get the immediateStopped flag of a given event. + * @param {Event} event The event to get. + * @returns {boolean} The flag to stop propagation immediately. + * @private + */ +export function isStopped(event) { + return pd(event).immediateStopped; +} + +/** + * Set the current event phase of a given event. + * @param {Event} event The event to set current target. + * @param {number} eventPhase New event phase. + * @returns {void} + * @private + */ +export function setEventPhase(event, eventPhase) { + pd(event).eventPhase = eventPhase; +} + +/** + * Set the current target of a given event. + * @param {Event} event The event to set current target. + * @param {EventTarget|null} currentTarget New current target. + * @returns {void} + * @private + */ +export function setCurrentTarget(event, currentTarget) { + pd(event).currentTarget = currentTarget; +} + +/** + * Set a passive listener of a given event. + * @param {Event} event The event to set current target. + * @param {Function|null} passiveListener New passive listener. + * @returns {void} + * @private + */ +export function setPassiveListener(event, passiveListener) { + pd(event).passiveListener = passiveListener; +} diff --git a/ui-v2/app/utils/dom/event-target/rsvp.js b/ui-v2/app/utils/dom/event-target/rsvp.js new file mode 100644 index 0000000000..d1a108663e --- /dev/null +++ b/ui-v2/app/utils/dom/event-target/rsvp.js @@ -0,0 +1,63 @@ +// Simple RSVP.EventTarget wrapper to make it more like a standard EventTarget +import RSVP from 'rsvp'; +// See https://github.com/mysticatea/event-target-shim/blob/v4.0.2/src/event.mjs +// The MIT License (MIT) - Copyright (c) 2015 Toru Nagashima +import { setCurrentTarget, wrapEvent } from './event-target-shim/event'; + +const EventTarget = function() {}; +function callbacksFor(object) { + let callbacks = object._promiseCallbacks; + + if (!callbacks) { + callbacks = object._promiseCallbacks = {}; + } + + return callbacks; +} +EventTarget.prototype = Object.assign( + Object.create(Object.prototype, { + constructor: { + value: EventTarget, + configurable: true, + writable: true, + }, + }), + { + dispatchEvent: function(obj) { + // borrow just what I need from event-target-shim + // to make true events even ErrorEvents with targets + const wrappedEvent = wrapEvent(this, obj); + setCurrentTarget(wrappedEvent, null); + // RSVP trigger doesn't bind to `this` + // the rest is pretty much the contents of `trigger` + // but with a `.bind(this)` to make it compatible + // with standard EventTarget + // we use `let` and `callbacksFor` above, just to keep things the same as rsvp.js + const eventName = obj.type; + const options = wrappedEvent; + let allCallbacks = callbacksFor(this); + + let callbacks = allCallbacks[eventName]; + if (callbacks) { + // Don't cache the callbacks.length since it may grow + let callback; + for (let i = 0; i < callbacks.length; i++) { + callback = callbacks[i]; + callback.bind(this)(options); + } + } + }, + addEventListener: function(event, cb) { + this.on(event, cb); + }, + removeEventListener: function(event, cb) { + try { + this.off(event, cb); + } catch (e) { + // passthrough + } + }, + } +); +RSVP.EventTarget.mixin(EventTarget.prototype); +export default EventTarget; diff --git a/ui-v2/app/utils/form/builder.js b/ui-v2/app/utils/form/builder.js index 2047f0e355..101e238781 100644 --- a/ui-v2/app/utils/form/builder.js +++ b/ui-v2/app/utils/form/builder.js @@ -77,9 +77,13 @@ export default function(changeset = defaultChangeset, getFormNameProperty = pars } const data = this.getData(); // ember-data/changeset dance + // TODO: This works for ember-data RecordSets and Changesets but not for plain js Objects + // see settings const json = typeof data.toJSON === 'function' ? data.toJSON() : get(data, 'data').toJSON(); // if the form doesn't include a property then throw so it can be // caught outside, therefore the user can deal with things that aren't in the data + // TODO: possibly need to add support for deeper properties using `get` here + // for example `client.blocking` instead of just `blocking` if (!Object.keys(json).includes(prop)) { const error = new Error(`${prop} property doesn't exist`); error.target = target; diff --git a/ui-v2/tests/acceptance/dc/list-blocking.feature b/ui-v2/tests/acceptance/dc/list-blocking.feature new file mode 100644 index 0000000000..790d20dce0 --- /dev/null +++ b/ui-v2/tests/acceptance/dc/list-blocking.feature @@ -0,0 +1,34 @@ +@setupApplicationTest +Feature: dc / list-blocking + In order to see updates without refreshing the page + As a user + I want to see changes if I change consul externally + Background: + Given 1 datacenter model with the value "dc-1" + And settings from yaml + --- + consul:client: + blocking: 1 + throttle: 200 + --- + Scenario: + And 3 [Model] models + And a network latency of 100 + When I visit the [Page] page for yaml + --- + dc: dc-1 + --- + Then the url should be /dc-1/[Url] + And pause until I see 3 [Model] models + And an external edit results in 5 [Model] models + And pause until I see 5 [Model] models + And an external edit results in 1 [Model] model + And pause until I see 1 [Model] model + And an external edit results in 0 [Model] models + And pause until I see 0 [Model] models + Where: + -------------------------------------------- + | Page | Model | Url | + | services | service | services | + | nodes | node | nodes | + -------------------------------------------- diff --git a/ui-v2/tests/acceptance/page-navigation.feature b/ui-v2/tests/acceptance/page-navigation.feature index c90c4b5765..44dbe02611 100644 --- a/ui-v2/tests/acceptance/page-navigation.feature +++ b/ui-v2/tests/acceptance/page-navigation.feature @@ -44,7 +44,7 @@ Feature: Page Navigation | Item | Model | URL | Endpoint | Back | | service | services | /dc-1/services/service-0 | /v1/health/service/service-0?dc=dc-1 | /dc-1/services | | node | nodes | /dc-1/nodes/node-0 | /v1/session/node/node-0?dc=dc-1 | /dc-1/nodes | - | kv | kvs | /dc-1/kv/necessitatibus-0/edit | /v1/session/info/ee52203d-989f-4f7a-ab5a-2bef004164ca?dc=dc-1 | /dc-1/kv | + | kv | kvs | /dc-1/kv/0-key-value/edit | /v1/session/info/ee52203d-989f-4f7a-ab5a-2bef004164ca?dc=dc-1 | /dc-1/kv | # | acl | acls | /dc-1/acls/anonymous | /v1/acl/info/anonymous?dc=dc-1 | /dc-1/acls | | intention | intentions | /dc-1/intentions/ee52203d-989f-4f7a-ab5a-2bef004164ca | /v1/internal/ui/services?dc=dc-1 | /dc-1/intentions | | token | tokens | /dc-1/acls/tokens/ee52203d-989f-4f7a-ab5a-2bef004164ca | /v1/acl/policies?dc=dc-1 | /dc-1/acls/tokens | @@ -116,7 +116,7 @@ Feature: Page Navigation Where: -------------------------------------------------------------------------------------------------------- | Item | Model | URL | Back | - | kv | kvs | /dc-1/kv/necessitatibus-0/edit | /dc-1/kv | + | kv | kvs | /dc-1/kv/0-key-value/edit | /dc-1/kv | # | acl | acls | /dc-1/acls/anonymous | /dc-1/acls | | intention | intentions | /dc-1/intentions/ee52203d-989f-4f7a-ab5a-2bef004164ca | /dc-1/intentions | -------------------------------------------------------------------------------------------------------- diff --git a/ui-v2/tests/acceptance/steps/dc/list-blocking-steps.js b/ui-v2/tests/acceptance/steps/dc/list-blocking-steps.js new file mode 100644 index 0000000000..3c9a76f69f --- /dev/null +++ b/ui-v2/tests/acceptance/steps/dc/list-blocking-steps.js @@ -0,0 +1,10 @@ +import steps from '../steps'; + +// step definitions that are shared between features should be moved to the +// tests/acceptance/steps/steps.js file + +export default function(assert) { + return steps(assert).then('I should find a file', function() { + assert.ok(true, this.step); + }); +} diff --git a/ui-v2/tests/integration/utils/dom/event-source/callable-test.js b/ui-v2/tests/integration/utils/dom/event-source/callable-test.js new file mode 100644 index 0000000000..02605d6f66 --- /dev/null +++ b/ui-v2/tests/integration/utils/dom/event-source/callable-test.js @@ -0,0 +1,84 @@ +import domEventSourceCallable from 'consul-ui/utils/dom/event-source/callable'; +import EventTarget from 'consul-ui/utils/dom/event-target/rsvp'; +import { Promise } from 'rsvp'; + +import { module } from 'qunit'; +import { setupTest } from 'ember-qunit'; +import test from 'ember-sinon-qunit/test-support/test'; + +module('Integration | Utility | dom/event-source/callable', function(hooks) { + setupTest(hooks); + test('it dispatches messages', function(assert) { + assert.expect(1); + const EventSource = domEventSourceCallable(EventTarget); + const listener = this.stub(); + const source = new EventSource( + function(configuration) { + return new Promise(resolve => { + setTimeout(() => { + this.dispatchEvent({ + type: 'message', + data: null, + }); + resolve(); + }, configuration.milliseconds); + }); + }, + { + milliseconds: 100, + } + ); + source.addEventListener('message', function() { + listener(); + }); + return new Promise(function(resolve) { + setTimeout(function() { + source.close(); + assert.equal(listener.callCount, 5); + resolve(); + }, 550); + }); + }); + test('it dispatches a single open event and closes when called with no callable', function(assert) { + assert.expect(4); + const EventSource = domEventSourceCallable(EventTarget); + const listener = this.stub(); + const source = new EventSource(); + source.addEventListener('open', function(e) { + assert.deepEqual(e.target, this); + assert.equal(e.target.readyState, 1); + listener(); + }); + return Promise.resolve().then(function() { + assert.ok(listener.calledOnce); + assert.equal(source.readyState, 2); + }); + }); + test('it dispatches a single open event, and calls the specified callable that can dispatch an event', function(assert) { + assert.expect(1); + const EventSource = domEventSourceCallable(EventTarget); + const listener = this.stub(); + const source = new EventSource(function() { + return new Promise(resolve => { + setTimeout(() => { + this.dispatchEvent({ + type: 'message', + data: {}, + }); + this.close(); + }, 190); + }); + }); + source.addEventListener('open', function() { + // open is called first + listener(); + }); + return new Promise(function(resolve) { + source.addEventListener('message', function() { + // message is called second + assert.ok(listener.calledOnce); + resolve(); + }); + }); + }); +}); diff --git a/ui-v2/tests/steps.js b/ui-v2/tests/steps.js index 859cfe830b..d0adc96f59 100644 --- a/ui-v2/tests/steps.js +++ b/ui-v2/tests/steps.js @@ -65,7 +65,10 @@ export default function(assert) { }, yadda) ) // doubles - .given(['$number $model model[s]?', '$number $model models'], function(number, model) { + .given(['an external edit results in $number $model model[s]?'], function(number, model) { + return create(number, model); + }) + .given(['$number $model model[s]?'], function(number, model) { return create(number, model); }) .given(['$number $model model[s]? with the value "$value"'], function(number, model, value) { @@ -77,7 +80,15 @@ export default function(assert) { return create(number, model, data); } ) - .given(["I'm using a legacy token"], function(number, model, data) { + .given(['settings from yaml\n$yaml'], function(data) { + return Object.keys(data).forEach(function(key) { + window.localStorage[key] = JSON.stringify(data[key]); + }); + }) + .given('a network latency of $number', function(number) { + api.server.setCookie('CONSUL_LATENCY', number); + }) + .given(["I'm using a legacy token"], function() { window.localStorage['consul:token'] = JSON.stringify({ AccessorID: null, SecretID: 'id' }); }) // TODO: Abstract this away from HTTP @@ -188,6 +199,26 @@ export default function(assert) { }); }) // assertions + .then('pause until I see $number $model model[s]?', function(num, model) { + return new Promise(function(resolve) { + let count = 0; + const interval = setInterval(function() { + if (++count >= 50) { + clearInterval(interval); + assert.ok(false); + resolve(); + } + const len = currentPage[`${pluralize(model)}`].filter(function(item) { + return item.isVisible; + }).length; + if (len === num) { + clearInterval(interval); + assert.equal(len, num, `Expected ${num} ${model}s, saw ${len}`); + resolve(); + } + }, 100); + }); + }) .then('a $method request is made to "$url" with the body from yaml\n$yaml', function( method, url, @@ -358,6 +389,9 @@ export default function(assert) { .then('I have settings like yaml\n$yaml', function(data) { // TODO: Inject this const settings = window.localStorage; + // TODO: this and the setup should probably use consul: + // as we are talking about 'settings' here not localStorage + // so the prefix should be hidden Object.keys(data).forEach(function(prop) { const actual = settings.getItem(prop); const expected = data[prop]; diff --git a/ui-v2/tests/unit/controllers/settings-test.js b/ui-v2/tests/unit/controllers/settings-test.js new file mode 100644 index 0000000000..236dfcdf2d --- /dev/null +++ b/ui-v2/tests/unit/controllers/settings-test.js @@ -0,0 +1,12 @@ +import { moduleFor, test } from 'ember-qunit'; + +moduleFor('controller:settings', 'Unit | Controller | settings', { + // Specify the other units that are required for this test. + needs: ['service:settings', 'service:dom'], +}); + +// Replace this with your real tests. +test('it exists', function(assert) { + let controller = this.subject(); + assert.ok(controller); +}); diff --git a/ui-v2/tests/unit/routes/settings-test.js b/ui-v2/tests/unit/routes/settings-test.js index 52f71ebcec..53583e8088 100644 --- a/ui-v2/tests/unit/routes/settings-test.js +++ b/ui-v2/tests/unit/routes/settings-test.js @@ -3,6 +3,7 @@ import { moduleFor, test } from 'ember-qunit'; moduleFor('route:settings', 'Unit | Route | settings', { // Specify the other units that are required for this test. needs: [ + 'service:client/http', 'service:repository/dc', 'service:settings', 'service:logger', diff --git a/ui-v2/tests/unit/utils/dom/event-source/blocking-test.js b/ui-v2/tests/unit/utils/dom/event-source/blocking-test.js new file mode 100644 index 0000000000..c405636a8e --- /dev/null +++ b/ui-v2/tests/unit/utils/dom/event-source/blocking-test.js @@ -0,0 +1,87 @@ +import domEventSourceBlocking, { + create5xxBackoff, +} from 'consul-ui/utils/dom/event-source/blocking'; +import { module } from 'qunit'; +import test from 'ember-sinon-qunit/test-support/test'; + +module('Unit | Utility | dom/event-source/blocking'); + +const createEventSource = function() { + return class { + constructor(cb) { + this.readyState = 1; + this.source = cb; + this.source.apply(this, arguments); + } + addEventListener() {} + removeEventListener() {} + dispatchEvent() {} + close() {} + }; +}; +const createPromise = function(resolve = function() {}) { + class PromiseMock { + constructor(cb = function() {}) { + cb(resolve); + } + then(cb) { + setTimeout(() => cb.bind(this)(), 0); + return this; + } + catch(cb) { + cb({ message: 'error' }); + return this; + } + } + PromiseMock.resolve = function() { + return new PromiseMock(); + }; + return PromiseMock; +}; +test('it creates an BlockingEventSource class implementing EventSource', function(assert) { + const EventSource = createEventSource(); + const BlockingEventSource = domEventSourceBlocking(EventSource, function() {}); + assert.ok(BlockingEventSource instanceof Function); + const source = new BlockingEventSource(function() { + return createPromise().resolve(); + }); + assert.ok(source instanceof EventSource); +}); +test("the 5xx backoff continues to throw when it's not a 5xx", function(assert) { + const backoff = create5xxBackoff(); + [ + undefined, + null, + new Error(), + { errors: [] }, + { errors: [{ status: '0' }] }, + { errors: [{ status: 501 }] }, + { errors: [{ status: '401' }] }, + { errors: [{ status: '500' }] }, + { errors: [{ status: '5' }] }, + { errors: [{ status: '50' }] }, + { errors: [{ status: '5000' }] }, + { errors: [{ status: '5050' }] }, + ].forEach(function(item) { + assert.throws(function() { + backoff(item); + }); + }); +}); +test('the 5xx backoff returns a resolve promise on a 5xx (apart from 500)', function(assert) { + [ + { errors: [{ status: '501' }] }, + { errors: [{ status: '503' }] }, + { errors: [{ status: '504' }] }, + { errors: [{ status: '524' }] }, + ].forEach(item => { + const timeout = this.stub().callsArg(0); + const resolve = this.stub().withArgs(item); + const Promise = createPromise(resolve); + const backoff = create5xxBackoff(undefined, Promise, timeout); + const promise = backoff(item); + assert.ok(promise instanceof Promise, 'a promise was returned'); + assert.ok(resolve.calledOnce, 'the promise was resolved with the correct arguments'); + assert.ok(timeout.calledOnce, 'timeout was called once'); + }); +}); diff --git a/ui-v2/tests/unit/utils/dom/event-source/cache-test.js b/ui-v2/tests/unit/utils/dom/event-source/cache-test.js new file mode 100644 index 0000000000..1d9a4df49a --- /dev/null +++ b/ui-v2/tests/unit/utils/dom/event-source/cache-test.js @@ -0,0 +1,145 @@ +import domEventSourceCache from 'consul-ui/utils/dom/event-source/cache'; +import { module } from 'qunit'; +import test from 'ember-sinon-qunit/test-support/test'; + +module('Unit | Utility | dom/event-source/cache'); + +const createEventSource = function() { + return class { + constructor(cb) { + this.source = cb; + this.source.apply(this, arguments); + } + addEventListener() {} + removeEventListener() {} + dispatchEvent() {} + close() {} + }; +}; +const createPromise = function( + resolve = result => result, + reject = (result = { message: 'error' }) => result +) { + class PromiseMock { + constructor(cb = function() {}) { + cb(resolve); + } + then(cb) { + setTimeout(() => cb.bind(this)(resolve()), 0); + return this; + } + catch(cb) { + setTimeout(() => cb.bind(this)(reject()), 0); + return this; + } + } + PromiseMock.resolve = function(result) { + return new PromiseMock(function(resolve) { + resolve(result); + }); + }; + PromiseMock.reject = function() { + return new PromiseMock(); + }; + return PromiseMock; +}; +test('it returns a function', function(assert) { + const EventSource = createEventSource(); + const Promise = createPromise(); + + const getCache = domEventSourceCache(function() {}, EventSource, Promise); + assert.ok(typeof getCache === 'function'); +}); +test('getCache returns a function', function(assert) { + const EventSource = createEventSource(); + const Promise = createPromise(); + + const getCache = domEventSourceCache(function() {}, EventSource, Promise); + const obj = {}; + const cache = getCache(obj); + assert.ok(typeof cache === 'function'); +}); +test('cache creates the default EventSource and keeps it open when there is a cursor', function(assert) { + const EventSource = createEventSource(); + const stub = { + configuration: { cursor: 1 }, + }; + const Promise = createPromise(function() { + return stub; + }); + const source = this.stub().returns(Promise.resolve()); + const cb = this.stub(); + const getCache = domEventSourceCache(source, EventSource, Promise); + const obj = {}; + const cache = getCache(obj); + const promisedEventSource = cache(cb, { + key: 'key', + settings: { + enabled: true, + }, + }); + assert.ok(source.calledOnce, 'promisifying source called once'); + assert.ok(promisedEventSource instanceof Promise, 'source returns a Promise'); + const retrievedEventSource = cache(cb, { + key: 'key', + settings: { + enabled: true, + }, + }); + assert.deepEqual(promisedEventSource, retrievedEventSource); + assert.ok(source.calledTwice, 'promisifying source called once'); + assert.ok(retrievedEventSource instanceof Promise, 'source returns a Promise'); +}); +test('cache creates the default EventSource and keeps it open when there is a cursor', function(assert) { + const EventSource = createEventSource(); + const stub = { + close: this.stub(), + configuration: { cursor: 1 }, + }; + const Promise = createPromise(function() { + return stub; + }); + const source = this.stub().returns(Promise.resolve()); + const cb = this.stub(); + const getCache = domEventSourceCache(source, EventSource, Promise); + const obj = {}; + const cache = getCache(obj); + const promisedEventSource = cache(cb, { + key: 0, + settings: { + enabled: true, + }, + }); + assert.ok(source.calledOnce, 'promisifying source called once'); + assert.ok(cb.calledOnce, 'callable event source callable called once'); + assert.ok(promisedEventSource instanceof Promise, 'source returns a Promise'); + // >> + return promisedEventSource.then(function() { + assert.notOk(stub.close.called, "close wasn't called"); + }); +}); +test("cache creates the default EventSource and closes it when there isn't a cursor", function(assert) { + const EventSource = createEventSource(); + const stub = { + close: this.stub(), + configuration: {}, + }; + const Promise = createPromise(function() { + return stub; + }); + const source = this.stub().returns(Promise.resolve()); + const cb = this.stub(); + const getCache = domEventSourceCache(source, EventSource, Promise); + const obj = {}; + const cache = getCache(obj); + const promisedEventSource = cache(cb, { + key: 0, + }); + assert.ok(source.calledOnce, 'promisifying source called once'); + assert.ok(cb.calledOnce, 'callable event source callable called once'); + assert.ok(promisedEventSource instanceof Promise, 'source returns a Promise'); + // >> + return promisedEventSource.then(function() { + assert.ok(stub.close.calledOnce, 'close was called'); + }); +}); diff --git a/ui-v2/tests/unit/utils/dom/event-source/callable-test.js b/ui-v2/tests/unit/utils/dom/event-source/callable-test.js new file mode 100644 index 0000000000..e43b342fb1 --- /dev/null +++ b/ui-v2/tests/unit/utils/dom/event-source/callable-test.js @@ -0,0 +1,65 @@ +import domEventSourceCallable, { defaultRunner } from 'consul-ui/utils/dom/event-source/callable'; +import { module } from 'qunit'; +import test from 'ember-sinon-qunit/test-support/test'; + +module('Unit | Utility | dom/event-source/callable'); + +const createEventTarget = function() { + return class { + addEventListener() {} + removeEventListener() {} + dispatchEvent() {} + }; +}; +const createPromise = function() { + class PromiseMock { + then(cb) { + cb(); + return this; + } + catch(cb) { + cb({ message: 'error' }); + return this; + } + } + PromiseMock.resolve = function() { + return new PromiseMock(); + }; + return PromiseMock; +}; +test('it creates an EventSource class implementing EventTarget', function(assert) { + const EventTarget = createEventTarget(); + const EventSource = domEventSourceCallable(EventTarget, createPromise()); + assert.ok(EventSource instanceof Function); + const source = new EventSource(); + assert.ok(source instanceof EventTarget); +}); +test('the default runner loops and can be closed', function(assert) { + assert.expect(12); // 10 not closed, 1 to close and the final call count + let count = 0; + const isClosed = function() { + count++; + assert.ok(true); + return count === 11; + }; + const configuration = {}; + const then = this.stub().callsArg(0); + const target = { + source: function(configuration) { + return { + then: then, + }; + }, + }; + defaultRunner(target, configuration, isClosed); + assert.ok(then.callCount == 10); +}); +test('it calls the defaultRunner', function(assert) { + const Promise = createPromise(); + const EventTarget = createEventTarget(); + const run = this.stub(); + const EventSource = domEventSourceCallable(EventTarget, Promise, run); + const source = new EventSource(); + assert.ok(run.calledOnce); + assert.equal(source.readyState, 2); +}); diff --git a/ui-v2/tests/unit/utils/dom/event-source/index-test.js b/ui-v2/tests/unit/utils/dom/event-source/index-test.js new file mode 100644 index 0000000000..0e99418ebe --- /dev/null +++ b/ui-v2/tests/unit/utils/dom/event-source/index-test.js @@ -0,0 +1,28 @@ +import { + source, + proxy, + cache, + resolve, + CallableEventSource, + ReopenableEventSource, + BlockingEventSource, + StorageEventSource, +} from 'consul-ui/utils/dom/event-source/index'; +import { module, test } from 'qunit'; + +module('Unit | Utility | dom/event source/index'); + +// Replace this with your real tests. +test('it works', function(assert) { + // All The EventSource + assert.ok(typeof CallableEventSource === 'function'); + assert.ok(typeof ReopenableEventSource === 'function'); + assert.ok(typeof BlockingEventSource === 'function'); + assert.ok(typeof StorageEventSource === 'function'); + + // Utils + assert.ok(typeof source === 'function'); + assert.ok(typeof proxy === 'function'); + assert.ok(typeof cache === 'function'); + assert.ok(typeof resolve === 'function'); +}); diff --git a/ui-v2/tests/unit/utils/dom/event-source/proxy-test.js b/ui-v2/tests/unit/utils/dom/event-source/proxy-test.js new file mode 100644 index 0000000000..75f136efaa --- /dev/null +++ b/ui-v2/tests/unit/utils/dom/event-source/proxy-test.js @@ -0,0 +1,10 @@ +import domEventSourceProxy from 'consul-ui/utils/dom/event-source/proxy'; +import { module, test } from 'qunit'; + +module('Unit | Utility | dom/event source/proxy'); + +// Replace this with your real tests. +test('it works', function(assert) { + let result = domEventSourceProxy(); + assert.ok(result); +}); diff --git a/ui-v2/tests/unit/utils/dom/event-source/reopenable-test.js b/ui-v2/tests/unit/utils/dom/event-source/reopenable-test.js new file mode 100644 index 0000000000..4936690140 --- /dev/null +++ b/ui-v2/tests/unit/utils/dom/event-source/reopenable-test.js @@ -0,0 +1,46 @@ +import domEventSourceReopenable from 'consul-ui/utils/dom/event-source/reopenable'; +import { module } from 'qunit'; +import test from 'ember-sinon-qunit/test-support/test'; + +module('Unit | Utility | dom/event-source/reopenable'); + +const createEventSource = function() { + return class { + constructor(cb) { + this.readyState = 1; + this.source = cb; + this.source.apply(this, arguments); + } + addEventListener() {} + removeEventListener() {} + dispatchEvent() {} + close() {} + }; +}; +test('it creates an Reopenable class implementing EventSource', function(assert) { + const EventSource = createEventSource(); + const ReopenableEventSource = domEventSourceReopenable(EventSource); + assert.ok(ReopenableEventSource instanceof Function); + const source = new ReopenableEventSource(function() {}); + assert.ok(source instanceof EventSource); +}); +test('it reopens the event source when reopen is called', function(assert) { + const callable = this.stub(); + const EventSource = createEventSource(); + const ReopenableEventSource = domEventSourceReopenable(EventSource); + const source = new ReopenableEventSource(callable); + assert.equal(source.readyState, 1); + // first automatic EventSource `open` + assert.ok(callable.calledOnce); + source.readyState = 3; + source.reopen(); + // still only called once as it hasn't completely closed yet + // therefore is just opened by resetting the readyState + assert.ok(callable.calledOnce); + assert.equal(source.readyState, 1); + // properly close the source + source.readyState = 2; + source.reopen(); + // this time it is reopened via a recall of the callable + assert.ok(callable.calledTwice); +}); diff --git a/ui-v2/tests/unit/utils/dom/event-source/resolver-test.js b/ui-v2/tests/unit/utils/dom/event-source/resolver-test.js new file mode 100644 index 0000000000..e123dbed9f --- /dev/null +++ b/ui-v2/tests/unit/utils/dom/event-source/resolver-test.js @@ -0,0 +1,10 @@ +import domEventSourceResolver from 'consul-ui/utils/dom/event-source/resolver'; +import { module, test } from 'qunit'; + +module('Unit | Utility | dom/event source/resolver'); + +// Replace this with your real tests. +test('it works', function(assert) { + let result = domEventSourceResolver(); + assert.ok(result); +}); diff --git a/ui-v2/tests/unit/utils/dom/event-source/storage-test.js b/ui-v2/tests/unit/utils/dom/event-source/storage-test.js new file mode 100644 index 0000000000..ccd83872a7 --- /dev/null +++ b/ui-v2/tests/unit/utils/dom/event-source/storage-test.js @@ -0,0 +1,10 @@ +import domEventSourceStorage from 'consul-ui/utils/dom/event-source/storage'; +import { module, test } from 'qunit'; + +module('Unit | Utility | dom/event source/storage'); + +// Replace this with your real tests. +test('it works', function(assert) { + let result = domEventSourceStorage(function EventTarget() {}); + assert.ok(result); +}); diff --git a/ui-v2/tests/unit/utils/dom/event-target/rsvp-test.js b/ui-v2/tests/unit/utils/dom/event-target/rsvp-test.js new file mode 100644 index 0000000000..7cea41640e --- /dev/null +++ b/ui-v2/tests/unit/utils/dom/event-target/rsvp-test.js @@ -0,0 +1,13 @@ +import domEventTargetRsvp from 'consul-ui/utils/dom/event-target/rsvp'; +import { module, test } from 'qunit'; + +module('Unit | Utility | dom/event-target/rsvp'); + +// Replace this with your real tests. +test('it has EventTarget methods', function(assert) { + const result = domEventTargetRsvp; + assert.equal(typeof result, 'function'); + ['addEventListener', 'removeEventListener', 'dispatchEvent'].forEach(function(item) { + assert.equal(typeof result.prototype[item], 'function'); + }); +}); diff --git a/ui-v2/yarn.lock b/ui-v2/yarn.lock index ce3cef4734..819d093918 100644 --- a/ui-v2/yarn.lock +++ b/ui-v2/yarn.lock @@ -543,6 +543,7 @@ "@gardenhq/component-factory@^1.4.0": version "1.4.0" resolved "https://registry.yarnpkg.com/@gardenhq/component-factory/-/component-factory-1.4.0.tgz#f5da8ddf2050fde9c69f4426d61fe55de043e78d" + integrity sha1-9dqN3yBQ/enGn0Qm1h/lXeBD540= dependencies: "@gardenhq/domino" "^1.0.0" "@gardenhq/tick-control" "^2.0.0" @@ -552,6 +553,7 @@ "@gardenhq/domino@^1.0.0": version "1.0.0" resolved "https://registry.yarnpkg.com/@gardenhq/domino/-/domino-1.0.0.tgz#832c493f3f05697b7df4ccce00c4cf620dc60923" + integrity sha1-gyxJPz8FaXt99MzOAMTPYg3GCSM= optionalDependencies: min-document "^2.19.0" unfetch "^2.1.2" @@ -560,6 +562,7 @@ "@gardenhq/o@^8.0.1": version "8.0.1" resolved "https://registry.yarnpkg.com/@gardenhq/o/-/o-8.0.1.tgz#d6772cec7e4295a951165284cf43fbd0a373b779" + integrity sha1-1ncs7H5ClalRFlKEz0P70KNzt3k= dependencies: "@gardenhq/component-factory" "^1.4.0" "@gardenhq/tick-control" "^2.0.0" @@ -577,10 +580,12 @@ "@gardenhq/tick-control@^2.0.0": version "2.0.0" resolved "https://registry.yarnpkg.com/@gardenhq/tick-control/-/tick-control-2.0.0.tgz#f84fe38ca7a09b7b2b52f42945c50429ba639897" + integrity sha1-+E/jjKegm3srUvQpRcUEKbpjmJc= "@gardenhq/willow@^6.2.0": version "6.2.0" resolved "https://registry.yarnpkg.com/@gardenhq/willow/-/willow-6.2.0.tgz#3e4bc220a89099732746ead3385cc097bfb70186" + integrity sha1-PkvCIKiQmXMnRurTOFzAl7+3AYY= "@glimmer/di@^0.2.0": version "0.2.1" @@ -593,8 +598,9 @@ "@glimmer/di" "^0.2.0" "@hashicorp/api-double@^1.3.0": - version "1.4.4" - resolved "https://registry.yarnpkg.com/@hashicorp/api-double/-/api-double-1.4.4.tgz#db5521230b0031bfc3dc3cc5b775f17413a4fe91" + version "1.4.5" + resolved "https://registry.yarnpkg.com/@hashicorp/api-double/-/api-double-1.4.5.tgz#839ba882fad76eb17fd2eb3a8899bf5dd5a162a8" + integrity sha512-X8xRtZGXu4JAlh/deaaPW15L8gJIqwNpVEM2OKLkQu1AWHXSh3NF8Vhd5U81061+Dha8Ohl8aEE7LZ8f1tPvzg== dependencies: "@gardenhq/o" "^8.0.1" "@gardenhq/tick-control" "^2.0.0" @@ -606,12 +612,14 @@ js-yaml "^3.10.0" "@hashicorp/consul-api-double@^2.0.1": - version "2.0.1" - resolved "https://registry.yarnpkg.com/@hashicorp/consul-api-double/-/consul-api-double-2.0.1.tgz#eaf2e3f230fbdd876c90b931fd4bb4d94aac10e2" + version "2.1.0" + resolved "https://registry.yarnpkg.com/@hashicorp/consul-api-double/-/consul-api-double-2.1.0.tgz#511e6a48842ad31133e2070f3b2307568539b10e" + integrity sha512-cyW7TiKQylrWzVUORT1e6m4SU8tQ1V5BYEKW2th7QwHP8OFazn/+om9hud/9X5YtjEuSPIQCmFIvhEVwZgLVpQ== "@hashicorp/ember-cli-api-double@^1.3.0": version "1.7.0" resolved "https://registry.yarnpkg.com/@hashicorp/ember-cli-api-double/-/ember-cli-api-double-1.7.0.tgz#4fdab6152157dd82b999de030c593c87e0cdb8b7" + integrity sha512-ojPcUPyId+3hTbwAtBGYbP5TfCGVAH8Ky6kH+BzlisIO/8XKURo9BSYnFtmYWLgXQVLOIE3iuoia5kOjGS/w2A== dependencies: "@hashicorp/api-double" "^1.3.0" array-range "^1.0.1" @@ -769,6 +777,7 @@ "@xg-wang/whatwg-fetch@^3.0.0": version "3.0.0" resolved "https://registry.yarnpkg.com/@xg-wang/whatwg-fetch/-/whatwg-fetch-3.0.0.tgz#f7b222c012a238e7d6e89ed3d72a1e0edb58453d" + integrity sha512-ULtqA6L75RLzTNW68IiOja0XYv4Ebc3OGMzfia1xxSEMpD0mk/pMvkQX0vbCFyQmKc5xGp80Ms2WiSlXLh8hbA== "@xtuc/ieee754@^1.2.0": version "1.2.0" @@ -995,6 +1004,7 @@ are-we-there-yet@~1.1.2: argparse@^1.0.7: version "1.0.10" resolved "https://registry.yarnpkg.com/argparse/-/argparse-1.0.10.tgz#bcd6791ea5ae09725e17e5ad988134cd40b3d911" + integrity sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg== dependencies: sprintf-js "~1.0.2" @@ -1031,6 +1041,7 @@ array-find-index@^1.0.1: array-flatten@1.1.1: version "1.1.1" resolved "https://registry.yarnpkg.com/array-flatten/-/array-flatten-1.1.1.tgz#9a5f699051b1e7073328f2a008968b64ea2955d2" + integrity sha1-ml9pkFGx5wczKPKgCJaLZOopVdI= array-map@~0.0.0: version "0.0.0" @@ -1039,6 +1050,7 @@ array-map@~0.0.0: array-range@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/array-range/-/array-range-1.0.1.tgz#f56e46591843611c6a56f77ef02eda7c50089bfc" + integrity sha1-9W5GWRhDYRxqVvd+8C7afFAIm/w= array-reduce@~0.0.0: version "0.0.0" @@ -1745,6 +1757,7 @@ babel-runtime@^6.18.0, babel-runtime@^6.22.0, babel-runtime@^6.26.0: babel-standalone@^6.24.2: version "6.26.0" resolved "https://registry.yarnpkg.com/babel-standalone/-/babel-standalone-6.26.0.tgz#15fb3d35f2c456695815ebf1ed96fe7f015b6886" + integrity sha1-Ffs9NfLEVmlYFevx7Zb+fwFbaIY= babel-template@^6.24.1, babel-template@^6.26.0: version "6.26.0" @@ -1903,6 +1916,7 @@ body-parser@1.18.2: body-parser@1.18.3, body-parser@^1.18.3: version "1.18.3" resolved "https://registry.yarnpkg.com/body-parser/-/body-parser-1.18.3.tgz#5b292198ffdd553b3a0f20ded0592b956955c8b4" + integrity sha1-WykhmP/dVTs6DyDe0FkrlWlVyLQ= dependencies: bytes "3.0.0" content-type "~1.0.4" @@ -2640,6 +2654,7 @@ bytes@1: bytes@3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/bytes/-/bytes-3.0.0.tgz#d32815404d689699f85a4ea4fa8755dd13a96048" + integrity sha1-0ygVQE1olpn4Wk6k+odV3ROpYEg= cacache@^10.0.4: version "10.0.4" @@ -2895,6 +2910,7 @@ class-utils@^0.3.5: classtrophobic-es5@^0.2.1: version "0.2.1" resolved "https://registry.yarnpkg.com/classtrophobic-es5/-/classtrophobic-es5-0.2.1.tgz#9bbfa62a9928abf26f385440032fb49da1cda88f" + integrity sha1-m7+mKpkoq/JvOFRAAy+0naHNqI8= clean-base-url@^1.0.0: version "1.0.0" @@ -3091,6 +3107,7 @@ commander@^2.6.0: commander@~2.13.0: version "2.13.0" resolved "https://registry.yarnpkg.com/commander/-/commander-2.13.0.tgz#6964bca67685df7c1f1430c584f07d7597885b9c" + integrity sha512-MVuS359B+YzaWqjCL/c+22gfryv+mCBPHAv3zyVI2GN8EY6IRP8VwtasXn8jyyhvvq84R4ImN1OKRtcbIasjYA== commander@~2.17.1: version "2.17.1" @@ -3206,10 +3223,12 @@ constants-browserify@^1.0.0, constants-browserify@~1.0.0: content-disposition@0.5.2: version "0.5.2" resolved "https://registry.yarnpkg.com/content-disposition/-/content-disposition-0.5.2.tgz#0cf68bb9ddf5f2be7961c3a85178cb85dba78cb4" + integrity sha1-DPaLud318r55YcOoUXjLhdunjLQ= content-type@~1.0.4: version "1.0.4" resolved "https://registry.yarnpkg.com/content-type/-/content-type-1.0.4.tgz#e138cc75e040c727b1966fe5e5f8c9aee256fe3b" + integrity sha512-hIP3EEPs8tB9AT1L+NUqtwOAps4mk2Zob89MWXMHjHWg9milF/j4osnnQLXBCBFBk/tvIG/tUc9mOUJiPBhPXA== continuable-cache@^0.3.1: version "0.3.1" @@ -3232,6 +3251,7 @@ convert-source-map@~1.1.0: cookie-parser@^1.4.3: version "1.4.3" resolved "https://registry.yarnpkg.com/cookie-parser/-/cookie-parser-1.4.3.tgz#0fe31fa19d000b95f4aadf1f53fdc2b8a203baa5" + integrity sha1-D+MfoZ0AC5X0qt8fU/3CuKIDuqU= dependencies: cookie "0.3.1" cookie-signature "1.0.6" @@ -3239,10 +3259,12 @@ cookie-parser@^1.4.3: cookie-signature@1.0.6: version "1.0.6" resolved "https://registry.yarnpkg.com/cookie-signature/-/cookie-signature-1.0.6.tgz#e303a882b342cc3ee8ca513a79999734dab3ae2c" + integrity sha1-4wOogrNCzD7oylE6eZmXNNqzriw= cookie@0.3.1: version "0.3.1" resolved "https://registry.yarnpkg.com/cookie/-/cookie-0.3.1.tgz#e7e0a1f9ef43b4c8ba925c5c5a96e806d16873bb" + integrity sha1-5+Ch+e9DtMi6klxcWpboBtFoc7s= copy-concurrently@^1.0.0: version "1.0.5" @@ -3601,6 +3623,7 @@ des.js@^1.0.0: destroy@~1.0.4: version "1.0.4" resolved "https://registry.yarnpkg.com/destroy/-/destroy-1.0.4.tgz#978857442c44749e4206613e37946205826abd80" + integrity sha1-l4hXRCxEdJ5CBmE+N5RiBYJqvYA= detect-file@^0.1.0: version "0.1.0" @@ -3657,6 +3680,7 @@ dom-serializer@0: dom-walk@^0.1.0: version "0.1.1" resolved "https://registry.yarnpkg.com/dom-walk/-/dom-walk-0.1.1.tgz#672226dc74c8f799ad35307df936aba11acd6018" + integrity sha1-ZyIm3HTI95mtNTB9+TaroRrNYBg= domain-browser@^1.1.1: version "1.2.0" @@ -3716,6 +3740,7 @@ editions@^1.1.1: ee-first@1.1.1: version "1.1.1" resolved "https://registry.yarnpkg.com/ee-first/-/ee-first-1.1.1.tgz#590c61156b0ae2f4f0255732a158b266bc56b21d" + integrity sha1-WQxhFWsK4vTwJVcyoViyZrxWsh0= electron-to-chromium@^1.3.30, electron-to-chromium@^1.3.47: version "1.3.62" @@ -4701,6 +4726,7 @@ emojis-list@^2.0.0: encodeurl@~1.0.2: version "1.0.2" resolved "https://registry.yarnpkg.com/encodeurl/-/encodeurl-1.0.2.tgz#ad3ff4c86ec2d029322f5a02c3a9a606c95b3f59" + integrity sha1-rT/0yG7C0CkyL1oCw6mmBslbP1k= encoding@^0.1.11: version "0.1.12" @@ -4866,6 +4892,7 @@ es6-weak-map@^2.0.1: escape-html@~1.0.3: version "1.0.3" resolved "https://registry.yarnpkg.com/escape-html/-/escape-html-1.0.3.tgz#0258eae4d3d0c0974de1c169188ef0051d1d1988" + integrity sha1-Aljq5NPQwJdN4cFpGI7wBR0dGYg= escape-string-regexp@^1.0.0, escape-string-regexp@^1.0.2, escape-string-regexp@^1.0.5: version "1.0.5" @@ -4958,6 +4985,7 @@ espree@^3.5.4: esprima@^4.0.0: version "4.0.1" resolved "https://registry.yarnpkg.com/esprima/-/esprima-4.0.1.tgz#13b04cdb3e6c5d19df91ab6987a8695619b0aa71" + integrity sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A== esprima@~3.0.0: version "3.0.0" @@ -4994,6 +5022,7 @@ esutils@^2.0.2: etag@~1.8.1: version "1.8.1" resolved "https://registry.yarnpkg.com/etag/-/etag-1.8.1.tgz#41ae2eeb65efa62268aebfea83ac7d79299b0887" + integrity sha1-Qa4u62XvpiJorr/qg6x9eSmbCIc= event-emitter@~0.3.5: version "0.3.5" @@ -5189,6 +5218,7 @@ express@^4.10.7, express@^4.12.3: express@^4.16.2: version "4.16.4" resolved "https://registry.yarnpkg.com/express/-/express-4.16.4.tgz#fddef61926109e24c515ea97fd2f1bdbf62df12e" + integrity sha512-j12Uuyb4FMrd/qQAm6uCHAkPtO8FDTRJZBDd5D2KOL2eLaz1yUNdUB/NOIyq0iU4q4cFarsUCrnFDPBcnksuOg== dependencies: accepts "~1.3.5" array-flatten "1.1.1" @@ -5292,10 +5322,12 @@ eyes@0.1.x: fake-xml-http-request@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/fake-xml-http-request/-/fake-xml-http-request-2.0.0.tgz#41a92f0ca539477700cb1dafd2df251d55dac8ff" + integrity sha512-UjNnynb6eLAB0lyh2PlTEkjRJORnNsVF1hbzU+PQv89/cyBV9GDRCy7JAcLQgeCLYT+3kaumWWZKEJvbaK74eQ== faker@^4.1.0: version "4.1.0" resolved "https://registry.yarnpkg.com/faker/-/faker-4.1.0.tgz#1e45bbbecc6774b3c195fad2835109c6d748cc3f" + integrity sha1-HkW7vsxndLPBlfrSg1EJxtdIzD8= fast-deep-equal@^1.0.0: version "1.1.0" @@ -5372,7 +5404,8 @@ file-entry-cache@^2.0.0: file-saver@^1.3.3: version "1.3.8" - resolved "http://registry.npmjs.org/file-saver/-/file-saver-1.3.8.tgz#e68a30c7cb044e2fb362b428469feb291c2e09d8" + resolved "https://registry.yarnpkg.com/file-saver/-/file-saver-1.3.8.tgz#e68a30c7cb044e2fb362b428469feb291c2e09d8" + integrity sha512-spKHSBQIxxS81N/O21WmuXA2F6wppUCsutpzenOeZzOCCJ5gEfcbqJP983IrpLXzYmXnMUa6J03SubcNPdKrlg== filename-regex@^2.0.0: version "2.0.1" @@ -5410,7 +5443,8 @@ fill-range@^4.0.0: finalhandler@1.1.1: version "1.1.1" - resolved "http://registry.npmjs.org/finalhandler/-/finalhandler-1.1.1.tgz#eebf4ed840079c83f4249038c9d703008301b105" + resolved "https://registry.yarnpkg.com/finalhandler/-/finalhandler-1.1.1.tgz#eebf4ed840079c83f4249038c9d703008301b105" + integrity sha512-Y1GUDo39ez4aHAw7MysnUD5JzYX+WaIj8I57kO3aEPT1fFRL4sr7mjei97FgnwhAyyzRYmQZaTHb2+9uZ1dPtg== dependencies: debug "2.6.9" encodeurl "~1.0.2" @@ -5552,6 +5586,7 @@ formatio@1.2.0: forwarded@~0.1.2: version "0.1.2" resolved "https://registry.yarnpkg.com/forwarded/-/forwarded-0.1.2.tgz#98c23dab1175657b8c0573e8ceccd91b0ff18c84" + integrity sha1-mMI9qxF1ZXuMBXPozszZGw/xjIQ= fragment-cache@^0.2.1: version "0.2.1" @@ -5562,6 +5597,7 @@ fragment-cache@^0.2.1: fresh@0.5.2: version "0.5.2" resolved "https://registry.yarnpkg.com/fresh/-/fresh-0.5.2.tgz#3d8cadd90d976569fa835ab1f8e4b23a105605a7" + integrity sha1-PYyt2Q2XZWn6g1qx+OSyOhBWBac= from2@^2.1.0: version "2.3.0" @@ -6200,7 +6236,8 @@ http-errors@1.6.2: http-errors@1.6.3, http-errors@~1.6.2, http-errors@~1.6.3: version "1.6.3" - resolved "http://registry.npmjs.org/http-errors/-/http-errors-1.6.3.tgz#8b55680bb4be283a0b5bf4ea2e38580be1d9320d" + resolved "https://registry.yarnpkg.com/http-errors/-/http-errors-1.6.3.tgz#8b55680bb4be283a0b5bf4ea2e38580be1d9320d" + integrity sha1-i1VoC7S+KDoLW/TqLjhYC+HZMg0= dependencies: depd "~1.1.2" inherits "2.0.3" @@ -6260,6 +6297,7 @@ husky@^1.1.0: hyperhtml@^0.15.5: version "0.15.10" resolved "https://registry.yarnpkg.com/hyperhtml/-/hyperhtml-0.15.10.tgz#5e5f42393d4fc30cd803063fb88a5c9d97625e1c" + integrity sha512-D3dkc5nac47dzGXhLfGTearEoUXLk8ijSrj+5ngEH1Od+6EZ9Cwjspj/MWWx74DWpvCH+glO7M+B7WqCYSzkTg== iconv-lite@0.4.19: version "0.4.19" @@ -6268,6 +6306,7 @@ iconv-lite@0.4.19: iconv-lite@0.4.23: version "0.4.23" resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.4.23.tgz#297871f63be507adcfbfca715d0cd0eed84e9a63" + integrity sha512-neyTUVFtahjf0mB3dZT77u+8O0QB89jFdnBkd5P1JgYPbPaia3gXXOVL2fq8VyU2gMMD7SaN7QukTB/pmXYvDA== dependencies: safer-buffer ">= 2.1.2 < 3" @@ -6431,6 +6470,7 @@ invert-kv@^1.0.0: ipaddr.js@1.8.0: version "1.8.0" resolved "https://registry.yarnpkg.com/ipaddr.js/-/ipaddr.js-1.8.0.tgz#eaa33d6ddd7ace8f7f6fe0c9ca0440e706738b1e" + integrity sha1-6qM9bd16zo9/b+DJygRA5wZzix4= is-accessor-descriptor@^0.1.6: version "0.1.6" @@ -6631,6 +6671,7 @@ is-path-inside@^1.0.0: is-plain-obj@^1.1: version "1.1.0" resolved "https://registry.yarnpkg.com/is-plain-obj/-/is-plain-obj-1.1.0.tgz#71a50c8429dfca773c92a390a4a03b39fcd51d3e" + integrity sha1-caUMhCnfync8kqOQpKA7OfzVHT4= is-plain-object@^2.0.1, is-plain-object@^2.0.3, is-plain-object@^2.0.4: version "2.0.4" @@ -6862,7 +6903,15 @@ js-yaml@0.3.x: version "0.3.7" resolved "https://registry.yarnpkg.com/js-yaml/-/js-yaml-0.3.7.tgz#d739d8ee86461e54b354d6a7d7d1f2ad9a167f62" -js-yaml@^3.10.0, js-yaml@^3.11.0, js-yaml@^3.12.0, js-yaml@^3.8.4: +js-yaml@^3.10.0, js-yaml@^3.11.0, js-yaml@^3.8.4: + version "3.12.1" + resolved "https://registry.yarnpkg.com/js-yaml/-/js-yaml-3.12.1.tgz#295c8632a18a23e054cf5c9d3cecafe678167600" + integrity sha512-um46hB9wNOKlwkHgiuyEVAybXBjwFUV0Z/RaHJblRd9DXltue9FTYvzCr9ErQrK9Adz5MU4gHWVaNUfdmrC8qA== + dependencies: + argparse "^1.0.7" + esprima "^4.0.0" + +js-yaml@^3.12.0: version "3.12.0" resolved "https://registry.yarnpkg.com/js-yaml/-/js-yaml-3.12.0.tgz#eaed656ec8344f10f527c6bfa1b6e2244de167d1" dependencies: @@ -7701,7 +7750,8 @@ mdurl@^1.0.1: media-typer@0.3.0: version "0.3.0" - resolved "http://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz#8710d7af0aa626f8fffa1ce00168545263255748" + resolved "https://registry.yarnpkg.com/media-typer/-/media-typer-0.3.0.tgz#8710d7af0aa626f8fffa1ce00168545263255748" + integrity sha1-hxDXrwqmJvj/+hzgAWhUUmMlV0g= mem@^1.1.0: version "1.1.0" @@ -7740,10 +7790,12 @@ meow@^3.4.0, meow@^3.7.0: merge-descriptors@1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/merge-descriptors/-/merge-descriptors-1.0.1.tgz#b00aaa556dd8b44568150ec9d1b953f3f90cbb61" + integrity sha1-sAqqVW3YtEVoFQ7J0blT8/kMu2E= merge-options@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/merge-options/-/merge-options-1.0.1.tgz#2a64b24457becd4e4dc608283247e94ce589aa32" + integrity sha512-iuPV41VWKWBIOpBsjoxjDZw8/GbSfZ2mk7N1453bwMrfzdrIk7EzBd+8UVR6rkw67th7xnk9Dytl3J+lHPdxvg== dependencies: is-plain-obj "^1.1" @@ -7772,6 +7824,7 @@ merge@^1.1.3: methods@~1.1.2: version "1.1.2" resolved "https://registry.yarnpkg.com/methods/-/methods-1.1.2.tgz#5529a4d67654134edcc5266656835b0f851afcee" + integrity sha1-VSmk1nZUE07cxSZmVoNbD4Ua/O4= micromatch@^2.1.5, micromatch@^2.3.11, micromatch@^2.3.7: version "2.3.11" @@ -7827,6 +7880,7 @@ mime-db@~1.36.0: mime-db@~1.37.0: version "1.37.0" resolved "https://registry.yarnpkg.com/mime-db/-/mime-db-1.37.0.tgz#0b6a0ce6fdbe9576e25f1f2d2fde8830dc0ad0d8" + integrity sha512-R3C4db6bgQhlIhPU48fUtdVmKnflq+hRdad7IyKhtFj06VPNVdk2RhiYL3UjQIlso8L+YxAtFkobT0VK+S/ybg== mime-types@^2.1.12, mime-types@~2.1.17, mime-types@~2.1.19, mime-types@~2.1.7: version "2.1.20" @@ -7843,12 +7897,14 @@ mime-types@^2.1.18: mime-types@~2.1.18: version "2.1.21" resolved "https://registry.yarnpkg.com/mime-types/-/mime-types-2.1.21.tgz#28995aa1ecb770742fe6ae7e58f9181c744b3f96" + integrity sha512-3iL6DbwpyLzjR3xHSFNFeb9Nz/M8WDkX33t1GFQnFOllWk8pOrh/LSrB5OXlnlW5P9LH73X6loW/eogc+F5lJg== dependencies: mime-db "~1.37.0" mime@1.4.1: version "1.4.1" resolved "https://registry.yarnpkg.com/mime/-/mime-1.4.1.tgz#121f9ebc49e3766f311a76e1fa1c8003c4b03aa6" + integrity sha512-KI1+qOZu5DcW6wayYHSzR/tXKCDC5Om4s1z2QJjDULzLcmf3DvzS7oluY4HCTrc+9FiKmWUgeNLg7W3uIQvxtQ== mimic-fn@^1.0.0: version "1.2.0" @@ -7857,6 +7913,7 @@ mimic-fn@^1.0.0: min-document@^2.19.0: version "2.19.0" resolved "https://registry.yarnpkg.com/min-document/-/min-document-2.19.0.tgz#7bd282e3f5842ed295bb748cdd9f1ffa2c824685" + integrity sha1-e9KC4/WELtKVu3SM3Z8f+iyCRoU= dependencies: dom-walk "^0.1.0" @@ -7972,6 +8029,7 @@ morgan@^1.8.1: mousetrap@^1.6.1: version "1.6.2" resolved "https://registry.yarnpkg.com/mousetrap/-/mousetrap-1.6.2.tgz#caadd9cf886db0986fb2fee59a82f6bd37527587" + integrity sha512-jDjhi7wlHwdO6q6DS7YRmSHcuI+RVxadBkLt3KHrhd3C2b+w5pKefg3oj5beTcHZyVFA9Aksf+yEE1y5jxUjVA== mout@^1.0.0: version "1.1.0" @@ -7991,6 +8049,7 @@ move-concurrently@^1.0.1: ms@2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/ms/-/ms-2.0.0.tgz#5608aeadfc00be6c2901df5f9861788de0d597c8" + integrity sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g= ms@^2.1.1: version "2.1.1" @@ -8051,7 +8110,8 @@ natural-compare@^1.4.0: ncp@^2.0.0: version "2.0.0" - resolved "http://registry.npmjs.org/ncp/-/ncp-2.0.0.tgz#195a21d6c46e361d2fb1281ba38b91e9df7bdbb3" + resolved "https://registry.yarnpkg.com/ncp/-/ncp-2.0.0.tgz#195a21d6c46e361d2fb1281ba38b91e9df7bdbb3" + integrity sha1-GVoh1sRuNh0vsSgbo4uR6d9727M= needle@^2.2.1: version "2.2.4" @@ -8064,6 +8124,7 @@ needle@^2.2.1: negotiator@0.6.1: version "0.6.1" resolved "https://registry.yarnpkg.com/negotiator/-/negotiator-0.6.1.tgz#2b327184e8992101177b28563fb5e7102acd0ca9" + integrity sha1-KzJxhOiZIQEXeyhWP7XnECrNDKk= neo-async@^2.5.0: version "2.5.2" @@ -8409,6 +8470,7 @@ object.values@^1.0.4: on-finished@~2.3.0: version "2.3.0" resolved "https://registry.yarnpkg.com/on-finished/-/on-finished-2.3.0.tgz#20f1336481b083cd75337992a16971aa2d906947" + integrity sha1-IPEzZIGwg811M3mSoWlxqi2QaUc= dependencies: ee-first "1.1.1" @@ -8607,6 +8669,7 @@ parseuri@0.0.5: parseurl@~1.3.2: version "1.3.2" resolved "https://registry.yarnpkg.com/parseurl/-/parseurl-1.3.2.tgz#fc289d4ed8993119460c156253262cdc8de65bf3" + integrity sha1-/CidTtiZMRlGDBViUyYs3I3mW/M= pascalcase@^0.1.1: version "0.1.1" @@ -8663,6 +8726,7 @@ path-posix@^1.0.0: path-to-regexp@0.1.7: version "0.1.7" resolved "https://registry.yarnpkg.com/path-to-regexp/-/path-to-regexp-0.1.7.tgz#df604178005f522f15eb4490e7247a1bfaa67f8c" + integrity sha1-32BBeABfUi8V60SQ5yR6G/qmf4w= path-to-regexp@^1.7.0: version "1.7.0" @@ -8783,6 +8847,7 @@ preserve@^0.2.0: pretender@^2.0.0: version "2.1.1" resolved "https://registry.yarnpkg.com/pretender/-/pretender-2.1.1.tgz#5085f0a1272c31d5b57c488386f69e6ca207cb35" + integrity sha512-IkidsJzaroAanw3I43tKCFm2xCpurkQr9aPXv5/jpN+LfCwDaeI8rngVWtQZTx4qqbhc5zJspnLHJ4N/25KvDQ== dependencies: "@xg-wang/whatwg-fetch" "^3.0.0" fake-xml-http-request "^2.0.0" @@ -8974,6 +9039,7 @@ randomfill@^1.0.3: range-parser@~1.2.0: version "1.2.0" resolved "https://registry.yarnpkg.com/range-parser/-/range-parser-1.2.0.tgz#f49be6b487894ddc40dcc94a322f611092e00d5e" + integrity sha1-9JvmtIeJTdxA3MlKMi9hEJLgDV4= raw-body@2.3.2: version "2.3.2" @@ -8987,6 +9053,7 @@ raw-body@2.3.2: raw-body@2.3.3: version "2.3.3" resolved "https://registry.yarnpkg.com/raw-body/-/raw-body-2.3.3.tgz#1b324ece6b5706e153855bc1148c65bb7f6ea0c3" + integrity sha512-9esiElv1BrZoI3rCDuOuKCBRbuApGGaDPQfjSflGxdy4oyzqghxu6klEkkVIvBje+FF0BX9coEv8KqW6X/7njw== dependencies: bytes "3.0.0" http-errors "1.6.3" @@ -9115,6 +9182,7 @@ recast@^0.11.3: recursive-readdir-sync@^1.0.6: version "1.0.6" resolved "https://registry.yarnpkg.com/recursive-readdir-sync/-/recursive-readdir-sync-1.0.6.tgz#1dbf6d32f3c5bb8d3cde97a6c588d547a9e13d56" + integrity sha1-Hb9tMvPFu4083pemxYjVR6nhPVY= redent@^1.0.0: version "1.0.0" @@ -9436,6 +9504,7 @@ rollup-plugin-commonjs@^9.1.0: rollup-plugin-memory@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/rollup-plugin-memory/-/rollup-plugin-memory-2.0.0.tgz#0a8ac6b57fa0e714f89a15c3ac82bc93f89c47c5" + integrity sha1-CorGtX+g5xT4mhXDrIK8k/icR8U= rollup-plugin-node-resolve@^3.3.0: version "3.4.0" @@ -9468,6 +9537,7 @@ rollup@^0.59.0: route-recognizer@^0.3.3: version "0.3.4" resolved "https://registry.yarnpkg.com/route-recognizer/-/route-recognizer-0.3.4.tgz#39ab1ffbce1c59e6d2bdca416f0932611e4f3ca3" + integrity sha512-2+MhsfPhvauN1O8KaXpXAOfR/fwe8dnUXVM+xw7yt40lJRfPVQxV6yryZm0cgRvAj5fMF/mdRZbL2ptwbs5i2g== rsvp@^3.0.14, rsvp@^3.0.16, rsvp@^3.0.17, rsvp@^3.0.18, rsvp@^3.0.21, rsvp@^3.0.6, rsvp@^3.1.0, rsvp@^3.2.1, rsvp@^3.3.3, rsvp@^3.5.0: version "3.6.2" @@ -9550,6 +9620,7 @@ safe-regex@^1.1.0: "safer-buffer@>= 2.1.2 < 3", safer-buffer@^2.0.2, safer-buffer@^2.1.0, safer-buffer@~2.1.0: version "2.1.2" resolved "https://registry.yarnpkg.com/safer-buffer/-/safer-buffer-2.1.2.tgz#44fa161b0187b9549dd84bb91802f9bd8385cd6a" + integrity sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg== samsam@1.3.0, samsam@1.x, samsam@^1.1.3: version "1.3.0" @@ -9619,6 +9690,7 @@ semver@~5.3.0: send@0.16.2: version "0.16.2" resolved "https://registry.yarnpkg.com/send/-/send-0.16.2.tgz#6ecca1e0f8c156d141597559848df64730a6bbc1" + integrity sha512-E64YFPUssFHEFBvpbbjr44NCLtI1AohxQ8ZSiJjQLskAdKuriYEP6VyGEsRDH8ScozGpkaX1BGvhanqCwkcEZw== dependencies: debug "2.6.9" depd "~1.1.2" @@ -9641,6 +9713,7 @@ serialize-javascript@^1.4.0: serve-static@1.13.2: version "1.13.2" resolved "https://registry.yarnpkg.com/serve-static/-/serve-static-1.13.2.tgz#095e8472fd5b46237db50ce486a43f4b86c6cec1" + integrity sha512-p/tdJrO4U387R9oMjb1oj7qSMaMfmOyd4j9hOFoxZe2baQszgHcSWjuya/CiT5kgZZKRudHNOA0pYXOl8rQ5nw== dependencies: encodeurl "~1.0.2" escape-html "~1.0.3" @@ -9684,6 +9757,7 @@ setprototypeof@1.0.3: setprototypeof@1.1.0: version "1.1.0" resolved "https://registry.yarnpkg.com/setprototypeof/-/setprototypeof-1.1.0.tgz#d0bd85536887b6fe7c0d818cb962d9d91c54e656" + integrity sha512-BvE/TwpZX4FXExxOxZyRGQQv651MSwmWKZGqvmPcRIjDqWub67kTKuIMx43cZZrS/cBBzwBcNDWoFxt2XEFIpQ== sha.js@^2.4.0, sha.js@^2.4.8, sha.js@~2.4.4: version "2.4.11" @@ -9894,6 +9968,7 @@ source-map@0.4.x, source-map@^0.4.2, source-map@^0.4.4: source-map@^0.5.0, source-map@^0.5.3, source-map@^0.5.6, source-map@^0.5.7, source-map@~0.5.0, source-map@~0.5.1, source-map@~0.5.3: version "0.5.7" resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.5.7.tgz#8a039d2d1021d22d1ea14c80d8ea468ba2ef3fcc" + integrity sha1-igOdLRAh0i0eoUyA2OpGi6LvP8w= source-map@^0.6.1, source-map@~0.6.1: version "0.6.1" @@ -9960,6 +10035,7 @@ sprintf-js@^1.0.3: sprintf-js@~1.0.2: version "1.0.3" resolved "https://registry.yarnpkg.com/sprintf-js/-/sprintf-js-1.0.3.tgz#04e6926f662895354f3dd015203633b857297e2c" + integrity sha1-BOaSb2YolTVPPdAVIDYzuFcpfiw= sri-toolbox@^0.2.0: version "0.2.0" @@ -10012,6 +10088,7 @@ static-extend@^0.1.1: statuses@~1.4.0: version "1.4.0" resolved "https://registry.yarnpkg.com/statuses/-/statuses-1.4.0.tgz#bb73d446da2796106efcc1b601a253d6c46bd087" + integrity sha512-zhSCtt8v2NDrRlPQpCNtw/heZLtfUDqxBM1udqikb/Hbk52LK4nQSwr10u77iopCW5LsyHpuXS0GnEc48mLeew== stdout-stream@^1.4.0: version "1.4.1" @@ -10608,6 +10685,7 @@ underscore@~1.6.0: unfetch@^2.1.2: version "2.1.2" resolved "https://registry.yarnpkg.com/unfetch/-/unfetch-2.1.2.tgz#684fee4d8acdb135bdb26c0364c642fc326ca95b" + integrity sha1-aE/uTYrNsTW9smwDZMZC/DJsqVs= unicode-canonical-property-names-ecmascript@^1.0.4: version "1.0.4" @@ -10662,6 +10740,7 @@ universalify@^0.1.0: unpipe@1.0.0, unpipe@~1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/unpipe/-/unpipe-1.0.0.tgz#b2bf4ee8514aae6165b4817829d21b2ef49904ec" + integrity sha1-sr9O6FFKrmFltIF4KdIbLvSZBOw= unquote@~1.1.1: version "1.1.1" @@ -10758,6 +10837,7 @@ util@^0.10.3: utils-merge@1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/utils-merge/-/utils-merge-1.0.1.tgz#9f95710f50a267947b2ccc124741c1028427e713" + integrity sha1-n5VxD1CiZ5R7LMwSR0HBAoQn5xM= uuid@^3.0.0, uuid@^3.1.0, uuid@^3.3.2: version "3.3.2" @@ -10779,6 +10859,7 @@ validate-npm-package-name@^3.0.0: vary@~1.1.2: version "1.1.2" resolved "https://registry.yarnpkg.com/vary/-/vary-1.1.2.tgz#2299f02c6ded30d4a5961b0b9f74524a18f634fc" + integrity sha1-IpnwLG3tMNSllhsLn3RSShj2NPw= verror@1.10.0: version "1.10.0" @@ -11004,6 +11085,7 @@ xdg-basedir@^3.0.0: xhr2@^0.1.4: version "0.1.4" resolved "https://registry.yarnpkg.com/xhr2/-/xhr2-0.1.4.tgz#7f87658847716db5026323812f818cadab387a5f" + integrity sha1-f4dliEdxbbUCYyOBL4GMras4el8= xmldom@^0.1.19: version "0.1.27" From cfa4bc264ef229d74a6408ba8ead62f313bf0fed Mon Sep 17 00:00:00 2001 From: John Cowen Date: Thu, 21 Feb 2019 13:05:05 +0000 Subject: [PATCH 26/52] UI: Add ember steps:list command for listing available steps (#5255) * ui: Add ember steps:list command for listing available steps 1. Adds `command` addon to house the new command 2. Start to organize out the steps themselves, bring a bit more order to things ready to dedupe and cleanup --- ui-v2/GNUmakefile | 3 + ui-v2/lib/commands/index.js | 19 + ui-v2/lib/commands/lib/list.js | 67 +++ ui-v2/lib/commands/package.json | 6 + ui-v2/package.json | 8 +- ui-v2/tests/acceptance/steps/steps.js | 83 +++- ui-v2/tests/steps.js | 549 ++---------------------- ui-v2/tests/steps/assertions/dom.js | 45 ++ ui-v2/tests/steps/assertions/http.js | 111 +++++ ui-v2/tests/steps/assertions/model.js | 50 +++ ui-v2/tests/steps/assertions/page.js | 99 +++++ ui-v2/tests/steps/debug/index.js | 20 + ui-v2/tests/steps/doubles/http.js | 15 + ui-v2/tests/steps/doubles/model.js | 22 + ui-v2/tests/steps/interactions/click.js | 33 ++ ui-v2/tests/steps/interactions/form.js | 49 +++ ui-v2/tests/steps/interactions/visit.js | 19 + ui-v2/yarn.lock | 98 ++++- 18 files changed, 776 insertions(+), 520 deletions(-) create mode 100644 ui-v2/lib/commands/index.js create mode 100644 ui-v2/lib/commands/lib/list.js create mode 100644 ui-v2/lib/commands/package.json create mode 100644 ui-v2/tests/steps/assertions/dom.js create mode 100644 ui-v2/tests/steps/assertions/http.js create mode 100644 ui-v2/tests/steps/assertions/model.js create mode 100644 ui-v2/tests/steps/assertions/page.js create mode 100644 ui-v2/tests/steps/debug/index.js create mode 100644 ui-v2/tests/steps/doubles/http.js create mode 100644 ui-v2/tests/steps/doubles/model.js create mode 100644 ui-v2/tests/steps/interactions/click.js create mode 100644 ui-v2/tests/steps/interactions/form.js create mode 100644 ui-v2/tests/steps/interactions/visit.js diff --git a/ui-v2/GNUmakefile b/ui-v2/GNUmakefile index e87521b992..682077582b 100644 --- a/ui-v2/GNUmakefile +++ b/ui-v2/GNUmakefile @@ -25,6 +25,9 @@ lint: deps format: deps yarn run format:js +steps: + yarn run steps:list + node_modules: yarn.lock package.json yarn install diff --git a/ui-v2/lib/commands/index.js b/ui-v2/lib/commands/index.js new file mode 100644 index 0000000000..c8c558db54 --- /dev/null +++ b/ui-v2/lib/commands/index.js @@ -0,0 +1,19 @@ +/* eslint no-console: "off" */ +/* eslint-env node */ +'use strict'; +module.exports = { + name: 'commands', + includedCommands: function() { + return { + 'steps:list': { + name: 'steps:list', + run: function(config, args) { + require('./lib/list.js')(`${process.cwd()}/tests/steps.js`); + }, + }, + }; + }, + isDevelopingAddon() { + return true; + }, +}; diff --git a/ui-v2/lib/commands/lib/list.js b/ui-v2/lib/commands/lib/list.js new file mode 100644 index 0000000000..db868141d6 --- /dev/null +++ b/ui-v2/lib/commands/lib/list.js @@ -0,0 +1,67 @@ +/* eslint no-console: "off" */ +/* eslint-env node */ +'use strict'; +const babel = require('@babel/core'); +const read = require('fs').readFileSync; +const path = require('path'); +const vm = require('vm'); +const color = require('chalk'); + +const out = function(prefix, step, desc) { + if (!Array.isArray(step)) { + step = [step]; + } + step.forEach(function(item) { + const str = + prefix + + item.replace('\n', ' | ').replace(/\$\w+/g, function(match) { + return color.cyan(match); + }); + console.log(color.green(str)); + }); +}; +const library = { + given: function(step, cb, desc) { + out('Given ', step, desc); + return this; + }, + desc: function(desc) { + console.log(color.yellow(`- ${desc.trim()}`)); + }, + section: function() { + console.log(color.yellow(`##`)); + }, + then: function(step, cb, desc) { + out('Then ', step, desc); + return this; + }, + when: function(step, cb, desc) { + out('When ', step, desc); + return this; + }, +}; +const exec = function(filename) { + const js = read(filename); + const code = babel.transform(js.toString(), { + filename: filename, + presets: [require('babel-preset-env')], + }).code; + const exports = {}; + vm.runInNewContext( + code, + { + exports: exports, + require: function(str) { + return exec(path.resolve(`${process.cwd()}/tests`, `${str}.js`)).default; + }, + }, + { + filename: filename, + } + ); + return exports; +}; + +module.exports = function(filename) { + exec(filename).default(function() {}, library, {}, {}, {}, function() {}); +}; diff --git a/ui-v2/lib/commands/package.json b/ui-v2/lib/commands/package.json new file mode 100644 index 0000000000..722b9500ec --- /dev/null +++ b/ui-v2/lib/commands/package.json @@ -0,0 +1,6 @@ +{ + "name": "commands", + "keywords": [ + "ember-addon" + ] +} diff --git a/ui-v2/package.json b/ui-v2/package.json index ccdf44f5d1..507717e5ee 100644 --- a/ui-v2/package.json +++ b/ui-v2/package.json @@ -20,7 +20,8 @@ "test-parallel": "EMBER_EXAM_PARALLEL=true ember exam --split=4 --parallel", "test:view": "ember test --server --test-port=${EMBER_TEST_PORT:-7357}", "test:coverage": "COVERAGE=true ember test --test-port=${EMBER_TEST_PORT:-7357}", - "test:view:coverage": "COVERAGE=true ember test --server --test-port=${EMBER_TEST_PORT:-7357}" + "test:view:coverage": "COVERAGE=true ember test --server --test-port=${EMBER_TEST_PORT:-7357}", + "steps:list": "node ./lib/commands/bin/list.js" }, "husky": { "hooks": { @@ -38,11 +39,13 @@ ] }, "devDependencies": { + "@babel/core": "^7.2.2", "@hashicorp/consul-api-double": "^2.0.1", "@hashicorp/ember-cli-api-double": "^1.3.0", "babel-plugin-transform-object-rest-spread": "^6.26.0", "base64-js": "^1.3.0", "broccoli-asset-rev": "^2.4.5", + "chalk": "^2.4.2", "dart-sass": "^1.14.1", "ember-ajax": "^3.0.0", "ember-browserify": "^1.2.2", @@ -104,7 +107,8 @@ "ember-addon": { "paths": [ "lib/startup", - "lib/block-slots" + "lib/block-slots", + "lib/commands" ] } } diff --git a/ui-v2/tests/acceptance/steps/steps.js b/ui-v2/tests/acceptance/steps/steps.js index df45d3f284..c8da3b0a2b 100644 --- a/ui-v2/tests/acceptance/steps/steps.js +++ b/ui-v2/tests/acceptance/steps/steps.js @@ -1,2 +1,83 @@ +/* eslint no-console: "off" */ +import Inflector from 'ember-inflector'; +import utils from '@ember/test-helpers'; +import getDictionary from '@hashicorp/ember-cli-api-double/dictionary'; + +import yadda from 'consul-ui/tests/helpers/yadda'; +import pages from 'consul-ui/tests/pages'; +import api from 'consul-ui/tests/helpers/api'; + import steps from 'consul-ui/tests/steps'; -export default steps; + +const pluralize = function(str) { + return Inflector.inflector.pluralize(str); +}; +export default function(assert) { + const library = yadda.localisation.English.library( + getDictionary(function(model, cb) { + switch (model) { + case 'datacenter': + case 'datacenters': + case 'dcs': + model = 'dc'; + break; + case 'services': + model = 'service'; + break; + case 'nodes': + model = 'node'; + break; + case 'kvs': + model = 'kv'; + break; + case 'acls': + model = 'acl'; + break; + case 'sessions': + model = 'session'; + break; + case 'intentions': + model = 'intention'; + break; + } + cb(null, model); + }, yadda) + ); + const create = function(number, name, value) { + // don't return a promise here as + // I don't need it to wait + api.server.createList(name, number, value); + }; + const respondWith = function(url, data) { + api.server.respondWith(url.split('?')[0], data); + }; + const setCookie = function(key, value) { + api.server.setCookie(key, value); + }; + const getLastNthRequest = function(arr) { + return function(n, method) { + let requests = arr.slice(0).reverse(); + if (method) { + requests = requests.filter(function(item) { + return item.method === method; + }); + } + if (n == null) { + return requests; + } + return requests[n]; + }; + }; + return steps(assert, library, pages, { + pluralize: pluralize, + triggerKeyEvent: utils.triggerKeyEvent, + currentURL: utils.currentURL, + click: utils.click, + fillIn: utils.fillIn, + find: utils.find, + lastNthRequest: getLastNthRequest(api.server.history), + respondWith: respondWith, + create: create, + set: setCookie, + }); +} diff --git a/ui-v2/tests/steps.js b/ui-v2/tests/steps.js index d0adc96f59..cad75cceef 100644 --- a/ui-v2/tests/steps.js +++ b/ui-v2/tests/steps.js @@ -1,521 +1,38 @@ -/* eslint no-console: "off" */ -import Inflector from 'ember-inflector'; -import yadda from './helpers/yadda'; -import { currentURL, click, triggerKeyEvent, fillIn, find } from '@ember/test-helpers'; -import getDictionary from '@hashicorp/ember-cli-api-double/dictionary'; -import pages from 'consul-ui/tests/pages'; -import api from 'consul-ui/tests/helpers/api'; +import models from './steps/doubles/model'; +import http from './steps/doubles/http'; +import visit from './steps/interactions/visit'; +import click from './steps/interactions/click'; +import form from './steps/interactions/form'; +import debug from './steps/debug/index'; +import assertHttp from './steps/assertions/http'; +import assertModel from './steps/assertions/model'; +import assertPage from './steps/assertions/page'; +import assertDom from './steps/assertions/dom'; + // const dont = `( don't| shouldn't| can't)?`; -const pluralize = function(str) { - return Inflector.inflector.pluralize(str); -}; -const create = function(number, name, value) { - // don't return a promise here as - // I don't need it to wait - api.server.createList(name, number, value); -}; -const lastRequest = function(method) { - return api.server.history - .slice(0) - .reverse() - .find(function(item) { - return item.method === method; - }); -}; -const fillInElement = function(page, name, value) { - const cm = document.querySelector(`textarea[name="${name}"] + .CodeMirror`); - if (cm) { - cm.CodeMirror.setValue(value); + +export default function(assert, library, pages, utils) { + var currentPage; + const getCurrentPage = function() { + return currentPage; + }; + const setCurrentPage = function(page) { + currentPage = page; return page; - } else { - return page.fillIn(name, value); - } -}; -var currentPage; -export default function(assert) { - return ( - yadda.localisation.English.library( - getDictionary(function(model, cb) { - switch (model) { - case 'datacenter': - case 'datacenters': - case 'dcs': - model = 'dc'; - break; - case 'services': - model = 'service'; - break; - case 'nodes': - model = 'node'; - break; - case 'kvs': - model = 'kv'; - break; - case 'acls': - model = 'acl'; - break; - case 'sessions': - model = 'session'; - break; - case 'intentions': - model = 'intention'; - break; - } - cb(null, model); - }, yadda) - ) - // doubles - .given(['an external edit results in $number $model model[s]?'], function(number, model) { - return create(number, model); - }) - .given(['$number $model model[s]?'], function(number, model) { - return create(number, model); - }) - .given(['$number $model model[s]? with the value "$value"'], function(number, model, value) { - return create(number, model, value); - }) - .given( - ['$number $model model[s]? from yaml\n$yaml', '$number $model model[s]? from json\n$json'], - function(number, model, data) { - return create(number, model, data); - } - ) - .given(['settings from yaml\n$yaml'], function(data) { - return Object.keys(data).forEach(function(key) { - window.localStorage[key] = JSON.stringify(data[key]); - }); - }) - .given('a network latency of $number', function(number) { - api.server.setCookie('CONSUL_LATENCY', number); - }) - .given(["I'm using a legacy token"], function() { - window.localStorage['consul:token'] = JSON.stringify({ AccessorID: null, SecretID: 'id' }); - }) - // TODO: Abstract this away from HTTP - .given(['the url "$url" responds with a $status status'], function(url, status) { - return api.server.respondWithStatus(url.split('?')[0], parseInt(status)); - }) - .given(['the url "$url" responds with from yaml\n$yaml'], function(url, data) { - api.server.respondWith(url.split('?')[0], data); - }) - // interactions - .when('I visit the $name page', function(name) { - currentPage = pages[name]; - return currentPage.visit(); - }) - .when('I visit the $name page for the "$id" $model', function(name, id, model) { - currentPage = pages[name]; - return currentPage.visit({ - [model]: id, - }); - }) - .when( - ['I visit the $name page for yaml\n$yaml', 'I visit the $name page for json\n$json'], - function(name, data) { - currentPage = pages[name]; - // TODO: Consider putting an assertion here for testing the current url - // do I absolutely definitely need that all the time? - return pages[name].visit(data); - } - ) - .when('I click "$selector"', function(selector) { - return click(selector); - }) - // TODO: Probably nicer to think of better vocab than having the 'without " rule' - .when('I click (?!")$property(?!")', function(property) { - try { - return currentPage[property](); - } catch (e) { - console.error(e); - throw new Error(`The '${property}' property on the page object doesn't exist`); - } - }) - .when('I click $prop on the $component', function(prop, component) { - // Collection - var obj; - if (typeof currentPage[component].objectAt === 'function') { - obj = currentPage[component].objectAt(0); - } else { - obj = currentPage[component]; - } - const func = obj[prop].bind(obj); - try { - return func(); - } catch (e) { - throw new Error( - `The '${prop}' property on the '${component}' page object doesn't exist.\n${e.message}` - ); - } - }) - .when('I submit', function(selector) { - return currentPage.submit(); - }) - .then('I fill in "$name" with "$value"', function(name, value) { - return currentPage.fillIn(name, value); - }) - .then(['I fill in with yaml\n$yaml', 'I fill in with json\n$json'], function(data) { - return Object.keys(data).reduce(function(prev, item, i, arr) { - return fillInElement(prev, item, data[item]); - }, currentPage); - }) - .then( - ['I fill in the $form form with yaml\n$yaml', 'I fill in the $form with json\n$json'], - function(form, data) { - return Object.keys(data).reduce(function(prev, item, i, arr) { - const name = `${form}[${item}]`; - return fillInElement(prev, name, data[item]); - }, currentPage); - } - ) - .then(['I type "$text" into "$selector"'], function(text, selector) { - return fillIn(selector, text); - }) - .then(['I type with yaml\n$yaml'], function(data) { - const keys = Object.keys(data); - return keys - .reduce(function(prev, item, i, arr) { - return prev.fillIn(item, data[item]); - }, currentPage) - .then(function() { - return Promise.all( - keys.map(function(item) { - return triggerKeyEvent(`[name="${item}"]`, 'keyup', 83); // TODO: This is 's', be more generic - }) - ); - }); - }) - // debugging helpers - .then('print the current url', function(url) { - console.log(currentURL()); - return Promise.resolve(); - }) - .then('log the "$text"', function(text) { - console.log(text); - return Promise.resolve(); - }) - .then('pause for $milliseconds', function(milliseconds) { - return new Promise(function(resolve) { - setTimeout(resolve, milliseconds); - }); - }) - // assertions - .then('pause until I see $number $model model[s]?', function(num, model) { - return new Promise(function(resolve) { - let count = 0; - const interval = setInterval(function() { - if (++count >= 50) { - clearInterval(interval); - assert.ok(false); - resolve(); - } - const len = currentPage[`${pluralize(model)}`].filter(function(item) { - return item.isVisible; - }).length; - if (len === num) { - clearInterval(interval); - assert.equal(len, num, `Expected ${num} ${model}s, saw ${len}`); - resolve(); - } - }, 100); - }); - }) - .then('a $method request is made to "$url" with the body from yaml\n$yaml', function( - method, - url, - data - ) { - const request = api.server.history[api.server.history.length - 2]; - assert.equal( - request.method, - method, - `Expected the request method to be ${method}, was ${request.method}` - ); - assert.equal(request.url, url, `Expected the request url to be ${url}, was ${request.url}`); - const body = JSON.parse(request.requestBody); - Object.keys(data).forEach(function(key, i, arr) { - assert.deepEqual( - body[key], - data[key], - `Expected the payload to contain ${key} equaling ${data[key]}, ${key} was ${body[key]}` - ); - }); - }) - // TODO: This one can replace the above one, it covers more use cases - // also DRY it out a bit - .then('a $method request is made to "$url" from yaml\n$yaml', function(method, url, yaml) { - const request = api.server.history[api.server.history.length - 2]; - assert.equal( - request.method, - method, - `Expected the request method to be ${method}, was ${request.method}` - ); - assert.equal(request.url, url, `Expected the request url to be ${url}, was ${request.url}`); - let data = yaml.body || {}; - const body = JSON.parse(request.requestBody); - Object.keys(data).forEach(function(key, i, arr) { - assert.equal( - body[key], - data[key], - `Expected the payload to contain ${key} to equal ${body[key]}, ${key} was ${data[key]}` - ); - }); - data = yaml.headers || {}; - const headers = request.requestHeaders; - Object.keys(data).forEach(function(key, i, arr) { - assert.equal( - headers[key], - data[key], - `Expected the payload to contain ${key} to equal ${headers[key]}, ${key} was ${ - data[key] - }` - ); - }); - }) - .then('a $method request is made to "$url" with the body "$body"', function( - method, - url, - data - ) { - const request = api.server.history[api.server.history.length - 2]; - assert.equal( - request.method, - method, - `Expected the request method to be ${method}, was ${request.method}` - ); - assert.equal(request.url, url, `Expected the request url to be ${url}, was ${request.url}`); - const body = request.requestBody; - assert.equal(body, data, `Expected the request body to be ${data}, was ${body}`); - }) - .then('a $method request is made to "$url" with no body', function(method, url) { - const request = api.server.history[api.server.history.length - 2]; - assert.equal( - request.method, - method, - `Expected the request method to be ${method}, was ${request.method}` - ); - assert.equal(request.url, url, `Expected the request url to be ${url}, was ${request.url}`); - const body = request.requestBody; - assert.equal(body, null, `Expected the request body to be null, was ${body}`); - }) + }; - .then('a $method request is made to "$url"', function(method, url) { - const request = api.server.history[api.server.history.length - 2]; - assert.equal( - request.method, - method, - `Expected the request method to be ${method}, was ${request.method}` - ); - assert.equal(request.url, url, `Expected the request url to be ${url}, was ${request.url}`); - }) - .then('the last $method request was made to "$url"', function(method, url) { - const request = lastRequest(method); - assert.equal( - request.method, - method, - `Expected the request method to be ${method}, was ${request.method}` - ); - assert.equal(request.url, url, `Expected the request url to be ${url}, was ${request.url}`); - }) - .then('the last $method request was made to "$url" with the body from yaml\n$yaml', function( - method, - url, - data - ) { - const request = lastRequest(method); - assert.ok(request, `Expected a ${method} request`); - assert.equal( - request.method, - method, - `Expected the request method to be ${method}, was ${request.method}` - ); - assert.equal(request.url, url, `Expected the request url to be ${url}, was ${request.url}`); - const body = JSON.parse(request.requestBody); - Object.keys(data).forEach(function(key, i, arr) { - assert.deepEqual( - body[key], - data[key], - `Expected the payload to contain ${key} equaling ${data[key]}, ${key} was ${body[key]}` - ); - }); - }) - .then('the last $method requests were like yaml\n$yaml', function(method, data) { - const requests = api.server.history.reverse().filter(function(item) { - return item.method === method; - }); - data.reverse().forEach(function(item, i, arr) { - assert.equal( - requests[i].url, - item, - `Expected the request url to be ${item}, was ${requests[i].url}` - ); - }); - }) - .then('the url should be $url', function(url) { - // TODO: nice! $url should be wrapped in "" - if (url === "''") { - url = ''; - } - const current = currentURL() || ''; - assert.equal(current, url, `Expected the url to be ${url} was ${current}`); - }) - .then(['I see $num $model', 'I see $num $model model', 'I see $num $model models'], function( - num, - model - ) { - const len = currentPage[pluralize(model)].filter(function(item) { - return item.isVisible; - }).length; + models(library, utils.create); + http(library, utils.respondWith, utils.set); + visit(library, pages, setCurrentPage); + click(library, utils.click, getCurrentPage); + form(library, utils.fillIn, utils.triggerKeyEvent, getCurrentPage); + debug(library, assert, utils.currentURL); + assertHttp(library, assert, utils.lastNthRequest); + assertModel(library, assert, getCurrentPage, utils.pluralize); + assertPage(library, assert, getCurrentPage); + assertDom(library, assert, utils.find, utils.currentURL); - assert.equal(len, num, `Expected ${num} ${pluralize(model)}, saw ${len}`); - }) - // TODO: I${ dont } see - .then([`I see $num $model model[s]? with the $property "$value"`], function( - // negate, - num, - model, - property, - value - ) { - const len = currentPage[pluralize(model)].filter(function(item) { - return item.isVisible && item[property] == value; - }).length; - assert.equal( - len, - num, - `Expected ${num} ${pluralize(model)} with ${property} set to "${value}", saw ${len}` - ); - }) - // TODO: Make this accept a 'contains' word so you can search for text containing also - .then('I have settings like yaml\n$yaml', function(data) { - // TODO: Inject this - const settings = window.localStorage; - // TODO: this and the setup should probably use consul: - // as we are talking about 'settings' here not localStorage - // so the prefix should be hidden - Object.keys(data).forEach(function(prop) { - const actual = settings.getItem(prop); - const expected = data[prop]; - assert.strictEqual(actual, expected, `Expected settings to be ${expected} was ${actual}`); - }); - }) - .then('I see $property on the $component like yaml\n$yaml', function( - property, - component, - yaml - ) { - const _component = currentPage[component]; - const iterator = new Array(_component.length).fill(true); - // this will catch if we get aren't managing to select a component - assert.ok(iterator.length > 0); - iterator.forEach(function(item, i, arr) { - const actual = - typeof _component.objectAt(i)[property] === 'undefined' - ? null - : _component.objectAt(i)[property]; - - // anything coming from the DOM is going to be text/strings - // if the yaml has numbers, cast them to strings - // TODO: This would get problematic for deeper objects - // will have to look to do this recursively - const expected = typeof yaml[i] === 'number' ? yaml[i].toString() : yaml[i]; - - assert.deepEqual( - actual, - expected, - `Expected to see ${property} on ${component}[${i}] as ${JSON.stringify( - expected - )}, was ${JSON.stringify(actual)}` - ); - }); - }) - .then(['I see $property on the $component'], function(property, component) { - // TODO: Time to work on repetition - // Collection - var obj; - if (typeof currentPage[component].objectAt === 'function') { - obj = currentPage[component].objectAt(0); - } else { - obj = currentPage[component]; - } - let _component; - if (typeof obj === 'function') { - const func = obj[property].bind(obj); - try { - _component = func(); - } catch (e) { - console.error(e); - throw new Error( - `The '${property}' property on the '${component}' page object doesn't exist` - ); - } - } else { - _component = obj; - } - assert.ok(_component[property], `Expected to see ${property} on ${component}`); - }) - .then(["I don't see $property on the $component"], function(property, component) { - // Collection - var obj; - if (typeof currentPage[component].objectAt === 'function') { - obj = currentPage[component].objectAt(0); - } else { - obj = currentPage[component]; - } - const func = obj[property].bind(obj); - assert.throws( - function() { - func(); - }, - function(e) { - return e.toString().indexOf('Element not found') !== -1; - }, - `Expected to not see ${property} on ${component}` - ); - }) - .then(["I don't see $property"], function(property) { - assert.throws( - function() { - currentPage[property](); - }, - function(e) { - return e.toString().indexOf('Element not found') !== -1; - }, - `Expected to not see ${property}` - ); - }) - .then(['I see $property'], function(property) { - assert.ok(currentPage[property], `Expected to see ${property}`); - }) - .then(['I see $property like "$value"'], function(property, value) { - assert.equal( - currentPage[property], - value, - `Expected to see ${property}, was ${currentPage[property]}` - ); - }) - .then(['I see the text "$text" in "$selector"'], function(text, selector) { - assert.ok( - find(selector).textContent.indexOf(text) !== -1, - `Expected to see "${text}" in "${selector}"` - ); - }) - // TODO: Think of better language - // TODO: These should be mergeable - .then(['"$selector" has the "$class" class'], function(selector, cls) { - // because `find` doesn't work, guessing its sandboxed to ember's container - assert.ok( - document.querySelector(selector).classList.contains(cls), - `Expected [class] to contain ${cls} on ${selector}` - ); - }) - .then(['"$selector" doesn\'t have the "$class" class'], function(selector, cls) { - assert.ok( - !document.querySelector(selector).classList.contains(cls), - `Expected [class] not to contain ${cls} on ${selector}` - ); - }) - .then('ok', function() { - assert.ok(true); - }) - ); + return library.given(["I'm using a legacy token"], function(number, model, data) { + window.localStorage['consul:token'] = JSON.stringify({ AccessorID: null, SecretID: 'id' }); + }); } diff --git a/ui-v2/tests/steps/assertions/dom.js b/ui-v2/tests/steps/assertions/dom.js new file mode 100644 index 0000000000..ec26e165d2 --- /dev/null +++ b/ui-v2/tests/steps/assertions/dom.js @@ -0,0 +1,45 @@ +export default function(scenario, assert, find, currentURL) { + scenario + .then(['I see the text "$text" in "$selector"'], function(text, selector) { + assert.ok( + find(selector).textContent.indexOf(text) !== -1, + `Expected to see "${text}" in "${selector}"` + ); + }) + // TODO: Think of better language + // TODO: These should be mergeable + .then(['"$selector" has the "$class" class'], function(selector, cls) { + // because `find` doesn't work, guessing its sandboxed to ember's container + assert.ok( + document.querySelector(selector).classList.contains(cls), + `Expected [class] to contain ${cls} on ${selector}` + ); + }) + .then(['"$selector" doesn\'t have the "$class" class'], function(selector, cls) { + assert.ok( + !document.querySelector(selector).classList.contains(cls), + `Expected [class] not to contain ${cls} on ${selector}` + ); + }) + // TODO: Make this accept a 'contains' word so you can search for text containing also + .then('I have settings like yaml\n$yaml', function(data) { + // TODO: Inject this + const settings = window.localStorage; + // TODO: this and the setup should probably use consul: + // as we are talking about 'settings' here not localStorage + // so the prefix should be hidden + Object.keys(data).forEach(function(prop) { + const actual = settings.getItem(prop); + const expected = data[prop]; + assert.strictEqual(actual, expected, `Expected settings to be ${expected} was ${actual}`); + }); + }) + .then('the url should be $url', function(url) { + // TODO: nice! $url should be wrapped in "" + if (url === "''") { + url = ''; + } + const current = currentURL() || ''; + assert.equal(current, url, `Expected the url to be ${url} was ${current}`); + }); +} diff --git a/ui-v2/tests/steps/assertions/http.js b/ui-v2/tests/steps/assertions/http.js new file mode 100644 index 0000000000..b6dc7042f5 --- /dev/null +++ b/ui-v2/tests/steps/assertions/http.js @@ -0,0 +1,111 @@ +export default function(scenario, assert, lastNthRequest) { + // lastNthRequest should return a + // { + // method: '', + // requestBody: '', + // requestHeaders: '' + // } + const assertRequest = function(request, method, url) { + assert.equal( + request.method, + method, + `Expected the request method to be ${method}, was ${request.method}` + ); + assert.equal(request.url, url, `Expected the request url to be ${url}, was ${request.url}`); + }; + scenario + .then('a $method request is made to "$url" with the body from yaml\n$yaml', function( + method, + url, + data + ) { + const request = lastNthRequest(1); + assertRequest(request, method, url); + const body = JSON.parse(request.requestBody); + Object.keys(data).forEach(function(key, i, arr) { + assert.deepEqual( + body[key], + data[key], + `Expected the payload to contain ${key} equaling ${data[key]}, ${key} was ${body[key]}` + ); + }); + }) + // TODO: This one can replace the above one, it covers more use cases + // also DRY it out a bit + .then('a $method request is made to "$url" from yaml\n$yaml', function(method, url, yaml) { + const request = lastNthRequest(1); + assertRequest(request, method, url); + let data = yaml.body || {}; + const body = JSON.parse(request.requestBody); + Object.keys(data).forEach(function(key, i, arr) { + assert.equal( + body[key], + data[key], + `Expected the payload to contain ${key} to equal ${body[key]}, ${key} was ${data[key]}` + ); + }); + data = yaml.headers || {}; + const headers = request.requestHeaders; + Object.keys(data).forEach(function(key, i, arr) { + assert.equal( + headers[key], + data[key], + `Expected the payload to contain ${key} to equal ${headers[key]}, ${key} was ${data[key]}` + ); + }); + }) + .then('a $method request is made to "$url" with the body "$body"', function(method, url, data) { + const request = lastNthRequest(1); + assertRequest(request, method, url); + assert.equal( + request.requestBody, + data, + `Expected the request body to be ${data}, was ${request.requestBody}` + ); + }) + .then('a $method request is made to "$url" with no body', function(method, url) { + const request = lastNthRequest(1); + assertRequest(request, method, url); + assert.equal( + request.requestBody, + null, + `Expected the request body to be null, was ${request.requestBody}` + ); + }) + + .then('a $method request is made to "$url"', function(method, url) { + const request = lastNthRequest(1); + assertRequest(request, method, url); + }) + .then('the last $method request was made to "$url"', function(method, url) { + const request = lastNthRequest(0, method); + assertRequest(request, method, url); + }) + .then('the last $method request was made to "$url" with the body from yaml\n$yaml', function( + method, + url, + data + ) { + const request = lastNthRequest(0, method); + const body = JSON.parse(request.requestBody); + assert.ok(request, `Expected a ${method} request`); + assertRequest(request, method, url); + Object.keys(data).forEach(function(key, i, arr) { + assert.deepEqual( + body[key], + data[key], + `Expected the payload to contain ${key} equaling ${data[key]}, ${key} was ${body[key]}` + ); + }); + }) + .then('the last $method requests were like yaml\n$yaml', function(method, data) { + const requests = lastNthRequest(null, method); + data.reverse().forEach(function(item, i, arr) { + assert.equal( + requests[i].url, + item, + `Expected the request url to be ${item}, was ${requests[i].url}` + ); + }); + }); +} diff --git a/ui-v2/tests/steps/assertions/model.js b/ui-v2/tests/steps/assertions/model.js new file mode 100644 index 0000000000..82e11f6470 --- /dev/null +++ b/ui-v2/tests/steps/assertions/model.js @@ -0,0 +1,50 @@ +export default function(scenario, assert, currentPage, pluralize) { + scenario + .then('pause until I see $number $model model[s]?', function(num, model) { + return new Promise(function(resolve) { + let count = 0; + const interval = setInterval(function() { + if (++count >= 50) { + clearInterval(interval); + assert.ok(false); + resolve(); + } + const len = currentPage()[pluralize(model)].filter(function(item) { + return item.isVisible; + }).length; + if (len === num) { + clearInterval(interval); + assert.equal(len, num, `Expected ${num} ${model}s, saw ${len}`); + resolve(); + } + }, 100); + }); + }) + .then(['I see $num $model', 'I see $num $model model', 'I see $num $model models'], function( + num, + model + ) { + const len = currentPage()[pluralize(model)].filter(function(item) { + return item.isVisible; + }).length; + + assert.equal(len, num, `Expected ${num} ${pluralize(model)}, saw ${len}`); + }) + // TODO: I${ dont } see + .then([`I see $num $model model[s]? with the $property "$value"`], function( + // negate, + num, + model, + property, + value + ) { + const len = currentPage()[pluralize(model)].filter(function(item) { + return item.isVisible && item[property] == value; + }).length; + assert.equal( + len, + num, + `Expected ${num} ${pluralize(model)} with ${property} set to "${value}", saw ${len}` + ); + }); +} diff --git a/ui-v2/tests/steps/assertions/page.js b/ui-v2/tests/steps/assertions/page.js new file mode 100644 index 0000000000..043a6f4bb9 --- /dev/null +++ b/ui-v2/tests/steps/assertions/page.js @@ -0,0 +1,99 @@ +/* eslint no-console: "off" */ +export default function(scenario, assert, currentPage) { + scenario + .then('I see $property on the $component like yaml\n$yaml', function( + property, + component, + yaml + ) { + const _component = currentPage()[component]; + const iterator = new Array(_component.length).fill(true); + // this will catch if we get aren't managing to select a component + assert.ok(iterator.length > 0); + iterator.forEach(function(item, i, arr) { + const actual = + typeof _component.objectAt(i)[property] === 'undefined' + ? null + : _component.objectAt(i)[property]; + + // anything coming from the DOM is going to be text/strings + // if the yaml has numbers, cast them to strings + // TODO: This would get problematic for deeper objects + // will have to look to do this recursively + const expected = typeof yaml[i] === 'number' ? yaml[i].toString() : yaml[i]; + + assert.deepEqual( + actual, + expected, + `Expected to see ${property} on ${component}[${i}] as ${JSON.stringify( + expected + )}, was ${JSON.stringify(actual)}` + ); + }); + }) + .then(['I see $property on the $component'], function(property, component) { + // TODO: Time to work on repetition + // Collection + var obj; + if (typeof currentPage()[component].objectAt === 'function') { + obj = currentPage()[component].objectAt(0); + } else { + obj = currentPage()[component]; + } + let _component; + if (typeof obj === 'function') { + const func = obj[property].bind(obj); + try { + _component = func(); + } catch (e) { + console.error(e); + throw new Error( + `The '${property}' property on the '${component}' page object doesn't exist` + ); + } + } else { + _component = obj; + } + assert.ok(_component[property], `Expected to see ${property} on ${component}`); + }) + .then(["I don't see $property on the $component"], function(property, component) { + // Collection + var obj; + if (typeof currentPage()[component].objectAt === 'function') { + obj = currentPage()[component].objectAt(0); + } else { + obj = currentPage()[component]; + } + const func = obj[property].bind(obj); + assert.throws( + function() { + func(); + }, + function(e) { + return e.toString().indexOf('Element not found') !== -1; + }, + `Expected to not see ${property} on ${component}` + ); + }) + .then(["I don't see $property"], function(property) { + assert.throws( + function() { + currentPage()[property](); + }, + function(e) { + return e.toString().indexOf('Element not found') !== -1; + }, + `Expected to not see ${property}` + ); + }) + .then(['I see $property'], function(property) { + assert.ok(currentPage()[property], `Expected to see ${property}`); + }) + .then(['I see $property like "$value"'], function(property, value) { + assert.equal( + currentPage()[property], + value, + `Expected to see ${property}, was ${currentPage()[property]}` + ); + }); +} diff --git a/ui-v2/tests/steps/debug/index.js b/ui-v2/tests/steps/debug/index.js new file mode 100644 index 0000000000..79622ad922 --- /dev/null +++ b/ui-v2/tests/steps/debug/index.js @@ -0,0 +1,20 @@ +/* eslint no-console: "off" */ +export default function(scenario, assert, currentURL) { + scenario + .then('print the current url', function(url) { + console.log(currentURL()); + return Promise.resolve(); + }) + .then('log the "$text"', function(text) { + console.log(text); + return Promise.resolve(); + }) + .then('pause for $milliseconds', function(milliseconds) { + return new Promise(function(resolve) { + setTimeout(resolve, milliseconds); + }); + }) + .then('ok', function() { + assert.ok(true); + }); +} diff --git a/ui-v2/tests/steps/doubles/http.js b/ui-v2/tests/steps/doubles/http.js new file mode 100644 index 0000000000..67a725b535 --- /dev/null +++ b/ui-v2/tests/steps/doubles/http.js @@ -0,0 +1,15 @@ +export default function(scenario, respondWith, set) { + // respondWith should set the url to return a certain response shape + scenario + .given(['the url "$url" responds with a $status status'], function(url, status) { + respondWith(url, { + status: parseInt(status), + }); + }) + .given(['the url "$url" responds with from yaml\n$yaml'], function(url, data) { + respondWith(url, data); + }) + .given('a network latency of $number', function(number) { + set('CONSUL_LATENCY', number); + }); +} diff --git a/ui-v2/tests/steps/doubles/model.js b/ui-v2/tests/steps/doubles/model.js new file mode 100644 index 0000000000..f02bdbf50a --- /dev/null +++ b/ui-v2/tests/steps/doubles/model.js @@ -0,0 +1,22 @@ +export default function(scenario, create) { + scenario + .given(['an external edit results in $number $model model[s]?'], function(number, model) { + return create(number, model); + }) + .given(['$number $model model[s]?'], function(number, model) { + return create(number, model); + }) + .given(['$number $model model[s]? with the value "$value"'], function(number, model, value) { + return create(number, model, value); + }) + .given( + ['$number $model model[s]? from yaml\n$yaml', '$number $model model[s]? from json\n$json'], + function(number, model, data) { + return create(number, model, data); + } + ).given(['settings from yaml\n$yaml'], function(data) { + return Object.keys(data).forEach(function(key) { + window.localStorage[key] = JSON.stringify(data[key]); + }); + }); +} diff --git a/ui-v2/tests/steps/interactions/click.js b/ui-v2/tests/steps/interactions/click.js new file mode 100644 index 0000000000..aa71d1d31e --- /dev/null +++ b/ui-v2/tests/steps/interactions/click.js @@ -0,0 +1,33 @@ +/* eslint no-console: "off" */ +export default function(scenario, click, currentPage) { + scenario + .when('I click "$selector"', function(selector) { + return click(selector); + }) + // TODO: Probably nicer to think of better vocab than having the 'without " rule' + .when('I click (?!")$property(?!")', function(property) { + try { + return currentPage()[property](); + } catch (e) { + console.error(e); + throw new Error(`The '${property}' property on the page object doesn't exist`); + } + }) + .when('I click $prop on the $component', function(prop, component) { + // Collection + var obj; + if (typeof currentPage()[component].objectAt === 'function') { + obj = currentPage()[component].objectAt(0); + } else { + obj = currentPage()[component]; + } + const func = obj[prop].bind(obj); + try { + return func(); + } catch (e) { + throw new Error( + `The '${prop}' property on the '${component}' page object doesn't exist.\n${e.message}` + ); + } + }); +} diff --git a/ui-v2/tests/steps/interactions/form.js b/ui-v2/tests/steps/interactions/form.js new file mode 100644 index 0000000000..8ef2812f3b --- /dev/null +++ b/ui-v2/tests/steps/interactions/form.js @@ -0,0 +1,49 @@ +export default function(scenario, fillIn, triggerKeyEvent, currentPage) { + const fillInElement = function(page, name, value) { + const cm = document.querySelector(`textarea[name="${name}"] + .CodeMirror`); + if (cm) { + cm.CodeMirror.setValue(value); + return page; + } else { + return page.fillIn(name, value); + } + }; + scenario + .when('I submit', function(selector) { + return currentPage().submit(); + }) + .then('I fill in "$name" with "$value"', function(name, value) { + return currentPage().fillIn(name, value); + }) + .then(['I fill in with yaml\n$yaml', 'I fill in with json\n$json'], function(data) { + return Object.keys(data).reduce(function(prev, item, i, arr) { + return fillInElement(prev, item, data[item]); + }, currentPage()); + }) + .then( + ['I fill in the $form form with yaml\n$yaml', 'I fill in the $form with json\n$json'], + function(form, data) { + return Object.keys(data).reduce(function(prev, item, i, arr) { + const name = `${form}[${item}]`; + return fillInElement(prev, name, data[item]); + }, currentPage()); + } + ) + .then(['I type "$text" into "$selector"'], function(text, selector) { + return fillIn(selector, text); + }) + .then(['I type with yaml\n$yaml'], function(data) { + const keys = Object.keys(data); + return keys + .reduce(function(prev, item, i, arr) { + return prev.fillIn(item, data[item]); + }, currentPage()) + .then(function() { + return Promise.all( + keys.map(function(item) { + return triggerKeyEvent(`[name="${item}"]`, 'keyup', 83); // TODO: This is 's', be more generic + }) + ); + }); + }); +} diff --git a/ui-v2/tests/steps/interactions/visit.js b/ui-v2/tests/steps/interactions/visit.js new file mode 100644 index 0000000000..6a2ff76dc1 --- /dev/null +++ b/ui-v2/tests/steps/interactions/visit.js @@ -0,0 +1,19 @@ +export default function(scenario, pages, set) { + scenario + .when('I visit the $name page', function(name) { + return set(pages[name]).visit(); + }) + .when('I visit the $name page for the "$id" $model', function(name, id, model) { + return set(pages[name]).visit({ + [model]: id, + }); + }) + .when( + ['I visit the $name page for yaml\n$yaml', 'I visit the $name page for json\n$json'], + function(name, data) { + // TODO: Consider putting an assertion here for testing the current url + // do I absolutely definitely need that all the time? + return set(pages[name]).visit(data); + } + ); +} diff --git a/ui-v2/yarn.lock b/ui-v2/yarn.lock index 819d093918..705593f057 100644 --- a/ui-v2/yarn.lock +++ b/ui-v2/yarn.lock @@ -27,6 +27,26 @@ semver "^5.4.1" source-map "^0.5.0" +"@babel/core@^7.2.2": + version "7.3.3" + resolved "https://registry.yarnpkg.com/@babel/core/-/core-7.3.3.tgz#d090d157b7c5060d05a05acaebc048bd2b037947" + integrity sha512-w445QGI2qd0E0GlSnq6huRZWPMmQGCp5gd5ZWS4hagn0EiwzxD5QMFkpchyusAyVC1n27OKXzQ0/88aVU9n4xQ== + dependencies: + "@babel/code-frame" "^7.0.0" + "@babel/generator" "^7.3.3" + "@babel/helpers" "^7.2.0" + "@babel/parser" "^7.3.3" + "@babel/template" "^7.2.2" + "@babel/traverse" "^7.2.2" + "@babel/types" "^7.3.3" + convert-source-map "^1.1.0" + debug "^4.1.0" + json5 "^2.1.0" + lodash "^4.17.11" + resolve "^1.3.2" + semver "^5.4.1" + source-map "^0.5.0" + "@babel/generator@^7.0.0", "@babel/generator@^7.1.2": version "7.1.2" resolved "https://registry.yarnpkg.com/@babel/generator/-/generator-7.1.2.tgz#fde75c072575ce7abbd97322e8fef5bae67e4630" @@ -37,6 +57,17 @@ source-map "^0.5.0" trim-right "^1.0.1" +"@babel/generator@^7.2.2", "@babel/generator@^7.3.3": + version "7.3.3" + resolved "https://registry.yarnpkg.com/@babel/generator/-/generator-7.3.3.tgz#185962ade59a52e00ca2bdfcfd1d58e528d4e39e" + integrity sha512-aEADYwRRZjJyMnKN7llGIlircxTCofm3dtV5pmY6ob18MSIuipHpA2yZWkPlycwu5HJcx/pADS3zssd8eY7/6A== + dependencies: + "@babel/types" "^7.3.3" + jsesc "^2.5.1" + lodash "^4.17.11" + source-map "^0.5.0" + trim-right "^1.0.1" + "@babel/helper-annotate-as-pure@^7.0.0": version "7.0.0" resolved "https://registry.yarnpkg.com/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.0.0.tgz#323d39dd0b50e10c7c06ca7d7638e6864d8c5c32" @@ -181,6 +212,15 @@ "@babel/traverse" "^7.1.0" "@babel/types" "^7.1.2" +"@babel/helpers@^7.2.0": + version "7.3.1" + resolved "https://registry.yarnpkg.com/@babel/helpers/-/helpers-7.3.1.tgz#949eec9ea4b45d3210feb7dc1c22db664c9e44b9" + integrity sha512-Q82R3jKsVpUV99mgX50gOPCWwco9Ec5Iln/8Vyu4osNIOQgSrd9RFrQeUvmvddFNoLwMyOUWU+5ckioEKpDoGA== + dependencies: + "@babel/template" "^7.1.2" + "@babel/traverse" "^7.1.5" + "@babel/types" "^7.3.0" + "@babel/highlight@^7.0.0": version "7.0.0" resolved "https://registry.yarnpkg.com/@babel/highlight/-/highlight-7.0.0.tgz#f710c38c8d458e6dd9a201afb637fcb781ce99e4" @@ -193,6 +233,11 @@ version "7.1.2" resolved "https://registry.yarnpkg.com/@babel/parser/-/parser-7.1.2.tgz#85c5c47af6d244fab77bce6b9bd830e38c978409" +"@babel/parser@^7.2.2", "@babel/parser@^7.2.3", "@babel/parser@^7.3.3": + version "7.3.3" + resolved "https://registry.yarnpkg.com/@babel/parser/-/parser-7.3.3.tgz#092d450db02bdb6ccb1ca8ffd47d8774a91aef87" + integrity sha512-xsH1CJoln2r74hR+y7cg2B5JCPaTh+Hd+EbBRk9nWGSNspuo6krjhX0Om6RnRQuIvFq8wVXCLKH3kwKDYhanSg== + "@babel/plugin-proposal-async-generator-functions@^7.1.0": version "7.1.0" resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-async-generator-functions/-/plugin-proposal-async-generator-functions-7.1.0.tgz#41c1a702e10081456e23a7b74d891922dd1bb6ce" @@ -503,6 +548,15 @@ "@babel/parser" "^7.1.2" "@babel/types" "^7.1.2" +"@babel/template@^7.2.2": + version "7.2.2" + resolved "https://registry.yarnpkg.com/@babel/template/-/template-7.2.2.tgz#005b3fdf0ed96e88041330379e0da9a708eb2907" + integrity sha512-zRL0IMM02AUDwghf5LMSSDEz7sBCO2YnNmpg3uWTZj/v1rcG2BmQUvaGU8GhU8BvfMh1k2KIAYZ7Ji9KXPUg7g== + dependencies: + "@babel/code-frame" "^7.0.0" + "@babel/parser" "^7.2.2" + "@babel/types" "^7.2.2" + "@babel/traverse@^7.0.0", "@babel/traverse@^7.1.0": version "7.1.0" resolved "https://registry.yarnpkg.com/@babel/traverse/-/traverse-7.1.0.tgz#503ec6669387efd182c3888c4eec07bcc45d91b2" @@ -517,6 +571,21 @@ globals "^11.1.0" lodash "^4.17.10" +"@babel/traverse@^7.1.5", "@babel/traverse@^7.2.2": + version "7.2.3" + resolved "https://registry.yarnpkg.com/@babel/traverse/-/traverse-7.2.3.tgz#7ff50cefa9c7c0bd2d81231fdac122f3957748d8" + integrity sha512-Z31oUD/fJvEWVR0lNZtfgvVt512ForCTNKYcJBGbPb1QZfve4WGH8Wsy7+Mev33/45fhP/hwQtvgusNdcCMgSw== + dependencies: + "@babel/code-frame" "^7.0.0" + "@babel/generator" "^7.2.2" + "@babel/helper-function-name" "^7.1.0" + "@babel/helper-split-export-declaration" "^7.0.0" + "@babel/parser" "^7.2.3" + "@babel/types" "^7.2.2" + debug "^4.1.0" + globals "^11.1.0" + lodash "^4.17.10" + "@babel/types@^7.0.0", "@babel/types@^7.1.2": version "7.1.2" resolved "https://registry.yarnpkg.com/@babel/types/-/types-7.1.2.tgz#183e7952cf6691628afdc2e2b90d03240bac80c0" @@ -525,6 +594,15 @@ lodash "^4.17.10" to-fast-properties "^2.0.0" +"@babel/types@^7.2.2", "@babel/types@^7.3.0", "@babel/types@^7.3.3": + version "7.3.3" + resolved "https://registry.yarnpkg.com/@babel/types/-/types-7.3.3.tgz#6c44d1cdac2a7625b624216657d5bc6c107ab436" + integrity sha512-2tACZ80Wg09UnPg5uGAOUvvInaqLk3l/IAhQzlxLQOIXacr6bMsra5SH6AWw/hIDRCSbCdHP2KzSOD+cT7TzMQ== + dependencies: + esutils "^2.0.2" + lodash "^4.17.11" + to-fast-properties "^2.0.0" + "@ember/ordered-set@^1.0.0": version "1.0.0" resolved "https://registry.yarnpkg.com/@ember/ordered-set/-/ordered-set-1.0.0.tgz#cf9ab5fd7510bcad370370ebcded705f6d1c542b" @@ -1231,6 +1309,7 @@ babel-code-frame@^6.22.0, babel-code-frame@^6.26.0: babel-core@^6.14.0, babel-core@^6.26.0, babel-core@^6.26.3: version "6.26.3" resolved "https://registry.yarnpkg.com/babel-core/-/babel-core-6.26.3.tgz#b2e2f09e342d0f0c88e2f02e067794125e75c207" + integrity sha512-6jyFLuDmeidKmUEb3NM+/yawG0M2bDZ9Z1qbZP59cyHLz8kYGKYwpJP0UwUKKUiTRNvxfLesJnTedqczP7cTDA== dependencies: babel-code-frame "^6.26.0" babel-generator "^6.26.0" @@ -2821,6 +2900,15 @@ chalk@^2.4.1: escape-string-regexp "^1.0.5" supports-color "^5.3.0" +chalk@^2.4.2: + version "2.4.2" + resolved "https://registry.yarnpkg.com/chalk/-/chalk-2.4.2.tgz#cd42541677a54333cf541a49108c1432b44c9424" + integrity sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ== + dependencies: + ansi-styles "^3.2.1" + escape-string-regexp "^1.0.5" + supports-color "^5.3.0" + chalk@~0.4.0: version "0.4.0" resolved "http://registry.npmjs.org/chalk/-/chalk-0.4.0.tgz#5199a3ddcd0c1efe23bc08c1b027b06176e0c64f" @@ -6989,6 +7077,13 @@ json5@^0.5.0, json5@^0.5.1: version "0.5.1" resolved "http://registry.npmjs.org/json5/-/json5-0.5.1.tgz#1eade7acc012034ad84e2396767ead9fa5495821" +json5@^2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/json5/-/json5-2.1.0.tgz#e7a0c62c48285c628d20a10b85c89bb807c32850" + integrity sha512-8Mh9h6xViijj36g7Dxi+Y4S6hNGV96vcJZr/SrlHh1LR/pEn/8j/+qIBbs44YKl69Lrfctp4QD+AdWLTMqEZAQ== + dependencies: + minimist "^1.2.0" + jsonfile@^2.1.0: version "2.4.0" resolved "https://registry.yarnpkg.com/jsonfile/-/jsonfile-2.4.0.tgz#3736a2b428b87bbda0cc83b53fa3d633a35c2ae8" @@ -7594,9 +7689,10 @@ lodash@^4.14.0, lodash@^4.17.4: version "4.17.10" resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.10.tgz#1b7793cf7259ea38fb3661d4d38b3260af8ae4e7" -lodash@^4.17.10, lodash@^4.17.5, lodash@~4.17.10: +lodash@^4.17.10, lodash@^4.17.11, lodash@^4.17.5, lodash@~4.17.10: version "4.17.11" resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.11.tgz#b39ea6229ef607ecd89e2c8df12536891cac9b8d" + integrity sha512-cQKh8igo5QUhZ7lg38DYWAxMvjSAKG0A8wGSVimP07SIUEK2UO+arSRKbRZWtelMtN5V0Hkwh5ryOto/SshYIg== log-symbols@^1.0.2: version "1.0.2" From 355f034822fe34bfe8b6db86eb20ccb986591280 Mon Sep 17 00:00:00 2001 From: John Cowen Date: Thu, 21 Feb 2019 13:10:53 +0000 Subject: [PATCH 27/52] UI: Service Instances (#5326) This gives more prominence to 'Service Instances' as opposed to 'Services'. It also begins to surface Connect related 'nouns' such as 'Proxies' and 'Upstreams' and begins to interconnect them giving more visibility to operators. Various smaller changes: 1. Move healthcheck-status component to healthcheck-output 2. Create a new healthcheck-status component for showing the number of checks plus its icon 3. Create a new healthcheck-info component to group multiple statuses plus a different view if there are no checks 4. Componentize tag-list --- ui-v2/app/adapters/proxy.js | 20 ++++ ui-v2/app/components/healthcheck-info.js | 4 + ui-v2/app/components/healthcheck-list.js | 36 ++++++++ ui-v2/app/components/healthcheck-output.js | 5 + ui-v2/app/components/healthcheck-status.js | 11 ++- ui-v2/app/components/tab-nav.js | 1 + ui-v2/app/components/tag-list.js | 6 ++ ui-v2/app/controllers/dc/services/instance.js | 17 ++++ ui-v2/app/controllers/dc/services/show.js | 57 ++++++------ ui-v2/app/initializers/search.js | 3 +- ui-v2/app/models/proxy.js | 12 +++ ui-v2/app/router.js | 3 + ui-v2/app/routes/dc/services/instance.js | 29 ++++++ ui-v2/app/routes/dc/services/show.js | 1 + ui-v2/app/serializers/proxy.js | 6 ++ ui-v2/app/services/dom.js | 4 +- ui-v2/app/services/repository/proxy.js | 33 +++++++ ui-v2/app/services/repository/service.js | 39 ++++++-- .../styles/components/app-view/layout.scss | 9 ++ .../app/styles/components/app-view/skin.scss | 30 ++++-- .../styles/components/breadcrumbs/skin.scss | 11 ++- .../app/styles/components/form-elements.scss | 2 +- .../styles/components/healthcheck-info.scss | 12 +++ .../index.scss | 0 .../components/healthcheck-info/layout.scss | 32 +++++++ .../components/healthcheck-info/skin.scss | 21 +++++ ...ck-status.scss => healthcheck-output.scss} | 20 ++-- .../components/healthcheck-output/index.scss | 2 + .../layout.scss | 14 +-- .../skin.scss | 20 ++-- ui-v2/app/styles/components/icons/index.scss | 7 +- ui-v2/app/styles/components/index.scss | 4 +- ui-v2/app/styles/components/pill.scss | 3 +- ui-v2/app/styles/components/table.scss | 43 ++++----- ui-v2/app/styles/components/table/layout.scss | 42 +-------- ui-v2/app/styles/components/tabs.scss | 2 +- ui-v2/app/styles/components/tabs/layout.scss | 3 + ui-v2/app/styles/components/tabs/skin.scss | 9 ++ .../styles/components/tabular-collection.scss | 27 ++++-- ui-v2/app/styles/components/tag-list.scss | 5 + .../app/styles/components/tag-list/index.scss | 2 + .../styles/components/tag-list/layout.scss | 10 ++ .../app/styles/components/tag-list/skin.scss | 0 ui-v2/app/styles/core/typography.scss | 6 +- ui-v2/app/styles/routes/dc/service/index.scss | 17 ---- ui-v2/app/styles/variables/custom-query.scss | 4 +- .../templates/components/healthcheck-info.hbs | 9 ++ .../templates/components/healthcheck-list.hbs | 5 + .../components/healthcheck-output.hbs | 25 +++++ .../components/healthcheck-status.hbs | 28 +----- ui-v2/app/templates/components/tag-list.hbs | 8 ++ .../app/templates/dc/nodes/-healthchecks.hbs | 6 +- ui-v2/app/templates/dc/nodes/-services.hbs | 2 +- .../app/templates/dc/services/-instances.hbs | 55 +++++++++++ .../app/templates/dc/services/-nodechecks.hbs | 8 ++ .../templates/dc/services/-servicechecks.hbs | 8 ++ ui-v2/app/templates/dc/services/-tags.hbs | 7 ++ .../app/templates/dc/services/-upstreams.hbs | 27 ++++++ ui-v2/app/templates/dc/services/index.hbs | 16 +--- ui-v2/app/templates/dc/services/instance.hbs | 72 +++++++++++++++ ui-v2/app/templates/dc/services/show.hbs | 91 +++++-------------- ui-v2/app/utils/computed/purify.js | 20 ++-- .../components/catalog-filter.feature | 25 ----- .../tests/acceptance/dc/services/show.feature | 16 +--- .../components/healthcheck-info-test.js | 22 +++++ .../components/healthcheck-list-test.js | 23 +++++ .../components/healthcheck-output-test.js | 34 +++++++ .../components/healthcheck-status-test.js | 18 +--- .../integration/components/tag-list-test.js | 33 +++++++ ui-v2/tests/pages/dc/services/show.js | 14 +-- ui-v2/tests/unit/adapters/proxy-test.js | 12 +++ .../controllers/dc/services/instance-test.js | 12 +++ ui-v2/tests/unit/models/proxy-test.js | 14 +++ .../unit/routes/dc/services/instance-test.js | 11 +++ ui-v2/tests/unit/serializers/proxy-test.js | 24 +++++ 75 files changed, 919 insertions(+), 370 deletions(-) create mode 100644 ui-v2/app/adapters/proxy.js create mode 100644 ui-v2/app/components/healthcheck-info.js create mode 100644 ui-v2/app/components/healthcheck-list.js create mode 100644 ui-v2/app/components/healthcheck-output.js create mode 100644 ui-v2/app/components/tag-list.js create mode 100644 ui-v2/app/controllers/dc/services/instance.js create mode 100644 ui-v2/app/models/proxy.js create mode 100644 ui-v2/app/routes/dc/services/instance.js create mode 100644 ui-v2/app/serializers/proxy.js create mode 100644 ui-v2/app/services/repository/proxy.js create mode 100644 ui-v2/app/styles/components/healthcheck-info.scss rename ui-v2/app/styles/components/{healthcheck-status => healthcheck-info}/index.scss (100%) create mode 100644 ui-v2/app/styles/components/healthcheck-info/layout.scss create mode 100644 ui-v2/app/styles/components/healthcheck-info/skin.scss rename ui-v2/app/styles/components/{healthcheck-status.scss => healthcheck-output.scss} (56%) create mode 100644 ui-v2/app/styles/components/healthcheck-output/index.scss rename ui-v2/app/styles/components/{healthcheck-status => healthcheck-output}/layout.scss (63%) rename ui-v2/app/styles/components/{healthcheck-status => healthcheck-output}/skin.scss (60%) create mode 100644 ui-v2/app/styles/components/tag-list.scss create mode 100644 ui-v2/app/styles/components/tag-list/index.scss create mode 100644 ui-v2/app/styles/components/tag-list/layout.scss create mode 100644 ui-v2/app/styles/components/tag-list/skin.scss create mode 100644 ui-v2/app/templates/components/healthcheck-info.hbs create mode 100644 ui-v2/app/templates/components/healthcheck-list.hbs create mode 100644 ui-v2/app/templates/components/healthcheck-output.hbs create mode 100644 ui-v2/app/templates/components/tag-list.hbs create mode 100644 ui-v2/app/templates/dc/services/-instances.hbs create mode 100644 ui-v2/app/templates/dc/services/-nodechecks.hbs create mode 100644 ui-v2/app/templates/dc/services/-servicechecks.hbs create mode 100644 ui-v2/app/templates/dc/services/-tags.hbs create mode 100644 ui-v2/app/templates/dc/services/-upstreams.hbs create mode 100644 ui-v2/app/templates/dc/services/instance.hbs create mode 100644 ui-v2/tests/integration/components/healthcheck-info-test.js create mode 100644 ui-v2/tests/integration/components/healthcheck-list-test.js create mode 100644 ui-v2/tests/integration/components/healthcheck-output-test.js create mode 100644 ui-v2/tests/integration/components/tag-list-test.js create mode 100644 ui-v2/tests/unit/adapters/proxy-test.js create mode 100644 ui-v2/tests/unit/controllers/dc/services/instance-test.js create mode 100644 ui-v2/tests/unit/models/proxy-test.js create mode 100644 ui-v2/tests/unit/routes/dc/services/instance-test.js create mode 100644 ui-v2/tests/unit/serializers/proxy-test.js diff --git a/ui-v2/app/adapters/proxy.js b/ui-v2/app/adapters/proxy.js new file mode 100644 index 0000000000..d4f4f41ea5 --- /dev/null +++ b/ui-v2/app/adapters/proxy.js @@ -0,0 +1,20 @@ +import Adapter from './application'; +import { PRIMARY_KEY, SLUG_KEY } from 'consul-ui/models/proxy'; +import { OK as HTTP_OK } from 'consul-ui/utils/http/status'; +export default Adapter.extend({ + urlForQuery: function(query, modelName) { + if (typeof query.id === 'undefined') { + throw new Error('You must specify an id'); + } + // https://www.consul.io/api/catalog.html#list-nodes-for-connect-capable-service + return this.appendURL('catalog/connect', [query.id], this.cleanQuery(query)); + }, + handleResponse: function(status, headers, payload, requestData) { + let response = payload; + if (status === HTTP_OK) { + const url = this.parseURL(requestData.url); + response = this.handleBatchResponse(url, response, PRIMARY_KEY, SLUG_KEY); + } + return this._super(status, headers, response, requestData); + }, +}); diff --git a/ui-v2/app/components/healthcheck-info.js b/ui-v2/app/components/healthcheck-info.js new file mode 100644 index 0000000000..abe1ccedb6 --- /dev/null +++ b/ui-v2/app/components/healthcheck-info.js @@ -0,0 +1,4 @@ +import Component from '@ember/component'; +export default Component.extend({ + tagName: '', +}); diff --git a/ui-v2/app/components/healthcheck-list.js b/ui-v2/app/components/healthcheck-list.js new file mode 100644 index 0000000000..092a1aadaf --- /dev/null +++ b/ui-v2/app/components/healthcheck-list.js @@ -0,0 +1,36 @@ +import Component from '@ember/component'; +import { get } from '@ember/object'; + +export default Component.extend({ + // TODO: Could potentially do this on attr change + actions: { + sortChecksByImportance: function(a, b) { + const statusA = get(a, 'Status'); + const statusB = get(b, 'Status'); + switch (statusA) { + case 'passing': + // a = passing + // unless b is also passing then a is less important + return statusB === 'passing' ? 0 : 1; + case 'critical': + // a = critical + // unless b is also critical then a is more important + return statusB === 'critical' ? 0 : -1; + case 'warning': + // a = warning + switch (statusB) { + // b is passing so a is more important + case 'passing': + return -1; + // b is critical so a is less important + case 'critical': + return 1; + // a and b are both warning, therefore equal + default: + return 0; + } + } + return 0; + }, + }, +}); diff --git a/ui-v2/app/components/healthcheck-output.js b/ui-v2/app/components/healthcheck-output.js new file mode 100644 index 0000000000..227501fc5c --- /dev/null +++ b/ui-v2/app/components/healthcheck-output.js @@ -0,0 +1,5 @@ +import Component from '@ember/component'; + +export default Component.extend({ + classNames: ['healthcheck-output'], +}); diff --git a/ui-v2/app/components/healthcheck-status.js b/ui-v2/app/components/healthcheck-status.js index 25a646d758..367cca6469 100644 --- a/ui-v2/app/components/healthcheck-status.js +++ b/ui-v2/app/components/healthcheck-status.js @@ -1,5 +1,12 @@ import Component from '@ember/component'; - +import { get, computed } from '@ember/object'; export default Component.extend({ - classNames: ['healthcheck-status'], + tagName: '', + count: computed('value', function() { + const value = get(this, 'value'); + if (Array.isArray(value)) { + return value.length; + } + return value; + }), }); diff --git a/ui-v2/app/components/tab-nav.js b/ui-v2/app/components/tab-nav.js index 142b50bf4d..db166df641 100644 --- a/ui-v2/app/components/tab-nav.js +++ b/ui-v2/app/components/tab-nav.js @@ -3,4 +3,5 @@ import Component from '@ember/component'; export default Component.extend({ name: 'tab', tagName: 'nav', + classNames: ['tab-nav'], }); diff --git a/ui-v2/app/components/tag-list.js b/ui-v2/app/components/tag-list.js new file mode 100644 index 0000000000..1656e4a23c --- /dev/null +++ b/ui-v2/app/components/tag-list.js @@ -0,0 +1,6 @@ +import Component from '@ember/component'; + +export default Component.extend({ + tagName: 'dl', + classNames: ['tag-list'], +}); diff --git a/ui-v2/app/controllers/dc/services/instance.js b/ui-v2/app/controllers/dc/services/instance.js new file mode 100644 index 0000000000..a8934de52d --- /dev/null +++ b/ui-v2/app/controllers/dc/services/instance.js @@ -0,0 +1,17 @@ +import Controller from '@ember/controller'; +import { set } from '@ember/object'; + +export default Controller.extend({ + setProperties: function() { + this._super(...arguments); + // This method is called immediately after `Route::setupController`, and done here rather than there + // as this is a variable used purely for view level things, if the view was different we might not + // need this variable + set(this, 'selectedTab', 'service-checks'); + }, + actions: { + change: function(e) { + set(this, 'selectedTab', e.target.value); + }, + }, +}); diff --git a/ui-v2/app/controllers/dc/services/show.js b/ui-v2/app/controllers/dc/services/show.js index d4653888a5..3f21a7b36b 100644 --- a/ui-v2/app/controllers/dc/services/show.js +++ b/ui-v2/app/controllers/dc/services/show.js @@ -1,38 +1,39 @@ import Controller from '@ember/controller'; -import { get, computed } from '@ember/object'; -import sumOfUnhealthy from 'consul-ui/utils/sumOfUnhealthy'; -import hasStatus from 'consul-ui/utils/hasStatus'; -import WithHealthFiltering from 'consul-ui/mixins/with-health-filtering'; +import { get, set, computed } from '@ember/object'; +import { inject as service } from '@ember/service'; import WithSearching from 'consul-ui/mixins/with-searching'; -export default Controller.extend(WithSearching, WithHealthFiltering, { +export default Controller.extend(WithSearching, { + dom: service('dom'), init: function() { this.searchParams = { - healthyServiceNode: 's', - unhealthyServiceNode: 's', + serviceInstance: 's', }; this._super(...arguments); }, - searchableHealthy: computed('healthy', function() { - return get(this, 'searchables.healthyServiceNode') - .add(get(this, 'healthy')) - .search(get(this, this.searchParams.healthyServiceNode)); + setProperties: function() { + this._super(...arguments); + // This method is called immediately after `Route::setupController`, and done here rather than there + // as this is a variable used purely for view level things, if the view was different we might not + // need this variable + set(this, 'selectedTab', 'instances'); + }, + searchable: computed('items', function() { + return get(this, 'searchables.serviceInstance') + .add(get(this, 'items')) + .search(get(this, this.searchParams.serviceInstance)); }), - searchableUnhealthy: computed('unhealthy', function() { - return get(this, 'searchables.unhealthyServiceNode') - .add(get(this, 'unhealthy')) - .search(get(this, this.searchParams.unhealthyServiceNode)); - }), - unhealthy: computed('filtered', function() { - return get(this, 'filtered').filter(function(item) { - return sumOfUnhealthy(item.Checks) > 0; - }); - }), - healthy: computed('filtered', function() { - return get(this, 'filtered').filter(function(item) { - return sumOfUnhealthy(item.Checks) === 0; - }); - }), - filter: function(item, { s = '', status = '' }) { - return hasStatus(get(item, 'Checks'), status); + actions: { + change: function(e) { + set(this, 'selectedTab', e.target.value); + // Ensure tabular-collections sizing is recalculated + // now it is visible in the DOM + get(this, 'dom') + .components('.tab-section input[type="radio"]:checked + div table') + .forEach(function(item) { + if (typeof item.didAppear === 'function') { + item.didAppear(); + } + }); + }, }, }); diff --git a/ui-v2/app/initializers/search.js b/ui-v2/app/initializers/search.js index 69875fdb11..ebdd48c926 100644 --- a/ui-v2/app/initializers/search.js +++ b/ui-v2/app/initializers/search.js @@ -22,8 +22,7 @@ export function initialize(application) { kv: kv(filterable), healthyNode: node(filterable), unhealthyNode: node(filterable), - healthyServiceNode: serviceNode(filterable), - unhealthyServiceNode: serviceNode(filterable), + serviceInstance: serviceNode(filterable), nodeservice: nodeService(filterable), service: service(filterable), }; diff --git a/ui-v2/app/models/proxy.js b/ui-v2/app/models/proxy.js new file mode 100644 index 0000000000..9e08582199 --- /dev/null +++ b/ui-v2/app/models/proxy.js @@ -0,0 +1,12 @@ +import Model from 'ember-data/model'; +import attr from 'ember-data/attr'; + +export const PRIMARY_KEY = 'uid'; +export const SLUG_KEY = 'ID'; +export default Model.extend({ + [PRIMARY_KEY]: attr('string'), + [SLUG_KEY]: attr('string'), + ServiceName: attr('string'), + ServiceID: attr('string'), + ServiceProxyDestination: attr('string'), +}); diff --git a/ui-v2/app/router.js b/ui-v2/app/router.js index 628a713c7a..764bc5f358 100644 --- a/ui-v2/app/router.js +++ b/ui-v2/app/router.js @@ -18,6 +18,9 @@ export const routes = { show: { _options: { path: '/:name' }, }, + instance: { + _options: { path: '/:name/:id' }, + }, }, // Nodes represent a consul node nodes: { diff --git a/ui-v2/app/routes/dc/services/instance.js b/ui-v2/app/routes/dc/services/instance.js new file mode 100644 index 0000000000..da863ba418 --- /dev/null +++ b/ui-v2/app/routes/dc/services/instance.js @@ -0,0 +1,29 @@ +import Route from '@ember/routing/route'; +import { inject as service } from '@ember/service'; +import { hash } from 'rsvp'; +import { get } from '@ember/object'; + +export default Route.extend({ + repo: service('repository/service'), + proxyRepo: service('repository/proxy'), + model: function(params) { + const repo = get(this, 'repo'); + const proxyRepo = get(this, 'proxyRepo'); + const dc = this.modelFor('dc').dc.Name; + return hash({ + item: repo.findInstanceBySlug(params.id, params.name, dc), + }).then(function(model) { + return hash({ + proxy: + get(service, 'Kind') !== 'connect-proxy' + ? proxyRepo.findInstanceBySlug(params.id, params.name, dc) + : null, + ...model, + }); + }); + }, + setupController: function(controller, model) { + this._super(...arguments); + controller.setProperties(model); + }, +}); diff --git a/ui-v2/app/routes/dc/services/show.js b/ui-v2/app/routes/dc/services/show.js index bf5fa0d657..9376abdbc1 100644 --- a/ui-v2/app/routes/dc/services/show.js +++ b/ui-v2/app/routes/dc/services/show.js @@ -19,6 +19,7 @@ export default Route.extend({ return { ...model, ...{ + // Nodes happen to be the ServiceInstances here items: model.item.Nodes, }, }; diff --git a/ui-v2/app/serializers/proxy.js b/ui-v2/app/serializers/proxy.js new file mode 100644 index 0000000000..7c3c5c42e0 --- /dev/null +++ b/ui-v2/app/serializers/proxy.js @@ -0,0 +1,6 @@ +import Serializer from './application'; +import { PRIMARY_KEY } from 'consul-ui/models/proxy'; + +export default Serializer.extend({ + primaryKey: PRIMARY_KEY, +}); diff --git a/ui-v2/app/services/dom.js b/ui-v2/app/services/dom.js index a3bda1c8c8..740406cb59 100644 --- a/ui-v2/app/services/dom.js +++ b/ui-v2/app/services/dom.js @@ -70,7 +70,9 @@ export default Service.extend({ // with traditional/standard web components you wouldn't actually need this // method as you could just get to their methods from the dom element component: function(selector, context) { - // TODO: support passing a dom element, when we need to do that + if (typeof selector !== 'string') { + return $_(selector); + } return $_(this.element(selector, context)); }, components: function(selector, context) { diff --git a/ui-v2/app/services/repository/proxy.js b/ui-v2/app/services/repository/proxy.js new file mode 100644 index 0000000000..ce8c055d83 --- /dev/null +++ b/ui-v2/app/services/repository/proxy.js @@ -0,0 +1,33 @@ +import RepositoryService from 'consul-ui/services/repository'; +import { PRIMARY_KEY } from 'consul-ui/models/proxy'; +import { get } from '@ember/object'; +const modelName = 'proxy'; +export default RepositoryService.extend({ + getModelName: function() { + return modelName; + }, + getPrimaryKey: function() { + return PRIMARY_KEY; + }, + findAllBySlug: function(slug, dc, configuration = {}) { + const query = { + id: slug, + dc: dc, + }; + if (typeof configuration.cursor !== 'undefined') { + query.index = configuration.cursor; + } + return this.get('store').query(this.getModelName(), query); + }, + findInstanceBySlug: function(id, slug, dc, configuration) { + return this.findAllBySlug(slug, dc, configuration).then(function(items) { + if (get(items, 'length') > 0) { + const instance = items.findBy('ServiceProxyDestination', id); + if (instance) { + return instance; + } + } + return; + }); + }, +}); diff --git a/ui-v2/app/services/repository/service.js b/ui-v2/app/services/repository/service.js index 5654c3a618..2da90a3c72 100644 --- a/ui-v2/app/services/repository/service.js +++ b/ui-v2/app/services/repository/service.js @@ -7,16 +7,35 @@ export default RepositoryService.extend({ }, findBySlug: function(slug, dc) { return this._super(...arguments).then(function(item) { - const nodes = get(item, 'Nodes'); - const service = get(nodes, 'firstObject'); - const tags = nodes - .reduce(function(prev, item) { - return prev.concat(get(item, 'Service.Tags') || []); - }, []) - .uniq(); - set(service, 'Tags', tags); - set(service, 'Nodes', nodes); - return service; + const nodes = get(item, 'Nodes'); + const service = get(nodes, 'firstObject'); + const tags = nodes + .reduce(function(prev, item) { + return prev.concat(get(item, 'Service.Tags') || []); + }, []) + .uniq(); + set(service, 'Tags', tags); + set(service, 'Nodes', nodes); + return service; + }); + }, + findInstanceBySlug: function(id, slug, dc, configuration) { + return this.findBySlug(slug, dc, configuration).then(function(item) { + const i = item.Nodes.findIndex(function(item) { + return item.Service.ID === id; }); + if (i !== -1) { + const service = item.Nodes[i].Service; + service.Node = item.Nodes[i].Node; + service.ServiceChecks = item.Nodes[i].Checks.filter(function(item) { + return item.ServiceID != ''; + }); + service.NodeChecks = item.Nodes[i].Checks.filter(function(item) { + return item.ServiceID == ''; + }); + return service; + } + // TODO: probably need to throw a 404 here? + }); }, }); diff --git a/ui-v2/app/styles/components/app-view/layout.scss b/ui-v2/app/styles/components/app-view/layout.scss index 34827cc5bf..f518b5d38d 100644 --- a/ui-v2/app/styles/components/app-view/layout.scss +++ b/ui-v2/app/styles/components/app-view/layout.scss @@ -10,6 +10,15 @@ display: flex; align-items: flex-start; } +%app-view header dl { + float: left; + margin-top: 25px; + margin-right: 50px; + margin-bottom: 20px; +} +%app-view header dt { + font-weight: bold; +} /* units */ %app-view { margin-top: 50px; diff --git a/ui-v2/app/styles/components/app-view/skin.scss b/ui-v2/app/styles/components/app-view/skin.scss index e0269410ff..a117fa1258 100644 --- a/ui-v2/app/styles/components/app-view/skin.scss +++ b/ui-v2/app/styles/components/app-view/skin.scss @@ -1,10 +1,28 @@ -%app-view h2, -%app-view header > div:last-of-type { - border-bottom: $decor-border-100; -} -%app-view header > div:last-of-type, %app-view h2 { - border-color: $keyline-light; + border-bottom: $decor-border-200; +} +@media #{$--horizontal-selects} { + %app-view header h1 { + border-bottom: $decor-border-200; + } +} +@media #{$--lt-horizontal-selects} { + %app-view header > div > div:last-child { + border-bottom: $decor-border-200; + } +} +%app-view header > div > div:last-child, +%app-view header h1, +%app-view h2 { + border-color: $gray-200; +} +// We know that any sibling navs might have a top border +// by default. As its squashed up to a h1, in this +// case hide its border to avoid double border +@media #{$--horizontal-selects} { + %app-view header h1 ~ nav { + border-top: 0 !important; + } } %app-content div > dl > dd { color: $gray-400; diff --git a/ui-v2/app/styles/components/breadcrumbs/skin.scss b/ui-v2/app/styles/components/breadcrumbs/skin.scss index 6bbe41f288..fb86355e92 100644 --- a/ui-v2/app/styles/components/breadcrumbs/skin.scss +++ b/ui-v2/app/styles/components/breadcrumbs/skin.scss @@ -1,9 +1,18 @@ -%breadcrumbs a { +%breadcrumbs li > * { @extend %with-chevron; } +%breadcrumbs li > strong::before { + color: $gray-300; +} +%breadcrumbs li > a::before { + color: rgba($color-action, 0.5); +} %breadcrumbs ol { list-style-type: none; } %breadcrumbs a { color: $color-action; } +%breadcrumbs strong { + color: $gray-400; +} diff --git a/ui-v2/app/styles/components/form-elements.scss b/ui-v2/app/styles/components/form-elements.scss index 942efbb8cb..c0bd6fa6e0 100644 --- a/ui-v2/app/styles/components/form-elements.scss +++ b/ui-v2/app/styles/components/form-elements.scss @@ -24,7 +24,7 @@ form table, %app-content form dl { @extend %form-row; } -%app-content [role='radiogroup'] { +%app-content form:not(.filter-bar) [role='radiogroup'] { @extend %radio-group; } %radio-group label { diff --git a/ui-v2/app/styles/components/healthcheck-info.scss b/ui-v2/app/styles/components/healthcheck-info.scss new file mode 100644 index 0000000000..a249d32c49 --- /dev/null +++ b/ui-v2/app/styles/components/healthcheck-info.scss @@ -0,0 +1,12 @@ +@import './healthcheck-info/index'; +@import './icons/index'; +tr dl { + @extend %healthcheck-info; +} +td span.zero { + @extend %with-no-healthchecks; + // TODO: Why isn't this is layout? + display: block; + text-indent: 20px; + color: $gray-400; +} diff --git a/ui-v2/app/styles/components/healthcheck-status/index.scss b/ui-v2/app/styles/components/healthcheck-info/index.scss similarity index 100% rename from ui-v2/app/styles/components/healthcheck-status/index.scss rename to ui-v2/app/styles/components/healthcheck-info/index.scss diff --git a/ui-v2/app/styles/components/healthcheck-info/layout.scss b/ui-v2/app/styles/components/healthcheck-info/layout.scss new file mode 100644 index 0000000000..0f084db303 --- /dev/null +++ b/ui-v2/app/styles/components/healthcheck-info/layout.scss @@ -0,0 +1,32 @@ +%healthcheck-info { + display: flex; + height: 100%; + float: left; +} +%healthcheck-info > * { + display: block; +} +%healthcheck-info dt.zero { + display: none; +} +%healthcheck-info dd.zero { + visibility: hidden; +} +%healthcheck-info dt { + text-indent: -9000px; +} +%healthcheck-info dt.warning { + overflow: visible; +} +%healthcheck-info dt.warning::before { + top: 7px; +} +%healthcheck-info dt.warning::after { + left: -2px; + top: -1px; +} +%healthcheck-info dd { + box-sizing: content-box; + margin-left: 22px; + padding-right: 10px; +} diff --git a/ui-v2/app/styles/components/healthcheck-info/skin.scss b/ui-v2/app/styles/components/healthcheck-info/skin.scss new file mode 100644 index 0000000000..9b22b05f01 --- /dev/null +++ b/ui-v2/app/styles/components/healthcheck-info/skin.scss @@ -0,0 +1,21 @@ +%healthcheck-info dt.passing { + @extend %with-passing; +} +%healthcheck-info dt.warning { + @extend %with-warning; +} +%healthcheck-info dt.critical { + @extend %with-critical; +} +%healthcheck-info dt.passing, +%healthcheck-info dt.passing + dd { + color: $color-success; +} +%healthcheck-info dt.warning, +%healthcheck-info dt.warning + dd { + color: $color-alert; +} +%healthcheck-info dt.critical, +%healthcheck-info dt.critical + dd { + color: $color-failure; +} diff --git a/ui-v2/app/styles/components/healthcheck-status.scss b/ui-v2/app/styles/components/healthcheck-output.scss similarity index 56% rename from ui-v2/app/styles/components/healthcheck-status.scss rename to ui-v2/app/styles/components/healthcheck-output.scss index 550b2c992a..96216d6315 100644 --- a/ui-v2/app/styles/components/healthcheck-status.scss +++ b/ui-v2/app/styles/components/healthcheck-output.scss @@ -1,32 +1,32 @@ -@import './healthcheck-status/index'; +@import './healthcheck-output/index'; @import './icons/index'; -.healthcheck-status { - @extend %healthcheck-status; +.healthcheck-output { + @extend %healthcheck-output; } -%healthcheck-status.passing { +%healthcheck-output.passing { @extend %with-passing; } -%healthcheck-status.warning { +%healthcheck-output.warning { @extend %with-warning; } -%healthcheck-status.critical { +%healthcheck-output.critical { @extend %with-critical; } -@media #{$--lt-spacious-healthcheck-status} { - .healthcheck-status button.copy-btn { +@media #{$--lt-spacious-healthcheck-output} { + .healthcheck-output button.copy-btn { margin-top: -11px; margin-right: -18px; padding: 0; width: 20px; visibility: hidden; } - %healthcheck-status { + %healthcheck-output { padding-left: 30px; padding-top: 10px; padding-bottom: 15px; padding-right: 13px; } - %healthcheck-status::before { + %healthcheck-output::before { width: 15px !important; height: 15px !important; left: 9px; diff --git a/ui-v2/app/styles/components/healthcheck-output/index.scss b/ui-v2/app/styles/components/healthcheck-output/index.scss new file mode 100644 index 0000000000..bc18252196 --- /dev/null +++ b/ui-v2/app/styles/components/healthcheck-output/index.scss @@ -0,0 +1,2 @@ +@import './skin'; +@import './layout'; diff --git a/ui-v2/app/styles/components/healthcheck-status/layout.scss b/ui-v2/app/styles/components/healthcheck-output/layout.scss similarity index 63% rename from ui-v2/app/styles/components/healthcheck-status/layout.scss rename to ui-v2/app/styles/components/healthcheck-output/layout.scss index ef40daf375..5f1a9403de 100644 --- a/ui-v2/app/styles/components/healthcheck-status/layout.scss +++ b/ui-v2/app/styles/components/healthcheck-output/layout.scss @@ -1,4 +1,4 @@ -%healthcheck-status::before { +%healthcheck-output::before { background-size: 55%; width: 25px !important; height: 25px !important; @@ -6,25 +6,25 @@ top: 20px !important; margin-top: 0 !important; } -%healthcheck-status.warning::before { +%healthcheck-output.warning::before { background-size: 100%; } -%healthcheck-status { +%healthcheck-output { padding: 20px 24px; padding-bottom: 26px; padding-left: 57px; margin-bottom: 24px; position: relative; } -%healthcheck-status pre { +%healthcheck-output pre { padding: 12px; } -%healthcheck-status .with-feedback { +%healthcheck-output .with-feedback { float: right; } -%healthcheck-status dt { +%healthcheck-output dt { margin-bottom: 0.2em; } -%healthcheck-status dd:first-of-type { +%healthcheck-output dd:first-of-type { margin-bottom: 0.6em; } diff --git a/ui-v2/app/styles/components/healthcheck-status/skin.scss b/ui-v2/app/styles/components/healthcheck-output/skin.scss similarity index 60% rename from ui-v2/app/styles/components/healthcheck-status/skin.scss rename to ui-v2/app/styles/components/healthcheck-output/skin.scss index d0fd2cec13..9d26d4d663 100644 --- a/ui-v2/app/styles/components/healthcheck-status/skin.scss +++ b/ui-v2/app/styles/components/healthcheck-output/skin.scss @@ -1,35 +1,35 @@ -%healthcheck-status { +%healthcheck-output { border-width: 1px; } -%healthcheck-status, -%healthcheck-status pre { +%healthcheck-output, +%healthcheck-output pre { border-radius: $decor-radius-100; } -%healthcheck-status dd:first-of-type { +%healthcheck-output dd:first-of-type { color: $gray-400; } -%healthcheck-status pre { +%healthcheck-output pre { background-color: $black; color: $white; } -%healthcheck-status.passing { +%healthcheck-output.passing { /* TODO: this should be a frame-gray */ // @extend %frame-green-500; color: $gray-900; border-color: $gray-200; border-style: solid; } -%healthcheck-status.warning { +%healthcheck-output.warning { @extend %frame-yellow-500; color: $gray-900; } -%healthcheck-status.critical { +%healthcheck-output.critical { @extend %frame-red-500; color: $gray-900; } -%healthcheck-status.passing::before { +%healthcheck-output.passing::before { background-color: $color-success !important; } -%healthcheck-status.critical::before { +%healthcheck-output.critical::before { background-color: $color-danger !important; } diff --git a/ui-v2/app/styles/components/icons/index.scss b/ui-v2/app/styles/components/icons/index.scss index acc1eb7d6f..4c61d11f3c 100644 --- a/ui-v2/app/styles/components/icons/index.scss +++ b/ui-v2/app/styles/components/icons/index.scss @@ -93,12 +93,11 @@ } %with-chevron::before { @extend %pseudo-icon; - background-image: url('data:image/svg+xml;charset=UTF-8,'); + content: '❮'; width: 6px; - height: 9px; + background-color: transparent; left: 0; - margin-top: -4px; - background-color: $color-transparent; + font-size: 0.7rem; } %with-folder::before { @extend %pseudo-icon; diff --git a/ui-v2/app/styles/components/index.scss b/ui-v2/app/styles/components/index.scss index f461f7bea1..edfda9d16e 100644 --- a/ui-v2/app/styles/components/index.scss +++ b/ui-v2/app/styles/components/index.scss @@ -16,7 +16,9 @@ @import './app-view'; @import './product'; -@import './healthcheck-status'; +@import './tag-list'; +@import './healthcheck-output'; +@import './healthcheck-info'; @import './healthchecked-resource'; @import './freetext-filter'; @import './filter-bar'; diff --git a/ui-v2/app/styles/components/pill.scss b/ui-v2/app/styles/components/pill.scss index 4d8f0673a1..af1809c339 100644 --- a/ui-v2/app/styles/components/pill.scss +++ b/ui-v2/app/styles/components/pill.scss @@ -1,4 +1,5 @@ @import './pill/index'; -td strong { +td strong, +%tag-list span { @extend %pill; } diff --git a/ui-v2/app/styles/components/table.scss b/ui-v2/app/styles/components/table.scss index 364c214b65..749774ad1a 100644 --- a/ui-v2/app/styles/components/table.scss +++ b/ui-v2/app/styles/components/table.scss @@ -1,41 +1,30 @@ @import './icons/index'; @import './table/index'; + +html.template-service.template-list td:first-child a span, +html.template-node.template-show #services td:first-child a span, +html.template-service.template-show #instances td:first-child a span { + @extend %with-external-source-icon; + float: left; + margin-right: 10px; + margin-top: 2px; +} +/* This nudges the th in for the external source icons */ +html.template-node.template-show #services th:first-child, +html.template-service.template-show #instances th:first-child, +html.template-service.template-list main th:first-child { + text-indent: 28px; +} + td.folder { @extend %with-folder; } -td dt.passing { - @extend %with-passing; -} -td dt.warning { - @extend %with-warning; -} -td dt.critical { - @extend %with-critical; -} -td span.zero { - @extend %with-no-healthchecks; - display: block; - text-indent: 20px; - color: $gray-400; -} table:not(.sessions) tr { cursor: pointer; } table:not(.sessions) td:first-child { padding: 0; } -td dt.passing, -td dt.passing + dd { - color: $color-success; -} -td dt.warning, -td dt.warning + dd { - color: $color-alert; -} -td dt.critical, -td dt.critical + dd { - color: $color-failure; -} /* Header Tooltips/Icon*/ th { overflow: visible; diff --git a/ui-v2/app/styles/components/table/layout.scss b/ui-v2/app/styles/components/table/layout.scss index 23301b423e..2706e64dc5 100644 --- a/ui-v2/app/styles/components/table/layout.scss +++ b/ui-v2/app/styles/components/table/layout.scss @@ -31,7 +31,7 @@ table th { padding-bottom: 0.6em; } table td, -table td a { +table td:first-child a { padding: 0.9em 0; } table th, @@ -50,44 +50,6 @@ td:not(.actions) a { overflow: hidden; } -// TODO: this isn't specific to table -// these are the node health 3 column display -tr > * dl { - float: left; -} -td dl { - height: 100%; -} -td dl { - display: flex; -} -td dl > * { - display: block; -} -td dt.zero { - display: none; -} -td dd.zero { - visibility: hidden; -} -td dt { - text-indent: -9000px; -} -td dt.warning { - overflow: visible; -} -td dt.warning::before { - top: 7px; -} -td dt.warning::after { - left: -2px; - top: -1px; -} -td dd { - box-sizing: content-box; - margin-left: 22px; - padding-right: 10px; -} /* hide actions on narrow screens, you can always click in do everything from there */ @media #{$--lt-wide-table} { tr > .actions { @@ -96,6 +58,8 @@ td dd { } /* ideally these would be in route css files, but left here as they */ /* accomplish the same thing (hide non-essential columns for tables) */ +/* TODO: Move these to component/table.scss for the moment */ +/* Also mixed with things in component/tabular-collection.scss move those also */ @media #{$--lt-medium-table} { /* Policy > Datacenters */ html.template-policy.template-list tr > :nth-child(2) { diff --git a/ui-v2/app/styles/components/tabs.scss b/ui-v2/app/styles/components/tabs.scss index b0c08a7f8c..64a1b9138e 100644 --- a/ui-v2/app/styles/components/tabs.scss +++ b/ui-v2/app/styles/components/tabs.scss @@ -1,5 +1,5 @@ @import './tabs/index'; -main header nav:last-of-type:not(:first-of-type) { +.tab-nav { @extend %tab-nav; } .tab-section { diff --git a/ui-v2/app/styles/components/tabs/layout.scss b/ui-v2/app/styles/components/tabs/layout.scss index 9588b870eb..7b20b1aa1f 100644 --- a/ui-v2/app/styles/components/tabs/layout.scss +++ b/ui-v2/app/styles/components/tabs/layout.scss @@ -2,6 +2,9 @@ /* this keeps in-tab-section toolbars flush to the top, see Node Detail > Services */ margin-top: 0 !important; } +%tab-nav { + clear: both; +} @media #{$--horizontal-tabs} { %tab-nav ul { display: flex; diff --git a/ui-v2/app/styles/components/tabs/skin.scss b/ui-v2/app/styles/components/tabs/skin.scss index 81faad3695..1538bcf0d8 100644 --- a/ui-v2/app/styles/components/tabs/skin.scss +++ b/ui-v2/app/styles/components/tabs/skin.scss @@ -1,3 +1,12 @@ +%tab-nav { + /* %frame-gray-something */ + border-bottom: $decor-border-100; + border-top: $decor-border-200; +} +%tab-nav { + /* %frame-gray-something */ + border-color: $gray-200; +} %tab-nav label { cursor: pointer; } diff --git a/ui-v2/app/styles/components/tabular-collection.scss b/ui-v2/app/styles/components/tabular-collection.scss index c9745e787f..53d6678ef6 100644 --- a/ui-v2/app/styles/components/tabular-collection.scss +++ b/ui-v2/app/styles/components/tabular-collection.scss @@ -35,17 +35,16 @@ table.dom-recycling { /* using: */ /* calc(<100% divided by number of non-fixed width cells> - ) */ -html.template-service.template-list td:first-child a span, -html.template-node.template-show #services td:first-child a span { - @extend %with-external-source-icon; - float: left; - margin-right: 10px; - margin-top: 2px; -} /*TODO: trs only live in tables, get rid of table */ html.template-service.template-list main table tr { @extend %services-row; } +html.template-service.template-show #instances table tr { + @extend %instances-row; +} +html.template-instance.template-show #upstreams table tr { + @extend %upstreams-row; +} html.template-intention.template-list main table tr { @extend %intentions-row; } @@ -146,6 +145,12 @@ html.template-node.template-show main table.sessions tr { html.template-token.template-list main table tr td.me ~ td:nth-of-type(5) { display: none; } + html.template-service.template-show #instances tr > :nth-child(3) { + display: none; + } + %instances-row > * { + width: calc(100% / 4); + } } %kvs-row > *:first-child { @@ -155,7 +160,7 @@ html.template-node.template-show main table.sessions tr { @extend %table-actions; } %node-services-row > * { - width: 33%; + width: calc(100% / 3); } %policies-row > * { width: calc(33% - 20px); @@ -172,3 +177,9 @@ html.template-node.template-show main table.sessions tr { %services-row > * { width: auto; } +%instances-row > * { + width: calc(100% / 5); +} +%upstreams-row > * { + width: calc(100% / 3); +} diff --git a/ui-v2/app/styles/components/tag-list.scss b/ui-v2/app/styles/components/tag-list.scss new file mode 100644 index 0000000000..6bc2ea8e70 --- /dev/null +++ b/ui-v2/app/styles/components/tag-list.scss @@ -0,0 +1,5 @@ +@import './tag-list/index'; +.tag-list, +td.tags { + @extend %tag-list; +} diff --git a/ui-v2/app/styles/components/tag-list/index.scss b/ui-v2/app/styles/components/tag-list/index.scss new file mode 100644 index 0000000000..bc18252196 --- /dev/null +++ b/ui-v2/app/styles/components/tag-list/index.scss @@ -0,0 +1,2 @@ +@import './skin'; +@import './layout'; diff --git a/ui-v2/app/styles/components/tag-list/layout.scss b/ui-v2/app/styles/components/tag-list/layout.scss new file mode 100644 index 0000000000..2590f8b4c1 --- /dev/null +++ b/ui-v2/app/styles/components/tag-list/layout.scss @@ -0,0 +1,10 @@ +%tag-list dt { + display: none; +} +// TODO: Currently this is here to overwrite +// the default definition list layout used in edit pages +// ideally we'd be more specific with those to say +// only add padding to dl's in edit pages +%tag-list dd { + padding-left: 0; +} diff --git a/ui-v2/app/styles/components/tag-list/skin.scss b/ui-v2/app/styles/components/tag-list/skin.scss new file mode 100644 index 0000000000..e69de29bb2 diff --git a/ui-v2/app/styles/core/typography.scss b/ui-v2/app/styles/core/typography.scss index 2331c49c90..bc945842a8 100644 --- a/ui-v2/app/styles/core/typography.scss +++ b/ui-v2/app/styles/core/typography.scss @@ -36,10 +36,10 @@ h1, h2, %header-nav, %healthchecked-resource header span, -%healthcheck-status dt, +%healthcheck-output dt, %copy-button, %app-content div > dl > dt, -td a { +td:first-child a { font-weight: $typo-weight-semibold; } %form-element > span, @@ -51,7 +51,7 @@ caption { font-weight: $typo-weight-semibold !important; } th, -%breadcrumbs a, +%breadcrumbs li > *, %action-group-action, %tab-nav, %tooltip-bubble { diff --git a/ui-v2/app/styles/routes/dc/service/index.scss b/ui-v2/app/styles/routes/dc/service/index.scss index c38a957a9d..e69de29bb2 100644 --- a/ui-v2/app/styles/routes/dc/service/index.scss +++ b/ui-v2/app/styles/routes/dc/service/index.scss @@ -1,17 +0,0 @@ -@import '../../../components/pill/index'; -html.template-service.template-show main dl { - display: flex; - margin-bottom: 1.4em; -} -html.template-service.template-show main dt { - display: none; -} -// TODO: Generalize this, also see nodes/index -html.template-service.template-list td.tags span, -html.template-service.template-show main dd span { - @extend %pill; -} -html.template-node.template-show #services th:first-child, -html.template-service.template-list main th:first-child { - text-indent: 28px; -} diff --git a/ui-v2/app/styles/variables/custom-query.scss b/ui-v2/app/styles/variables/custom-query.scss index 56895ef266..8e7160e678 100644 --- a/ui-v2/app/styles/variables/custom-query.scss +++ b/ui-v2/app/styles/variables/custom-query.scss @@ -26,8 +26,8 @@ $--lt-wide-footer: '(max-width: 420px)'; $--spacious-page-header: '(min-width: 850px)'; $--lt-spacious-page-header: '(max-width: 849px)'; -$--spacious-healthcheck-status: '(min-width: 421px)'; -$--lt-spacious-healthcheck-status: '(max-width: 420px)'; +$--spacious-healthcheck-output: '(min-width: 421px)'; +$--lt-spacious-healthcheck-output: '(max-width: 420px)'; $--wide-form: '(min-width: 421px)'; $--lt-wide-form: '(max-width: 420px)'; diff --git a/ui-v2/app/templates/components/healthcheck-info.hbs b/ui-v2/app/templates/components/healthcheck-info.hbs new file mode 100644 index 0000000000..13b62ac08c --- /dev/null +++ b/ui-v2/app/templates/components/healthcheck-info.hbs @@ -0,0 +1,9 @@ +{{#if (and (lt passing 1) (lt warning 1) (lt critical 1) )}} + 0 +{{else}} +
    + {{healthcheck-status width=passingWidth name='passing' value=passing}} + {{healthcheck-status width=warningWidth name='warning' value=warning}} + {{healthcheck-status width=criticalWidth name='critical' value=critical}} +
    +{{/if}} diff --git a/ui-v2/app/templates/components/healthcheck-list.hbs b/ui-v2/app/templates/components/healthcheck-list.hbs new file mode 100644 index 0000000000..4b5774588e --- /dev/null +++ b/ui-v2/app/templates/components/healthcheck-list.hbs @@ -0,0 +1,5 @@ +
      +{{#each (sort-by (action 'sortChecksByImportance') items) as |check| }} + {{healthcheck-output data-test-node-healthcheck=check.Name tagName='li' name=check.Name class=check.Status status=check.Status notes=check.Notes output=check.Output}} +{{/each}} +
    diff --git a/ui-v2/app/templates/components/healthcheck-output.hbs b/ui-v2/app/templates/components/healthcheck-output.hbs new file mode 100644 index 0000000000..05a75e40a2 --- /dev/null +++ b/ui-v2/app/templates/components/healthcheck-output.hbs @@ -0,0 +1,25 @@ +{{#feedback-dialog type='inline'}} + {{#block-slot 'action' as |success error|}} + {{#copy-button success=(action success) error=(action error) clipboardText=output title='copy output to clipboard'}} + Copy Output + {{/copy-button}} + {{/block-slot}} + {{#block-slot 'success' as |transition|}} +

    + Copied IP Address! +

    + {{/block-slot}} + {{#block-slot 'error' as |transition|}} +

    + Sorry, something went wrong! +

    + {{/block-slot}} +{{/feedback-dialog}} +
    +
    {{name}}
    +
    {{notes}}
    +
    Output
    +
    +
    {{output}}
    +
    +
    \ No newline at end of file diff --git a/ui-v2/app/templates/components/healthcheck-status.hbs b/ui-v2/app/templates/components/healthcheck-status.hbs index 05a75e40a2..383f67386c 100644 --- a/ui-v2/app/templates/components/healthcheck-status.hbs +++ b/ui-v2/app/templates/components/healthcheck-status.hbs @@ -1,25 +1,3 @@ -{{#feedback-dialog type='inline'}} - {{#block-slot 'action' as |success error|}} - {{#copy-button success=(action success) error=(action error) clipboardText=output title='copy output to clipboard'}} - Copy Output - {{/copy-button}} - {{/block-slot}} - {{#block-slot 'success' as |transition|}} -

    - Copied IP Address! -

    - {{/block-slot}} - {{#block-slot 'error' as |transition|}} -

    - Sorry, something went wrong! -

    - {{/block-slot}} -{{/feedback-dialog}} -
    -
    {{name}}
    -
    {{notes}}
    -
    Output
    -
    -
    {{output}}
    -
    -
    \ No newline at end of file +{{!-- we use concat here to avoid ember adding returns between words, which causes a layout issue--}} +
    {{ concat 'Healthchecks ' (capitalize name) }}
    +
    {{format-number count}}
    \ No newline at end of file diff --git a/ui-v2/app/templates/components/tag-list.hbs b/ui-v2/app/templates/components/tag-list.hbs new file mode 100644 index 0000000000..c51ea2a418 --- /dev/null +++ b/ui-v2/app/templates/components/tag-list.hbs @@ -0,0 +1,8 @@ +{{#if (gt items.length 0)}} +
    Tags
    +
    + {{#each items as |item|}} + {{item}} + {{/each}} +
    +{{/if}} diff --git a/ui-v2/app/templates/dc/nodes/-healthchecks.hbs b/ui-v2/app/templates/dc/nodes/-healthchecks.hbs index a956fad631..19acae8c38 100644 --- a/ui-v2/app/templates/dc/nodes/-healthchecks.hbs +++ b/ui-v2/app/templates/dc/nodes/-healthchecks.hbs @@ -1,9 +1,5 @@ {{#if (gt item.Checks.length 0) }} -
      -{{#each (sort-by (action 'sortChecksByImportance') item.Checks) as |check| }} - {{healthcheck-status data-test-node-healthcheck=check.Name tagName='li' name=check.Name class=check.Status status=check.Status notes=check.Notes output=check.Output}} -{{/each}} -
    + {{healthcheck-list items=item.Checks}} {{else}}

    This node has no health checks. diff --git a/ui-v2/app/templates/dc/nodes/-services.hbs b/ui-v2/app/templates/dc/nodes/-services.hbs index 856e51b172..e692bb0d52 100644 --- a/ui-v2/app/templates/dc/nodes/-services.hbs +++ b/ui-v2/app/templates/dc/nodes/-services.hbs @@ -19,7 +19,7 @@ - {{item.Service}}{{#if (not-eq item.ID item.Service) }}({{item.ID}}){{/if}} + {{item.Service}}{{#if (not-eq item.ID item.Service) }} ({{item.ID}}){{/if}} diff --git a/ui-v2/app/templates/dc/services/-instances.hbs b/ui-v2/app/templates/dc/services/-instances.hbs new file mode 100644 index 0000000000..7ef34c3400 --- /dev/null +++ b/ui-v2/app/templates/dc/services/-instances.hbs @@ -0,0 +1,55 @@ +{{#if (gt items.length 0) }} + +

    + {{freetext-filter searchable=searchable value=s placeholder="Search"}} +
    +{{/if}} + {{#changeable-set dispatcher=searchable}} + {{#block-slot 'set' as |filtered|}} + {{#tabular-collection + data-test-instances + items=filtered as |item index| + }} + {{#block-slot 'header'}} + ID + Node + Address + Node Checks + Service Checks + {{/block-slot}} + {{#block-slot 'row'}} + + + + {{ or item.Service.ID item.Service.Service }} + + + + {{item.Node.Node}} + + + {{item.Service.Address}}:{{item.Service.Port}} + + + {{#with (reject-by 'ServiceID' '' item.Checks) as |checks|}} + {{healthcheck-info + passing=(filter-by 'Status' 'passing' checks) warning=(filter-by 'Status' 'warning' checks) critical=(filter-by 'Status' 'critical' checks) + }} + {{/with}} + + + {{#with (filter-by 'ServiceID' '' item.Checks) as |checks|}} + {{healthcheck-info + passing=(filter-by 'Status' 'passing' checks) warning=(filter-by 'Status' 'warning' checks) critical=(filter-by 'Status' 'critical' checks) + }} + {{/with}} + + {{/block-slot}} + {{/tabular-collection}} + {{/block-slot}} + {{#block-slot 'empty'}} +

    + There are no services. +

    + {{/block-slot}} + {{/changeable-set}} diff --git a/ui-v2/app/templates/dc/services/-nodechecks.hbs b/ui-v2/app/templates/dc/services/-nodechecks.hbs new file mode 100644 index 0000000000..487db34fc9 --- /dev/null +++ b/ui-v2/app/templates/dc/services/-nodechecks.hbs @@ -0,0 +1,8 @@ +{{#if (gt item.NodeChecks.length 0) }} + {{healthcheck-list items=item.NodeChecks}} +{{else}} +

    + This instance has no node health checks. +

    +{{/if}} + diff --git a/ui-v2/app/templates/dc/services/-servicechecks.hbs b/ui-v2/app/templates/dc/services/-servicechecks.hbs new file mode 100644 index 0000000000..424772e705 --- /dev/null +++ b/ui-v2/app/templates/dc/services/-servicechecks.hbs @@ -0,0 +1,8 @@ +{{#if (gt item.ServiceChecks.length 0) }} + {{healthcheck-list items=item.ServiceChecks}} +{{else}} +

    + This instance has no service health checks. +

    +{{/if}} + diff --git a/ui-v2/app/templates/dc/services/-tags.hbs b/ui-v2/app/templates/dc/services/-tags.hbs new file mode 100644 index 0000000000..c0a3a0f783 --- /dev/null +++ b/ui-v2/app/templates/dc/services/-tags.hbs @@ -0,0 +1,7 @@ +{{#if (gt item.Tags.length 0) }} +{{tag-list items=item.Tags}} +{{else}} +

    + There are no tags. +

    +{{/if}} diff --git a/ui-v2/app/templates/dc/services/-upstreams.hbs b/ui-v2/app/templates/dc/services/-upstreams.hbs new file mode 100644 index 0000000000..f4ad6fcc81 --- /dev/null +++ b/ui-v2/app/templates/dc/services/-upstreams.hbs @@ -0,0 +1,27 @@ +{{#if (gt item.Proxy.Upstreams.length 0) }} +{{#tabular-collection + data-test-upstreams + items=item.Proxy.Upstreams as |item index| +}} + {{#block-slot 'header'}} + Destination Name + Destination Type + Local Bind Port + {{/block-slot}} + {{#block-slot 'row'}} + + {{item.DestinationName}} + + + {{item.DestinationType}} + + + {{item.LocalBindPort}} + + {{/block-slot}} +{{/tabular-collection}} +{{else}} +

    + There are no upstreams. +

    +{{/if}} diff --git a/ui-v2/app/templates/dc/services/index.hbs b/ui-v2/app/templates/dc/services/index.hbs index aee20a9718..7306ea10f3 100644 --- a/ui-v2/app/templates/dc/services/index.hbs +++ b/ui-v2/app/templates/dc/services/index.hbs @@ -35,18 +35,10 @@ - {{#if (and (lt item.ChecksPassing 1) (lt item.ChecksWarning 1) (lt item.ChecksCritical 1) )}} - 0 - {{else}} -
    -
    Healthchecks Passing
    -
    {{format-number item.ChecksPassing}}
    -
    Healthchecks Warning
    -
    {{format-number item.ChecksWarning}}
    -
    Healthchecks Critical
    -
    {{format-number item.ChecksCritical}}
    -
    - {{/if}} + {{healthcheck-info + passing=item.ChecksPassing warning=item.ChecksWarning critical=item.ChecksCritical + passingWidth=passingWidth warningWidth=warningWidth criticalWidth=criticalWidth + }} {{#if (gt item.Tags.length 0)}} diff --git a/ui-v2/app/templates/dc/services/instance.hbs b/ui-v2/app/templates/dc/services/instance.hbs new file mode 100644 index 0000000000..0638552104 --- /dev/null +++ b/ui-v2/app/templates/dc/services/instance.hbs @@ -0,0 +1,72 @@ +{{#app-view class="instance show"}} + {{#block-slot 'breadcrumbs'}} +
      +
    1. All Services
    2. +
    3. Service ({{item.Service}})
    4. +
    5. Instance
    6. +
    + {{/block-slot}} + {{#block-slot 'header'}} +

    + {{ item.ID }} +{{#with (service/external-source item) as |externalSource| }} + {{#with (css-var (concat '--' externalSource '-color-svg') 'none') as |bg| }} + {{#if (not-eq bg 'none') }} + Registered via {{externalSource}} + {{/if}} + {{/with}} +{{/with}} +

    +
    +
    Service Name
    +
    {{item.Service}}
    +
    +
    +
    Node Name
    +
    {{item.Node.Node}}
    +
    +{{#if proxy}} +
    +
    Sidecar Proxy
    +
    {{proxy.ServiceID}}
    +
    +{{/if}} +{{#if (eq item.Kind 'connect-proxy')}} +
    +
    Dest. Service Instance
    +
    {{item.Proxy.DestinationServiceID}}
    +
    +
    +
    Local Service Address
    +
    {{item.Proxy.LocalServiceAddress}}:{{item.Proxy.LocalServicePort}}
    +
    +{{/if}} + {{/block-slot}} + {{#block-slot 'content'}} + {{tab-nav + items=(compact + (array + 'Service Checks' + 'Node Checks' +(if (eq item.Kind 'connect-proxy') 'Upstreams' '') + 'Tags' + ) + ) + selected=selectedTab + }} + {{#each + (compact + (array + (hash id=(slugify 'Service Checks') partial='dc/services/servicechecks') + (hash id=(slugify 'Node Checks') partial='dc/services/nodechecks') +(if (eq item.Kind 'connect-proxy') (hash id=(slugify 'Upstreams') partial='dc/services/upstreams') '') + (hash id=(slugify 'Tags') partial='dc/services/tags') + ) + ) as |panel| + }} + {{#tab-section id=panel.id selected=(eq (if selectedTab selectedTab '') panel.id) onchange=(action "change")}} + {{partial panel.partial}} + {{/tab-section}} + {{/each}} + {{/block-slot}} +{{/app-view}} \ No newline at end of file diff --git a/ui-v2/app/templates/dc/services/show.hbs b/ui-v2/app/templates/dc/services/show.hbs index 5a75f0055f..bd693d9b85 100644 --- a/ui-v2/app/templates/dc/services/show.hbs +++ b/ui-v2/app/templates/dc/services/show.hbs @@ -15,76 +15,29 @@ {{/with}} {{/with}}

    - {{/block-slot}} - {{#block-slot 'toolbar'}} -{{#if (gt items.length 0) }} - {{catalog-filter searchable=(array searchableHealthy searchableUnhealthy) filters=healthFilters search=s status=filters.status onchange=(action 'filter')}} -{{/if}} + + {{tab-nav + items=(compact + (array + 'Instances' + 'Tags' + ) + ) + selected=selectedTab + }} {{/block-slot}} {{#block-slot 'content'}} -{{#if (gt item.Tags.length 0)}} -
    -
    Tags
    -
    - {{#each item.Tags as |item|}} - {{item}} - {{/each}} -
    -
    -{{/if}} -{{#if (gt unhealthy.length 0) }} -
    -

    Unhealthy Nodes

    -
    -
      - {{#changeable-set dispatcher=searchableUnhealthy}} - {{#block-slot 'set' as |unhealthy|}} - {{#each unhealthy as |item|}} - {{healthchecked-resource - tagName='li' - data-test-node=item.Node.Node - href=(href-to 'dc.nodes.show' item.Node.Node) - name=item.Node.Node - service=item.Service.ID - address=(concat (default item.Service.Address item.Node.Address) ':' item.Service.Port) - checks=item.Checks - }} - {{/each}} - {{/block-slot}} - {{#block-slot 'empty'}} -

      - There are no unhealthy nodes for that search. -

      - {{/block-slot}} - {{/changeable-set}} -
    -
    -
    -{{/if}} -{{#if (gt healthy.length 0) }} -
    -

    Healthy Nodes

    - {{#changeable-set dispatcher=searchableHealthy}} - {{#block-slot 'set' as |healthy|}} - {{#list-collection cellHeight=113 items=healthy as |item index|}} - {{healthchecked-resource - href=(href-to 'dc.nodes.show' item.Node.Node) - data-test-node=item.Node.Node - name=item.Node.Node - service=item.Service.ID - address=(concat (default item.Service.Address item.Node.Address) ':' item.Service.Port) - checks=item.Checks - status=item.Checks.[0].Status - }} - {{/list-collection}} - {{/block-slot}} - {{#block-slot 'empty'}} -

    - There are no healthy nodes for that search. -

    - {{/block-slot}} - {{/changeable-set}} -
    -{{/if}} + {{#each + (compact + (array + (hash id=(slugify 'Instances') partial='dc/services/instances') + (hash id=(slugify 'Tags') partial='dc/services/tags') + ) + ) as |panel| + }} + {{#tab-section id=panel.id selected=(eq (if selectedTab selectedTab '') panel.id) onchange=(action "change")}} + {{partial panel.partial}} + {{/tab-section}} + {{/each}} {{/block-slot}} {{/app-view}} diff --git a/ui-v2/app/utils/computed/purify.js b/ui-v2/app/utils/computed/purify.js index 3c9eba3410..1008ed8c47 100644 --- a/ui-v2/app/utils/computed/purify.js +++ b/ui-v2/app/utils/computed/purify.js @@ -1,4 +1,4 @@ -import { get } from '@ember/object'; +import { get, computed } from '@ember/object'; /** * Converts a conventional non-pure Ember `computed` function into a pure one @@ -8,20 +8,18 @@ import { get } from '@ember/object'; * @param {function} filter - Optional string filter function to pre-process the names of computed properties * @returns {function} - A pure `computed` function */ - -export default function(computed, filter) { +const _success = function(value) { + return value; +}; +const purify = function(computed, filter = args => args) { return function() { let args = [...arguments]; - let success = function(value) { - return value; - }; + let success = _success; // pop the user function off the end if (typeof args[args.length - 1] === 'function') { success = args.pop(); } - if (typeof filter === 'function') { - args = filter(args); - } + args = filter(args); // this is the 'conventional' `computed` const cb = function(name) { return success.apply( @@ -39,4 +37,6 @@ export default function(computed, filter) { // concat/push the user function back on return computed(...args.concat([cb])); }; -} +}; +export const subscribe = purify(computed); +export default purify; diff --git a/ui-v2/tests/acceptance/components/catalog-filter.feature b/ui-v2/tests/acceptance/components/catalog-filter.feature index ed11e247bd..3b5dad1177 100644 --- a/ui-v2/tests/acceptance/components/catalog-filter.feature +++ b/ui-v2/tests/acceptance/components/catalog-filter.feature @@ -123,31 +123,6 @@ Feature: components / catalog-filter | Model | Page | Url | | service | node | /dc-1/nodes/node-0 | ------------------------------------------------- - Scenario: Filtering [Model] in [Page] - Given 1 datacenter model with the value "dc1" - And 2 [Model] models from yaml - --- - - ID: node-0 - --- - When I visit the [Page] page for yaml - --- - dc: dc1 - service: service-0 - --- - Then I fill in with yaml - --- - s: service-0-with-id - --- - And I see 1 [Model] model - Then I see id on the unhealthy like yaml - --- - - service-0-with-id - --- - Where: - ------------------------------------------------- - | Model | Page | Url | - | nodes | service | /dc-1/services/service-0 | - ------------------------------------------------- Scenario: Given 1 datacenter model with the value "dc-1" And 3 service models from yaml diff --git a/ui-v2/tests/acceptance/dc/services/show.feature b/ui-v2/tests/acceptance/dc/services/show.feature index 5fa48f0def..0c707f31b9 100644 --- a/ui-v2/tests/acceptance/dc/services/show.feature +++ b/ui-v2/tests/acceptance/dc/services/show.feature @@ -52,7 +52,7 @@ Feature: dc / services / show: Show Service Then I see the text "Tag1" in "[data-test-tags] span:nth-child(1)" Then I see the text "Tag2" in "[data-test-tags] span:nth-child(2)" Then I see the text "Tag3" in "[data-test-tags] span:nth-child(3)" - Scenario: Given various services the various ports on their nodes are displayed + Scenario: Given various services the various nodes on their instances are displayed Given 1 datacenter model with the value "dc1" And 3 node models And 1 service model from yaml @@ -83,21 +83,9 @@ Feature: dc / services / show: Show Service dc: dc1 service: service-0 --- - Then I see address on the healthy like yaml + Then I see address on the instances like yaml --- - "1.1.1.1:8080" - --- - Then I see address on the unhealthy like yaml - --- - "2.2.2.2:8000" - "3.3.3.3:8888" --- - Then I see id on the healthy like yaml - --- - - "passing-service-8080" - --- - Then I see id on the unhealthy like yaml - --- - - "service-8000" - - "service-8888" - --- diff --git a/ui-v2/tests/integration/components/healthcheck-info-test.js b/ui-v2/tests/integration/components/healthcheck-info-test.js new file mode 100644 index 0000000000..613a650657 --- /dev/null +++ b/ui-v2/tests/integration/components/healthcheck-info-test.js @@ -0,0 +1,22 @@ +import { moduleForComponent, test } from 'ember-qunit'; +import hbs from 'htmlbars-inline-precompile'; + +moduleForComponent('healthcheck-info', 'Integration | Component | healthcheck info', { + integration: true, +}); + +test('it renders', function(assert) { + // Set any properties with this.set('myProperty', 'value'); + // Handle any actions with this.on('myAction', function(val) { ... }); + + this.render(hbs`{{healthcheck-info}}`); + + assert.equal(this.$('dl').length, 1); + + // Template block usage: + this.render(hbs` + {{#healthcheck-info}} + {{/healthcheck-info}} + `); + assert.equal(this.$('dl').length, 1); +}); diff --git a/ui-v2/tests/integration/components/healthcheck-list-test.js b/ui-v2/tests/integration/components/healthcheck-list-test.js new file mode 100644 index 0000000000..a85c4866de --- /dev/null +++ b/ui-v2/tests/integration/components/healthcheck-list-test.js @@ -0,0 +1,23 @@ +import { moduleForComponent, test } from 'ember-qunit'; +import hbs from 'htmlbars-inline-precompile'; + +moduleForComponent('healthcheck-list', 'Integration | Component | healthcheck list', { + integration: true, +}); + +test('it renders', function(assert) { + // Set any properties with this.set('myProperty', 'value'); + // Handle any actions with this.on('myAction', function(val) { ... }); + + this.render(hbs`{{healthcheck-list}}`); + + assert.equal(this.$('ul').length, 1); + + // Template block usage: + this.render(hbs` + {{#healthcheck-list}} + {{/healthcheck-list}} + `); + + assert.equal(this.$('ul').length, 1); +}); diff --git a/ui-v2/tests/integration/components/healthcheck-output-test.js b/ui-v2/tests/integration/components/healthcheck-output-test.js new file mode 100644 index 0000000000..b72e7412f9 --- /dev/null +++ b/ui-v2/tests/integration/components/healthcheck-output-test.js @@ -0,0 +1,34 @@ +import { moduleForComponent, test } from 'ember-qunit'; +import hbs from 'htmlbars-inline-precompile'; + +moduleForComponent('healthcheck-output', 'Integration | Component | healthcheck output', { + integration: true, +}); + +test('it renders', function(assert) { + // Set any properties with this.set('myProperty', 'value'); + // Handle any actions with this.on('myAction', function(val) { ... }); + + this.render(hbs`{{healthcheck-output}}`); + + assert.notEqual( + this.$() + .text() + .trim() + .indexOf('Output'), + -1 + ); + + // Template block usage: + this.render(hbs` + {{#healthcheck-output}}{{/healthcheck-output}} + `); + + assert.notEqual( + this.$() + .text() + .trim() + .indexOf('Output'), + -1 + ); +}); diff --git a/ui-v2/tests/integration/components/healthcheck-status-test.js b/ui-v2/tests/integration/components/healthcheck-status-test.js index b19207e5a4..f4e9bd78ff 100644 --- a/ui-v2/tests/integration/components/healthcheck-status-test.js +++ b/ui-v2/tests/integration/components/healthcheck-status-test.js @@ -10,25 +10,11 @@ test('it renders', function(assert) { // Handle any actions with this.on('myAction', function(val) { ... }); this.render(hbs`{{healthcheck-status}}`); - - assert.notEqual( - this.$() - .text() - .trim() - .indexOf('Output'), - -1 - ); + assert.equal(this.$('dt').length, 1); // Template block usage: this.render(hbs` {{#healthcheck-status}}{{/healthcheck-status}} `); - - assert.notEqual( - this.$() - .text() - .trim() - .indexOf('Output'), - -1 - ); + assert.equal(this.$('dt').length, 1); }); diff --git a/ui-v2/tests/integration/components/tag-list-test.js b/ui-v2/tests/integration/components/tag-list-test.js new file mode 100644 index 0000000000..6924c8562c --- /dev/null +++ b/ui-v2/tests/integration/components/tag-list-test.js @@ -0,0 +1,33 @@ +import { moduleForComponent, test } from 'ember-qunit'; +import hbs from 'htmlbars-inline-precompile'; + +moduleForComponent('tag-list', 'Integration | Component | tag list', { + integration: true, +}); + +test('it renders', function(assert) { + // Set any properties with this.set('myProperty', 'value'); + // Handle any actions with this.on('myAction', function(val) { ... }); + + this.render(hbs`{{tag-list}}`); + + assert.equal( + this.$() + .text() + .trim(), + '' + ); + + // Template block usage: + this.render(hbs` + {{#tag-list}} + {{/tag-list}} + `); + + assert.equal( + this.$() + .text() + .trim(), + '' + ); +}); diff --git a/ui-v2/tests/pages/dc/services/show.js b/ui-v2/tests/pages/dc/services/show.js index f50c60c834..5b5fb2e381 100644 --- a/ui-v2/tests/pages/dc/services/show.js +++ b/ui-v2/tests/pages/dc/services/show.js @@ -2,18 +2,8 @@ export default function(visitable, attribute, collection, text, filter) { return { visit: visitable('/:dc/services/:service'), externalSource: attribute('data-test-external-source', 'h1 span'), - nodes: collection('[data-test-node]', { - name: attribute('data-test-node'), - }), - healthy: collection('[data-test-healthy] [data-test-node]', { - name: attribute('data-test-node'), - address: text('header strong'), - id: text('header em'), - }), - unhealthy: collection('[data-test-unhealthy] [data-test-node]', { - name: attribute('data-test-node'), - address: text('header strong'), - id: text('header em'), + instances: collection('#instances [data-test-tabular-row]', { + address: text('[data-test-address]'), }), filter: filter, }; diff --git a/ui-v2/tests/unit/adapters/proxy-test.js b/ui-v2/tests/unit/adapters/proxy-test.js new file mode 100644 index 0000000000..13859457ed --- /dev/null +++ b/ui-v2/tests/unit/adapters/proxy-test.js @@ -0,0 +1,12 @@ +import { module, test } from 'qunit'; +import { setupTest } from 'ember-qunit'; + +module('Unit | Adapter | proxy', function(hooks) { + setupTest(hooks); + + // Replace this with your real tests. + test('it exists', function(assert) { + let adapter = this.owner.lookup('adapter:proxy'); + assert.ok(adapter); + }); +}); diff --git a/ui-v2/tests/unit/controllers/dc/services/instance-test.js b/ui-v2/tests/unit/controllers/dc/services/instance-test.js new file mode 100644 index 0000000000..2b0693934f --- /dev/null +++ b/ui-v2/tests/unit/controllers/dc/services/instance-test.js @@ -0,0 +1,12 @@ +import { moduleFor, test } from 'ember-qunit'; + +moduleFor('controller:dc/services/instance', 'Unit | Controller | dc/services/instance', { + // Specify the other units that are required for this test. + // needs: ['controller:foo'] +}); + +// Replace this with your real tests. +test('it exists', function(assert) { + let controller = this.subject(); + assert.ok(controller); +}); diff --git a/ui-v2/tests/unit/models/proxy-test.js b/ui-v2/tests/unit/models/proxy-test.js new file mode 100644 index 0000000000..b37e80f56d --- /dev/null +++ b/ui-v2/tests/unit/models/proxy-test.js @@ -0,0 +1,14 @@ +import { module, test } from 'qunit'; +import { setupTest } from 'ember-qunit'; +import { run } from '@ember/runloop'; + +module('Unit | Model | proxy', function(hooks) { + setupTest(hooks); + + // Replace this with your real tests. + test('it exists', function(assert) { + let store = this.owner.lookup('service:store'); + let model = run(() => store.createRecord('proxy', {})); + assert.ok(model); + }); +}); diff --git a/ui-v2/tests/unit/routes/dc/services/instance-test.js b/ui-v2/tests/unit/routes/dc/services/instance-test.js new file mode 100644 index 0000000000..122dc9ee16 --- /dev/null +++ b/ui-v2/tests/unit/routes/dc/services/instance-test.js @@ -0,0 +1,11 @@ +import { moduleFor, test } from 'ember-qunit'; + +moduleFor('route:dc/services/instance', 'Unit | Route | dc/services/instance', { + // Specify the other units that are required for this test. + needs: ['service:repository/service', 'service:repository/proxy'], +}); + +test('it exists', function(assert) { + let route = this.subject(); + assert.ok(route); +}); diff --git a/ui-v2/tests/unit/serializers/proxy-test.js b/ui-v2/tests/unit/serializers/proxy-test.js new file mode 100644 index 0000000000..44090cfe02 --- /dev/null +++ b/ui-v2/tests/unit/serializers/proxy-test.js @@ -0,0 +1,24 @@ +import { module, test } from 'qunit'; +import { setupTest } from 'ember-qunit'; +import { run } from '@ember/runloop'; + +module('Unit | Serializer | proxy', function(hooks) { + setupTest(hooks); + + // Replace this with your real tests. + test('it exists', function(assert) { + let store = this.owner.lookup('service:store'); + let serializer = store.serializerFor('proxy'); + + assert.ok(serializer); + }); + + test('it serializes records', function(assert) { + let store = this.owner.lookup('service:store'); + let record = run(() => store.createRecord('proxy', {})); + + let serializedRecord = record.serialize(); + + assert.ok(serializedRecord); + }); +}); From 8f35715e47208bb39516e084ff94087ebb518362 Mon Sep 17 00:00:00 2001 From: John Cowen Date: Thu, 21 Feb 2019 13:57:13 +0000 Subject: [PATCH 28/52] UI: Service Numbers (#5348) Add totals to some listing views, remove healthcheck totals 1. Adds markup to render totals for Services, Nodes, Intentions and v1 ACLs 2. Removes counts from healthcheck filters, and therefore simplify text, moving the copy to the templates 3. Alter test to reflect the fact that the text of the buttons are no static in the component template rather than a dynamic attribute --- ui-v2/app/mixins/with-health-filtering.js | 37 ------------------- ui-v2/app/styles/components/app-view.scss | 3 ++ ui-v2/app/styles/core/typography.scss | 9 +++-- .../templates/components/catalog-filter.hbs | 7 +++- ui-v2/app/templates/dc/acls/index.hbs | 2 +- ui-v2/app/templates/dc/intentions/index.hbs | 2 +- ui-v2/app/templates/dc/nodes/index.hbs | 4 +- ui-v2/app/templates/dc/services/index.hbs | 4 +- .../components/catalog-filter-test.js | 14 +------ 9 files changed, 23 insertions(+), 59 deletions(-) diff --git a/ui-v2/app/mixins/with-health-filtering.js b/ui-v2/app/mixins/with-health-filtering.js index 06ad378261..ee20846897 100644 --- a/ui-v2/app/mixins/with-health-filtering.js +++ b/ui-v2/app/mixins/with-health-filtering.js @@ -1,25 +1,6 @@ import Mixin from '@ember/object/mixin'; import WithFiltering from 'consul-ui/mixins/with-filtering'; -import { computed, get } from '@ember/object'; -import ucfirst from 'consul-ui/utils/ucfirst'; -const countStatus = function(items, status) { - if (status === '') { - return get(items, 'length'); - } - const key = `Checks${ucfirst(status)}`; - return items.reduce(function(prev, item, i, arr) { - const num = get(item, key); - return ( - prev + - (typeof num !== 'undefined' - ? num - : get(item, 'Checks').filter(function(item) { - return item.Status === status; - }).length) || 0 - ); - }, 0); -}; export default Mixin.create(WithFiltering, { queryParams: { status: { @@ -29,22 +10,4 @@ export default Mixin.create(WithFiltering, { as: 'filter', }, }, - healthFilters: computed('items.[]', function() { - const items = get(this, 'items'); - const objs = ['', 'passing', 'warning', 'critical'].map(function(item) { - const count = countStatus(items, item); - return { - count: count, - label: `${item === '' ? 'All' : ucfirst(item)} (${count.toLocaleString()})`, - value: item, - }; - }); - objs[0].label = `All (${objs - .slice(1) - .reduce(function(prev, item, i, arr) { - return prev + item.count; - }, 0) - .toLocaleString()})`; - return objs; - }), }); diff --git a/ui-v2/app/styles/components/app-view.scss b/ui-v2/app/styles/components/app-view.scss index 8b4c559755..2ddc65d217 100644 --- a/ui-v2/app/styles/components/app-view.scss +++ b/ui-v2/app/styles/components/app-view.scss @@ -18,6 +18,9 @@ main { %app-view h1 span { @extend %with-external-source-icon; } +%app-view h1 em { + color: $gray-600; +} %app-view header .actions a, %app-view header .actions button { @extend %button-compact; diff --git a/ui-v2/app/styles/core/typography.scss b/ui-v2/app/styles/core/typography.scss index bc945842a8..5f01d4be8e 100644 --- a/ui-v2/app/styles/core/typography.scss +++ b/ui-v2/app/styles/core/typography.scss @@ -61,12 +61,14 @@ main label a[rel*='help'], td:first-child em, %pill, %form-element > strong, -%healthchecked-resource strong { +%healthchecked-resource strong, +%app-view h1 em { font-weight: $typo-weight-normal; } %form-element > em, td:first-child em, -%healthchecked-resource header em { +%healthchecked-resource header em, +%app-view h1 em { font-style: normal; } %footer > * { @@ -76,7 +78,8 @@ h1 { font-size: $typo-header-100; } h2, -%header-drop-nav .is-active { +%header-drop-nav .is-active, +%app-view h1 em { font-size: $typo-size-500; } body, diff --git a/ui-v2/app/templates/components/catalog-filter.hbs b/ui-v2/app/templates/components/catalog-filter.hbs index 9d95acc258..5185b0921e 100644 --- a/ui-v2/app/templates/components/catalog-filter.hbs +++ b/ui-v2/app/templates/components/catalog-filter.hbs @@ -1,4 +1,9 @@ {{!
    }} {{freetext-filter searchable=searchable value=search placeholder="Search by name"}} - {{radio-group name="status" value=status items=filters onchange=(action onchange)}} + {{radio-group name="status" value=status items=(array + (hash label='All (Any Status)' value='' ) + (hash label='Critical Checks' value='critical') + (hash label='Warning Checks' value='warning') + (hash label='Passing Checks' value='passing') + ) onchange=(action onchange)}} {{!
    }} diff --git a/ui-v2/app/templates/dc/acls/index.hbs b/ui-v2/app/templates/dc/acls/index.hbs index 3617db3c93..497ec5bffa 100644 --- a/ui-v2/app/templates/dc/acls/index.hbs +++ b/ui-v2/app/templates/dc/acls/index.hbs @@ -4,7 +4,7 @@ {{/block-slot}} {{#block-slot 'header'}}

    - ACL Tokens + ACL Tokens {{format-number items.length}} total

    {{/block-slot}} diff --git a/ui-v2/app/templates/dc/intentions/index.hbs b/ui-v2/app/templates/dc/intentions/index.hbs index db4540389f..b521d8b6e7 100644 --- a/ui-v2/app/templates/dc/intentions/index.hbs +++ b/ui-v2/app/templates/dc/intentions/index.hbs @@ -4,7 +4,7 @@ {{/block-slot}} {{#block-slot 'header'}}

    - Intentions + Intentions {{format-number items.length}} total

    {{/block-slot}} diff --git a/ui-v2/app/templates/dc/nodes/index.hbs b/ui-v2/app/templates/dc/nodes/index.hbs index 0d9ca57ed4..82d93a5260 100644 --- a/ui-v2/app/templates/dc/nodes/index.hbs +++ b/ui-v2/app/templates/dc/nodes/index.hbs @@ -1,13 +1,13 @@ {{#app-view class="node list"}} {{#block-slot 'header'}}

    - Nodes + Nodes {{format-number items.length}} total

    {{/block-slot}} {{#block-slot 'toolbar'}} {{#if (gt items.length 0) }} - {{catalog-filter searchable=(array searchableHealthy searchableUnhealthy) filters=healthFilters search=s status=filters.status onchange=(action 'filter')}} + {{catalog-filter searchable=(array searchableHealthy searchableUnhealthy) search=s status=filters.status onchange=(action 'filter')}} {{/if}} {{/block-slot}} {{#block-slot 'content'}} diff --git a/ui-v2/app/templates/dc/services/index.hbs b/ui-v2/app/templates/dc/services/index.hbs index 7306ea10f3..16f287eb4d 100644 --- a/ui-v2/app/templates/dc/services/index.hbs +++ b/ui-v2/app/templates/dc/services/index.hbs @@ -5,13 +5,13 @@ {{/block-slot}} {{#block-slot 'header'}}

    - Services + Services {{format-number items.length}} total

    {{/block-slot}} {{#block-slot 'toolbar'}} {{#if (gt items.length 0) }} - {{catalog-filter searchable=searchable filters=healthFilters search=filters.s status=filters.status onchange=(action 'filter')}} + {{catalog-filter searchable=searchable search=filters.s status=filters.status onchange=(action 'filter')}} {{/if}} {{/block-slot}} {{#block-slot 'content'}} diff --git a/ui-v2/tests/integration/components/catalog-filter-test.js b/ui-v2/tests/integration/components/catalog-filter-test.js index e4ba4793f9..df2e4fd750 100644 --- a/ui-v2/tests/integration/components/catalog-filter-test.js +++ b/ui-v2/tests/integration/components/catalog-filter-test.js @@ -11,22 +11,12 @@ test('it renders', function(assert) { this.render(hbs`{{catalog-filter}}`); - assert.equal( - this.$() - .text() - .trim(), - 'Search' - ); + assert.equal(this.$().find('form').length, 1); // Template block usage: this.render(hbs` {{#catalog-filter}}{{/catalog-filter}} `); - assert.equal( - this.$() - .text() - .trim(), - 'Search' - ); + assert.equal(this.$().find('form').length, 1); }); From 18b0de2785e61b176855c44d45158763846b5cf7 Mon Sep 17 00:00:00 2001 From: John Cowen Date: Thu, 21 Feb 2019 16:25:20 +0000 Subject: [PATCH 29/52] UI: Use custom block-slots for changeableset --- ui-v2/app/components/changeable-set.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ui-v2/app/components/changeable-set.js b/ui-v2/app/components/changeable-set.js index f9d88d91c4..9a50c24293 100644 --- a/ui-v2/app/components/changeable-set.js +++ b/ui-v2/app/components/changeable-set.js @@ -1,6 +1,6 @@ import Component from '@ember/component'; import { get, set } from '@ember/object'; -import SlotsMixin from 'ember-block-slots'; +import SlotsMixin from 'block-slots'; import WithListeners from 'consul-ui/mixins/with-listeners'; export default Component.extend(WithListeners, SlotsMixin, { From 11ccea88e16fa2d3fe2405778717dc0394dda8d8 Mon Sep 17 00:00:00 2001 From: John Cowen Date: Thu, 7 Mar 2019 10:19:48 +0100 Subject: [PATCH 30/52] ui: Amend breakpoints for new numberless filter buttons (#5381) --- ui-v2/app/styles/variables/custom-query.scss | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/ui-v2/app/styles/variables/custom-query.scss b/ui-v2/app/styles/variables/custom-query.scss index 8e7160e678..3f31e55818 100644 --- a/ui-v2/app/styles/variables/custom-query.scss +++ b/ui-v2/app/styles/variables/custom-query.scss @@ -1,9 +1,9 @@ $ideal-width: 1260px; -$--horizontal-filters: '(min-width: 850px)'; -$--lt-horizontal-filters: '(max-width: 849px)'; +$--horizontal-filters: '(min-width: 910px)'; +$--lt-horizontal-filters: '(max-width: 909px)'; -$--horizontal-selects: '(min-width: 615px)'; -$--lt-horizontal-selects: '(max-width: 614px)'; +$--horizontal-selects: '(min-width: 670px)'; +$--lt-horizontal-selects: '(max-width: 669px)'; $--horizontal-nav: '(min-width: 850px)'; $--lt-horizontal-nav: '(max-width: 849px)'; From 20e1a39aef9e96e88e4669ca4d94c20219ded137 Mon Sep 17 00:00:00 2001 From: John Cowen Date: Thu, 7 Mar 2019 10:20:54 +0100 Subject: [PATCH 31/52] ui: remove call to super in setupController (#5383) --- ui-v2/app/routes/dc.js | 1 - ui-v2/app/routes/dc/acls/create.js | 1 - ui-v2/app/routes/dc/acls/edit.js | 1 - ui-v2/app/routes/dc/acls/index.js | 1 - ui-v2/app/routes/dc/acls/policies/edit.js | 1 - ui-v2/app/routes/dc/acls/policies/index.js | 1 - ui-v2/app/routes/dc/acls/tokens/edit.js | 1 - ui-v2/app/routes/dc/acls/tokens/index.js | 1 - ui-v2/app/routes/dc/intentions/create.js | 1 - ui-v2/app/routes/dc/intentions/edit.js | 1 - ui-v2/app/routes/dc/intentions/index.js | 1 - ui-v2/app/routes/dc/kv/create.js | 1 - ui-v2/app/routes/dc/kv/edit.js | 1 - ui-v2/app/routes/dc/kv/index.js | 1 - ui-v2/app/routes/dc/nodes/index.js | 1 - ui-v2/app/routes/dc/nodes/show.js | 1 - ui-v2/app/routes/dc/services/index.js | 1 - ui-v2/app/routes/dc/services/instance.js | 1 - ui-v2/app/routes/dc/services/show.js | 1 - ui-v2/app/routes/settings.js | 1 - 20 files changed, 20 deletions(-) diff --git a/ui-v2/app/routes/dc.js b/ui-v2/app/routes/dc.js index 6b8f466a46..b795f8f773 100644 --- a/ui-v2/app/routes/dc.js +++ b/ui-v2/app/routes/dc.js @@ -19,7 +19,6 @@ export default Route.extend({ }); }, setupController: function(controller, model) { - this._super(...arguments); controller.setProperties(model); }, }); diff --git a/ui-v2/app/routes/dc/acls/create.js b/ui-v2/app/routes/dc/acls/create.js index 315a07fb60..a424260e88 100644 --- a/ui-v2/app/routes/dc/acls/create.js +++ b/ui-v2/app/routes/dc/acls/create.js @@ -22,7 +22,6 @@ export default Route.extend(WithAclActions, { }); }, setupController: function(controller, model) { - this._super(...arguments); controller.setProperties(model); }, deactivate: function() { diff --git a/ui-v2/app/routes/dc/acls/edit.js b/ui-v2/app/routes/dc/acls/edit.js index 565f90bbe7..d016c15bf8 100644 --- a/ui-v2/app/routes/dc/acls/edit.js +++ b/ui-v2/app/routes/dc/acls/edit.js @@ -16,7 +16,6 @@ export default Route.extend(WithAclActions, { }); }, setupController: function(controller, model) { - this._super(...arguments); controller.setProperties(model); }, }); diff --git a/ui-v2/app/routes/dc/acls/index.js b/ui-v2/app/routes/dc/acls/index.js index b36a9e3527..b7e521b8e3 100644 --- a/ui-v2/app/routes/dc/acls/index.js +++ b/ui-v2/app/routes/dc/acls/index.js @@ -36,7 +36,6 @@ export default Route.extend(WithAclActions, { }); }, setupController: function(controller, model) { - this._super(...arguments); controller.setProperties(model); }, }); diff --git a/ui-v2/app/routes/dc/acls/policies/edit.js b/ui-v2/app/routes/dc/acls/policies/edit.js index 3e09d345e8..0940813c4f 100644 --- a/ui-v2/app/routes/dc/acls/policies/edit.js +++ b/ui-v2/app/routes/dc/acls/policies/edit.js @@ -31,7 +31,6 @@ export default SingleRoute.extend(WithPolicyActions, { }); }, setupController: function(controller, model) { - this._super(...arguments); controller.setProperties(model); }, }); diff --git a/ui-v2/app/routes/dc/acls/policies/index.js b/ui-v2/app/routes/dc/acls/policies/index.js index 6ab9d1a704..094b4a1457 100644 --- a/ui-v2/app/routes/dc/acls/policies/index.js +++ b/ui-v2/app/routes/dc/acls/policies/index.js @@ -23,7 +23,6 @@ export default Route.extend(WithPolicyActions, { }); }, setupController: function(controller, model) { - this._super(...arguments); controller.setProperties(model); }, }); diff --git a/ui-v2/app/routes/dc/acls/tokens/edit.js b/ui-v2/app/routes/dc/acls/tokens/edit.js index 8371f86ab5..384a521337 100644 --- a/ui-v2/app/routes/dc/acls/tokens/edit.js +++ b/ui-v2/app/routes/dc/acls/tokens/edit.js @@ -38,7 +38,6 @@ export default SingleRoute.extend(WithTokenActions, { }); }, setupController: function(controller, model) { - this._super(...arguments); controller.setProperties(model); }, getEmptyPolicy: function() { diff --git a/ui-v2/app/routes/dc/acls/tokens/index.js b/ui-v2/app/routes/dc/acls/tokens/index.js index 757871e013..2f3e21afcb 100644 --- a/ui-v2/app/routes/dc/acls/tokens/index.js +++ b/ui-v2/app/routes/dc/acls/tokens/index.js @@ -36,7 +36,6 @@ export default Route.extend(WithTokenActions, { }); }, setupController: function(controller, model) { - this._super(...arguments); controller.setProperties(model); }, }); diff --git a/ui-v2/app/routes/dc/intentions/create.js b/ui-v2/app/routes/dc/intentions/create.js index a52a8429d5..cf49eac252 100644 --- a/ui-v2/app/routes/dc/intentions/create.js +++ b/ui-v2/app/routes/dc/intentions/create.js @@ -32,7 +32,6 @@ export default Route.extend(WithIntentionActions, { }); }, setupController: function(controller, model) { - this._super(...arguments); controller.setProperties(model); }, deactivate: function() { diff --git a/ui-v2/app/routes/dc/intentions/edit.js b/ui-v2/app/routes/dc/intentions/edit.js index 16b085dbe9..138ead24e5 100644 --- a/ui-v2/app/routes/dc/intentions/edit.js +++ b/ui-v2/app/routes/dc/intentions/edit.js @@ -26,7 +26,6 @@ export default Route.extend(WithAclActions, { }); }, setupController: function(controller, model) { - this._super(...arguments); controller.setProperties(model); }, }); diff --git a/ui-v2/app/routes/dc/intentions/index.js b/ui-v2/app/routes/dc/intentions/index.js index ec41bf54aa..750f11c0cc 100644 --- a/ui-v2/app/routes/dc/intentions/index.js +++ b/ui-v2/app/routes/dc/intentions/index.js @@ -19,7 +19,6 @@ export default Route.extend(WithIntentionActions, { }); }, setupController: function(controller, model) { - this._super(...arguments); controller.setProperties(model); }, }); diff --git a/ui-v2/app/routes/dc/kv/create.js b/ui-v2/app/routes/dc/kv/create.js index 44ec169e58..d63ce89338 100644 --- a/ui-v2/app/routes/dc/kv/create.js +++ b/ui-v2/app/routes/dc/kv/create.js @@ -24,7 +24,6 @@ export default Route.extend(WithKvActions, { }); }, setupController: function(controller, model) { - this._super(...arguments); controller.setProperties(model); }, deactivate: function() { diff --git a/ui-v2/app/routes/dc/kv/edit.js b/ui-v2/app/routes/dc/kv/edit.js index 170e512f6a..44c13a5f15 100644 --- a/ui-v2/app/routes/dc/kv/edit.js +++ b/ui-v2/app/routes/dc/kv/edit.js @@ -32,7 +32,6 @@ export default Route.extend(WithKvActions, { }); }, setupController: function(controller, model) { - this._super(...arguments); controller.setProperties(model); }, }); diff --git a/ui-v2/app/routes/dc/kv/index.js b/ui-v2/app/routes/dc/kv/index.js index 21ef89dc05..ad2a8a7b73 100644 --- a/ui-v2/app/routes/dc/kv/index.js +++ b/ui-v2/app/routes/dc/kv/index.js @@ -55,7 +55,6 @@ export default Route.extend(WithKvActions, { }, }, setupController: function(controller, model) { - this._super(...arguments); controller.setProperties(model); }, }); diff --git a/ui-v2/app/routes/dc/nodes/index.js b/ui-v2/app/routes/dc/nodes/index.js index 5488397458..8cbd56ecdc 100644 --- a/ui-v2/app/routes/dc/nodes/index.js +++ b/ui-v2/app/routes/dc/nodes/index.js @@ -17,7 +17,6 @@ export default Route.extend({ }); }, setupController: function(controller, model) { - this._super(...arguments); controller.setProperties(model); }, }); diff --git a/ui-v2/app/routes/dc/nodes/show.js b/ui-v2/app/routes/dc/nodes/show.js index 9d9f344588..0449afc2df 100644 --- a/ui-v2/app/routes/dc/nodes/show.js +++ b/ui-v2/app/routes/dc/nodes/show.js @@ -41,7 +41,6 @@ export default Route.extend(WithBlockingActions, { }); }, setupController: function(controller, model) { - this._super(...arguments); controller.setProperties(model); }, actions: { diff --git a/ui-v2/app/routes/dc/services/index.js b/ui-v2/app/routes/dc/services/index.js index 7b74816dbd..2b900f502e 100644 --- a/ui-v2/app/routes/dc/services/index.js +++ b/ui-v2/app/routes/dc/services/index.js @@ -18,7 +18,6 @@ export default Route.extend({ }); }, setupController: function(controller, model) { - this._super(...arguments); controller.setProperties(model); }, }); diff --git a/ui-v2/app/routes/dc/services/instance.js b/ui-v2/app/routes/dc/services/instance.js index da863ba418..b358fdd396 100644 --- a/ui-v2/app/routes/dc/services/instance.js +++ b/ui-v2/app/routes/dc/services/instance.js @@ -23,7 +23,6 @@ export default Route.extend({ }); }, setupController: function(controller, model) { - this._super(...arguments); controller.setProperties(model); }, }); diff --git a/ui-v2/app/routes/dc/services/show.js b/ui-v2/app/routes/dc/services/show.js index 9376abdbc1..3757421cd8 100644 --- a/ui-v2/app/routes/dc/services/show.js +++ b/ui-v2/app/routes/dc/services/show.js @@ -26,7 +26,6 @@ export default Route.extend({ }); }, setupController: function(controller, model) { - this._super(...arguments); controller.setProperties(model); }, }); diff --git a/ui-v2/app/routes/settings.js b/ui-v2/app/routes/settings.js index 0dc59dc02c..d62cb6b9c8 100644 --- a/ui-v2/app/routes/settings.js +++ b/ui-v2/app/routes/settings.js @@ -21,7 +21,6 @@ export default Route.extend({ }); }, setupController: function(controller, model) { - this._super(...arguments); controller.setProperties(model); }, actions: { From 5dd9cd2d2e3991425e1baef0431cb15226ad6803 Mon Sep 17 00:00:00 2001 From: John Cowen Date: Thu, 7 Mar 2019 11:51:39 +0100 Subject: [PATCH 32/52] UI: Add forking based on service instance id existence (#5392) * ui: Add forking based on service instance id existence Proxies come in 2 flavours, 'normal' and sidecar. We know when a proxy is a sidecar proxy based on whether a DestinationServiceID is set. LocalServiceAddress and LocalServicePort are only relevant for sidecar proxies. This adds template logic to show different text depending on this information. Additionally adds test around connect proxies (#5418) 1. Adds page object for the instance detail page 2. Adds further scenario steps used in the tests 3. Adds acceptance testing around the instance detail page. Services with proxies and the sidecar proxies and proxies themselves 4. Adds datacenter column for upstreams 5. Fixes bug routing bug for decision as to whether to request proxy information or not --- ui-v2/app/models/proxy.js | 2 +- ui-v2/app/routes/dc/services/instance.js | 6 +- ui-v2/app/services/repository/proxy.js | 6 +- ui-v2/app/services/repository/service.js | 10 ++- .../styles/components/tabular-collection.scss | 2 +- .../templates/components/healthcheck-list.hbs | 2 +- .../app/templates/dc/services/-upstreams.hbs | 18 +++-- ui-v2/app/templates/dc/services/instance.hbs | 11 ++- .../dc/services/instances/error.feature | 15 ++++ .../dc/services/instances/proxy.feature | 48 +++++++++++++ .../dc/services/instances/show.feature | 68 +++++++++++++++++++ .../services/instances/sidecar-proxy.feature | 29 ++++++++ .../dc/services/instances/with-proxy.feature | 20 ++++++ .../services/instances/with-sidecar.feature | 22 ++++++ .../dc/services/instances/error-steps.js | 10 +++ .../dc/services/instances/proxy-steps.js | 10 +++ .../steps/dc/services/instances/show-steps.js | 10 +++ .../services/instances/sidecar-proxy-steps.js | 10 +++ .../dc/services/instances/with-proxy-steps.js | 10 +++ .../services/instances/with-sidecar-steps.js | 10 +++ ui-v2/tests/helpers/type-to-url.js | 3 + ui-v2/tests/pages.js | 2 + ui-v2/tests/pages/dc/services/instance.js | 19 ++++++ ui-v2/tests/steps/assertions/dom.js | 6 ++ ui-v2/tests/steps/assertions/model.js | 5 +- ui-v2/tests/steps/assertions/page.js | 24 +++++-- ui-v2/yarn.lock | 6 +- 27 files changed, 356 insertions(+), 28 deletions(-) create mode 100644 ui-v2/tests/acceptance/dc/services/instances/error.feature create mode 100644 ui-v2/tests/acceptance/dc/services/instances/proxy.feature create mode 100644 ui-v2/tests/acceptance/dc/services/instances/show.feature create mode 100644 ui-v2/tests/acceptance/dc/services/instances/sidecar-proxy.feature create mode 100644 ui-v2/tests/acceptance/dc/services/instances/with-proxy.feature create mode 100644 ui-v2/tests/acceptance/dc/services/instances/with-sidecar.feature create mode 100644 ui-v2/tests/acceptance/steps/dc/services/instances/error-steps.js create mode 100644 ui-v2/tests/acceptance/steps/dc/services/instances/proxy-steps.js create mode 100644 ui-v2/tests/acceptance/steps/dc/services/instances/show-steps.js create mode 100644 ui-v2/tests/acceptance/steps/dc/services/instances/sidecar-proxy-steps.js create mode 100644 ui-v2/tests/acceptance/steps/dc/services/instances/with-proxy-steps.js create mode 100644 ui-v2/tests/acceptance/steps/dc/services/instances/with-sidecar-steps.js create mode 100644 ui-v2/tests/pages/dc/services/instance.js diff --git a/ui-v2/app/models/proxy.js b/ui-v2/app/models/proxy.js index 9e08582199..3e4fb58228 100644 --- a/ui-v2/app/models/proxy.js +++ b/ui-v2/app/models/proxy.js @@ -8,5 +8,5 @@ export default Model.extend({ [SLUG_KEY]: attr('string'), ServiceName: attr('string'), ServiceID: attr('string'), - ServiceProxyDestination: attr('string'), + ServiceProxy: attr(), }); diff --git a/ui-v2/app/routes/dc/services/instance.js b/ui-v2/app/routes/dc/services/instance.js index b358fdd396..b4d0f1c5b9 100644 --- a/ui-v2/app/routes/dc/services/instance.js +++ b/ui-v2/app/routes/dc/services/instance.js @@ -15,9 +15,9 @@ export default Route.extend({ }).then(function(model) { return hash({ proxy: - get(service, 'Kind') !== 'connect-proxy' - ? proxyRepo.findInstanceBySlug(params.id, params.name, dc) - : null, + get(model.item, 'Kind') === 'connect-proxy' + ? null + : proxyRepo.findInstanceBySlug(params.id, params.name, dc), ...model, }); }); diff --git a/ui-v2/app/services/repository/proxy.js b/ui-v2/app/services/repository/proxy.js index ce8c055d83..41f1207b4e 100644 --- a/ui-v2/app/services/repository/proxy.js +++ b/ui-v2/app/services/repository/proxy.js @@ -22,7 +22,11 @@ export default RepositoryService.extend({ findInstanceBySlug: function(id, slug, dc, configuration) { return this.findAllBySlug(slug, dc, configuration).then(function(items) { if (get(items, 'length') > 0) { - const instance = items.findBy('ServiceProxyDestination', id); + let instance = items.findBy('ServiceProxy.DestinationServiceID', id); + if (instance) { + return instance; + } + instance = items.findBy('ServiceProxy.DestinationServiceName', slug); if (instance) { return instance; } diff --git a/ui-v2/app/services/repository/service.js b/ui-v2/app/services/repository/service.js index 2da90a3c72..61ef1d61cd 100644 --- a/ui-v2/app/services/repository/service.js +++ b/ui-v2/app/services/repository/service.js @@ -35,7 +35,15 @@ export default RepositoryService.extend({ }); return service; } - // TODO: probably need to throw a 404 here? + // TODO: Add an store.error("404", "message") or similar + const e = new Error(); + e.errors = [ + { + status: '404', + title: 'Unable to find instance', + }, + ]; + throw e; }); }, }); diff --git a/ui-v2/app/styles/components/tabular-collection.scss b/ui-v2/app/styles/components/tabular-collection.scss index 53d6678ef6..d26b72e656 100644 --- a/ui-v2/app/styles/components/tabular-collection.scss +++ b/ui-v2/app/styles/components/tabular-collection.scss @@ -181,5 +181,5 @@ html.template-node.template-show main table.sessions tr { width: calc(100% / 5); } %upstreams-row > * { - width: calc(100% / 3); + width: calc(100% / 4); } diff --git a/ui-v2/app/templates/components/healthcheck-list.hbs b/ui-v2/app/templates/components/healthcheck-list.hbs index 4b5774588e..babca35d74 100644 --- a/ui-v2/app/templates/components/healthcheck-list.hbs +++ b/ui-v2/app/templates/components/healthcheck-list.hbs @@ -1,4 +1,4 @@ -
      +
        {{#each (sort-by (action 'sortChecksByImportance') items) as |check| }} {{healthcheck-output data-test-node-healthcheck=check.Name tagName='li' name=check.Name class=check.Status status=check.Status notes=check.Notes output=check.Output}} {{/each}} diff --git a/ui-v2/app/templates/dc/services/-upstreams.hbs b/ui-v2/app/templates/dc/services/-upstreams.hbs index f4ad6fcc81..62031ba28e 100644 --- a/ui-v2/app/templates/dc/services/-upstreams.hbs +++ b/ui-v2/app/templates/dc/services/-upstreams.hbs @@ -4,19 +4,23 @@ items=item.Proxy.Upstreams as |item index| }} {{#block-slot 'header'}} - Destination Name - Destination Type - Local Bind Port + Upstream + Datacenter + Type + Local Bind Address {{/block-slot}} {{#block-slot 'row'}} - - {{item.DestinationName}} + + {{item.DestinationName}} + + + {{item.Datacenter}} {{item.DestinationType}} - - {{item.LocalBindPort}} + + {{item.LocalBindAddress}}:{{item.LocalBindPort}} {{/block-slot}} {{/tabular-collection}} diff --git a/ui-v2/app/templates/dc/services/instance.hbs b/ui-v2/app/templates/dc/services/instance.hbs index 0638552104..9b3a286ba4 100644 --- a/ui-v2/app/templates/dc/services/instance.hbs +++ b/ui-v2/app/templates/dc/services/instance.hbs @@ -27,19 +27,26 @@ {{#if proxy}}
        -
        Sidecar Proxy
        +
        {{if proxy.ServiceProxy.DestinationServiceID "Sidecar " ""}}Proxy
        {{proxy.ServiceID}}
        {{/if}} {{#if (eq item.Kind 'connect-proxy')}} + {{#if item.Proxy.DestinationServiceID}}
        -
        Dest. Service Instance
        +
        Dest. Service Instance
        {{item.Proxy.DestinationServiceID}}
        Local Service Address
        {{item.Proxy.LocalServiceAddress}}:{{item.Proxy.LocalServicePort}}
        + {{else}} +
        +
        Dest. Service
        +
        {{item.Proxy.DestinationServiceName}}
        +
        + {{/if}} {{/if}} {{/block-slot}} {{#block-slot 'content'}} diff --git a/ui-v2/tests/acceptance/dc/services/instances/error.feature b/ui-v2/tests/acceptance/dc/services/instances/error.feature new file mode 100644 index 0000000000..bf4ef1dc6e --- /dev/null +++ b/ui-v2/tests/acceptance/dc/services/instances/error.feature @@ -0,0 +1,15 @@ +@setupApplicationTest +Feature: dc / services / instances / error: Visit Service Instance what doesn't exist + Scenario: No instance can be found in the API response + Given 1 datacenter model with the value "dc1" + And 1 service model + When I visit the instance page for yaml + --- + dc: dc1 + service: service-0 + id: id-that-doesnt-exist + --- + Then the url should be /dc1/services/service-0/id-that-doesnt-exist + And I see the text "404 (Unable to find instance)" in "[data-test-error]" + + diff --git a/ui-v2/tests/acceptance/dc/services/instances/proxy.feature b/ui-v2/tests/acceptance/dc/services/instances/proxy.feature new file mode 100644 index 0000000000..1d69efb6b2 --- /dev/null +++ b/ui-v2/tests/acceptance/dc/services/instances/proxy.feature @@ -0,0 +1,48 @@ +@setupApplicationTest +Feature: dc / services / instances / proxy: Show Proxy Service Instance + Scenario: A Proxy Service instance + Given 1 datacenter model with the value "dc1" + And 1 service model from yaml + --- + - Service: + Kind: connect-proxy + Name: service-0-proxy + ID: service-0-proxy-with-id + Proxy: + DestinationServiceName: service-0 + Upstreams: + - DestinationType: service + DestinationName: service-1 + LocalBindAddress: 127.0.0.1 + LocalBindPort: 1111 + - DestinationType: prepared_query + DestinationName: service-group + LocalBindAddress: 127.0.0.1 + LocalBindPort: 1112 + --- + When I visit the instance page for yaml + --- + dc: dc1 + service: service-0-proxy + id: service-0-proxy-with-id + --- + Then the url should be /dc1/services/service-0-proxy/service-0-proxy-with-id + And I see destination on the proxy like "service" + + And I see serviceChecksIsSelected on the tabs + + When I click upstreams on the tabs + And I see upstreamsIsSelected on the tabs + And I see 2 of the upstreams object + And I see name on the upstreams like yaml + --- + - service-1 + - service-group + --- + And I see type on the upstreams like yaml + --- + - service + - prepared_query + --- + + diff --git a/ui-v2/tests/acceptance/dc/services/instances/show.feature b/ui-v2/tests/acceptance/dc/services/instances/show.feature new file mode 100644 index 0000000000..bf3f64971c --- /dev/null +++ b/ui-v2/tests/acceptance/dc/services/instances/show.feature @@ -0,0 +1,68 @@ +@setupApplicationTest +Feature: dc / services / instances / show: Show Service Instance + Scenario: A Service instance has no Proxy + Given 1 datacenter model with the value "dc1" + And 1 service model from yaml + --- + - Service: + ID: service-0-with-id + Tags: ['Tag1', 'Tag2'] + Meta: + external-source: nomad + Checks: + - Name: Service check + ServiceID: service-0 + Output: Output of check + Status: passing + - Name: Service check + ServiceID: service-0 + Output: Output of check + Status: warning + - Name: Service check + ServiceID: service-0 + Output: Output of check + Status: critical + - Name: Node check + ServiceID: "" + Output: Output of check + Status: passing + - Name: Node check + ServiceID: "" + Output: Output of check + Status: warning + - Name: Node check + ServiceID: "" + Output: Output of check + Status: critical + --- + And 1 proxy model from yaml + --- + - ServiceProxy: + DestinationServiceName: service-1 + DestinationServiceID: ~ + --- + When I visit the instance page for yaml + --- + dc: dc1 + service: service-0 + id: service-0-with-id + --- + Then the url should be /dc1/services/service-0/service-0-with-id + Then I don't see type on the proxy + + Then I see externalSource like "nomad" + + And I don't see upstreams on the tabs + And I see serviceChecksIsSelected on the tabs + And I see 3 of the serviceChecks object + + When I click nodeChecks on the tabs + And I see nodeChecksIsSelected on the tabs + And I see 3 of the nodeChecks object + + When I click tags on the tabs + And I see tagsIsSelected on the tabs + + Then I see the text "Tag1" in "[data-test-tags] span:nth-child(1)" + Then I see the text "Tag2" in "[data-test-tags] span:nth-child(2)" + diff --git a/ui-v2/tests/acceptance/dc/services/instances/sidecar-proxy.feature b/ui-v2/tests/acceptance/dc/services/instances/sidecar-proxy.feature new file mode 100644 index 0000000000..3a650565d9 --- /dev/null +++ b/ui-v2/tests/acceptance/dc/services/instances/sidecar-proxy.feature @@ -0,0 +1,29 @@ +@setupApplicationTest +Feature: dc / services / instances / sidecar-proxy: Show Sidecar Proxy Service Instance + Scenario: A Sidecar Proxy Service instance + Given 1 datacenter model with the value "dc1" + And 1 service model from yaml + --- + - Service: + Kind: connect-proxy + Name: service-0-sidecar-proxy + ID: service-0-sidecar-proxy-with-id + Proxy: + DestinationServiceName: service-0 + DestinationServiceID: service-0-with-id + --- + When I visit the instance page for yaml + --- + dc: dc1 + service: service-0-sidecar-proxy + id: service-0-sidecar-proxy-with-id + --- + Then the url should be /dc1/services/service-0-sidecar-proxy/service-0-sidecar-proxy-with-id + And I see destination on the proxy like "instance" + + And I see serviceChecksIsSelected on the tabs + + When I click upstreams on the tabs + And I see upstreamsIsSelected on the tabs + + diff --git a/ui-v2/tests/acceptance/dc/services/instances/with-proxy.feature b/ui-v2/tests/acceptance/dc/services/instances/with-proxy.feature new file mode 100644 index 0000000000..145bf95cdd --- /dev/null +++ b/ui-v2/tests/acceptance/dc/services/instances/with-proxy.feature @@ -0,0 +1,20 @@ +@setupApplicationTest +Feature: dc / services / instances / with-proxy: Show Service Instance with a proxy + Scenario: A Service instance has a Proxy (no DestinationServiceID) + Given 1 datacenter model with the value "dc1" + And 1 proxy model from yaml + --- + - ServiceProxy: + DestinationServiceID: ~ + --- + When I visit the instance page for yaml + --- + dc: dc1 + service: service-0 + id: service-0-with-id + --- + Then the url should be /dc1/services/service-0/service-0-with-id + And I see type on the proxy like "proxy" + + And I see serviceChecksIsSelected on the tabs + And I don't see upstreams on the tabs diff --git a/ui-v2/tests/acceptance/dc/services/instances/with-sidecar.feature b/ui-v2/tests/acceptance/dc/services/instances/with-sidecar.feature new file mode 100644 index 0000000000..5e7c6c3f5c --- /dev/null +++ b/ui-v2/tests/acceptance/dc/services/instances/with-sidecar.feature @@ -0,0 +1,22 @@ +@setupApplicationTest +Feature: dc / services / instances / with-sidecar: Show Service Instance with a Sidecar Proxy + Scenario: A Service instance has a Sidecar Proxy (a DestinationServiceID) + Given 1 datacenter model with the value "dc1" + And 1 proxy model from yaml + --- + - ServiceProxy: + DestinationServiceID: service-1 + --- + When I visit the instance page for yaml + --- + dc: dc1 + service: service-0 + id: service-0-with-id + --- + Then the url should be /dc1/services/service-0/service-0-with-id + And I see type on the proxy like "sidecar-proxy" + + And I see serviceChecksIsSelected on the tabs + And I don't see upstreams on the tabs + + diff --git a/ui-v2/tests/acceptance/steps/dc/services/instances/error-steps.js b/ui-v2/tests/acceptance/steps/dc/services/instances/error-steps.js new file mode 100644 index 0000000000..9bfbe9ac9b --- /dev/null +++ b/ui-v2/tests/acceptance/steps/dc/services/instances/error-steps.js @@ -0,0 +1,10 @@ +import steps from '../../../steps'; + +// step definitions that are shared between features should be moved to the +// tests/acceptance/steps/steps.js file + +export default function(assert) { + return steps(assert).then('I should find a file', function() { + assert.ok(true, this.step); + }); +} diff --git a/ui-v2/tests/acceptance/steps/dc/services/instances/proxy-steps.js b/ui-v2/tests/acceptance/steps/dc/services/instances/proxy-steps.js new file mode 100644 index 0000000000..9bfbe9ac9b --- /dev/null +++ b/ui-v2/tests/acceptance/steps/dc/services/instances/proxy-steps.js @@ -0,0 +1,10 @@ +import steps from '../../../steps'; + +// step definitions that are shared between features should be moved to the +// tests/acceptance/steps/steps.js file + +export default function(assert) { + return steps(assert).then('I should find a file', function() { + assert.ok(true, this.step); + }); +} diff --git a/ui-v2/tests/acceptance/steps/dc/services/instances/show-steps.js b/ui-v2/tests/acceptance/steps/dc/services/instances/show-steps.js new file mode 100644 index 0000000000..9bfbe9ac9b --- /dev/null +++ b/ui-v2/tests/acceptance/steps/dc/services/instances/show-steps.js @@ -0,0 +1,10 @@ +import steps from '../../../steps'; + +// step definitions that are shared between features should be moved to the +// tests/acceptance/steps/steps.js file + +export default function(assert) { + return steps(assert).then('I should find a file', function() { + assert.ok(true, this.step); + }); +} diff --git a/ui-v2/tests/acceptance/steps/dc/services/instances/sidecar-proxy-steps.js b/ui-v2/tests/acceptance/steps/dc/services/instances/sidecar-proxy-steps.js new file mode 100644 index 0000000000..9bfbe9ac9b --- /dev/null +++ b/ui-v2/tests/acceptance/steps/dc/services/instances/sidecar-proxy-steps.js @@ -0,0 +1,10 @@ +import steps from '../../../steps'; + +// step definitions that are shared between features should be moved to the +// tests/acceptance/steps/steps.js file + +export default function(assert) { + return steps(assert).then('I should find a file', function() { + assert.ok(true, this.step); + }); +} diff --git a/ui-v2/tests/acceptance/steps/dc/services/instances/with-proxy-steps.js b/ui-v2/tests/acceptance/steps/dc/services/instances/with-proxy-steps.js new file mode 100644 index 0000000000..9bfbe9ac9b --- /dev/null +++ b/ui-v2/tests/acceptance/steps/dc/services/instances/with-proxy-steps.js @@ -0,0 +1,10 @@ +import steps from '../../../steps'; + +// step definitions that are shared between features should be moved to the +// tests/acceptance/steps/steps.js file + +export default function(assert) { + return steps(assert).then('I should find a file', function() { + assert.ok(true, this.step); + }); +} diff --git a/ui-v2/tests/acceptance/steps/dc/services/instances/with-sidecar-steps.js b/ui-v2/tests/acceptance/steps/dc/services/instances/with-sidecar-steps.js new file mode 100644 index 0000000000..3231912b98 --- /dev/null +++ b/ui-v2/tests/acceptance/steps/dc/services/instances/with-sidecar-steps.js @@ -0,0 +1,10 @@ +import steps from '../../../steps'; + +// step definitions that are shared between features should be moved to the +// tests/acceptance/steps/steps.js file + +export default function(assert) { + return steps(assert).then('I should find a file', function() { + assert.ok(true, this.step); + }); +} diff --git a/ui-v2/tests/helpers/type-to-url.js b/ui-v2/tests/helpers/type-to-url.js index 1b217dcb4d..72c648f64d 100644 --- a/ui-v2/tests/helpers/type-to-url.js +++ b/ui-v2/tests/helpers/type-to-url.js @@ -7,6 +7,9 @@ export default function(type) { case 'service': requests = ['/v1/internal/ui/services', '/v1/health/service/']; break; + case 'proxy': + requests = ['/v1/catalog/connect']; + break; case 'node': requests = ['/v1/internal/ui/nodes', '/v1/internal/ui/node/']; break; diff --git a/ui-v2/tests/pages.js b/ui-v2/tests/pages.js index 69e890b32a..389df60470 100644 --- a/ui-v2/tests/pages.js +++ b/ui-v2/tests/pages.js @@ -19,6 +19,7 @@ import dcs from 'consul-ui/tests/pages/dc'; import settings from 'consul-ui/tests/pages/settings'; import services from 'consul-ui/tests/pages/dc/services/index'; import service from 'consul-ui/tests/pages/dc/services/show'; +import instance from 'consul-ui/tests/pages/dc/services/instance'; import nodes from 'consul-ui/tests/pages/dc/nodes/index'; import node from 'consul-ui/tests/pages/dc/nodes/show'; import kvs from 'consul-ui/tests/pages/dc/kv/index'; @@ -41,6 +42,7 @@ export default { dcs: create(dcs(visitable, clickable, attribute, collection)), services: create(services(visitable, clickable, attribute, collection, page, catalogFilter)), service: create(service(visitable, attribute, collection, text, catalogFilter)), + instance: create(instance(visitable, attribute, collection, text, radiogroup)), nodes: create(nodes(visitable, clickable, attribute, collection, catalogFilter)), node: create(node(visitable, deletable, clickable, attribute, collection, radiogroup)), kvs: create(kvs(visitable, deletable, creatable, clickable, attribute, collection)), diff --git a/ui-v2/tests/pages/dc/services/instance.js b/ui-v2/tests/pages/dc/services/instance.js new file mode 100644 index 0000000000..69a707780d --- /dev/null +++ b/ui-v2/tests/pages/dc/services/instance.js @@ -0,0 +1,19 @@ +export default function(visitable, attribute, collection, text, radiogroup) { + return { + visit: visitable('/:dc/services/:service/:id'), + externalSource: attribute('data-test-external-source', 'h1 span'), + tabs: radiogroup('tab', ['service-checks', 'node-checks', 'upstreams', 'tags']), + serviceChecks: collection('#service-checks [data-test-healthchecks] li', {}), + nodeChecks: collection('#node-checks [data-test-healthchecks] li', {}), + upstreams: collection('#upstreams [data-test-tabular-row]', { + name: text('[data-test-destination-name]'), + datacenter: text('[data-test-destination-datacenter]'), + type: text('[data-test-destination-type]'), + address: text('[data-test-local-bind-address]'), + }), + proxy: { + type: attribute('data-test-proxy-type', '[data-test-proxy-type]'), + destination: attribute('data-test-proxy-destination', '[data-test-proxy-destination]'), + }, + }; +} diff --git a/ui-v2/tests/steps/assertions/dom.js b/ui-v2/tests/steps/assertions/dom.js index ec26e165d2..519ed3ab7a 100644 --- a/ui-v2/tests/steps/assertions/dom.js +++ b/ui-v2/tests/steps/assertions/dom.js @@ -6,6 +6,12 @@ export default function(scenario, assert, find, currentURL) { `Expected to see "${text}" in "${selector}"` ); }) + .then(['I see the exact text "$text" in "$selector"'], function(text, selector) { + assert.ok( + find(selector).textContent.trim() === text, + `Expected to see the exact "${text}" in "${selector}"` + ); + }) // TODO: Think of better language // TODO: These should be mergeable .then(['"$selector" has the "$class" class'], function(selector, cls) { diff --git a/ui-v2/tests/steps/assertions/model.js b/ui-v2/tests/steps/assertions/model.js index 82e11f6470..7019c086ea 100644 --- a/ui-v2/tests/steps/assertions/model.js +++ b/ui-v2/tests/steps/assertions/model.js @@ -20,10 +20,7 @@ export default function(scenario, assert, currentPage, pluralize) { }, 100); }); }) - .then(['I see $num $model', 'I see $num $model model', 'I see $num $model models'], function( - num, - model - ) { + .then(['I see $num $model model[s]?'], function(num, model) { const len = currentPage()[pluralize(model)].filter(function(item) { return item.isVisible; }).length; diff --git a/ui-v2/tests/steps/assertions/page.js b/ui-v2/tests/steps/assertions/page.js index 043a6f4bb9..d0bf6d838d 100644 --- a/ui-v2/tests/steps/assertions/page.js +++ b/ui-v2/tests/steps/assertions/page.js @@ -56,6 +56,13 @@ export default function(scenario, assert, currentPage) { } assert.ok(_component[property], `Expected to see ${property} on ${component}`); }) + .then(['I see $num of the $component object'], function(num, component) { + assert.equal( + currentPage()[component].length, + num, + `Expected to see ${num} items in the ${component} object` + ); + }) .then(["I don't see $property on the $component"], function(property, component) { // Collection var obj; @@ -64,9 +71,9 @@ export default function(scenario, assert, currentPage) { } else { obj = currentPage()[component]; } - const func = obj[property].bind(obj); assert.throws( function() { + const func = obj[property].bind(obj); func(); }, function(e) { @@ -89,11 +96,20 @@ export default function(scenario, assert, currentPage) { .then(['I see $property'], function(property) { assert.ok(currentPage()[property], `Expected to see ${property}`); }) - .then(['I see $property like "$value"'], function(property, value) { + .then(['I see $property on the $component like "$value"'], function( + property, + component, + value + ) { + const target = currentPage()[component][property]; assert.equal( - currentPage()[property], + target, value, - `Expected to see ${property}, was ${currentPage()[property]}` + `Expected to see ${property} on ${component} as ${value}, was ${target}` ); + }) + .then(['I see $property like "$value"'], function(property, value) { + const target = currentPage()[property]; + assert.equal(target, value, `Expected to see ${property} as ${value}, was ${target}`); }); } diff --git a/ui-v2/yarn.lock b/ui-v2/yarn.lock index 705593f057..8790b5031c 100644 --- a/ui-v2/yarn.lock +++ b/ui-v2/yarn.lock @@ -690,9 +690,9 @@ js-yaml "^3.10.0" "@hashicorp/consul-api-double@^2.0.1": - version "2.1.0" - resolved "https://registry.yarnpkg.com/@hashicorp/consul-api-double/-/consul-api-double-2.1.0.tgz#511e6a48842ad31133e2070f3b2307568539b10e" - integrity sha512-cyW7TiKQylrWzVUORT1e6m4SU8tQ1V5BYEKW2th7QwHP8OFazn/+om9hud/9X5YtjEuSPIQCmFIvhEVwZgLVpQ== + version "2.2.0" + resolved "https://registry.yarnpkg.com/@hashicorp/consul-api-double/-/consul-api-double-2.2.0.tgz#b72b086dd4c88485f83b6f2fe30c5cb45b8d0e6a" + integrity sha512-9U+pqdBJn/ZruUYg7J2A3k9FS44FZQsMGBfKQSxaBRU50dgkpGLtOyOOFUqgi/RCAwn4GUOzgXgHRRs5TAeStg== "@hashicorp/ember-cli-api-double@^1.3.0": version "1.7.0" From 3d7654029753081062b16934edf724cfd36bc862 Mon Sep 17 00:00:00 2001 From: John Cowen Date: Thu, 21 Mar 2019 15:46:12 +0000 Subject: [PATCH 33/52] ui: Fix erroneous HTML that was being fixed by either browser/ember (#5530) The resulting DOM from this template was actually correct, we'd assume it was being fixed by the browser --- ui-v2/app/templates/dc/kv/edit.hbs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/ui-v2/app/templates/dc/kv/edit.hbs b/ui-v2/app/templates/dc/kv/edit.hbs index 7bf538afb7..cfcb18de92 100644 --- a/ui-v2/app/templates/dc/kv/edit.hbs +++ b/ui-v2/app/templates/dc/kv/edit.hbs @@ -39,10 +39,10 @@
        ID
        {{session.ID}}
        Behavior
        -
        <{{session.Behavior}}/dd> +
        {{session.Behavior}}
        {{#if session.Delay }}
        Delay
        -
        <{{session.LockDelay}}/dd> +
        {{session.LockDelay}}
        {{/if}} {{#if session.TTL }}
        TTL
        From 10c1f29ffaca65e88a95ee470b77e4759007590a Mon Sep 17 00:00:00 2001 From: John Cowen Date: Fri, 22 Mar 2019 17:01:10 +0000 Subject: [PATCH 34/52] ui: Add proxy icons to proxy services and instances where appropriate (#5463) --- ui-v2/app/controllers/dc/services/index.js | 7 ++++++- ui-v2/app/styles/components/app-view.scss | 7 ++++++- ui-v2/app/styles/components/icons/index.scss | 10 ++++++++++ ui-v2/app/styles/components/table.scss | 8 ++++++++ ui-v2/app/styles/components/table/layout.scss | 3 +++ ui-v2/app/styles/components/tabular-collection.scss | 3 +++ ui-v2/app/styles/components/type-icon/index.scss | 2 ++ ui-v2/app/styles/components/type-icon/layout.scss | 5 +++++ ui-v2/app/styles/components/type-icon/skin.scss | 6 ++++++ ui-v2/app/styles/components/with-tooltip.scss | 2 +- ui-v2/app/styles/components/with-tooltip/index.scss | 2 +- ui-v2/app/styles/core/typography.scss | 6 ++++-- ui-v2/app/templates/dc/services/index.hbs | 8 ++++++++ ui-v2/app/templates/dc/services/instance.hbs | 3 +++ ui-v2/app/templates/dc/services/show.hbs | 3 +++ 15 files changed, 69 insertions(+), 6 deletions(-) create mode 100644 ui-v2/app/styles/components/type-icon/index.scss create mode 100644 ui-v2/app/styles/components/type-icon/layout.scss create mode 100644 ui-v2/app/styles/components/type-icon/skin.scss diff --git a/ui-v2/app/controllers/dc/services/index.js b/ui-v2/app/controllers/dc/services/index.js index 6da28085fc..912a5316b3 100644 --- a/ui-v2/app/controllers/dc/services/index.js +++ b/ui-v2/app/controllers/dc/services/index.js @@ -51,7 +51,12 @@ export default Controller.extend(WithEventSource, WithSearching, WithHealthFilte return widthDeclaration(get(this, 'maxWidth')); }), remainingWidth: computed('maxWidth', function() { - return htmlSafe(`width: calc(50% - ${Math.round(get(this, 'maxWidth') / 2)}px)`); + // maxWidth is the maximum width of the healthchecks column + // there are currently 2 other columns so divide it by 2 and + // take that off 50% (100% / number of fluid columns) + // also we added a Type column which we've currently fixed to 100px + // so again divide that by 2 and take it off each fluid column + return htmlSafe(`width: calc(50% - 50px - ${Math.round(get(this, 'maxWidth') / 2)}px)`); }), maxPassing: computed('filtered', function() { return max(get(this, 'filtered'), 'ChecksPassing'); diff --git a/ui-v2/app/styles/components/app-view.scss b/ui-v2/app/styles/components/app-view.scss index 2ddc65d217..11f634561a 100644 --- a/ui-v2/app/styles/components/app-view.scss +++ b/ui-v2/app/styles/components/app-view.scss @@ -1,6 +1,7 @@ @import './app-view/index'; @import './filter-bar/index'; @import './buttons/index'; +@import './type-icon/index'; main { @extend %app-view; } @@ -15,9 +16,13 @@ main { margin-top: 5px; } } -%app-view h1 span { +// TODO: This should be its own component +%app-view h1 span[data-tooltip] { @extend %with-external-source-icon; } +%app-view h1 span.kind-proxy { + @extend %type-icon, %with-proxy; +} %app-view h1 em { color: $gray-600; } diff --git a/ui-v2/app/styles/components/icons/index.scss b/ui-v2/app/styles/components/icons/index.scss index 4c61d11f3c..13e9751672 100644 --- a/ui-v2/app/styles/components/icons/index.scss +++ b/ui-v2/app/styles/components/icons/index.scss @@ -54,6 +54,7 @@ %with-folder { text-indent: 30px; } +%with-proxy, %with-hashicorp, %with-folder, %with-chevron, @@ -79,6 +80,15 @@ margin-top: -10px; background-color: $color-transparent; } +%with-proxy::before { + @extend %pseudo-icon; + background-image: url('data:image/svg+xml;charset=UTF-8,'); + width: 18px; + height: 18px; + left: 3px; + margin-top: -9px; + background-color: $color-transparent; +} %with-clipboard { padding-left: 38px !important; } diff --git a/ui-v2/app/styles/components/table.scss b/ui-v2/app/styles/components/table.scss index 749774ad1a..1a98bbc998 100644 --- a/ui-v2/app/styles/components/table.scss +++ b/ui-v2/app/styles/components/table.scss @@ -1,5 +1,6 @@ @import './icons/index'; @import './table/index'; +@import './type-icon/index'; html.template-service.template-list td:first-child a span, html.template-node.template-show #services td:first-child a span, @@ -19,6 +20,13 @@ html.template-service.template-list main th:first-child { td.folder { @extend %with-folder; } +td .kind-proxy { + @extend %type-icon, %with-proxy; + text-indent: -9000px !important; + width: 24px; + margin-top: -8px; + transform: scale(0.7); +} table:not(.sessions) tr { cursor: pointer; } diff --git a/ui-v2/app/styles/components/table/layout.scss b/ui-v2/app/styles/components/table/layout.scss index 2706e64dc5..6cf8ef1ccd 100644 --- a/ui-v2/app/styles/components/table/layout.scss +++ b/ui-v2/app/styles/components/table/layout.scss @@ -65,6 +65,9 @@ td:not(.actions) a { html.template-policy.template-list tr > :nth-child(2) { display: none; } + html.template-service.template-list tr > :nth-child(2) { + display: none; + } } @media #{$--lt-wide-table} { html.template-intention.template-list tr > :nth-last-child(2) { diff --git a/ui-v2/app/styles/components/tabular-collection.scss b/ui-v2/app/styles/components/tabular-collection.scss index d26b72e656..c90adf3f9b 100644 --- a/ui-v2/app/styles/components/tabular-collection.scss +++ b/ui-v2/app/styles/components/tabular-collection.scss @@ -174,6 +174,9 @@ html.template-node.template-show main table.sessions tr { // (100% / 2) - (160px / 2) // width: calc(50% - 160px); // } +%services-row > *:nth-child(2) { + width: 100px; +} %services-row > * { width: auto; } diff --git a/ui-v2/app/styles/components/type-icon/index.scss b/ui-v2/app/styles/components/type-icon/index.scss new file mode 100644 index 0000000000..bc18252196 --- /dev/null +++ b/ui-v2/app/styles/components/type-icon/index.scss @@ -0,0 +1,2 @@ +@import './skin'; +@import './layout'; diff --git a/ui-v2/app/styles/components/type-icon/layout.scss b/ui-v2/app/styles/components/type-icon/layout.scss new file mode 100644 index 0000000000..a88b65d79e --- /dev/null +++ b/ui-v2/app/styles/components/type-icon/layout.scss @@ -0,0 +1,5 @@ +%type-icon { + display: inline-block; + text-indent: 20px; + padding: 3px; +} diff --git a/ui-v2/app/styles/components/type-icon/skin.scss b/ui-v2/app/styles/components/type-icon/skin.scss new file mode 100644 index 0000000000..b6d0be1129 --- /dev/null +++ b/ui-v2/app/styles/components/type-icon/skin.scss @@ -0,0 +1,6 @@ +%type-icon { + border-radius: 4px; + + background: $gray-100; + color: $gray-400; +} diff --git a/ui-v2/app/styles/components/with-tooltip.scss b/ui-v2/app/styles/components/with-tooltip.scss index df1a62d51a..c4e536ff12 100644 --- a/ui-v2/app/styles/components/with-tooltip.scss +++ b/ui-v2/app/styles/components/with-tooltip.scss @@ -1,4 +1,4 @@ @import './with-tooltip/index'; -%app-view h1 span { +%app-view h1 span[data-tooltip] { @extend %with-pseudo-tooltip; } diff --git a/ui-v2/app/styles/components/with-tooltip/index.scss b/ui-v2/app/styles/components/with-tooltip/index.scss index 3ee08c67cb..bca4c98e29 100644 --- a/ui-v2/app/styles/components/with-tooltip/index.scss +++ b/ui-v2/app/styles/components/with-tooltip/index.scss @@ -12,7 +12,7 @@ %with-pseudo-tooltip { text-indent: -9000px; font-size: 0; - top: -9px; + top: -7px; } %with-pseudo-tooltip::after, diff --git a/ui-v2/app/styles/core/typography.scss b/ui-v2/app/styles/core/typography.scss index 5f01d4be8e..298874a035 100644 --- a/ui-v2/app/styles/core/typography.scss +++ b/ui-v2/app/styles/core/typography.scss @@ -54,7 +54,8 @@ th, %breadcrumbs li > *, %action-group-action, %tab-nav, -%tooltip-bubble { +%tooltip-bubble, +%type-icon { font-weight: $typo-weight-medium; } main label a[rel*='help'], @@ -96,7 +97,8 @@ caption, %form-element > span, %tooltip-bubble, %healthchecked-resource strong, -%footer { +%footer, +%type-icon { font-size: $typo-size-700; } %toggle label span { diff --git a/ui-v2/app/templates/dc/services/index.hbs b/ui-v2/app/templates/dc/services/index.hbs index 16f287eb4d..25885f12e5 100644 --- a/ui-v2/app/templates/dc/services/index.hbs +++ b/ui-v2/app/templates/dc/services/index.hbs @@ -24,6 +24,7 @@ }} {{#block-slot 'header'}} Service + Type Health ChecksThe number of health checks for the service on all nodes Tags {{/block-slot}} @@ -34,6 +35,13 @@ {{item.Name}} + +{{#if (eq item.Kind 'connect-proxy')}} + Proxy +{{else}} +   +{{/if}} + {{healthcheck-info passing=item.ChecksPassing warning=item.ChecksWarning critical=item.ChecksCritical diff --git a/ui-v2/app/templates/dc/services/instance.hbs b/ui-v2/app/templates/dc/services/instance.hbs index 9b3a286ba4..76a0b95d26 100644 --- a/ui-v2/app/templates/dc/services/instance.hbs +++ b/ui-v2/app/templates/dc/services/instance.hbs @@ -16,6 +16,9 @@ {{/if}} {{/with}} {{/with}} +{{#if (eq item.Kind 'connect-proxy')}} + Proxy +{{/if}}
        Service Name
        diff --git a/ui-v2/app/templates/dc/services/show.hbs b/ui-v2/app/templates/dc/services/show.hbs index bd693d9b85..07f19a0690 100644 --- a/ui-v2/app/templates/dc/services/show.hbs +++ b/ui-v2/app/templates/dc/services/show.hbs @@ -14,6 +14,9 @@ {{/if}} {{/with}} {{/with}} +{{#if (eq item.Service.Kind 'connect-proxy')}} + Proxy +{{/if}} {{tab-nav From 83cd4684c6b575ca9a12de670784540935a57681 Mon Sep 17 00:00:00 2001 From: John Cowen Date: Fri, 22 Mar 2019 17:08:18 +0000 Subject: [PATCH 35/52] UI: Amends blocking queries text and toggle component in settings (#5467) --- ui-v2/app/styles/components/app-view/layout.scss | 5 +++++ ui-v2/app/styles/components/app-view/skin.scss | 3 +++ ui-v2/app/styles/core/typography.scss | 1 + ui-v2/app/templates/settings.hbs | 6 ++++-- 4 files changed, 13 insertions(+), 2 deletions(-) diff --git a/ui-v2/app/styles/components/app-view/layout.scss b/ui-v2/app/styles/components/app-view/layout.scss index f518b5d38d..4c1fa44032 100644 --- a/ui-v2/app/styles/components/app-view/layout.scss +++ b/ui-v2/app/styles/components/app-view/layout.scss @@ -30,6 +30,11 @@ padding-bottom: 0.2em; margin-bottom: 1.1em; } +%app-view fieldset h2, +%app-view fieldset p { + padding-bottom: 0; + margin-bottom: 0; +} %app-view header .actions > *:not(:last-child) { margin-right: 12px; } diff --git a/ui-v2/app/styles/components/app-view/skin.scss b/ui-v2/app/styles/components/app-view/skin.scss index a117fa1258..607135363d 100644 --- a/ui-v2/app/styles/components/app-view/skin.scss +++ b/ui-v2/app/styles/components/app-view/skin.scss @@ -1,6 +1,9 @@ %app-view h2 { border-bottom: $decor-border-200; } +%app-view fieldset h2 { + border-bottom: none; +} @media #{$--horizontal-selects} { %app-view header h1 { border-bottom: $decor-border-200; diff --git a/ui-v2/app/styles/core/typography.scss b/ui-v2/app/styles/core/typography.scss index 298874a035..35792cc434 100644 --- a/ui-v2/app/styles/core/typography.scss +++ b/ui-v2/app/styles/core/typography.scss @@ -85,6 +85,7 @@ h2, } body, %action-group-action, +fieldset h2, pre code, input, textarea, diff --git a/ui-v2/app/templates/settings.hbs b/ui-v2/app/templates/settings.hbs index f38dfe069a..aaf9565340 100644 --- a/ui-v2/app/templates/settings.hbs +++ b/ui-v2/app/templates/settings.hbs @@ -7,14 +7,16 @@ {{/block-slot}} {{#block-slot 'content'}}

        - These settings allow you to configure your browser for the Consul Web UI. Everything is saved to localstorage, and persists through visits and browser usage. + These settings are specific to the Consul web UI. They are saved to local storage and persist through browser usage and visits.

        +

        Blocking Queries

        +

        Automatically get updated catalog information without refreshing the page. Any changes made to services and nodes would be reflected in real time.

        From e2df5de795de875ba287d716d37d752777b6c45e Mon Sep 17 00:00:00 2001 From: John Cowen Date: Fri, 22 Mar 2019 17:09:27 +0000 Subject: [PATCH 36/52] UI: Add blocking cursor validation and more straightforward throttle (#5470) More recommendations for blocking queries clients was added here: https://github.com/hashicorp/consul/pull/5358 This commit mainly adds cursor/index validation/correction based on these recommendations (plus tests) The recommendations also suggest that clients should include rate limiting. Because of this, we've moved the throttling out of Consul UI specific code and into Blocking Query specific code. Currently the 'rate limiting' in this commit only adds a sleep to every iteration of the loop, which is not the recommended approach, but the code here organizes the throttling functionality into something we can work with later to provide something more apt. --- .../services/repository/type/event-source.js | 54 ++++++--------- ui-v2/app/utils/dom/event-source/blocking.js | 67 +++++++++++++------ .../tests/acceptance/dc/list-blocking.feature | 1 - .../utils/dom/event-source/blocking-test.js | 61 +++++++++++++++++ 4 files changed, 127 insertions(+), 56 deletions(-) diff --git a/ui-v2/app/services/repository/type/event-source.js b/ui-v2/app/services/repository/type/event-source.js index 2305756062..7c4423835e 100644 --- a/ui-v2/app/services/repository/type/event-source.js +++ b/ui-v2/app/services/repository/type/event-source.js @@ -7,7 +7,6 @@ import { cache as createCache, BlockingEventSource } from 'consul-ui/utils/dom/e const createProxy = function(repo, find, settings, cache, serialize = JSON.stringify) { // proxied find*..(id, dc) - const throttle = get(this, 'wait').execute; const client = get(this, 'client'); return function() { const key = `${repo.getModelName()}.${find}.${serialize([...arguments])}`; @@ -16,49 +15,38 @@ const createProxy = function(repo, find, settings, cache, serialize = JSON.strin return newPromisedEventSource( function(configuration) { // take a copy of the original arguments - // this means we don't have any configuration object on it let args = [..._args]; if (configuration.settings.enabled) { // ...and only add our current cursor/configuration if we are blocking args = args.concat([configuration]); } - // save a callback so we can conditionally throttle - const cb = () => { - // original find... with configuration now added - return repo[find](...args) - .then(res => { - if (!configuration.settings.enabled) { - // blocking isn't enabled, immediately close - this.close(); - } - return res; - }) - .catch(function(e) { - // setup the aborted connection restarting - // this should happen here to avoid cache deletion - const status = get(e, 'errors.firstObject.status'); - if (status === '0') { - // Any '0' errors (abort) should possibly try again, depending upon the circumstances - // whenAvailable returns a Promise that resolves when the client is available - // again - return client.whenAvailable(e); - } - throw e; - }); - }; - // if we have a cursor (which means its at least the second call) - // and we have a throttle setting, wait for so many ms - if (typeof configuration.cursor !== 'undefined' && configuration.settings.throttle) { - return throttle(configuration.settings.throttle).then(cb); - } - return cb(); + // original find... with configuration now added + return repo[find](...args) + .then(res => { + if (!configuration.settings.enabled) { + // blocking isn't enabled, immediately close + this.close(); + } + return res; + }) + .catch(function(e) { + // setup the aborted connection restarting + // this should happen here to avoid cache deletion + const status = get(e, 'errors.firstObject.status'); + if (status === '0') { + // Any '0' errors (abort) should possibly try again, depending upon the circumstances + // whenAvailable returns a Promise that resolves when the client is available + // again + return client.whenAvailable(e); + } + throw e; + }); }, { key: key, type: BlockingEventSource, settings: { enabled: settings.blocking, - throttle: settings.throttle, }, } ); diff --git a/ui-v2/app/utils/dom/event-source/blocking.js b/ui-v2/app/utils/dom/event-source/blocking.js index d5f8968f61..e329eecd9a 100644 --- a/ui-v2/app/utils/dom/event-source/blocking.js +++ b/ui-v2/app/utils/dom/event-source/blocking.js @@ -21,6 +21,26 @@ export const create5xxBackoff = function(ms = 3000, P = Promise, wait = setTimeo throw err; }; }; +export const validateCursor = function(current, prev = null) { + let cursor = parseInt(current); + if (!isNaN(cursor)) { + // if cursor is less than the current cursor, reset to zero + if (prev !== null && cursor < prev) { + cursor = 0; + } + // if cursor is less than 0, its always safe to use 1 + return Math.max(cursor, 1); + } +}; +const throttle = function(configuration, prev, current) { + return function(obj) { + return new Promise(function(resolve, reject) { + setTimeout(function() { + resolve(obj); + }, 200); + }); + }; +}; const defaultCreateEvent = function(result, configuration) { return { type: 'message', @@ -55,29 +75,32 @@ export default function(EventSource, backoff = create5xxBackoff()) { .apply(this, [superConfiguration]) .catch(backoff) .then(result => { - if (!(result instanceof Error)) { - const _createEvent = - typeof createEvent === 'function' ? createEvent : defaultCreateEvent; - let event = _createEvent(result, configuration); - // allow custom types, but make a default of `message`, ideally this would check for CustomEvent - // but keep this flexible for the moment - if (!event.type) { - event = { - type: 'message', - data: event, - }; - } - // meta is also configurable by using createEvent - const meta = get(event.data || {}, 'meta'); - if (meta) { - // pick off the `cursor` from the meta and add it to configuration - configuration.cursor = meta.cursor; - } - this.currentEvent = event; - this.dispatchEvent(this.currentEvent); - this.previousEvent = this.currentEvent; + if (result instanceof Error) { + return result; } - return result; + const _createEvent = + typeof createEvent === 'function' ? createEvent : defaultCreateEvent; + let event = _createEvent(result, configuration); + // allow custom types, but make a default of `message`, ideally this would check for CustomEvent + // but keep this flexible for the moment + if (!event.type) { + event = { + type: 'message', + data: event, + }; + } + // meta is also configurable by using createEvent + const meta = get(event.data || {}, 'meta'); + if (meta) { + // pick off the `cursor` from the meta and add it to configuration + // along with cursor validation + configuration.cursor = validateCursor(meta.cursor, configuration.cursor); + } + this.currentEvent = event; + this.dispatchEvent(this.currentEvent); + const throttledResolve = throttle(configuration, this.currentEvent, this.previousEvent); + this.previousEvent = this.currentEvent; + return throttledResolve(result); }); }, configuration); } diff --git a/ui-v2/tests/acceptance/dc/list-blocking.feature b/ui-v2/tests/acceptance/dc/list-blocking.feature index 790d20dce0..6385b86a21 100644 --- a/ui-v2/tests/acceptance/dc/list-blocking.feature +++ b/ui-v2/tests/acceptance/dc/list-blocking.feature @@ -9,7 +9,6 @@ Feature: dc / list-blocking --- consul:client: blocking: 1 - throttle: 200 --- Scenario: And 3 [Model] models diff --git a/ui-v2/tests/unit/utils/dom/event-source/blocking-test.js b/ui-v2/tests/unit/utils/dom/event-source/blocking-test.js index c405636a8e..ee506165b5 100644 --- a/ui-v2/tests/unit/utils/dom/event-source/blocking-test.js +++ b/ui-v2/tests/unit/utils/dom/event-source/blocking-test.js @@ -1,4 +1,5 @@ import domEventSourceBlocking, { + validateCursor, create5xxBackoff, } from 'consul-ui/utils/dom/event-source/blocking'; import { module } from 'qunit'; @@ -85,3 +86,63 @@ test('the 5xx backoff returns a resolve promise on a 5xx (apart from 500)', func assert.ok(timeout.calledOnce, 'timeout was called once'); }); }); +test("the cursor validation always returns undefined if the cursor can't be parsed to an integer", function(assert) { + ['null', null, '', undefined].forEach(item => { + const actual = validateCursor(item); + assert.equal(actual, undefined); + }); +}); +test('the cursor validation always returns a cursor greater than zero', function(assert) { + [ + { + cursor: 0, + expected: 1, + }, + { + cursor: -10, + expected: 1, + }, + { + cursor: -1, + expected: 1, + }, + { + cursor: -1000, + expected: 1, + }, + { + cursor: 10, + expected: 10, + }, + ].forEach(item => { + const actual = validateCursor(item.cursor); + assert.equal(actual, item.expected, 'cursor is greater than zero'); + }); +}); +test('the cursor validation resets to 1 if its less than the previous cursor', function(assert) { + [ + { + previous: 100, + cursor: 99, + expected: 1, + }, + { + previous: 100, + cursor: -10, + expected: 1, + }, + { + previous: 100, + cursor: 0, + expected: 1, + }, + { + previous: 100, + cursor: 101, + expected: 101, + }, + ].forEach(item => { + const actual = validateCursor(item.cursor, item.previous); + assert.equal(actual, item.expected); + }); +}); From 9daf8f53d96beb000d52a09b1c044eaeb1f88b9c Mon Sep 17 00:00:00 2001 From: John Cowen Date: Fri, 22 Mar 2019 17:10:33 +0000 Subject: [PATCH 37/52] ui: Adds blocking query support to the service detail page (#5479) This commit includes several pieces of functionality to enable services to be removed and the page to present information that this has happened but also keep the deleted information on the page. Along with the more usual blocking query based listing. To enable this: 1. Implements `meta` on the model (only available on collections in ember) 2. Adds new `catchable` ComputedProperty alongside a `listen` helper for working with specific errors that can be thrown from EventSources in an ember-like way. Briefly, normal computed properties update when a property changes, EventSources can additionally throw errors so we can catch them and show different visuals based on that. --- ui-v2/app/computed/catchable.js | 11 ++++++ ui-v2/app/controllers/dc/services/show.js | 19 +++++++++- .../app/instance-initializers/event-source.js | 6 +++ ui-v2/app/mixins/with-event-source.js | 31 +++++++++++++++- ui-v2/app/models/service.js | 1 + ui-v2/app/routes/dc/services/show.js | 8 ---- ui-v2/app/serializers/application.js | 25 +++++++------ ui-v2/app/services/repository/service.js | 14 +++++++ .../templates/dc/services/-notifications.hbs | 7 ++++ ui-v2/app/templates/dc/services/index.hbs | 3 +- ui-v2/app/templates/dc/services/show.hbs | 3 ++ ui-v2/config/environment.js | 2 + .../tests/acceptance/dc/list-blocking.feature | 37 +++++++++++++++---- ui-v2/tests/helpers/set-cookies.js | 1 + ui-v2/tests/helpers/type-to-url.js | 1 + .../services/repository/service-test.js | 4 ++ .../unit/controllers/dc/services/show-test.js | 2 +- 17 files changed, 144 insertions(+), 31 deletions(-) create mode 100644 ui-v2/app/computed/catchable.js create mode 100644 ui-v2/app/templates/dc/services/-notifications.hbs diff --git a/ui-v2/app/computed/catchable.js b/ui-v2/app/computed/catchable.js new file mode 100644 index 0000000000..9cc2bf008b --- /dev/null +++ b/ui-v2/app/computed/catchable.js @@ -0,0 +1,11 @@ +import ComputedProperty from '@ember/object/computed'; +import computedFactory from 'consul-ui/utils/computed/factory'; + +export default class Catchable extends ComputedProperty { + catch(cb) { + return this.meta({ + catch: cb, + }); + } +} +export const computed = computedFactory(Catchable); diff --git a/ui-v2/app/controllers/dc/services/show.js b/ui-v2/app/controllers/dc/services/show.js index 3f21a7b36b..8182d66203 100644 --- a/ui-v2/app/controllers/dc/services/show.js +++ b/ui-v2/app/controllers/dc/services/show.js @@ -1,9 +1,13 @@ import Controller from '@ember/controller'; import { get, set, computed } from '@ember/object'; +import { alias } from '@ember/object/computed'; import { inject as service } from '@ember/service'; import WithSearching from 'consul-ui/mixins/with-searching'; -export default Controller.extend(WithSearching, { +import WithEventSource, { listen } from 'consul-ui/mixins/with-event-source'; +export default Controller.extend(WithEventSource, WithSearching, { dom: service('dom'), + notify: service('flashMessages'), + items: alias('item.Nodes'), init: function() { this.searchParams = { serviceInstance: 's', @@ -17,6 +21,19 @@ export default Controller.extend(WithSearching, { // need this variable set(this, 'selectedTab', 'instances'); }, + item: listen('item').catch(function(e) { + if (e.target.readyState === 1) { + // OPEN + if (get(e, 'error.errors.firstObject.status') === '404') { + get(this, 'notify').add({ + destroyOnClick: false, + sticky: true, + type: 'warning', + action: 'update', + }); + } + } + }), searchable: computed('items', function() { return get(this, 'searchables.serviceInstance') .add(get(this, 'items')) diff --git a/ui-v2/app/instance-initializers/event-source.js b/ui-v2/app/instance-initializers/event-source.js index 705accdd5b..01265dfec5 100644 --- a/ui-v2/app/instance-initializers/event-source.js +++ b/ui-v2/app/instance-initializers/event-source.js @@ -34,6 +34,12 @@ export function initialize(container) { repo: 'repository/service/event-source', }, }, + { + route: 'dc/services/show', + services: { + repo: 'repository/service/event-source', + }, + }, ]) .forEach(function(definition) { if (typeof definition.extend !== 'undefined') { diff --git a/ui-v2/app/mixins/with-event-source.js b/ui-v2/app/mixins/with-event-source.js index 1fccd1dc83..f315f32f0b 100644 --- a/ui-v2/app/mixins/with-event-source.js +++ b/ui-v2/app/mixins/with-event-source.js @@ -1,12 +1,38 @@ import Mixin from '@ember/object/mixin'; +import { computed as catchable } from 'consul-ui/computed/catchable'; +import purify from 'consul-ui/utils/computed/purify'; -export default Mixin.create({ +import WithListeners from 'consul-ui/mixins/with-listeners'; +const PREFIX = '_'; +export default Mixin.create(WithListeners, { + setProperties: function(model) { + const _model = {}; + Object.keys(model).forEach(key => { + // here (see comment below on deleting) + if (typeof this[key] !== 'undefined' && this[key].isDescriptor) { + _model[`${PREFIX}${key}`] = model[key]; + const meta = this.constructor.metaForProperty(key) || {}; + if (typeof meta.catch === 'function') { + if (typeof _model[`${PREFIX}${key}`].addEventListener === 'function') { + this.listen(_model[`_${key}`], 'error', meta.catch.bind(this)); + } + } + } else { + _model[key] = model[key]; + } + }); + return this._super(_model); + }, reset: function(exiting) { if (exiting) { Object.keys(this).forEach(prop => { if (this[prop] && typeof this[prop].close === 'function') { this[prop].close(); // ember doesn't delete on 'resetController' by default + // right now we only call reset when we are exiting, therefore a full + // setProperties will be called the next time we enter the Route so this + // is ok for what we need and means that the above conditional works + // as expected (see 'here' comment above) delete this[prop]; } }); @@ -14,3 +40,6 @@ export default Mixin.create({ return this._super(...arguments); }, }); +export const listen = purify(catchable, function(props) { + return props.map(item => `${PREFIX}${item}`); +}); diff --git a/ui-v2/app/models/service.js b/ui-v2/app/models/service.js index cf98df3114..3d7e425928 100644 --- a/ui-v2/app/models/service.js +++ b/ui-v2/app/models/service.js @@ -30,6 +30,7 @@ export default Model.extend({ Node: attr(), Service: attr(), Checks: attr(), + meta: attr(), passing: computed('ChecksPassing', 'Checks', function() { let num = 0; // TODO: use typeof diff --git a/ui-v2/app/routes/dc/services/show.js b/ui-v2/app/routes/dc/services/show.js index 3757421cd8..670c607669 100644 --- a/ui-v2/app/routes/dc/services/show.js +++ b/ui-v2/app/routes/dc/services/show.js @@ -15,14 +15,6 @@ export default Route.extend({ const repo = get(this, 'repo'); return hash({ item: repo.findBySlug(params.name, this.modelFor('dc').dc.Name), - }).then(function(model) { - return { - ...model, - ...{ - // Nodes happen to be the ServiceInstances here - items: model.item.Nodes, - }, - }; }); }, setupController: function(controller, model) { diff --git a/ui-v2/app/serializers/application.js b/ui-v2/app/serializers/application.js index 8150179254..defe01efe8 100644 --- a/ui-v2/app/serializers/application.js +++ b/ui-v2/app/serializers/application.js @@ -15,15 +15,6 @@ export default Serializer.extend({ const headers = payload[HTTP_HEADERS_SYMBOL] || {}; delete payload[HTTP_HEADERS_SYMBOL]; const normalizedPayload = this.normalizePayload(payload, id, requestType); - const response = this._super( - store, - primaryModelClass, - { - [primaryModelClass.modelName]: normalizedPayload, - }, - id, - requestType - ); // put the meta onto the response, here this is ok // as JSON-API allows this and our specific data is now in // response[primaryModelClass.modelName] @@ -31,7 +22,7 @@ export default Serializer.extend({ // (which was the reason for the Symbol-like property earlier) // use a method modelled on ember-data methods so we have the opportunity to // do this on a per-model level - response.meta = this.normalizeMeta( + const meta = this.normalizeMeta( store, primaryModelClass, headers, @@ -39,7 +30,19 @@ export default Serializer.extend({ id, requestType ); - return response; + if (requestType === 'queryRecord') { + normalizedPayload.meta = meta; + } + return this._super( + store, + primaryModelClass, + { + meta: meta, + [primaryModelClass.modelName]: normalizedPayload, + }, + id, + requestType + ); }, normalizeMeta: function(store, primaryModelClass, headers, payload, id, requestType) { const meta = { diff --git a/ui-v2/app/services/repository/service.js b/ui-v2/app/services/repository/service.js index 61ef1d61cd..3e42c84f56 100644 --- a/ui-v2/app/services/repository/service.js +++ b/ui-v2/app/services/repository/service.js @@ -8,6 +8,18 @@ export default RepositoryService.extend({ findBySlug: function(slug, dc) { return this._super(...arguments).then(function(item) { const nodes = get(item, 'Nodes'); + if (nodes.length === 0) { + // TODO: Add an store.error("404", "message") or similar + // or move all this to serializer + const e = new Error(); + e.errors = [ + { + status: '404', + title: 'Not found', + }, + ]; + throw e; + } const service = get(nodes, 'firstObject'); const tags = nodes .reduce(function(prev, item) { @@ -16,6 +28,7 @@ export default RepositoryService.extend({ .uniq(); set(service, 'Tags', tags); set(service, 'Nodes', nodes); + set(service, 'meta', get(item, 'meta')); return service; }); }, @@ -36,6 +49,7 @@ export default RepositoryService.extend({ return service; } // TODO: Add an store.error("404", "message") or similar + // or move all this to serializer const e = new Error(); e.errors = [ { diff --git a/ui-v2/app/templates/dc/services/-notifications.hbs b/ui-v2/app/templates/dc/services/-notifications.hbs new file mode 100644 index 0000000000..9dee0a3ec9 --- /dev/null +++ b/ui-v2/app/templates/dc/services/-notifications.hbs @@ -0,0 +1,7 @@ +{{#if (eq type 'update')}} + {{#if (eq status 'warning') }} + This service has been deregistered and no longer exists in the catalog. + {{else}} + {{/if}} +{{/if}} + diff --git a/ui-v2/app/templates/dc/services/index.hbs b/ui-v2/app/templates/dc/services/index.hbs index 25885f12e5..ccc7e02e7e 100644 --- a/ui-v2/app/templates/dc/services/index.hbs +++ b/ui-v2/app/templates/dc/services/index.hbs @@ -1,7 +1,6 @@ {{#app-view class="service list"}} - {{!TODO: Look at the item passed through to figure what partial to show, also move into its own service partial, for the moment keeping here for visibility}} {{#block-slot 'notification' as |status type|}} - {{partial 'dc/acls/notifications'}} + {{partial 'dc/services/notifications'}} {{/block-slot}} {{#block-slot 'header'}}

        diff --git a/ui-v2/app/templates/dc/services/show.hbs b/ui-v2/app/templates/dc/services/show.hbs index 07f19a0690..a648fd8337 100644 --- a/ui-v2/app/templates/dc/services/show.hbs +++ b/ui-v2/app/templates/dc/services/show.hbs @@ -1,4 +1,7 @@ {{#app-view class="service show"}} + {{#block-slot 'notification' as |status type|}} + {{partial 'dc/services/notifications'}} + {{/block-slot}} {{#block-slot 'breadcrumbs'}}
        1. All Services
        2. diff --git a/ui-v2/config/environment.js b/ui-v2/config/environment.js index 0d937aeafb..aaeed171fd 100644 --- a/ui-v2/config/environment.js +++ b/ui-v2/config/environment.js @@ -27,7 +27,9 @@ module.exports = function(environment) { injectionFactories: ['view', 'controller', 'component'], }, }; + // TODO: These should probably go onto APP ENV = Object.assign({}, ENV, { + CONSUL_UI_DISABLE_REALTIME: false, CONSUL_GIT_SHA: (function() { if (process.env.CONSUL_GIT_SHA) { return process.env.CONSUL_GIT_SHA; diff --git a/ui-v2/tests/acceptance/dc/list-blocking.feature b/ui-v2/tests/acceptance/dc/list-blocking.feature index 6385b86a21..21bb5ec990 100644 --- a/ui-v2/tests/acceptance/dc/list-blocking.feature +++ b/ui-v2/tests/acceptance/dc/list-blocking.feature @@ -10,8 +10,8 @@ Feature: dc / list-blocking consul:client: blocking: 1 --- - Scenario: - And 3 [Model] models + Scenario: Viewing the listing pages + Given 3 [Model] models And a network latency of 100 When I visit the [Page] page for yaml --- @@ -26,8 +26,31 @@ Feature: dc / list-blocking And an external edit results in 0 [Model] models And pause until I see 0 [Model] models Where: - -------------------------------------------- - | Page | Model | Url | - | services | service | services | - | nodes | node | nodes | - -------------------------------------------- + ------------------------------------------------ + | Page | Model | Url | + | services | service | services | + | nodes | node | nodes | + ------------------------------------------------ + Scenario: Viewing detail pages with a listing + Given 3 [Model] models + And a network latency of 100 + When I visit the [Page] page for yaml + --- + dc: dc-1 + service: service-0 + --- + Then the url should be /dc-1/[Url] + And pause until I see 3 [Model] models + And an external edit results in 5 [Model] models + And pause until I see 5 [Model] models + And an external edit results in 1 [Model] model + And pause until I see 1 [Model] model + And an external edit results in 0 [Model] models + And pause for 300 + And I see the text "deregistered" in "[data-notification]" + Where: + ------------------------------------------------ + | Page | Model | Url | + | service | instance | services/service-0 | + ------------------------------------------------ + diff --git a/ui-v2/tests/helpers/set-cookies.js b/ui-v2/tests/helpers/set-cookies.js index 0cfa58313b..e56b5d6a54 100644 --- a/ui-v2/tests/helpers/set-cookies.js +++ b/ui-v2/tests/helpers/set-cookies.js @@ -9,6 +9,7 @@ export default function(type, count, obj) { key = 'CONSUL_SERVICE_COUNT'; break; case 'node': + case 'instance': key = 'CONSUL_NODE_COUNT'; break; case 'kv': diff --git a/ui-v2/tests/helpers/type-to-url.js b/ui-v2/tests/helpers/type-to-url.js index 72c648f64d..4fc736c83d 100644 --- a/ui-v2/tests/helpers/type-to-url.js +++ b/ui-v2/tests/helpers/type-to-url.js @@ -5,6 +5,7 @@ export default function(type) { requests = ['/v1/catalog/datacenters']; break; case 'service': + case 'instance': requests = ['/v1/internal/ui/services', '/v1/health/service/']; break; case 'proxy': diff --git a/ui-v2/tests/integration/services/repository/service-test.js b/ui-v2/tests/integration/services/repository/service-test.js index 630aa38e4a..cac6967922 100644 --- a/ui-v2/tests/integration/services/repository/service-test.js +++ b/ui-v2/tests/integration/services/repository/service-test.js @@ -68,6 +68,10 @@ test('findBySlug returns the correct data for item endpoint', function(assert) { const service = payload.Nodes[0]; service.Nodes = nodes; service.Tags = payload.Nodes[0].Service.Tags; + service.meta = { + date: undefined, + cursor: undefined, + }; return service; }) diff --git a/ui-v2/tests/unit/controllers/dc/services/show-test.js b/ui-v2/tests/unit/controllers/dc/services/show-test.js index 4b4a50ef72..373d5bec9a 100644 --- a/ui-v2/tests/unit/controllers/dc/services/show-test.js +++ b/ui-v2/tests/unit/controllers/dc/services/show-test.js @@ -2,7 +2,7 @@ import { moduleFor, test } from 'ember-qunit'; moduleFor('controller:dc/services/show', 'Unit | Controller | dc/services/show', { // Specify the other units that are required for this test. - needs: ['service:search', 'service:dom'], + needs: ['service:search', 'service:dom', 'service:flashMessages'], }); // Replace this with your real tests. From a14a37a078b55f17173937eb56c05d93d81adcf6 Mon Sep 17 00:00:00 2001 From: John Cowen Date: Fri, 22 Mar 2019 17:24:40 +0000 Subject: [PATCH 38/52] UI: Add support for blocking queries on the service instance detail page (#5487) This commit includes several pieces of functionality to enable services to be removed and the page to present information that this has happened but also keep the deleted information on the page. Along with the more usual blocking query based listing. To enable this: 1. Implements `meta` on the model (only available on collections in ember) 2. Adds new `catchable` ComputedProperty alongside a `listen` helper for working with specific errors that can be thrown from EventSources in an ember-like way. Briefly, normal computed properties update when a property changes, EventSources can additionally throw errors so we can catch them and show different visuals based on that. Also: Add support for blocking queries on the service instance detail page 1. Previous we could return undefined when a service instance has no proxy, but this means we have nothing to attach `meta` to. We've changed this to return an almost empty object, so with only a meta property. At first glance there doesn't seem to be any way to provide a proxy object to templates and be able to detect whether it is actually null or not so we instead change some conditional logic in the templates to detect the property we are using to generate the anchor. 2. Made a `pauseUntil` test helper function for steps where we wait for things. This helps for DRYness but also means if we can move away from setInterval to something else later, we can do it in one place 3. Whilst running into point 1 here, we managed to make the blocking queries eternally loop. Whilst this is due to an error in the code and shouldn't ever happen whilst in actual use, we've added an extra check so that we only recur/loop the blocking query if the previous response has a `meta.cursor` Adds support for blocking queries on the node detail page (#5489) 1. Moves data re-shaping for the templates variables into a repository so they are easily covered by blocking queries (into coordinatesRepo) 2. The node API returns a 404 as signal for deregistration, we also close the sessions and coordinates blocking queries when this happens --- ui-v2/app/controllers/dc/nodes/show.js | 24 ++++++++++++-- ui-v2/app/controllers/dc/services/instance.js | 20 ++++++++++-- .../app/instance-initializers/event-source.js | 19 +++++++++-- ui-v2/app/models/node.js | 1 + ui-v2/app/routes/dc/nodes/show.js | 32 ++++++------------- ui-v2/app/routes/dc/services/instance.js | 3 ++ ui-v2/app/services/repository/coordinate.js | 15 +++++++++ ui-v2/app/services/repository/node.js | 11 ------- ui-v2/app/services/repository/proxy.js | 17 ++++++---- ui-v2/app/services/repository/service.js | 1 + .../app/templates/dc/nodes/-notifications.hbs | 6 +++- ui-v2/app/templates/dc/nodes/show.hbs | 10 +++--- ui-v2/app/templates/dc/services/instance.hbs | 5 ++- .../tests/acceptance/dc/list-blocking.feature | 10 +++--- ui-v2/tests/acceptance/dc/nodes/show.feature | 32 +++++++++++++++---- .../dc/services/instances/show.feature | 21 ++++++++++-- .../services/repository/node-test.js | 4 +++ ui-v2/tests/steps.js | 20 ++++++++++-- ui-v2/tests/steps/assertions/dom.js | 14 +++++++- ui-v2/tests/steps/assertions/model.js | 27 ++++++---------- .../unit/controllers/dc/nodes/show-test.js | 2 +- .../controllers/dc/services/instance-test.js | 2 +- ui-v2/tests/unit/routes/dc/nodes/show-test.js | 1 + 23 files changed, 206 insertions(+), 91 deletions(-) diff --git a/ui-v2/app/controllers/dc/nodes/show.js b/ui-v2/app/controllers/dc/nodes/show.js index f90de09113..3dc82775e6 100644 --- a/ui-v2/app/controllers/dc/nodes/show.js +++ b/ui-v2/app/controllers/dc/nodes/show.js @@ -1,9 +1,14 @@ import Controller from '@ember/controller'; import { inject as service } from '@ember/service'; import { get, set, computed } from '@ember/object'; +import { alias } from '@ember/object/computed'; import WithSearching from 'consul-ui/mixins/with-searching'; -export default Controller.extend(WithSearching, { +import WithEventSource, { listen } from 'consul-ui/mixins/with-event-source'; + +export default Controller.extend(WithEventSource, WithSearching, { dom: service('dom'), + notify: service('flashMessages'), + items: alias('item.Services'), queryParams: { s: { as: 'filter', @@ -16,6 +21,21 @@ export default Controller.extend(WithSearching, { }; this._super(...arguments); }, + item: listen('item').catch(function(e) { + if (e.target.readyState === 1) { + // OPEN + if (get(e, 'error.errors.firstObject.status') === '404') { + get(this, 'notify').add({ + destroyOnClick: false, + sticky: true, + type: 'warning', + action: 'update', + }); + get(this, 'tomography').close(); + get(this, 'sessions').close(); + } + } + }), searchable: computed('items', function() { return get(this, 'searchables.nodeservice') .add(get(this, 'items')) @@ -28,7 +48,7 @@ export default Controller.extend(WithSearching, { // This method is called immediately after `Route::setupController`, and done here rather than there // as this is a variable used purely for view level things, if the view was different we might not // need this variable - set(this, 'selectedTab', get(this.item, 'Checks.length') > 0 ? 'health-checks' : 'services'); + set(this, 'selectedTab', get(this, 'item.Checks.length') > 0 ? 'health-checks' : 'services'); }, actions: { change: function(e) { diff --git a/ui-v2/app/controllers/dc/services/instance.js b/ui-v2/app/controllers/dc/services/instance.js index a8934de52d..37847bb519 100644 --- a/ui-v2/app/controllers/dc/services/instance.js +++ b/ui-v2/app/controllers/dc/services/instance.js @@ -1,7 +1,10 @@ import Controller from '@ember/controller'; -import { set } from '@ember/object'; +import { get, set } from '@ember/object'; +import { inject as service } from '@ember/service'; +import WithEventSource, { listen } from 'consul-ui/mixins/with-event-source'; -export default Controller.extend({ +export default Controller.extend(WithEventSource, { + notify: service('flashMessages'), setProperties: function() { this._super(...arguments); // This method is called immediately after `Route::setupController`, and done here rather than there @@ -9,6 +12,19 @@ export default Controller.extend({ // need this variable set(this, 'selectedTab', 'service-checks'); }, + item: listen('item').catch(function(e) { + if (e.target.readyState === 1) { + // OPEN + if (get(e, 'error.errors.firstObject.status') === '404') { + get(this, 'notify').add({ + destroyOnClick: false, + sticky: true, + type: 'warning', + action: 'update', + }); + } + } + }), actions: { change: function(e) { set(this, 'selectedTab', e.target.value); diff --git a/ui-v2/app/instance-initializers/event-source.js b/ui-v2/app/instance-initializers/event-source.js index 01265dfec5..d2a707ee10 100644 --- a/ui-v2/app/instance-initializers/event-source.js +++ b/ui-v2/app/instance-initializers/event-source.js @@ -5,7 +5,7 @@ export function initialize(container) { if (config[enabled] || window.localStorage.getItem(enabled) !== null) { return; } - ['node', 'service'] + ['node', 'coordinate', 'session', 'service', 'proxy'] .map(function(item) { // create repositories that return a promise resolving to an EventSource return { @@ -20,7 +20,7 @@ export function initialize(container) { }) .concat([ // These are the routes where we overwrite the 'default' - // repo service. Default repos are repos that return a promise resovlving to + // repo service. Default repos are repos that return a promise resolving to // an ember-data record or recordset { route: 'dc/nodes/index', @@ -28,6 +28,14 @@ export function initialize(container) { repo: 'repository/node/event-source', }, }, + { + route: 'dc/nodes/show', + services: { + repo: 'repository/node/event-source', + coordinateRepo: 'repository/coordinate/event-source', + sessionRepo: 'repository/session/event-source', + }, + }, { route: 'dc/services/index', services: { @@ -40,6 +48,13 @@ export function initialize(container) { repo: 'repository/service/event-source', }, }, + { + route: 'dc/services/instance', + services: { + repo: 'repository/service/event-source', + proxyRepo: 'repository/proxy/event-source', + }, + }, ]) .forEach(function(definition) { if (typeof definition.extend !== 'undefined') { diff --git a/ui-v2/app/models/node.js b/ui-v2/app/models/node.js index cbe4272a58..be33ca7cf6 100644 --- a/ui-v2/app/models/node.js +++ b/ui-v2/app/models/node.js @@ -21,6 +21,7 @@ export default Model.extend({ Datacenter: attr('string'), Segment: attr(), Coord: attr(), + meta: attr(), hasStatus: function(status) { return hasStatus(get(this, 'Checks'), status); }, diff --git a/ui-v2/app/routes/dc/nodes/show.js b/ui-v2/app/routes/dc/nodes/show.js index 0449afc2df..ad7bf3b029 100644 --- a/ui-v2/app/routes/dc/nodes/show.js +++ b/ui-v2/app/routes/dc/nodes/show.js @@ -1,17 +1,14 @@ import Route from '@ember/routing/route'; import { inject as service } from '@ember/service'; import { hash } from 'rsvp'; -import { get, set } from '@ember/object'; +import { get } from '@ember/object'; -import distance from 'consul-ui/utils/distance'; -import tomographyFactory from 'consul-ui/utils/tomography'; import WithBlockingActions from 'consul-ui/mixins/with-blocking-actions'; -const tomography = tomographyFactory(distance); - export default Route.extend(WithBlockingActions, { repo: service('repository/node'), sessionRepo: service('repository/session'), + coordinateRepo: service('repository/coordinate'), queryParams: { s: { as: 'filter', @@ -20,24 +17,11 @@ export default Route.extend(WithBlockingActions, { }, model: function(params) { const dc = this.modelFor('dc').dc.Name; - const repo = get(this, 'repo'); - const sessionRepo = get(this, 'sessionRepo'); + const name = params.name; return hash({ - item: repo.findBySlug(params.name, dc), - }).then(function(model) { - // TODO: Consider loading this after initial page load - const coordinates = get(model.item, 'Coordinates'); - return hash({ - ...model, - ...{ - tomography: - get(coordinates, 'length') > 1 - ? tomography(params.name, coordinates.map(item => get(item, 'data'))) - : null, - items: get(model.item, 'Services'), - sessions: sessionRepo.findByNode(get(model.item, 'Node'), dc), - }, - }); + item: get(this, 'repo').findBySlug(name, dc), + tomography: get(this, 'coordinateRepo').findAllByNode(name, dc), + sessions: get(this, 'sessionRepo').findByNode(name, dc), }); }, setupController: function(controller, model) { @@ -52,7 +36,9 @@ export default Route.extend(WithBlockingActions, { const node = get(item, 'Node'); return repo.remove(item).then(() => { return repo.findByNode(node, dc).then(function(sessions) { - set(controller, 'sessions', sessions); + controller.setProperties({ + sessions: sessions, + }); }); }); }, 'delete'); diff --git a/ui-v2/app/routes/dc/services/instance.js b/ui-v2/app/routes/dc/services/instance.js index b4d0f1c5b9..2fb9eb30fd 100644 --- a/ui-v2/app/routes/dc/services/instance.js +++ b/ui-v2/app/routes/dc/services/instance.js @@ -13,6 +13,9 @@ export default Route.extend({ return hash({ item: repo.findInstanceBySlug(params.id, params.name, dc), }).then(function(model) { + // this will not be run in a blocking loop, but this is ok as + // its highly unlikely that a service will suddenly change to being a + // connect-proxy or vice versa so leave as is for now return hash({ proxy: get(model.item, 'Kind') === 'connect-proxy' diff --git a/ui-v2/app/services/repository/coordinate.js b/ui-v2/app/services/repository/coordinate.js index f6f5c1d8e6..75b3f2f1f7 100644 --- a/ui-v2/app/services/repository/coordinate.js +++ b/ui-v2/app/services/repository/coordinate.js @@ -1,8 +1,23 @@ +import { get } from '@ember/object'; import RepositoryService from 'consul-ui/services/repository'; +import tomographyFactory from 'consul-ui/utils/tomography'; +import distance from 'consul-ui/utils/distance'; +const tomography = tomographyFactory(distance); + const modelName = 'coordinate'; export default RepositoryService.extend({ getModelName: function() { return modelName; }, + findAllByNode: function(node, dc, configuration) { + return this.findAllByDatacenter(dc, configuration).then(function(coordinates) { + let results = {}; + if (get(coordinates, 'length') > 1) { + results = tomography(node, coordinates.map(item => get(item, 'data'))); + } + results.meta = get(coordinates, 'meta'); + return results; + }); + }, }); diff --git a/ui-v2/app/services/repository/node.js b/ui-v2/app/services/repository/node.js index 0d7eb57344..eec4f211d3 100644 --- a/ui-v2/app/services/repository/node.js +++ b/ui-v2/app/services/repository/node.js @@ -1,20 +1,9 @@ import RepositoryService from 'consul-ui/services/repository'; import { inject as service } from '@ember/service'; -import { get } from '@ember/object'; const modelName = 'node'; export default RepositoryService.extend({ coordinates: service('repository/coordinate'), getModelName: function() { return modelName; }, - findBySlug: function(slug, dc) { - return this._super(...arguments).then(node => { - return get(this, 'coordinates') - .findAllByDatacenter(dc) - .then(function(res) { - node.Coordinates = res; - return node; - }); - }); - }, }); diff --git a/ui-v2/app/services/repository/proxy.js b/ui-v2/app/services/repository/proxy.js index 41f1207b4e..6daeee8c54 100644 --- a/ui-v2/app/services/repository/proxy.js +++ b/ui-v2/app/services/repository/proxy.js @@ -1,6 +1,6 @@ import RepositoryService from 'consul-ui/services/repository'; import { PRIMARY_KEY } from 'consul-ui/models/proxy'; -import { get } from '@ember/object'; +import { get, set } from '@ember/object'; const modelName = 'proxy'; export default RepositoryService.extend({ getModelName: function() { @@ -21,17 +21,20 @@ export default RepositoryService.extend({ }, findInstanceBySlug: function(id, slug, dc, configuration) { return this.findAllBySlug(slug, dc, configuration).then(function(items) { + let res = {}; if (get(items, 'length') > 0) { let instance = items.findBy('ServiceProxy.DestinationServiceID', id); if (instance) { - return instance; - } - instance = items.findBy('ServiceProxy.DestinationServiceName', slug); - if (instance) { - return instance; + res = instance; + } else { + instance = items.findBy('ServiceProxy.DestinationServiceName', slug); + if (instance) { + res = instance; + } } } - return; + set(res, 'meta', get(items, 'meta')); + return res; }); }, }); diff --git a/ui-v2/app/services/repository/service.js b/ui-v2/app/services/repository/service.js index 3e42c84f56..472c6ae670 100644 --- a/ui-v2/app/services/repository/service.js +++ b/ui-v2/app/services/repository/service.js @@ -46,6 +46,7 @@ export default RepositoryService.extend({ service.NodeChecks = item.Nodes[i].Checks.filter(function(item) { return item.ServiceID == ''; }); + set(service, 'meta', get(item, 'meta')); return service; } // TODO: Add an store.error("404", "message") or similar diff --git a/ui-v2/app/templates/dc/nodes/-notifications.hbs b/ui-v2/app/templates/dc/nodes/-notifications.hbs index 70f90530b4..cbc36249f0 100644 --- a/ui-v2/app/templates/dc/nodes/-notifications.hbs +++ b/ui-v2/app/templates/dc/nodes/-notifications.hbs @@ -4,5 +4,9 @@ {{else}} There was an error invalidating the session. {{/if}} +{{else if (eq type 'update')}} + {{#if (eq status 'warning') }} + This node no longer exists in the catalog. + {{else}} + {{/if}} {{/if}} - diff --git a/ui-v2/app/templates/dc/nodes/show.hbs b/ui-v2/app/templates/dc/nodes/show.hbs index 10a12fc576..3a4d135ffe 100644 --- a/ui-v2/app/templates/dc/nodes/show.hbs +++ b/ui-v2/app/templates/dc/nodes/show.hbs @@ -18,7 +18,7 @@ (array 'Health Checks' 'Services' - (if tomography 'Round Trip Time' '') + (if tomography.distances 'Round Trip Time' '') 'Lock Sessions' ) ) @@ -48,10 +48,10 @@ {{#each (compact (array - (hash id=(slugify 'Health Checks') partial='dc/nodes/healthchecks') - (hash id=(slugify 'Services') partial='dc/nodes/services') - (if tomography (hash id=(slugify 'Round Trip Time') partial='dc/nodes/rtt') '') - (hash id=(slugify 'Lock Sessions') partial='dc/nodes/sessions') + (hash id=(slugify 'Health Checks') partial='dc/nodes/healthchecks') + (hash id=(slugify 'Services') partial='dc/nodes/services') + (if tomography.distances (hash id=(slugify 'Round Trip Time') partial='dc/nodes/rtt') '') + (hash id=(slugify 'Lock Sessions') partial='dc/nodes/sessions') ) ) as |panel| }} diff --git a/ui-v2/app/templates/dc/services/instance.hbs b/ui-v2/app/templates/dc/services/instance.hbs index 76a0b95d26..b5aae2b9ea 100644 --- a/ui-v2/app/templates/dc/services/instance.hbs +++ b/ui-v2/app/templates/dc/services/instance.hbs @@ -1,4 +1,7 @@ {{#app-view class="instance show"}} + {{#block-slot 'notification' as |status type|}} + {{partial 'dc/services/notifications'}} + {{/block-slot}} {{#block-slot 'breadcrumbs'}}
          1. All Services
          2. @@ -28,7 +31,7 @@
            Node Name
            {{item.Node.Node}}

        -{{#if proxy}} +{{#if proxy.ServiceName}}
        {{if proxy.ServiceProxy.DestinationServiceID "Sidecar " ""}}Proxy
        {{proxy.ServiceID}}
        diff --git a/ui-v2/tests/acceptance/dc/list-blocking.feature b/ui-v2/tests/acceptance/dc/list-blocking.feature index 21bb5ec990..7413ae8a7b 100644 --- a/ui-v2/tests/acceptance/dc/list-blocking.feature +++ b/ui-v2/tests/acceptance/dc/list-blocking.feature @@ -46,11 +46,9 @@ Feature: dc / list-blocking And an external edit results in 1 [Model] model And pause until I see 1 [Model] model And an external edit results in 0 [Model] models - And pause for 300 - And I see the text "deregistered" in "[data-notification]" + And pause until I see the text "deregistered" in "[data-notification]" Where: - ------------------------------------------------ - | Page | Model | Url | + ------------------------------------------------- + | Page | Model | Url | | service | instance | services/service-0 | - ------------------------------------------------ - + ------------------------------------------------- \ No newline at end of file diff --git a/ui-v2/tests/acceptance/dc/nodes/show.feature b/ui-v2/tests/acceptance/dc/nodes/show.feature index 2ea28bb3e2..21ca13a9ad 100644 --- a/ui-v2/tests/acceptance/dc/nodes/show.feature +++ b/ui-v2/tests/acceptance/dc/nodes/show.feature @@ -1,8 +1,9 @@ @setupApplicationTest Feature: dc / nodes / show: Show node - Scenario: Given 2 nodes all the tabs are visible and clickable + Background: Given 1 datacenter model with the value "dc1" - And 2 node models from yaml + Scenario: Given 2 nodes all the tabs are visible and clickable + Given 2 node models from yaml When I visit the node page for yaml --- dc: dc1 @@ -19,8 +20,7 @@ Feature: dc / nodes / show: Show node When I click lockSessions on the tabs And I see lockSessionsIsSelected on the tabs Scenario: Given 1 node all the tabs are visible and clickable and the RTT one isn't there - Given 1 datacenter model with the value "dc1" - And 1 node models from yaml + Given 1 node models from yaml --- ID: node-0 --- @@ -39,8 +39,7 @@ Feature: dc / nodes / show: Show node When I click lockSessions on the tabs And I see lockSessionsIsSelected on the tabs Scenario: Given 1 node with no checks all the tabs are visible but the Services tab is selected - Given 1 datacenter model with the value "dc1" - And 1 node models from yaml + Given 1 node models from yaml --- ID: node-0 Checks: [] @@ -55,3 +54,24 @@ Feature: dc / nodes / show: Show node And I see roundTripTime on the tabs And I see lockSessions on the tabs And I see servicesIsSelected on the tabs + Scenario: A node warns when deregistered whilst blocking + Given 1 node model from yaml + --- + ID: node-0 + --- + And settings from yaml + --- + consul:client: + blocking: 1 + throttle: 200 + --- + And a network latency of 100 + When I visit the node page for yaml + --- + dc: dc1 + node: node-0 + --- + Then the url should be /dc1/nodes/node-0 + And the url "/v1/internal/ui/node/node-0" responds with a 404 status + And pause until I see the text "no longer exists" in "[data-notification]" + diff --git a/ui-v2/tests/acceptance/dc/services/instances/show.feature b/ui-v2/tests/acceptance/dc/services/instances/show.feature index bf3f64971c..8547bf6615 100644 --- a/ui-v2/tests/acceptance/dc/services/instances/show.feature +++ b/ui-v2/tests/acceptance/dc/services/instances/show.feature @@ -1,6 +1,6 @@ @setupApplicationTest Feature: dc / services / instances / show: Show Service Instance - Scenario: A Service instance has no Proxy + Background: Given 1 datacenter model with the value "dc1" And 1 service model from yaml --- @@ -41,6 +41,7 @@ Feature: dc / services / instances / show: Show Service Instance DestinationServiceName: service-1 DestinationServiceID: ~ --- + Scenario: A Service instance has no Proxy When I visit the instance page for yaml --- dc: dc1 @@ -49,7 +50,6 @@ Feature: dc / services / instances / show: Show Service Instance --- Then the url should be /dc1/services/service-0/service-0-with-id Then I don't see type on the proxy - Then I see externalSource like "nomad" And I don't see upstreams on the tabs @@ -65,4 +65,21 @@ Feature: dc / services / instances / show: Show Service Instance Then I see the text "Tag1" in "[data-test-tags] span:nth-child(1)" Then I see the text "Tag2" in "[data-test-tags] span:nth-child(2)" + Scenario: A Service instance warns when deregistered whilst blocking + Given settings from yaml + --- + consul:client: + blocking: 1 + throttle: 200 + --- + And a network latency of 100 + When I visit the instance page for yaml + --- + dc: dc1 + service: service-0 + id: service-0-with-id + --- + Then the url should be /dc1/services/service-0/service-0-with-id + And an external edit results in 0 instance models + And pause until I see the text "deregistered" in "[data-notification]" diff --git a/ui-v2/tests/integration/services/repository/node-test.js b/ui-v2/tests/integration/services/repository/node-test.js index 8a6b4816da..d3086c04fc 100644 --- a/ui-v2/tests/integration/services/repository/node-test.js +++ b/ui-v2/tests/integration/services/repository/node-test.js @@ -55,6 +55,10 @@ test('findBySlug returns the correct data for item endpoint', function(assert) { return Object.assign({}, item, { Datacenter: dc, uid: `["${dc}","${item.ID}"]`, + meta: { + date: undefined, + cursor: undefined, + }, }); }) ); diff --git a/ui-v2/tests/steps.js b/ui-v2/tests/steps.js index cad75cceef..b115e0c0dd 100644 --- a/ui-v2/tests/steps.js +++ b/ui-v2/tests/steps.js @@ -21,6 +21,22 @@ export default function(assert, library, pages, utils) { return page; }; + const pauseUntil = function(cb) { + return new Promise(function(resolve, reject) { + let count = 0; + const interval = setInterval(function() { + if (++count >= 50) { + clearInterval(interval); + assert.ok(false); + reject(); + } + cb(function() { + clearInterval(interval); + resolve(); + }); + }, 100); + }); + }; models(library, utils.create); http(library, utils.respondWith, utils.set); visit(library, pages, setCurrentPage); @@ -28,9 +44,9 @@ export default function(assert, library, pages, utils) { form(library, utils.fillIn, utils.triggerKeyEvent, getCurrentPage); debug(library, assert, utils.currentURL); assertHttp(library, assert, utils.lastNthRequest); - assertModel(library, assert, getCurrentPage, utils.pluralize); + assertModel(library, assert, getCurrentPage, pauseUntil, utils.pluralize); assertPage(library, assert, getCurrentPage); - assertDom(library, assert, utils.find, utils.currentURL); + assertDom(library, assert, pauseUntil, utils.find, utils.currentURL); return library.given(["I'm using a legacy token"], function(number, model, data) { window.localStorage['consul:token'] = JSON.stringify({ AccessorID: null, SecretID: 'id' }); diff --git a/ui-v2/tests/steps/assertions/dom.js b/ui-v2/tests/steps/assertions/dom.js index 519ed3ab7a..12699d04bf 100644 --- a/ui-v2/tests/steps/assertions/dom.js +++ b/ui-v2/tests/steps/assertions/dom.js @@ -1,5 +1,17 @@ -export default function(scenario, assert, find, currentURL) { +export default function(scenario, assert, pauseUntil, find, currentURL) { scenario + .then('pause until I see the text "$text" in "$selector"', function(text, selector) { + return pauseUntil(function(resolve) { + const $el = find(selector); + if ($el) { + const hasText = $el.textContent.indexOf(text) !== -1; + if (hasText) { + assert.ok(hasText, `Expected to see "${text}" in "${selector}"`); + resolve(); + } + } + }); + }) .then(['I see the text "$text" in "$selector"'], function(text, selector) { assert.ok( find(selector).textContent.indexOf(text) !== -1, diff --git a/ui-v2/tests/steps/assertions/model.js b/ui-v2/tests/steps/assertions/model.js index 7019c086ea..c7d68bd33a 100644 --- a/ui-v2/tests/steps/assertions/model.js +++ b/ui-v2/tests/steps/assertions/model.js @@ -1,23 +1,14 @@ -export default function(scenario, assert, currentPage, pluralize) { +export default function(scenario, assert, currentPage, pauseUntil, pluralize) { scenario .then('pause until I see $number $model model[s]?', function(num, model) { - return new Promise(function(resolve) { - let count = 0; - const interval = setInterval(function() { - if (++count >= 50) { - clearInterval(interval); - assert.ok(false); - resolve(); - } - const len = currentPage()[pluralize(model)].filter(function(item) { - return item.isVisible; - }).length; - if (len === num) { - clearInterval(interval); - assert.equal(len, num, `Expected ${num} ${model}s, saw ${len}`); - resolve(); - } - }, 100); + return pauseUntil(function(resolve) { + const len = currentPage()[pluralize(model)].filter(function(item) { + return item.isVisible; + }).length; + if (len === num) { + assert.equal(len, num, `Expected ${num} ${model}s, saw ${len}`); + resolve(); + } }); }) .then(['I see $num $model model[s]?'], function(num, model) { diff --git a/ui-v2/tests/unit/controllers/dc/nodes/show-test.js b/ui-v2/tests/unit/controllers/dc/nodes/show-test.js index a1dd0c5618..d6f93489b6 100644 --- a/ui-v2/tests/unit/controllers/dc/nodes/show-test.js +++ b/ui-v2/tests/unit/controllers/dc/nodes/show-test.js @@ -2,7 +2,7 @@ import { moduleFor, test } from 'ember-qunit'; moduleFor('controller:dc/nodes/show', 'Unit | Controller | dc/nodes/show', { // Specify the other units that are required for this test. - needs: ['service:search', 'service:dom'], + needs: ['service:search', 'service:dom', 'service:flashMessages'], }); // Replace this with your real tests. diff --git a/ui-v2/tests/unit/controllers/dc/services/instance-test.js b/ui-v2/tests/unit/controllers/dc/services/instance-test.js index 2b0693934f..7e4fc2ca75 100644 --- a/ui-v2/tests/unit/controllers/dc/services/instance-test.js +++ b/ui-v2/tests/unit/controllers/dc/services/instance-test.js @@ -2,7 +2,7 @@ import { moduleFor, test } from 'ember-qunit'; moduleFor('controller:dc/services/instance', 'Unit | Controller | dc/services/instance', { // Specify the other units that are required for this test. - // needs: ['controller:foo'] + needs: ['service:dom', 'service:flashMessages'], }); // Replace this with your real tests. diff --git a/ui-v2/tests/unit/routes/dc/nodes/show-test.js b/ui-v2/tests/unit/routes/dc/nodes/show-test.js index e6f486e6bc..4f512ba9aa 100644 --- a/ui-v2/tests/unit/routes/dc/nodes/show-test.js +++ b/ui-v2/tests/unit/routes/dc/nodes/show-test.js @@ -4,6 +4,7 @@ moduleFor('route:dc/nodes/show', 'Unit | Route | dc/nodes/show', { // Specify the other units that are required for this test. needs: [ 'service:repository/node', + 'service:repository/coordinate', 'service:repository/session', 'service:feedback', 'service:logger', From 4a9d259304cd181026a8cd2fe43757fe1ab84b5d Mon Sep 17 00:00:00 2001 From: John Cowen Date: Mon, 25 Mar 2019 14:20:59 +0000 Subject: [PATCH 39/52] ui: Remove index.html from the docs URL so we just point to `/docs` (#5547) --- ui-v2/app/templates/components/hashicorp-consul.hbs | 4 ++-- ui-v2/app/templates/error.hbs | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/ui-v2/app/templates/components/hashicorp-consul.hbs b/ui-v2/app/templates/components/hashicorp-consul.hbs index 32161b51e6..4c7ba823ef 100644 --- a/ui-v2/app/templates/components/hashicorp-consul.hbs +++ b/ui-v2/app/templates/components/hashicorp-consul.hbs @@ -42,7 +42,7 @@