diff --git a/ui/app/adapters/application.js b/ui/app/adapters/application.js index b1504ff035de..13a24d0f2f76 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/deployment.js b/ui/app/adapters/deployment.js new file mode 100644 index 000000000000..6dbc20dbd382 --- /dev/null +++ b/ui/app/adapters/deployment.js @@ -0,0 +1,29 @@ +import Watchable from './watchable'; + +export default Watchable.extend({ + promote(deployment) { + const id = deployment.get('id'); + const url = urlForAction(this.urlForFindRecord(id, 'deployment'), '/promote'); + return this.ajax(url, 'POST', { + data: { + DeploymentId: id, + All: true, + }, + }); + }, +}); + +// The deployment action API endpoints all end with the ID +// /deployment/:action/:deployment_id instead of /deployment/:deployment_id/:action +function urlForAction(url, extension = '') { + const [path, params] = url.split('?'); + const pathParts = path.split('/'); + const idPart = pathParts.pop(); + let newUrl = `${pathParts.join('/')}${extension}/${idPart}`; + + if (params) { + newUrl += `?${params}`; + } + + return newUrl; +} diff --git a/ui/app/adapters/job.js b/ui/app/adapters/job.js index e130478155af..efc18efea449 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; @@ -59,6 +65,51 @@ export default Watchable.extend({ const url = this.urlForFindRecord(job.get('id'), 'job'); return this.ajax(url, 'DELETE'); }, + + parse(spec) { + const url = addToPath(this.urlForFindAll('job'), '/parse'); + return this.ajax(url, 'POST', { + data: { + JobHCL: spec, + Canonicalize: true, + }, + }); + }, + + plan(job) { + 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 = jobId; + store.pushPayload('job-plan', { jobPlans: [json] }); + return store.peekRecord('job-plan', jobId); + }); + }, + + // Running a job doesn't follow REST create semantics so it's easier to + // treat it as an action. + run(job) { + return this.ajax(this.urlForCreateRecord('job'), 'POST', { + data: { + Job: job.get('_newDefinitionJSON'), + }, + }); + }, + + 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 000000000000..2620954dce6f --- /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/components/job-page/parts/latest-deployment.js b/ui/app/components/job-page/parts/latest-deployment.js index 72ea12f8ec83..b68978520d13 100644 --- a/ui/app/components/job-page/parts/latest-deployment.js +++ b/ui/app/components/job-page/parts/latest-deployment.js @@ -1,8 +1,27 @@ import Component from '@ember/component'; +import { task } from 'ember-concurrency'; +import messageFromAdapterError from 'nomad-ui/utils/message-from-adapter-error'; export default Component.extend({ job: null, tagName: '', + handleError() {}, + isShowingDeploymentDetails: false, + + promote: task(function*() { + try { + yield this.get('job.latestDeployment.content').promote(); + } catch (err) { + let message = messageFromAdapterError(err); + if (!message || message === 'Forbidden') { + message = 'Your ACL token does not grant permission to promote deployments.'; + } + this.get('handleError')({ + title: 'Could Not Promote Deployment', + description: message, + }); + } + }), }); diff --git a/ui/app/components/job-page/parts/title.js b/ui/app/components/job-page/parts/title.js index fb8e3232c007..ba328462726b 100644 --- a/ui/app/components/job-page/parts/title.js +++ b/ui/app/components/job-page/parts/title.js @@ -1,4 +1,6 @@ import Component from '@ember/component'; +import { task } from 'ember-concurrency'; +import messageFromAdapterError from 'nomad-ui/utils/message-from-adapter-error'; export default Component.extend({ tagName: '', @@ -8,16 +10,42 @@ export default Component.extend({ handleError() {}, - actions: { - stopJob() { - this.get('job') - .stop() - .catch(() => { - this.get('handleError')({ - title: 'Could Not Stop Job', - description: 'Your ACL token does not grant permission to stop jobs.', - }); - }); - }, - }, + stopJob: task(function*() { + try { + const job = this.get('job'); + yield job.stop(); + // Eagerly update the job status to avoid flickering + this.job.set('status', 'dead'); + } catch (err) { + this.get('handleError')({ + title: 'Could Not Stop Job', + description: 'Your ACL token does not grant permission to stop jobs.', + }); + } + }), + + startJob: task(function*() { + const job = this.get('job'); + const definition = yield job.fetchRawDefinition(); + + delete definition.Stop; + job.set('_newDefinition', JSON.stringify(definition)); + + try { + yield job.parse(); + yield job.update(); + // Eagerly update the job status to avoid flickering + job.set('status', 'running'); + } catch (err) { + let message = messageFromAdapterError(err); + if (!message || message === 'Forbidden') { + message = 'Your ACL token does not grant permission to stop jobs.'; + } + + this.get('handleError')({ + title: 'Could Not Start Job', + description: message, + }); + } + }), }); diff --git a/ui/app/components/placement-failure.js b/ui/app/components/placement-failure.js new file mode 100644 index 000000000000..b8052235f1e1 --- /dev/null +++ b/ui/app/components/placement-failure.js @@ -0,0 +1,10 @@ +import Component from '@ember/component'; +import { or } from '@ember/object/computed'; + +export default Component.extend({ + // Either provide a taskGroup or a failedTGAlloc + taskGroup: null, + failedTGAlloc: null, + + placementFailures: or('taskGroup.placementFailures', 'failedTGAlloc'), +}); diff --git a/ui/app/components/two-step-button.js b/ui/app/components/two-step-button.js index 7016e96ec24f..fe40c16f244c 100644 --- a/ui/app/components/two-step-button.js +++ b/ui/app/components/two-step-button.js @@ -1,5 +1,6 @@ import Component from '@ember/component'; import { equal } from '@ember/object/computed'; +import RSVP from 'rsvp'; export default Component.extend({ classNames: ['two-step-button'], @@ -8,6 +9,7 @@ export default Component.extend({ cancelText: '', confirmText: '', confirmationMessage: '', + awaitingConfirmation: false, onConfirm() {}, onCancel() {}, @@ -22,5 +24,10 @@ export default Component.extend({ promptForConfirmation() { this.set('state', 'prompt'); }, + confirm() { + RSVP.resolve(this.get('onConfirm')()).then(() => { + this.send('setToIdle'); + }); + }, }, }); diff --git a/ui/app/controllers/jobs/job/definition.js b/ui/app/controllers/jobs/job/definition.js index 7efede0aa5bd..ff03ba643653 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 new file mode 100644 index 000000000000..baaf84183074 --- /dev/null +++ b/ui/app/controllers/jobs/run.js @@ -0,0 +1,9 @@ +import Controller from '@ember/controller'; + +export default Controller.extend({ + onSubmit(id, namespace) { + this.transitionToRoute('jobs.job', id, { + queryParams: { jobNamespace: namespace }, + }); + }, +}); diff --git a/ui/app/models/deployment.js b/ui/app/models/deployment.js index 5d86e9296e7a..e3fefb94fb92 100644 --- a/ui/app/models/deployment.js +++ b/ui/app/models/deployment.js @@ -1,5 +1,6 @@ import { alias, equal } from '@ember/object/computed'; import { computed } from '@ember/object'; +import { assert } from '@ember/debug'; import Model from 'ember-data/model'; import attr from 'ember-data/attr'; import { belongsTo, hasMany } from 'ember-data/relationships'; @@ -58,4 +59,9 @@ export default Model.extend({ return classMap[this.get('status')] || 'is-dark'; }), + + promote() { + assert('A deployment needs to requirePromotion to be promoted', this.get('requiresPromotion')); + return this.store.adapterFor('deployment').promote(this); + }, }); diff --git a/ui/app/models/job-plan.js b/ui/app/models/job-plan.js new file mode 100644 index 000000000000..8f9c10345f1b --- /dev/null +++ b/ui/app/models/job-plan.js @@ -0,0 +1,8 @@ +import Model from 'ember-data/model'; +import attr from 'ember-data/attr'; +import { fragmentArray } from 'ember-data-model-fragments/attributes'; + +export default Model.extend({ + diff: attr(), + failedTGAllocs: fragmentArray('placement-failure', { defaultValue: () => [] }), +}); diff --git a/ui/app/models/job.js b/ui/app/models/job.js index b69040eaaa78..6db6ef676779 100644 --- a/ui/app/models/job.js +++ b/ui/app/models/job.js @@ -4,6 +4,8 @@ import Model from 'ember-data/model'; import attr from 'ember-data/attr'; import { belongsTo, hasMany } from 'ember-data/relationships'; import { fragmentArray } from 'ember-data-model-fragments/attributes'; +import RSVP from 'rsvp'; +import { assert } from '@ember/debug'; const JOB_TYPES = ['service', 'batch', 'system']; @@ -191,6 +193,68 @@ export default Model.extend({ return this.store.adapterFor('job').stop(this); }, + plan() { + assert('A job must be parsed before planned', this.get('_newDefinitionJSON')); + return this.store.adapterFor('job').plan(this); + }, + + run() { + assert('A job must be parsed before ran', this.get('_newDefinitionJSON')); + 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; + + try { + // If the definition is already JSON then it doesn't need to be parsed. + const json = JSON.parse(definition); + this.set('_newDefinitionJSON', 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 + // in anyway, the parse endpoint will throw an error. + promise = this.store + .adapterFor('job') + .parse(this.get('_newDefinition')) + .then(response => { + this.set('_newDefinitionJSON', response); + this.setIdByPayload(response); + }); + } + + return promise; + }, + + setIdByPayload(payload) { + const namespace = payload.Namespace || 'default'; + const id = payload.Name; + + this.set('plainId', id); + this.set('id', JSON.stringify([id, namespace])); + + const namespaceRecord = this.store.peekRecord('namespace', namespace); + if (namespaceRecord) { + this.set('namespace', namespaceRecord); + } + }, + + resetId() { + this.set('id', JSON.stringify([this.get('plainId'), this.get('namespace.name') || 'default'])); + }, + statusClass: computed('status', function() { const classMap = { pending: 'is-pending', @@ -206,4 +270,13 @@ export default Model.extend({ // Lazily decode the base64 encoded payload return window.atob(this.get('payload') || ''); }), + + // An arbitrary HCL or JSON string that is used by the serializer to plan + // and run this job. Used for both new job models and saved job models. + _newDefinition: attr('string'), + + // The new definition may be HCL, in which case the API will need to parse the + // spec first. In order to preserve both the original HCL and the parsed response + // that will be submitted to the create job endpoint, another prop is necessary. + _newDefinitionJSON: attr('string'), }); diff --git a/ui/app/router.js b/ui/app/router.js index 39446ffa9583..2c298fa7132c 100644 --- a/ui/app/router.js +++ b/ui/app/router.js @@ -8,6 +8,7 @@ const Router = EmberRouter.extend({ Router.map(function() { this.route('jobs', function() { + this.route('run'); this.route('job', { path: '/:job_name' }, function() { this.route('task-group', { path: '/:name' }); this.route('definition'); @@ -15,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 8730f83b8de6..f6294ebf5ccc 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/routes/jobs/run.js b/ui/app/routes/jobs/run.js new file mode 100644 index 000000000000..121e9b4ce9e3 --- /dev/null +++ b/ui/app/routes/jobs/run.js @@ -0,0 +1,26 @@ +import Route from '@ember/routing/route'; +import { inject as service } from '@ember/service'; + +export default Route.extend({ + store: service(), + system: service(), + + breadcrumbs: [ + { + label: 'Run', + args: ['jobs.run'], + }, + ], + + model() { + return this.get('store').createRecord('job', { + namespace: this.get('system.activeNamespace'), + }); + }, + + resetController(controller, isExiting) { + if (isExiting) { + controller.get('model').deleteRecord(); + } + }, +}); diff --git a/ui/app/serializers/job-plan.js b/ui/app/serializers/job-plan.js new file mode 100644 index 000000000000..0280734383df --- /dev/null +++ b/ui/app/serializers/job-plan.js @@ -0,0 +1,12 @@ +import { get } from '@ember/object'; +import { assign } from '@ember/polyfills'; +import ApplicationSerializer from './application'; + +export default ApplicationSerializer.extend({ + normalize(typeHash, hash) { + hash.FailedTGAllocs = Object.keys(hash.FailedTGAllocs || {}).map(key => { + return assign({ Name: key }, get(hash, `FailedTGAllocs.${key}`) || {}); + }); + return this._super(...arguments); + }, +}); diff --git a/ui/app/services/watch-list.js b/ui/app/services/watch-list.js index 51154d6bd307..4387c40c43c8 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/codemirror.scss b/ui/app/styles/components/codemirror.scss index 59f4d755b322..81c08356e679 100644 --- a/ui/app/styles/components/codemirror.scss +++ b/ui/app/styles/components/codemirror.scss @@ -4,6 +4,10 @@ $dark-bright: lighten($dark, 15%); height: auto; } +.CodeMirror-scroll { + min-height: 500px; +} + .cm-s-hashi, .cm-s-hashi-read-only { &.CodeMirror { @@ -39,7 +43,7 @@ $dark-bright: lighten($dark, 15%); } span.cm-comment { - color: $grey-light; + color: $grey; } span.cm-string, diff --git a/ui/app/styles/components/page-layout.scss b/ui/app/styles/components/page-layout.scss index 294f041ff96f..26bcf732e9ea 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/styles/core.scss b/ui/app/styles/core.scss index e4391415b276..a92c17adea10 100644 --- a/ui/app/styles/core.scss +++ b/ui/app/styles/core.scss @@ -1,34 +1,35 @@ // Utils -@import "./utils/reset.scss"; -@import "./utils/z-indices"; -@import "./utils/product-colors"; -@import "./utils/bumper"; +@import './utils/reset.scss'; +@import './utils/z-indices'; +@import './utils/product-colors'; +@import './utils/bumper'; +@import './utils/layout'; // Start with Bulma variables as a foundation -@import "bulma/sass/utilities/initial-variables"; +@import 'bulma/sass/utilities/initial-variables'; // Override variables where appropriate -@import "./core/variables.scss"; +@import './core/variables.scss'; // Bring in the rest of Bulma -@import "bulma/bulma"; +@import 'bulma/bulma'; // Override Bulma details where appropriate -@import "./core/buttons"; -@import "./core/breadcrumb"; -@import "./core/columns"; -@import "./core/forms"; -@import "./core/icon"; -@import "./core/level"; -@import "./core/menu"; -@import "./core/message"; -@import "./core/navbar"; -@import "./core/notification"; -@import "./core/pagination"; -@import "./core/progress"; -@import "./core/section"; -@import "./core/table"; -@import "./core/tabs"; -@import "./core/tag"; -@import "./core/title"; -@import "./core/typography"; +@import './core/buttons'; +@import './core/breadcrumb'; +@import './core/columns'; +@import './core/forms'; +@import './core/icon'; +@import './core/level'; +@import './core/menu'; +@import './core/message'; +@import './core/navbar'; +@import './core/notification'; +@import './core/pagination'; +@import './core/progress'; +@import './core/section'; +@import './core/table'; +@import './core/tabs'; +@import './core/tag'; +@import './core/title'; +@import './core/typography'; diff --git a/ui/app/styles/utils/layout.scss b/ui/app/styles/utils/layout.scss new file mode 100644 index 000000000000..6504a68cfc2b --- /dev/null +++ b/ui/app/styles/utils/layout.scss @@ -0,0 +1,3 @@ +.is-associative { + margin-top: -0.75em; +} diff --git a/ui/app/templates/components/distribution-bar.hbs b/ui/app/templates/components/distribution-bar.hbs index 4db55477f203..c5a5a546aece 100644 --- a/ui/app/templates/components/distribution-bar.hbs +++ b/ui/app/templates/components/distribution-bar.hbs @@ -15,7 +15,7 @@
    {{#each _data as |datum index|}} -
  1. +
  2. {{datum.label}} diff --git a/ui/app/templates/components/freestyle/sg-two-step-button.hbs b/ui/app/templates/components/freestyle/sg-two-step-button.hbs index f39096b0e1e1..74f43919fbd7 100644 --- a/ui/app/templates/components/freestyle/sg-two-step-button.hbs +++ b/ui/app/templates/components/freestyle/sg-two-step-button.hbs @@ -20,3 +20,21 @@
{{/freestyle-usage}} + +{{#freestyle-usage "two-step-button-loading" title="Two Step Button Loading State"}} +
+

+ This is a page title + {{two-step-button + idleText="Scary Action" + cancelText="Nvm" + confirmText="Yep" + confirmationMessage="Wait, really? Like...seriously?" + awaitingConfirmation=true + state="prompt"}} +

+
+{{/freestyle-usage}} +{{#freestyle-annotation}} + Note: the state property is internal state and only used here to bypass the idle state for demonstration purposes. +{{/freestyle-annotation}} diff --git a/ui/app/templates/components/job-diff.hbs b/ui/app/templates/components/job-diff.hbs index 543311238879..e87a91e732e8 100644 --- a/ui/app/templates/components/job-diff.hbs +++ b/ui/app/templates/components/job-diff.hbs @@ -80,12 +80,12 @@ Task: "{{task.Name}}" {{#if task.Annotations}} - ({{#each task.Annotations as |annotation index|}} + ({{~#each task.Annotations as |annotation index|}} {{annotation}} - {{#unless (eq index (dec annotations.length))}},{{/unless}} - {{/each}}) + {{#unless (eq index (dec task.Annotations.length))}},{{/unless}} + {{/each~}}) {{/if}} - {{#if (or verbose (eq (lowercase task.Type "edited")))}} + {{#if (or verbose (eq (lowercase task.Type) "edited"))}} {{job-diff-fields-and-objects fields=task.Fields objects=task.Objects}} {{/if}} diff --git a/ui/app/templates/components/job-editor.hbs b/ui/app/templates/components/job-editor.hbs new file mode 100644 index 000000000000..8f85ebc5859e --- /dev/null +++ b/ui/app/templates/components/job-editor.hbs @@ -0,0 +1,95 @@ +{{#if parseError}} +
+

Parse Error

+

{{parseError}}

+
+{{/if}} +{{#if planError}} +
+

Plan Error

+

{{planError}}

+
+{{/if}} +{{#if runError}} +
+

Run Error

+

{{runError}}

+
+{{/if}} + +{{#if (eq stage "editor")}} + {{#if (and showEditorMessage (eq context "new"))}} +
+
+
+

Run a Job

+

Paste or author HCL or JSON to submit to your cluster. A plan will be requested before the job is submitted.

+
+
+ +
+
+
+ {{/if}} +
+
+ Job Definition + {{#if cancelable}} + + {{/if}} +
+
+ {{ivy-codemirror + data-test-editor + value=(or job._newDefinition jobSpec) + valueUpdated=(action (mut job._newDefinition)) + options=(hash + mode="javascript" + theme="hashi" + tabSize=2 + lineNumbers=true + )}} +
+
+
+ +
+{{/if}} + +{{#if (eq stage "plan")}} + {{#if showPlanMessage}} +
+
+
+

Job Plan

+

This is the impact running this job will have on your cluster.

+
+
+ +
+
+
+ {{/if}} +
+
Job Plan
+
+ {{job-diff data-test-plan-output diff=planOutput.diff verbose=false}} +
+
+
+
Scheduler dry-run
+
+ {{#if planOutput.failedTGAllocs}} + {{#each planOutput.failedTGAllocs as |placementFailure|}} + {{placement-failure failedTGAlloc=placementFailure}} + {{/each}} + {{else}} + All tasks successfully allocated. + {{/if}} +
+
+
+ + +
+{{/if}} diff --git a/ui/app/templates/components/job-page/parts/latest-deployment.hbs b/ui/app/templates/components/job-page/parts/latest-deployment.hbs index b67f0bd0b846..a2a3aef3e6e3 100644 --- a/ui/app/templates/components/job-page/parts/latest-deployment.hbs +++ b/ui/app/templates/components/job-page/parts/latest-deployment.hbs @@ -13,7 +13,12 @@ {{job.latestDeployment.status}} {{#if job.latestDeployment.requiresPromotion}} - Deployment is running but requires promotion + {{/if}} diff --git a/ui/app/templates/components/job-page/parts/recent-allocations.hbs b/ui/app/templates/components/job-page/parts/recent-allocations.hbs index 4a0c22965b05..062f06cc2f64 100644 --- a/ui/app/templates/components/job-page/parts/recent-allocations.hbs +++ b/ui/app/templates/components/job-page/parts/recent-allocations.hbs @@ -2,7 +2,7 @@
Recent Allocations
-
+
{{#if job.allocations.length}} {{#list-table source=sortedAllocations diff --git a/ui/app/templates/components/job-page/parts/summary.hbs b/ui/app/templates/components/job-page/parts/summary.hbs index 38e9a1b68d56..0bacf5c52de8 100644 --- a/ui/app/templates/components/job-page/parts/summary.hbs +++ b/ui/app/templates/components/job-page/parts/summary.hbs @@ -39,7 +39,7 @@ class="split-view" as |chart|}}
    {{#each chart.data as |datum index|}} -
  1. +
  2. {{datum.value}} diff --git a/ui/app/templates/components/job-page/parts/title.hbs b/ui/app/templates/components/job-page/parts/title.hbs index 445640d760a5..131b06e56279 100644 --- a/ui/app/templates/components/job-page/parts/title.hbs +++ b/ui/app/templates/components/job-page/parts/title.hbs @@ -9,6 +9,16 @@ cancelText="Cancel" confirmText="Yes, Stop" confirmationMessage="Are you sure you want to stop this job?" - onConfirm=(action "stopJob")}} + awaitingConfirmation=stopJob.isRunning + onConfirm=(perform stopJob)}} + {{else}} + {{two-step-button + data-test-start + idleText="Start" + cancelText="Cancel" + confirmText="Yes, Start" + confirmationMessage="Are you sure you want to start this job?" + awaitingConfirmation=startJob.isRunning + onConfirm=(perform startJob)}} {{/if}} diff --git a/ui/app/templates/components/job-page/service.hbs b/ui/app/templates/components/job-page/service.hbs index 6a921beb8bb6..cbef54c1f248 100644 --- a/ui/app/templates/components/job-page/service.hbs +++ b/ui/app/templates/components/job-page/service.hbs @@ -17,7 +17,7 @@ {{job-page/parts/placement-failures job=job}} - {{job-page/parts/latest-deployment job=job}} + {{job-page/parts/latest-deployment job=job handleError=(action "handleError")}} {{job-page/parts/task-groups job=job diff --git a/ui/app/templates/components/placement-failure.hbs b/ui/app/templates/components/placement-failure.hbs index 63f49a826488..9cf7089ecbd0 100644 --- a/ui/app/templates/components/placement-failure.hbs +++ b/ui/app/templates/components/placement-failure.hbs @@ -1,7 +1,7 @@ -{{#if taskGroup.placementFailures}} - {{#with taskGroup.placementFailures as |failures|}} +{{#if placementFailures}} + {{#with placementFailures as |failures|}}

    - {{taskGroup.name}} + {{placementFailures.name}} {{inc failures.coalescedFailures}} unplaced

      @@ -37,4 +37,3 @@
    {{/with}} {{/if}} - diff --git a/ui/app/templates/components/two-step-button.hbs b/ui/app/templates/components/two-step-button.hbs index e9fe906b59e8..1b95184fea51 100644 --- a/ui/app/templates/components/two-step-button.hbs +++ b/ui/app/templates/components/two-step-button.hbs @@ -4,16 +4,22 @@ {{else if isPendingConfirmation}} {{confirmationMessage}} - - {{/if}} diff --git a/ui/app/templates/jobs/index.hbs b/ui/app/templates/jobs/index.hbs index a306f5106169..10a04f4225e3 100644 --- a/ui/app/templates/jobs/index.hbs +++ b/ui/app/templates/jobs/index.hbs @@ -2,11 +2,16 @@ {{#if isForbidden}} {{partial "partials/forbidden-message"}} {{else}} - {{#if filteredJobs.length}} -
    -
    {{search-box data-test-jobs-search searchTerm=(mut searchTerm) placeholder="Search jobs..."}}
    +
    + {{#if filteredJobs.length}} +
    + {{search-box data-test-jobs-search searchTerm=(mut searchTerm) placeholder="Search jobs..."}} +
    + {{/if}} +
    + {{#link-to "jobs.run" data-test-run-job class="button is-primary is-pulled-right"}}Run Job{{/link-to}}
    - {{/if}} +
    {{#list-pagination source=sortedJobs size=pageSize diff --git a/ui/app/templates/jobs/job/definition.hbs b/ui/app/templates/jobs/job/definition.hbs index 2e4280af27fa..4f30522b665d 100644 --- a/ui/app/templates/jobs/job/definition.hbs +++ b/ui/app/templates/jobs/job/definition.hbs @@ -1,8 +1,21 @@ {{partial "jobs/job/subnav"}}
    -
    -
    - {{json-viewer data-test-definition-view json=model.definition}} + {{#unless isEditing}} +
    +
    + Job Definition + +
    +
    + {{json-viewer data-test-definition-view json=definition}} +
    -
    + {{else}} + {{job-editor + job=job + cancelable=true + context="edit" + onCancel=(action onCancel) + onSubmit=(action onSubmit)}} + {{/unless}}
    diff --git a/ui/app/templates/jobs/job/task-group.hbs b/ui/app/templates/jobs/job/task-group.hbs index 1c20a03e779b..b9ead72d8493 100644 --- a/ui/app/templates/jobs/job/task-group.hbs +++ b/ui/app/templates/jobs/job/task-group.hbs @@ -27,7 +27,7 @@ {{#allocation-status-bar allocationContainer=model.summary class="split-view" as |chart|}}
      {{#each chart.data as |datum index|}} -
    1. +
    2. {{datum.value}} diff --git a/ui/app/templates/jobs/run.hbs b/ui/app/templates/jobs/run.hbs new file mode 100644 index 000000000000..2fa8f850f260 --- /dev/null +++ b/ui/app/templates/jobs/run.hbs @@ -0,0 +1,6 @@ +
      + {{job-editor + job=model + context="new" + onSubmit=(action onSubmit)}} +
      diff --git a/ui/app/utils/message-from-adapter-error.js b/ui/app/utils/message-from-adapter-error.js new file mode 100644 index 000000000000..2b1ce864bacf --- /dev/null +++ b/ui/app/utils/message-from-adapter-error.js @@ -0,0 +1,6 @@ +// Returns a single string based on the response the adapter received +export default function messageFromAdapterError(error) { + if (error.errors) { + return error.errors.mapBy('detail').join('\n\n'); + } +} diff --git a/ui/app/utils/properties/local-storage.js b/ui/app/utils/properties/local-storage.js new file mode 100644 index 000000000000..5049ed27ce60 --- /dev/null +++ b/ui/app/utils/properties/local-storage.js @@ -0,0 +1,19 @@ +import { computed } from '@ember/object'; + +// An Ember.Computed property that persists set values in localStorage +// and will attempt to get its initial value from localStorage before +// falling back to a default. +// +// ex. showTutorial: localStorageProperty('nomadTutorial', true), +export default function localStorageProperty(localStorageKey, defaultValue) { + return computed({ + get() { + const persistedValue = window.localStorage.getItem(localStorageKey); + return persistedValue ? JSON.parse(persistedValue) : defaultValue; + }, + set(key, value) { + window.localStorage.setItem(localStorageKey, JSON.stringify(value)); + return value; + }, + }); +} diff --git a/ui/ember-cli-build.js b/ui/ember-cli-build.js index 447069309516..87dfc3f0f7e1 100644 --- a/ui/ember-cli-build.js +++ b/ui/ember-cli-build.js @@ -13,7 +13,6 @@ module.exports = function(defaults) { paths: ['public/images/icons'], }, codemirror: { - themes: ['solarized'], modes: ['javascript'], }, funnel: { diff --git a/ui/mirage/config.js b/ui/mirage/config.js index 0311a8d94eb6..3cc92a434dc9 100644 --- a/ui/mirage/config.js +++ b/ui/mirage/config.js @@ -2,6 +2,8 @@ import Ember from 'ember'; import Response from 'ember-cli-mirage/response'; import { HOSTS } from './common'; import { logFrames, logEncode } from './data/logs'; +import { generateDiff } from './factories/job-version'; +import { generateTaskGroupFailures } from './factories/evaluation'; const { copy } = Ember; @@ -55,6 +57,49 @@ export default function() { }) ); + this.post('/jobs', function(schema, req) { + const body = JSON.parse(req.requestBody); + + if (!body.Job) return new Response(400, {}, 'Job is a required field on the request payload'); + + return okEmpty(); + }); + + this.post('/jobs/parse', function(schema, req) { + const body = JSON.parse(req.requestBody); + + if (!body.JobHCL) + return new Response(400, {}, 'JobHCL is a required field on the request payload'); + if (!body.Canonicalize) return new Response(400, {}, 'Expected Canonicalize to be true'); + + // Parse the name out of the first real line of HCL to match IDs in the new job record + // Regex expectation: + // in: job "job-name" { + // out: job-name + const nameFromHCLBlock = /.+?"(.+?)"/; + const jobName = body.JobHCL.trim() + .split('\n')[0] + .match(nameFromHCLBlock)[1]; + + const job = server.create('job', { id: jobName }); + return new Response(200, {}, this.serialize(job)); + }); + + this.post('/job/:id/plan', function(schema, req) { + const body = JSON.parse(req.requestBody); + + if (!body.Job) return new Response(400, {}, 'Job is a required field on the request payload'); + if (!body.Diff) return new Response(400, {}, 'Expected Diff to be true'); + + const FailedTGAllocs = body.Job.Unschedulable && generateFailedTGAllocs(body.Job); + + return new Response( + 200, + {}, + JSON.stringify({ FailedTGAllocs, Diff: generateDiff(req.params.id) }) + ); + }); + this.get( '/job/:id', withBlockingSupport(function({ jobs }, { params, queryParams }) { @@ -71,6 +116,14 @@ export default function() { }) ); + this.post('/job/:id', function(schema, req) { + const body = JSON.parse(req.requestBody); + + if (!body.Job) return new Response(400, {}, 'Job is a required field on the request payload'); + + return okEmpty(); + }); + this.get( '/job/:id/summary', withBlockingSupport(function({ jobSummaries }, { params }) { @@ -107,8 +160,7 @@ export default function() { createAllocations: parent.createAllocations, }); - // Return bogus, since the response is normally just eval information - return new Response(200, {}, '{}'); + return okEmpty(); }); this.delete('/job/:id', function(schema, { params }) { @@ -118,6 +170,9 @@ export default function() { }); this.get('/deployment/:id'); + this.post('/deployment/promote/:id', function() { + return new Response(204, {}, ''); + }); this.get('/job/:id/evaluations', function({ evaluations }, { params }) { return this.serialize(evaluations.where({ jobId: params.id })); @@ -276,3 +331,22 @@ function filterKeys(object, ...keys) { return clone; } + +// An empty response but not a 204 No Content. This is still a valid JSON +// response that represents a payload with no worthwhile data. +function okEmpty() { + return new Response(200, {}, '{}'); +} + +function generateFailedTGAllocs(job, taskGroups) { + const taskGroupsFromSpec = job.TaskGroups && job.TaskGroups.mapBy('Name'); + + let tgNames = ['tg-one', 'tg-two']; + if (taskGroupsFromSpec && taskGroupsFromSpec.length) tgNames = taskGroupsFromSpec; + if (taskGroups && taskGroups.length) tgNames = taskGroups; + + return tgNames.reduce((hash, tgName) => { + hash[tgName] = generateTaskGroupFailures(); + return hash; + }, {}); +} diff --git a/ui/mirage/factories/deployment-task-group-summary.js b/ui/mirage/factories/deployment-task-group-summary.js index addc4c8aa489..b08f02fa87a3 100644 --- a/ui/mirage/factories/deployment-task-group-summary.js +++ b/ui/mirage/factories/deployment-task-group-summary.js @@ -8,6 +8,8 @@ export default Factory.extend({ autoRevert: () => Math.random() > 0.5, promoted: () => Math.random() > 0.5, + requiresPromotion: false, + requireProgressBy: () => faker.date.past(0.5 / 365, REF_TIME), desiredTotal: faker.random.number({ min: 1, max: 10 }), diff --git a/ui/mirage/factories/deployment.js b/ui/mirage/factories/deployment.js index 8f26f086f727..e6800b0a4ee7 100644 --- a/ui/mirage/factories/deployment.js +++ b/ui/mirage/factories/deployment.js @@ -27,6 +27,8 @@ export default Factory.extend({ server.create('deployment-task-group-summary', { deployment, name: server.db.taskGroups.find(id).name, + desiredCanaries: 1, + promoted: false, }) ); diff --git a/ui/mirage/factories/evaluation.js b/ui/mirage/factories/evaluation.js index ebf01e217556..0dbfa25dc4c6 100644 --- a/ui/mirage/factories/evaluation.js +++ b/ui/mirage/factories/evaluation.js @@ -72,19 +72,7 @@ export default Factory.extend({ } const placementFailures = failedTaskGroupNames.reduce((hash, name) => { - hash[name] = { - CoalescedFailures: faker.random.number({ min: 1, max: 20 }), - NodesEvaluated: faker.random.number({ min: 1, max: 100 }), - NodesExhausted: faker.random.number({ min: 1, max: 100 }), - - NodesAvailable: Math.random() > 0.7 ? generateNodesAvailable() : null, - ClassFiltered: Math.random() > 0.7 ? generateClassFiltered() : null, - ConstraintFiltered: Math.random() > 0.7 ? generateConstraintFiltered() : null, - ClassExhausted: Math.random() > 0.7 ? generateClassExhausted() : null, - DimensionExhausted: Math.random() > 0.7 ? generateDimensionExhausted() : null, - QuotaExhausted: Math.random() > 0.7 ? generateQuotaExhausted() : null, - Scores: Math.random() > 0.7 ? generateScores() : null, - }; + hash[name] = generateTaskGroupFailures(); return hash; }, {}); @@ -111,3 +99,19 @@ function assignJob(evaluation, server) { job_id: job.id, }); } + +export function generateTaskGroupFailures() { + return { + CoalescedFailures: faker.random.number({ min: 1, max: 20 }), + NodesEvaluated: faker.random.number({ min: 1, max: 100 }), + NodesExhausted: faker.random.number({ min: 1, max: 100 }), + + NodesAvailable: Math.random() > 0.7 ? generateNodesAvailable() : null, + ClassFiltered: Math.random() > 0.7 ? generateClassFiltered() : null, + ConstraintFiltered: Math.random() > 0.7 ? generateConstraintFiltered() : null, + ClassExhausted: Math.random() > 0.7 ? generateClassExhausted() : null, + DimensionExhausted: Math.random() > 0.7 ? generateDimensionExhausted() : null, + QuotaExhausted: Math.random() > 0.7 ? generateQuotaExhausted() : null, + Scores: Math.random() > 0.7 ? generateScores() : null, + }; +} diff --git a/ui/mirage/factories/job-version.js b/ui/mirage/factories/job-version.js index 3e1db492da44..fe3404450e07 100644 --- a/ui/mirage/factories/job-version.js +++ b/ui/mirage/factories/job-version.js @@ -6,7 +6,7 @@ export default Factory.extend({ stable: faker.random.boolean, submitTime: () => faker.date.past(2 / 365, REF_TIME) * 1000000, diff() { - return generateDiff(this); + return generateDiff(this.jobId); }, jobId: null, @@ -39,10 +39,10 @@ export default Factory.extend({ }, }); -function generateDiff(version) { +export function generateDiff(id) { return { Fields: null, - ID: version.jobId, + ID: id, Objects: null, TaskGroups: [ { diff --git a/ui/mirage/factories/job.js b/ui/mirage/factories/job.js index e6380106e7fb..096c66228847 100644 --- a/ui/mirage/factories/job.js +++ b/ui/mirage/factories/job.js @@ -8,8 +8,12 @@ const JOB_TYPES = ['service', 'batch', 'system']; const JOB_STATUSES = ['pending', 'running', 'dead']; export default Factory.extend({ - id: i => `job-${i}`, - name: i => `${faker.list.random(...JOB_PREFIXES)()}-${faker.hacker.noun().dasherize()}-${i}`, + id: i => + `${faker.list.random(...JOB_PREFIXES)()}-${faker.hacker.noun().dasherize()}-${i}`.toLowerCase(), + + name() { + return this.id; + }, groupsCount: () => faker.random.number({ min: 1, max: 5 }), diff --git a/ui/tests/.eslintrc.js b/ui/tests/.eslintrc.js index 7cf1e81db44d..0d333ae8eb47 100644 --- a/ui/tests/.eslintrc.js +++ b/ui/tests/.eslintrc.js @@ -5,6 +5,7 @@ module.exports = { selectSearch: true, removeMultipleOption: true, clearSelected: true, + getCodeMirrorInstance: true, }, env: { embertest: true, diff --git a/ui/tests/acceptance/job-definition-test.js b/ui/tests/acceptance/job-definition-test.js index 65736df28b90..78f65541bb00 100644 --- a/ui/tests/acceptance/job-definition-test.js +++ b/ui/tests/acceptance/job-definition-test.js @@ -29,3 +29,56 @@ test('the job definition page requests the job to display in an unmutated form', .filter(url => url === jobURL); assert.ok(jobRequests.length === 2, 'Two requests for the job were made'); }); + +test('the job definition can be edited', function(assert) { + assert.notOk(Definition.editor.isPresent, 'Editor is not shown on load'); + + Definition.edit(); + + andThen(() => { + assert.ok(Definition.editor.isPresent, 'Editor is shown after clicking edit'); + assert.notOk(Definition.jsonViewer, 'Editor replaces the JSON viewer'); + }); +}); + +test('when in editing mode, the action can be canceled, showing the read-only definition again', function(assert) { + Definition.edit(); + + andThen(() => { + Definition.editor.cancelEditing(); + }); + + andThen(() => { + assert.ok(Definition.jsonViewer, 'The JSON Viewer is back'); + assert.notOk(Definition.editor.isPresent, 'The editor is gone'); + }); +}); + +test('when in editing mode, the editor is prepopulated with the job definition', function(assert) { + const requests = server.pretender.handledRequests; + const jobDefinition = requests.findBy('url', `/v1/job/${job.id}`).responseText; + const formattedJobDefinition = JSON.stringify(JSON.parse(jobDefinition), null, 2); + + Definition.edit(); + + andThen(() => { + assert.equal( + Definition.editor.editor.contents, + formattedJobDefinition, + 'The editor already has the job definition in it' + ); + }); +}); + +test('when changes are submitted, the site redirects to the job overview page', function(assert) { + Definition.edit(); + + andThen(() => { + Definition.editor.plan(); + Definition.editor.run(); + }); + + andThen(() => { + assert.equal(currentURL(), `/jobs/${job.id}`, 'Now on the job overview page'); + }); +}); diff --git a/ui/tests/acceptance/job-run-test.js b/ui/tests/acceptance/job-run-test.js new file mode 100644 index 000000000000..8d9f9904761b --- /dev/null +++ b/ui/tests/acceptance/job-run-test.js @@ -0,0 +1,98 @@ +import { assign } from '@ember/polyfills'; +import { currentURL } from 'ember-native-dom-helpers'; +import { test } from 'qunit'; +import moduleForAcceptance from 'nomad-ui/tests/helpers/module-for-acceptance'; +import JobRun from 'nomad-ui/tests/pages/jobs/run'; + +const newJobName = 'new-job'; +const newJobTaskGroupName = 'redis'; + +const jsonJob = overrides => { + return JSON.stringify( + assign( + {}, + { + Name: newJobName, + Namespace: 'default', + Datacenters: ['dc1'], + Priority: 50, + TaskGroups: [ + { + Name: newJobTaskGroupName, + Tasks: [ + { + Name: 'redis', + Driver: 'docker', + }, + ], + }, + ], + }, + overrides + ), + null, + 2 + ); +}; + +moduleForAcceptance('Acceptance | job run', { + beforeEach() { + // Required for placing allocations (a result of creating jobs) + server.create('node'); + }, +}); + +test('visiting /jobs/run', function(assert) { + JobRun.visit(); + + andThen(() => { + assert.equal(currentURL(), '/jobs/run'); + }); +}); + +test('when submitting a job, the site redirects to the new job overview page', function(assert) { + const spec = jsonJob(); + + JobRun.visit(); + + andThen(() => { + JobRun.editor.editor.fillIn(spec); + JobRun.editor.plan(); + }); + + andThen(() => { + JobRun.editor.run(); + }); + andThen(() => { + assert.equal( + currentURL(), + `/jobs/${newJobName}`, + `Redirected to the job overview page for ${newJobName}` + ); + }); +}); + +test('when submitting a job to a different namespace, the redirect to the job overview page takes namespace into account', function(assert) { + const newNamespace = 'second-namespace'; + + server.create('namespace', { id: newNamespace }); + const spec = jsonJob({ Namespace: newNamespace }); + + JobRun.visit(); + + andThen(() => { + JobRun.editor.editor.fillIn(spec); + JobRun.editor.plan(); + }); + + andThen(() => { + JobRun.editor.run(); + }); + andThen(() => { + assert.equal( + currentURL(), + `/jobs/${newJobName}?namespace=${newNamespace}`, + `Redirected to the job overview page for ${newJobName} and switched the namespace to ${newNamespace}` + ); + }); +}); diff --git a/ui/tests/acceptance/jobs-list-test.js b/ui/tests/acceptance/jobs-list-test.js index 0437a7d958c0..69c9eed71340 100644 --- a/ui/tests/acceptance/jobs-list-test.js +++ b/ui/tests/acceptance/jobs-list-test.js @@ -69,6 +69,18 @@ test('each job row should link to the corresponding job', function(assert) { }); }); +test('the new job button transitions to the new job page', function(assert) { + JobsList.visit(); + + andThen(() => { + JobsList.runJob(); + }); + + andThen(() => { + assert.equal(currentURL(), '/jobs/run'); + }); +}); + test('when there are no jobs, there is an empty message', function(assert) { JobsList.visit(); diff --git a/ui/tests/helpers/codemirror.js b/ui/tests/helpers/codemirror.js new file mode 100644 index 000000000000..247eb769915e --- /dev/null +++ b/ui/tests/helpers/codemirror.js @@ -0,0 +1,26 @@ +import { registerHelper } from '@ember/test'; + +const invariant = (truthy, error) => { + if (!truthy) throw new Error(error); +}; + +export function getCodeMirrorInstance(container) { + return function(selector) { + const cmService = container.lookup('service:code-mirror'); + + const element = document.querySelector(selector); + invariant(element, `Selector ${selector} matched no elements`); + + const cm = cmService.instanceFor(element.id); + invariant(cm, `No registered CodeMirror instance for ${selector}`); + + return cm; + }; +} + +export default function registerCodeMirrorHelpers() { + registerHelper('getCodeMirrorInstance', function(app, selector) { + const helper = getCodeMirrorInstance(app.__container__); + return helper(selector); + }); +} diff --git a/ui/tests/helpers/start-app.js b/ui/tests/helpers/start-app.js index 304c6e3773a0..496f7190a8ea 100644 --- a/ui/tests/helpers/start-app.js +++ b/ui/tests/helpers/start-app.js @@ -3,8 +3,10 @@ import { merge } from '@ember/polyfills'; import Application from '../../app'; import config from '../../config/environment'; import registerPowerSelectHelpers from 'ember-power-select/test-support/helpers'; +import registerCodeMirrorHelpers from 'nomad-ui/tests/helpers/codemirror'; registerPowerSelectHelpers(); +registerCodeMirrorHelpers(); export default function startApp(attrs) { let attributes = merge({}, config.APP); diff --git a/ui/tests/integration/job-editor-test.js b/ui/tests/integration/job-editor-test.js new file mode 100644 index 000000000000..9513a650e54b --- /dev/null +++ b/ui/tests/integration/job-editor-test.js @@ -0,0 +1,492 @@ +import { getOwner } from '@ember/application'; +import { assign } from '@ember/polyfills'; +import { run } from '@ember/runloop'; +import { test, moduleForComponent } from 'ember-qunit'; +import wait from 'ember-test-helpers/wait'; +import hbs from 'htmlbars-inline-precompile'; +import { create } from 'ember-cli-page-object'; +import sinon from 'sinon'; +import { startMirage } from 'nomad-ui/initializers/ember-cli-mirage'; +import { getCodeMirrorInstance } from 'nomad-ui/tests/helpers/codemirror'; +import jobEditor from 'nomad-ui/tests/pages/components/job-editor'; +import { initialize as fragmentSerializerInitializer } from 'nomad-ui/initializers/fragment-serializer'; + +const Editor = create(jobEditor()); + +moduleForComponent('job-editor', 'Integration | Component | job-editor', { + integration: true, + beforeEach() { + window.localStorage.clear(); + + fragmentSerializerInitializer(getOwner(this)); + + // Normally getCodeMirrorInstance is a registered test helper, + // but those registered test helpers only work in acceptance tests. + window._getCodeMirrorInstance = window.getCodeMirrorInstance; + window.getCodeMirrorInstance = getCodeMirrorInstance(getOwner(this)); + + this.store = getOwner(this).lookup('service:store'); + this.server = startMirage(); + + // Required for placing allocations (a result of creating jobs) + this.server.create('node'); + + Editor.setContext(this); + }, + afterEach() { + this.server.shutdown(); + Editor.removeContext(); + window.getCodeMirrorInstance = window._getCodeMirrorInstance; + delete window._getCodeMirrorInstance; + }, +}); + +const newJobName = 'new-job'; +const newJobTaskGroupName = 'redis'; +const jsonJob = overrides => { + return JSON.stringify( + assign( + {}, + { + Name: newJobName, + Namespace: 'default', + Datacenters: ['dc1'], + Priority: 50, + TaskGroups: [ + { + Name: newJobTaskGroupName, + Tasks: [ + { + Name: 'redis', + Driver: 'docker', + }, + ], + }, + ], + }, + overrides + ), + null, + 2 + ); +}; + +const hclJob = () => ` +job "${newJobName}" { + namespace = "default" + datacenters = ["dc1"] + + task "${newJobTaskGroupName}" { + driver = "docker" + } +} +`; + +const commonTemplate = hbs` + {{job-editor + job=job + context=context + onSubmit=onSubmit}} +`; + +const cancelableTemplate = hbs` + {{job-editor + job=job + context=context + cancelable=true + onSubmit=onSubmit + onCancel=onCancel}} +`; + +const renderNewJob = (component, job) => () => { + component.setProperties({ job, onSubmit: sinon.spy(), context: 'new' }); + component.render(commonTemplate); + return wait(); +}; + +const renderEditJob = (component, job) => () => { + component.setProperties({ job, onSubmit: sinon.spy(), onCancel: sinon.spy(), context: 'edit' }); + component.render(cancelableTemplate); +}; + +const planJob = spec => () => { + Editor.editor.fillIn(spec); + return wait().then(() => { + Editor.plan(); + return wait(); + }); +}; + +test('the default state is an editor with an explanation popup', function(assert) { + let job; + run(() => { + job = this.store.createRecord('job'); + }); + + return wait() + .then(renderNewJob(this, job)) + .then(() => { + assert.ok(Editor.editorHelp.isPresent, 'Editor explanation popup is present'); + assert.ok(Editor.editor.isPresent, 'Editor is present'); + }); +}); + +test('the explanation popup can be dismissed', function(assert) { + let job; + run(() => { + job = this.store.createRecord('job'); + }); + + return wait() + .then(renderNewJob(this, job)) + .then(() => { + Editor.editorHelp.dismiss(); + return wait(); + }) + .then(() => { + assert.notOk(Editor.editorHelp.isPresent, 'Editor explanation popup is gone'); + assert.equal( + window.localStorage.nomadMessageJobEditor, + 'false', + 'Dismissal is persisted in localStorage' + ); + }); +}); + +test('the explanation popup is not shown once the dismissal state is set in localStorage', function(assert) { + window.localStorage.nomadMessageJobEditor = 'false'; + + let job; + run(() => { + job = this.store.createRecord('job'); + }); + + return wait() + .then(renderNewJob(this, job)) + .then(() => { + assert.notOk(Editor.editorHelp.isPresent, 'Editor explanation popup is gone'); + }); +}); + +test('submitting a json job skips the parse endpoint', function(assert) { + const spec = jsonJob(); + let job; + run(() => { + job = this.store.createRecord('job'); + }); + + return wait() + .then(renderNewJob(this, job)) + .then(planJob(spec)) + .then(() => { + const requests = this.server.pretender.handledRequests.mapBy('url'); + assert.notOk(requests.includes('/v1/jobs/parse'), 'JSON job spec is not parsed'); + assert.ok(requests.includes(`/v1/job/${newJobName}/plan`), 'JSON job spec is still planned'); + }); +}); + +test('submitting an hcl job requires the parse endpoint', function(assert) { + const spec = hclJob(); + let job; + run(() => { + job = this.store.createRecord('job'); + }); + + return wait() + .then(renderNewJob(this, job)) + .then(planJob(spec)) + .then(() => { + const requests = this.server.pretender.handledRequests.mapBy('url'); + assert.ok(requests.includes('/v1/jobs/parse'), 'HCL job spec is parsed first'); + assert.ok(requests.includes(`/v1/job/${newJobName}/plan`), 'HCL job spec is planned'); + assert.ok( + requests.indexOf('/v1/jobs/parse') < requests.indexOf(`/v1/job/${newJobName}/plan`), + 'Parse comes before Plan' + ); + }); +}); + +test('when a job is successfully parsed and planned, the plan is shown to the user', function(assert) { + const spec = hclJob(); + let job; + run(() => { + job = this.store.createRecord('job'); + }); + + return wait() + .then(renderNewJob(this, job)) + .then(planJob(spec)) + .then(() => { + assert.ok(Editor.planOutput, 'The plan is outputted'); + assert.notOk(Editor.editor.isPresent, 'The editor is replaced with the plan output'); + assert.ok(Editor.planHelp.isPresent, 'The plan explanation popup is shown'); + }); +}); + +test('from the plan screen, the cancel button goes back to the editor with the job still in tact', function(assert) { + const spec = hclJob(); + let job; + run(() => { + job = this.store.createRecord('job'); + }); + + return wait() + .then(renderNewJob(this, job)) + .then(planJob(spec)) + .then(() => { + Editor.cancel(); + return wait(); + }) + .then(() => { + assert.ok(Editor.editor.isPresent, 'The editor is shown again'); + assert.equal( + Editor.editor.contents, + spec, + 'The spec that was planned is still in the editor' + ); + }); +}); + +test('when parse fails, the parse error message is shown', function(assert) { + const spec = hclJob(); + const errorMessage = 'Parse Failed!! :o'; + + let job; + run(() => { + job = this.store.createRecord('job'); + }); + + this.server.pretender.post('/v1/jobs/parse', () => [400, {}, errorMessage]); + + return wait() + .then(renderNewJob(this, job)) + .then(planJob(spec)) + .then(() => { + assert.notOk(Editor.planError.isPresent, 'Plan error is not shown'); + assert.notOk(Editor.runError.isPresent, 'Run error is not shown'); + + assert.ok(Editor.parseError.isPresent, 'Parse error is shown'); + assert.equal( + Editor.parseError.message, + errorMessage, + 'The error message from the server is shown in the error in the UI' + ); + }); +}); + +test('when plan fails, the plan error message is shown', function(assert) { + const spec = hclJob(); + const errorMessage = 'Plan Failed!! :o'; + + let job; + run(() => { + job = this.store.createRecord('job'); + }); + + this.server.pretender.post(`/v1/job/${newJobName}/plan`, () => [400, {}, errorMessage]); + + return wait() + .then(renderNewJob(this, job)) + .then(planJob(spec)) + .then(() => { + assert.notOk(Editor.parseError.isPresent, 'Parse error is not shown'); + assert.notOk(Editor.runError.isPresent, 'Run error is not shown'); + + assert.ok(Editor.planError.isPresent, 'Plan error is shown'); + assert.equal( + Editor.planError.message, + errorMessage, + 'The error message from the server is shown in the error in the UI' + ); + }); +}); + +test('when run fails, the run error message is shown', function(assert) { + const spec = hclJob(); + const errorMessage = 'Run Failed!! :o'; + + let job; + run(() => { + job = this.store.createRecord('job'); + }); + + this.server.pretender.post('/v1/jobs', () => [400, {}, errorMessage]); + + return wait() + .then(renderNewJob(this, job)) + .then(planJob(spec)) + .then(() => { + Editor.run(); + return wait(); + }) + .then(() => { + assert.notOk(Editor.planError.isPresent, 'Plan error is not shown'); + assert.notOk(Editor.parseError.isPresent, 'Parse error is not shown'); + + assert.ok(Editor.runError.isPresent, 'Run error is shown'); + assert.equal( + Editor.runError.message, + errorMessage, + 'The error message from the server is shown in the error in the UI' + ); + }); +}); + +test('when the scheduler dry-run has warnings, the warnings are shown to the user', function(assert) { + const spec = jsonJob({ Unschedulable: true }); + let job; + run(() => { + job = this.store.createRecord('job'); + }); + + return wait() + .then(renderNewJob(this, job)) + .then(planJob(spec)) + .then(() => { + assert.ok( + Editor.dryRunMessage.errored, + 'The scheduler dry-run message is in the warning state' + ); + assert.notOk( + Editor.dryRunMessage.succeeded, + 'The success message is not shown in addition to the warning message' + ); + assert.ok( + Editor.dryRunMessage.body.includes(newJobTaskGroupName), + 'The scheduler dry-run message includes the warning from send back by the API' + ); + }); +}); + +test('when the scheduler dry-run has no warnings, a success message is shown to the user', function(assert) { + const spec = hclJob(); + let job; + run(() => { + job = this.store.createRecord('job'); + }); + + return wait() + .then(renderNewJob(this, job)) + .then(planJob(spec)) + .then(() => { + assert.ok( + Editor.dryRunMessage.succeeded, + 'The scheduler dry-run message is in the success state' + ); + assert.notOk( + Editor.dryRunMessage.errored, + 'The warning message is not shown in addition to the success message' + ); + }); +}); + +test('when a job is submitted in the edit context, a POST request is made to the update job endpoint', function(assert) { + const spec = hclJob(); + let job; + run(() => { + job = this.store.createRecord('job'); + }); + + return wait() + .then(renderEditJob(this, job)) + .then(planJob(spec)) + .then(() => { + Editor.run(); + }) + .then(() => { + const requests = this.server.pretender.handledRequests + .filterBy('method', 'POST') + .mapBy('url'); + assert.ok(requests.includes(`/v1/job/${newJobName}`), 'A request was made to job update'); + assert.notOk(requests.includes('/v1/jobs'), 'A request was not made to job create'); + }); +}); + +test('when a job is submitted in the new context, a POST request is made to the create job endpoint', function(assert) { + const spec = hclJob(); + let job; + run(() => { + job = this.store.createRecord('job'); + }); + + return wait() + .then(renderNewJob(this, job)) + .then(planJob(spec)) + .then(() => { + Editor.run(); + }) + .then(() => { + const requests = this.server.pretender.handledRequests + .filterBy('method', 'POST') + .mapBy('url'); + assert.ok(requests.includes('/v1/jobs'), 'A request was made to job create'); + assert.notOk( + requests.includes(`/v1/job/${newJobName}`), + 'A request was not made to job update' + ); + }); +}); + +test('when a job is successfully submitted, the onSubmit hook is called', function(assert) { + const spec = hclJob(); + let job; + run(() => { + job = this.store.createRecord('job'); + }); + + return wait() + .then(renderNewJob(this, job)) + .then(planJob(spec)) + .then(() => { + Editor.run(); + return wait(); + }) + .then(() => { + assert.ok( + this.get('onSubmit').calledWith(newJobName, 'default'), + 'The onSubmit hook was called with the correct arguments' + ); + }); +}); + +test('when the job-editor cancelable flag is false, there is no cancel button in the header', function(assert) { + let job; + run(() => { + job = this.store.createRecord('job'); + }); + + return wait() + .then(renderNewJob(this, job)) + .then(() => { + assert.notOk(Editor.cancelEditingIsAvailable, 'No way to cancel editing'); + }); +}); + +test('when the job-editor cancelable flag is true, there is a cancel button in the header', function(assert) { + let job; + run(() => { + job = this.store.createRecord('job'); + }); + + return wait() + .then(renderEditJob(this, job)) + .then(() => { + assert.ok(Editor.cancelEditingIsAvailable, 'Cancel editing button exists'); + }); +}); + +test('when the job-editor cancel button is clicked, the onCancel hook is called', function(assert) { + let job; + run(() => { + job = this.store.createRecord('job'); + }); + + return wait() + .then(renderEditJob(this, job)) + .then(() => { + Editor.cancelEditing(); + }) + .then(() => { + assert.ok(this.get('onCancel').calledOnce, 'The onCancel hook was called'); + }); +}); diff --git a/ui/tests/integration/job-page/helpers.js b/ui/tests/integration/job-page/helpers.js index ef64e86af453..21deb51b17e8 100644 --- a/ui/tests/integration/job-page/helpers.js +++ b/ui/tests/integration/job-page/helpers.js @@ -19,11 +19,31 @@ export function stopJob() { }); } -export function expectStopError(assert) { +export function startJob() { + click('[data-test-start] [data-test-idle-button]'); + return wait().then(() => { + click('[data-test-start] [data-test-confirm-button]'); + return wait(); + }); +} + +export function expectStartRequest(assert, server, job) { + const expectedURL = jobURL(job); + const request = server.pretender.handledRequests + .filterBy('method', 'POST') + .find(req => req.url === expectedURL); + + const requestPayload = JSON.parse(request.requestBody).Job; + + assert.ok(request, 'POST URL was made correctly'); + assert.ok(requestPayload.Stop == null, 'The Stop signal is not sent in the POST request'); +} + +export function expectError(assert, title) { return () => { assert.equal( find('[data-test-job-error-title]').textContent, - 'Could Not Stop Job', + title, 'Appropriate error is shown' ); assert.ok( diff --git a/ui/tests/integration/job-page/periodic-test.js b/ui/tests/integration/job-page/periodic-test.js index ba93d1a51ad4..0908447f9d0a 100644 --- a/ui/tests/integration/job-page/periodic-test.js +++ b/ui/tests/integration/job-page/periodic-test.js @@ -4,7 +4,14 @@ import { click, find, findAll } from 'ember-native-dom-helpers'; import wait from 'ember-test-helpers/wait'; import hbs from 'htmlbars-inline-precompile'; import { startMirage } from 'nomad-ui/initializers/ember-cli-mirage'; -import { jobURL, stopJob, expectStopError, expectDeleteRequest } from './helpers'; +import { + jobURL, + stopJob, + startJob, + expectError, + expectDeleteRequest, + expectStartRequest, +} from './helpers'; moduleForComponent('job-page/periodic', 'Integration | Component | job-page/periodic', { integration: true, @@ -167,5 +174,51 @@ test('Stopping a job without proper permissions shows an error message', functio return wait(); }) .then(stopJob) - .then(expectStopError(assert)); + .then(expectError(assert, 'Could Not Stop Job')); +}); + +test('Starting a job sends a post request for the job using the current definition', function(assert) { + let job; + + const mirageJob = this.server.create('job', 'periodic', { + childrenCount: 0, + createAllocations: false, + status: 'dead', + }); + this.store.findAll('job'); + + return wait() + .then(() => { + job = this.store.peekAll('job').findBy('plainId', mirageJob.id); + + this.setProperties(commonProperties(job)); + this.render(commonTemplate); + + return wait(); + }) + .then(startJob) + .then(() => expectStartRequest(assert, this.server, job)); +}); + +test('Starting a job without proper permissions shows an error message', function(assert) { + this.server.pretender.post('/v1/job/:id', () => [403, {}, null]); + + const mirageJob = this.server.create('job', 'periodic', { + childrenCount: 0, + createAllocations: false, + status: 'dead', + }); + this.store.findAll('job'); + + return wait() + .then(() => { + const job = this.store.peekAll('job').findBy('plainId', mirageJob.id); + + this.setProperties(commonProperties(job)); + this.render(commonTemplate); + + return wait(); + }) + .then(startJob) + .then(expectError(assert, 'Could Not Start Job')); }); diff --git a/ui/tests/integration/job-page/service-test.js b/ui/tests/integration/job-page/service-test.js index aef698a149cf..635acaad0f1c 100644 --- a/ui/tests/integration/job-page/service-test.js +++ b/ui/tests/integration/job-page/service-test.js @@ -1,16 +1,19 @@ import { getOwner } from '@ember/application'; import { assign } from '@ember/polyfills'; import { test, moduleForComponent } from 'ember-qunit'; +import { click, find } from 'ember-native-dom-helpers'; import wait from 'ember-test-helpers/wait'; import hbs from 'htmlbars-inline-precompile'; import { startMirage } from 'nomad-ui/initializers/ember-cli-mirage'; -import { stopJob, expectStopError, expectDeleteRequest } from './helpers'; +import { startJob, stopJob, expectError, expectDeleteRequest, expectStartRequest } from './helpers'; import Job from 'nomad-ui/tests/pages/jobs/detail'; +import { initialize as fragmentSerializerInitializer } from 'nomad-ui/initializers/fragment-serializer'; moduleForComponent('job-page/service', 'Integration | Component | job-page/service', { integration: true, beforeEach() { Job.setContext(this); + fragmentSerializerInitializer(getOwner(this)); window.localStorage.clear(); this.store = getOwner(this).lookup('service:store'); this.server = startMirage(); @@ -88,7 +91,45 @@ test('Stopping a job without proper permissions shows an error message', functio return wait(); }) .then(stopJob) - .then(expectStopError(assert)); + .then(expectError(assert, 'Could Not Stop Job')); +}); + +test('Starting a job sends a post request for the job using the current definition', function(assert) { + let job; + + const mirageJob = makeMirageJob(this.server, { status: 'dead' }); + this.store.findAll('job'); + + return wait() + .then(() => { + job = this.store.peekAll('job').findBy('plainId', mirageJob.id); + + this.setProperties(commonProperties(job)); + this.render(commonTemplate); + + return wait(); + }) + .then(startJob) + .then(() => expectStartRequest(assert, this.server, job)); +}); + +test('Starting a job without proper permissions shows an error message', function(assert) { + this.server.pretender.post('/v1/job/:id', () => [403, {}, null]); + + const mirageJob = makeMirageJob(this.server, { status: 'dead' }); + this.store.findAll('job'); + + return wait() + .then(() => { + const job = this.store.peekAll('job').findBy('plainId', mirageJob.id); + + this.setProperties(commonProperties(job)); + this.render(commonTemplate); + + return wait(); + }) + .then(startJob) + .then(expectError(assert, 'Could Not Start Job')); }); test('Recent allocations shows allocations in the job context', function(assert) { @@ -165,3 +206,77 @@ test('Recent allocations shows an empty message when the job has no allocations' ); }); }); + +test('Active deployment can be promoted', function(assert) { + let job; + let deployment; + + this.server.create('node'); + const mirageJob = makeMirageJob(this.server, { activeDeployment: true }); + + this.store.findAll('job'); + + return wait() + .then(() => { + job = this.store.peekAll('job').findBy('plainId', mirageJob.id); + deployment = job.get('latestDeployment'); + + this.setProperties(commonProperties(job)); + this.render(commonTemplate); + + return wait(); + }) + .then(() => { + click('[data-test-promote-canary]'); + return wait(); + }) + .then(() => { + const requests = this.server.pretender.handledRequests; + assert.ok( + requests + .filterBy('method', 'POST') + .findBy('url', `/v1/deployment/promote/${deployment.get('id')}`), + 'A promote POST request was made' + ); + }); +}); + +test('When promoting the active deployment fails, an error is shown', function(assert) { + this.server.pretender.post('/v1/deployment/promote/:id', () => [403, {}, null]); + + let job; + + this.server.create('node'); + const mirageJob = makeMirageJob(this.server, { activeDeployment: true }); + + this.store.findAll('job'); + + return wait() + .then(() => { + job = this.store.peekAll('job').findBy('plainId', mirageJob.id); + + this.setProperties(commonProperties(job)); + this.render(commonTemplate); + + return wait(); + }) + .then(() => { + click('[data-test-promote-canary]'); + return wait(); + }) + .then(() => { + assert.equal( + find('[data-test-job-error-title]').textContent, + 'Could Not Promote Deployment', + 'Appropriate error is shown' + ); + assert.ok( + find('[data-test-job-error-body]').textContent.includes('ACL'), + 'The error message mentions ACLs' + ); + + click('[data-test-job-error-close]'); + assert.notOk(find('[data-test-job-error-title]'), 'Error message is dismissable'); + return wait(); + }); +}); diff --git a/ui/tests/integration/placement-failure-test.js b/ui/tests/integration/placement-failure-test.js index b5bf79800604..97d8b688e648 100644 --- a/ui/tests/integration/placement-failure-test.js +++ b/ui/tests/integration/placement-failure-test.js @@ -108,6 +108,7 @@ function createFixture(obj = {}, name = 'Placement Failure') { name: name, placementFailures: assign( { + name: name, coalescedFailures: 10, nodesEvaluated: 0, nodesAvailable: { diff --git a/ui/tests/integration/two-step-button-test.js b/ui/tests/integration/two-step-button-test.js index 41eff1e6e4a8..12d23c1d7382 100644 --- a/ui/tests/integration/two-step-button-test.js +++ b/ui/tests/integration/two-step-button-test.js @@ -13,6 +13,7 @@ const commonProperties = () => ({ cancelText: 'Cancel Action', confirmText: 'Confirm Action', confirmationMessage: 'Are you certain', + awaitingConfirmation: false, onConfirm: sinon.spy(), onCancel: sinon.spy(), }); @@ -23,6 +24,7 @@ const commonTemplate = hbs` cancelText=cancelText confirmText=confirmText confirmationMessage=confirmationMessage + awaitingConfirmation=awaitingConfirmation onConfirm=onConfirm onCancel=onCancel}} `; @@ -109,3 +111,27 @@ test('confirming the promptForConfirmation state calls the onConfirm hook and re }); }); }); + +test('when awaitingConfirmation is true, the cancel and submit buttons are disabled and the submit button is loading', function(assert) { + const props = commonProperties(); + props.awaitingConfirmation = true; + this.setProperties(props); + this.render(commonTemplate); + + click('[data-test-idle-button]'); + + return wait().then(() => { + assert.ok( + find('[data-test-cancel-button]').hasAttribute('disabled'), + 'The cancel button is disabled' + ); + assert.ok( + find('[data-test-confirm-button]').hasAttribute('disabled'), + 'The confirm button is disabled' + ); + assert.ok( + find('[data-test-confirm-button]').classList.contains('is-loading'), + 'The confirm button is in a loading state' + ); + }); +}); diff --git a/ui/tests/pages/components/error.js b/ui/tests/pages/components/error.js new file mode 100644 index 000000000000..066ef08fc8ec --- /dev/null +++ b/ui/tests/pages/components/error.js @@ -0,0 +1,11 @@ +import { clickable, isPresent, text } from 'ember-cli-page-object'; + +export default function(selectorBase = 'data-test-error') { + return { + scope: `[${selectorBase}]`, + isPresent: isPresent(), + title: text(`[${selectorBase}-title]`), + message: text(`[${selectorBase}-message]`), + seekHelp: clickable(`[${selectorBase}-message] a`), + }; +} diff --git a/ui/tests/pages/components/job-editor.js b/ui/tests/pages/components/job-editor.js new file mode 100644 index 000000000000..387cd95a1e12 --- /dev/null +++ b/ui/tests/pages/components/job-editor.js @@ -0,0 +1,51 @@ +import { clickable, hasClass, isPresent, text } from 'ember-cli-page-object'; +import { codeFillable, code } from 'nomad-ui/tests/pages/helpers/codemirror'; + +import error from 'nomad-ui/tests/pages/components/error'; + +export default () => ({ + scope: '[data-test-job-editor]', + + isPresent: isPresent(), + + planError: error('data-test-plan-error'), + parseError: error('data-test-parse-error'), + runError: error('data-test-run-error'), + + plan: clickable('[data-test-plan]'), + cancel: clickable('[data-test-cancel]'), + run: clickable('[data-test-run]'), + + cancelEditing: clickable('[data-test-cancel-editing]'), + cancelEditingIsAvailable: isPresent('[data-test-cancel-editing]'), + + planOutput: text('[data-test-plan-output]'), + + planHelp: { + isPresent: isPresent('[data-test-plan-help-title]'), + title: text('[data-test-plan-help-title]'), + message: text('[data-test-plan-help-message]'), + dismiss: clickable('[data-test-plan-help-dismiss]'), + }, + + editorHelp: { + isPresent: isPresent('[data-test-editor-help-title]'), + title: text('[data-test-editor-help-title]'), + message: text('[data-test-editor-help-message]'), + dismiss: clickable('[data-test-editor-help-dismiss]'), + }, + + editor: { + isPresent: isPresent('[data-test-editor]'), + contents: code('[data-test-editor]'), + fillIn: codeFillable('[data-test-editor]'), + }, + + dryRunMessage: { + scope: '[data-test-dry-run-message]', + title: text('[data-test-dry-run-title]'), + body: text('[data-test-dry-run-body]'), + errored: hasClass('is-warning'), + succeeded: hasClass('is-primary'), + }, +}); diff --git a/ui/tests/pages/helpers/codemirror.js b/ui/tests/pages/helpers/codemirror.js new file mode 100644 index 000000000000..8a64b6e5bca8 --- /dev/null +++ b/ui/tests/pages/helpers/codemirror.js @@ -0,0 +1,32 @@ +// Like fillable, but for the CodeMirror editor +// +// Usage: fillIn: codeFillable('[data-test-editor]') +// Page.fillIn(code); +export function codeFillable(selector) { + return { + isDescriptor: true, + + get() { + return function(code) { + const cm = getCodeMirrorInstance(selector); + cm.setValue(code); + return this; + }; + }, + }; +} + +// Like text, but for the CodeMirror editor +// +// Usage: content: code('[data-test-editor]') +// Page.code(); // some = [ 'string', 'of', 'code' ] +export function code(selector) { + return { + isDescriptor: true, + + get() { + const cm = getCodeMirrorInstance(selector); + return cm.getValue(); + }, + }; +} diff --git a/ui/tests/pages/jobs/job/definition.js b/ui/tests/pages/jobs/job/definition.js index 789d0cabc3b1..b015019b714d 100644 --- a/ui/tests/pages/jobs/job/definition.js +++ b/ui/tests/pages/jobs/job/definition.js @@ -1,7 +1,12 @@ -import { create, isPresent, visitable } from 'ember-cli-page-object'; +import { create, isPresent, visitable, clickable } from 'ember-cli-page-object'; + +import jobEditor from 'nomad-ui/tests/pages/components/job-editor'; export default create({ visit: visitable('/jobs/:id/definition'), jsonViewer: isPresent('[data-test-definition-view]'), + editor: jobEditor(), + + edit: clickable('[data-test-edit-job]'), }); diff --git a/ui/tests/pages/jobs/list.js b/ui/tests/pages/jobs/list.js index cd59ac7ef675..252b92efc125 100644 --- a/ui/tests/pages/jobs/list.js +++ b/ui/tests/pages/jobs/list.js @@ -16,6 +16,8 @@ export default create({ search: fillable('[data-test-jobs-search] input'), + runJob: clickable('[data-test-run-job]'), + jobs: collection('[data-test-job-row]', { id: attribute('data-test-job-row'), name: text('[data-test-job-name]'), diff --git a/ui/tests/pages/jobs/run.js b/ui/tests/pages/jobs/run.js new file mode 100644 index 000000000000..03c24ef14d35 --- /dev/null +++ b/ui/tests/pages/jobs/run.js @@ -0,0 +1,8 @@ +import { create, visitable } from 'ember-cli-page-object'; + +import jobEditor from 'nomad-ui/tests/pages/components/job-editor'; + +export default create({ + visit: visitable('/jobs/run'), + editor: jobEditor(), +});