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]'),
+ },
});