Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add filters to Allocations #11544

Merged
merged 11 commits into from
Dec 18, 2021
3 changes: 3 additions & 0 deletions .changelog/11544.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
```release-note:feature
ui: Add filters to allocations table in jobs/job/allocation view
```
80 changes: 79 additions & 1 deletion ui/app/controllers/jobs/job/allocations.js
Original file line number Diff line number Diff line change
@@ -1,9 +1,13 @@
/* eslint-disable ember/no-incorrect-calls-with-inline-anonymous-functions */
import { alias } from '@ember/object/computed';
import Controller from '@ember/controller';
import { action, 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 WithNamespaceResetting from 'nomad-ui/mixins/with-namespace-resetting';
import { serialize, deserializedQueryParam as selection } from 'nomad-ui/utils/qp-serialize';
import classic from 'ember-classic-decorator';

@classic
Expand All @@ -25,8 +29,20 @@ export default class AllocationsController extends Controller.extend(
{
sortDescending: 'desc',
},
{
qpStatus: 'status',
},
{
qpClient: 'client',
},
{
qpTaskGroup: 'taskGroup',
},
];

qpStatus = '';
qpClient = '';
qpTaskGroup = '';
currentPage = 1;
pageSize = 25;

Expand All @@ -45,12 +61,74 @@ export default class AllocationsController extends Controller.extend(
return this.get('model.allocations') || [];
}

@alias('allocations') listToSort;
@computed('allocations.[]', 'selectionStatus', 'selectionClient', 'selectionTaskGroup')
get filteredAllocations() {
const { selectionStatus, selectionClient, selectionTaskGroup } = this;

return this.allocations.filter(alloc => {
if (selectionStatus.length && !selectionStatus.includes(alloc.clientStatus)) {
return false;
}
if (selectionClient.length && !selectionClient.includes(alloc.get('node.shortId'))) {
return false;
}
if (selectionTaskGroup.length && !selectionTaskGroup.includes(alloc.taskGroupName)) {
return false;
}
return true;
});
}

@alias('filteredAllocations') listToSort;
@alias('listSorted') listToSearch;
@alias('listSearched') sortedAllocations;

@selection('qpStatus') selectionStatus;
@selection('qpClient') selectionClient;
@selection('qpTaskGroup') selectionTaskGroup;

@action
gotoAllocation(allocation) {
this.transitionToRoute('allocations.allocation', allocation);
}

get optionsAllocationStatus() {
return [
{ key: 'pending', label: 'Pending' },
{ key: 'running', label: 'Running' },
{ key: 'complete', label: 'Complete' },
{ key: 'failed', label: 'Failed' },
{ key: 'lost', label: 'Lost' },
];
}

@computed('model.allocations.[]', 'selectionClient')
get optionsClients() {
const clients = Array.from(new Set(this.model.allocations.mapBy('node.shortId'))).compact();
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We are repeating this multiple times in this file. I understand that this pattern is used in other places as well controllers/clients/index.js for example. It looks like we can create a computed-macro out of this pattern.


// Update query param when the list of clients changes.
scheduleOnce('actions', () => {
// eslint-disable-next-line ember/no-side-effects
this.set('qpClient', serialize(intersection(clients, this.selectionClient)));
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we probably don't want to trigger side-effects in computeds. Again this is done in other places as well but we should investigate why we feel this is necessary to do

});

return clients.sort().map(c => ({ key: c, label: c }));
}

@computed('model.allocations.[]', 'selectionTaskGroup')
get optionsTaskGroups() {
const taskGroups = Array.from(new Set(this.model.allocations.mapBy('taskGroupName'))).compact();

// Update query param when the list of task groups changes.
scheduleOnce('actions', () => {
// eslint-disable-next-line ember/no-side-effects
this.set('qpTaskGroup', serialize(intersection(taskGroups, this.selectionTaskGroup)));
});

return taskGroups.sort().map(tg => ({ key: tg, label: tg }));
}

setFacetQueryParam(queryParam, selection) {
this.set(queryParam, serialize(selection));
}
}
31 changes: 28 additions & 3 deletions ui/app/templates/jobs/job/allocations.hbs
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,39 @@
<JobSubnav @job={{this.job}} />
<section class="section">
{{#if this.allocations.length}}
<div class="content">
<div>
<div class="toolbar">
<div class="toolbar-item">
<SearchBox
data-test-allocations-search
@searchTerm={{mut this.searchTerm}}
@onChange={{action this.resetPagination}}
@placeholder="Search allocations..." />
</div>
<div class="toolbar-item is-right-aligned">
<div class="button-bar">
<MultiSelectDropdown
data-test-allocation-status-facet
@label="Status"
@options={{this.optionsAllocationStatus}}
@selection={{this.selectionStatus}}
@onSelect={{action this.setFacetQueryParam "qpStatus"}}
/>
<MultiSelectDropdown
data-test-allocation-client-facet
@label="Client"
@options={{this.optionsClients}}
@selection={{this.selectionClient}}
@onSelect={{action this.setFacetQueryParam "qpClient"}}
/>
<MultiSelectDropdown
data-test-allocation-task-group-facet
@label="Task Group"
@options={{this.optionsTaskGroups}}
@selection={{this.selectionTaskGroup}}
@onSelect={{action this.setFacetQueryParam "qpTaskGroup"}}
/>
</div>
</div>
</div>
{{#if this.sortedAllocations}}
<ListPagination
Expand Down Expand Up @@ -70,4 +95,4 @@
</div>
</div>
{{/if}}
</section>
</section>
147 changes: 147 additions & 0 deletions ui/tests/acceptance/job-allocations-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -124,4 +124,151 @@ module('Acceptance | job allocations', function(hooks) {
assert.ok(Allocations.error.isPresent, 'Error message is shown');
assert.equal(Allocations.error.title, 'Not Found', 'Error message is for 404');
});

testFacet('Status', {
facet: Allocations.facets.status,
paramName: 'status',
expectedOptions: ['Pending', 'Running', 'Complete', 'Failed', 'Lost'],
async beforeEach() {
['pending', 'running', 'complete', 'failed', 'lost'].forEach(s => {
server.createList('allocation', 5, { clientStatus: s });
});
await Allocations.visit({ id: job.id });
},
filter: (alloc, selection) => alloc.jobId == job.id && selection.includes(alloc.clientStatus),
});

testFacet('Client', {
facet: Allocations.facets.client,
paramName: 'client',
expectedOptions(allocs) {
return Array.from(
new Set(
allocs
.filter(alloc => alloc.jobId == job.id)
.mapBy('nodeId')
.map(id => id.split('-')[0])
)
).sort();
},
async beforeEach() {
server.createList('node', 5);
server.createList('allocation', 20);

await Allocations.visit({ id: job.id });
},
filter: (alloc, selection) =>
alloc.jobId == job.id && selection.includes(alloc.nodeId.split('-')[0]),
});

testFacet('Task Group', {
facet: Allocations.facets.taskGroup,
paramName: 'taskGroup',
expectedOptions(allocs) {
return Array.from(
new Set(allocs.filter(alloc => alloc.jobId == job.id).mapBy('taskGroup'))
).sort();
},
async beforeEach() {
job = server.create('job', {
type: 'service',
status: 'running',
groupsCount: 5,
});

await Allocations.visit({ id: job.id });
},
filter: (alloc, selection) => alloc.jobId == job.id && selection.includes(alloc.taskGroup),
});
});

function testFacet(label, { facet, paramName, beforeEach, filter, expectedOptions }) {
test(`facet ${label} | the ${label} facet has the correct options`, async function(assert) {
await beforeEach();
await facet.toggle();

let expectation;
if (typeof expectedOptions === 'function') {
expectation = expectedOptions(server.db.allocations);
} else {
expectation = expectedOptions;
}

assert.deepEqual(
facet.options.map(option => option.label.trim()),
expectation,
'Options for facet are as expected'
);
});

test(`facet ${label} | the ${label} facet filters the allocations list by ${label}`, async function(assert) {
let option;

await beforeEach();

await facet.toggle();
option = facet.options.objectAt(0);
await option.toggle();

const selection = [option.key];
const expectedAllocs = server.db.allocations
.filter(alloc => filter(alloc, selection))
.sortBy('modifyIndex')
.reverse();

Allocations.allocations.forEach((alloc, index) => {
assert.equal(
alloc.id,
expectedAllocs[index].id,
`Allocation at ${index} is ${expectedAllocs[index].id}`
);
});
});

test(`facet ${label} | selecting multiple options in the ${label} facet results in a broader search`, async function(assert) {
const selection = [];

await beforeEach();
await facet.toggle();

const option1 = facet.options.objectAt(0);
const option2 = facet.options.objectAt(1);
await option1.toggle();
selection.push(option1.key);
await option2.toggle();
selection.push(option2.key);

const expectedAllocs = server.db.allocations
.filter(alloc => filter(alloc, selection))
.sortBy('modifyIndex')
.reverse();

Allocations.allocations.forEach((alloc, index) => {
assert.equal(
alloc.id,
expectedAllocs[index].id,
`Allocation at ${index} is ${expectedAllocs[index].id}`
);
});
});

test(`facet ${label} | selecting options in the ${label} facet updates the ${paramName} query param`, async function(assert) {
const selection = [];

await beforeEach();
await facet.toggle();

const option1 = facet.options.objectAt(0);
const option2 = facet.options.objectAt(1);
await option1.toggle();
selection.push(option1.key);
await option2.toggle();
selection.push(option2.key);

assert.equal(
currentURL(),
`/jobs/${job.id}/allocations?${paramName}=${encodeURIComponent(JSON.stringify(selection))}`,
'URL has the correct query param key and value'
);
});
}
7 changes: 7 additions & 0 deletions ui/tests/pages/jobs/job/allocations.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {

import allocations from 'nomad-ui/tests/pages/components/allocations';
import error from 'nomad-ui/tests/pages/components/error';
import { multiFacet } from 'nomad-ui/tests/pages/components/facet';

export default create({
visit: visitable('/jobs/:id/allocations'),
Expand All @@ -22,6 +23,12 @@ export default create({

...allocations(),

facets: {
status: multiFacet('[data-test-allocation-status-facet]'),
client: multiFacet('[data-test-allocation-client-facet]'),
taskGroup: multiFacet('[data-test-allocation-task-group-facet]'),
},

isEmpty: isPresent('[data-test-empty-allocations-list]'),
emptyState: {
headline: text('[data-test-empty-allocations-list-headline]'),
Expand Down