github.com/hernad/nomad@v1.6.112/ui/app/components/job-editor.js (about)

     1  /**
     2   * Copyright (c) HashiCorp, Inc.
     3   * SPDX-License-Identifier: MPL-2.0
     4   */
     5  
     6  // @ts-check
     7  import Component from '@glimmer/component';
     8  import { inject as service } from '@ember/service';
     9  import { action } from '@ember/object';
    10  import { task } from 'ember-concurrency';
    11  import messageFromAdapterError from 'nomad-ui/utils/message-from-adapter-error';
    12  import localStorageProperty from 'nomad-ui/utils/properties/local-storage';
    13  import { tracked } from '@glimmer/tracking';
    14  
    15  /**
    16   * JobEditor component that provides an interface for editing and managing Nomad jobs.
    17   *
    18   * @class JobEditor
    19   * @extends Component
    20   */
    21  export default class JobEditor extends Component {
    22    @service config;
    23    @service store;
    24    @service notifications;
    25  
    26    @tracked error = null;
    27    @tracked planOutput = null;
    28  
    29    /**
    30     * Initialize the component, setting the definition and definition variables on the model if available.
    31     */
    32    constructor() {
    33      super(...arguments);
    34  
    35      if (this.definition) {
    36        this.setDefinitionOnModel();
    37      }
    38  
    39      if (this.args.variables) {
    40        this.args.job.set(
    41          '_newDefinitionVariables',
    42          this.jsonToHcl(this.args.variables.flags).concat(
    43            this.args.variables.literal
    44          )
    45        );
    46      }
    47    }
    48  
    49    /**
    50     * Check if the component is in editing mode.
    51     *
    52     * @returns {boolean} True if the component is in 'new' or 'edit' context, otherwise false.
    53     */
    54    get isEditing() {
    55      return ['new', 'edit'].includes(this.args.context);
    56    }
    57  
    58    @action
    59    setDefinitionOnModel() {
    60      this.args.job.set('_newDefinition', this.definition);
    61    }
    62  
    63    /**
    64     * Enter the edit mode and defensively set the definition on the model.
    65     */
    66    @action
    67    edit() {
    68      this.setDefinitionOnModel();
    69      this.args.onToggleEdit(true);
    70    }
    71  
    72    @action
    73    onCancel() {
    74      this.args.onToggleEdit(false);
    75    }
    76  
    77    /**
    78     * Determine the current stage of the component based on the plan output and editing state.
    79     *
    80     * @returns {"review"|"edit"|"read"} The current stage, either 'review', 'edit', or 'read'.
    81     */
    82    get stage() {
    83      if (this.planOutput) return 'review';
    84      if (this.isEditing) return 'edit';
    85      else return 'read';
    86    }
    87  
    88    @localStorageProperty('nomadMessageJobPlan', true) shouldShowPlanMessage;
    89    @localStorageProperty('nomadShouldWrapCode', false) shouldWrapCode;
    90  
    91    @action
    92    dismissPlanMessage() {
    93      this.shouldShowPlanMessage = false;
    94    }
    95  
    96    /**
    97     * A task that performs the job parsing and planning.
    98     * On error, it calls the onError method.
    99     */
   100    @(task(function* () {
   101      this.reset();
   102  
   103      try {
   104        yield this.args.job.parse();
   105      } catch (err) {
   106        this.onError(err, 'parse', 'parse jobs');
   107        return;
   108      }
   109  
   110      try {
   111        const plan = yield this.args.job.plan();
   112        this.planOutput = plan;
   113      } catch (err) {
   114        this.onError(err, 'plan', 'plan jobs');
   115      }
   116    }).drop())
   117    plan;
   118  
   119    /**
   120     * A task that submits the job, either running a new job or updating an existing one.
   121     * On error, it calls the onError method and resets our planOutput state.
   122     */
   123    @task(function* () {
   124      try {
   125        if (this.args.context === 'new') {
   126          yield this.args.job.run();
   127        } else {
   128          yield this.args.job.update(this.args.format);
   129        }
   130  
   131        const id = this.args.job.plainId;
   132        const namespace = this.args.job.belongsTo('namespace').id() || 'default';
   133  
   134        this.reset();
   135  
   136        // Treat the job as ephemeral and only provide ID parts.
   137        this.args.onSubmit(id, namespace);
   138      } catch (err) {
   139        this.onError(err, 'run', 'submit jobs');
   140        this.planOutput = null;
   141      }
   142    })
   143    submit;
   144  
   145    /**
   146     * Handle errors, setting the error object and scrolling to the error message.
   147     *
   148     * @param {Error} err - The error object.
   149     * @param {"parse"|"plan"|"run"} type - The type of error (e.g., 'parse', 'plan', 'run').
   150     * @param {string} actionMsg - A message describing the action that caused the error.
   151     */
   152    onError(err, type, actionMsg) {
   153      const error = messageFromAdapterError(err, actionMsg);
   154      this.error = { message: error, type };
   155      this.scrollToError();
   156    }
   157  
   158    @action
   159    reset() {
   160      this.planOutput = null;
   161      this.error = null;
   162    }
   163  
   164    scrollToError() {
   165      if (!this.config.get('isTest')) {
   166        window.scrollTo(0, 0);
   167      }
   168    }
   169  
   170    /**
   171     * Update the job's definition or definition variables based on the provided type.
   172     *
   173     * @param {string} value - The new value for the job's definition or definition variables.
   174     * @param {_codemirror} _codemirror - The CodeMirror instance (not used in this action).
   175     * @param {"hclVariables"|"job"} [type='job'] - The type of code being updated ('job' or 'hclVariables').
   176     */
   177    @action
   178    updateCode(value, _codemirror, type = 'job') {
   179      if (!this.args.job.isDestroying && !this.args.job.isDestroyed) {
   180        if (type === 'hclVariables') {
   181          this.args.job.set('_newDefinitionVariables', value);
   182        } else {
   183          this.args.job.set('_newDefinition', value);
   184        }
   185      }
   186    }
   187  
   188    /**
   189     * Toggle the wrapping of the job's definition or definition variables.
   190     */
   191    @action
   192    toggleWrap() {
   193      this.shouldWrapCode = !this.shouldWrapCode;
   194    }
   195  
   196    /**
   197     * Read the content of an uploaded job specification file and update the job's definition.
   198     *
   199     * @param {Event} event - The input change event containing the selected file.
   200     */
   201    @action
   202    uploadJobSpec(event) {
   203      const reader = new FileReader();
   204      reader.onload = () => {
   205        this.updateCode(reader.result);
   206      };
   207  
   208      const [file] = event.target.files;
   209      reader.readAsText(file);
   210    }
   211  
   212    /**
   213     * Download the job's definition or specification as .nomad.hcl file locally
   214     */
   215    @action
   216    async handleSaveAsFile() {
   217      try {
   218        const blob = new Blob([this.args.job._newDefinition], {
   219          type: 'text/plain',
   220        });
   221        const url = window.URL.createObjectURL(blob);
   222        const downloadAnchor = document.createElement('a');
   223  
   224        downloadAnchor.href = url;
   225        downloadAnchor.target = '_blank';
   226        downloadAnchor.rel = 'noopener noreferrer';
   227        downloadAnchor.download = 'jobspec.nomad.hcl';
   228  
   229        downloadAnchor.click();
   230        downloadAnchor.remove();
   231  
   232        window.URL.revokeObjectURL(url);
   233        this.notifications.add({
   234          title: 'jobspec.nomad.hcl has been downloaded',
   235          color: 'success',
   236          icon: 'download',
   237        });
   238      } catch (err) {
   239        this.notifications.add({
   240          title: 'Error downloading file',
   241          message: err.message,
   242          color: 'critical',
   243          sticky: true,
   244        });
   245      }
   246    }
   247  
   248    /**
   249     * Get the definition or specification based on the view type.
   250     *
   251     * @returns {string} The definition or specification in JSON or HCL format.
   252     */
   253    get definition() {
   254      if (this.args.view === 'full-definition') {
   255        return JSON.stringify(this.args.definition, null, 2);
   256      } else {
   257        return this.args.specification;
   258      }
   259    }
   260  
   261    /**
   262     * Convert a JSON object to an HCL string.
   263     *
   264     * @param {Object} obj - The JSON object to convert.
   265     * @returns {string} The HCL string representation of the JSON object.
   266     */
   267    jsonToHcl(obj) {
   268      const hclLines = [];
   269  
   270      for (const key in obj) {
   271        const value = obj[key];
   272        const hclValue = typeof value === 'string' ? `"${value}"` : value;
   273        hclLines.push(`${key}=${hclValue}\n`);
   274      }
   275  
   276      return hclLines.join('\n');
   277    }
   278  
   279    get data() {
   280      return {
   281        cancelable: this.args.cancelable,
   282        definition: this.definition,
   283        format: this.args.format,
   284        hasSpecification: !!this.args.specification,
   285        hasVariables:
   286          !!this.args.variables?.flags || !!this.args.variables?.literal,
   287        job: this.args.job,
   288        planOutput: this.planOutput,
   289        shouldShowPlanMessage: this.shouldShowPlanMessage,
   290        view: this.args.view,
   291        shouldWrap: this.shouldWrapCode,
   292      };
   293    }
   294  
   295    get fns() {
   296      return {
   297        onCancel: this.onCancel,
   298        onDismissPlanMessage: this.dismissPlanMessage,
   299        onEdit: this.edit,
   300        onPlan: this.plan,
   301        onReset: this.reset,
   302        onSaveAs: this.args.handleSaveAsTemplate,
   303        onSaveFile: this.handleSaveAsFile,
   304        onSubmit: this.submit,
   305        onSelect: this.args.onSelect,
   306        onUpdate: this.updateCode,
   307        onUpload: this.uploadJobSpec,
   308        onToggleWrap: this.toggleWrap,
   309      };
   310    }
   311  }