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

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