Merge pull request #4189 from hashicorp/f-ui-stop-job-button

UI: Stop job button
This commit is contained in:
Michael Lange 2018-04-20 18:12:24 -07:00 committed by GitHub
commit b8c91c90fe
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
24 changed files with 527 additions and 113 deletions

View File

@ -74,14 +74,24 @@ export default Watchable.extend({
forcePeriodic(job) {
if (job.get('periodic')) {
const [path, params] = this.buildURL('job', job.get('id'), job, 'findRecord').split('?');
let url = `${path}/periodic/force`;
if (params) {
url += `?${params}`;
}
const url = addToPath(this.urlForFindRecord(job.get('id'), 'job'), '/periodic/force');
return this.ajax(url, 'POST');
}
},
stop(job) {
const url = this.urlForFindRecord(job.get('id'), 'job');
return this.ajax(url, 'DELETE');
},
});
function addToPath(url, extension = '') {
const [path, params] = url.split('?');
let newUrl = `${path}${extension}`;
if (params) {
newUrl += `?${params}`;
}
return newUrl;
}

View File

@ -17,6 +17,9 @@ export default Component.extend({
gotoTaskGroup() {},
gotoJob() {},
// Set to a { title, description } to surface an error
errorMessage: null,
breadcrumbs: computed('job.{name,id}', function() {
const job = this.get('job');
return [
@ -33,4 +36,13 @@ export default Component.extend({
},
];
}),
actions: {
clearErrorMessage() {
this.set('errorMessage', null);
},
handleError(errorObject) {
this.set('errorMessage', errorObject);
},
},
});

View File

@ -0,0 +1,8 @@
import Component from '@ember/component';
export default Component.extend({
tagName: '',
errorMessage: null,
onDismiss() {},
});

View File

@ -0,0 +1,23 @@
import Component from '@ember/component';
export default Component.extend({
tagName: '',
job: null,
title: null,
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.',
});
});
},
},
});

View File

@ -4,18 +4,21 @@ import { inject as service } from '@ember/service';
export default AbstractJobPage.extend({
store: service(),
errorMessage: '',
errorMessage: null,
actions: {
forceLaunch() {
this.get('job')
.forcePeriodic()
.catch(error => {
this.set('errorMessage', `Could not force launch: ${error}`);
.catch(() => {
this.set('errorMessage', {
title: 'Could Not Force Launch',
description: 'Your ACL token does not grant permission to submit jobs.',
});
});
},
clearErrorMessage() {
this.set('errorMessage', '');
this.set('errorMessage', null);
},
},
});

View File

@ -0,0 +1,26 @@
import Component from '@ember/component';
import { equal } from '@ember/object/computed';
export default Component.extend({
classNames: ['two-step-button'],
idleText: '',
cancelText: '',
confirmText: '',
confirmationMessage: '',
onConfirm() {},
onCancel() {},
state: 'idle',
isIdle: equal('state', 'idle'),
isPendingConfirmation: equal('state', 'prompt'),
actions: {
setToIdle() {
this.set('state', 'idle');
},
promptForConfirmation() {
this.set('state', 'prompt');
},
},
});

View File

@ -162,6 +162,10 @@ export default Model.extend({
return this.store.adapterFor('job').forcePeriodic(this);
},
stop() {
return this.store.adapterFor('job').stop(this);
},
statusClass: computed('status', function() {
const classMap = {
pending: 'is-pending',

View File

@ -1,18 +1,19 @@
@import "./components/badge";
@import "./components/boxed-section";
@import "./components/cli-window";
@import "./components/ember-power-select";
@import "./components/empty-message";
@import "./components/error-container";
@import "./components/gutter";
@import "./components/inline-definitions";
@import "./components/job-diff";
@import "./components/json-viewer";
@import "./components/loading-spinner";
@import "./components/metrics";
@import "./components/node-status-light";
@import "./components/page-layout";
@import "./components/simple-list";
@import "./components/status-text";
@import "./components/timeline";
@import "./components/tooltip";
@import './components/badge';
@import './components/boxed-section';
@import './components/cli-window';
@import './components/ember-power-select';
@import './components/empty-message';
@import './components/error-container';
@import './components/gutter';
@import './components/inline-definitions';
@import './components/job-diff';
@import './components/json-viewer';
@import './components/loading-spinner';
@import './components/metrics';
@import './components/node-status-light';
@import './components/page-layout';
@import './components/simple-list';
@import './components/status-text';
@import './components/timeline';
@import './components/tooltip';
@import './components/two-step-button';

View File

@ -0,0 +1,14 @@
.two-step-button {
display: inline;
position: relative;
.confirmation-text {
position: absolute;
left: 0;
top: -1.2em;
font-size: $body-size;
font-weight: $weight-normal;
color: darken($grey-blue, 20%);
white-space: nowrap;
}
}

View File

@ -6,10 +6,9 @@
{{/each}}
{{/global-header}}
{{#job-page/parts/body job=job onNamespaceChange=onNamespaceChange}}
<h1 class="title">
{{job.name}}
<span class="bumper-left tag {{job.statusClass}}" data-test-job-status>{{job.status}}</span>
</h1>
{{job-page/parts/error errorMessage=errorMessage onDismiss=(action "clearErrorMessage")}}
{{job-page/parts/title job=job handleError=(action "handleError")}}
<div class="boxed-section job-stats">
<div class="boxed-section-body">

View File

@ -6,10 +6,9 @@
{{/each}}
{{/global-header}}
{{#job-page/parts/body job=job onNamespaceChange=onNamespaceChange}}
<h1 class="title">
{{job.trimmedName}}
<span class="bumper-left tag {{job.statusClass}}" data-test-job-status>{{job.status}}</span>
</h1>
{{job-page/parts/error errorMessage=errorMessage onDismiss=(action "clearErrorMessage")}}
{{job-page/parts/title job=job title=job.trimmedName handleError=(action "handleError")}}
<div class="boxed-section job-stats">
<div class="boxed-section-body">

View File

@ -6,11 +6,11 @@
{{/each}}
{{/global-header}}
{{#job-page/parts/body job=job onNamespaceChange=onNamespaceChange}}
<h1 class="title">
{{job.name}}
<span class="bumper-left tag {{job.statusClass}}" data-test-job-status>{{job.status}}</span>
{{job-page/parts/error errorMessage=errorMessage onDismiss=(action "clearErrorMessage")}}
{{#job-page/parts/title job=job handleError=(action "handleError")}}
<span class="tag is-hollow">Parameterized</span>
</h1>
{{/job-page/parts/title}}
<div class="boxed-section job-stats">
<div class="boxed-section-body">

View File

@ -0,0 +1,13 @@
{{#if errorMessage}}
<div class="notification is-danger">
<div class="columns">
<div class="column">
<h3 data-test-job-error-title class="title is-4">{{errorMessage.title}}</h3>
<p data-test-job-error-body>{{errorMessage.description}}</p>
</div>
<div class="column is-centered is-minimum">
<button data-test-job-error-close class="button is-danger" onclick={{action onDismiss}}>Okay</button>
</div>
</div>
</div>
{{/if}}

View File

@ -0,0 +1,14 @@
<h1 class="title">
{{or title job.name}}
<span class="bumper-left tag {{job.statusClass}}" data-test-job-status>{{job.status}}</span>
{{yield}}
{{#if (not (eq job.status "dead"))}}
{{two-step-button
data-test-stop
idleText="Stop"
cancelText="Cancel"
confirmText="Yes, Stop"
confirmationMessage="Are you sure you want to stop this job?"
onConfirm=(action "stopJob")}}
{{/if}}
</h1>

View File

@ -6,10 +6,9 @@
{{/each}}
{{/global-header}}
{{#job-page/parts/body job=job onNamespaceChange=onNamespaceChange}}
<h1 class="title">
{{job.trimmedName}}
<span class="bumper-left tag {{job.statusClass}}" data-test-job-status>{{job.status}}</span>
</h1>
{{job-page/parts/error errorMessage=errorMessage onDismiss=(action "clearErrorMessage")}}
{{job-page/parts/title job=job title=job.trimmedName handleError=(action "handleError")}}
<div class="boxed-section job-stats">
<div class="boxed-section-body">

View File

@ -6,25 +6,12 @@
{{/each}}
{{/global-header}}
{{#job-page/parts/body job=job onNamespaceChange=onNamespaceChange}}
{{#if errorMessage}}
<div class="notification is-danger">
<div class="columns">
<div class="column">
<h3 data-test-force-error-title class="title is-4">Could Not Force Launch</h3>
<p data-test-force-error-body>Your ACL token does not grant permission to submit jobs.</p>
</div>
<div class="column is-centered is-minimum">
<button data-test-force-error-close class="button is-danger" {{action "clearErrorMessage"}}>Okay</button>
</div>
</div>
</div>
{{/if}}
<h1 class="title">
{{job.name}}
<span class="bumper-left tag {{job.statusClass}}" data-test-job-status>{{job.status}}</span>
{{job-page/parts/error errorMessage=errorMessage onDismiss=(action "clearErrorMessage")}}
{{#job-page/parts/title job=job title=job.trimmedName handleError=(action "handleError")}}
<span class="tag is-hollow">periodic</span>
<button data-test-force-launch class="button is-warning is-small is-inline" onclick={{action "forceLaunch"}}>Force Launch</button>
</h1>
{{/job-page/parts/title}}
<div class="boxed-section job-stats">
<div class="boxed-section-body">

View File

@ -6,10 +6,9 @@
{{/each}}
{{/global-header}}
{{#job-page/parts/body job=job onNamespaceChange=onNamespaceChange}}
<h1 class="title">
{{job.name}}
<span class="bumper-left tag {{job.statusClass}}" data-test-job-status>{{job.status}}</span>
</h1>
{{job-page/parts/error errorMessage=errorMessage onDismiss=(action "clearErrorMessage")}}
{{job-page/parts/title job=job handleError=(action "handleError")}}
<div class="boxed-section job-stats">
<div class="boxed-section-body">

View File

@ -6,10 +6,9 @@
{{/each}}
{{/global-header}}
{{#job-page/parts/body job=job onNamespaceChange=onNamespaceChange}}
<h1 class="title">
{{job.name}}
<span class="bumper-left tag {{job.statusClass}}" data-test-job-status>{{job.status}}</span>
</h1>
{{job-page/parts/error errorMessage=errorMessage onDismiss=(action "clearErrorMessage")}}
{{job-page/parts/title job=job handleError=(action "handleError")}}
<div class="boxed-section job-stats">
<div class="boxed-section-body">

View File

@ -0,0 +1,19 @@
{{#if isIdle}}
<button data-test-idle-button type="button" class="button is-warning is-small is-inline" onclick={{action "promptForConfirmation"}}>
{{idleText}}
</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 is-inline" onclick={{action (queue
(action "setToIdle")
(action onCancel)
)}}>
{{cancelText}}
</button>
<button data-test-confirm-button class="button is-danger is-small is-inline" onclick={{action (queue
(action "setToIdle")
(action onConfirm)
)}}>
{{confirmText}}
</button>
{{/if}}

View File

@ -106,6 +106,12 @@ export default function() {
return new Response(200, {}, '{}');
});
this.delete('/job/:id', function(schema, { params }) {
const job = schema.jobs.find(params.id);
job.update({ status: 'dead' });
return new Response(204, {}, '');
});
this.get('/deployment/:id');
this.get('/job/:id/evaluations', function({ evaluations }, { params }) {

View File

@ -0,0 +1,51 @@
import { click, find } from 'ember-native-dom-helpers';
import wait from 'ember-test-helpers/wait';
export function jobURL(job, path = '') {
const id = job.get('plainId');
const namespace = job.get('namespace.name') || 'default';
let expectedURL = `/v1/job/${id}${path}`;
if (namespace !== 'default') {
expectedURL += `?namespace=${namespace}`;
}
return expectedURL;
}
export function stopJob() {
click('[data-test-stop] [data-test-idle-button]');
return wait().then(() => {
click('[data-test-stop] [data-test-confirm-button]');
return wait();
});
}
export function expectStopError(assert) {
return () => {
assert.equal(
find('[data-test-job-error-title]').textContent,
'Could Not Stop Job',
'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();
};
}
export function expectDeleteRequest(assert, server, job) {
const expectedURL = jobURL(job);
assert.ok(
server.pretender.handledRequests
.filterBy('method', 'DELETE')
.find(req => req.url === expectedURL),
'DELETE URL was made correctly'
);
return wait();
}

View File

@ -4,6 +4,7 @@ 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';
moduleForComponent('job-page/periodic', 'Integration | Component | job-page/periodic', {
integration: true,
@ -19,6 +20,23 @@ moduleForComponent('job-page/periodic', 'Integration | Component | job-page/peri
},
});
const commonTemplate = hbs`
{{job-page/periodic
job=job
sortProperty=sortProperty
sortDescending=sortDescending
currentPage=currentPage
gotoJob=gotoJob}}
`;
const commonProperties = job => ({
job,
sortProperty: 'name',
sortDescending: true,
currentPage: 1,
gotoJob: () => {},
});
test('Clicking Force Launch launches a new periodic child job', function(assert) {
const childrenCount = 3;
@ -32,22 +50,9 @@ test('Clicking Force Launch launches a new periodic child job', function(assert)
return wait().then(() => {
const job = this.store.peekAll('job').findBy('plainId', 'parent');
this.setProperties({
job,
sortProperty: 'name',
sortDescending: true,
currentPage: 1,
gotoJob: () => {},
});
this.render(hbs`
{{job-page/periodic
job=job
sortProperty=sortProperty
sortDescending=sortDescending
currentPage=currentPage
gotoJob=gotoJob}}
`);
this.setProperties(commonProperties(job));
this.render(commonTemplate);
return wait().then(() => {
const currentJobCount = server.db.jobs.length;
@ -61,15 +66,10 @@ test('Clicking Force Launch launches a new periodic child job', function(assert)
click('[data-test-force-launch]');
return wait().then(() => {
const id = job.get('plainId');
const namespace = job.get('namespace.name') || 'default';
let expectedURL = `/v1/job/${id}/periodic/force`;
if (namespace !== 'default') {
expectedURL += `?namespace=${namespace}`;
}
const expectedURL = jobURL(job, '/periodic/force');
assert.ok(
server.pretender.handledRequests
this.server.pretender.handledRequests
.filterBy('method', 'POST')
.find(req => req.url === expectedURL),
'POST URL was correct'
@ -82,55 +82,90 @@ test('Clicking Force Launch launches a new periodic child job', function(assert)
});
test('Clicking force launch without proper permissions shows an error message', function(assert) {
server.pretender.post('/v1/job/:id/periodic/force', () => [403, {}, null]);
this.server.pretender.post('/v1/job/:id/periodic/force', () => [403, {}, null]);
this.server.create('job', 'periodic', {
id: 'parent',
childrenCount: 1,
createAllocations: false,
status: 'running',
});
this.store.findAll('job');
return wait().then(() => {
const job = this.store.peekAll('job').findBy('plainId', 'parent');
this.setProperties({
job,
sortProperty: 'name',
sortDescending: true,
currentPage: 1,
gotoJob: () => {},
});
this.render(hbs`
{{job-page/periodic
job=job
sortProperty=sortProperty
sortDescending=sortDescending
currentPage=currentPage
gotoJob=gotoJob}}
`);
this.setProperties(commonProperties(job));
this.render(commonTemplate);
return wait().then(() => {
assert.notOk(find('[data-test-force-error-title]'), 'No error message yet');
assert.notOk(find('[data-test-job-error-title]'), 'No error message yet');
click('[data-test-force-launch]');
return wait().then(() => {
assert.equal(
find('[data-test-force-error-title]').textContent,
find('[data-test-job-error-title]').textContent,
'Could Not Force Launch',
'Appropriate error is shown'
);
assert.ok(
find('[data-test-force-error-body]').textContent.includes('ACL'),
find('[data-test-job-error-body]').textContent.includes('ACL'),
'The error message mentions ACLs'
);
click('[data-test-force-error-close]');
click('[data-test-job-error-close]');
assert.notOk(find('[data-test-force-error-title]'), 'Error message is dismissable');
assert.notOk(find('[data-test-job-error-title]'), 'Error message is dismissable');
});
});
});
});
test('Stopping a job sends a delete request for the job', function(assert) {
const mirageJob = this.server.create('job', 'periodic', {
childrenCount: 0,
createAllocations: false,
status: 'running',
});
let job;
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(stopJob)
.then(() => expectDeleteRequest(assert, this.server, job));
});
test('Stopping a job without proper permissions shows an error message', function(assert) {
this.server.pretender.delete('/v1/job/:id', () => [403, {}, null]);
const mirageJob = this.server.create('job', 'periodic', {
childrenCount: 0,
createAllocations: false,
status: 'running',
});
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(stopJob)
.then(expectStopError(assert));
});

View File

@ -0,0 +1,82 @@
import { getOwner } from '@ember/application';
import { test, moduleForComponent } from 'ember-qunit';
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';
moduleForComponent('job-page/service', 'Integration | Component | job-page/service', {
integration: true,
beforeEach() {
window.localStorage.clear();
this.store = getOwner(this).lookup('service:store');
this.server = startMirage();
this.server.create('namespace');
},
afterEach() {
this.server.shutdown();
window.localStorage.clear();
},
});
const commonTemplate = hbs`
{{job-page/service
job=job
sortProperty=sortProperty
sortDescending=sortDescending
currentPage=currentPage
gotoJob=gotoJob}}
`;
const commonProperties = job => ({
job,
sortProperty: 'name',
sortDescending: true,
currentPage: 1,
gotoJob() {},
});
const makeMirageJob = server =>
server.create('job', {
type: 'service',
createAllocations: false,
status: 'running',
});
test('Stopping a job sends a delete request for the job', function(assert) {
let job;
const mirageJob = makeMirageJob(this.server);
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(stopJob)
.then(() => expectDeleteRequest(assert, this.server, job));
});
test('Stopping a job without proper permissions shows an error message', function(assert) {
this.server.pretender.delete('/v1/job/:id', () => [403, {}, null]);
const mirageJob = makeMirageJob(this.server);
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(stopJob)
.then(expectStopError(assert));
});

View File

@ -0,0 +1,111 @@
import { find, click } from 'ember-native-dom-helpers';
import { test, moduleForComponent } from 'ember-qunit';
import wait from 'ember-test-helpers/wait';
import hbs from 'htmlbars-inline-precompile';
import sinon from 'sinon';
moduleForComponent('two-step-button', 'Integration | Component | two step button', {
integration: true,
});
const commonProperties = () => ({
idleText: 'Idle State Button',
cancelText: 'Cancel Action',
confirmText: 'Confirm Action',
confirmationMessage: 'Are you certain',
onConfirm: sinon.spy(),
onCancel: sinon.spy(),
});
const commonTemplate = hbs`
{{two-step-button
idleText=idleText
cancelText=cancelText
confirmText=confirmText
confirmationMessage=confirmationMessage
onConfirm=onConfirm
onCancel=onCancel}}
`;
test('presents as a button in the idle state', function(assert) {
const props = commonProperties();
this.setProperties(props);
this.render(commonTemplate);
assert.ok(find('[data-test-idle-button]'), 'Idle button is rendered');
assert.equal(
find('[data-test-idle-button]').textContent.trim(),
props.idleText,
'Button is labeled correctly'
);
assert.notOk(find('[data-test-cancel-button]'), 'No cancel button yet');
assert.notOk(find('[data-test-confirm-button]'), 'No confirm button yet');
assert.notOk(find('[data-test-confirmation-message]'), 'No confirmation message yet');
});
test('clicking the idle state button transitions into the promptForConfirmation state', function(assert) {
const props = commonProperties();
this.setProperties(props);
this.render(commonTemplate);
click('[data-test-idle-button]');
return wait().then(() => {
assert.ok(find('[data-test-cancel-button]'), 'Cancel button is rendered');
assert.equal(
find('[data-test-cancel-button]').textContent.trim(),
props.cancelText,
'Button is labeled correctly'
);
assert.ok(find('[data-test-confirm-button]'), 'Confirm button is rendered');
assert.equal(
find('[data-test-confirm-button]').textContent.trim(),
props.confirmText,
'Button is labeled correctly'
);
assert.equal(
find('[data-test-confirmation-message]').textContent.trim(),
props.confirmationMessage,
'Confirmation message is shown'
);
assert.notOk(find('[data-test-idle-button]'), 'No more idle button');
});
});
test('canceling in the promptForConfirmation state calls the onCancel hook and resets to the idle state', function(assert) {
const props = commonProperties();
this.setProperties(props);
this.render(commonTemplate);
click('[data-test-idle-button]');
return wait().then(() => {
click('[data-test-cancel-button]');
return wait().then(() => {
assert.ok(props.onCancel.calledOnce, 'The onCancel hook fired');
assert.ok(find('[data-test-idle-button]'), 'Idle button is back');
});
});
});
test('confirming the promptForConfirmation state calls the onConfirm hook and resets to the idle state', function(assert) {
const props = commonProperties();
this.setProperties(props);
this.render(commonTemplate);
click('[data-test-idle-button]');
return wait().then(() => {
click('[data-test-confirm-button]');
return wait().then(() => {
assert.ok(props.onConfirm.calledOnce, 'The onConfirm hook fired');
assert.ok(find('[data-test-idle-button]'), 'Idle button is back');
});
});
});