diff --git a/ui/app/controllers/clients/index.js b/ui/app/controllers/clients/index.js index ac19f9d9d3d..e4e7f771bd8 100644 --- a/ui/app/controllers/clients/index.js +++ b/ui/app/controllers/clients/index.js @@ -1,8 +1,11 @@ import { alias } from '@ember/object/computed'; import Controller, { inject as controller } from '@ember/controller'; import { computed } from '@ember/object'; +import { scheduleOnce } from '@ember/runloop'; +import intersection from 'lodash.intersection'; import Sortable from 'nomad-ui/mixins/sortable'; import Searchable from 'nomad-ui/mixins/searchable'; +import { serialize, deserializedQueryParam as selection } from 'nomad-ui/utils/qp-serialize'; export default Controller.extend(Sortable, Searchable, { clientsController: controller('clients'), @@ -15,6 +18,10 @@ export default Controller.extend(Sortable, Searchable, { searchTerm: 'search', sortProperty: 'sort', sortDescending: 'desc', + qpClass: 'class', + qpStatus: 'status', + qpDatacenter: 'dc', + qpFlags: 'flags', }, currentPage: 1, @@ -25,12 +32,97 @@ export default Controller.extend(Sortable, Searchable, { searchProps: computed(() => ['id', 'name', 'datacenter']), - listToSort: alias('nodes'), + qpClass: '', + qpStatus: '', + qpDatacenter: '', + qpFlags: '', + + selectionClass: selection('qpClass'), + selectionStatus: selection('qpStatus'), + selectionDatacenter: selection('qpDatacenter'), + selectionFlags: selection('qpFlags'), + + optionsClass: computed('nodes.[]', function() { + const classes = Array.from(new Set(this.get('nodes').mapBy('nodeClass'))).compact(); + + // Remove any invalid node classes from the query param/selection + scheduleOnce('actions', () => { + this.set('qpClass', serialize(intersection(classes, this.get('selectionClass')))); + }); + + return classes.sort().map(dc => ({ key: dc, label: dc })); + }), + + optionsStatus: computed(() => [ + { key: 'initializing', label: 'Initializing' }, + { key: 'ready', label: 'Ready' }, + { key: 'down', label: 'Down' }, + ]), + + optionsDatacenter: computed('nodes.[]', function() { + const datacenters = Array.from(new Set(this.get('nodes').mapBy('datacenter'))).compact(); + + // Remove any invalid datacenters from the query param/selection + scheduleOnce('actions', () => { + this.set( + 'qpDatacenter', + serialize(intersection(datacenters, this.get('selectionDatacenter'))) + ); + }); + + return datacenters.sort().map(dc => ({ key: dc, label: dc })); + }), + + optionsFlags: computed(() => [ + { key: 'ineligible', label: 'Ineligible' }, + { key: 'draining', label: 'Draining' }, + ]), + + filteredNodes: computed( + 'nodes.[]', + 'selectionClass', + 'selectionStatus', + 'selectionDatacenter', + 'selectionFlags', + function() { + const { + selectionClass: classes, + selectionStatus: statuses, + selectionDatacenter: datacenters, + selectionFlags: flags, + } = this.getProperties( + 'selectionClass', + 'selectionStatus', + 'selectionDatacenter', + 'selectionFlags' + ); + + const onlyIneligible = flags.includes('ineligible'); + const onlyDraining = flags.includes('draining'); + + return this.get('nodes').filter(node => { + if (classes.length && !classes.includes(node.get('nodeClass'))) return false; + if (statuses.length && !statuses.includes(node.get('status'))) return false; + if (datacenters.length && !datacenters.includes(node.get('datacenter'))) return false; + + if (onlyIneligible && node.get('isEligible')) return false; + if (onlyDraining && !node.get('isDraining')) return false; + + return true; + }); + } + ), + + listToSort: alias('filteredNodes'), listToSearch: alias('listSorted'), sortedNodes: alias('listSearched'), isForbidden: alias('clientsController.isForbidden'), + setFacetQueryParam(queryParam, selection) { + this.set(queryParam, serialize(selection)); + }, + actions: { gotoNode(node) { this.transitionToRoute('clients.client', node); diff --git a/ui/app/controllers/jobs/index.js b/ui/app/controllers/jobs/index.js index bd27f7d9a3d..529469e8ffa 100644 --- a/ui/app/controllers/jobs/index.js +++ b/ui/app/controllers/jobs/index.js @@ -6,23 +6,7 @@ import { scheduleOnce } from '@ember/runloop'; import intersection from 'lodash.intersection'; import Sortable from 'nomad-ui/mixins/sortable'; import Searchable from 'nomad-ui/mixins/searchable'; - -// An unattractive but robust way to encode query params -const qpSerialize = arr => (arr.length ? JSON.stringify(arr) : ''); -const qpDeserialize = str => { - try { - return JSON.parse(str) - .compact() - .without(''); - } catch (e) { - return []; - } -}; - -const qpSelection = qpKey => - computed(qpKey, function() { - return qpDeserialize(this.get(qpKey)); - }); +import { serialize, deserializedQueryParam as selection } from 'nomad-ui/utils/qp-serialize'; export default Controller.extend(Sortable, Searchable, { system: service(), @@ -56,10 +40,10 @@ export default Controller.extend(Sortable, Searchable, { qpDatacenter: '', qpPrefix: '', - selectionType: qpSelection('qpType'), - selectionStatus: qpSelection('qpStatus'), - selectionDatacenter: qpSelection('qpDatacenter'), - selectionPrefix: qpSelection('qpPrefix'), + selectionType: selection('qpType'), + selectionStatus: selection('qpStatus'), + selectionDatacenter: selection('qpDatacenter'), + selectionPrefix: selection('qpPrefix'), optionsType: computed(() => [ { key: 'batch', label: 'Batch' }, @@ -88,7 +72,7 @@ export default Controller.extend(Sortable, Searchable, { scheduleOnce('actions', () => { this.set( 'qpDatacenter', - qpSerialize(intersection(availableDatacenters, this.get('selectionDatacenter'))) + serialize(intersection(availableDatacenters, this.get('selectionDatacenter'))) ); }); @@ -122,10 +106,7 @@ export default Controller.extend(Sortable, Searchable, { // Remove any invalid prefixes from the query param/selection const availablePrefixes = prefixes.mapBy('prefix'); scheduleOnce('actions', () => { - this.set( - 'qpPrefix', - qpSerialize(intersection(availablePrefixes, this.get('selectionPrefix'))) - ); + this.set('qpPrefix', serialize(intersection(availablePrefixes, this.get('selectionPrefix')))); }); // Sort, format, and include the count in the label @@ -202,7 +183,7 @@ export default Controller.extend(Sortable, Searchable, { isShowingDeploymentDetails: false, setFacetQueryParam(queryParam, selection) { - this.set(queryParam, qpSerialize(selection)); + this.set(queryParam, serialize(selection)); }, actions: { diff --git a/ui/app/models/node.js b/ui/app/models/node.js index 93ac0038f52..eb86e19db36 100644 --- a/ui/app/models/node.js +++ b/ui/app/models/node.js @@ -11,6 +11,7 @@ export default Model.extend({ // Available from list response name: attr('string'), datacenter: attr('string'), + nodeClass: attr('string'), isDraining: attr('boolean'), schedulingEligibility: attr('string'), status: attr('string'), diff --git a/ui/app/templates/clients/index.hbs b/ui/app/templates/clients/index.hbs index 4ed9e77127b..785483eff99 100644 --- a/ui/app/templates/clients/index.hbs +++ b/ui/app/templates/clients/index.hbs @@ -2,16 +2,44 @@ {{#if isForbidden}} {{partial "partials/forbidden-message"}} {{else}} - {{#if nodes.length}} -
-
+
+ {{#if nodes.length}} +
{{search-box searchTerm=(mut searchTerm) onChange=(action resetPagination) placeholder="Search clients..."}}
+ {{/if}} +
+
+ {{multi-select-dropdown + data-test-class-facet + label="Class" + options=optionsClass + selection=selectionClass + onSelect=(action setFacetQueryParam "qpClass")}} + {{multi-select-dropdown + data-test-status-facet + label="Status" + options=optionsStatus + selection=selectionStatus + onSelect=(action setFacetQueryParam "qpStatus")}} + {{multi-select-dropdown + data-test-datacenter-facet + label="Datacenter" + options=optionsDatacenter + selection=selectionDatacenter + onSelect=(action setFacetQueryParam "qpDatacenter")}} + {{multi-select-dropdown + data-test-flags-facet + label="Flags" + options=optionsFlags + selection=selectionFlags + onSelect=(action setFacetQueryParam "qpFlags")}} +
- {{/if}} +
{{#list-pagination source=sortedNodes size=pageSize @@ -53,6 +81,11 @@

The cluster currently has no client nodes.

+ {{else if (eq filteredNodes.length 0)}} +

No Matches

+

+ No clients match your current filter selection. +

{{else if searchTerm}}

No Matches

No clients match the term {{searchTerm}}

diff --git a/ui/app/utils/qp-serialize.js b/ui/app/utils/qp-serialize.js new file mode 100644 index 00000000000..af2d5769e39 --- /dev/null +++ b/ui/app/utils/qp-serialize.js @@ -0,0 +1,20 @@ +import { computed } from '@ember/object'; + +// An unattractive but robust way to encode query params +export const serialize = arr => (arr.length ? JSON.stringify(arr) : ''); + +export const deserialize = str => { + try { + return JSON.parse(str) + .compact() + .without(''); + } catch (e) { + return []; + } +}; + +// A computed property macro for deserializing a query param +export const deserializedQueryParam = qpKey => + computed(qpKey, function() { + return deserialize(this.get(qpKey)); + }); diff --git a/ui/mirage/factories/node.js b/ui/mirage/factories/node.js index a7c66cd3ad2..5649786be25 100644 --- a/ui/mirage/factories/node.js +++ b/ui/mirage/factories/node.js @@ -5,6 +5,7 @@ import moment from 'moment'; const UUIDS = provide(100, faker.random.uuid.bind(faker.random)); const NODE_STATUSES = ['initializing', 'ready', 'down']; +const NODE_CLASSES = provide(7, faker.company.bsBuzz.bind(faker.company)); const REF_DATE = new Date(); export default Factory.extend({ @@ -12,6 +13,7 @@ export default Factory.extend({ name: i => `nomad@${HOSTS[i % HOSTS.length]}`, datacenter: faker.list.random(...DATACENTERS), + nodeClass: faker.list.random(...NODE_CLASSES), drain: faker.random.boolean, status: faker.list.random(...NODE_STATUSES), tls_enabled: faker.random.boolean, diff --git a/ui/tests/acceptance/clients-list-test.js b/ui/tests/acceptance/clients-list-test.js index e40028c659f..1b46e0a4551 100644 --- a/ui/tests/acceptance/clients-list-test.js +++ b/ui/tests/acceptance/clients-list-test.js @@ -114,3 +114,216 @@ test('when accessing clients is forbidden, show a message with a link to the tok assert.equal(currentURL(), '/settings/tokens'); }); }); + +testFacet('Class', { + facet: ClientsList.facets.class, + paramName: 'class', + expectedOptions(nodes) { + return Array.from(new Set(nodes.mapBy('nodeClass'))).sort(); + }, + beforeEach() { + server.create('agent'); + server.createList('node', 2, { nodeClass: 'nc-one' }); + server.createList('node', 2, { nodeClass: 'nc-two' }); + server.createList('node', 2, { nodeClass: 'nc-three' }); + ClientsList.visit(); + }, + filter: (node, selection) => selection.includes(node.nodeClass), +}); + +testFacet('Status', { + facet: ClientsList.facets.status, + paramName: 'status', + expectedOptions: ['Initializing', 'Ready', 'Down'], + beforeEach() { + server.create('agent'); + server.createList('node', 2, { status: 'initializing' }); + server.createList('node', 2, { status: 'ready' }); + server.createList('node', 2, { status: 'down' }); + ClientsList.visit(); + }, + filter: (node, selection) => selection.includes(node.status), +}); + +testFacet('Datacenters', { + facet: ClientsList.facets.datacenter, + paramName: 'dc', + expectedOptions(nodes) { + return Array.from(new Set(nodes.mapBy('datacenter'))).sort(); + }, + beforeEach() { + server.create('agent'); + server.createList('node', 2, { datacenter: 'pdx-1' }); + server.createList('node', 2, { datacenter: 'nyc-1' }); + server.createList('node', 2, { datacenter: 'ams-1' }); + ClientsList.visit(); + }, + filter: (node, selection) => selection.includes(node.datacenter), +}); + +testFacet('Flags', { + facet: ClientsList.facets.flags, + paramName: 'flags', + expectedOptions: ['Ineligible', 'Draining'], + beforeEach() { + server.create('agent'); + server.createList('node', 2, { schedulingEligibility: 'eligible', drain: false }); + server.createList('node', 2, { schedulingEligibility: 'ineligible', drain: false }); + server.createList('node', 2, { schedulingEligibility: 'ineligible', drain: true }); + ClientsList.visit(); + }, + filter: (node, selection) => { + if (selection.includes('draining') && !node.drain) return false; + if (selection.includes('ineligible') && node.schedulingEligibility === 'eligible') return false; + return true; + }, +}); + +test('when the facet selections result in no matches, the empty state states why', function(assert) { + server.create('agent'); + server.createList('node', 2, { status: 'ready' }); + + ClientsList.visit(); + + andThen(() => { + ClientsList.facets.status.toggle(); + }); + + andThen(() => { + ClientsList.facets.status.options.objectAt(0).toggle(); + }); + + andThen(() => { + assert.ok(ClientsList.isEmpty, 'There is an empty message'); + assert.equal(ClientsList.empty.headline, 'No Matches', 'The message is appropriate'); + }); +}); + +test('the clients list is immediately filtered based on query params', function(assert) { + server.create('agent'); + server.create('node', { nodeClass: 'omg-large' }); + server.create('node', { nodeClass: 'wtf-tiny' }); + + ClientsList.visit({ class: JSON.stringify(['wtf-tiny']) }); + + andThen(() => { + assert.equal(ClientsList.nodes.length, 1, 'Only one client shown due to query param'); + }); +}); + +function testFacet(label, { facet, paramName, beforeEach, filter, expectedOptions }) { + test(`the ${label} facet has the correct options`, function(assert) { + beforeEach(); + + andThen(() => { + facet.toggle(); + }); + + andThen(() => { + let expectation; + if (typeof expectedOptions === 'function') { + expectation = expectedOptions(server.db.nodes); + } else { + expectation = expectedOptions; + } + + assert.deepEqual( + facet.options.map(option => option.label.trim()), + expectation, + 'Options for facet are as expected' + ); + }); + }); + + test(`the ${label} facet filters the nodes list by ${label}`, function(assert) { + let option; + + beforeEach(); + + andThen(() => { + facet.toggle(); + }); + + andThen(() => { + option = facet.options.objectAt(0); + option.toggle(); + }); + + andThen(() => { + const selection = [option.key]; + const expectedNodes = server.db.nodes + .filter(node => filter(node, selection)) + .sortBy('modifyIndex') + .reverse(); + + ClientsList.nodes.forEach((node, index) => { + assert.equal( + node.id, + expectedNodes[index].id.split('-')[0], + `Node at ${index} is ${expectedNodes[index].id}` + ); + }); + }); + }); + + test(`selecting multiple options in the ${label} facet results in a broader search`, function(assert) { + const selection = []; + + beforeEach(); + + andThen(() => { + facet.toggle(); + }); + + andThen(() => { + const option1 = facet.options.objectAt(0); + const option2 = facet.options.objectAt(1); + option1.toggle(); + selection.push(option1.key); + option2.toggle(); + selection.push(option2.key); + }); + + andThen(() => { + const expectedNodes = server.db.nodes + .filter(node => filter(node, selection)) + .sortBy('modifyIndex') + .reverse(); + + ClientsList.nodes.forEach((node, index) => { + assert.equal( + node.id, + expectedNodes[index].id.split('-')[0], + `Node at ${index} is ${expectedNodes[index].id}` + ); + }); + }); + }); + + test(`selecting options in the ${label} facet updates the ${paramName} query param`, function(assert) { + const selection = []; + + beforeEach(); + + andThen(() => { + facet.toggle(); + }); + + andThen(() => { + const option1 = facet.options.objectAt(0); + const option2 = facet.options.objectAt(1); + option1.toggle(); + selection.push(option1.key); + option2.toggle(); + selection.push(option2.key); + }); + + andThen(() => { + assert.equal( + currentURL(), + `/clients?${paramName}=${encodeURIComponent(JSON.stringify(selection))}`, + 'URL has the correct query param key and value' + ); + }); + }); +} diff --git a/ui/tests/pages/clients/list.js b/ui/tests/pages/clients/list.js index 06b0f2cd0f2..2945dd0cff0 100644 --- a/ui/tests/pages/clients/list.js +++ b/ui/tests/pages/clients/list.js @@ -8,6 +8,8 @@ import { visitable, } from 'ember-cli-page-object'; +import facet from 'nomad-ui/tests/pages/components/facet'; + export default create({ visit: visitable('/clients'), @@ -40,4 +42,11 @@ export default create({ message: text('[data-test-error-message]'), seekHelp: clickable('[data-test-error-message] a'), }, + + facets: { + class: facet('[data-test-class-facet]'), + status: facet('[data-test-status-facet]'), + datacenter: facet('[data-test-datacenter-facet]'), + flags: facet('[data-test-flags-facet]'), + }, });