diff --git a/ui/app/controllers/optimize.js b/ui/app/controllers/optimize.js index 57f610883..0456b7cc8 100644 --- a/ui/app/controllers/optimize.js +++ b/ui/app/controllers/optimize.js @@ -1,11 +1,150 @@ +/* eslint-disable ember/no-incorrect-calls-with-inline-anonymous-functions */ import Controller from '@ember/controller'; import { action } from '@ember/object'; +import { tracked } from '@glimmer/tracking'; import { inject as controller } from '@ember/controller'; +import { scheduleOnce } from '@ember/runloop'; import { task } from 'ember-concurrency'; +import intersection from 'lodash.intersection'; +import { serialize, deserializedQueryParam as selection } from 'nomad-ui/utils/qp-serialize'; export default class OptimizeController extends Controller { @controller('optimize/summary') summaryController; + queryParams = [ + // { + // currentPage: 'page', + // }, + // { + // searchTerm: 'search', + // }, + // { + // sortProperty: 'sort', + // }, + // { + // sortDescending: 'desc', + // }, + { + qpType: 'type', + }, + { + qpStatus: 'status', + }, + { + qpDatacenter: 'dc', + }, + { + qpPrefix: 'prefix', + }, + ]; + + @tracked qpType = ''; + @tracked qpStatus = ''; + @tracked qpDatacenter = ''; + @tracked qpPrefix = ''; + + @selection('qpType') selectionType; + @selection('qpStatus') selectionStatus; + @selection('qpDatacenter') selectionDatacenter; + @selection('qpPrefix') selectionPrefix; + + optionsType = [ + { key: 'service', label: 'Service' }, + { key: 'system', label: 'System' }, + ]; + + optionsStatus = [ + { key: 'pending', label: 'Pending' }, + { key: 'running', label: 'Running' }, + { key: 'dead', label: 'Dead' }, + ]; + + get optionsDatacenter() { + const flatten = (acc, val) => acc.concat(val); + const allDatacenters = new Set(this.model.mapBy('job.datacenters').reduce(flatten, [])); + + // Remove any invalid datacenters from the query param/selection + const availableDatacenters = Array.from(allDatacenters).compact(); + scheduleOnce('actions', () => { + // eslint-disable-next-line ember/no-side-effects + this.qpDatacenter = serialize(intersection(availableDatacenters, this.selectionDatacenter)); + }); + + return availableDatacenters.sort().map(dc => ({ key: dc, label: dc })); + } + + get optionsPrefix() { + // A prefix is defined as the start of a job name up to the first - or . + // ex: mktg-analytics -> mktg, ds.supermodel.classifier -> ds + const hasPrefix = /.[-._]/; + + // Collect and count all the prefixes + const allNames = this.model.mapBy('job.name'); + const nameHistogram = allNames.reduce((hist, name) => { + if (hasPrefix.test(name)) { + const prefix = name.match(/(.+?)[-._]/)[1]; + hist[prefix] = hist[prefix] ? hist[prefix] + 1 : 1; + } + return hist; + }, {}); + + // Convert to an array + const nameTable = Object.keys(nameHistogram).map(key => ({ + prefix: key, + count: nameHistogram[key], + })); + + // Only consider prefixes that match more than one name + const prefixes = nameTable.filter(name => name.count > 1); + + // Remove any invalid prefixes from the query param/selection + const availablePrefixes = prefixes.mapBy('prefix'); + scheduleOnce('actions', () => { + // eslint-disable-next-line ember/no-side-effects + this.qpPrefix = serialize(intersection(availablePrefixes, this.selectionPrefix)); + }); + + // Sort, format, and include the count in the label + return prefixes.sortBy('prefix').map(name => ({ + key: name.prefix, + label: `${name.prefix} (${name.count})`, + })); + } + + get filteredSummaries() { + const { + selectionType: types, + selectionStatus: statuses, + selectionDatacenter: datacenters, + selectionPrefix: prefixes, + } = this; + + // A summary’s job must match ALL filter facets, but it can match ANY selection within a facet + // Always return early to prevent unnecessary facet predicates. + return this.model.filter(summary => { + const job = summary.get('job'); + + if (types.length && !types.includes(job.get('displayType'))) { + return false; + } + + if (statuses.length && !statuses.includes(job.get('status'))) { + return false; + } + + if (datacenters.length && !job.get('datacenters').find(dc => datacenters.includes(dc))) { + return false; + } + + const name = job.get('name'); + if (prefixes.length && !prefixes.find(prefix => name.startsWith(prefix))) { + return false; + } + + return true; + }); + } + get activeRecommendationSummary() { return this.summaryController.model; } @@ -30,4 +169,9 @@ export default class OptimizeController extends Controller { queryParams: { jobNamespace: summary.jobNamespace }, }); } + + @action + setFacetQueryParam(queryParam, selection) { + this[queryParam] = serialize(selection); + } } diff --git a/ui/app/templates/optimize.hbs b/ui/app/templates/optimize.hbs index b4cb14a8c..8cac4f433 100644 --- a/ui/app/templates/optimize.hbs +++ b/ui/app/templates/optimize.hbs @@ -1,10 +1,50 @@
{{#if @model}} +
+
+ {{#if @model}} + {{!-- --}} + {{/if}} +
+
+
+ + + + +
+
+
+ {{outlet}} + @source={{this.filteredSummaries}} as |t|> Job Recommended At diff --git a/ui/tests/acceptance/optimize-test.js b/ui/tests/acceptance/optimize-test.js index 2cc0b01ec..0716d117c 100644 --- a/ui/tests/acceptance/optimize-test.js +++ b/ui/tests/acceptance/optimize-test.js @@ -351,6 +351,244 @@ module('Acceptance | optimize', function(hooks) { }); }); +module('Acceptance | optimize facets', function(hooks) { + setupApplicationTest(hooks); + setupMirage(hooks); + + hooks.beforeEach(async function() { + server.create('node'); + + server.createList('namespace', 2); + + managementToken = server.create('token'); + + window.localStorage.clear(); + window.localStorage.nomadTokenSecret = managementToken.secretId; + }); + + test('the optimize page has appropriate faceted search options', async function(assert) { + server.createList('job', 4, { + status: 'running', + createRecommendations: true, + childrenCount: 0, + }); + + await Optimize.visit(); + + assert.ok(Optimize.facets.type.isPresent, 'Type facet found'); + assert.ok(Optimize.facets.status.isPresent, 'Status facet found'); + assert.ok(Optimize.facets.datacenter.isPresent, 'Datacenter facet found'); + assert.ok(Optimize.facets.prefix.isPresent, 'Prefix facet found'); + }); + + testFacet('Type', { + facet: Optimize.facets.type, + paramName: 'type', + expectedOptions: ['Service', 'System'], + async beforeEach() { + server.createList('job', 2, { + type: 'service', + createRecommendations: true, + groupsCount: 1, + groupTaskCount: 2, + }); + + server.createList('job', 2, { + type: 'system', + createRecommendations: true, + groupsCount: 1, + groupTaskCount: 2, + }); + await Optimize.visit(); + }, + filter(taskGroup, selection) { + let displayType = taskGroup.job.type; + return selection.includes(displayType); + }, + }); + + testFacet('Status', { + facet: Optimize.facets.status, + paramName: 'status', + expectedOptions: ['Pending', 'Running', 'Dead'], + async beforeEach() { + server.createList('job', 2, { + status: 'pending', + createRecommendations: true, + groupsCount: 1, + groupTaskCount: 2, + childrenCount: 0, + }); + server.createList('job', 2, { + status: 'running', + createRecommendations: true, + groupsCount: 1, + groupTaskCount: 2, + childrenCount: 0, + }); + server.createList('job', 2, { status: 'dead', createRecommendations: true, childrenCount: 0 }); + await Optimize.visit(); + }, + filter: (taskGroup, selection) => selection.includes(taskGroup.job.status), + }); + + testFacet('Datacenter', { + facet: Optimize.facets.datacenter, + paramName: 'dc', + expectedOptions(jobs) { + const allDatacenters = new Set( + jobs.mapBy('datacenters').reduce((acc, val) => acc.concat(val), []) + ); + return Array.from(allDatacenters).sort(); + }, + async beforeEach() { + server.create('job', { + datacenters: ['pdx', 'lax'], + createRecommendations: true, + groupsCount: 1, + groupTaskCount: 2, + childrenCount: 0, + }); + server.create('job', { + datacenters: ['pdx', 'ord'], + createRecommendations: true, + groupsCount: 1, + groupTaskCount: 2, + childrenCount: 0, + }); + server.create('job', { + datacenters: ['lax', 'jfk'], + createRecommendations: true, + groupsCount: 1, + groupTaskCount: 2, + childrenCount: 0, + }); + server.create('job', { + datacenters: ['jfk', 'dfw'], + createRecommendations: true, + groupsCount: 1, + groupTaskCount: 2, + childrenCount: 0, + }); + server.create('job', { datacenters: ['pdx'], createRecommendations: true, childrenCount: 0 }); + await Optimize.visit(); + }, + filter: (taskGroup, selection) => taskGroup.job.datacenters.find(dc => selection.includes(dc)), + }); + + testFacet('Prefix', { + facet: Optimize.facets.prefix, + paramName: 'prefix', + expectedOptions: ['hashi (3)', 'nmd (2)', 'pre (2)'], + async beforeEach() { + [ + 'pre-one', + 'hashi_one', + 'nmd.one', + 'one-alone', + 'pre_two', + 'hashi.two', + 'hashi-three', + 'nmd_two', + 'noprefix', + ].forEach(name => { + server.create('job', { + name, + createRecommendations: true, + createAllocations: true, + groupsCount: 1, + groupTaskCount: 2, + childrenCount: 0, + }); + }); + await Optimize.visit(); + }, + filter: (taskGroup, selection) => selection.find(prefix => taskGroup.job.name.startsWith(prefix)), + }); + + function testFacet(label, { facet, paramName, beforeEach, filter, expectedOptions }) { + test(`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.jobs); + } else { + expectation = expectedOptions; + } + + assert.deepEqual( + facet.options.map(option => option.label.trim()), + expectation, + 'Options for facet are as expected' + ); + }); + + test(`the ${label} facet filters the recommendation summaries 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 sortedRecommendations = server.db.recommendations + .sortBy('submitTime').reverse(); + + const recommendationTaskGroups = server.schema.tasks.find(sortedRecommendations.mapBy('taskId').uniq()).models.mapBy('taskGroup').uniqBy('id').filter(group => filter(group, selection)); + + Optimize.recommendationSummaries.forEach((summary, index) => { + const group = recommendationTaskGroups[index]; + assert.equal(summary.slug, `${group.job.name} / ${group.name}`); + }); + }); + + test(`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 sortedRecommendations = server.db.recommendations + .sortBy('submitTime').reverse(); + + const recommendationTaskGroups = server.schema.tasks.find(sortedRecommendations.mapBy('taskId').uniq()).models.mapBy('taskGroup').uniqBy('id').filter(group => filter(group, selection)); + + Optimize.recommendationSummaries.forEach((summary, index) => { + const group = recommendationTaskGroups[index]; + assert.equal(summary.slug, `${group.job.name} / ${group.name}`); + }); + }); + + test(`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.ok(currentURL().includes(encodeURIComponent(JSON.stringify(selection)))); + }); + } +}); + function formattedMemDiff(memDiff) { const absMemDiff = Math.abs(memDiff); const negativeSign = memDiff < 0 ? '-' : ''; diff --git a/ui/tests/pages/optimize.js b/ui/tests/pages/optimize.js index 8d54ad467..9ec25bc97 100644 --- a/ui/tests/pages/optimize.js +++ b/ui/tests/pages/optimize.js @@ -10,6 +10,7 @@ import { } from 'ember-cli-page-object'; import recommendationCard from 'nomad-ui/tests/pages/components/recommendation-card'; +import facet from 'nomad-ui/tests/pages/components/facet'; export default create({ visit: visitable('/optimize'), @@ -55,4 +56,11 @@ export default create({ isPresent: isPresent('[data-test-error]'), title: text('[data-test-error-title]'), }, + + facets: { + type: facet('[data-test-type-facet]'), + status: facet('[data-test-status-facet]'), + datacenter: facet('[data-test-datacenter-facet]'), + prefix: facet('[data-test-prefix-facet]'), + }, });