mirror of
https://github.com/kemko/nomad.git
synced 2026-01-06 02:15:43 +03:00
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
if (id) {
|
||||
url.push(encodeURIComponent(id));
|
||||
}
|
||||
|
||||
if (prefix) {
|
||||
url.unshift(prefix);
|
||||
}
|
||||
|
||||
url = url.join('/');
|
||||
if (!host && url && url.charAt(0) !== '/') {
|
||||
url = '/' + url;
|
||||
}
|
||||
|
||||
return url;
|
||||
},
|
||||
urlForFindRecord: urlForRecord,
|
||||
urlForUpdateRecord: urlForRecord,
|
||||
});
|
||||
|
||||
function urlForRecord(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);
|
||||
}
|
||||
}
|
||||
|
||||
if (id) {
|
||||
url.push(encodeURIComponent(id));
|
||||
}
|
||||
|
||||
if (prefix) {
|
||||
url.unshift(prefix);
|
||||
}
|
||||
|
||||
url = url.join('/');
|
||||
if (!host && url && url.charAt(0) !== '/') {
|
||||
url = '/' + url;
|
||||
}
|
||||
|
||||
return url;
|
||||
}
|
||||
|
||||
29
ui/app/adapters/deployment.js
Normal file
29
ui/app/adapters/deployment.js
Normal file
@@ -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;
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
102
ui/app/components/job-editor.js
Normal file
102
ui/app/components/job-editor.js
Normal file
@@ -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);
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}),
|
||||
});
|
||||
|
||||
10
ui/app/components/placement-failure.js
Normal file
10
ui/app/components/placement-failure.js
Normal file
@@ -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'),
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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 },
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
9
ui/app/controllers/jobs/run.js
Normal file
9
ui/app/controllers/jobs/run.js
Normal file
@@ -0,0 +1,9 @@
|
||||
import Controller from '@ember/controller';
|
||||
|
||||
export default Controller.extend({
|
||||
onSubmit(id, namespace) {
|
||||
this.transitionToRoute('jobs.job', id, {
|
||||
queryParams: { jobNamespace: namespace },
|
||||
});
|
||||
},
|
||||
});
|
||||
@@ -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);
|
||||
},
|
||||
});
|
||||
|
||||
8
ui/app/models/job-plan.js
Normal file
8
ui/app/models/job-plan.js
Normal file
@@ -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: () => [] }),
|
||||
});
|
||||
@@ -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'),
|
||||
});
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
26
ui/app/routes/jobs/run.js
Normal file
26
ui/app/routes/jobs/run.js
Normal file
@@ -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();
|
||||
}
|
||||
},
|
||||
});
|
||||
12
ui/app/serializers/job-plan.js
Normal file
12
ui/app/serializers/job-plan.js
Normal file
@@ -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);
|
||||
},
|
||||
});
|
||||
@@ -18,6 +18,6 @@ export default Service.extend({
|
||||
},
|
||||
|
||||
setIndexFor(url, value) {
|
||||
list[url] = value;
|
||||
list[url] = +value;
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
.page-layout {
|
||||
height: 100%;
|
||||
min-height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
3
ui/app/styles/utils/layout.scss
Normal file
3
ui/app/styles/utils/layout.scss
Normal file
@@ -0,0 +1,3 @@
|
||||
.is-associative {
|
||||
margin-top: -0.75em;
|
||||
}
|
||||
@@ -15,7 +15,7 @@
|
||||
<div class="chart-tooltip {{if isActive "active" "inactive"}}" style={{tooltipStyle}}>
|
||||
<ol>
|
||||
{{#each _data as |datum index|}}
|
||||
<li class="{{if (eq datum.index activeDatum.index) "active"}}">
|
||||
<li class="{{if (eq datum.label activeDatum.label) "active"}}">
|
||||
<span class="label {{if (eq datum.value 0) "is-empty"}}">
|
||||
<span class="color-swatch {{if datum.className datum.className (concat "swatch-" index)}}" />
|
||||
{{datum.label}}
|
||||
|
||||
|
Before Width: | Height: | Size: 801 B After Width: | Height: | Size: 801 B |
@@ -20,3 +20,21 @@
|
||||
</h1>
|
||||
</div>
|
||||
{{/freestyle-usage}}
|
||||
|
||||
{{#freestyle-usage "two-step-button-loading" title="Two Step Button Loading State"}}
|
||||
<div class="mock-spacing">
|
||||
<h1 class="title">
|
||||
This is a page title
|
||||
{{two-step-button
|
||||
idleText="Scary Action"
|
||||
cancelText="Nvm"
|
||||
confirmText="Yep"
|
||||
confirmationMessage="Wait, really? Like...seriously?"
|
||||
awaitingConfirmation=true
|
||||
state="prompt"}}
|
||||
</h1>
|
||||
</div>
|
||||
{{/freestyle-usage}}
|
||||
{{#freestyle-annotation}}
|
||||
<strong>Note:</strong> the <code>state</code> property is internal state and only used here to bypass the idle state for demonstration purposes.
|
||||
{{/freestyle-annotation}}
|
||||
|
||||
@@ -80,12 +80,12 @@
|
||||
</span>
|
||||
Task: "{{task.Name}}"
|
||||
{{#if task.Annotations}}
|
||||
({{#each task.Annotations as |annotation index|}}
|
||||
({{~#each task.Annotations as |annotation index|}}
|
||||
<span class="{{css-class annotation}}">{{annotation}}</span>
|
||||
{{#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}}
|
||||
</div>
|
||||
|
||||
95
ui/app/templates/components/job-editor.hbs
Normal file
95
ui/app/templates/components/job-editor.hbs
Normal file
@@ -0,0 +1,95 @@
|
||||
{{#if parseError}}
|
||||
<div data-test-parse-error class="notification is-danger">
|
||||
<h3 class="title is-4" data-test-parse-error-title>Parse Error</h3>
|
||||
<p data-test-parse-error-message>{{parseError}}</p>
|
||||
</div>
|
||||
{{/if}}
|
||||
{{#if planError}}
|
||||
<div data-test-plan-error class="notification is-danger">
|
||||
<h3 class="title is-4" data-test-plan-error-title>Plan Error</h3>
|
||||
<p data-test-plan-error-message>{{planError}}</p>
|
||||
</div>
|
||||
{{/if}}
|
||||
{{#if runError}}
|
||||
<div data-test-run-error class="notification is-danger">
|
||||
<h3 class="title is-4" data-test-run-error-title>Run Error</h3>
|
||||
<p data-test-run-error-message>{{runError}}</p>
|
||||
</div>
|
||||
{{/if}}
|
||||
|
||||
{{#if (eq stage "editor")}}
|
||||
{{#if (and showEditorMessage (eq context "new"))}}
|
||||
<div class="notification is-info">
|
||||
<div class="columns">
|
||||
<div class="column">
|
||||
<h3 class="title is-4" data-test-editor-help-title>Run a Job</h3>
|
||||
<p data-test-editor-help-message>Paste or author HCL or JSON to submit to your cluster. A plan will be requested before the job is submitted.</p>
|
||||
</div>
|
||||
<div class="column is-centered is-minimum">
|
||||
<button class="button is-info" onclick={{toggle-action "showEditorMessage" this}} data-test-editor-help-dismiss>Okay</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{{/if}}
|
||||
<div class="boxed-section">
|
||||
<div class="boxed-section-head">
|
||||
Job Definition
|
||||
{{#if cancelable}}
|
||||
<button class="button is-light is-compact pull-right" onclick={{action onCancel}} data-test-cancel-editing>Cancel</button>
|
||||
{{/if}}
|
||||
</div>
|
||||
<div class="boxed-section-body is-full-bleed">
|
||||
{{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
|
||||
)}}
|
||||
</div>
|
||||
</div>
|
||||
<div class="content is-associative">
|
||||
<button class="button is-primary {{if plan.isRunning "is-loading"}}" type="button" onclick={{perform plan}} disabled={{or plan.isRunning (not job._newDefinition)}} data-test-plan>Plan</button>
|
||||
</div>
|
||||
{{/if}}
|
||||
|
||||
{{#if (eq stage "plan")}}
|
||||
{{#if showPlanMessage}}
|
||||
<div class="notification is-info">
|
||||
<div class="columns">
|
||||
<div class="column">
|
||||
<h3 class="title is-4" data-test-plan-help-title>Job Plan</h3>
|
||||
<p data-test-plan-help-message>This is the impact running this job will have on your cluster.</p>
|
||||
</div>
|
||||
<div class="column is-centered is-minimum">
|
||||
<button class="button is-info" onclick={{toggle-action "showPlanMessage" this}} data-test-plan-help-dismiss>Okay</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{{/if}}
|
||||
<div class="boxed-section">
|
||||
<div class="boxed-section-head">Job Plan</div>
|
||||
<div class="boxed-section-body is-dark">
|
||||
{{job-diff data-test-plan-output diff=planOutput.diff verbose=false}}
|
||||
</div>
|
||||
</div>
|
||||
<div class="boxed-section {{if planOutput.failedTGAllocs "is-warning" "is-primary"}}" data-test-dry-run-message>
|
||||
<div class="boxed-section-head" data-test-dry-run-title>Scheduler dry-run</div>
|
||||
<div class="boxed-section-body" data-test-dry-run-body>
|
||||
{{#if planOutput.failedTGAllocs}}
|
||||
{{#each planOutput.failedTGAllocs as |placementFailure|}}
|
||||
{{placement-failure failedTGAlloc=placementFailure}}
|
||||
{{/each}}
|
||||
{{else}}
|
||||
All tasks successfully allocated.
|
||||
{{/if}}
|
||||
</div>
|
||||
</div>
|
||||
<div class="content is-associative">
|
||||
<button class="button is-primary {{if submit.isRunning "is-loading"}}" type="button" onclick={{perform submit}} disabled={{submit.isRunning}} data-test-run>Run</button>
|
||||
<button class="button is-light" type="button" onclick={{action reset}} data-test-cancel>Cancel</button>
|
||||
</div>
|
||||
{{/if}}
|
||||
@@ -13,7 +13,12 @@
|
||||
{{job.latestDeployment.status}}
|
||||
</span>
|
||||
{{#if job.latestDeployment.requiresPromotion}}
|
||||
<span class="tag bumper-left is-warning no-text-transform">Deployment is running but requires promotion</span>
|
||||
<button
|
||||
data-test-promote-canary
|
||||
type="button"
|
||||
class="button is-warning is-small pull-right {{if promote.isRunning "is-loading"}}"
|
||||
disabled={{promote.isRunning}}
|
||||
onclick={{perform promote}}>Promote Canary</button>
|
||||
{{/if}}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<div class="boxed-section-head">
|
||||
Recent Allocations
|
||||
</div>
|
||||
<div class="boxed-section-body is-full-bleed">
|
||||
<div class="boxed-section-body {{if job.allocations.length "is-full-bleed"}}">
|
||||
{{#if job.allocations.length}}
|
||||
{{#list-table
|
||||
source=sortedAllocations
|
||||
|
||||
@@ -39,7 +39,7 @@
|
||||
class="split-view" as |chart|}}
|
||||
<ol data-test-legend class="legend">
|
||||
{{#each chart.data as |datum index|}}
|
||||
<li class="{{datum.className}} {{if (eq datum.index chart.activeDatum.index) "is-active"}} {{if (eq datum.value 0) "is-empty"}}">
|
||||
<li class="{{datum.className}} {{if (eq datum.label chart.activeDatum.label) "is-active"}} {{if (eq datum.value 0) "is-empty"}}">
|
||||
<span class="color-swatch {{if datum.className datum.className (concat "swatch-" index)}}" />
|
||||
<span class="value" data-test-legend-value="{{datum.className}}">{{datum.value}}</span>
|
||||
<span class="label">
|
||||
|
||||
@@ -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}}
|
||||
</h1>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{{#if taskGroup.placementFailures}}
|
||||
{{#with taskGroup.placementFailures as |failures|}}
|
||||
{{#if placementFailures}}
|
||||
{{#with placementFailures as |failures|}}
|
||||
<h3 class="title is-5" data-test-placement-failure-task-group>
|
||||
{{taskGroup.name}}
|
||||
{{placementFailures.name}}
|
||||
<span class="badge is-light" data-test-placement-failure-coalesced-failures>{{inc failures.coalescedFailures}} unplaced</span>
|
||||
</h3>
|
||||
<ul class="simple-list">
|
||||
@@ -37,4 +37,3 @@
|
||||
</ul>
|
||||
{{/with}}
|
||||
{{/if}}
|
||||
|
||||
|
||||
@@ -4,16 +4,22 @@
|
||||
</button>
|
||||
{{else if isPendingConfirmation}}
|
||||
<span data-test-confirmation-message class="confirmation-text">{{confirmationMessage}}</span>
|
||||
<button data-test-cancel-button type="button" class="button is-dark is-outlined is-small" onclick={{action (queue
|
||||
(action "setToIdle")
|
||||
(action onCancel)
|
||||
)}}>
|
||||
<button
|
||||
data-test-cancel-button
|
||||
type="button"
|
||||
class="button is-dark is-outlined is-small"
|
||||
disabled={{awaitingConfirmation}}
|
||||
onclick={{action (queue
|
||||
(action "setToIdle")
|
||||
(action onCancel)
|
||||
)}}>
|
||||
{{cancelText}}
|
||||
</button>
|
||||
<button data-test-confirm-button class="button is-danger is-small" onclick={{action (queue
|
||||
(action "setToIdle")
|
||||
(action onConfirm)
|
||||
)}}>
|
||||
<button
|
||||
data-test-confirm-button
|
||||
class="button is-danger is-small {{if awaitingConfirmation "is-loading"}}"
|
||||
disabled={{awaitingConfirmation}}
|
||||
onclick={{action "confirm"}}>
|
||||
{{confirmText}}
|
||||
</button>
|
||||
{{/if}}
|
||||
|
||||
@@ -2,11 +2,16 @@
|
||||
{{#if isForbidden}}
|
||||
{{partial "partials/forbidden-message"}}
|
||||
{{else}}
|
||||
{{#if filteredJobs.length}}
|
||||
<div class="content">
|
||||
<div>{{search-box data-test-jobs-search searchTerm=(mut searchTerm) placeholder="Search jobs..."}}</div>
|
||||
<div class="columns">
|
||||
{{#if filteredJobs.length}}
|
||||
<div class="column">
|
||||
{{search-box data-test-jobs-search searchTerm=(mut searchTerm) placeholder="Search jobs..."}}
|
||||
</div>
|
||||
{{/if}}
|
||||
<div class="column is-centered">
|
||||
{{#link-to "jobs.run" data-test-run-job class="button is-primary is-pulled-right"}}Run Job{{/link-to}}
|
||||
</div>
|
||||
{{/if}}
|
||||
</div>
|
||||
{{#list-pagination
|
||||
source=sortedJobs
|
||||
size=pageSize
|
||||
|
||||
@@ -1,8 +1,21 @@
|
||||
{{partial "jobs/job/subnav"}}
|
||||
<section class="section">
|
||||
<div class="boxed-section">
|
||||
<div class="boxed-section-body is-full-bleed">
|
||||
{{json-viewer data-test-definition-view json=model.definition}}
|
||||
{{#unless isEditing}}
|
||||
<div class="boxed-section">
|
||||
<div class="boxed-section-head">
|
||||
Job Definition
|
||||
<button class="button is-light is-compact pull-right" type="button" onclick={{action edit}} data-test-edit-job>Edit</button>
|
||||
</div>
|
||||
<div class="boxed-section-body is-full-bleed">
|
||||
{{json-viewer data-test-definition-view json=definition}}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{{else}}
|
||||
{{job-editor
|
||||
job=job
|
||||
cancelable=true
|
||||
context="edit"
|
||||
onCancel=(action onCancel)
|
||||
onSubmit=(action onSubmit)}}
|
||||
{{/unless}}
|
||||
</section>
|
||||
|
||||
@@ -27,7 +27,7 @@
|
||||
{{#allocation-status-bar allocationContainer=model.summary class="split-view" as |chart|}}
|
||||
<ol class="legend">
|
||||
{{#each chart.data as |datum index|}}
|
||||
<li class="{{datum.className}} {{if (eq datum.index chart.activeDatum.index) "is-active"}} {{if (eq datum.value 0) "is-empty"}}">
|
||||
<li class="{{datum.className}} {{if (eq datum.label chart.activeDatum.label) "is-active"}} {{if (eq datum.value 0) "is-empty"}}">
|
||||
<span class="color-swatch {{if datum.className datum.className (concat "swatch-" index)}}" />
|
||||
<span class="value">{{datum.value}}</span>
|
||||
<span class="label">
|
||||
|
||||
6
ui/app/templates/jobs/run.hbs
Normal file
6
ui/app/templates/jobs/run.hbs
Normal file
@@ -0,0 +1,6 @@
|
||||
<section class="section">
|
||||
{{job-editor
|
||||
job=model
|
||||
context="new"
|
||||
onSubmit=(action onSubmit)}}
|
||||
</section>
|
||||
6
ui/app/utils/message-from-adapter-error.js
Normal file
6
ui/app/utils/message-from-adapter-error.js
Normal file
@@ -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');
|
||||
}
|
||||
}
|
||||
19
ui/app/utils/properties/local-storage.js
Normal file
19
ui/app/utils/properties/local-storage.js
Normal file
@@ -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;
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -13,7 +13,6 @@ module.exports = function(defaults) {
|
||||
paths: ['public/images/icons'],
|
||||
},
|
||||
codemirror: {
|
||||
themes: ['solarized'],
|
||||
modes: ['javascript'],
|
||||
},
|
||||
funnel: {
|
||||
|
||||
@@ -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;
|
||||
}, {});
|
||||
}
|
||||
|
||||
@@ -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 }),
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
);
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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: [
|
||||
{
|
||||
|
||||
@@ -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 }),
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ module.exports = {
|
||||
selectSearch: true,
|
||||
removeMultipleOption: true,
|
||||
clearSelected: true,
|
||||
getCodeMirrorInstance: true,
|
||||
},
|
||||
env: {
|
||||
embertest: true,
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
98
ui/tests/acceptance/job-run-test.js
Normal file
98
ui/tests/acceptance/job-run-test.js
Normal file
@@ -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}`
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
|
||||
|
||||
26
ui/tests/helpers/codemirror.js
Normal file
26
ui/tests/helpers/codemirror.js
Normal file
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
492
ui/tests/integration/job-editor-test.js
Normal file
492
ui/tests/integration/job-editor-test.js
Normal file
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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(
|
||||
|
||||
@@ -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'));
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -108,6 +108,7 @@ function createFixture(obj = {}, name = 'Placement Failure') {
|
||||
name: name,
|
||||
placementFailures: assign(
|
||||
{
|
||||
name: name,
|
||||
coalescedFailures: 10,
|
||||
nodesEvaluated: 0,
|
||||
nodesAvailable: {
|
||||
|
||||
@@ -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'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
11
ui/tests/pages/components/error.js
Normal file
11
ui/tests/pages/components/error.js
Normal file
@@ -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`),
|
||||
};
|
||||
}
|
||||
51
ui/tests/pages/components/job-editor.js
Normal file
51
ui/tests/pages/components/job-editor.js
Normal file
@@ -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'),
|
||||
},
|
||||
});
|
||||
32
ui/tests/pages/helpers/codemirror.js
Normal file
32
ui/tests/pages/helpers/codemirror.js
Normal file
@@ -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();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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]'),
|
||||
});
|
||||
|
||||
@@ -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]'),
|
||||
|
||||
8
ui/tests/pages/jobs/run.js
Normal file
8
ui/tests/pages/jobs/run.js
Normal file
@@ -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(),
|
||||
});
|
||||
Reference in New Issue
Block a user