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/deployment.js b/ui/app/adapters/deployment.js
new file mode 100644
index 00000000000..6dbc20dbd38
--- /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 e130478155a..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;
@@ -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 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/components/job-page/parts/latest-deployment.js b/ui/app/components/job-page/parts/latest-deployment.js
index 72ea12f8ec8..b68978520d1 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 fb8e3232c00..ba328462726 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 00000000000..b8052235f1e
--- /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 7016e96ec24..fe40c16f244 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 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
new file mode 100644
index 00000000000..baaf8418307
--- /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 5d86e9296e7..e3fefb94fb9 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 00000000000..8f9c10345f1
--- /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 b69040eaaa7..6db6ef67677 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 39446ffa958..2c298fa7132 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 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/routes/jobs/run.js b/ui/app/routes/jobs/run.js
new file mode 100644
index 00000000000..121e9b4ce9e
--- /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 00000000000..0280734383d
--- /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 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/codemirror.scss b/ui/app/styles/components/codemirror.scss
index 59f4d755b32..81c08356e67 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 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/styles/core.scss b/ui/app/styles/core.scss
index e4391415b27..a92c17adea1 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 00000000000..6504a68cfc2
--- /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 4db55477f20..c5a5a546aec 100644
--- a/ui/app/templates/components/distribution-bar.hbs
+++ b/ui/app/templates/components/distribution-bar.hbs
@@ -15,7 +15,7 @@
{{/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 54331123887..e87a91e732e 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 00000000000..8f85ebc5859
--- /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 b67f0bd0b84..a2a3aef3e6e 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 4a0c22965b0..062f06cc2f6 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 38e9a1b68d5..0bacf5c52de 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|}}
- -
+
-
{{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 445640d760a..131b06e5627 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 6a921beb8bb..cbef54c1f24 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 63f49a82648..9cf7089ecbd 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
{{/with}}
{{/if}}
-
diff --git a/ui/app/templates/components/two-step-button.hbs b/ui/app/templates/components/two-step-button.hbs
index e9fe906b59e..1b95184fea5 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}}
-