Adds usage vs quota data to the launch instance dialog. Adds a reusable

progress bar indicator.

Fixes bug 905563.

Change-Id: I5e3cc627be1ac4342f0b4e0a5abb09f70938fa60
This commit is contained in:
Tres Henry 2012-02-23 16:55:33 -08:00
parent 9897670519
commit 7ab394829f
10 changed files with 208 additions and 91 deletions

View File

@ -41,15 +41,14 @@ VOLUME_STATE_AVAILABLE = "available"
class VNCConsole(APIDictWrapper):
"""
Wrapper for the "console" dictionary returned by the
"""Wrapper for the "console" dictionary returned by the
novaclient.servers.get_vnc_console method.
"""
_attrs = ['url', 'type']
class Quota(object):
""" Wrapper for individual limits in a quota. """
"""Wrapper for individual limits in a quota."""
def __init__(self, name, limit):
self.name = name
self.limit = limit
@ -59,9 +58,8 @@ class Quota(object):
class QuotaSet(object):
"""
Wrapper for novaclient.quotas.QuotaSet objects which wraps the individual
quotas inside Quota objects.
"""Wrapper for novaclient.quotas.QuotaSet objects which wraps the
individual quotas inside Quota objects.
"""
def __init__(self, apiresource):
self.items = []
@ -78,6 +76,7 @@ class Server(APIResourceWrapper):
"""Simple wrapper around novaclient.server.Server
Preserves the request info so image name can later be retrieved
"""
_attrs = ['addresses', 'attrs', 'id', 'image', 'links',
'metadata', 'name', 'private_ip', 'public_ip', 'status', 'uuid',
@ -105,7 +104,7 @@ class Server(APIResourceWrapper):
class Usage(APIResourceWrapper):
"""Simple wrapper around contrib/simple_usage.py"""
"""Simple wrapper around contrib/simple_usage.py."""
_attrs = ['start', 'server_usages', 'stop', 'tenant_id',
'total_local_gb_usage', 'total_memory_mb_usage',
'total_vcpus_usage', 'total_hours']
@ -147,15 +146,14 @@ class Usage(APIResourceWrapper):
class SecurityGroup(APIResourceWrapper):
"""
Wrapper around novaclient.security_groups.SecurityGroup which wraps its
"""Wrapper around novaclient.security_groups.SecurityGroup which wraps its
rules in SecurityGroupRule objects and allows access to them.
"""
_attrs = ['id', 'name', 'description', 'tenant_id']
@property
def rules(self):
""" Wraps transmitted rule info in the novaclient rule class. """
"""Wraps transmitted rule info in the novaclient rule class."""
if not hasattr(self, "_rules"):
manager = nova_rules.SecurityGroupRuleManager
self._rules = [nova_rules.SecurityGroupRule(manager, rule) for \
@ -226,38 +224,29 @@ def flavor_list(request):
def tenant_floating_ip_list(request):
"""
Fetches a list of all floating ips.
"""
"""Fetches a list of all floating ips."""
return novaclient(request).floating_ips.list()
def floating_ip_pools_list(request):
"""
Fetches a list of all floating ip pools.
"""
"""Fetches a list of all floating ip pools."""
return novaclient(request).floating_ip_pools.list()
def tenant_floating_ip_get(request, floating_ip_id):
"""
Fetches a floating ip.
"""
"""Fetches a floating ip."""
return novaclient(request).floating_ips.get(floating_ip_id)
def tenant_floating_ip_allocate(request, pool=None):
"""
Allocates a floating ip to tenant.
Optionally you may provide a pool for which you would like the IP.
"""Allocates a floating ip to tenant. Optionally you may provide a pool
for which you would like the IP.
"""
return novaclient(request).floating_ips.create(pool=pool)
def tenant_floating_ip_release(request, floating_ip_id):
"""
Releases floating ip from the pool of a tenant.
"""
"""Releases floating ip from the pool of a tenant."""
return novaclient(request).floating_ips.delete(floating_ip_id)
@ -310,7 +299,7 @@ def server_list(request, search_opts=None, all_tenants=False):
def server_console_output(request, instance_id, tail_length=None):
"""Gets console output of an instance"""
"""Gets console output of an instance."""
return novaclient(request).servers.get_console_output(instance_id,
length=tail_length)
@ -341,8 +330,7 @@ def server_update(request, instance_id, name):
def server_add_floating_ip(request, server, floating_ip):
"""
Associates floating IP to server's fixed IP.
"""Associates floating IP to server's fixed IP.
"""
server = novaclient(request).servers.get(server)
fip = novaclient(request).floating_ips.get(floating_ip)
@ -350,8 +338,7 @@ def server_add_floating_ip(request, server, floating_ip):
def server_remove_floating_ip(request, server, floating_ip):
"""
Removes relationship between floating and server's fixed ip.
"""Removes relationship between floating and server's fixed ip.
"""
fip = novaclient(request).floating_ips.get(floating_ip)
server = novaclient(request).servers.get(fip.instance_id)
@ -378,6 +365,31 @@ def usage_list(request, start, end):
return [Usage(u) for u in novaclient(request).usage.list(start, end, True)]
def tenant_quota_usages(request):
"""Builds a dictionary of current usage against quota for the current
tenant.
"""
# TODO(tres): Make this capture floating_ips and volumes as well.
instances = server_list(request)
quotas = tenant_quota_get(request, request.user.tenant_id)
flavors = dict([(f.id, f) for f in flavor_list(request)])
usages = {'instances': {'flavor_fields': [], 'used': len(instances)},
'cores': {'flavor_fields': ['vcpus'], 'used': 0},
'gigabytes': {'flavor_fields': ['disk', 'ephemeral'], 'used': 0},
'ram': {'flavor_fields': ['ram'], 'used': 0}}
for usage in usages:
for instance in instances:
for flavor_field in usages[usage]['flavor_fields']:
usages[usage]['used'] += getattr(
flavors[instance.flavor['id']], flavor_field)
usages[usage]['quota'] = getattr(quotas, usage)
usages[usage]['available'] = usages[usage]['quota'] - \
usages[usage]['used']
return usages
def security_group_list(request):
return [SecurityGroup(g) for g in novaclient(request).\
security_groups.list()]

View File

@ -33,16 +33,15 @@ IMAGES_INDEX_URL = reverse('horizon:nova:images_and_snapshots:index')
class ImageViewTests(test.TestCase):
def test_launch_get(self):
image = self.images.first()
tenant = self.tenants.first()
quota = self.quotas.first()
quota_usages = self.quota_usages.first()
self.mox.StubOutWithMock(api, 'image_get_meta')
self.mox.StubOutWithMock(api, 'tenant_quota_get')
self.mox.StubOutWithMock(api, 'tenant_quota_usages')
self.mox.StubOutWithMock(api, 'flavor_list')
self.mox.StubOutWithMock(api, 'keypair_list')
self.mox.StubOutWithMock(api, 'security_group_list')
api.image_get_meta(IsA(http.HttpRequest), image.id).AndReturn(image)
api.tenant_quota_get(IsA(http.HttpRequest), tenant.id).AndReturn(quota)
api.tenant_quota_usages(IsA(http.HttpRequest)).AndReturn(quota_usages)
api.flavor_list(IsA(http.HttpRequest)).AndReturn(self.flavors.list())
api.keypair_list(IsA(http.HttpRequest)).AndReturn(self.keypairs.list())
api.security_group_list(IsA(http.HttpRequest)) \
@ -119,14 +118,14 @@ class ImageViewTests(test.TestCase):
image = self.images.first()
self.mox.StubOutWithMock(api, 'image_get_meta')
self.mox.StubOutWithMock(api, 'tenant_quota_get')
self.mox.StubOutWithMock(api, 'tenant_quota_usages')
self.mox.StubOutWithMock(api, 'flavor_list')
self.mox.StubOutWithMock(api, 'keypair_list')
self.mox.StubOutWithMock(api, 'security_group_list')
api.image_get_meta(IsA(http.HttpRequest),
image.id).AndReturn(image)
api.tenant_quota_get(IsA(http.HttpRequest),
self.tenant.id).AndReturn(self.quotas.first())
api.tenant_quota_usages(IsA(http.HttpRequest)).AndReturn(
self.quota_usages.first())
exc = keystone_exceptions.ClientException('Failed.')
api.flavor_list(IsA(http.HttpRequest)).AndRaise(exc)
api.keypair_list(IsA(http.HttpRequest)).AndReturn(self.keypairs.list())
@ -144,13 +143,13 @@ class ImageViewTests(test.TestCase):
image = self.images.first()
self.mox.StubOutWithMock(api, 'image_get_meta')
self.mox.StubOutWithMock(api, 'tenant_quota_get')
self.mox.StubOutWithMock(api, 'tenant_quota_usages')
self.mox.StubOutWithMock(api, 'flavor_list')
self.mox.StubOutWithMock(api, 'keypair_list')
self.mox.StubOutWithMock(api, 'security_group_list')
api.image_get_meta(IsA(http.HttpRequest), image.id).AndReturn(image)
api.tenant_quota_get(IsA(http.HttpRequest),
self.tenant.id).AndReturn(self.quotas.first())
api.tenant_quota_usages(IsA(http.HttpRequest)).AndReturn(
self.quota_usages.first())
api.flavor_list(IsA(http.HttpRequest)).AndReturn(self.flavors.list())
exception = keystone_exceptions.ClientException('Failed.')
api.keypair_list(IsA(http.HttpRequest)).AndRaise(exception)

View File

@ -61,11 +61,8 @@ class LaunchView(forms.ModalFormView):
def get_context_data(self, **kwargs):
context = super(LaunchView, self).get_context_data(**kwargs)
tenant_id = self.request.user.tenant_id
try:
quotas = api.tenant_quota_get(self.request, tenant_id)
quotas.ram = int(quotas.ram)
context['quotas'] = quotas
context['usages'] = api.tenant_quota_usages(self.request)
except:
exceptions.handle(self.request)
return context

View File

@ -1,5 +1,6 @@
{% extends "horizon/common/_modal_form.html" %}
{% load i18n %}
{% load horizon i18n %}
{% block form_id %}launch_image_form{% endblock %}
{% block form_action %}{% url horizon:nova:images_and_snapshots:images:launch image.id %}{% endblock %}
@ -9,39 +10,42 @@
{% block modal-body %}
<div class="left">
<fieldset>
{% include "horizon/common/_form_fields.html" %}
</fieldset>
<fieldset>
{% include "horizon/common/_form_fields.html" %}
</fieldset>
</div>
<div class="right">
<h3>{% trans "Description:" %}</h3>
<p>{% trans "Specify the details for launching an instance. Also please make note of the table below; all projects have quotas which define the limit of resources they are allowed to provision." %}</p>
<table class="table table-striped table-bordered">
<tr>
<th>{% trans "Quota Name" %}</th>
<th>{% trans "Limit" %}</th>
</tr>
<tr>
<td>{% trans "RAM (MB)" %}</td>
<td>{{ quotas.ram }}MB</td>
</tr>
<tr>
<td>{% trans "Floating IPs" %}</td>
<td>{{ quotas.floating_ips }}</td>
</tr>
<tr>
<td>{% trans "Instances" %}</td>
<td>{{ quotas.instances }}</td>
</tr>
<tr>
<td>{% trans "Volumes" %}</td>
<td>{{ quotas.volumes }}</td>
</tr>
<tr>
<td>{% trans "Available Disk" %}</td>
<td>{{ quotas.gigabytes }}GB</td>
</tr>
</table>
<h3>{% trans "Description:" %}</h3>
<p>{% trans "Specify the details for launching an instance. The chart below shows the resources used by this project in relation to the project's quotas." %}</p>
<h3>{% trans "Project Quotas" %}</h3>
<div class="quota_title">
<strong>{% trans "Instance Count" %} <span>({{ usages.instances.used }})</span></strong>
<p>{{ usages.instances.available }} {% trans "Available" %}</p>
</div>
<div class="clearfix"></div>
<div 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 }} {% trans "Available" %}</p>
</div>
<div class="clearfix"></div>
<div 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 }} {% trans "GB" %} {% trans "Available" %}</p>
</div>
<div class="clearfix"></div>
<div 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 }} {% trans "MB" %} {% trans "Available" %}</p>
</div>
<div class="clearfix"></div>
<div class="quota_bar">{% horizon_progress_bar usages.ram.used usages.ram.quota %}</div>
</div>
{% endblock %}

View File

@ -0,0 +1 @@
<div class="progress_bar"><div class="progress_bar_fill" style="width: {% widthratio current_val max_val 100 %}%"></div></div>

View File

@ -96,6 +96,24 @@ def horizon_dashboard_nav(context):
'current': context['request'].horizon['panel'].slug}
@register.inclusion_tag('horizon/common/_progress_bar.html')
def horizon_progress_bar(current_val, max_val):
""" Renders a progress bar based on parameters passed to the tag. The first
parameter is the current value and the second is the max value.
Example: ``{% progress_bar 25 50 %}``
This will generate a half-full progress bar.
The rendered progress bar will fill the area of its container. To constrain
the rendered size of the bar provide a container with appropriate width and
height styles.
"""
return {'current_val': current_val,
'max_val': max_val}
class JSTemplateNode(template.Node):
""" Helper node for the ``jstemplate`` template tag. """
def __init__(self, nodelist):

View File

@ -156,3 +156,39 @@ class ComputeApiTests(test.APITestCase):
server.id,
floating_ip.id)
self.assertIsInstance(server, api.nova.Server)
def test_tenant_quota_usages(self):
servers = self.servers.list()
flavors = self.flavors.list()
quotas = self.quotas.first()
novaclient = self.stub_novaclient()
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.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',
'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}})

View File

@ -135,6 +135,7 @@ def data(TEST):
TEST.security_group_rules = TestDataContainer()
TEST.volumes = TestDataContainer()
TEST.quotas = TestDataContainer()
TEST.quota_usages = TestDataContainer()
TEST.floating_ips = TestDataContainer()
TEST.usages = TestDataContainer()
TEST.certs = TestDataContainer()
@ -152,17 +153,19 @@ def data(TEST):
# Flavors
flavor_1 = flavors.Flavor(flavors.FlavorManager,
dict(id="1",
name='m1.tiny',
vcpus=1,
disk=0,
ram=512))
{'id': "1",
'name': 'm1.tiny',
'vcpus': 1,
'disk': 0,
'ram': 512,
'OS-FLV-EXT-DATA:ephemeral': 0})
flavor_2 = flavors.Flavor(flavors.FlavorManager,
dict(id="2",
name='m1.massive',
vcpus=1000,
disk=1024,
ram=10000))
{'id': "2",
'name': 'm1.massive',
'vcpus': 1000,
'disk': 1024,
'ram': 10000,
'OS-FLV-EXT-DATA:ephemeral': 2048})
TEST.flavors.add(flavor_1, flavor_2)
# Keypairs
@ -203,15 +206,29 @@ def data(TEST):
quota_data = dict(metadata_items='1',
injected_file_content_bytes='1',
volumes='1',
gigabytes='1',
ram=1,
gigabytes='1000',
ram=10000,
floating_ips='1',
instances='1',
instances='10',
injected_files='1',
cores='1')
cores='10')
quota = quotas.QuotaSet(quotas.QuotaSetManager, quota_data)
TEST.quotas.add(quota)
# Quota Usages
TEST.quota_usages.add({'gigabytes': {'available': 1000,
'used': 0,
'quota': 1000},
'instances': {'available': 10,
'used': 0,
'quota': 10},
'ram': {'available': 10000,
'used': 0,
'quota': 10000},
'cores': {'available': 20,
'used': 0,
'quota': 20}})
# Servers
vals = {"host": "http://nova.example.com:8774",
"name": "server_1",

View File

@ -852,3 +852,36 @@ form .error {
color: #b94a48;
border: 1px solid #E9B1B0;
}
.progress_bar {
height: 100%;
width: 100%;
border: 1px solid #CCC;
background-color: #CCC;
}
.progress_bar_fill {
height: 100%;
background-color: #666;
}
.quota_title {
color: #999;
}
.quota_title strong {
color: #000;
}
.quota_title strong span {
font-weight: normal;
}
.quota_title p {
float: right;
}
.quota_bar {
height: 15px;
margin: -8px 0 8px;
}

View File

@ -6,7 +6,7 @@ set -o errexit
# Increment me any time the environment should be rebuilt.
# This includes dependncy changes, directory renames, etc.
# Simple integer secuence: 1, 2, 3...
environment_version=11
environment_version=12
#--------------------------------------------------------#
function usage {