Merge pull request #4600 from hashicorp/f-ui-job-writes

UI: Job Writes
This commit is contained in:
Michael Lange
2018-08-30 10:41:14 -07:00
committed by GitHub
64 changed files with 1822 additions and 127 deletions

View File

@@ -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;
}

View 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;
}

View File

@@ -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) {

View 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);
}
},
});

View File

@@ -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,
});
}
}),
});

View File

@@ -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,
});
}
}),
});

View 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'),
});

View File

@@ -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');
});
},
},
});

View File

@@ -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 },
});
},
});

View 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 },
});
},
});

View File

@@ -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);
},
});

View 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: () => [] }),
});

View File

@@ -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'),
});

View File

@@ -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');
});
});

View File

@@ -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
View 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();
}
},
});

View 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);
},
});

View File

@@ -18,6 +18,6 @@ export default Service.extend({
},
setIndexFor(url, value) {
list[url] = value;
list[url] = +value;
},
});

View File

@@ -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,

View File

@@ -1,5 +1,5 @@
.page-layout {
height: 100%;
min-height: 100%;
display: flex;
flex-direction: column;

View File

@@ -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';

View File

@@ -0,0 +1,3 @@
.is-associative {
margin-top: -0.75em;
}

View File

@@ -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

View File

@@ -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}}

View File

@@ -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>

View 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}}

View File

@@ -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>

View File

@@ -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

View File

@@ -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">

View File

@@ -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>

View File

@@ -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

View File

@@ -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}}

View File

@@ -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}}

View File

@@ -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

View File

@@ -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>

View File

@@ -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">

View File

@@ -0,0 +1,6 @@
<section class="section">
{{job-editor
job=model
context="new"
onSubmit=(action onSubmit)}}
</section>

View 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');
}
}

View 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;
},
});
}

View File

@@ -13,7 +13,6 @@ module.exports = function(defaults) {
paths: ['public/images/icons'],
},
codemirror: {
themes: ['solarized'],
modes: ['javascript'],
},
funnel: {

View File

@@ -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;
}, {});
}

View File

@@ -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 }),

View File

@@ -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,
})
);

View File

@@ -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,
};
}

View File

@@ -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: [
{

View File

@@ -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 }),

View File

@@ -5,6 +5,7 @@ module.exports = {
selectSearch: true,
removeMultipleOption: true,
clearSelected: true,
getCodeMirrorInstance: true,
},
env: {
embertest: true,

View File

@@ -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');
});
});

View 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}`
);
});
});

View File

@@ -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();

View 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);
});
}

View File

@@ -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);

View 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');
});
});

View File

@@ -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(

View File

@@ -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'));
});

View File

@@ -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();
});
});

View File

@@ -108,6 +108,7 @@ function createFixture(obj = {}, name = 'Placement Failure') {
name: name,
placementFailures: assign(
{
name: name,
coalescedFailures: 10,
nodesEvaluated: 0,
nodesAvailable: {

View File

@@ -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'
);
});
});

View 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`),
};
}

View 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'),
},
});

View 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();
},
};
}

View File

@@ -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]'),
});

View File

@@ -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]'),

View 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(),
});