Skip to content

Commit 83d9190

Browse files
Merge pull request #5236 from hashicorp/f-ui-jobs-filtering
UI: Faceted search on the jobs list page
2 parents 2e52b92 + b769365 commit 83d9190

File tree

8 files changed

+499
-6
lines changed

8 files changed

+499
-6
lines changed

ui/app/controllers/jobs/index.js

+157-2
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,28 @@ import { inject as service } from '@ember/service';
22
import { alias } from '@ember/object/computed';
33
import Controller, { inject as controller } from '@ember/controller';
44
import { computed } from '@ember/object';
5+
import { scheduleOnce } from '@ember/runloop';
6+
import intersection from 'lodash.intersection';
57
import Sortable from 'nomad-ui/mixins/sortable';
68
import Searchable from 'nomad-ui/mixins/searchable';
79

10+
// An unattractive but robust way to encode query params
11+
const qpSerialize = arr => (arr.length ? JSON.stringify(arr) : '');
12+
const qpDeserialize = str => {
13+
try {
14+
return JSON.parse(str)
15+
.compact()
16+
.without('');
17+
} catch (e) {
18+
return [];
19+
}
20+
};
21+
22+
const qpSelection = qpKey =>
23+
computed(qpKey, function() {
24+
return qpDeserialize(this.get(qpKey));
25+
});
26+
827
export default Controller.extend(Sortable, Searchable, {
928
system: service(),
1029
jobsController: controller('jobs'),
@@ -16,6 +35,10 @@ export default Controller.extend(Sortable, Searchable, {
1635
searchTerm: 'search',
1736
sortProperty: 'sort',
1837
sortDescending: 'desc',
38+
qpType: 'type',
39+
qpStatus: 'status',
40+
qpDatacenter: 'dc',
41+
qpPrefix: 'prefix',
1942
},
2043

2144
currentPage: 1,
@@ -28,11 +51,95 @@ export default Controller.extend(Sortable, Searchable, {
2851
fuzzySearchProps: computed(() => ['name']),
2952
fuzzySearchEnabled: true,
3053

54+
qpType: '',
55+
qpStatus: '',
56+
qpDatacenter: '',
57+
qpPrefix: '',
58+
59+
selectionType: qpSelection('qpType'),
60+
selectionStatus: qpSelection('qpStatus'),
61+
selectionDatacenter: qpSelection('qpDatacenter'),
62+
selectionPrefix: qpSelection('qpPrefix'),
63+
64+
optionsType: computed(() => [
65+
{ key: 'batch', label: 'Batch' },
66+
{ key: 'parameterized', label: 'Parameterized' },
67+
{ key: 'periodic', label: 'Periodic' },
68+
{ key: 'service', label: 'Service' },
69+
{ key: 'system', label: 'System' },
70+
]),
71+
72+
optionsStatus: computed(() => [
73+
{ key: 'pending', label: 'Pending' },
74+
{ key: 'running', label: 'Running' },
75+
{ key: 'dead', label: 'Dead' },
76+
]),
77+
78+
optionsDatacenter: computed('visibleJobs.[]', function() {
79+
const flatten = (acc, val) => acc.concat(val);
80+
const allDatacenters = new Set(
81+
this.get('visibleJobs')
82+
.mapBy('datacenters')
83+
.reduce(flatten, [])
84+
);
85+
86+
// Remove any invalid datacenters from the query param/selection
87+
const availableDatacenters = Array.from(allDatacenters).compact();
88+
scheduleOnce('actions', () => {
89+
this.set(
90+
'qpDatacenter',
91+
qpSerialize(intersection(availableDatacenters, this.get('selectionDatacenter')))
92+
);
93+
});
94+
95+
return availableDatacenters.sort().map(dc => ({ key: dc, label: dc }));
96+
}),
97+
98+
optionsPrefix: computed('visibleJobs.[]', function() {
99+
// A prefix is defined as the start of a job name up to the first - or .
100+
// ex: mktg-analytics -> mktg, ds.supermodel.classifier -> ds
101+
const hasPrefix = /.[-._]/;
102+
103+
// Collect and count all the prefixes
104+
const allNames = this.get('visibleJobs').mapBy('name');
105+
const nameHistogram = allNames.reduce((hist, name) => {
106+
if (hasPrefix.test(name)) {
107+
const prefix = name.match(/(.+?)[-.]/)[1];
108+
hist[prefix] = hist[prefix] ? hist[prefix] + 1 : 1;
109+
}
110+
return hist;
111+
}, {});
112+
113+
// Convert to an array
114+
const nameTable = Object.keys(nameHistogram).map(key => ({
115+
prefix: key,
116+
count: nameHistogram[key],
117+
}));
118+
119+
// Only consider prefixes that match more than one name
120+
const prefixes = nameTable.filter(name => name.count > 1);
121+
122+
// Remove any invalid prefixes from the query param/selection
123+
const availablePrefixes = prefixes.mapBy('prefix');
124+
scheduleOnce('actions', () => {
125+
this.set(
126+
'qpPrefix',
127+
qpSerialize(intersection(availablePrefixes, this.get('selectionPrefix')))
128+
);
129+
});
130+
131+
// Sort, format, and include the count in the label
132+
return prefixes.sortBy('prefix').map(name => ({
133+
key: name.prefix,
134+
label: `${name.prefix} (${name.count})`,
135+
}));
136+
}),
137+
31138
/**
32-
Filtered jobs are those that match the selected namespace and aren't children
139+
Visible jobs are those that match the selected namespace and aren't children
33140
of periodic or parameterized jobs.
34141
*/
35-
filteredJobs: computed('model.[]', '[email protected]', function() {
142+
visibleJobs: computed('model.[]', '[email protected]', function() {
36143
// Namespace related properties are ommitted from the dependent keys
37144
// due to a prop invalidation bug caused by region switching.
38145
const hasNamespaces = this.get('system.namespaces.length');
@@ -44,12 +151,60 @@ export default Controller.extend(Sortable, Searchable, {
44151
.filter(job => !job.get('parent.content'));
45152
}),
46153

154+
filteredJobs: computed(
155+
'visibleJobs.[]',
156+
'selectionType',
157+
'selectionStatus',
158+
'selectionDatacenter',
159+
'selectionPrefix',
160+
function() {
161+
const {
162+
selectionType: types,
163+
selectionStatus: statuses,
164+
selectionDatacenter: datacenters,
165+
selectionPrefix: prefixes,
166+
} = this.getProperties(
167+
'selectionType',
168+
'selectionStatus',
169+
'selectionDatacenter',
170+
'selectionPrefix'
171+
);
172+
173+
// A job must match ALL filter facets, but it can match ANY selection within a facet
174+
// Always return early to prevent unnecessary facet predicates.
175+
return this.get('visibleJobs').filter(job => {
176+
if (types.length && !types.includes(job.get('displayType'))) {
177+
return false;
178+
}
179+
180+
if (statuses.length && !statuses.includes(job.get('status'))) {
181+
return false;
182+
}
183+
184+
if (datacenters.length && !job.get('datacenters').find(dc => datacenters.includes(dc))) {
185+
return false;
186+
}
187+
188+
const name = job.get('name');
189+
if (prefixes.length && !prefixes.find(prefix => name.startsWith(prefix))) {
190+
return false;
191+
}
192+
193+
return true;
194+
});
195+
}
196+
),
197+
47198
listToSort: alias('filteredJobs'),
48199
listToSearch: alias('listSorted'),
49200
sortedJobs: alias('listSearched'),
50201

51202
isShowingDeploymentDetails: false,
52203

204+
setFacetQueryParam(queryParam, selection) {
205+
this.set(queryParam, qpSerialize(selection));
206+
},
207+
53208
actions: {
54209
gotoJob(job) {
55210
this.transitionToRoute('jobs.job', job.get('plainId'));

ui/app/styles/components/dropdown.scss

+6
Original file line numberDiff line numberDiff line change
@@ -142,4 +142,10 @@
142142
}
143143
}
144144
}
145+
146+
.dropdown-empty {
147+
display: block;
148+
padding: 8px 12px;
149+
color: $grey-light;
150+
}
145151
}

ui/app/templates/components/multi-select-dropdown.hbs

+3-1
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@
1818
{{#dd.content class="dropdown-options"}}
1919
<ul role="listbox" data-test-dropdown-options>
2020
{{#each options key="key" as |option|}}
21-
<li data-test-dropdown-option class="dropdown-option" tabindex="1" onkeydown={{action "traverseList" option}}>
21+
<li data-test-dropdown-option={{option.key}} class="dropdown-option" tabindex="1" onkeydown={{action "traverseList" option}}>
2222
<label>
2323
<input
2424
type="checkbox"
@@ -28,6 +28,8 @@
2828
{{option.label}}
2929
</label>
3030
</li>
31+
{{else}}
32+
<em data-test-dropdown-empty class="dropdown-empty">No options</em>
3133
{{/each}}
3234
</ul>
3335
{{/dd.content}}

ui/app/templates/jobs/index.hbs

+36-3
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
{{else}}
55
<div class="columns">
66
{{#if filteredJobs.length}}
7-
<div class="column">
7+
<div class="column is-one-third">
88
{{search-box
99
data-test-jobs-search
1010
searchTerm=(mut searchTerm)
@@ -13,7 +13,35 @@
1313
</div>
1414
{{/if}}
1515
<div class="column is-centered">
16-
{{#link-to "jobs.run" data-test-run-job class="button is-primary is-pulled-right"}}Run Job{{/link-to}}
16+
<div class="button-bar is-pulled-right">
17+
{{multi-select-dropdown
18+
data-test-type-facet
19+
label="Type"
20+
options=optionsType
21+
selection=selectionType
22+
onSelect=(action setFacetQueryParam "qpType")}}
23+
{{multi-select-dropdown
24+
data-test-status-facet
25+
label="Status"
26+
options=optionsStatus
27+
selection=selectionStatus
28+
onSelect=(action setFacetQueryParam "qpStatus")}}
29+
{{multi-select-dropdown
30+
data-test-datacenter-facet
31+
label="Datacenter"
32+
options=optionsDatacenter
33+
selection=selectionDatacenter
34+
onSelect=(action setFacetQueryParam "qpDatacenter")}}
35+
{{multi-select-dropdown
36+
data-test-prefix-facet
37+
label="Prefix"
38+
options=optionsPrefix
39+
selection=selectionPrefix
40+
onSelect=(action setFacetQueryParam "qpPrefix")}}
41+
</div>
42+
</div>
43+
<div class="column is-minimum is-centered">
44+
{{#link-to "jobs.run" data-test-run-job class="button is-primary"}}Run Job{{/link-to}}
1745
</div>
1846
</div>
1947
{{#list-pagination
@@ -52,11 +80,16 @@
5280
</div>
5381
{{else}}
5482
<div data-test-empty-jobs-list class="empty-message">
55-
{{#if (eq filteredJobs.length 0)}}
83+
{{#if (eq visibleJobs.length 0)}}
5684
<h3 data-test-empty-jobs-list-headline class="empty-message-headline">No Jobs</h3>
5785
<p class="empty-message-body">
5886
The cluster is currently empty.
5987
</p>
88+
{{else if (eq filteredJobs.length 0)}}
89+
<h3 data-test-empty-jobs-list-headline class="empty-message-headline">No Matches</h3>
90+
<p class="empty-message-body">
91+
No jobs match your current filter selection.
92+
</p>
6093
{{else if searchTerm}}
6194
<h3 data-test-empty-jobs-list-headline class="empty-message-headline">No Matches</h3>
6295
<p class="empty-message-body">No jobs match the term <strong>{{searchTerm}}</strong></p>

0 commit comments

Comments
 (0)