Skip to content

Commit

Permalink
Merge pull request #4600 from hashicorp/f-ui-job-writes
Browse files Browse the repository at this point in the history
UI: Job Writes
  • Loading branch information
DingoEatingFuzz committed Aug 30, 2018
2 parents 240eb66 + 334358a commit 130e951
Show file tree
Hide file tree
Showing 64 changed files with 1,818 additions and 123 deletions.
51 changes: 27 additions & 24 deletions ui/app/adapters/application.js
Original file line number Diff line number Diff line change
Expand Up @@ -81,32 +81,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;
}
29 changes: 29 additions & 0 deletions ui/app/adapters/deployment.js
Original file line number Diff line number Diff line change
@@ -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;
}
51 changes: 51 additions & 0 deletions ui/app/adapters/job.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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) {
Expand Down
102 changes: 102 additions & 0 deletions ui/app/components/job-editor.js
Original file line number Diff line number Diff line change
@@ -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);
}
},
});
19 changes: 19 additions & 0 deletions ui/app/components/job-page/parts/latest-deployment.js
Original file line number Diff line number Diff line change
@@ -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,
});
}
}),
});
52 changes: 40 additions & 12 deletions ui/app/components/job-page/parts/title.js
Original file line number Diff line number Diff line change
@@ -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: '',
Expand All @@ -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,
});
}
}),
});
10 changes: 10 additions & 0 deletions ui/app/components/placement-failure.js
Original file line number Diff line number Diff line change
@@ -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'),
});
7 changes: 7 additions & 0 deletions ui/app/components/two-step-button.js
Original file line number Diff line number Diff line change
@@ -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'],
Expand All @@ -8,6 +9,7 @@ export default Component.extend({
cancelText: '',
confirmText: '',
confirmationMessage: '',
awaitingConfirmation: false,
onConfirm() {},
onCancel() {},

Expand All @@ -22,5 +24,10 @@ export default Component.extend({
promptForConfirmation() {
this.set('state', 'prompt');
},
confirm() {
RSVP.resolve(this.get('onConfirm')()).then(() => {
this.send('setToIdle');
});
},
},
});
Loading

0 comments on commit 130e951

Please sign in to comment.