Merge pull request #5734 from hashicorp/f-ui/allocation-lifecycle
UI: Allocation lifecycle
This commit is contained in:
commit
dff3abb630
|
@ -1,3 +1,21 @@
|
||||||
import Watchable from './watchable';
|
import Watchable from './watchable';
|
||||||
|
import addToPath from 'nomad-ui/utils/add-to-path';
|
||||||
|
|
||||||
export default Watchable.extend();
|
export default Watchable.extend({
|
||||||
|
stop: adapterAction('/stop'),
|
||||||
|
|
||||||
|
restart(allocation, taskName) {
|
||||||
|
const prefix = `${this.host || '/'}${this.urlPrefix()}`;
|
||||||
|
const url = `${prefix}/client/allocation/${allocation.id}/restart`;
|
||||||
|
return this.ajax(url, 'PUT', {
|
||||||
|
data: taskName && { TaskName: taskName },
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
function adapterAction(path, verb = 'POST') {
|
||||||
|
return function(allocation) {
|
||||||
|
const url = addToPath(this.urlForFindRecord(allocation.id, 'allocation'), path);
|
||||||
|
return this.ajax(url, verb);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
|
@ -1,5 +1,6 @@
|
||||||
import { inject as service } from '@ember/service';
|
import { inject as service } from '@ember/service';
|
||||||
import Watchable from './watchable';
|
import Watchable from './watchable';
|
||||||
|
import addToPath from 'nomad-ui/utils/add-to-path';
|
||||||
|
|
||||||
export default Watchable.extend({
|
export default Watchable.extend({
|
||||||
system: service(),
|
system: service(),
|
||||||
|
@ -118,14 +119,3 @@ function associateNamespace(url, namespace) {
|
||||||
}
|
}
|
||||||
return url;
|
return url;
|
||||||
}
|
}
|
||||||
|
|
||||||
function addToPath(url, extension = '') {
|
|
||||||
const [path, params] = url.split('?');
|
|
||||||
let newUrl = `${path}${extension}`;
|
|
||||||
|
|
||||||
if (params) {
|
|
||||||
newUrl += `?${params}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
return newUrl;
|
|
||||||
}
|
|
||||||
|
|
|
@ -1,5 +1,7 @@
|
||||||
import Component from '@ember/component';
|
import Component from '@ember/component';
|
||||||
|
import { next } from '@ember/runloop';
|
||||||
import { equal } from '@ember/object/computed';
|
import { equal } from '@ember/object/computed';
|
||||||
|
import { task, waitForEvent } from 'ember-concurrency';
|
||||||
import RSVP from 'rsvp';
|
import RSVP from 'rsvp';
|
||||||
|
|
||||||
export default Component.extend({
|
export default Component.extend({
|
||||||
|
@ -10,6 +12,7 @@ export default Component.extend({
|
||||||
confirmText: '',
|
confirmText: '',
|
||||||
confirmationMessage: '',
|
confirmationMessage: '',
|
||||||
awaitingConfirmation: false,
|
awaitingConfirmation: false,
|
||||||
|
disabled: false,
|
||||||
onConfirm() {},
|
onConfirm() {},
|
||||||
onCancel() {},
|
onCancel() {},
|
||||||
|
|
||||||
|
@ -17,12 +20,25 @@ export default Component.extend({
|
||||||
isIdle: equal('state', 'idle'),
|
isIdle: equal('state', 'idle'),
|
||||||
isPendingConfirmation: equal('state', 'prompt'),
|
isPendingConfirmation: equal('state', 'prompt'),
|
||||||
|
|
||||||
|
cancelOnClickOutside: task(function*() {
|
||||||
|
while (true) {
|
||||||
|
let ev = yield waitForEvent(document.body, 'click');
|
||||||
|
if (!this.element.contains(ev.target) && !this.awaitingConfirmation) {
|
||||||
|
this.send('setToIdle');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
|
||||||
actions: {
|
actions: {
|
||||||
setToIdle() {
|
setToIdle() {
|
||||||
this.set('state', 'idle');
|
this.set('state', 'idle');
|
||||||
|
this.cancelOnClickOutside.cancelAll();
|
||||||
},
|
},
|
||||||
promptForConfirmation() {
|
promptForConfirmation() {
|
||||||
this.set('state', 'prompt');
|
this.set('state', 'prompt');
|
||||||
|
next(() => {
|
||||||
|
this.cancelOnClickOutside.perform();
|
||||||
|
});
|
||||||
},
|
},
|
||||||
confirm() {
|
confirm() {
|
||||||
RSVP.resolve(this.onConfirm()).then(() => {
|
RSVP.resolve(this.onConfirm()).then(() => {
|
||||||
|
|
|
@ -1,8 +1,11 @@
|
||||||
import Controller from '@ember/controller';
|
import Controller from '@ember/controller';
|
||||||
import { inject as service } from '@ember/service';
|
import { inject as service } from '@ember/service';
|
||||||
|
import { computed, observer } from '@ember/object';
|
||||||
import { alias } from '@ember/object/computed';
|
import { alias } from '@ember/object/computed';
|
||||||
|
import { task } from 'ember-concurrency';
|
||||||
import Sortable from 'nomad-ui/mixins/sortable';
|
import Sortable from 'nomad-ui/mixins/sortable';
|
||||||
import { lazyClick } from 'nomad-ui/helpers/lazy-click';
|
import { lazyClick } from 'nomad-ui/helpers/lazy-click';
|
||||||
|
import { watchRecord } from 'nomad-ui/utils/properties/watch';
|
||||||
|
|
||||||
export default Controller.extend(Sortable, {
|
export default Controller.extend(Sortable, {
|
||||||
token: service(),
|
token: service(),
|
||||||
|
@ -21,6 +24,50 @@ export default Controller.extend(Sortable, {
|
||||||
// Set in the route
|
// Set in the route
|
||||||
preempter: null,
|
preempter: null,
|
||||||
|
|
||||||
|
error: computed(() => {
|
||||||
|
// { title, description }
|
||||||
|
return null;
|
||||||
|
}),
|
||||||
|
|
||||||
|
onDismiss() {
|
||||||
|
this.set('error', null);
|
||||||
|
},
|
||||||
|
|
||||||
|
watchNext: watchRecord('allocation'),
|
||||||
|
|
||||||
|
observeWatchNext: observer('model.nextAllocation.clientStatus', function() {
|
||||||
|
const nextAllocation = this.model.nextAllocation;
|
||||||
|
if (nextAllocation && nextAllocation.content) {
|
||||||
|
this.watchNext.perform(nextAllocation);
|
||||||
|
} else {
|
||||||
|
this.watchNext.cancelAll();
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
|
||||||
|
stopAllocation: task(function*() {
|
||||||
|
try {
|
||||||
|
yield this.model.stop();
|
||||||
|
// Eagerly update the allocation clientStatus to avoid flickering
|
||||||
|
this.model.set('clientStatus', 'complete');
|
||||||
|
} catch (err) {
|
||||||
|
this.set('error', {
|
||||||
|
title: 'Could Not Stop Allocation',
|
||||||
|
description: 'Your ACL token does not grant allocation lifecycle permissions.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
|
||||||
|
restartAllocation: task(function*() {
|
||||||
|
try {
|
||||||
|
yield this.model.restart();
|
||||||
|
} catch (err) {
|
||||||
|
this.set('error', {
|
||||||
|
title: 'Could Not Restart Allocation',
|
||||||
|
description: 'Your ACL token does not grant allocation lifecycle permissions.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
|
||||||
actions: {
|
actions: {
|
||||||
gotoTask(allocation, task) {
|
gotoTask(allocation, task) {
|
||||||
this.transitionToRoute('allocations.allocation.task', task);
|
this.transitionToRoute('allocations.allocation.task', task);
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
import Controller from '@ember/controller';
|
import Controller from '@ember/controller';
|
||||||
import { computed } from '@ember/object';
|
import { computed } from '@ember/object';
|
||||||
import { alias } from '@ember/object/computed';
|
import { alias } from '@ember/object/computed';
|
||||||
|
import { task } from 'ember-concurrency';
|
||||||
|
|
||||||
export default Controller.extend({
|
export default Controller.extend({
|
||||||
network: alias('model.resources.networks.firstObject'),
|
network: alias('model.resources.networks.firstObject'),
|
||||||
|
@ -20,4 +21,24 @@ export default Controller.extend({
|
||||||
)
|
)
|
||||||
.sortBy('name');
|
.sortBy('name');
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
error: computed(() => {
|
||||||
|
// { title, description }
|
||||||
|
return null;
|
||||||
|
}),
|
||||||
|
|
||||||
|
onDismiss() {
|
||||||
|
this.set('error', null);
|
||||||
|
},
|
||||||
|
|
||||||
|
restartTask: task(function*() {
|
||||||
|
try {
|
||||||
|
yield this.model.restart();
|
||||||
|
} catch (err) {
|
||||||
|
this.set('error', {
|
||||||
|
title: 'Could Not Restart Task',
|
||||||
|
description: 'Your ACL token does not grant allocation lifecycle permissions.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
|
|
|
@ -31,8 +31,11 @@ export default Mixin.create(WithVisibilityDetection, {
|
||||||
},
|
},
|
||||||
|
|
||||||
actions: {
|
actions: {
|
||||||
willTransition() {
|
willTransition(transition) {
|
||||||
|
// Don't cancel watchers if transitioning into a sub-route
|
||||||
|
if (!transition.intent.name || !transition.intent.name.startsWith(this.routeName)) {
|
||||||
this.cancelAllWatchers();
|
this.cancelAllWatchers();
|
||||||
|
}
|
||||||
|
|
||||||
// Bubble the action up to the application route
|
// Bubble the action up to the application route
|
||||||
return true;
|
return true;
|
||||||
|
|
|
@ -99,4 +99,12 @@ export default Model.extend({
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
),
|
),
|
||||||
|
|
||||||
|
stop() {
|
||||||
|
return this.store.adapterFor('allocation').stop(this);
|
||||||
|
},
|
||||||
|
|
||||||
|
restart(taskName) {
|
||||||
|
return this.store.adapterFor('allocation').restart(this, taskName);
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
|
@ -43,4 +43,8 @@ export default Fragment.extend({
|
||||||
|
|
||||||
return classMap[this.state] || 'is-dark';
|
return classMap[this.state] || 'is-dark';
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
restart() {
|
||||||
|
return this.allocation.restart(this.name);
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
|
@ -10,4 +10,10 @@ export default Route.extend({
|
||||||
|
|
||||||
return this._super(...arguments);
|
return this._super(...arguments);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
resetController(controller, isExiting) {
|
||||||
|
if (isExiting) {
|
||||||
|
controller.watchNext.cancelAll();
|
||||||
|
}
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
|
@ -4,4 +4,8 @@
|
||||||
&.is-6 {
|
&.is-6 {
|
||||||
margin-bottom: 0.5rem;
|
margin-bottom: 0.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&.with-headroom {
|
||||||
|
margin-top: 1rem;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,8 +1,42 @@
|
||||||
<section class="section">
|
<section class="section">
|
||||||
<h1 data-test-title class="title">
|
{{#if error}}
|
||||||
|
<div data-test-inline-error class="notification is-danger">
|
||||||
|
<div class="columns">
|
||||||
|
<div class="column">
|
||||||
|
<h3 data-test-inline-error-title class="title is-4">{{error.title}}</h3>
|
||||||
|
<p data-test-inline-error-body>{{error.description}}</p>
|
||||||
|
</div>
|
||||||
|
<div class="column is-centered is-minimum">
|
||||||
|
<button data-test-inline-error-close class="button is-danger" onclick={{action onDismiss}}>Okay</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{/if}}
|
||||||
|
|
||||||
|
<h1 data-test-title class="title with-headroom">
|
||||||
Allocation {{model.name}}
|
Allocation {{model.name}}
|
||||||
<span class="bumper-left tag {{model.statusClass}}">{{model.clientStatus}}</span>
|
<span class="bumper-left tag {{model.statusClass}}">{{model.clientStatus}}</span>
|
||||||
<span class="tag is-hollow is-small no-text-transform">{{model.id}}</span>
|
<span class="tag is-hollow is-small no-text-transform">{{model.id}}</span>
|
||||||
|
{{#if model.isRunning}}
|
||||||
|
{{two-step-button
|
||||||
|
data-test-stop
|
||||||
|
idleText="Stop"
|
||||||
|
cancelText="Cancel"
|
||||||
|
confirmText="Yes, Stop"
|
||||||
|
confirmationMessage="Are you sure? This will reschedule the allocation on a different client."
|
||||||
|
awaitingConfirmation=stopAllocation.isRunning
|
||||||
|
disabled=(or stopAllocation.isRunning restartAllocation.isRunning)
|
||||||
|
onConfirm=(perform stopAllocation)}}
|
||||||
|
{{two-step-button
|
||||||
|
data-test-restart
|
||||||
|
idleText="Restart"
|
||||||
|
cancelText="Cancel"
|
||||||
|
confirmText="Yes, Restart"
|
||||||
|
confirmationMessage="Are you sure? This will restart the allocation in-place."
|
||||||
|
awaitingConfirmation=restartAllocation.isRunning
|
||||||
|
disabled=(or stopAllocation.isRunning restartAllocation.isRunning)
|
||||||
|
onConfirm=(perform restartAllocation)}}
|
||||||
|
{{/if}}
|
||||||
</h1>
|
</h1>
|
||||||
|
|
||||||
<div class="boxed-section is-small">
|
<div class="boxed-section is-small">
|
||||||
|
|
|
@ -1,8 +1,33 @@
|
||||||
{{partial "allocations/allocation/task/subnav"}}
|
{{partial "allocations/allocation/task/subnav"}}
|
||||||
<section class="section">
|
<section class="section">
|
||||||
|
{{#if error}}
|
||||||
|
<div data-test-inline-error class="notification is-danger">
|
||||||
|
<div class="columns">
|
||||||
|
<div class="column">
|
||||||
|
<h3 data-test-inline-error-title class="title is-4">{{error.title}}</h3>
|
||||||
|
<p data-test-inline-error-body>{{error.description}}</p>
|
||||||
|
</div>
|
||||||
|
<div class="column is-centered is-minimum">
|
||||||
|
<button data-test-inline-error-close class="button is-danger" onclick={{action onDismiss}}>Okay</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{/if}}
|
||||||
|
|
||||||
<h1 class="title" data-test-title>
|
<h1 class="title" data-test-title>
|
||||||
{{model.name}}
|
{{model.name}}
|
||||||
<span class="bumper-left tag {{model.stateClass}}" data-test-state>{{model.state}}</span>
|
<span class="bumper-left tag {{model.stateClass}}" data-test-state>{{model.state}}</span>
|
||||||
|
{{#if model.isRunning}}
|
||||||
|
{{two-step-button
|
||||||
|
data-test-restart
|
||||||
|
idleText="Restart"
|
||||||
|
cancelText="Cancel"
|
||||||
|
confirmText="Yes, Restart"
|
||||||
|
confirmationMessage="Are you sure? This will restart the task in-place."
|
||||||
|
awaitingConfirmation=restartTask.isRunning
|
||||||
|
disabled=restartTask.isRunning
|
||||||
|
onConfirm=(perform restartTask)}}
|
||||||
|
{{/if}}
|
||||||
</h1>
|
</h1>
|
||||||
|
|
||||||
<div class="boxed-section is-small">
|
<div class="boxed-section is-small">
|
||||||
|
|
|
@ -1,5 +1,10 @@
|
||||||
{{#if isIdle}}
|
{{#if isIdle}}
|
||||||
<button data-test-idle-button type="button" class="button is-danger is-outlined is-important is-small" onclick={{action "promptForConfirmation"}}>
|
<button
|
||||||
|
data-test-idle-button
|
||||||
|
type="button"
|
||||||
|
class="button is-danger is-outlined is-important is-small"
|
||||||
|
disabled={{disabled}}
|
||||||
|
onclick={{action "promptForConfirmation"}}>
|
||||||
{{idleText}}
|
{{idleText}}
|
||||||
</button>
|
</button>
|
||||||
{{else if isPendingConfirmation}}
|
{{else if isPendingConfirmation}}
|
||||||
|
|
11
ui/app/utils/add-to-path.js
Normal file
11
ui/app/utils/add-to-path.js
Normal file
|
@ -0,0 +1,11 @@
|
||||||
|
// Adds a string to the end of a URL path while being mindful of query params
|
||||||
|
export default function addToPath(url, extension = '') {
|
||||||
|
const [path, params] = url.split('?');
|
||||||
|
let newUrl = `${path}${extension}`;
|
||||||
|
|
||||||
|
if (params) {
|
||||||
|
newUrl += `?${params}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return newUrl;
|
||||||
|
}
|
|
@ -200,6 +200,10 @@ export default function() {
|
||||||
|
|
||||||
this.get('/allocation/:id');
|
this.get('/allocation/:id');
|
||||||
|
|
||||||
|
this.post('/allocation/:id/stop', function() {
|
||||||
|
return new Response(204, {}, '');
|
||||||
|
});
|
||||||
|
|
||||||
this.get('/namespaces', function({ namespaces }) {
|
this.get('/namespaces', function({ namespaces }) {
|
||||||
const records = namespaces.all();
|
const records = namespaces.all();
|
||||||
|
|
||||||
|
@ -301,6 +305,10 @@ export default function() {
|
||||||
};
|
};
|
||||||
|
|
||||||
// Client requests are available on the server and the client
|
// Client requests are available on the server and the client
|
||||||
|
this.put('/client/allocation/:id/restart', function() {
|
||||||
|
return new Response(204, {}, '');
|
||||||
|
});
|
||||||
|
|
||||||
this.get('/client/allocation/:id/stats', clientAllocationStatsHandler);
|
this.get('/client/allocation/:id/stats', clientAllocationStatsHandler);
|
||||||
this.get('/client/fs/logs/:allocation_id', clientAllocationLog);
|
this.get('/client/fs/logs/:allocation_id', clientAllocationLog);
|
||||||
|
|
||||||
|
|
|
@ -1,3 +1,4 @@
|
||||||
|
import { run } from '@ember/runloop';
|
||||||
import { currentURL } from '@ember/test-helpers';
|
import { currentURL } from '@ember/test-helpers';
|
||||||
import { assign } from '@ember/polyfills';
|
import { assign } from '@ember/polyfills';
|
||||||
import { module, test } from 'qunit';
|
import { module, test } from 'qunit';
|
||||||
|
@ -155,6 +156,63 @@ module('Acceptance | allocation detail', function(hooks) {
|
||||||
assert.ok(Allocation.error.isShown, 'Error message is shown');
|
assert.ok(Allocation.error.isShown, 'Error message is shown');
|
||||||
assert.equal(Allocation.error.title, 'Not Found', 'Error message is for 404');
|
assert.equal(Allocation.error.title, 'Not Found', 'Error message is for 404');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('allocation can be stopped', async function(assert) {
|
||||||
|
await Allocation.stop.idle();
|
||||||
|
await Allocation.stop.confirm();
|
||||||
|
|
||||||
|
assert.equal(
|
||||||
|
server.pretender.handledRequests.findBy('method', 'POST').url,
|
||||||
|
`/v1/allocation/${allocation.id}/stop`,
|
||||||
|
'Stop request is made for the allocation'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('allocation can be restarted', async function(assert) {
|
||||||
|
await Allocation.restart.idle();
|
||||||
|
await Allocation.restart.confirm();
|
||||||
|
|
||||||
|
assert.equal(
|
||||||
|
server.pretender.handledRequests.findBy('method', 'PUT').url,
|
||||||
|
`/v1/client/allocation/${allocation.id}/restart`,
|
||||||
|
'Restart request is made for the allocation'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('while an allocation is being restarted, the stop button is disabled', async function(assert) {
|
||||||
|
server.pretender.post('/v1/allocation/:id/stop', () => [204, {}, ''], true);
|
||||||
|
|
||||||
|
await Allocation.stop.idle();
|
||||||
|
|
||||||
|
run.later(() => {
|
||||||
|
assert.ok(Allocation.stop.isRunning, 'Stop is loading');
|
||||||
|
assert.ok(Allocation.restart.isDisabled, 'Restart is disabled');
|
||||||
|
server.pretender.resolve(server.pretender.requestReferences[0].request);
|
||||||
|
}, 500);
|
||||||
|
|
||||||
|
await Allocation.stop.confirm();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('if stopping or restarting fails, an error message is shown', async function(assert) {
|
||||||
|
server.pretender.post('/v1/allocation/:id/stop', () => [403, {}, '']);
|
||||||
|
|
||||||
|
await Allocation.stop.idle();
|
||||||
|
await Allocation.stop.confirm();
|
||||||
|
|
||||||
|
assert.ok(Allocation.inlineError.isShown, 'Inline error is shown');
|
||||||
|
assert.ok(
|
||||||
|
Allocation.inlineError.title.includes('Could Not Stop Allocation'),
|
||||||
|
'Title is descriptive'
|
||||||
|
);
|
||||||
|
assert.ok(
|
||||||
|
/ACL token.+?allocation lifecycle/.test(Allocation.inlineError.message),
|
||||||
|
'Message mentions ACLs and the appropriate permission'
|
||||||
|
);
|
||||||
|
|
||||||
|
await Allocation.inlineError.dismiss();
|
||||||
|
|
||||||
|
assert.notOk(Allocation.inlineError.isShown, 'Inline error is no longer shown');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
module('Acceptance | allocation detail (rescheduled)', function(hooks) {
|
module('Acceptance | allocation detail (rescheduled)', function(hooks) {
|
||||||
|
|
|
@ -174,6 +174,42 @@ module('Acceptance | task detail', function(hooks) {
|
||||||
assert.ok(Task.error.isPresent, 'Error message is shown');
|
assert.ok(Task.error.isPresent, 'Error message is shown');
|
||||||
assert.equal(Task.error.title, 'Not Found', 'Error message is for 404');
|
assert.equal(Task.error.title, 'Not Found', 'Error message is for 404');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('task can be restarted', async function(assert) {
|
||||||
|
await Task.restart.idle();
|
||||||
|
await Task.restart.confirm();
|
||||||
|
|
||||||
|
const request = server.pretender.handledRequests.findBy('method', 'PUT');
|
||||||
|
assert.equal(
|
||||||
|
request.url,
|
||||||
|
`/v1/client/allocation/${allocation.id}/restart`,
|
||||||
|
'Restart request is made for the allocation'
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.deepEqual(
|
||||||
|
JSON.parse(request.requestBody),
|
||||||
|
{ TaskName: task.name },
|
||||||
|
'Restart request is made for the correct task'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('when task restart fails, an error message is shown', async function(assert) {
|
||||||
|
server.pretender.put('/v1/client/allocation/:id/restart', () => [403, {}, '']);
|
||||||
|
|
||||||
|
await Task.restart.idle();
|
||||||
|
await Task.restart.confirm();
|
||||||
|
|
||||||
|
assert.ok(Task.inlineError.isShown, 'Inline error is shown');
|
||||||
|
assert.ok(Task.inlineError.title.includes('Could Not Restart Task'), 'Title is descriptive');
|
||||||
|
assert.ok(
|
||||||
|
/ACL token.+?allocation lifecycle/.test(Task.inlineError.message),
|
||||||
|
'Message mentions ACLs and the appropriate permission'
|
||||||
|
);
|
||||||
|
|
||||||
|
await Task.inlineError.dismiss();
|
||||||
|
|
||||||
|
assert.notOk(Task.inlineError.isShown, 'Inline error is no longer shown');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
module('Acceptance | task detail (no addresses)', function(hooks) {
|
module('Acceptance | task detail (no addresses)', function(hooks) {
|
||||||
|
|
|
@ -4,6 +4,10 @@ import { setupRenderingTest } from 'ember-qunit';
|
||||||
import { render, settled } from '@ember/test-helpers';
|
import { render, settled } from '@ember/test-helpers';
|
||||||
import hbs from 'htmlbars-inline-precompile';
|
import hbs from 'htmlbars-inline-precompile';
|
||||||
import sinon from 'sinon';
|
import sinon from 'sinon';
|
||||||
|
import { create } from 'ember-cli-page-object';
|
||||||
|
import twoStepButton from 'nomad-ui/tests/pages/components/two-step-button';
|
||||||
|
|
||||||
|
const TwoStepButton = create(twoStepButton());
|
||||||
|
|
||||||
module('Integration | Component | two step button', function(hooks) {
|
module('Integration | Component | two step button', function(hooks) {
|
||||||
setupRenderingTest(hooks);
|
setupRenderingTest(hooks);
|
||||||
|
@ -14,6 +18,7 @@ module('Integration | Component | two step button', function(hooks) {
|
||||||
confirmText: 'Confirm Action',
|
confirmText: 'Confirm Action',
|
||||||
confirmationMessage: 'Are you certain',
|
confirmationMessage: 'Are you certain',
|
||||||
awaitingConfirmation: false,
|
awaitingConfirmation: false,
|
||||||
|
disabled: false,
|
||||||
onConfirm: sinon.spy(),
|
onConfirm: sinon.spy(),
|
||||||
onCancel: sinon.spy(),
|
onCancel: sinon.spy(),
|
||||||
});
|
});
|
||||||
|
@ -25,6 +30,7 @@ module('Integration | Component | two step button', function(hooks) {
|
||||||
confirmText=confirmText
|
confirmText=confirmText
|
||||||
confirmationMessage=confirmationMessage
|
confirmationMessage=confirmationMessage
|
||||||
awaitingConfirmation=awaitingConfirmation
|
awaitingConfirmation=awaitingConfirmation
|
||||||
|
disabled=disabled
|
||||||
onConfirm=onConfirm
|
onConfirm=onConfirm
|
||||||
onCancel=onCancel}}
|
onCancel=onCancel}}
|
||||||
`;
|
`;
|
||||||
|
@ -35,11 +41,7 @@ module('Integration | Component | two step button', function(hooks) {
|
||||||
await render(commonTemplate);
|
await render(commonTemplate);
|
||||||
|
|
||||||
assert.ok(find('[data-test-idle-button]'), 'Idle button is rendered');
|
assert.ok(find('[data-test-idle-button]'), 'Idle button is rendered');
|
||||||
assert.equal(
|
assert.equal(TwoStepButton.idleText, props.idleText, 'Button is labeled correctly');
|
||||||
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-cancel-button]'), 'No cancel button yet');
|
||||||
assert.notOk(find('[data-test-confirm-button]'), 'No confirm button yet');
|
assert.notOk(find('[data-test-confirm-button]'), 'No confirm button yet');
|
||||||
|
@ -51,25 +53,17 @@ module('Integration | Component | two step button', function(hooks) {
|
||||||
this.setProperties(props);
|
this.setProperties(props);
|
||||||
await render(commonTemplate);
|
await render(commonTemplate);
|
||||||
|
|
||||||
click('[data-test-idle-button]');
|
TwoStepButton.idle();
|
||||||
|
|
||||||
return settled().then(() => {
|
return settled().then(() => {
|
||||||
assert.ok(find('[data-test-cancel-button]'), 'Cancel button is rendered');
|
assert.ok(find('[data-test-cancel-button]'), 'Cancel button is rendered');
|
||||||
assert.equal(
|
assert.equal(TwoStepButton.cancelText, props.cancelText, 'Button is labeled correctly');
|
||||||
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.ok(find('[data-test-confirm-button]'), 'Confirm button is rendered');
|
||||||
assert.equal(
|
assert.equal(TwoStepButton.confirmText, props.confirmText, 'Button is labeled correctly');
|
||||||
find('[data-test-confirm-button]').textContent.trim(),
|
|
||||||
props.confirmText,
|
|
||||||
'Button is labeled correctly'
|
|
||||||
);
|
|
||||||
|
|
||||||
assert.equal(
|
assert.equal(
|
||||||
find('[data-test-confirmation-message]').textContent.trim(),
|
TwoStepButton.confirmationMessage,
|
||||||
props.confirmationMessage,
|
props.confirmationMessage,
|
||||||
'Confirmation message is shown'
|
'Confirmation message is shown'
|
||||||
);
|
);
|
||||||
|
@ -83,10 +77,10 @@ module('Integration | Component | two step button', function(hooks) {
|
||||||
this.setProperties(props);
|
this.setProperties(props);
|
||||||
await render(commonTemplate);
|
await render(commonTemplate);
|
||||||
|
|
||||||
click('[data-test-idle-button]');
|
TwoStepButton.idle();
|
||||||
|
|
||||||
return settled().then(() => {
|
return settled().then(() => {
|
||||||
click('[data-test-cancel-button]');
|
TwoStepButton.cancel();
|
||||||
|
|
||||||
return settled().then(() => {
|
return settled().then(() => {
|
||||||
assert.ok(props.onCancel.calledOnce, 'The onCancel hook fired');
|
assert.ok(props.onCancel.calledOnce, 'The onCancel hook fired');
|
||||||
|
@ -100,10 +94,10 @@ module('Integration | Component | two step button', function(hooks) {
|
||||||
this.setProperties(props);
|
this.setProperties(props);
|
||||||
await render(commonTemplate);
|
await render(commonTemplate);
|
||||||
|
|
||||||
click('[data-test-idle-button]');
|
TwoStepButton.idle();
|
||||||
|
|
||||||
return settled().then(() => {
|
return settled().then(() => {
|
||||||
click('[data-test-confirm-button]');
|
TwoStepButton.confirm();
|
||||||
|
|
||||||
return settled().then(() => {
|
return settled().then(() => {
|
||||||
assert.ok(props.onConfirm.calledOnce, 'The onConfirm hook fired');
|
assert.ok(props.onConfirm.calledOnce, 'The onConfirm hook fired');
|
||||||
|
@ -118,21 +112,73 @@ module('Integration | Component | two step button', function(hooks) {
|
||||||
this.setProperties(props);
|
this.setProperties(props);
|
||||||
await render(commonTemplate);
|
await render(commonTemplate);
|
||||||
|
|
||||||
click('[data-test-idle-button]');
|
TwoStepButton.idle();
|
||||||
|
|
||||||
return settled().then(() => {
|
return settled().then(() => {
|
||||||
assert.ok(
|
assert.ok(TwoStepButton.cancelIsDisabled, 'The cancel button is disabled');
|
||||||
find('[data-test-cancel-button]').hasAttribute('disabled'),
|
assert.ok(TwoStepButton.confirmIsDisabled, 'The confirm button is disabled');
|
||||||
'The cancel button is disabled'
|
assert.ok(TwoStepButton.isRunning, 'The confirm button is in a loading state');
|
||||||
);
|
|
||||||
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'
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('when in the prompt state, clicking outside will reset state back to idle', async function(assert) {
|
||||||
|
const props = commonProperties();
|
||||||
|
this.setProperties(props);
|
||||||
|
await render(commonTemplate);
|
||||||
|
|
||||||
|
TwoStepButton.idle();
|
||||||
|
await settled();
|
||||||
|
|
||||||
|
assert.ok(find('[data-test-cancel-button]'), 'In the prompt state');
|
||||||
|
|
||||||
|
click(document.body);
|
||||||
|
await settled();
|
||||||
|
|
||||||
|
assert.ok(find('[data-test-idle-button]'), 'Back in the idle state');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('when in the prompt state, clicking inside will not reset state back to idle', async function(assert) {
|
||||||
|
const props = commonProperties();
|
||||||
|
this.setProperties(props);
|
||||||
|
await render(commonTemplate);
|
||||||
|
|
||||||
|
TwoStepButton.idle();
|
||||||
|
await settled();
|
||||||
|
|
||||||
|
assert.ok(find('[data-test-cancel-button]'), 'In the prompt state');
|
||||||
|
|
||||||
|
click('[data-test-confirmation-message]');
|
||||||
|
await settled();
|
||||||
|
|
||||||
|
assert.notOk(find('[data-test-idle-button]'), 'Still in the prompt state');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('when awaitingConfirmation is true, clicking outside does nothing', async function(assert) {
|
||||||
|
const props = commonProperties();
|
||||||
|
props.awaitingConfirmation = true;
|
||||||
|
this.setProperties(props);
|
||||||
|
await render(commonTemplate);
|
||||||
|
|
||||||
|
TwoStepButton.idle();
|
||||||
|
await settled();
|
||||||
|
|
||||||
|
assert.ok(find('[data-test-cancel-button]'), 'In the prompt state');
|
||||||
|
|
||||||
|
click(document.body);
|
||||||
|
await settled();
|
||||||
|
|
||||||
|
assert.notOk(find('[data-test-idle-button]'), 'Still in the prompt state');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('when disabled is true, the idle button is disabled', async function(assert) {
|
||||||
|
const props = commonProperties();
|
||||||
|
props.disabled = true;
|
||||||
|
this.setProperties(props);
|
||||||
|
await render(commonTemplate);
|
||||||
|
|
||||||
|
assert.ok(TwoStepButton.isDisabled, 'The idle button is disabled');
|
||||||
|
|
||||||
|
TwoStepButton.idle();
|
||||||
|
assert.ok(find('[data-test-idle-button]'), 'Still in the idle state after clicking');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -9,12 +9,16 @@ import {
|
||||||
} from 'ember-cli-page-object';
|
} from 'ember-cli-page-object';
|
||||||
|
|
||||||
import allocations from 'nomad-ui/tests/pages/components/allocations';
|
import allocations from 'nomad-ui/tests/pages/components/allocations';
|
||||||
|
import twoStepButton from 'nomad-ui/tests/pages/components/two-step-button';
|
||||||
|
|
||||||
export default create({
|
export default create({
|
||||||
visit: visitable('/allocations/:id'),
|
visit: visitable('/allocations/:id'),
|
||||||
|
|
||||||
title: text('[data-test-title]'),
|
title: text('[data-test-title]'),
|
||||||
|
|
||||||
|
stop: twoStepButton('[data-test-stop]'),
|
||||||
|
restart: twoStepButton('[data-test-restart]'),
|
||||||
|
|
||||||
details: {
|
details: {
|
||||||
scope: '[data-test-allocation-details]',
|
scope: '[data-test-allocation-details]',
|
||||||
|
|
||||||
|
@ -77,4 +81,11 @@ export default create({
|
||||||
message: text('[data-test-error-message]'),
|
message: text('[data-test-error-message]'),
|
||||||
seekHelp: clickable('[data-test-error-message] a'),
|
seekHelp: clickable('[data-test-error-message] a'),
|
||||||
},
|
},
|
||||||
|
|
||||||
|
inlineError: {
|
||||||
|
isShown: isPresent('[data-test-inline-error]'),
|
||||||
|
title: text('[data-test-inline-error-title]'),
|
||||||
|
message: text('[data-test-inline-error-body]'),
|
||||||
|
dismiss: clickable('[data-test-inline-error-close]'),
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
|
@ -8,6 +8,8 @@ import {
|
||||||
visitable,
|
visitable,
|
||||||
} from 'ember-cli-page-object';
|
} from 'ember-cli-page-object';
|
||||||
|
|
||||||
|
import twoStepButton from 'nomad-ui/tests/pages/components/two-step-button';
|
||||||
|
|
||||||
export default create({
|
export default create({
|
||||||
visit: visitable('/allocations/:id/:name'),
|
visit: visitable('/allocations/:id/:name'),
|
||||||
|
|
||||||
|
@ -15,6 +17,8 @@ export default create({
|
||||||
state: text('[data-test-state]'),
|
state: text('[data-test-state]'),
|
||||||
startedAt: text('[data-test-started-at]'),
|
startedAt: text('[data-test-started-at]'),
|
||||||
|
|
||||||
|
restart: twoStepButton('[data-test-restart]'),
|
||||||
|
|
||||||
breadcrumbs: collection('[data-test-breadcrumb]', {
|
breadcrumbs: collection('[data-test-breadcrumb]', {
|
||||||
id: attribute('data-test-breadcrumb'),
|
id: attribute('data-test-breadcrumb'),
|
||||||
text: text(),
|
text: text(),
|
||||||
|
@ -51,4 +55,11 @@ export default create({
|
||||||
message: text('[data-test-error-message]'),
|
message: text('[data-test-error-message]'),
|
||||||
seekHelp: clickable('[data-test-error-message] a'),
|
seekHelp: clickable('[data-test-error-message] a'),
|
||||||
},
|
},
|
||||||
|
|
||||||
|
inlineError: {
|
||||||
|
isShown: isPresent('[data-test-inline-error]'),
|
||||||
|
title: text('[data-test-inline-error-title]'),
|
||||||
|
message: text('[data-test-inline-error-body]'),
|
||||||
|
dismiss: clickable('[data-test-inline-error-close]'),
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
22
ui/tests/pages/components/two-step-button.js
Normal file
22
ui/tests/pages/components/two-step-button.js
Normal file
|
@ -0,0 +1,22 @@
|
||||||
|
import { attribute, clickable, hasClass, isPresent, text } from 'ember-cli-page-object';
|
||||||
|
|
||||||
|
export default scope => ({
|
||||||
|
scope,
|
||||||
|
|
||||||
|
isPresent: isPresent(),
|
||||||
|
|
||||||
|
idle: clickable('[data-test-idle-button]'),
|
||||||
|
confirm: clickable('[data-test-confirm-button]'),
|
||||||
|
cancel: clickable('[data-test-cancel-button]'),
|
||||||
|
|
||||||
|
isRunning: hasClass('is-loading', '[data-test-confirm-button]'),
|
||||||
|
isDisabled: attribute('disabled', '[data-test-idle-button]'),
|
||||||
|
|
||||||
|
cancelIsDisabled: attribute('disabled', '[data-test-cancel-button]'),
|
||||||
|
confirmIsDisabled: attribute('disabled', '[data-test-confirm-button]'),
|
||||||
|
|
||||||
|
idleText: text('[data-test-idle-button]'),
|
||||||
|
cancelText: text('[data-test-cancel-button]'),
|
||||||
|
confirmText: text('[data-test-confirm-button]'),
|
||||||
|
confirmationMessage: text('[data-test-confirmation-message]'),
|
||||||
|
});
|
77
ui/tests/unit/adapters/allocation-test.js
Normal file
77
ui/tests/unit/adapters/allocation-test.js
Normal file
|
@ -0,0 +1,77 @@
|
||||||
|
import { module, test } from 'qunit';
|
||||||
|
import { setupTest } from 'ember-qunit';
|
||||||
|
import { startMirage } from 'nomad-ui/initializers/ember-cli-mirage';
|
||||||
|
|
||||||
|
module('Unit | Adapter | Allocation', function(hooks) {
|
||||||
|
setupTest(hooks);
|
||||||
|
|
||||||
|
hooks.beforeEach(async function() {
|
||||||
|
this.store = this.owner.lookup('service:store');
|
||||||
|
this.subject = () => this.store.adapterFor('allocation');
|
||||||
|
|
||||||
|
this.server = startMirage();
|
||||||
|
|
||||||
|
this.server.create('namespace');
|
||||||
|
this.server.create('node');
|
||||||
|
this.server.create('job', { createAllocations: false });
|
||||||
|
this.server.create('allocation', { id: 'alloc-1' });
|
||||||
|
});
|
||||||
|
|
||||||
|
hooks.afterEach(function() {
|
||||||
|
this.server.shutdown();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('`stop` makes the correct API call', async function(assert) {
|
||||||
|
const { pretender } = this.server;
|
||||||
|
const allocationId = 'alloc-1';
|
||||||
|
|
||||||
|
const allocation = await this.store.findRecord('allocation', allocationId);
|
||||||
|
pretender.handledRequests.length = 0;
|
||||||
|
|
||||||
|
await this.subject().stop(allocation);
|
||||||
|
const req = pretender.handledRequests[0];
|
||||||
|
assert.equal(
|
||||||
|
`${req.method} ${req.url}`,
|
||||||
|
`POST /v1/allocation/${allocationId}/stop`,
|
||||||
|
`POST /v1/allocation/${allocationId}/stop`
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('`restart` makes the correct API call', async function(assert) {
|
||||||
|
const { pretender } = this.server;
|
||||||
|
const allocationId = 'alloc-1';
|
||||||
|
|
||||||
|
const allocation = await this.store.findRecord('allocation', allocationId);
|
||||||
|
pretender.handledRequests.length = 0;
|
||||||
|
|
||||||
|
await this.subject().restart(allocation);
|
||||||
|
const req = pretender.handledRequests[0];
|
||||||
|
assert.equal(
|
||||||
|
`${req.method} ${req.url}`,
|
||||||
|
`PUT /v1/client/allocation/${allocationId}/restart`,
|
||||||
|
`PUT /v1/client/allocation/${allocationId}/restart`
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('`restart` takes an optional task name and makes the correct API call', async function(assert) {
|
||||||
|
const { pretender } = this.server;
|
||||||
|
const allocationId = 'alloc-1';
|
||||||
|
const taskName = 'task-name';
|
||||||
|
|
||||||
|
const allocation = await this.store.findRecord('allocation', allocationId);
|
||||||
|
pretender.handledRequests.length = 0;
|
||||||
|
|
||||||
|
await this.subject().restart(allocation, taskName);
|
||||||
|
const req = pretender.handledRequests[0];
|
||||||
|
assert.equal(
|
||||||
|
`${req.method} ${req.url}`,
|
||||||
|
`PUT /v1/client/allocation/${allocationId}/restart`,
|
||||||
|
`PUT /v1/client/allocation/${allocationId}/restart`
|
||||||
|
);
|
||||||
|
assert.deepEqual(
|
||||||
|
JSON.parse(req.requestBody),
|
||||||
|
{ TaskName: taskName },
|
||||||
|
'Request body is correct'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
32
ui/tests/unit/utils/add-to-path-test.js
Normal file
32
ui/tests/unit/utils/add-to-path-test.js
Normal file
|
@ -0,0 +1,32 @@
|
||||||
|
import { module, test } from 'qunit';
|
||||||
|
import addToPath from 'nomad-ui/utils/add-to-path';
|
||||||
|
|
||||||
|
const testCases = [
|
||||||
|
{
|
||||||
|
name: 'Only domain',
|
||||||
|
in: ['https://domain.com', '/path'],
|
||||||
|
out: 'https://domain.com/path',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Deep path',
|
||||||
|
in: ['https://domain.com/a/path', '/to/nowhere'],
|
||||||
|
out: 'https://domain.com/a/path/to/nowhere',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'With Query Params',
|
||||||
|
in: ['https://domain.com?interesting=development', '/this-is-an'],
|
||||||
|
out: 'https://domain.com/this-is-an?interesting=development',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
module('Unit | Util | addToPath', function() {
|
||||||
|
testCases.forEach(testCase => {
|
||||||
|
test(testCase.name, function(assert) {
|
||||||
|
assert.equal(
|
||||||
|
addToPath.apply(null, testCase.in),
|
||||||
|
testCase.out,
|
||||||
|
`[${testCase.in.join(', ')}] => ${testCase.out}`
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
Loading…
Reference in a new issue