diff --git a/.changelog/11544.txt b/.changelog/11544.txt new file mode 100644 index 00000000000..3daa7ccd784 --- /dev/null +++ b/.changelog/11544.txt @@ -0,0 +1,3 @@ +```release-note:feature +ui: Add filters to allocations table in jobs/job/allocation view +``` diff --git a/ui/app/controllers/jobs/job/allocations.js b/ui/app/controllers/jobs/job/allocations.js index a01c199510d..d6ec836c47b 100644 --- a/ui/app/controllers/jobs/job/allocations.js +++ b/ui/app/controllers/jobs/job/allocations.js @@ -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 @@ -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; @@ -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(); + + // 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))); + }); + + 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)); + } } diff --git a/ui/app/templates/jobs/job/allocations.hbs b/ui/app/templates/jobs/job/allocations.hbs index e14452e483d..2dff6a224fd 100644 --- a/ui/app/templates/jobs/job/allocations.hbs +++ b/ui/app/templates/jobs/job/allocations.hbs @@ -2,14 +2,39 @@
{{#if this.allocations.length}} -
-
+
+
+
+
+ + + +
+
{{#if this.sortedAllocations}}
{{/if}} -
+ \ No newline at end of file diff --git a/ui/tests/acceptance/job-allocations-test.js b/ui/tests/acceptance/job-allocations-test.js index 7c86653c339..73fcc7207ed 100644 --- a/ui/tests/acceptance/job-allocations-test.js +++ b/ui/tests/acceptance/job-allocations-test.js @@ -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' + ); + }); +} diff --git a/ui/tests/pages/jobs/job/allocations.js b/ui/tests/pages/jobs/job/allocations.js index adb48aa704e..8b15cb932cb 100644 --- a/ui/tests/pages/jobs/job/allocations.js +++ b/ui/tests/pages/jobs/job/allocations.js @@ -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'), @@ -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]'),