diff --git a/ui/app/adapters/application.js b/ui/app/adapters/application.js index b1504ff035d..13a24d0f2f7 100644 --- a/ui/app/adapters/application.js +++ b/ui/app/adapters/application.js @@ -73,32 +73,35 @@ export default RESTAdapter.extend({ // // This is the original implementation of _buildURL // without the pluralization of modelName - urlForFindRecord(id, modelName) { - let path; - let url = []; - let host = get(this, 'host'); - let prefix = this.urlPrefix(); - - if (modelName) { - path = modelName.camelize(); - if (path) { - url.push(path); - } - } + urlForFindRecord: urlForRecord, + urlForUpdateRecord: urlForRecord, +}); - if (id) { - url.push(encodeURIComponent(id)); - } +function urlForRecord(id, modelName) { + let path; + let url = []; + let host = get(this, 'host'); + let prefix = this.urlPrefix(); - if (prefix) { - url.unshift(prefix); + if (modelName) { + path = modelName.camelize(); + if (path) { + url.push(path); } + } - url = url.join('/'); - if (!host && url && url.charAt(0) !== '/') { - url = '/' + url; - } + if (id) { + url.push(encodeURIComponent(id)); + } - return url; - }, -}); + if (prefix) { + url.unshift(prefix); + } + + url = url.join('/'); + if (!host && url && url.charAt(0) !== '/') { + url = '/' + url; + } + + return url; +} diff --git a/ui/app/adapters/job.js b/ui/app/adapters/job.js index a8c09ce094b..efc18efea44 100644 --- a/ui/app/adapters/job.js +++ b/ui/app/adapters/job.js @@ -33,6 +33,12 @@ export default Watchable.extend({ return associateNamespace(url, namespace); }, + urlForUpdateRecord(id, type, hash) { + const [name, namespace] = JSON.parse(id); + let url = this._super(name, type, hash); + return associateNamespace(url, namespace); + }, + xhrKey(url, method, options = {}) { const plainKey = this._super(...arguments); const namespace = options.data && options.data.namespace; @@ -71,15 +77,19 @@ export default Watchable.extend({ }, plan(job) { - const url = addToPath(this.urlForFindRecord(job.get('id'), 'job'), '/plan'); + const jobId = job.get('id'); + const store = this.get('store'); + const url = addToPath(this.urlForFindRecord(jobId, 'job'), '/plan'); + return this.ajax(url, 'POST', { data: { Job: job.get('_newDefinitionJSON'), Diff: true, }, }).then(json => { - json.ID = job.get('id'); - this.get('store').pushPayload('job-plan', { jobPlans: [json] }); + json.ID = jobId; + store.pushPayload('job-plan', { jobPlans: [json] }); + return store.peekRecord('job-plan', jobId); }); }, @@ -92,6 +102,14 @@ export default Watchable.extend({ }, }); }, + + update(job) { + return this.ajax(this.urlForUpdateRecord(job.get('id'), 'job'), 'POST', { + data: { + Job: job.get('_newDefinitionJSON'), + }, + }); + }, }); function associateNamespace(url, namespace) { diff --git a/ui/app/components/job-editor.js b/ui/app/components/job-editor.js new file mode 100644 index 00000000000..2620954dce6 --- /dev/null +++ b/ui/app/components/job-editor.js @@ -0,0 +1,102 @@ +import Component from '@ember/component'; +import { assert } from '@ember/debug'; +import { inject as service } from '@ember/service'; +import { computed } from '@ember/object'; +import { task } from 'ember-concurrency'; +import messageFromAdapterError from 'nomad-ui/utils/message-from-adapter-error'; +import localStorageProperty from 'nomad-ui/utils/properties/local-storage'; + +export default Component.extend({ + store: service(), + config: service(), + + 'data-test-job-editor': true, + + job: null, + onSubmit() {}, + context: computed({ + get() { + return this.get('_context'); + }, + set(key, value) { + const allowedValues = ['new', 'edit']; + + assert(`context must be one of: ${allowedValues.join(', ')}`, allowedValues.includes(value)); + + this.set('_context', value); + return value; + }, + }), + + _context: null, + parseError: null, + planError: null, + runError: null, + + planOutput: null, + + showPlanMessage: localStorageProperty('nomadMessageJobPlan', true), + showEditorMessage: localStorageProperty('nomadMessageJobEditor', true), + + stage: computed('planOutput', function() { + return this.get('planOutput') ? 'plan' : 'editor'; + }), + + plan: task(function*() { + this.reset(); + + try { + yield this.get('job').parse(); + } catch (err) { + const error = messageFromAdapterError(err) || 'Could not parse input'; + this.set('parseError', error); + this.scrollToError(); + return; + } + + try { + const plan = yield this.get('job').plan(); + this.set('planOutput', plan); + } catch (err) { + const error = messageFromAdapterError(err) || 'Could not plan job'; + this.set('planError', error); + this.scrollToError(); + } + }).drop(), + + submit: task(function*() { + try { + if (this.get('context') === 'new') { + yield this.get('job').run(); + } else { + yield this.get('job').update(); + } + + const id = this.get('job.plainId'); + const namespace = this.get('job.namespace.name') || 'default'; + + this.reset(); + + // Treat the job as ephemeral and only provide ID parts. + this.get('onSubmit')(id, namespace); + } catch (err) { + const error = messageFromAdapterError(err) || 'Could not submit job'; + this.set('runError', error); + this.set('planOutput', null); + this.scrollToError(); + } + }), + + reset() { + this.set('planOutput', null); + this.set('planError', null); + this.set('parseError', null); + this.set('runError', null); + }, + + scrollToError() { + if (!this.get('config.isTest')) { + window.scrollTo(0, 0); + } + }, +}); diff --git a/ui/app/controllers/jobs/job/definition.js b/ui/app/controllers/jobs/job/definition.js index 7efede0aa5b..ff03ba64365 100644 --- a/ui/app/controllers/jobs/job/definition.js +++ b/ui/app/controllers/jobs/job/definition.js @@ -4,4 +4,22 @@ import { alias } from '@ember/object/computed'; export default Controller.extend(WithNamespaceResetting, { job: alias('model.job'), + definition: alias('model.definition'), + + isEditing: false, + + edit() { + this.get('job').set('_newDefinition', JSON.stringify(this.get('definition'), null, 2)); + this.set('isEditing', true); + }, + + onCancel() { + this.set('isEditing', false); + }, + + onSubmit(id, namespace) { + this.transitionToRoute('jobs.job', id, { + queryParams: { jobNamespace: namespace }, + }); + }, }); diff --git a/ui/app/controllers/jobs/run.js b/ui/app/controllers/jobs/run.js index 79525e4604c..baaf8418307 100644 --- a/ui/app/controllers/jobs/run.js +++ b/ui/app/controllers/jobs/run.js @@ -1,69 +1,9 @@ import Controller from '@ember/controller'; -import { inject as service } from '@ember/service'; -import { computed } from '@ember/object'; -import { task } from 'ember-concurrency'; -import messageFromAdapterError from 'nomad-ui/utils/message-from-adapter-error'; -import localStorageProperty from 'nomad-ui/utils/properties/local-storage'; export default Controller.extend({ - store: service(), - - parseError: null, - planError: null, - runError: null, - - planOutput: null, - - showPlanMessage: localStorageProperty('nomadMessageJobPlan', true), - showEditorMessage: localStorageProperty('nomadMessageJobEditor', true), - - stage: computed('planOutput', function() { - return this.get('planOutput') ? 'plan' : 'editor'; - }), - - plan: task(function*() { - this.reset(); - - try { - yield this.get('model').parse(); - } catch (err) { - const error = messageFromAdapterError(err) || 'Could not parse input'; - this.set('parseError', error); - return; - } - - try { - yield this.get('model').plan(); - const plan = this.get('store').peekRecord('job-plan', this.get('model.id')); - this.set('planOutput', plan); - } catch (err) { - const error = messageFromAdapterError(err) || 'Could not plan job'; - this.set('planError', error); - } - }).drop(), - - submit: task(function*() { - try { - yield this.get('model').run(); - - const id = this.get('model.plainId'); - const namespace = this.get('model.namespace.name') || 'default'; - - this.reset(); - - // navigate to the new job page - this.transitionToRoute('jobs.job', id, { - queryParams: { jobNamespace: namespace }, - }); - } catch (err) { - const error = messageFromAdapterError(err) || 'Could not submit job'; - this.set('runError', error); - } - }), - - reset() { - this.set('planOutput', null); - this.set('planError', null); - this.set('parseError', null); + onSubmit(id, namespace) { + this.transitionToRoute('jobs.job', id, { + queryParams: { jobNamespace: namespace }, + }); }, }); diff --git a/ui/app/models/job.js b/ui/app/models/job.js index fc9dfd55532..6db6ef67677 100644 --- a/ui/app/models/job.js +++ b/ui/app/models/job.js @@ -203,6 +203,11 @@ export default Model.extend({ return this.store.adapterFor('job').run(this); }, + update() { + assert('A job must be parsed before updated', this.get('_newDefinitionJSON')); + return this.store.adapterFor('job').update(this); + }, + parse() { const definition = this.get('_newDefinition'); let promise; @@ -211,7 +216,12 @@ export default Model.extend({ // If the definition is already JSON then it doesn't need to be parsed. const json = JSON.parse(definition); this.set('_newDefinitionJSON', json); - this.setIDByPayload(json); + + // You can't set the ID of a record that already exists + if (this.get('isNew')) { + this.setIdByPayload(json); + } + promise = RSVP.resolve(definition); } catch (err) { // If the definition is invalid JSON, assume it is HCL. If it is invalid @@ -221,14 +231,14 @@ export default Model.extend({ .parse(this.get('_newDefinition')) .then(response => { this.set('_newDefinitionJSON', response); - this.setIDByPayload(response); + this.setIdByPayload(response); }); } return promise; }, - setIDByPayload(payload) { + setIdByPayload(payload) { const namespace = payload.Namespace || 'default'; const id = payload.Name; @@ -241,6 +251,10 @@ export default Model.extend({ } }, + resetId() { + this.set('id', JSON.stringify([this.get('plainId'), this.get('namespace.name') || 'default'])); + }, + statusClass: computed('status', function() { const classMap = { pending: 'is-pending', diff --git a/ui/app/router.js b/ui/app/router.js index 7a4015c28e2..2c298fa7132 100644 --- a/ui/app/router.js +++ b/ui/app/router.js @@ -16,6 +16,7 @@ Router.map(function() { this.route('deployments'); this.route('evaluations'); this.route('allocations'); + this.route('edit'); }); }); diff --git a/ui/app/routes/jobs/job/definition.js b/ui/app/routes/jobs/job/definition.js index 8730f83b8de..f6294ebf5cc 100644 --- a/ui/app/routes/jobs/job/definition.js +++ b/ui/app/routes/jobs/job/definition.js @@ -8,4 +8,13 @@ export default Route.extend({ definition, })); }, + + resetController(controller, isExiting) { + if (isExiting) { + const job = controller.get('job'); + job.rollbackAttributes(); + job.resetId(); + controller.set('isEditing', false); + } + }, }); diff --git a/ui/app/services/watch-list.js b/ui/app/services/watch-list.js index 51154d6bd30..4387c40c43c 100644 --- a/ui/app/services/watch-list.js +++ b/ui/app/services/watch-list.js @@ -18,6 +18,6 @@ export default Service.extend({ }, setIndexFor(url, value) { - list[url] = value; + list[url] = +value; }, }); diff --git a/ui/app/styles/components/page-layout.scss b/ui/app/styles/components/page-layout.scss index 294f041ff96..26bcf732e9e 100644 --- a/ui/app/styles/components/page-layout.scss +++ b/ui/app/styles/components/page-layout.scss @@ -1,5 +1,5 @@ .page-layout { - height: 100%; + min-height: 100%; display: flex; flex-direction: column; diff --git a/ui/app/templates/components/distribution-bar.hbs b/ui/app/templates/components/distribution-bar.hbs index 4db55477f20..c5a5a546aec 100644 --- a/ui/app/templates/components/distribution-bar.hbs +++ b/ui/app/templates/components/distribution-bar.hbs @@ -15,7 +15,7 @@
{{parseError}}
+{{planError}}
+{{runError}}
+Paste or author HCL or JSON to submit to your cluster. A plan will be requested before the job is submitted.
+This is the impact running this job will have on your cluster.
+{{parseError}}
-{{planError}}
-{{runError}}
-Paste or author HCL or JSON to submit to your cluster. A plan will be requested before the job is submitted.
-This is the impact running this job will have on your cluster.
-