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 }