github.com/anth0d/nomad@v0.0.0-20221214183521-ae3a0a2cad06/ui/app/controllers/optimize.js (about)

     1  /* eslint-disable ember/no-incorrect-calls-with-inline-anonymous-functions */
     2  import Controller from '@ember/controller';
     3  import { action } from '@ember/object';
     4  import { tracked } from '@glimmer/tracking';
     5  import { inject as controller } from '@ember/controller';
     6  import { inject as service } from '@ember/service';
     7  import { scheduleOnce } from '@ember/runloop';
     8  import { task } from 'ember-concurrency';
     9  import intersection from 'lodash.intersection';
    10  import {
    11    serialize,
    12    deserializedQueryParam as selection,
    13  } from 'nomad-ui/utils/qp-serialize';
    14  
    15  import EmberObject, { computed } from '@ember/object';
    16  import { alias } from '@ember/object/computed';
    17  import Searchable from 'nomad-ui/mixins/searchable';
    18  import classic from 'ember-classic-decorator';
    19  
    20  export default class OptimizeController extends Controller {
    21    @controller('optimize/summary') summaryController;
    22    @service router;
    23    @service system;
    24  
    25    queryParams = [
    26      {
    27        searchTerm: 'search',
    28      },
    29      {
    30        qpNamespace: 'namespacefilter',
    31      },
    32      {
    33        qpType: 'type',
    34      },
    35      {
    36        qpStatus: 'status',
    37      },
    38      {
    39        qpDatacenter: 'dc',
    40      },
    41      {
    42        qpPrefix: 'prefix',
    43      },
    44    ];
    45  
    46    constructor() {
    47      super(...arguments);
    48  
    49      this.summarySearch = RecommendationSummarySearch.create({
    50        dataSource: this,
    51      });
    52    }
    53  
    54    get namespaces() {
    55      return this.model.namespaces;
    56    }
    57  
    58    get summaries() {
    59      return this.model.summaries;
    60    }
    61  
    62    @tracked searchTerm = '';
    63  
    64    @tracked qpType = '';
    65    @tracked qpStatus = '';
    66    @tracked qpDatacenter = '';
    67    @tracked qpPrefix = '';
    68    @tracked qpNamespace = '*';
    69  
    70    @selection('qpType') selectionType;
    71    @selection('qpStatus') selectionStatus;
    72    @selection('qpDatacenter') selectionDatacenter;
    73    @selection('qpPrefix') selectionPrefix;
    74  
    75    get optionsNamespaces() {
    76      const availableNamespaces = this.namespaces.map((namespace) => ({
    77        key: namespace.name,
    78        label: namespace.name,
    79      }));
    80  
    81      availableNamespaces.unshift({
    82        key: '*',
    83        label: 'All (*)',
    84      });
    85  
    86      // Unset the namespace selection if it was server-side deleted
    87      if (!availableNamespaces.mapBy('key').includes(this.qpNamespace)) {
    88        scheduleOnce('actions', () => {
    89          // eslint-disable-next-line ember/no-side-effects
    90          this.qpNamespace = '*';
    91        });
    92      }
    93  
    94      return availableNamespaces;
    95    }
    96  
    97    optionsType = [
    98      { key: 'service', label: 'Service' },
    99      { key: 'system', label: 'System' },
   100    ];
   101  
   102    optionsStatus = [
   103      { key: 'pending', label: 'Pending' },
   104      { key: 'running', label: 'Running' },
   105      { key: 'dead', label: 'Dead' },
   106    ];
   107  
   108    get optionsDatacenter() {
   109      const flatten = (acc, val) => acc.concat(val);
   110      const allDatacenters = new Set(
   111        this.summaries.mapBy('job.datacenters').reduce(flatten, [])
   112      );
   113  
   114      // Remove any invalid datacenters from the query param/selection
   115      const availableDatacenters = Array.from(allDatacenters).compact();
   116      scheduleOnce('actions', () => {
   117        // eslint-disable-next-line ember/no-side-effects
   118        this.qpDatacenter = serialize(
   119          intersection(availableDatacenters, this.selectionDatacenter)
   120        );
   121      });
   122  
   123      return availableDatacenters.sort().map((dc) => ({ key: dc, label: dc }));
   124    }
   125  
   126    get optionsPrefix() {
   127      // A prefix is defined as the start of a job name up to the first - or .
   128      // ex: mktg-analytics -> mktg, ds.supermodel.classifier -> ds
   129      const hasPrefix = /.[-._]/;
   130  
   131      // Collect and count all the prefixes
   132      const allNames = this.summaries.mapBy('job.name');
   133      const nameHistogram = allNames.reduce((hist, name) => {
   134        if (hasPrefix.test(name)) {
   135          const prefix = name.match(/(.+?)[-._]/)[1];
   136          hist[prefix] = hist[prefix] ? hist[prefix] + 1 : 1;
   137        }
   138        return hist;
   139      }, {});
   140  
   141      // Convert to an array
   142      const nameTable = Object.keys(nameHistogram).map((key) => ({
   143        prefix: key,
   144        count: nameHistogram[key],
   145      }));
   146  
   147      // Only consider prefixes that match more than one name
   148      const prefixes = nameTable.filter((name) => name.count > 1);
   149  
   150      // Remove any invalid prefixes from the query param/selection
   151      const availablePrefixes = prefixes.mapBy('prefix');
   152      scheduleOnce('actions', () => {
   153        // eslint-disable-next-line ember/no-side-effects
   154        this.qpPrefix = serialize(
   155          intersection(availablePrefixes, this.selectionPrefix)
   156        );
   157      });
   158  
   159      // Sort, format, and include the count in the label
   160      return prefixes.sortBy('prefix').map((name) => ({
   161        key: name.prefix,
   162        label: `${name.prefix} (${name.count})`,
   163      }));
   164    }
   165  
   166    get filteredSummaries() {
   167      const {
   168        selectionType: types,
   169        selectionStatus: statuses,
   170        selectionDatacenter: datacenters,
   171        selectionPrefix: prefixes,
   172      } = this;
   173  
   174      // A summary’s job must match ALL filter facets, but it can match ANY selection within a facet
   175      // Always return early to prevent unnecessary facet predicates.
   176      return this.summarySearch.listSearched.filter((summary) => {
   177        const job = summary.get('job');
   178  
   179        if (job.isDestroying) {
   180          return false;
   181        }
   182  
   183        if (
   184          this.qpNamespace !== '*' &&
   185          job.get('namespace.name') !== this.qpNamespace
   186        ) {
   187          return false;
   188        }
   189  
   190        if (types.length && !types.includes(job.get('displayType'))) {
   191          return false;
   192        }
   193  
   194        if (statuses.length && !statuses.includes(job.get('status'))) {
   195          return false;
   196        }
   197  
   198        if (
   199          datacenters.length &&
   200          !job.get('datacenters').find((dc) => datacenters.includes(dc))
   201        ) {
   202          return false;
   203        }
   204  
   205        const name = job.get('name');
   206        if (
   207          prefixes.length &&
   208          !prefixes.find((prefix) => name.startsWith(prefix))
   209        ) {
   210          return false;
   211        }
   212  
   213        return true;
   214      });
   215    }
   216  
   217    get activeRecommendationSummary() {
   218      if (this.router.currentRouteName === 'optimize.summary') {
   219        return this.summaryController.model;
   220      } else {
   221        return undefined;
   222      }
   223    }
   224  
   225    // This is a task because the accordion uses timeouts for animation
   226    // eslint-disable-next-line require-yield
   227    @(task(function* () {
   228      const currentSummaryIndex = this.filteredSummaries.indexOf(
   229        this.activeRecommendationSummary
   230      );
   231      const nextSummary = this.filteredSummaries.objectAt(
   232        currentSummaryIndex + 1
   233      );
   234  
   235      if (nextSummary) {
   236        this.transitionToSummary(nextSummary);
   237      } else {
   238        this.send('reachedEnd');
   239      }
   240    }).drop())
   241    proceed;
   242  
   243    @action
   244    transitionToSummary(summary) {
   245      this.transitionToRoute('optimize.summary', summary.slug, {
   246        queryParams: { jobNamespace: summary.jobNamespace },
   247      });
   248    }
   249  
   250    @action
   251    setFacetQueryParam(queryParam, selection) {
   252      this[queryParam] = serialize(selection);
   253      this.syncActiveSummary();
   254    }
   255  
   256    @action
   257    syncActiveSummary() {
   258      scheduleOnce('actions', () => {
   259        if (
   260          !this.activeRecommendationSummary ||
   261          !this.filteredSummaries.includes(this.activeRecommendationSummary)
   262        ) {
   263          const firstFilteredSummary = this.filteredSummaries.objectAt(0);
   264  
   265          if (firstFilteredSummary) {
   266            this.transitionToSummary(firstFilteredSummary);
   267          } else {
   268            this.transitionToRoute('optimize');
   269          }
   270        }
   271      });
   272    }
   273  }
   274  
   275  @classic
   276  class RecommendationSummarySearch extends EmberObject.extend(Searchable) {
   277    @computed
   278    get fuzzySearchProps() {
   279      return ['slug'];
   280    }
   281  
   282    @alias('dataSource.summaries') listToSearch;
   283    @alias('dataSource.searchTerm') searchTerm;
   284  
   285    exactMatchEnabled = false;
   286    fuzzySearchEnabled = true;
   287    includeFuzzySearchMatches = true;
   288  }