github.com/hernad/nomad@v1.6.112/ui/app/models/job.js (about) 1 /** 2 * Copyright (c) HashiCorp, Inc. 3 * SPDX-License-Identifier: MPL-2.0 4 */ 5 6 import { alias, equal, or, and, mapBy } from '@ember/object/computed'; 7 import { computed } from '@ember/object'; 8 import Model from '@ember-data/model'; 9 import { attr, belongsTo, hasMany } from '@ember-data/model'; 10 import { fragment, fragmentArray } from 'ember-data-model-fragments/attributes'; 11 import RSVP from 'rsvp'; 12 import { assert } from '@ember/debug'; 13 import classic from 'ember-classic-decorator'; 14 15 const JOB_TYPES = ['service', 'batch', 'system', 'sysbatch']; 16 17 @classic 18 export default class Job extends Model { 19 @attr('string') region; 20 @attr('string') name; 21 @attr('string') plainId; 22 @attr('string') type; 23 @attr('number') priority; 24 @attr('boolean') allAtOnce; 25 26 @attr('string') status; 27 @attr('string') statusDescription; 28 @attr('number') createIndex; 29 @attr('number') modifyIndex; 30 @attr('date') submitTime; 31 @attr('string') nodePool; // Jobs are related to Node Pools either directly or via its Namespace, but no relationship. 32 33 @fragment('structured-attributes') meta; 34 35 get isPack() { 36 return !!this.meta?.structured?.pack; 37 } 38 39 // True when the job is the parent periodic or parameterized jobs 40 // Instances of periodic or parameterized jobs are false for both properties 41 @attr('boolean') periodic; 42 @attr('boolean') parameterized; 43 @attr('boolean') dispatched; 44 45 @attr() periodicDetails; 46 @attr() parameterizedDetails; 47 48 @computed('plainId') 49 get idWithNamespace() { 50 return `${this.plainId}@${this.belongsTo('namespace').id() ?? 'default'}`; 51 } 52 53 @computed('periodic', 'parameterized', 'dispatched') 54 get hasChildren() { 55 return this.periodic || (this.parameterized && !this.dispatched); 56 } 57 58 @computed('type') 59 get hasClientStatus() { 60 return this.type === 'system' || this.type === 'sysbatch'; 61 } 62 63 @belongsTo('job', { inverse: 'children' }) parent; 64 @hasMany('job', { inverse: 'parent' }) children; 65 66 // The parent job name is prepended to child launch job names 67 @computed('name', 'parent.content') 68 get trimmedName() { 69 return this.get('parent.content') 70 ? this.name.replace(/.+?\//, '') 71 : this.name; 72 } 73 74 // A composite of type and other job attributes to determine 75 // a better type descriptor for human interpretation rather 76 // than for scheduling. 77 @computed('isPack', 'type', 'periodic', 'parameterized') 78 get displayType() { 79 if (this.periodic) { 80 return { type: 'periodic', isPack: this.isPack }; 81 } else if (this.parameterized) { 82 return { type: 'parameterized', isPack: this.isPack }; 83 } 84 return { type: this.type, isPack: this.isPack }; 85 } 86 87 // A composite of type and other job attributes to determine 88 // type for templating rather than scheduling 89 @computed( 90 'type', 91 'periodic', 92 'parameterized', 93 'parent.{periodic,parameterized}' 94 ) 95 get templateType() { 96 const type = this.type; 97 98 if (this.get('parent.periodic')) { 99 return 'periodic-child'; 100 } else if (this.get('parent.parameterized')) { 101 return 'parameterized-child'; 102 } else if (this.periodic) { 103 return 'periodic'; 104 } else if (this.parameterized) { 105 return 'parameterized'; 106 } else if (JOB_TYPES.includes(type)) { 107 // Guard against the API introducing a new type before the UI 108 // is prepared to handle it. 109 return this.type; 110 } 111 112 // A fail-safe in the event the API introduces a new type. 113 return 'service'; 114 } 115 116 @attr() datacenters; 117 @fragmentArray('task-group', { defaultValue: () => [] }) taskGroups; 118 @belongsTo('job-summary') summary; 119 120 // A job model created from the jobs list response will be lacking 121 // task groups. This is an indicator that it needs to be reloaded 122 // if task group information is important. 123 @equal('taskGroups.length', 0) isPartial; 124 125 // If a job has only been loaded through the list request, the task groups 126 // are still unknown. However, the count of task groups is available through 127 // the job-summary model which is embedded in the jobs list response. 128 @or('taskGroups.length', 'taskGroupSummaries.length') taskGroupCount; 129 130 // Alias through to the summary, as if there was no relationship 131 @alias('summary.taskGroupSummaries') taskGroupSummaries; 132 @alias('summary.queuedAllocs') queuedAllocs; 133 @alias('summary.startingAllocs') startingAllocs; 134 @alias('summary.runningAllocs') runningAllocs; 135 @alias('summary.completeAllocs') completeAllocs; 136 @alias('summary.failedAllocs') failedAllocs; 137 @alias('summary.lostAllocs') lostAllocs; 138 @alias('summary.unknownAllocs') unknownAllocs; 139 @alias('summary.totalAllocs') totalAllocs; 140 @alias('summary.pendingChildren') pendingChildren; 141 @alias('summary.runningChildren') runningChildren; 142 @alias('summary.deadChildren') deadChildren; 143 @alias('summary.totalChildren') totalChildren; 144 145 @attr('number') version; 146 147 @hasMany('job-versions') versions; 148 @hasMany('allocations') allocations; 149 @hasMany('deployments') deployments; 150 @hasMany('evaluations') evaluations; 151 @hasMany('variables') variables; 152 @belongsTo('namespace') namespace; 153 @belongsTo('job-scale') scaleState; 154 @hasMany('services') services; 155 156 @hasMany('recommendation-summary') recommendationSummaries; 157 158 @computed('taskGroups.@each.drivers') 159 get drivers() { 160 return this.taskGroups 161 .mapBy('drivers') 162 .reduce((all, drivers) => { 163 all.push(...drivers); 164 return all; 165 }, []) 166 .uniq(); 167 } 168 169 @mapBy('allocations', 'unhealthyDrivers') allocationsUnhealthyDrivers; 170 171 // Getting all unhealthy drivers for a job can be incredibly expensive if the job 172 // has many allocations. This can lead to making an API request for many nodes. 173 @computed('allocations', 'allocationsUnhealthyDrivers.[]') 174 get unhealthyDrivers() { 175 return this.allocations 176 .mapBy('unhealthyDrivers') 177 .reduce((all, drivers) => { 178 all.push(...drivers); 179 return all; 180 }, []) 181 .uniq(); 182 } 183 184 @computed('evaluations.@each.isBlocked') 185 get hasBlockedEvaluation() { 186 return this.evaluations 187 .toArray() 188 .some((evaluation) => evaluation.get('isBlocked')); 189 } 190 191 @and('latestFailureEvaluation', 'hasBlockedEvaluation') hasPlacementFailures; 192 193 @computed('evaluations.{@each.modifyIndex,isPending}') 194 get latestEvaluation() { 195 const evaluations = this.evaluations; 196 if (!evaluations || evaluations.get('isPending')) { 197 return null; 198 } 199 return evaluations.sortBy('modifyIndex').get('lastObject'); 200 } 201 202 @computed('evaluations.{@each.modifyIndex,isPending}') 203 get latestFailureEvaluation() { 204 const evaluations = this.evaluations; 205 if (!evaluations || evaluations.get('isPending')) { 206 return null; 207 } 208 209 const failureEvaluations = evaluations.filterBy('hasPlacementFailures'); 210 if (failureEvaluations) { 211 return failureEvaluations.sortBy('modifyIndex').get('lastObject'); 212 } 213 214 return undefined; 215 } 216 217 @equal('type', 'service') supportsDeployments; 218 219 @belongsTo('deployment', { inverse: 'jobForLatest' }) latestDeployment; 220 221 @computed('latestDeployment', 'latestDeployment.isRunning') 222 get runningDeployment() { 223 const latest = this.latestDeployment; 224 if (latest.get('isRunning')) return latest; 225 return undefined; 226 } 227 228 fetchRawDefinition() { 229 return this.store.adapterFor('job').fetchRawDefinition(this); 230 } 231 232 fetchRawSpecification() { 233 return this.store.adapterFor('job').fetchRawSpecification(this); 234 } 235 236 forcePeriodic() { 237 return this.store.adapterFor('job').forcePeriodic(this); 238 } 239 240 stop() { 241 return this.store.adapterFor('job').stop(this); 242 } 243 244 purge() { 245 return this.store.adapterFor('job').purge(this); 246 } 247 248 plan() { 249 assert('A job must be parsed before planned', this._newDefinitionJSON); 250 return this.store.adapterFor('job').plan(this); 251 } 252 253 run() { 254 assert('A job must be parsed before ran', this._newDefinitionJSON); 255 return this.store.adapterFor('job').run(this); 256 } 257 258 update() { 259 assert('A job must be parsed before updated', this._newDefinitionJSON); 260 261 return this.store.adapterFor('job').update(this); 262 } 263 264 parse() { 265 const definition = this._newDefinition; 266 const variables = this._newDefinitionVariables; 267 let promise; 268 269 try { 270 // If the definition is already JSON then it doesn't need to be parsed. 271 const json = JSON.parse(definition); 272 this.set('_newDefinitionJSON', json); 273 274 // You can't set the ID of a record that already exists 275 if (this.isNew) { 276 this.setIdByPayload(json); 277 } 278 279 promise = RSVP.resolve(definition); 280 } catch (err) { 281 // If the definition is invalid JSON, assume it is HCL. If it is invalid 282 // in anyway, the parse endpoint will throw an error. 283 284 promise = this.store 285 .adapterFor('job') 286 .parse(this._newDefinition, variables) 287 .then((response) => { 288 this.set('_newDefinitionJSON', response); 289 this.setIdByPayload(response); 290 }); 291 } 292 293 return promise; 294 } 295 296 scale(group, count, message) { 297 if (message == null) 298 message = `Manually scaled to ${count} from the Nomad UI`; 299 return this.store.adapterFor('job').scale(this, group, count, message); 300 } 301 302 dispatch(meta, payload) { 303 return this.store.adapterFor('job').dispatch(this, meta, payload); 304 } 305 306 setIdByPayload(payload) { 307 const namespace = payload.Namespace || 'default'; 308 const id = payload.Name; 309 310 this.set('plainId', id); 311 this.set('_idBeforeSaving', JSON.stringify([id, namespace])); 312 313 const namespaceRecord = this.store.peekRecord('namespace', namespace); 314 if (namespaceRecord) { 315 this.set('namespace', namespaceRecord); 316 } 317 } 318 319 resetId() { 320 this.set( 321 'id', 322 JSON.stringify([this.plainId, this.get('namespace.name') || 'default']) 323 ); 324 } 325 326 @computed('status') 327 get statusClass() { 328 const classMap = { 329 pending: 'is-pending', 330 running: 'is-primary', 331 dead: 'is-light', 332 }; 333 334 return classMap[this.status] || 'is-dark'; 335 } 336 337 @attr('string') payload; 338 339 @computed('payload') 340 get decodedPayload() { 341 // Lazily decode the base64 encoded payload 342 return window.atob(this.payload || ''); 343 } 344 345 // An arbitrary HCL or JSON string that is used by the serializer to plan 346 // and run this job. Used for both new job models and saved job models. 347 @attr('string') _newDefinition; 348 349 // An arbitrary JSON string that is used by the adapter to plan 350 // and run this job. Used for both new job models and saved job models. 351 @attr('string') _newDefinitionVariables; 352 353 // The new definition may be HCL, in which case the API will need to parse the 354 // spec first. In order to preserve both the original HCL and the parsed response 355 // that will be submitted to the create job endpoint, another prop is necessary. 356 @attr('string') _newDefinitionJSON; 357 358 @computed('variables', 'parent', 'plainId') 359 get pathLinkedVariable() { 360 if (this.parent.get('id')) { 361 return this.variables?.findBy( 362 'path', 363 `nomad/jobs/${JSON.parse(this.parent.get('id'))[0]}` 364 ); 365 } else { 366 return this.variables?.findBy('path', `nomad/jobs/${this.plainId}`); 367 } 368 } 369 }