From 660f4a8b1ecc7882e6d75e3f40416d8a1a0536d3 Mon Sep 17 00:00:00 2001 From: Luiz Aoqui Date: Fri, 17 Dec 2021 16:55:40 -0500 Subject: [PATCH] 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 d6da57e68..b3cc6fea0 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', }, { - qpTaskGroup: 'taskGroup', + qpJob: 'job', + }, + { + 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 (selectionTaskGroup.length && !selectionTaskGroup.includes(alloc.taskGroupName)) { + if (selectionJob.length && !selectionJob.includes(alloc.get('plainJobId'))) { + return false; + } + 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 clients changes. + // Update query param when the list of jobs changes. scheduleOnce('actions', () => { // eslint-disable-next-line ember/no-side-effects - this.set('qpTaskGroup', serialize(intersection(taskGroups, this.selectionTaskGroup))); + this.set('qpJob', serialize(intersection(jobs, this.selectionJob))); }); - return taskGroups.sort().map(tg => ({ key: tg, label: tg })); + 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 namespaces changes. + scheduleOnce('actions', () => { + // eslint-disable-next-line ember/no-side-effects + this.set('qpNamespace', serialize(intersection(ns, this.selectionNamespace))); + }); + + 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 f2293d3fc..028ee23bf 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 3ccb8de5d..429cbce55 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 70afd69a7..daa6e647a 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]'),