open-nomad/ui/app/controllers/optimize.js
Michael Lange e8593ec1bb
ui: Update namespaces design (#10444)
This rethinks namespaces as a filter on list pages rather than a global setting.

The biggest net-new feature here is being able to select All (*) to list all jobs
or CSI volumes across namespaces.
2021-04-29 15:00:59 -05:00

272 lines
7.4 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/* 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 = [
{
searchTerm: 'search',
},
{
qpNamespace: 'namespacefilter',
},
{
qpType: 'type',
},
{
qpStatus: 'status',
},
{
qpDatacenter: 'dc',
},
{
qpPrefix: 'prefix',
},
];
constructor() {
super(...arguments);
this.summarySearch = RecommendationSummarySearch.create({
dataSource: this,
});
}
get namespaces() {
return this.model.namespaces;
}
get summaries() {
return this.model.summaries;
}
@tracked searchTerm = '';
@tracked qpType = '';
@tracked qpStatus = '';
@tracked qpDatacenter = '';
@tracked qpPrefix = '';
@tracked qpNamespace = '*';
@selection('qpType') selectionType;
@selection('qpStatus') selectionStatus;
@selection('qpDatacenter') selectionDatacenter;
@selection('qpPrefix') selectionPrefix;
get optionsNamespaces() {
const availableNamespaces = this.namespaces.map(namespace => ({
key: namespace.name,
label: namespace.name,
}));
availableNamespaces.unshift({
key: '*',
label: 'All (*)',
});
// Unset the namespace selection if it was server-side deleted
if (!availableNamespaces.mapBy('key').includes(this.qpNamespace)) {
scheduleOnce('actions', () => {
// eslint-disable-next-line ember/no-side-effects
this.qpNamespace = this.system.cachedNamespace || 'default';
});
}
return availableNamespaces;
}
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.summaries.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.summaries.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.summarySearch.listSearched.filter(summary => {
const job = summary.get('job');
if (job.isDestroying) {
return false;
}
if (this.qpNamespace !== '*' && job.get('namespace.name') !== this.qpNamespace) {
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
cacheNamespace(namespace) {
this.system.cachedNamespace = namespace;
}
@action
setFacetQueryParam(queryParam, selection) {
this[queryParam] = serialize(selection);
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.summaries') listToSearch;
@alias('dataSource.searchTerm') searchTerm;
exactMatchEnabled = false;
fuzzySearchEnabled = true;
includeFuzzySearchMatches = true;
}