From 727ca28212da834113a59a80900456da1af38f28 Mon Sep 17 00:00:00 2001 From: Jai Bhagat Date: Sat, 20 Nov 2021 11:22:48 -0500 Subject: [PATCH 01/13] feat: add filter client allocations table --- ui/app/controllers/clients/client/index.js | 35 ++++++++++++++++++++-- ui/app/templates/clients/client/index.hbs | 10 ++++++- 2 files changed, 42 insertions(+), 3 deletions(-) diff --git a/ui/app/controllers/clients/client/index.js b/ui/app/controllers/clients/client/index.js index 9b0121e088d..5ebb7e03a78 100644 --- a/ui/app/controllers/clients/client/index.js +++ b/ui/app/controllers/clients/client/index.js @@ -7,6 +7,7 @@ import { task } from 'ember-concurrency'; import Sortable from 'nomad-ui/mixins/sortable'; import Searchable from 'nomad-ui/mixins/searchable'; import messageFromAdapterError from 'nomad-ui/utils/message-from-adapter-error'; +import { serialize, deserializedQueryParam as selection } from 'nomad-ui/utils/qp-serialize'; import classic from 'ember-classic-decorator'; @classic @@ -27,11 +28,15 @@ export default class ClientController extends Controller.extend(Sortable, Search { onlyPreemptions: 'preemptions', }, + { + qpStatus: 'status', + }, ]; // Set in the route flagAsDraining = false; + qpStatus = ''; currentPage = 1; pageSize = 8; @@ -45,15 +50,25 @@ export default class ClientController extends Controller.extend(Sortable, Search onlyPreemptions = false; - @computed('model.allocations.[]', 'preemptions.[]', 'onlyPreemptions') + @computed('model.allocations.[]', 'preemptions.[]', 'onlyPreemptions', 'selectionStatus') get visibleAllocations() { - return this.onlyPreemptions ? this.preemptions : this.model.allocations; + const allocations = this.onlyPreemptions ? this.preemptions : this.model.allocations; + const { selectionStatus } = this; + + return allocations.filter(alloc => { + if (selectionStatus.length && !selectionStatus.includes(alloc.clientStatus)) { + return false; + } + return true; + }); } @alias('visibleAllocations') listToSort; @alias('listSorted') listToSearch; @alias('listSearched') sortedAllocations; + @selection('qpStatus') selectionStatus; + eligibilityError = null; stopDrainError = null; drainError = null; @@ -147,4 +162,20 @@ export default class ClientController extends Controller.extend(Sortable, Search const error = messageFromAdapterError(err) || 'Could not run drain'; this.set('drainError', error); } + + get optionsAllocationStatus() { + return [ + { key: 'queued', label: 'Queued' }, + { key: 'starting', label: 'Starting' }, + { key: 'running', label: 'Running' }, + { key: 'complete', label: 'Complete' }, + { key: 'failed', label: 'Failed' }, + { key: 'lost', label: 'Lost' }, + ]; + } + + @action + setFacetQueryParam(queryParam, selection) { + this.set(queryParam, serialize(selection)); + } } diff --git a/ui/app/templates/clients/client/index.hbs b/ui/app/templates/clients/client/index.hbs index 320bf7a335a..04907498d6e 100644 --- a/ui/app/templates/clients/client/index.hbs +++ b/ui/app/templates/clients/client/index.hbs @@ -299,7 +299,15 @@ @onChange={{action this.resetPagination}} @placeholder="Search allocations..." @class="is-inline pull-right" - @inputClass="is-compact" /> + @inputClass="is-compact" + /> +
Date: Sat, 20 Nov 2021 12:18:02 -0500 Subject: [PATCH 02/13] feat: add taskgroup filter to alloc table --- ui/app/controllers/clients/client/index.js | 34 ++++++++++++++++++++-- ui/app/templates/clients/client/index.hbs | 7 +++++ 2 files changed, 39 insertions(+), 2 deletions(-) diff --git a/ui/app/controllers/clients/client/index.js b/ui/app/controllers/clients/client/index.js index 5ebb7e03a78..a1d6f37b0eb 100644 --- a/ui/app/controllers/clients/client/index.js +++ b/ui/app/controllers/clients/client/index.js @@ -1,9 +1,12 @@ /* eslint-disable ember/no-observers */ +/* 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 { observes } from '@ember-decorators/object'; +import { scheduleOnce } from '@ember/runloop'; import { task } from 'ember-concurrency'; +import intersection from 'lodash.intersection'; import Sortable from 'nomad-ui/mixins/sortable'; import Searchable from 'nomad-ui/mixins/searchable'; import messageFromAdapterError from 'nomad-ui/utils/message-from-adapter-error'; @@ -31,12 +34,16 @@ export default class ClientController extends Controller.extend(Sortable, Search { qpStatus: 'status', }, + { + qpTaskGroup: 'taskGroup', + }, ]; // Set in the route flagAsDraining = false; qpStatus = ''; + qpTaskGroup = ''; currentPage = 1; pageSize = 8; @@ -50,15 +57,24 @@ export default class ClientController extends Controller.extend(Sortable, Search onlyPreemptions = false; - @computed('model.allocations.[]', 'preemptions.[]', 'onlyPreemptions', 'selectionStatus') + @computed( + 'model.allocations.[]', + 'preemptions.[]', + 'onlyPreemptions', + 'selectionStatus', + 'selectionTaskGroup' + ) get visibleAllocations() { const allocations = this.onlyPreemptions ? this.preemptions : this.model.allocations; - const { selectionStatus } = this; + const { selectionStatus, selectionTaskGroup } = this; return allocations.filter(alloc => { if (selectionStatus.length && !selectionStatus.includes(alloc.clientStatus)) { return false; } + if (selectionTaskGroup.length && !selectionTaskGroup.includes(alloc.taskGroupName)) { + return false; + } return true; }); } @@ -68,6 +84,7 @@ export default class ClientController extends Controller.extend(Sortable, Search @alias('listSearched') sortedAllocations; @selection('qpStatus') selectionStatus; + @selection('qpTaskGroup') selectionTaskGroup; eligibilityError = null; stopDrainError = null; @@ -174,6 +191,19 @@ export default class ClientController extends Controller.extend(Sortable, Search ]; } + @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 clients changes. + scheduleOnce('actions', () => { + // eslint-disable-next-line ember/no-side-effects + this.set('qpTaskGroup', serialize(intersection(taskGroups, this.selectionTaskGroup))); + }); + + return taskGroups.sort().map(dc => ({ key: dc, label: dc })); + } + @action setFacetQueryParam(queryParam, selection) { this.set(queryParam, serialize(selection)); diff --git a/ui/app/templates/clients/client/index.hbs b/ui/app/templates/clients/client/index.hbs index 04907498d6e..e9f64dee00f 100644 --- a/ui/app/templates/clients/client/index.hbs +++ b/ui/app/templates/clients/client/index.hbs @@ -308,6 +308,13 @@ @selection={{this.selectionStatus}} @onSelect={{action "setFacetQueryParam" "qpStatus"}} /> +
Date: Sat, 20 Nov 2021 14:48:28 -0500 Subject: [PATCH 03/13] fix: re-order multiselect and search boxes --- ui/app/styles/components/boxed-section.scss | 9 +++++ ui/app/templates/clients/client/index.hbs | 44 +++++++++++---------- 2 files changed, 32 insertions(+), 21 deletions(-) diff --git a/ui/app/styles/components/boxed-section.scss b/ui/app/styles/components/boxed-section.scss index bcebd868f41..901dc75036c 100644 --- a/ui/app/styles/components/boxed-section.scss +++ b/ui/app/styles/components/boxed-section.scss @@ -19,6 +19,15 @@ margin-left: auto; } + .is-subsection { + display: flex; + align-items: baseline; + + .is-padded { + padding: 0em 0em 0em 1em; + } + } + .is-fixed-width { display: inline-block; width: 8em; diff --git a/ui/app/templates/clients/client/index.hbs b/ui/app/templates/clients/client/index.hbs index e9f64dee00f..3ccb8de5d6f 100644 --- a/ui/app/templates/clients/client/index.hbs +++ b/ui/app/templates/clients/client/index.hbs @@ -294,27 +294,29 @@ {{/if}}
- - - +
+ + + +
Date: Sat, 20 Nov 2021 15:07:27 -0500 Subject: [PATCH 04/13] feat: add filters to alloc table in task group view --- ui/app/controllers/jobs/job/task-group.js | 62 ++++++++++++++++++++++- ui/app/templates/jobs/job/task-group.hbs | 30 ++++++++--- 2 files changed, 83 insertions(+), 9 deletions(-) diff --git a/ui/app/controllers/jobs/job/task-group.js b/ui/app/controllers/jobs/job/task-group.js index a85c97d30b2..b0fdb6d58cc 100644 --- a/ui/app/controllers/jobs/job/task-group.js +++ b/ui/app/controllers/jobs/job/task-group.js @@ -1,10 +1,14 @@ +/* eslint-disable ember/no-incorrect-calls-with-inline-anonymous-functions */ import { inject as service } from '@ember/service'; import { alias, readOnly } from '@ember/object/computed'; import Controller from '@ember/controller'; import { action, computed, get } 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 @@ -29,11 +33,19 @@ export default class TaskGroupController extends Controller.extend( { sortDescending: 'desc', }, + { + qpStatus: 'status', + }, + { + qpClient: 'client', + }, ]; currentPage = 1; @readOnly('userSettings.pageSize') pageSize; + qpStatus = ''; + qpClient = ''; sortProperty = 'modifyIndex'; sortDescending = true; @@ -42,15 +54,32 @@ export default class TaskGroupController extends Controller.extend( return ['shortId', 'name']; } - @computed('model.allocations.[]') + @computed('model.allocations.[]', 'selectionStatus', 'selectionClient') get allocations() { - return this.get('model.allocations') || []; + const allocations = this.get('model.allocations') || []; + const { selectionStatus, selectionClient } = this; + + if (!allocations.length) return allocations; + + return allocations.filter(alloc => { + if (selectionStatus.length && !selectionStatus.includes(alloc.clientStatus)) { + return false; + } + if (selectionClient.length && !selectionClient.includes(alloc.get('node.shortId'))) { + return false; + } + + return true; + }); } @alias('allocations') listToSort; @alias('listSorted') listToSearch; @alias('listSearched') sortedAllocations; + @selection('qpStatus') selectionStatus; + @selection('qpClient') selectionClient; + @computed('model.scaleState.events.@each.time', function() { const events = get(this, 'model.scaleState.events'); if (events) { @@ -83,4 +112,33 @@ export default class TaskGroupController extends Controller.extend( scaleTaskGroup(count) { return this.model.scale(count); } + + get optionsAllocationStatus() { + return [ + { key: 'queued', label: 'Queued' }, + { key: 'starting', label: 'Starting' }, + { 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(dc => ({ key: dc, label: dc })); + } + + @action + setFacetQueryParam(queryParam, selection) { + this.set(queryParam, serialize(selection)); + } } diff --git a/ui/app/templates/jobs/job/task-group.hbs b/ui/app/templates/jobs/job/task-group.hbs index 2472c7b1b38..87ef5d87a1f 100644 --- a/ui/app/templates/jobs/job/task-group.hbs +++ b/ui/app/templates/jobs/job/task-group.hbs @@ -66,16 +66,32 @@
-
Allocations - +
+ + + +
{{#if this.sortedAllocations}} From c0add56610b23ee2cdfb53f2a31100eb67b962e4 Mon Sep 17 00:00:00 2001 From: Jai <41024828+ChaiWithJai@users.noreply.github.com> Date: Fri, 17 Dec 2021 09:46:29 -0500 Subject: [PATCH 05/13] fix: more descriptive parameters in sort function Co-authored-by: Luiz Aoqui --- ui/app/controllers/clients/client/index.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ui/app/controllers/clients/client/index.js b/ui/app/controllers/clients/client/index.js index a1d6f37b0eb..d6da57e688a 100644 --- a/ui/app/controllers/clients/client/index.js +++ b/ui/app/controllers/clients/client/index.js @@ -201,7 +201,7 @@ export default class ClientController extends Controller.extend(Sortable, Search this.set('qpTaskGroup', serialize(intersection(taskGroups, this.selectionTaskGroup))); }); - return taskGroups.sort().map(dc => ({ key: dc, label: dc })); + return taskGroups.sort().map(tg => ({ key: tg, label: tg })); } @action From 6112620590d4af6d4f0ca55830a592c26949217f Mon Sep 17 00:00:00 2001 From: Luiz Aoqui Date: Fri, 17 Dec 2021 16:55:40 -0500 Subject: [PATCH 06/13] ui: fix client details page alloc status filter and replace task group with namespace and job --- ui/app/controllers/clients/client/index.js | 63 +++++--- ui/app/models/allocation.js | 6 + ui/app/templates/clients/client/index.hbs | 21 ++- ui/tests/acceptance/client-detail-test.js | 165 ++++++++++++++++++++- ui/tests/pages/clients/detail.js | 7 + 5 files changed, 237 insertions(+), 25 deletions(-) diff --git a/ui/app/controllers/clients/client/index.js b/ui/app/controllers/clients/client/index.js index d6da57e688a..b3cc6fea000 100644 --- a/ui/app/controllers/clients/client/index.js +++ b/ui/app/controllers/clients/client/index.js @@ -32,18 +32,22 @@ export default class ClientController extends Controller.extend(Sortable, Search onlyPreemptions: 'preemptions', }, { - qpStatus: 'status', + qpNamespace: 'namespace', + }, + { + qpJob: 'job', }, { - qpTaskGroup: 'taskGroup', + qpStatus: 'status', }, ]; // Set in the route flagAsDraining = false; + qpNamespace = ''; + qpJob = ''; qpStatus = ''; - qpTaskGroup = ''; currentPage = 1; pageSize = 8; @@ -61,18 +65,22 @@ export default class ClientController extends Controller.extend(Sortable, Search 'model.allocations.[]', 'preemptions.[]', 'onlyPreemptions', - 'selectionStatus', - 'selectionTaskGroup' + 'selectionNamespace', + 'selectionJob', + 'selectionStatus' ) get visibleAllocations() { const allocations = this.onlyPreemptions ? this.preemptions : this.model.allocations; - const { selectionStatus, selectionTaskGroup } = this; + const { selectionNamespace, selectionJob, selectionStatus } = this; return allocations.filter(alloc => { - if (selectionStatus.length && !selectionStatus.includes(alloc.clientStatus)) { + if (selectionNamespace.length && !selectionNamespace.includes(alloc.get('namespace'))) { + return false; + } + if (selectionJob.length && !selectionJob.includes(alloc.get('plainJobId'))) { return false; } - if (selectionTaskGroup.length && !selectionTaskGroup.includes(alloc.taskGroupName)) { + if (selectionStatus.length && !selectionStatus.includes(alloc.clientStatus)) { return false; } return true; @@ -83,8 +91,9 @@ export default class ClientController extends Controller.extend(Sortable, Search @alias('listSorted') listToSearch; @alias('listSearched') sortedAllocations; + @selection('qpNamespace') selectionNamespace; + @selection('qpJob') selectionJob; @selection('qpStatus') selectionStatus; - @selection('qpTaskGroup') selectionTaskGroup; eligibilityError = null; stopDrainError = null; @@ -182,8 +191,7 @@ export default class ClientController extends Controller.extend(Sortable, Search get optionsAllocationStatus() { return [ - { key: 'queued', label: 'Queued' }, - { key: 'starting', label: 'Starting' }, + { key: 'pending', label: 'Pending' }, { key: 'running', label: 'Running' }, { key: 'complete', label: 'Complete' }, { key: 'failed', label: 'Failed' }, @@ -191,17 +199,38 @@ export default class ClientController extends Controller.extend(Sortable, Search ]; } - @computed('model.allocations.[]', 'selectionTaskGroup') - get optionsTaskGroups() { - const taskGroups = Array.from(new Set(this.model.allocations.mapBy('taskGroupName'))).compact(); + @computed('model.allocations.[]', 'selectionJob', 'selectionNamespace') + get optionsJob() { + // Only show options for jobs in the selected namespaces, if any. + const ns = this.selectionNamespace; + const jobs = Array.from( + new Set( + this.model.allocations + .filter(a => ns.length === 0 || ns.includes(a.namespace)) + .mapBy('plainJobId') + ) + ).compact(); + + // Update query param when the list of jobs changes. + scheduleOnce('actions', () => { + // eslint-disable-next-line ember/no-side-effects + this.set('qpJob', serialize(intersection(jobs, this.selectionJob))); + }); + + return jobs.sort().map(job => ({ key: job, label: job })); + } + + @computed('model.allocations.[]', 'selectionNamespace') + get optionsNamespace() { + const ns = Array.from(new Set(this.model.allocations.mapBy('namespace'))).compact(); - // Update query param when the list of clients changes. + // Update query param when the list of namespaces changes. scheduleOnce('actions', () => { // eslint-disable-next-line ember/no-side-effects - this.set('qpTaskGroup', serialize(intersection(taskGroups, this.selectionTaskGroup))); + this.set('qpNamespace', serialize(intersection(ns, this.selectionNamespace))); }); - return taskGroups.sort().map(tg => ({ key: tg, label: tg })); + return ns.sort().map(n => ({ key: n, label: n })); } @action diff --git a/ui/app/models/allocation.js b/ui/app/models/allocation.js index f2293d3fc86..028ee23bf40 100644 --- a/ui/app/models/allocation.js +++ b/ui/app/models/allocation.js @@ -23,6 +23,7 @@ export default class Allocation extends Model { @shortUUIDProperty('id') shortId; @belongsTo('job') job; @belongsTo('node') node; + @attr('string') namespace; @attr('string') name; @attr('string') taskGroupName; @fragment('resources') resources; @@ -38,6 +39,11 @@ export default class Allocation extends Model { @attr('string') clientStatus; @attr('string') desiredStatus; + @computed('') + get plainJobId() { + return JSON.parse(this.belongsTo('job').id())[0]; + } + @computed('clientStatus') get statusIndex() { return STATUS_ORDER[this.clientStatus] || 100; diff --git a/ui/app/templates/clients/client/index.hbs b/ui/app/templates/clients/client/index.hbs index 3ccb8de5d6f..429cbce55fc 100644 --- a/ui/app/templates/clients/client/index.hbs +++ b/ui/app/templates/clients/client/index.hbs @@ -295,6 +295,20 @@ {{/if}}
+ + - selection.includes(alloc.jobId), + }); + + testFacet('Status', { + facet: ClientDetail.facets.status, + paramName: 'status', + expectedOptions: ['Pending', 'Running', 'Complete', 'Failed', 'Lost'], + async beforeEach() { + server.createList('job', 5, { createAllocations: false }); + ['pending', 'running', 'complete', 'failed', 'lost'].forEach(s => { + server.createList('allocation', 5, { clientStatus: s }); + }); + + await ClientDetail.visit({ id: node.id }); + }, + filter: (alloc, selection) => selection.includes(alloc.clientStatus), + }); }); module('Acceptance | client detail (multi-namespace)', function(hooks) { @@ -1018,7 +1046,11 @@ module('Acceptance | client detail (multi-namespace)', function(hooks) { // Make a job for each namespace, but have both scheduled on the same node server.create('job', { id: 'job-1', namespaceId: 'default', createAllocations: false }); - server.createList('allocation', 3, { nodeId: node.id, clientStatus: 'running' }); + server.createList('allocation', 3, { + nodeId: node.id, + jobId: 'job-1', + clientStatus: 'running', + }); server.create('job', { id: 'job-2', namespaceId: 'other-namespace', createAllocations: false }); server.createList('allocation', 3, { @@ -1047,4 +1079,135 @@ module('Acceptance | client detail (multi-namespace)', function(hooks) { 'Job Two fetched correctly' ); }); + + testFacet('Namespace', { + facet: ClientDetail.facets.namespace, + paramName: 'namespace', + expectedOptions(allocs) { + return Array.from(new Set(allocs.mapBy('namespace'))).sort(); + }, + async beforeEach() { + await ClientDetail.visit({ id: node.id }); + }, + filter: (alloc, selection) => selection.includes(alloc.namespace), + }); + + test('facet Namespace | selecting namespace filters job options', async function(assert) { + await ClientDetail.visit({ id: node.id }); + + const nsFacet = ClientDetail.facets.namespace; + const jobFacet = ClientDetail.facets.job; + + // Select both namespaces. + await nsFacet.toggle(); + await nsFacet.options.objectAt(0).toggle(); + await nsFacet.options.objectAt(1).toggle(); + await jobFacet.toggle(); + + assert.deepEqual( + jobFacet.options.map(option => option.label.trim()), + ['job-1', 'job-2'] + ); + + // Select juse one namespace. + await nsFacet.toggle(); + await nsFacet.options.objectAt(1).toggle(); // deselect second option + await jobFacet.toggle(); + + assert.deepEqual( + jobFacet.options.map(option => option.label.trim()), + ['job-1'] + ); + }); }); + +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(); + + ClientDetail.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(); + + ClientDetail.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(), + `/clients/${node.id}?${paramName}=${encodeURIComponent(JSON.stringify(selection))}`, + 'URL has the correct query param key and value' + ); + }); +} diff --git a/ui/tests/pages/clients/detail.js b/ui/tests/pages/clients/detail.js index 70afd69a7d9..daa6e647aa0 100644 --- a/ui/tests/pages/clients/detail.js +++ b/ui/tests/pages/clients/detail.js @@ -14,6 +14,7 @@ import allocations from 'nomad-ui/tests/pages/components/allocations'; import twoStepButton from 'nomad-ui/tests/pages/components/two-step-button'; import notification from 'nomad-ui/tests/pages/components/notification'; import toggle from 'nomad-ui/tests/pages/components/toggle'; +import { multiFacet } from 'nomad-ui/tests/pages/components/facet'; export default create({ visit: visitable('/clients/:id'), @@ -45,6 +46,12 @@ export default create({ allCount: text('[data-test-filter-all]'), }, + facets: { + namespace: multiFacet('[data-test-allocation-namespace-facet]'), + job: multiFacet('[data-test-allocation-job-facet]'), + status: multiFacet('[data-test-allocation-status-facet]'), + }, + attributesTable: isPresent('[data-test-attributes]'), metaTable: isPresent('[data-test-meta]'), emptyMetaMessage: isPresent('[data-test-empty-meta-message]'), From 648b71c96aa727a9dc5b1bf09710ea75ce48716b Mon Sep 17 00:00:00 2001 From: Luiz Aoqui Date: Fri, 17 Dec 2021 17:38:04 -0500 Subject: [PATCH 07/13] ui: display empty message in the client details page if there are no allocations to show --- ui/app/controllers/clients/client/index.js | 19 ++--- ui/app/templates/clients/client/index.hbs | 99 +++++++++++++--------- ui/tests/acceptance/client-detail-test.js | 22 +++++ ui/tests/pages/clients/detail.js | 6 ++ 4 files changed, 95 insertions(+), 51 deletions(-) diff --git a/ui/app/controllers/clients/client/index.js b/ui/app/controllers/clients/client/index.js index b3cc6fea000..a918a7b8298 100644 --- a/ui/app/controllers/clients/client/index.js +++ b/ui/app/controllers/clients/client/index.js @@ -61,19 +61,16 @@ export default class ClientController extends Controller.extend(Sortable, Search onlyPreemptions = false; - @computed( - 'model.allocations.[]', - 'preemptions.[]', - 'onlyPreemptions', - 'selectionNamespace', - 'selectionJob', - 'selectionStatus' - ) + @computed('model.allocations.[]', 'preemptions.[]', 'onlyPreemptions') get visibleAllocations() { - const allocations = this.onlyPreemptions ? this.preemptions : this.model.allocations; + return this.onlyPreemptions ? this.preemptions : this.model.allocations; + } + + @computed('visibleAllocations.[]', 'selectionNamespace', 'selectionJob', 'selectionStatus') + get filteredAllocations() { const { selectionNamespace, selectionJob, selectionStatus } = this; - return allocations.filter(alloc => { + return this.visibleAllocations.filter(alloc => { if (selectionNamespace.length && !selectionNamespace.includes(alloc.get('namespace'))) { return false; } @@ -87,7 +84,7 @@ export default class ClientController extends Controller.extend(Sortable, Search }); } - @alias('visibleAllocations') listToSort; + @alias('filteredAllocations') listToSort; @alias('listSorted') listToSearch; @alias('listSearched') sortedAllocations; diff --git a/ui/app/templates/clients/client/index.hbs b/ui/app/templates/clients/client/index.hbs index 429cbce55fc..7e623678c40 100644 --- a/ui/app/templates/clients/client/index.hbs +++ b/ui/app/templates/clients/client/index.hbs @@ -325,47 +325,66 @@ />
-
- - - - - ID - Created - Modified - Status - Job - Version - Volume - CPU - Memory - - - - - -
- +
+ {{#if this.sortedAllocations.length}} + + + + + ID + Created + Modified + Status + Job + Version + Volume + CPU + Memory + + + + + +
+ +
+
+ {{else}} +
+ {{#if (eq this.visibleAllocations.length 0)}} +

No Allocations

+

+ The node doesn't have any allocations. +

+ {{else if this.searchTerm}} +

No Matches

+

No allocations match the term {{this.searchTerm}}

+ {{else if (eq this.sortedAllocations.length 0)}} +

No Matches

+

+ No allocations match your current filter selection. +

+ {{/if}}
- + {{/if}}
diff --git a/ui/tests/acceptance/client-detail-test.js b/ui/tests/acceptance/client-detail-test.js index f4471825529..cfd9042adcb 100644 --- a/ui/tests/acceptance/client-detail-test.js +++ b/ui/tests/acceptance/client-detail-test.js @@ -130,6 +130,15 @@ module('Acceptance | client detail', function(hooks) { ); }); + test('/clients/:id should show empty message if there are no allocations on the node', async function(assert) { + const emptyNode = server.create('node'); + + await ClientDetail.visit({ id: emptyNode.id }); + + assert.true(ClientDetail.emptyAllocations.isVisible, 'Empty message is visible'); + assert.equal(ClientDetail.emptyAllocations.headline, 'No Allocations'); + }); + test('each allocation should have high-level details for the allocation', async function(assert) { const allocation = server.db.allocations .where({ nodeId: node.id }) @@ -1028,6 +1037,19 @@ module('Acceptance | client detail', function(hooks) { }, filter: (alloc, selection) => selection.includes(alloc.clientStatus), }); + + test('fiter results with no matches display empty message', async function(assert) { + const job = server.create('job', { createAllocations: false }); + server.create('allocation', { jobId: job.id, clientStatus: 'running' }); + + await ClientDetail.visit({ id: node.id }); + const statusFacet = ClientDetail.facets.status; + await statusFacet.toggle(); + await statusFacet.options.objectAt(0).toggle(); + + assert.true(ClientDetail.emptyAllocations.isVisible); + assert.equal(ClientDetail.emptyAllocations.headline, 'No Matches'); + }); }); module('Acceptance | client detail (multi-namespace)', function(hooks) { diff --git a/ui/tests/pages/clients/detail.js b/ui/tests/pages/clients/detail.js index daa6e647aa0..bcfe0d2de28 100644 --- a/ui/tests/pages/clients/detail.js +++ b/ui/tests/pages/clients/detail.js @@ -39,6 +39,12 @@ export default create({ ...allocations(), + emptyAllocations: { + scope: '[data-test-empty-allocations-list]', + headline: text('[data-test-empty-allocations-list-headline]'), + body: text('[data-test-empty-allocations-list-body]'), + }, + allocationFilter: { preemptions: clickable('[data-test-filter-preemptions]'), all: clickable('[data-test-filter-all]'), From 1d773d0d9e3e624deb7707b44d59515f9af29c4a Mon Sep 17 00:00:00 2001 From: Luiz Aoqui Date: Fri, 17 Dec 2021 18:45:31 -0500 Subject: [PATCH 08/13] ui: fix task group alloc filter and add tests --- ui/app/controllers/jobs/job/task-group.js | 25 ++-- ui/app/templates/jobs/job/task-group.hbs | 2 +- ui/tests/acceptance/task-group-detail-test.js | 140 ++++++++++++++++++ ui/tests/pages/jobs/job/task-group.js | 6 + 4 files changed, 160 insertions(+), 13 deletions(-) diff --git a/ui/app/controllers/jobs/job/task-group.js b/ui/app/controllers/jobs/job/task-group.js index b0fdb6d58cc..807fa71725e 100644 --- a/ui/app/controllers/jobs/job/task-group.js +++ b/ui/app/controllers/jobs/job/task-group.js @@ -13,10 +13,10 @@ import classic from 'ember-classic-decorator'; @classic export default class TaskGroupController extends Controller.extend( - Sortable, - Searchable, - WithNamespaceResetting - ) { + Sortable, + Searchable, + WithNamespaceResetting +) { @service userSettings; @service can; @@ -54,14 +54,16 @@ export default class TaskGroupController extends Controller.extend( return ['shortId', 'name']; } - @computed('model.allocations.[]', 'selectionStatus', 'selectionClient') + @computed('model.allocations.[]') get allocations() { - const allocations = this.get('model.allocations') || []; - const { selectionStatus, selectionClient } = this; + return this.get('model.allocations') || []; + } - if (!allocations.length) return allocations; + @computed('allocations.[]', 'selectionStatus', 'selectionClient') + get filteredAllocations() { + const { selectionStatus, selectionClient } = this; - return allocations.filter(alloc => { + return this.allocations.filter(alloc => { if (selectionStatus.length && !selectionStatus.includes(alloc.clientStatus)) { return false; } @@ -73,7 +75,7 @@ export default class TaskGroupController extends Controller.extend( }); } - @alias('allocations') listToSort; + @alias('filteredAllocations') listToSort; @alias('listSorted') listToSearch; @alias('listSearched') sortedAllocations; @@ -115,8 +117,7 @@ export default class TaskGroupController extends Controller.extend( get optionsAllocationStatus() { return [ - { key: 'queued', label: 'Queued' }, - { key: 'starting', label: 'Starting' }, + { key: 'pending', label: 'Pending' }, { key: 'running', label: 'Running' }, { key: 'complete', label: 'Complete' }, { key: 'failed', label: 'Failed' }, diff --git a/ui/app/templates/jobs/job/task-group.hbs b/ui/app/templates/jobs/job/task-group.hbs index 87ef5d87a1f..2c00dcfc2d6 100644 --- a/ui/app/templates/jobs/job/task-group.hbs +++ b/ui/app/templates/jobs/job/task-group.hbs @@ -78,7 +78,7 @@ @onSelect={{action "setFacetQueryParam" "qpStatus"}} /> ev.count == null).length ); }); + + testFacet('Status', { + facet: TaskGroup.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 TaskGroup.visit({ id: job.id, name: taskGroup.name }); + }, + filter: (alloc, selection) => + alloc.jobId == job.id && + alloc.taskGroup == taskGroup.name && + selection.includes(alloc.clientStatus), + }); + + testFacet('Client', { + facet: TaskGroup.facets.client, + paramName: 'client', + expectedOptions(allocs) { + return Array.from( + new Set( + allocs + .filter(alloc => alloc.jobId == job.id && alloc.taskGroup == taskGroup.name) + .mapBy('nodeId') + .map(id => id.split('-')[0]) + ) + ).sort(); + }, + async beforeEach() { + const nodes = server.createList('node', 3, 'forceIPv4'); + nodes.forEach(node => + server.createList('allocation', 5, { + nodeId: node.id, + jobId: job.id, + taskGroup: taskGroup.name, + }) + ); + await TaskGroup.visit({ id: job.id, name: taskGroup.name }); + }, + filter: (alloc, selection) => + alloc.jobId == job.id && + alloc.taskGroup == taskGroup.name && + selection.includes(alloc.nodeId.split('-')[0]), + }); }); + +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(); + + TaskGroup.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(); + + TaskGroup.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}/${taskGroup.name}?${paramName}=${encodeURIComponent( + JSON.stringify(selection) + )}`, + 'URL has the correct query param key and value' + ); + }); +} diff --git a/ui/tests/pages/jobs/job/task-group.js b/ui/tests/pages/jobs/job/task-group.js index 192ea152a2f..28bd08dbd21 100644 --- a/ui/tests/pages/jobs/job/task-group.js +++ b/ui/tests/pages/jobs/job/task-group.js @@ -13,6 +13,7 @@ import error from 'nomad-ui/tests/pages/components/error'; import pageSizeSelect from 'nomad-ui/tests/pages/components/page-size-select'; import stepperInput from 'nomad-ui/tests/pages/components/stepper-input'; import LifecycleChart from 'nomad-ui/tests/pages/components/lifecycle-chart'; +import { multiFacet } from 'nomad-ui/tests/pages/components/facet'; export default create({ pageSize: 25, @@ -33,6 +34,11 @@ export default create({ isEmpty: isPresent('[data-test-empty-allocations-list]'), + facets: { + status: multiFacet('[data-test-allocation-status-facet]'), + client: multiFacet('[data-test-allocation-client-facet]'), + }, + lifecycleChart: LifecycleChart, hasVolumes: isPresent('[data-test-volumes]'), From ba1151198e2e2db3cb61ddf606be8a35cc4ee7c3 Mon Sep 17 00:00:00 2001 From: Luiz Aoqui Date: Fri, 17 Dec 2021 18:47:25 -0500 Subject: [PATCH 09/13] changelog: add entry for #11545 --- .changelog/11545.txt | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 .changelog/11545.txt diff --git a/.changelog/11545.txt b/.changelog/11545.txt new file mode 100644 index 00000000000..427dcbcea83 --- /dev/null +++ b/.changelog/11545.txt @@ -0,0 +1,3 @@ +```release-note:improvement +ui: Add filters to the allocation list in the client and task group details pages +``` From 770bb0534ad80cb8abb28f872b0d7a64e3e0864f Mon Sep 17 00:00:00 2001 From: Luiz Aoqui Date: Fri, 17 Dec 2021 18:55:41 -0500 Subject: [PATCH 10/13] ui: fix linting --- ui/app/controllers/jobs/job/task-group.js | 8 ++++---- ui/tests/acceptance/task-group-detail-test.js | 1 - 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/ui/app/controllers/jobs/job/task-group.js b/ui/app/controllers/jobs/job/task-group.js index 807fa71725e..796aab4ce3a 100644 --- a/ui/app/controllers/jobs/job/task-group.js +++ b/ui/app/controllers/jobs/job/task-group.js @@ -13,10 +13,10 @@ import classic from 'ember-classic-decorator'; @classic export default class TaskGroupController extends Controller.extend( - Sortable, - Searchable, - WithNamespaceResetting -) { + Sortable, + Searchable, + WithNamespaceResetting + ) { @service userSettings; @service can; diff --git a/ui/tests/acceptance/task-group-detail-test.js b/ui/tests/acceptance/task-group-detail-test.js index 1c9a4d63efe..795a227ee23 100644 --- a/ui/tests/acceptance/task-group-detail-test.js +++ b/ui/tests/acceptance/task-group-detail-test.js @@ -13,7 +13,6 @@ import TaskGroup from 'nomad-ui/tests/pages/jobs/job/task-group'; import Layout from 'nomad-ui/tests/pages/layout'; import pageSizeSelect from './behaviors/page-size-select'; import moment from 'moment'; -import { pauseTest } from '@ember/test-helpers/setup-context'; let job; let taskGroup; From e6ee0619c0222aab3ce091d9bd8908803188bfe3 Mon Sep 17 00:00:00 2001 From: Luiz Aoqui Date: Fri, 17 Dec 2021 20:02:59 -0500 Subject: [PATCH 11/13] ui: fix allocation serializer tests --- ui/tests/unit/serializers/allocation-test.js | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/ui/tests/unit/serializers/allocation-test.js b/ui/tests/unit/serializers/allocation-test.js index 80d58bbd6b7..5250d8336a0 100644 --- a/ui/tests/unit/serializers/allocation-test.js +++ b/ui/tests/unit/serializers/allocation-test.js @@ -35,6 +35,7 @@ module('Unit | Serializer | Allocation', function(hooks) { attributes: { taskGroupName: 'test-group', name: 'test-summary[1]', + namespace: 'test-namespace', modifyTime: sampleDate, createTime: sampleDate, states: [ @@ -102,6 +103,7 @@ module('Unit | Serializer | Allocation', function(hooks) { attributes: { taskGroupName: 'test-group', name: 'test-summary[1]', + namespace: 'test-namespace', modifyTime: sampleDate, createTime: sampleDate, states: [ @@ -172,6 +174,7 @@ module('Unit | Serializer | Allocation', function(hooks) { attributes: { taskGroupName: 'test-group', name: 'test-summary[1]', + namespace: 'test-namespace', modifyTime: sampleDate, createTime: sampleDate, states: [ @@ -259,6 +262,7 @@ module('Unit | Serializer | Allocation', function(hooks) { attributes: { taskGroupName: 'test-group', name: 'test-summary[1]', + namespace: 'test-namespace', modifyTime: sampleDate, createTime: sampleDate, states: [ @@ -332,6 +336,7 @@ module('Unit | Serializer | Allocation', function(hooks) { attributes: { taskGroupName: 'test-group', name: 'test-summary[1]', + namespace: 'test-namespace', modifyTime: sampleDate, createTime: sampleDate, states: [ From efd05eaa54b88a1a1026649c1e2836b64ed5ba70 Mon Sep 17 00:00:00 2001 From: Luiz Aoqui Date: Fri, 17 Dec 2021 20:23:28 -0500 Subject: [PATCH 12/13] ui: fix volume serializer tests --- ui/tests/unit/serializers/volume-test.js | 3 +++ 1 file changed, 3 insertions(+) diff --git a/ui/tests/unit/serializers/volume-test.js b/ui/tests/unit/serializers/volume-test.js index 72822cf4950..792538fc789 100644 --- a/ui/tests/unit/serializers/volume-test.js +++ b/ui/tests/unit/serializers/volume-test.js @@ -260,6 +260,7 @@ module('Unit | Serializer | Volume', function(hooks) { attributes: { createTime: REF_DATE, modifyTime: REF_DATE, + namespace: 'namespace-2', taskGroupName: 'foobar', wasPreempted: false, states: [], @@ -292,6 +293,7 @@ module('Unit | Serializer | Volume', function(hooks) { attributes: { createTime: REF_DATE, modifyTime: REF_DATE, + namespace: 'namespace-2', taskGroupName: 'write-here', wasPreempted: false, states: [], @@ -324,6 +326,7 @@ module('Unit | Serializer | Volume', function(hooks) { attributes: { createTime: REF_DATE, modifyTime: REF_DATE, + namespace: 'namespace-2', taskGroupName: 'look-if-you-must', wasPreempted: false, states: [], From a8c9676c99c7da42ec71721e061f54cdbc616d76 Mon Sep 17 00:00:00 2001 From: Luiz Aoqui Date: Fri, 17 Dec 2021 20:41:53 -0500 Subject: [PATCH 13/13] ui: fix action call to set filter query param --- ui/app/controllers/clients/client/index.js | 1 - ui/app/controllers/jobs/job/task-group.js | 1 - ui/app/templates/clients/client/index.hbs | 6 +++--- ui/app/templates/jobs/job/task-group.hbs | 4 ++-- 4 files changed, 5 insertions(+), 7 deletions(-) diff --git a/ui/app/controllers/clients/client/index.js b/ui/app/controllers/clients/client/index.js index a918a7b8298..bc4eeec79b0 100644 --- a/ui/app/controllers/clients/client/index.js +++ b/ui/app/controllers/clients/client/index.js @@ -230,7 +230,6 @@ export default class ClientController extends Controller.extend(Sortable, Search return ns.sort().map(n => ({ key: n, label: n })); } - @action setFacetQueryParam(queryParam, selection) { this.set(queryParam, serialize(selection)); } diff --git a/ui/app/controllers/jobs/job/task-group.js b/ui/app/controllers/jobs/job/task-group.js index 796aab4ce3a..f8a0ec3620f 100644 --- a/ui/app/controllers/jobs/job/task-group.js +++ b/ui/app/controllers/jobs/job/task-group.js @@ -138,7 +138,6 @@ export default class TaskGroupController extends Controller.extend( return clients.sort().map(dc => ({ key: dc, label: dc })); } - @action setFacetQueryParam(queryParam, selection) { this.set(queryParam, serialize(selection)); } diff --git a/ui/app/templates/clients/client/index.hbs b/ui/app/templates/clients/client/index.hbs index 7e623678c40..c7096c9afa9 100644 --- a/ui/app/templates/clients/client/index.hbs +++ b/ui/app/templates/clients/client/index.hbs @@ -300,21 +300,21 @@ @label="Namespace" @options={{this.optionsNamespace}} @selection={{this.selectionNamespace}} - @onSelect={{action "setFacetQueryParam" "qpNamespace"}} + @onSelect={{action this.setFacetQueryParam "qpNamespace"}} />