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