github.com/hernad/nomad@v1.6.112/ui/app/controllers/jobs/index.js (about) 1 /** 2 * Copyright (c) HashiCorp, Inc. 3 * SPDX-License-Identifier: MPL-2.0 4 */ 5 6 /* eslint-disable ember/no-incorrect-calls-with-inline-anonymous-functions */ 7 import { inject as service } from '@ember/service'; 8 import { alias, readOnly } from '@ember/object/computed'; 9 import Controller from '@ember/controller'; 10 import { computed, action } from '@ember/object'; 11 import { scheduleOnce } from '@ember/runloop'; 12 import intersection from 'lodash.intersection'; 13 import Sortable from 'nomad-ui/mixins/sortable'; 14 import Searchable from 'nomad-ui/mixins/searchable'; 15 import { 16 serialize, 17 deserializedQueryParam as selection, 18 } from 'nomad-ui/utils/qp-serialize'; 19 import classic from 'ember-classic-decorator'; 20 21 @classic 22 export default class IndexController extends Controller.extend( 23 Sortable, 24 Searchable 25 ) { 26 @service system; 27 @service userSettings; 28 @service router; 29 30 isForbidden = false; 31 32 queryParams = [ 33 { 34 currentPage: 'page', 35 }, 36 { 37 searchTerm: 'search', 38 }, 39 { 40 sortProperty: 'sort', 41 }, 42 { 43 sortDescending: 'desc', 44 }, 45 { 46 qpType: 'type', 47 }, 48 { 49 qpStatus: 'status', 50 }, 51 { 52 qpDatacenter: 'dc', 53 }, 54 { 55 qpPrefix: 'prefix', 56 }, 57 { 58 qpNamespace: 'namespace', 59 }, 60 { 61 qpNodePool: 'nodePool', 62 }, 63 ]; 64 65 currentPage = 1; 66 @readOnly('userSettings.pageSize') pageSize; 67 68 sortProperty = 'modifyIndex'; 69 sortDescending = true; 70 71 @computed 72 get searchProps() { 73 return ['id', 'name']; 74 } 75 76 @computed 77 get fuzzySearchProps() { 78 return ['name']; 79 } 80 81 fuzzySearchEnabled = true; 82 83 qpType = ''; 84 qpStatus = ''; 85 qpDatacenter = ''; 86 qpPrefix = ''; 87 qpNodePool = ''; 88 89 @selection('qpType') selectionType; 90 @selection('qpStatus') selectionStatus; 91 @selection('qpDatacenter') selectionDatacenter; 92 @selection('qpPrefix') selectionPrefix; 93 @selection('qpNodePool') selectionNodePool; 94 95 @computed 96 get optionsType() { 97 return [ 98 { key: 'batch', label: 'Batch' }, 99 { key: 'pack', label: 'Pack' }, 100 { key: 'parameterized', label: 'Parameterized' }, 101 { key: 'periodic', label: 'Periodic' }, 102 { key: 'service', label: 'Service' }, 103 { key: 'system', label: 'System' }, 104 { key: 'sysbatch', label: 'System Batch' }, 105 ]; 106 } 107 108 @computed 109 get optionsStatus() { 110 return [ 111 { key: 'pending', label: 'Pending' }, 112 { key: 'running', label: 'Running' }, 113 { key: 'dead', label: 'Dead' }, 114 ]; 115 } 116 117 @computed('selectionDatacenter', 'visibleJobs.[]') 118 get optionsDatacenter() { 119 const flatten = (acc, val) => acc.concat(val); 120 const allDatacenters = new Set( 121 this.visibleJobs.mapBy('datacenters').reduce(flatten, []) 122 ); 123 124 // Remove any invalid datacenters from the query param/selection 125 const availableDatacenters = Array.from(allDatacenters).compact(); 126 scheduleOnce('actions', () => { 127 // eslint-disable-next-line ember/no-side-effects 128 this.set( 129 'qpDatacenter', 130 serialize(intersection(availableDatacenters, this.selectionDatacenter)) 131 ); 132 }); 133 134 return availableDatacenters.sort().map((dc) => ({ key: dc, label: dc })); 135 } 136 137 @computed('selectionPrefix', 'visibleJobs.[]') 138 get optionsPrefix() { 139 // A prefix is defined as the start of a job name up to the first - or . 140 // ex: mktg-analytics -> mktg, ds.supermodel.classifier -> ds 141 const hasPrefix = /.[-._]/; 142 143 // Collect and count all the prefixes 144 const allNames = this.visibleJobs.mapBy('name'); 145 const nameHistogram = allNames.reduce((hist, name) => { 146 if (hasPrefix.test(name)) { 147 const prefix = name.match(/(.+?)[-._]/)[1]; 148 hist[prefix] = hist[prefix] ? hist[prefix] + 1 : 1; 149 } 150 return hist; 151 }, {}); 152 153 // Convert to an array 154 const nameTable = Object.keys(nameHistogram).map((key) => ({ 155 prefix: key, 156 count: nameHistogram[key], 157 })); 158 159 // Only consider prefixes that match more than one name 160 const prefixes = nameTable.filter((name) => name.count > 1); 161 162 // Remove any invalid prefixes from the query param/selection 163 const availablePrefixes = prefixes.mapBy('prefix'); 164 scheduleOnce('actions', () => { 165 // eslint-disable-next-line ember/no-side-effects 166 this.set( 167 'qpPrefix', 168 serialize(intersection(availablePrefixes, this.selectionPrefix)) 169 ); 170 }); 171 172 // Sort, format, and include the count in the label 173 return prefixes.sortBy('prefix').map((name) => ({ 174 key: name.prefix, 175 label: `${name.prefix} (${name.count})`, 176 })); 177 } 178 179 @computed('qpNamespace', 'model.namespaces.[]') 180 get optionsNamespaces() { 181 const availableNamespaces = this.model.namespaces.map((namespace) => ({ 182 key: namespace.name, 183 label: namespace.name, 184 })); 185 186 availableNamespaces.unshift({ 187 key: '*', 188 label: 'All (*)', 189 }); 190 191 // Unset the namespace selection if it was server-side deleted 192 if (!availableNamespaces.mapBy('key').includes(this.qpNamespace)) { 193 scheduleOnce('actions', () => { 194 // eslint-disable-next-line ember/no-side-effects 195 this.set('qpNamespace', '*'); 196 }); 197 } 198 199 return availableNamespaces; 200 } 201 202 @computed('selectionNodePool', 'model.nodePools.[]') 203 get optionsNodePool() { 204 const availableNodePools = this.model.nodePools; 205 206 scheduleOnce('actions', () => { 207 // eslint-disable-next-line ember/no-side-effects 208 this.set( 209 'qpNodePool', 210 serialize( 211 intersection( 212 availableNodePools.map(({ name }) => name), 213 this.selectionNodePool 214 ) 215 ) 216 ); 217 }); 218 219 return availableNodePools.map((nodePool) => ({ 220 key: nodePool.name, 221 label: nodePool.name, 222 })); 223 } 224 225 /** 226 Visible jobs are those that match the selected namespace and aren't children 227 of periodic or parameterized jobs. 228 */ 229 @computed('model.jobs.@each.parent') 230 get visibleJobs() { 231 if (!this.model || !this.model.jobs) return []; 232 return this.model.jobs 233 .compact() 234 .filter((job) => !job.isNew) 235 .filter((job) => !job.get('parent.content')); 236 } 237 238 @computed( 239 'visibleJobs.[]', 240 'selectionType', 241 'selectionStatus', 242 'selectionDatacenter', 243 'selectionNodePool', 244 'selectionPrefix' 245 ) 246 get filteredJobs() { 247 const { 248 selectionType: types, 249 selectionStatus: statuses, 250 selectionDatacenter: datacenters, 251 selectionPrefix: prefixes, 252 selectionNodePool: nodePools, 253 } = this; 254 255 // A job must match ALL filter facets, but it can match ANY selection within a facet 256 // Always return early to prevent unnecessary facet predicates. 257 return this.visibleJobs.filter((job) => { 258 const shouldShowPack = types.includes('pack') && job.displayType.isPack; 259 260 if (types.length && shouldShowPack) { 261 return true; 262 } 263 264 if (types.length && !types.includes(job.get('displayType.type'))) { 265 return false; 266 } 267 268 if (statuses.length && !statuses.includes(job.get('status'))) { 269 return false; 270 } 271 272 if ( 273 datacenters.length && 274 !job.get('datacenters').find((dc) => datacenters.includes(dc)) 275 ) { 276 return false; 277 } 278 279 if (nodePools.length && !nodePools.includes(job.get('nodePool'))) { 280 return false; 281 } 282 283 const name = job.get('name'); 284 if ( 285 prefixes.length && 286 !prefixes.find((prefix) => name.startsWith(prefix)) 287 ) { 288 return false; 289 } 290 291 return true; 292 }); 293 } 294 295 @alias('filteredJobs') listToSearch; 296 @alias('listSearched') listToSort; 297 @alias('listSorted') sortedJobs; 298 299 isShowingDeploymentDetails = false; 300 301 setFacetQueryParam(queryParam, selection) { 302 this.set(queryParam, serialize(selection)); 303 } 304 305 @action 306 goToRun() { 307 this.router.transitionTo('jobs.run'); 308 } 309 }