Volume Progress Bar & Fixes For Quota
When a volume creation exceed the allocation quota a vague error message was returned that offered the user no guidance as to what went wrong. This has been fixed. Fixes Bug #1012883 This change also abstracts the Quota javascript to allow it to be used anywhere on the site for any progress bars that are to be shown in the future. Implements blueprint progress-bar-javascript On top of this I have renamed all "can_haz" filters in the code, as they are really sort of embarassing. Lastly, I have added the ability to append JS events to the window load when a modal is loaded as a static page, OR when it is loaded as an AJAX modal. Change-Id: I4b0cefa160cafbbd07d4b0981f62febaed051871
This commit is contained in:
parent
0ffa674697
commit
406cb5d56c
@ -407,18 +407,19 @@ def usage_list(request, start, end):
|
||||
def tenant_quota_usages(request):
|
||||
"""
|
||||
Builds a dictionary of current usage against quota for the current
|
||||
tenant.
|
||||
project.
|
||||
"""
|
||||
# TODO(tres): Make this capture floating_ips and volumes as well.
|
||||
instances = server_list(request)
|
||||
floating_ips = tenant_floating_ip_list(request)
|
||||
quotas = tenant_quota_get(request, request.user.tenant_id)
|
||||
flavors = dict([(f.id, f) for f in flavor_list(request)])
|
||||
volumes = volume_list(request)
|
||||
|
||||
usages = {'instances': {'flavor_fields': [], 'used': len(instances)},
|
||||
'cores': {'flavor_fields': ['vcpus'], 'used': 0},
|
||||
'gigabytes': {'used': 0,
|
||||
'flavor_fields': ['disk',
|
||||
'OS-FLV-EXT-DATA:ephemeral']},
|
||||
'gigabytes': {'used': sum([int(v.size) for v in volumes]),
|
||||
'flavor_fields': []},
|
||||
'volumes': {'used': len(volumes), 'flavor_fields': []},
|
||||
'ram': {'flavor_fields': ['ram'], 'used': 0},
|
||||
'floating_ips': {'flavor_fields': [], 'used': len(floating_ips)}}
|
||||
|
||||
@ -427,11 +428,18 @@ def tenant_quota_usages(request):
|
||||
for flavor_field in usages[usage]['flavor_fields']:
|
||||
usages[usage]['used'] += getattr(
|
||||
flavors[instance.flavor['id']], flavor_field, 0)
|
||||
|
||||
usages[usage]['quota'] = getattr(quotas, usage)
|
||||
|
||||
if usages[usage]['quota'] is None:
|
||||
usages[usage]['quota'] = float("inf")
|
||||
usages[usage]['available'] = float("inf")
|
||||
elif type(usages[usage]['quota']) is str:
|
||||
usages[usage]['quota'] = int(usages[usage]['quota'])
|
||||
else:
|
||||
if type(usages[usage]['used']) is str:
|
||||
usages[usage]['used'] = int(usages[usage]['used'])
|
||||
|
||||
usages[usage]['available'] = usages[usage]['quota'] - \
|
||||
usages[usage]['used']
|
||||
|
||||
|
@ -9,5 +9,3 @@
|
||||
{% block dash_main %}
|
||||
{% include 'nova/images_and_snapshots/snapshots/_create.html' %}
|
||||
{% endblock %}
|
||||
|
||||
|
||||
|
@ -1,4 +1,4 @@
|
||||
{% load i18n horizon %}
|
||||
{% load i18n horizon humanize %}
|
||||
|
||||
<p>{% blocktrans %}Specify the details for launching an instance.{% endblocktrans %}</p>
|
||||
<p>{% blocktrans %}The chart below shows the resources used by this project in relation to the project's quotas.{% endblocktrans %}</p>
|
||||
@ -17,55 +17,37 @@
|
||||
|
||||
<div class="quota-dynamic">
|
||||
<h4>{% trans "Project Quotas" %}</h4>
|
||||
<div class="quota_title">
|
||||
<strong>{% trans "Instance Count" %} <span>({{ usages.instances.used }})</span></strong>
|
||||
<p>{{ usages.instances.available|quota }}</p>
|
||||
<div class="quota_title clearfix">
|
||||
<strong>{% trans "Number of Instances" %} <span>({{ usages.instances.used|intcomma }})</span></strong>
|
||||
<p>{{ usages.instances.available|quota|intcomma }}</p>
|
||||
</div>
|
||||
<div id="quota_instances" class="quota_bar" data-progress-indicator-flavor data-quota-limit="{{ usages.instances.quota }}" data-quota-used="{{ usages.instances.used }}">
|
||||
{% horizon_progress_bar usages.instances.used usages.instances.quota %}
|
||||
</div>
|
||||
<div class="clearfix"></div>
|
||||
<div id="quota_instances" class="quota_bar">{% horizon_progress_bar usages.instances.used usages.instances.quota %}</div>
|
||||
|
||||
<div class="quota_title">
|
||||
<strong>{% trans "VCPUs" %} <span>({{ usages.cores.used }})</span></strong>
|
||||
<p>{{ usages.cores.available|quota }}</p>
|
||||
<div class="quota_title clearfix">
|
||||
<strong>{% trans "Number of VCPUs" %} <span>({{ usages.cores.used|intcomma }})</span></strong>
|
||||
<p>{{ usages.cores.available|quota|intcomma }}</p>
|
||||
</div>
|
||||
<div id="quota_vcpus" class="quota_bar" data-progress-indicator-flavor data-quota-limit="{{ usages.cores.quota }}" data-quota-used="{{ usages.cores.used }}">
|
||||
{% horizon_progress_bar usages.cores.used usages.cores.quota %}
|
||||
</div>
|
||||
<div class="clearfix"></div>
|
||||
<div id="quota_cores" class="quota_bar">{% horizon_progress_bar usages.cores.used usages.cores.quota %}</div>
|
||||
|
||||
<div class="quota_title">
|
||||
<strong>{% trans "Disk" %} <span>({{ usages.gigabytes.used }} {% trans "GB" %})</span></strong>
|
||||
<p>{{ usages.gigabytes.available|quota:"GB" }}</p>
|
||||
<div class="quota_title clearfix">
|
||||
<strong>{% trans "Total Memory" %} <span>({{ usages.ram.used|intcomma }} {% trans "MB" %})</span></strong>
|
||||
<p>{{ usages.ram.available|quota:"MB"|intcomma }}</p>
|
||||
</div>
|
||||
<div class="clearfix"></div>
|
||||
<div id="quota_disk" class="quota_bar">{% horizon_progress_bar usages.gigabytes.used usages.gigabytes.quota %}</div>
|
||||
|
||||
<div class="quota_title">
|
||||
<strong>{% trans "Memory" %} <span>({{ usages.ram.used }} {% trans "MB" %})</span></strong>
|
||||
<p>{{ usages.ram.available|quota:"MB" }}</p>
|
||||
<div id="quota_ram" data-progress-indicator-flavor data-quota-limit="{{ usages.ram.quota }}" data-quota-used="{{ usages.ram.used }}" class="quota_bar">
|
||||
{% horizon_progress_bar usages.ram.used usages.ram.quota %}
|
||||
</div>
|
||||
<div class="clearfix"></div>
|
||||
<div id="quota_ram" class="quota_bar">{% horizon_progress_bar usages.ram.used usages.ram.quota %}</div>
|
||||
</div>
|
||||
|
||||
<script type="text/javascript" charset="utf-8">
|
||||
var horizon_flavors = {{ flavors|safe }};
|
||||
var horizon_usages = {{ usages_json|safe }};
|
||||
|
||||
// FIXME(gabriel): move this function into a horizon primitive when we have
|
||||
// one constructed at the head of the document. :-/
|
||||
(function () {
|
||||
function fire_change(el) {
|
||||
if ("fireEvent" in el) {
|
||||
el.fireEvent("onchange");
|
||||
}
|
||||
else
|
||||
{
|
||||
var evt = document.createEvent("HTMLEvents");
|
||||
evt.initEvent("change", true, true);
|
||||
el.dispatchEvent(evt);
|
||||
}
|
||||
}
|
||||
fire_change(document.getElementById('id_flavor'));
|
||||
fire_change(document.getElementById('id_source_type'));
|
||||
fire_change(document.getElementById('id_volume_type'));
|
||||
})();
|
||||
if(typeof horizon !== 'undefined') {
|
||||
horizon.Quota.initWithFlavors({{ flavors|safe }});
|
||||
} else {
|
||||
addHorizonLoadEvent(function() {
|
||||
horizon.Quota.initWithFlavors({{ flavors|safe }});
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
@ -1,22 +1,3 @@
|
||||
{% load i18n horizon %}
|
||||
|
||||
<p>{% blocktrans %}An instance can be launched with varying types of attached storage. You may select from those options here.{% endblocktrans %}</p>
|
||||
|
||||
<script type="text/javascript" charset="utf-8">
|
||||
// FIXME(gabriel): move this function into a horizon primitive when we have
|
||||
// one constructed at the head of the document. :-/
|
||||
(function () {
|
||||
function fire_change(el) {
|
||||
if ("fireEvent" in el) {
|
||||
el.fireEvent("onchange");
|
||||
}
|
||||
else
|
||||
{
|
||||
var evt = document.createEvent("HTMLEvents");
|
||||
evt.initEvent("change", true, true);
|
||||
el.dispatchEvent(evt);
|
||||
}
|
||||
}
|
||||
fire_change(document.getElementById('id_volume_type'));
|
||||
})();
|
||||
</script>
|
||||
|
@ -1,5 +1,5 @@
|
||||
{% extends "horizon/common/_modal_form.html" %}
|
||||
{% load i18n %}
|
||||
{% load i18n horizon humanize %}
|
||||
|
||||
{% block form_id %}{% endblock %}
|
||||
{% block form_action %}{% url horizon:nova:instances_and_volumes:volumes:create %}{% endblock %}
|
||||
@ -8,15 +8,47 @@
|
||||
{% block modal-header %}{% trans "Create Volume" %}{% endblock %}
|
||||
|
||||
{% block modal-body %}
|
||||
<div class="left">
|
||||
<div class="left">
|
||||
<fieldset>
|
||||
{% include "horizon/common/_form_fields.html" %}
|
||||
{% include "horizon/common/_form_fields.html" %}
|
||||
</fieldset>
|
||||
</div>
|
||||
<div class="right">
|
||||
</div>
|
||||
|
||||
<div class="right quota-dynamic">
|
||||
<h3>{% trans "Description" %}:</h3>
|
||||
|
||||
<p>{% trans "Volumes are block devices that can be attached to instances." %}</p>
|
||||
</div>
|
||||
|
||||
<h3>{% trans "Volume Quotas" %}</h3>
|
||||
|
||||
<div class="quota_title clearfix">
|
||||
<strong>{% trans "Total Gigabytes" %} <span>({{ usages.gigabytes.used|intcomma }} GB)</span></strong>
|
||||
<p>{{ usages.gigabytes.available|quota:"GB"|intcomma }}</p>
|
||||
</div>
|
||||
|
||||
<div id="quota_size" data-progress-indicator-for="id_size" data-quota-limit="{{ usages.gigabytes.quota }}" data-quota-used="{{ usages.gigabytes.used }}" class="quota_bar">
|
||||
{% horizon_progress_bar usages.gigabytes.used usages.gigabytes.quota %}
|
||||
</div>
|
||||
|
||||
<div class="quota_title clearfix">
|
||||
<strong>{% trans "Number of Volumes" %} <span>({{ usages.volumes.used|intcomma }})</span></strong>
|
||||
<p>{{ usages.volumes.available|quota|intcomma }}</p>
|
||||
</div>
|
||||
|
||||
<div id="quota_volumes" data-progress-indicator-step-by="1" data-quota-limit="{{ usages.volumes.quota }}" data-quota-used="{{ usages.volumes.used }}" class="quota_bar">
|
||||
{% horizon_progress_bar usages.volumes.used usages.volumes.quota %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script type="text/javascript" charset="utf-8">
|
||||
if(typeof horizon !== 'undefined') {
|
||||
horizon.Quota.init();
|
||||
} else {
|
||||
addHorizonLoadEvent(function() {
|
||||
horizon.Quota.init();
|
||||
});
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
{% block modal-footer %}
|
||||
|
@ -9,6 +9,7 @@ Views for managing Nova volumes.
|
||||
|
||||
from django import shortcuts
|
||||
from django.contrib import messages
|
||||
from django.forms import ValidationError
|
||||
from django.utils.translation import ugettext_lazy as _
|
||||
|
||||
from horizon import api
|
||||
@ -26,13 +27,38 @@ class CreateForm(forms.SelfHandlingForm):
|
||||
|
||||
def handle(self, request, data):
|
||||
try:
|
||||
# FIXME(johnp): Nova (cinderclient) currently returns a useless
|
||||
# error message when the quota is exceeded when trying to create
|
||||
# a volume, so we need to check for that scenario here before we
|
||||
# send it off to Nova to try and create.
|
||||
usages = api.tenant_quota_usages(request)
|
||||
|
||||
if type(data['size']) is str:
|
||||
data['size'] = int(data['size'])
|
||||
|
||||
if usages['gigabytes']['available'] < data['size']:
|
||||
error_message = _('A volume of %iGB cannot be created as you'
|
||||
' only have %iGB of your quota available.'
|
||||
% (data['size'],
|
||||
usages['gigabytes']['available'],))
|
||||
raise ValidationError(error_message)
|
||||
elif usages['volumes']['available'] <= 0:
|
||||
error_message = _('You are already using all of your available'
|
||||
' volumes.')
|
||||
raise ValidationError(error_message)
|
||||
|
||||
api.volume_create(request, data['size'], data['name'],
|
||||
data['description'])
|
||||
message = 'Creating volume "%s"' % data['name']
|
||||
|
||||
messages.info(request, message)
|
||||
except ValidationError, e:
|
||||
return self.api_error(e.messages[0])
|
||||
except:
|
||||
exceptions.handle(request,
|
||||
_("Unable to create volume."))
|
||||
exceptions.handle(request, ignore=True)
|
||||
|
||||
return self.api_error(_("Unable to create volume."))
|
||||
|
||||
return shortcuts.redirect("horizon:nova:instances_and_volumes:index")
|
||||
|
||||
|
||||
|
@ -27,6 +27,69 @@ from horizon import test
|
||||
|
||||
|
||||
class VolumeViewTests(test.TestCase):
|
||||
@test.create_stubs({api: ('tenant_quota_usages', 'volume_create',)})
|
||||
def test_create_volume(self):
|
||||
usage = {'gigabytes': {'available': 250}, 'volumes': {'available': 6}}
|
||||
formData = {'name': u'A Volume I Am Making',
|
||||
'description': u'This is a volume I am making for a test.',
|
||||
'method': u'CreateForm',
|
||||
'size': 50}
|
||||
|
||||
api.tenant_quota_usages(IsA(http.HttpRequest)).AndReturn(usage)
|
||||
api.volume_create(IsA(http.HttpRequest),
|
||||
formData['size'],
|
||||
formData['name'],
|
||||
formData['description'])
|
||||
|
||||
self.mox.ReplayAll()
|
||||
|
||||
url = reverse('horizon:nova:instances_and_volumes:volumes:create')
|
||||
res = self.client.post(url, formData)
|
||||
|
||||
redirect_url = reverse('horizon:nova:instances_and_volumes:index')
|
||||
self.assertRedirectsNoFollow(res, redirect_url)
|
||||
|
||||
@test.create_stubs({api: ('tenant_quota_usages',)})
|
||||
def test_create_volume_gb_used_over_alloted_quota(self):
|
||||
usage = {'gigabytes': {'available': 100, 'used': 20}}
|
||||
formData = {'name': u'This Volume Is Huge!',
|
||||
'description': u'This is a volume that is just too big!',
|
||||
'method': u'CreateForm',
|
||||
'size': 5000}
|
||||
|
||||
api.tenant_quota_usages(IsA(http.HttpRequest)).AndReturn(usage)
|
||||
api.tenant_quota_usages(IsA(http.HttpRequest)).AndReturn(usage)
|
||||
|
||||
self.mox.ReplayAll()
|
||||
|
||||
url = reverse('horizon:nova:instances_and_volumes:volumes:create')
|
||||
res = self.client.post(url, formData)
|
||||
|
||||
expected_error = [u'A volume of 5000GB cannot be created as you only'
|
||||
' have 100GB of your quota available.']
|
||||
self.assertEqual(res.context['form'].errors['__all__'], expected_error)
|
||||
|
||||
@test.create_stubs({api: ('tenant_quota_usages',)})
|
||||
def test_create_volume_number_over_alloted_quota(self):
|
||||
usage = {'gigabytes': {'available': 100, 'used': 20},
|
||||
'volumes': {'available': 0}}
|
||||
formData = {'name': u'Too Many...',
|
||||
'description': u'We have no volumes left!',
|
||||
'method': u'CreateForm',
|
||||
'size': 10}
|
||||
|
||||
api.tenant_quota_usages(IsA(http.HttpRequest)).AndReturn(usage)
|
||||
api.tenant_quota_usages(IsA(http.HttpRequest)).AndReturn(usage)
|
||||
|
||||
self.mox.ReplayAll()
|
||||
|
||||
url = reverse('horizon:nova:instances_and_volumes:volumes:create')
|
||||
res = self.client.post(url, formData)
|
||||
|
||||
expected_error = [u'You are already using all of your available'
|
||||
' volumes.']
|
||||
self.assertEqual(res.context['form'].errors['__all__'], expected_error)
|
||||
|
||||
@test.create_stubs({api: ('volume_get',), api.nova: ('server_list',)})
|
||||
def test_edit_attachments(self):
|
||||
volume = self.volumes.first()
|
||||
|
@ -44,6 +44,15 @@ class CreateView(forms.ModalFormView):
|
||||
form_class = CreateForm
|
||||
template_name = 'nova/instances_and_volumes/volumes/create.html'
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
context = super(CreateView, self).get_context_data(**kwargs)
|
||||
try:
|
||||
context['usages'] = api.tenant_quota_usages(self.request)
|
||||
except:
|
||||
exceptions.handle(self.request)
|
||||
|
||||
return context
|
||||
|
||||
|
||||
class CreateSnapshotView(forms.ModalFormView):
|
||||
form_class = CreateSnapshotForm
|
||||
|
@ -57,7 +57,8 @@ class HorizonReporterFilter(SafeExceptionReporterFilter):
|
||||
sensitive_variables = None
|
||||
while current_frame is not None:
|
||||
if (current_frame.f_code.co_name == 'sensitive_variables_wrapper'
|
||||
and 'sensitive_variables_wrapper' in current_frame.f_locals):
|
||||
and 'sensitive_variables_wrapper'
|
||||
in current_frame.f_locals):
|
||||
# The sensitive_variables decorator was used, so we take note
|
||||
# of the sensitive variables' names.
|
||||
wrapper = current_frame.f_locals['sensitive_variables_wrapper']
|
||||
|
244
horizon/static/horizon/js/horizon.quota.js
Normal file
244
horizon/static/horizon/js/horizon.quota.js
Normal file
@ -0,0 +1,244 @@
|
||||
/*
|
||||
Used for animating and displaying quota information on forms which use the
|
||||
Bootstrap progress bars. Also used for displaying flavor details on modal-
|
||||
dialogs.
|
||||
|
||||
Usage:
|
||||
In order to have progress bars that work with this, you need to have a
|
||||
DOM structure like this in your Django template:
|
||||
|
||||
<div id="your_progress_bar_id" class="quota_bar">
|
||||
{% horizon_progress_bar total_number_used max_number_allowed %}
|
||||
</div>
|
||||
|
||||
With this progress bar, you then need to add some data- HTML attributes
|
||||
to the div #your_progress_bar_id. The available data- attributes are:
|
||||
|
||||
data-quota-used="integer" REQUIRED
|
||||
Integer representing the total number used by the user.
|
||||
|
||||
data-quota-limit="integer" REQUIRED
|
||||
Integer representing the total quota limit the user has. Note this IS
|
||||
NOT the amount remaining they can use, but the total original quota.
|
||||
|
||||
ONE OF THE THREE ATTRIBUTES BELOW IS REQUIRED:
|
||||
|
||||
data-progress-indicator-step-by="integer" OPTIONAL
|
||||
Indicates the numeric unit the quota JavaScript should automatically
|
||||
animate this progress bar by on load. Can be used with the other
|
||||
data- attributes.
|
||||
|
||||
A good use-case here is when you have a modal dialog to create ONE
|
||||
volume, and you have a progress bar for volumes, but there are no
|
||||
form elements that represent that number (as it is not settable by
|
||||
the user.)
|
||||
|
||||
data-progress-indicator-for="html_id_of_form_input"
|
||||
Tells the quota JavaScript which form element on this page is tied to
|
||||
this progress indicator. If this form element is an input, it will
|
||||
automatically fire on "keyup" in that form field, and change this
|
||||
progress bar to denote the numeric change.
|
||||
|
||||
data-progress-indicator-flavor
|
||||
This attribute is used to tell this quota JavaScript that this
|
||||
progress bar is controller by an instance flavor select form element.
|
||||
This attribute takes no value, but is used and configured
|
||||
automatically by this script to update when a new flavor is choosen
|
||||
by the end-user.
|
||||
*/
|
||||
horizon.Quota = {
|
||||
is_flavor_quota: false, // Is this a flavor-based quota display?
|
||||
user_value_progress_bars: [], // Progress bars triggered by user-changeable form elements.
|
||||
auto_value_progress_bars: [], // Progress bars that should be automatically changed.
|
||||
flavor_progress_bars: [], // Progress bars that relate to flavor details.
|
||||
user_value_form_inputs: [], // The actual form inputs that trigger progress changes.
|
||||
selected_flavor: null, // The flavor object of the current selected flavor on the form.
|
||||
flavors: [], // The flavor objects the form represents, passed to us in initWithFlavors.
|
||||
|
||||
/*
|
||||
Determines the progress bars and form elements to be used for quota
|
||||
display. Also attaches handlers to the form elements as well as performing
|
||||
the animations when the progress bars first load.
|
||||
*/
|
||||
init: function() {
|
||||
this.user_value_progress_bars = $('div[data-progress-indicator-for]');
|
||||
this.auto_value_progress_bars = $('div[data-progress-indicator-step-by]');
|
||||
this.user_value_form_inputs = $($.map(this.user_value_progress_bars, function(elm) {
|
||||
return ('#' + $(elm).attr('data-progress-indicator-for'));
|
||||
}));
|
||||
|
||||
this._initialAnimations();
|
||||
this._attachInputHandlers();
|
||||
},
|
||||
|
||||
/*
|
||||
Sets up the quota to be used with flavor form selectors, which requires
|
||||
some different handling of the forms. Also calls init() so that all of the
|
||||
other animations and handlers are taken care of as well when initializing
|
||||
with this method.
|
||||
*/
|
||||
initWithFlavors: function(flavors) {
|
||||
this.is_flavor_quota = true;
|
||||
this.flavor_progress_bars = $('div[data-progress-indicator-flavor]');
|
||||
this.flavors = flavors;
|
||||
|
||||
this.init();
|
||||
|
||||
this.showFlavorDetails();
|
||||
this.updateFlavorUsage();
|
||||
},
|
||||
|
||||
// Returns the flavor object for the selected flavor in the form.
|
||||
getSelectedFlavor: function() {
|
||||
if(this.is_flavor_quota) {
|
||||
this.selected_flavor = _.find(this.flavors, function(flavor) {
|
||||
return flavor.id == $("#id_flavor").children(":selected").val();
|
||||
});
|
||||
} else {
|
||||
this.selected_flavor = null;
|
||||
}
|
||||
|
||||
return this.selected_flavor;
|
||||
},
|
||||
|
||||
/*
|
||||
Populates the flavor details table with the flavor attributes of the
|
||||
selected flavor on the form select element.
|
||||
*/
|
||||
showFlavorDetails: function() {
|
||||
this.getSelectedFlavor();
|
||||
|
||||
var name = horizon.utils.truncate(this.selected_flavor.name, 14, true);
|
||||
var vcpus = horizon.utils.humanizeNumbers(this.selected_flavor.vcpus);
|
||||
var disk = horizon.utils.humanizeNumbers(this.selected_flavor.disk);
|
||||
var ephemeral = horizon.utils.humanizeNumbers(this.selected_flavor["OS-FLV-EXT-DATA:ephemeral"]);
|
||||
var disk_total = this.selected_flavor.disk + this.selected_flavor["OS-FLV-EXT-DATA:ephemeral"];
|
||||
var disk_total_display = horizon.utils.humanizeNumbers(disk_total);
|
||||
var ram = horizon.utils.humanizeNumbers(this.selected_flavor.ram);
|
||||
|
||||
$("#flavor_name").html(name);
|
||||
$("#flavor_vcpus").text(vcpus);
|
||||
$("#flavor_disk").text(disk);
|
||||
$("#flavor_ephemeral").text(ephemeral);
|
||||
$("#flavor_disk_total").text(disk_total_display);
|
||||
$("#flavor_ram").text(ram);
|
||||
},
|
||||
|
||||
// Updates a progress bar, taking care of exceeding quota display as well.
|
||||
update: function(element, percentage_used, percentage_to_update) {
|
||||
var update_width = percentage_to_update;
|
||||
|
||||
if(percentage_to_update + percentage_used > 100) {
|
||||
update_width = 100 - percentage_used;
|
||||
|
||||
if(!element.hasClass('progress_bar_over')) {
|
||||
element.addClass('progress_bar_over');
|
||||
}
|
||||
} else {
|
||||
element.removeClass('progress_bar_over');
|
||||
}
|
||||
|
||||
element.animate({width: update_width + "%"}, 300);
|
||||
},
|
||||
|
||||
/*
|
||||
When a new flavor is selected, this takes care of updating the relevant
|
||||
progress bars associated with the flavor quota usage.
|
||||
*/
|
||||
updateFlavorUsage: function() {
|
||||
if(!this.is_flavor_quota) return;
|
||||
|
||||
var scope = this;
|
||||
var instance_count = (parseInt($("#id_count").val(), 10) || 1);
|
||||
var update_amount = 0;
|
||||
|
||||
this.getSelectedFlavor();
|
||||
|
||||
$(this.flavor_progress_bars).each(function(index, element) {
|
||||
var element_id = $(element).attr('id');
|
||||
var progress_stat = element_id.match(/^quota_(.+)/)[1];
|
||||
|
||||
if(progress_stat === undefined) {
|
||||
return;
|
||||
} else if(progress_stat === 'instances') {
|
||||
update_amount = instance_count;
|
||||
} else {
|
||||
update_amount = (scope.selected_flavor[progress_stat] * instance_count);
|
||||
}
|
||||
|
||||
scope.updateUsageFor(element, update_amount);
|
||||
});
|
||||
},
|
||||
|
||||
// Does the math to calculate what percentage to update a progress bar by.
|
||||
updateUsageFor: function(progress_element, increment_by) {
|
||||
progress_element = $(progress_element);
|
||||
|
||||
var update_indicator = progress_element.find('.progress_bar_selected');
|
||||
var quota_limit = parseInt(progress_element.attr('data-quota-limit'), 10);
|
||||
var quota_used = parseInt(progress_element.attr('data-quota-used'), 10);
|
||||
var percentage_to_update = ((increment_by / quota_limit) * 100);
|
||||
var percentage_used = ((quota_used / quota_limit) * 100);
|
||||
|
||||
this.update(update_indicator, percentage_used, percentage_to_update);
|
||||
},
|
||||
|
||||
/*
|
||||
Attaches event handlers for the form elements associated with the
|
||||
progress bars.
|
||||
*/
|
||||
_attachInputHandlers: function() {
|
||||
var scope = this;
|
||||
|
||||
if(this.is_flavor_quota) {
|
||||
var eventCallback = function(evt) {
|
||||
scope.showFlavorDetails();
|
||||
scope.updateFlavorUsage();
|
||||
};
|
||||
|
||||
$('#id_flavor').on('change', eventCallback);
|
||||
$('#id_count').on('keyup', eventCallback);
|
||||
}
|
||||
|
||||
$(this.user_value_form_inputs).each(function(index, element) {
|
||||
$(element).on('keyup', function(evt) {
|
||||
var progress_element = $('div[data-progress-indicator-for=' + $(evt.target).attr('id') + ']');
|
||||
var integers_in_input = $(evt.target).val().match(/\d+/g);
|
||||
var user_integer;
|
||||
|
||||
if(integers_in_input === null) {
|
||||
user_integer = 0;
|
||||
} else if(integers_in_input.length > 1) {
|
||||
/*
|
||||
Join all the numbers together that have been typed in. This takes
|
||||
care of junk input like "dd8d72n3k" and uses just the digits in
|
||||
that input, resulting in "8723".
|
||||
*/
|
||||
user_integer = integers_in_input.join('');
|
||||
} else if(integers_in_input.length == 1) {
|
||||
user_integer = integers_in_input[0];
|
||||
}
|
||||
|
||||
var progress_amount = parseInt(user_integer, 10);
|
||||
|
||||
scope.updateUsageFor(progress_element, progress_amount);
|
||||
});
|
||||
});
|
||||
},
|
||||
|
||||
/*
|
||||
Animate the progress bars of elements which indicate they should
|
||||
automatically be incremented, as opposed to elements which trigger
|
||||
progress updates based on form element input or changes.
|
||||
*/
|
||||
_initialAnimations: function() {
|
||||
var scope = this;
|
||||
|
||||
$(this.auto_value_progress_bars).each(function(index, element) {
|
||||
var auto_progress = $(element);
|
||||
var update_amount = parseInt(auto_progress.attr('data-progress-indicator-step-by'), 10);
|
||||
|
||||
scope.updateUsageFor(auto_progress, update_amount);
|
||||
});
|
||||
}
|
||||
};
|
@ -1,69 +0,0 @@
|
||||
/* Update quota usage infographics when a flavor is selected to show the usage
|
||||
* that will be consumed by the selected flavor. */
|
||||
horizon.updateQuotaUsages = function(flavors, usages) {
|
||||
var selectedFlavor = _.find(flavors, function(flavor) {
|
||||
return flavor.id == $("#id_flavor").children(":selected").val();
|
||||
});
|
||||
|
||||
var selectedCount = parseInt($("#id_count").val(), 10);
|
||||
if(isNaN(selectedCount)) {
|
||||
selectedCount = 1;
|
||||
}
|
||||
|
||||
// Map usage data fields to their corresponding html elements
|
||||
var flavorUsageMapping = [
|
||||
{'usage': 'instances', 'element': 'quota_instances'},
|
||||
{'usage': 'cores', 'element': 'quota_cores'},
|
||||
{'usage': 'gigabytes', 'element': 'quota_disk'},
|
||||
{'usage': 'ram', 'element': 'quota_ram'}
|
||||
];
|
||||
|
||||
var el, used, usage, width;
|
||||
_.each(flavorUsageMapping, function(mapping) {
|
||||
el = $('#' + mapping.element + " .progress_bar_selected");
|
||||
used = 0;
|
||||
usage = usages[mapping.usage];
|
||||
|
||||
if(mapping.usage == "instances") {
|
||||
used = selectedCount;
|
||||
} else {
|
||||
_.each(usage.flavor_fields, function(flavorField) {
|
||||
used += (selectedFlavor[flavorField] * selectedCount);
|
||||
});
|
||||
}
|
||||
|
||||
available = 100 - $('#' + mapping.element + " .progress_bar_fill").attr("data-width");
|
||||
if(used + usage.used <= usage.quota) {
|
||||
width = Math.round((used / usage.quota) * 100);
|
||||
el.removeClass('progress_bar_over');
|
||||
} else {
|
||||
width = available;
|
||||
if(!el.hasClass('progress_bar_over')) {
|
||||
el.addClass('progress_bar_over');
|
||||
}
|
||||
}
|
||||
|
||||
el.animate({width: width + "%"}, 300);
|
||||
});
|
||||
|
||||
// Also update flavor details
|
||||
$("#flavor_name").html(horizon.utils.truncate(selectedFlavor.name, 14, true));
|
||||
$("#flavor_vcpus").text(selectedFlavor.vcpus);
|
||||
$("#flavor_disk").text(selectedFlavor.disk);
|
||||
$("#flavor_ephemeral").text(selectedFlavor["OS-FLV-EXT-DATA:ephemeral"]);
|
||||
$("#flavor_disk_total").text(selectedFlavor.disk + selectedFlavor["OS-FLV-EXT-DATA:ephemeral"]);
|
||||
$("#flavor_ram").text(selectedFlavor.ram);
|
||||
};
|
||||
|
||||
horizon.addInitFunction(function () {
|
||||
var quota_containers = $(".quota-dynamic");
|
||||
if (quota_containers.length) {
|
||||
horizon.updateQuotaUsages(horizon_flavors, horizon_usages);
|
||||
}
|
||||
$(document).on("change", "#id_flavor", function() {
|
||||
horizon.updateQuotaUsages(horizon_flavors, horizon_usages);
|
||||
});
|
||||
$(document).on("keyup", "#id_count", function() {
|
||||
horizon.updateQuotaUsages(horizon_flavors, horizon_usages);
|
||||
});
|
||||
});
|
@ -3,14 +3,33 @@ horizon.utils = {
|
||||
capitalize: function(string) {
|
||||
return string.charAt(0).toUpperCase() + string.slice(1);
|
||||
},
|
||||
// Truncate a string at the desired length
|
||||
|
||||
/*
|
||||
Adds commas to any integer or numbers within a string for human display.
|
||||
|
||||
EG:
|
||||
horizon.utils.humanizeNumbers(1234); -> "1,234"
|
||||
horizon.utils.humanizeNumbers("My Total: 1234"); -> "My Total: 1,234"
|
||||
*/
|
||||
humanizeNumbers: function(number) {
|
||||
return number.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ",");
|
||||
},
|
||||
|
||||
/*
|
||||
Truncate a string at the desired length. Optionally append an ellipsis
|
||||
to the end of the string.
|
||||
|
||||
EG:
|
||||
horizon.utils.truncate("String that is too long.", 18, true); ->
|
||||
"String that is too…"
|
||||
*/
|
||||
truncate: function(string, size, includeEllipsis) {
|
||||
var ellip = "";
|
||||
if(includeEllipsis) {
|
||||
ellip = "…";
|
||||
}
|
||||
if(string.length > size) {
|
||||
return string.substring(0, size) + ellip;
|
||||
if(includeEllipsis) {
|
||||
return string.substring(0, (size - 3)) + "…";
|
||||
} else {
|
||||
return string.substring(0, size);
|
||||
}
|
||||
} else {
|
||||
return string;
|
||||
}
|
||||
|
@ -3,7 +3,7 @@
|
||||
<div class='clearfix'>
|
||||
<ul class="nav nav-tabs">
|
||||
{% for component in components %}
|
||||
{% if user|can_haz:component %}
|
||||
{% if user|has_permissions:component %}
|
||||
<li{% if current.slug == component.slug %} class="active"{% endif %}>
|
||||
<a href="{{ component.get_absolute_url }}" tabindex='1'>{{ component.name }}</a>
|
||||
</li>
|
||||
|
@ -25,7 +25,7 @@
|
||||
<script src='{{ STATIC_URL }}horizon/js/horizon.instances.js' type='text/javascript' charset='utf-8'></script>
|
||||
<script src='{{ STATIC_URL }}horizon/js/horizon.messages.js' type='text/javascript' charset='utf-8'></script>
|
||||
<script src='{{ STATIC_URL }}horizon/js/horizon.modals.js' type='text/javascript' charset='utf-8'></script>
|
||||
<script src='{{ STATIC_URL }}horizon/js/horizon.quotas.js' type='text/javascript' charset='utf-8'></script>
|
||||
<script src='{{ STATIC_URL }}horizon/js/horizon.quota.js' type='text/javascript' charset='utf-8'></script>
|
||||
<script src='{{ STATIC_URL }}horizon/js/horizon.tables.js' type='text/javascript' charset='utf-8'></script>
|
||||
<script src='{{ STATIC_URL }}horizon/js/horizon.tabs.js' type='text/javascript' charset='utf-8'></script>
|
||||
<script src='{{ STATIC_URL }}horizon/js/horizon.templates.js' type='text/javascript' charset='utf-8'></script>
|
||||
|
@ -1,7 +1,7 @@
|
||||
{% load horizon %}
|
||||
|
||||
{% for heading, panels in components.iteritems %}
|
||||
{% with panels|can_haz_list:user as filtered_panels %}
|
||||
{% with panels|has_permissions_on_list:user as filtered_panels %}
|
||||
{% if filtered_panels %}
|
||||
{% if heading %}<h4>{{ heading }}</h4>{% endif %}
|
||||
<ul class="main_nav">
|
||||
|
20
horizon/templates/horizon/client_side/_script_loader.html
Normal file
20
horizon/templates/horizon/client_side/_script_loader.html
Normal file
@ -0,0 +1,20 @@
|
||||
<script type="text/javascript" charset="utf-8">
|
||||
/*
|
||||
Added so that we can append Horizon scoped JS events to
|
||||
the DOM load events without running in to the "horizon"
|
||||
name-space not currently being defined since we load the
|
||||
scripts at the bottom of the page.
|
||||
*/
|
||||
var addHorizonLoadEvent = function(func) {
|
||||
var old_onload = window.onload;
|
||||
|
||||
if (typeof window.onload != 'function') {
|
||||
window.onload = func;
|
||||
} else {
|
||||
window.onload = function() {
|
||||
old_onload();
|
||||
func();
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
@ -27,7 +27,7 @@ register = template.Library()
|
||||
|
||||
|
||||
@register.filter
|
||||
def can_haz(user, component):
|
||||
def has_permissions(user, component):
|
||||
"""
|
||||
Checks if the given user meets the requirements for the component. This
|
||||
includes both user roles and services in the service catalog.
|
||||
@ -50,8 +50,9 @@ def can_haz(user, component):
|
||||
|
||||
|
||||
@register.filter
|
||||
def can_haz_list(components, user):
|
||||
return [component for component in components if can_haz(user, component)]
|
||||
def has_permissions_on_list(components, user):
|
||||
return [component for component
|
||||
in components if has_permissions(user, component)]
|
||||
|
||||
|
||||
@register.inclusion_tag('horizon/_nav_list.html', takes_context=True)
|
||||
|
@ -157,45 +157,50 @@ class ComputeApiTests(test.APITestCase):
|
||||
floating_ip.id)
|
||||
self.assertIsInstance(server, api.nova.Server)
|
||||
|
||||
@test.create_stubs({api.nova: ('volume_list',
|
||||
'server_list',
|
||||
'flavor_list',
|
||||
'tenant_floating_ip_list',
|
||||
'tenant_quota_get',)})
|
||||
def test_tenant_quota_usages(self):
|
||||
servers = self.servers.list()
|
||||
flavors = self.flavors.list()
|
||||
floating_ips = self.floating_ips.list()
|
||||
quotas = self.quotas.first()
|
||||
novaclient = self.stub_novaclient()
|
||||
api.nova.flavor_list(IsA(http.HttpRequest)) \
|
||||
.AndReturn(self.flavors.list())
|
||||
api.nova.tenant_quota_get(IsA(http.HttpRequest), '1') \
|
||||
.AndReturn(self.quotas.first())
|
||||
api.nova.tenant_floating_ip_list(IsA(http.HttpRequest)) \
|
||||
.AndReturn(self.floating_ips.list())
|
||||
api.nova.server_list(IsA(http.HttpRequest)) \
|
||||
.AndReturn(self.servers.list())
|
||||
api.nova.volume_list(IsA(http.HttpRequest)) \
|
||||
.AndReturn(self.volumes.list())
|
||||
|
||||
novaclient.servers = self.mox.CreateMockAnything()
|
||||
novaclient.servers.list(True, {'project_id': '1'}).AndReturn(servers)
|
||||
novaclient.flavors = self.mox.CreateMockAnything()
|
||||
novaclient.flavors.list().AndReturn(flavors)
|
||||
novaclient.floating_ips = self.mox.CreateMockAnything()
|
||||
novaclient.floating_ips.list().AndReturn(floating_ips)
|
||||
novaclient.quotas = self.mox.CreateMockAnything()
|
||||
novaclient.quotas.get(self.tenant.id).AndReturn(quotas)
|
||||
self.mox.ReplayAll()
|
||||
|
||||
quota_usages = api.tenant_quota_usages(self.request)
|
||||
|
||||
self.assertIsInstance(quota_usages, dict)
|
||||
self.assertEquals(quota_usages,
|
||||
{'gigabytes': {'available': 1000,
|
||||
'used': 0,
|
||||
'flavor_fields': ['disk',
|
||||
'OS-FLV-EXT-DATA:ephemeral'],
|
||||
'quota': 1000},
|
||||
'instances': {'available': 8,
|
||||
'used': 2,
|
||||
'flavor_fields': [],
|
||||
'quota': 10},
|
||||
'ram': {'available': 8976,
|
||||
'used': 1024,
|
||||
'flavor_fields': ['ram'],
|
||||
'quota': 10000},
|
||||
'cores': {'available': 8,
|
||||
'used': 2,
|
||||
'flavor_fields': ['vcpus'],
|
||||
'quota': 10},
|
||||
'floating_ips': {'available': -1,
|
||||
expected_output = {'gigabytes': {
|
||||
'used': 80,
|
||||
'flavor_fields': [],
|
||||
'quota': 1000},
|
||||
'ram': {
|
||||
'available': 8976,
|
||||
'used': 1024,
|
||||
'flavor_fields': ['ram'],
|
||||
'quota': 10000},
|
||||
'floating_ips': {
|
||||
'used': 2,
|
||||
'flavor_fields': [],
|
||||
'quota': 1}})
|
||||
'quota': 1},
|
||||
'instances': {
|
||||
'used': 2,
|
||||
'flavor_fields': [],
|
||||
'quota': 10},
|
||||
'volumes': {
|
||||
'used': 3,
|
||||
'flavor_fields': [],
|
||||
'quota': 1},
|
||||
'cores': {
|
||||
'used': 2,
|
||||
'flavor_fields': ['vcpus'],
|
||||
'quota': 10}}
|
||||
|
||||
self.assertEquals(quota_usages, expected_output)
|
||||
|
@ -5,6 +5,7 @@
|
||||
<meta content='text/html; charset=utf-8' http-equiv='Content-Type' />
|
||||
<title>{% block title %}{% endblock %} – {% site_branding %} Dashboard</title>
|
||||
{% block css %}{% endblock %}
|
||||
{% include "horizon/client_side/_script_loader.html" %}
|
||||
</head>
|
||||
<body id="{% block body_id %}{% endblock %}">
|
||||
{% block content %}
|
||||
|
@ -42,6 +42,7 @@ DATABASES = {'default': {'ENGINE': 'django.db.backends.sqlite3'}}
|
||||
INSTALLED_APPS = (
|
||||
'django.contrib.sessions',
|
||||
'django.contrib.messages',
|
||||
'django.contrib.humanize',
|
||||
'django_nose',
|
||||
'horizon',
|
||||
'horizon.tests',
|
||||
|
@ -233,7 +233,7 @@ class WorkflowsTests(test.TestCase):
|
||||
self.assertContains(output, unicode(TestActionTwo.name))
|
||||
self.assertContains(output, unicode(TestActionThree.name))
|
||||
|
||||
def test_can_haz(self):
|
||||
def test_has_permissions(self):
|
||||
self.assertQuerysetEqual(TestWorkflow._cls_registry, [])
|
||||
TestWorkflow.register(AdminStep)
|
||||
flow = TestWorkflow(self.request)
|
||||
|
@ -29,7 +29,7 @@ from django.template.defaultfilters import linebreaks, safe
|
||||
|
||||
from horizon import base
|
||||
from horizon import exceptions
|
||||
from horizon.templatetags.horizon import can_haz
|
||||
from horizon.templatetags.horizon import has_permissions
|
||||
from horizon.utils import html
|
||||
|
||||
|
||||
@ -561,7 +561,7 @@ class Workflow(html.HTMLElement):
|
||||
self._registry[default_step] = default_step(self)
|
||||
self._ordered_steps = [self._registry[step_class]
|
||||
for step_class in ordered_step_classes
|
||||
if can_haz(self.request.user,
|
||||
if has_permissions(self.request.user,
|
||||
self._registry[step_class])]
|
||||
|
||||
def _order_steps(self):
|
||||
|
@ -111,6 +111,7 @@ INSTALLED_APPS = (
|
||||
'django.contrib.sessions',
|
||||
'django.contrib.messages',
|
||||
'django.contrib.staticfiles',
|
||||
'django.contrib.humanize',
|
||||
'compressor',
|
||||
'horizon',
|
||||
'horizon.dashboards.nova',
|
||||
|
@ -8,6 +8,7 @@
|
||||
{% block css %}
|
||||
{% include "_stylesheets.html" %}
|
||||
{% endblock %}
|
||||
{% include "horizon/client_side/_script_loader.html" %}
|
||||
</head>
|
||||
<body id="{% block body_id %}{% endblock %}">
|
||||
{% block content %}
|
||||
|
Loading…
x
Reference in New Issue
Block a user