From 65ef2969c7baa40bdd7d1641d8cbaf5bf7c6d9e7 Mon Sep 17 00:00:00 2001 From: John Cowen Date: Tue, 6 Nov 2018 09:10:20 +0000 Subject: [PATCH] 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