diff --git a/horizon/api/nova.py b/horizon/api/nova.py index de30ed3d7..113e39e00 100644 --- a/horizon/api/nova.py +++ b/horizon/api/nova.py @@ -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'] diff --git a/horizon/dashboards/nova/images_and_snapshots/templates/images_and_snapshots/snapshots/create.html b/horizon/dashboards/nova/images_and_snapshots/templates/images_and_snapshots/snapshots/create.html index 086d013b4..8dba1e41d 100644 --- a/horizon/dashboards/nova/images_and_snapshots/templates/images_and_snapshots/snapshots/create.html +++ b/horizon/dashboards/nova/images_and_snapshots/templates/images_and_snapshots/snapshots/create.html @@ -9,5 +9,3 @@ {% block dash_main %} {% include 'nova/images_and_snapshots/snapshots/_create.html' %} {% endblock %} - - diff --git a/horizon/dashboards/nova/instances_and_volumes/templates/instances_and_volumes/instances/_launch_details_help.html b/horizon/dashboards/nova/instances_and_volumes/templates/instances_and_volumes/instances/_launch_details_help.html index 125475ecd..89362202e 100644 --- a/horizon/dashboards/nova/instances_and_volumes/templates/instances_and_volumes/instances/_launch_details_help.html +++ b/horizon/dashboards/nova/instances_and_volumes/templates/instances_and_volumes/instances/_launch_details_help.html @@ -1,4 +1,4 @@ -{% load i18n horizon %} +{% load i18n horizon humanize %}

{% blocktrans %}Specify the details for launching an instance.{% endblocktrans %}

{% blocktrans %}The chart below shows the resources used by this project in relation to the project's quotas.{% endblocktrans %}

@@ -17,55 +17,37 @@

{% trans "Project Quotas" %}

-
- {% trans "Instance Count" %} ({{ usages.instances.used }}) -

{{ usages.instances.available|quota }}

+
+ {% trans "Number of Instances" %} ({{ usages.instances.used|intcomma }}) +

{{ usages.instances.available|quota|intcomma }}

+
+
+ {% horizon_progress_bar usages.instances.used usages.instances.quota %}
-
-
{% horizon_progress_bar usages.instances.used usages.instances.quota %}
-
- {% trans "VCPUs" %} ({{ usages.cores.used }}) -

{{ usages.cores.available|quota }}

+
+ {% trans "Number of VCPUs" %} ({{ usages.cores.used|intcomma }}) +

{{ usages.cores.available|quota|intcomma }}

+
+
+ {% horizon_progress_bar usages.cores.used usages.cores.quota %}
-
-
{% horizon_progress_bar usages.cores.used usages.cores.quota %}
-
- {% trans "Disk" %} ({{ usages.gigabytes.used }} {% trans "GB" %}) -

{{ usages.gigabytes.available|quota:"GB" }}

+
+ {% trans "Total Memory" %} ({{ usages.ram.used|intcomma }} {% trans "MB" %}) +

{{ usages.ram.available|quota:"MB"|intcomma }}

-
-
{% horizon_progress_bar usages.gigabytes.used usages.gigabytes.quota %}
- -
- {% trans "Memory" %} ({{ usages.ram.used }} {% trans "MB" %}) -

{{ usages.ram.available|quota:"MB" }}

+
+ {% horizon_progress_bar usages.ram.used usages.ram.quota %}
-
-
{% horizon_progress_bar usages.ram.used usages.ram.quota %}
diff --git a/horizon/dashboards/nova/instances_and_volumes/templates/instances_and_volumes/instances/_launch_volumes_help.html b/horizon/dashboards/nova/instances_and_volumes/templates/instances_and_volumes/instances/_launch_volumes_help.html index 6d0f7d3a2..26f4429cb 100644 --- a/horizon/dashboards/nova/instances_and_volumes/templates/instances_and_volumes/instances/_launch_volumes_help.html +++ b/horizon/dashboards/nova/instances_and_volumes/templates/instances_and_volumes/instances/_launch_volumes_help.html @@ -1,22 +1,3 @@ {% load i18n horizon %}

{% blocktrans %}An instance can be launched with varying types of attached storage. You may select from those options here.{% endblocktrans %}

- - diff --git a/horizon/dashboards/nova/instances_and_volumes/templates/instances_and_volumes/volumes/_create.html b/horizon/dashboards/nova/instances_and_volumes/templates/instances_and_volumes/volumes/_create.html index f0645485b..47f1adef2 100644 --- a/horizon/dashboards/nova/instances_and_volumes/templates/instances_and_volumes/volumes/_create.html +++ b/horizon/dashboards/nova/instances_and_volumes/templates/instances_and_volumes/volumes/_create.html @@ -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 %} -
+
- {% include "horizon/common/_form_fields.html" %} + {% include "horizon/common/_form_fields.html" %}
-
-
+
+ +

{% trans "Description" %}:

+

{% trans "Volumes are block devices that can be attached to instances." %}

-
+ +

{% trans "Volume Quotas" %}

+ +
+ {% trans "Total Gigabytes" %} ({{ usages.gigabytes.used|intcomma }} GB) +

{{ usages.gigabytes.available|quota:"GB"|intcomma }}

+
+ +
+ {% horizon_progress_bar usages.gigabytes.used usages.gigabytes.quota %} +
+ +
+ {% trans "Number of Volumes" %} ({{ usages.volumes.used|intcomma }}) +

{{ usages.volumes.available|quota|intcomma }}

+
+ +
+ {% horizon_progress_bar usages.volumes.used usages.volumes.quota %} +
+
+ + {% endblock %} {% block modal-footer %} diff --git a/horizon/dashboards/nova/instances_and_volumes/volumes/forms.py b/horizon/dashboards/nova/instances_and_volumes/volumes/forms.py index 33f8dd6c7..b35c18170 100644 --- a/horizon/dashboards/nova/instances_and_volumes/volumes/forms.py +++ b/horizon/dashboards/nova/instances_and_volumes/volumes/forms.py @@ -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") diff --git a/horizon/dashboards/nova/instances_and_volumes/volumes/tests.py b/horizon/dashboards/nova/instances_and_volumes/volumes/tests.py index 88f59431b..bfdef759e 100644 --- a/horizon/dashboards/nova/instances_and_volumes/volumes/tests.py +++ b/horizon/dashboards/nova/instances_and_volumes/volumes/tests.py @@ -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() diff --git a/horizon/dashboards/nova/instances_and_volumes/volumes/views.py b/horizon/dashboards/nova/instances_and_volumes/volumes/views.py index 03b74624b..63eb6de28 100644 --- a/horizon/dashboards/nova/instances_and_volumes/volumes/views.py +++ b/horizon/dashboards/nova/instances_and_volumes/volumes/views.py @@ -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 diff --git a/horizon/exceptions.py b/horizon/exceptions.py index 0c3a8c6fa..707b4fe62 100644 --- a/horizon/exceptions.py +++ b/horizon/exceptions.py @@ -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'] diff --git a/horizon/static/horizon/js/horizon.quota.js b/horizon/static/horizon/js/horizon.quota.js new file mode 100644 index 000000000..7dc9a27bd --- /dev/null +++ b/horizon/static/horizon/js/horizon.quota.js @@ -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: + +
+ {% horizon_progress_bar total_number_used max_number_allowed %} +
+ + 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); + }); + } +}; diff --git a/horizon/static/horizon/js/horizon.quotas.js b/horizon/static/horizon/js/horizon.quotas.js deleted file mode 100644 index 49b8b87e2..000000000 --- a/horizon/static/horizon/js/horizon.quotas.js +++ /dev/null @@ -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); - }); -}); diff --git a/horizon/static/horizon/js/horizon.utils.js b/horizon/static/horizon/js/horizon.utils.js index 376549b51..bc361167a 100644 --- a/horizon/static/horizon/js/horizon.utils.js +++ b/horizon/static/horizon/js/horizon.utils.js @@ -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; } diff --git a/horizon/templates/horizon/_nav_list.html b/horizon/templates/horizon/_nav_list.html index b7f57e279..f300be27a 100644 --- a/horizon/templates/horizon/_nav_list.html +++ b/horizon/templates/horizon/_nav_list.html @@ -3,7 +3,7 @@