3b0f876ae5
This builds on filtering to allow the optimize page to show recommendations for the active namespace vs all namespaces. If turning off the toggle causes the summary from the active card to become excluded from the filtered list, the active summary changes, as with the facets. It also includes a fix for this bug: https://github.com/hashicorp/nomad/pull/9294#pullrequestreview-527748994
244 lines
6.9 KiB
JavaScript
244 lines
6.9 KiB
JavaScript
/* 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 { inject as service } from '@ember/service';
|
||
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';
|
||
|
||
import EmberObject, { computed } from '@ember/object';
|
||
import { alias } from '@ember/object/computed';
|
||
import Searchable from 'nomad-ui/mixins/searchable';
|
||
import classic from 'ember-classic-decorator';
|
||
|
||
export default class OptimizeController extends Controller {
|
||
@controller('optimize/summary') summaryController;
|
||
@service router;
|
||
@service system;
|
||
|
||
queryParams = [
|
||
{
|
||
includeAllNamespaces: 'all-namespaces',
|
||
},
|
||
{
|
||
searchTerm: 'search',
|
||
},
|
||
{
|
||
qpType: 'type',
|
||
},
|
||
{
|
||
qpStatus: 'status',
|
||
},
|
||
{
|
||
qpDatacenter: 'dc',
|
||
},
|
||
{
|
||
qpPrefix: 'prefix',
|
||
},
|
||
];
|
||
|
||
constructor() {
|
||
super(...arguments);
|
||
|
||
this.summarySearch = RecommendationSummarySearch.create({
|
||
dataSource: this,
|
||
});
|
||
}
|
||
|
||
@tracked searchTerm = '';
|
||
|
||
@tracked qpType = '';
|
||
@tracked qpStatus = '';
|
||
@tracked qpDatacenter = '';
|
||
@tracked qpPrefix = '';
|
||
|
||
@tracked includeAllNamespaces = true;
|
||
|
||
@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;
|
||
|
||
const shouldShowNamespaces = this.system.shouldShowNamespaces;
|
||
const activeNamespace = shouldShowNamespaces ? this.system.activeNamespace.name : undefined;
|
||
|
||
// 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.summarySearch.listSearched.filter(summary => {
|
||
const job = summary.get('job');
|
||
|
||
if (
|
||
shouldShowNamespaces &&
|
||
!this.includeAllNamespaces &&
|
||
activeNamespace !== summary.jobNamespace
|
||
) {
|
||
return false;
|
||
}
|
||
|
||
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() {
|
||
if (this.router.currentRouteName === 'optimize.summary') {
|
||
return this.summaryController.model;
|
||
} else {
|
||
return undefined;
|
||
}
|
||
}
|
||
|
||
// This is a task because the accordion uses timeouts for animation
|
||
// eslint-disable-next-line require-yield
|
||
@(task(function*() {
|
||
const currentSummaryIndex = this.filteredSummaries.indexOf(this.activeRecommendationSummary);
|
||
const nextSummary = this.filteredSummaries.objectAt(currentSummaryIndex + 1);
|
||
|
||
if (nextSummary) {
|
||
this.transitionToSummary(nextSummary);
|
||
} else {
|
||
this.send('reachedEnd');
|
||
}
|
||
}).drop())
|
||
proceed;
|
||
|
||
@action
|
||
transitionToSummary(summary) {
|
||
this.transitionToRoute('optimize.summary', summary.slug, {
|
||
queryParams: { jobNamespace: summary.jobNamespace },
|
||
});
|
||
}
|
||
|
||
@action
|
||
setFacetQueryParam(queryParam, selection) {
|
||
this[queryParam] = serialize(selection);
|
||
this.syncActiveSummary();
|
||
}
|
||
|
||
@action
|
||
toggleIncludeAllNamespaces() {
|
||
this.includeAllNamespaces = !this.includeAllNamespaces;
|
||
this.syncActiveSummary();
|
||
}
|
||
|
||
@action
|
||
syncActiveSummary() {
|
||
scheduleOnce('actions', () => {
|
||
if (
|
||
!this.activeRecommendationSummary ||
|
||
!this.filteredSummaries.includes(this.activeRecommendationSummary)
|
||
) {
|
||
const firstFilteredSummary = this.filteredSummaries.objectAt(0);
|
||
|
||
if (firstFilteredSummary) {
|
||
this.transitionToSummary(firstFilteredSummary);
|
||
} else {
|
||
this.transitionToRoute('optimize');
|
||
}
|
||
}
|
||
});
|
||
}
|
||
}
|
||
|
||
@classic
|
||
class RecommendationSummarySearch extends EmberObject.extend(Searchable) {
|
||
@computed
|
||
get fuzzySearchProps() {
|
||
return ['slug'];
|
||
}
|
||
|
||
@alias('dataSource.model') listToSearch;
|
||
@alias('dataSource.searchTerm') searchTerm;
|
||
|
||
exactMatchEnabled = false;
|
||
fuzzySearchEnabled = true;
|
||
includeFuzzySearchMatches = true;
|
||
}
|