Add facets for recommendation summaries

This is mostly copied from the jobs list. One uncertainty
is what to do when changing a facet causes the currently-
active card to be excluded from the filtered list 🤔
This commit is contained in:
Buck Doyle 2020-11-06 15:53:58 -06:00
parent 20ec481090
commit cc0336bf0f
4 changed files with 431 additions and 1 deletions

View file

@ -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 summarys 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);
}
}

View file

@ -1,10 +1,50 @@
<PageLayout>
<section class="section">
{{#if @model}}
<div class="toolbar">
<div class="toolbar-item">
{{#if @model}}
{{!-- <SearchBox
data-test-jobs-search
@searchTerm={{mut this.searchTerm}}
@onChange={{action this.resetPagination}}
@placeholder="Search jobs..." /> --}}
{{/if}}
</div>
<div class="toolbar-item is-right-aligned is-mobile-full-width">
<div class="button-bar">
<MultiSelectDropdown
data-test-type-facet
@label="Type"
@options={{this.optionsType}}
@selection={{this.selectionType}}
@onSelect={{action this.setFacetQueryParam "qpType"}} />
<MultiSelectDropdown
data-test-status-facet
@label="Status"
@options={{this.optionsStatus}}
@selection={{this.selectionStatus}}
@onSelect={{action this.setFacetQueryParam "qpStatus"}} />
<MultiSelectDropdown
data-test-datacenter-facet
@label="Datacenter"
@options={{this.optionsDatacenter}}
@selection={{this.selectionDatacenter}}
@onSelect={{action this.setFacetQueryParam "qpDatacenter"}} />
<MultiSelectDropdown
data-test-prefix-facet
@label="Prefix"
@options={{this.optionsPrefix}}
@selection={{this.selectionPrefix}}
@onSelect={{action this.setFacetQueryParam "qpPrefix"}} />
</div>
</div>
</div>
{{outlet}}
<ListTable
@source={{this.model}} as |t|>
@source={{this.filteredSummaries}} as |t|>
<t.head>
<th>Job</th>
<th>Recommended At</th>

View file

@ -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 ? '-' : '';

View file

@ -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]'),
},
});