diff --git a/ui/app/controllers/jobs/index.js b/ui/app/controllers/jobs/index.js index e2bdb4a01061..bd27f7d9a3d0 100644 --- a/ui/app/controllers/jobs/index.js +++ b/ui/app/controllers/jobs/index.js @@ -2,9 +2,28 @@ import { inject as service } from '@ember/service'; 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'; +// 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)); + }); + export default Controller.extend(Sortable, Searchable, { system: service(), jobsController: controller('jobs'), @@ -16,6 +35,10 @@ export default Controller.extend(Sortable, Searchable, { searchTerm: 'search', sortProperty: 'sort', sortDescending: 'desc', + qpType: 'type', + qpStatus: 'status', + qpDatacenter: 'dc', + qpPrefix: 'prefix', }, currentPage: 1, @@ -28,11 +51,95 @@ export default Controller.extend(Sortable, Searchable, { fuzzySearchProps: computed(() => ['name']), fuzzySearchEnabled: true, + qpType: '', + qpStatus: '', + qpDatacenter: '', + qpPrefix: '', + + selectionType: qpSelection('qpType'), + selectionStatus: qpSelection('qpStatus'), + selectionDatacenter: qpSelection('qpDatacenter'), + selectionPrefix: qpSelection('qpPrefix'), + + optionsType: computed(() => [ + { key: 'batch', label: 'Batch' }, + { key: 'parameterized', label: 'Parameterized' }, + { key: 'periodic', label: 'Periodic' }, + { key: 'service', label: 'Service' }, + { key: 'system', label: 'System' }, + ]), + + optionsStatus: computed(() => [ + { key: 'pending', label: 'Pending' }, + { key: 'running', label: 'Running' }, + { key: 'dead', label: 'Dead' }, + ]), + + optionsDatacenter: computed('visibleJobs.[]', function() { + const flatten = (acc, val) => acc.concat(val); + const allDatacenters = new Set( + this.get('visibleJobs') + .mapBy('datacenters') + .reduce(flatten, []) + ); + + // Remove any invalid datacenters from the query param/selection + const availableDatacenters = Array.from(allDatacenters).compact(); + scheduleOnce('actions', () => { + this.set( + 'qpDatacenter', + qpSerialize(intersection(availableDatacenters, this.get('selectionDatacenter'))) + ); + }); + + return availableDatacenters.sort().map(dc => ({ key: dc, label: dc })); + }), + + optionsPrefix: computed('visibleJobs.[]', function() { + // A prefix is defined as the start of a job name up to the first - or . + // ex: mktg-analytics -> mktg, ds.supermodel.classifier -> ds + const hasPrefix = /.[-._]/; + + // Collect and count all the prefixes + const allNames = this.get('visibleJobs').mapBy('name'); + const nameHistogram = allNames.reduce((hist, name) => { + if (hasPrefix.test(name)) { + const prefix = name.match(/(.+?)[-.]/)[1]; + hist[prefix] = hist[prefix] ? hist[prefix] + 1 : 1; + } + return hist; + }, {}); + + // Convert to an array + const nameTable = Object.keys(nameHistogram).map(key => ({ + prefix: key, + count: nameHistogram[key], + })); + + // Only consider prefixes that match more than one name + const prefixes = nameTable.filter(name => name.count > 1); + + // 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'))) + ); + }); + + // Sort, format, and include the count in the label + return prefixes.sortBy('prefix').map(name => ({ + key: name.prefix, + label: `${name.prefix} (${name.count})`, + })); + }), + /** - Filtered jobs are those that match the selected namespace and aren't children + Visible jobs are those that match the selected namespace and aren't children of periodic or parameterized jobs. */ - filteredJobs: computed('model.[]', 'model.@each.parent', function() { + visibleJobs: computed('model.[]', 'model.@each.parent', function() { // Namespace related properties are ommitted from the dependent keys // due to a prop invalidation bug caused by region switching. const hasNamespaces = this.get('system.namespaces.length'); @@ -44,12 +151,60 @@ export default Controller.extend(Sortable, Searchable, { .filter(job => !job.get('parent.content')); }), + filteredJobs: computed( + 'visibleJobs.[]', + 'selectionType', + 'selectionStatus', + 'selectionDatacenter', + 'selectionPrefix', + function() { + const { + selectionType: types, + selectionStatus: statuses, + selectionDatacenter: datacenters, + selectionPrefix: prefixes, + } = this.getProperties( + 'selectionType', + 'selectionStatus', + 'selectionDatacenter', + 'selectionPrefix' + ); + + // A job must match ALL filter facets, but it can match ANY selection within a facet + // Always return early to prevent unnecessary facet predicates. + return this.get('visibleJobs').filter(job => { + if (types.length && !types.includes(job.get('displayType'))) { + return false; + } + + if (statuses.length && !statuses.includes(job.get('status'))) { + return false; + } + + if (datacenters.length && !job.get('datacenters').find(dc => datacenters.includes(dc))) { + return false; + } + + const name = job.get('name'); + if (prefixes.length && !prefixes.find(prefix => name.startsWith(prefix))) { + return false; + } + + return true; + }); + } + ), + listToSort: alias('filteredJobs'), listToSearch: alias('listSorted'), sortedJobs: alias('listSearched'), isShowingDeploymentDetails: false, + setFacetQueryParam(queryParam, selection) { + this.set(queryParam, qpSerialize(selection)); + }, + actions: { gotoJob(job) { this.transitionToRoute('jobs.job', job.get('plainId')); diff --git a/ui/app/styles/components/dropdown.scss b/ui/app/styles/components/dropdown.scss index 53bab9929b11..7eb4c33f32c7 100644 --- a/ui/app/styles/components/dropdown.scss +++ b/ui/app/styles/components/dropdown.scss @@ -142,4 +142,10 @@ } } } + + .dropdown-empty { + display: block; + padding: 8px 12px; + color: $grey-light; + } } diff --git a/ui/app/templates/components/multi-select-dropdown.hbs b/ui/app/templates/components/multi-select-dropdown.hbs index 5e1f4aa09df5..f7b48e14988d 100644 --- a/ui/app/templates/components/multi-select-dropdown.hbs +++ b/ui/app/templates/components/multi-select-dropdown.hbs @@ -18,7 +18,7 @@ {{#dd.content class="dropdown-options"}} {{/dd.content}} diff --git a/ui/app/templates/jobs/index.hbs b/ui/app/templates/jobs/index.hbs index 97e5d1a76259..50ab81bf2075 100644 --- a/ui/app/templates/jobs/index.hbs +++ b/ui/app/templates/jobs/index.hbs @@ -4,7 +4,7 @@ {{else}}
{{#if filteredJobs.length}} -
+
{{search-box data-test-jobs-search searchTerm=(mut searchTerm) @@ -13,7 +13,35 @@
{{/if}}
- {{#link-to "jobs.run" data-test-run-job class="button is-primary is-pulled-right"}}Run Job{{/link-to}} +
+ {{multi-select-dropdown + data-test-type-facet + label="Type" + options=optionsType + selection=selectionType + onSelect=(action setFacetQueryParam "qpType")}} + {{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-prefix-facet + label="Prefix" + options=optionsPrefix + selection=selectionPrefix + onSelect=(action setFacetQueryParam "qpPrefix")}} +
+
+
+ {{#link-to "jobs.run" data-test-run-job class="button is-primary"}}Run Job{{/link-to}}
{{#list-pagination @@ -52,11 +80,16 @@
{{else}}
- {{#if (eq filteredJobs.length 0)}} + {{#if (eq visibleJobs.length 0)}}

No Jobs

The cluster is currently empty.

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

No Matches

+

+ No jobs match your current filter selection. +

{{else if searchTerm}}

No Matches

No jobs match the term {{searchTerm}}

diff --git a/ui/tests/acceptance/jobs-list-test.js b/ui/tests/acceptance/jobs-list-test.js index f8b8bc92abf5..7dfdd0e1e1f6 100644 --- a/ui/tests/acceptance/jobs-list-test.js +++ b/ui/tests/acceptance/jobs-list-test.js @@ -166,3 +166,262 @@ test('when accessing jobs is forbidden, show a message with a link to the tokens function typeForJob(job) { return job.periodic ? 'periodic' : job.parameterized ? 'parameterized' : job.type; } + +test('the jobs list page has appropriate faceted search options', function(assert) { + JobsList.visit(); + + andThen(() => { + assert.ok(JobsList.facets.type.isPresent, 'Type facet found'); + assert.ok(JobsList.facets.status.isPresent, 'Status facet found'); + assert.ok(JobsList.facets.datacenter.isPresent, 'Datacenter facet found'); + assert.ok(JobsList.facets.prefix.isPresent, 'Prefix facet found'); + }); +}); + +testFacet('Type', { + facet: JobsList.facets.type, + paramName: 'type', + expectedOptions: ['Batch', 'Parameterized', 'Periodic', 'Service', 'System'], + beforeEach() { + server.createList('job', 2, { createAllocations: false, type: 'batch' }); + server.createList('job', 2, { + createAllocations: false, + type: 'batch', + periodic: true, + childrenCount: 0, + }); + server.createList('job', 2, { + createAllocations: false, + type: 'batch', + parameterized: true, + childrenCount: 0, + }); + server.createList('job', 2, { createAllocations: false, type: 'service' }); + JobsList.visit(); + }, + filter(job, selection) { + let displayType = job.type; + if (job.parameterized) displayType = 'parameterized'; + if (job.periodic) displayType = 'periodic'; + return selection.includes(displayType); + }, +}); + +testFacet('Status', { + facet: JobsList.facets.status, + paramName: 'status', + expectedOptions: ['Pending', 'Running', 'Dead'], + beforeEach() { + server.createList('job', 2, { status: 'pending', createAllocations: false, childrenCount: 0 }); + server.createList('job', 2, { status: 'running', createAllocations: false, childrenCount: 0 }); + server.createList('job', 2, { status: 'dead', createAllocations: false, childrenCount: 0 }); + JobsList.visit(); + }, + filter: (job, selection) => selection.includes(job.status), +}); + +testFacet('Datacenter', { + facet: JobsList.facets.datacenter, + paramName: 'dc', + expectedOptions(jobs) { + const allDatacenters = new Set( + jobs.mapBy('datacenters').reduce((acc, val) => acc.concat(val), []) + ); + return Array.from(allDatacenters).sort(); + }, + beforeEach() { + server.create('job', { + datacenters: ['pdx', 'lax'], + createAllocations: false, + childrenCount: 0, + }); + server.create('job', { + datacenters: ['pdx', 'ord'], + createAllocations: false, + childrenCount: 0, + }); + server.create('job', { + datacenters: ['lax', 'jfk'], + createAllocations: false, + childrenCount: 0, + }); + server.create('job', { + datacenters: ['jfk', 'dfw'], + createAllocations: false, + childrenCount: 0, + }); + server.create('job', { datacenters: ['pdx'], createAllocations: false, childrenCount: 0 }); + JobsList.visit(); + }, + filter: (job, selection) => job.datacenters.find(dc => selection.includes(dc)), +}); + +testFacet('Prefix', { + facet: JobsList.facets.prefix, + paramName: 'prefix', + expectedOptions: ['hashi (3)', 'nmd (2)', 'pre (2)'], + beforeEach() { + [ + 'pre-one', + 'hashi-one', + 'nmd-one', + 'one-alone', + 'pre-two', + 'hashi-two', + 'hashi-three', + 'nmd-two', + 'noprefix', + ].forEach(name => { + server.create('job', { name, createAllocations: false, childrenCount: 0 }); + }); + JobsList.visit(); + }, + filter: (job, selection) => selection.find(prefix => job.name.startsWith(prefix)), +}); + +test('when the facet selections result in no matches, the empty state states why', function(assert) { + server.createList('job', 2, { status: 'pending', createAllocations: false, childrenCount: 0 }); + + JobsList.visit(); + + andThen(() => { + JobsList.facets.status.toggle(); + }); + + andThen(() => { + JobsList.facets.status.options.objectAt(1).toggle(); + }); + + andThen(() => { + assert.ok(JobsList.isEmpty, 'There is an empty message'); + assert.equal(JobsList.emptyState.headline, 'No Matches', 'The message is appropriate'); + }); +}); + +test('the jobs list is immediately filtered based on query params', function(assert) { + server.create('job', { type: 'batch', createAllocations: false }); + server.create('job', { type: 'service', createAllocations: false }); + + JobsList.visit({ type: JSON.stringify(['batch']) }); + + andThen(() => { + assert.equal(JobsList.jobs.length, 1, 'Only one job 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.jobs); + } 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 jobs 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 expectedJobs = server.db.jobs + .filter(job => filter(job, selection)) + .sortBy('modifyIndex') + .reverse(); + + JobsList.jobs.forEach((job, index) => { + assert.equal( + job.id, + expectedJobs[index].id, + `Job at ${index} is ${expectedJobs[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 expectedJobs = server.db.jobs + .filter(job => filter(job, selection)) + .sortBy('modifyIndex') + .reverse(); + + JobsList.jobs.forEach((job, index) => { + assert.equal( + job.id, + expectedJobs[index].id, + `Job at ${index} is ${expectedJobs[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(), + `/jobs?${paramName}=${encodeURIComponent(JSON.stringify(selection))}`, + 'URL has the correct query param key and value' + ); + }); + }); +} diff --git a/ui/tests/integration/multi-select-dropdown-test.js b/ui/tests/integration/multi-select-dropdown-test.js index 9b9ff1b0371e..f619ba808651 100644 --- a/ui/tests/integration/multi-select-dropdown-test.js +++ b/ui/tests/integration/multi-select-dropdown-test.js @@ -297,3 +297,15 @@ test('pressing ESC when the options list is open closes the list and returns foc 'The trigger has focus' ); }); + +test('when there are no list options, an empty message is shown', function(assert) { + const props = commonProperties(); + props.options = []; + this.setProperties(props); + this.render(commonTemplate); + + click('[data-test-dropdown-trigger]'); + assert.ok(find('[data-test-dropdown-options]'), 'The dropdown is still shown'); + assert.ok(find('[data-test-dropdown-empty]'), 'The empty state is shown'); + assert.notOk(find('[data-test-dropdown-option]'), 'No options are shown'); +}); diff --git a/ui/tests/pages/components/facet.js b/ui/tests/pages/components/facet.js new file mode 100644 index 000000000000..5dce211cdd29 --- /dev/null +++ b/ui/tests/pages/components/facet.js @@ -0,0 +1,17 @@ +import { isPresent, clickable, collection, text, attribute } from 'ember-cli-page-object'; + +export default scope => ({ + scope, + + isPresent: isPresent(), + + toggle: clickable('[data-test-dropdown-trigger]'), + + options: collection('[data-test-dropdown-option]', { + testContainer: '#ember-testing', + resetScope: true, + label: text(), + key: attribute('data-test-dropdown-option'), + toggle: clickable('label'), + }), +}); diff --git a/ui/tests/pages/jobs/list.js b/ui/tests/pages/jobs/list.js index 24aff62f2877..620a6cec445e 100644 --- a/ui/tests/pages/jobs/list.js +++ b/ui/tests/pages/jobs/list.js @@ -9,6 +9,8 @@ import { visitable, } from 'ember-cli-page-object'; +import facet from 'nomad-ui/tests/pages/components/facet'; + export default create({ pageSize: 10, @@ -55,4 +57,11 @@ export default create({ label: text(), }), }, + + facets: { + type: facet('[data-test-type-facet]'), + status: facet('[data-test-status-facet]'), + datacenter: facet('[data-test-datacenter-facet]'), + prefix: facet('[data-test-prefix-facet]'), + }, });