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:
parent
20ec481090
commit
cc0336bf0f
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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 ? '-' : '';
|
||||
|
|
|
@ -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]'),
|
||||
},
|
||||
});
|
||||
|
|
Loading…
Reference in a new issue