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 }