From 6c359166a65d19ad9ed205ac8ff68cab06ee4a77 Mon Sep 17 00:00:00 2001 From: Gabriel Hurley Date: Thu, 12 Jan 2012 14:47:10 -0800 Subject: [PATCH] Converts instances and volumes to new tables, modals, etc. This commit reworks the instances and volumes panels, extends that to the syspanel instances panel, cleans up usage-related code and moves it to overview and/or tenants panels as appropriate, and finally implements a new layout/modal interface style for combined modal/table views like security groups and volume attachments. Re-ordered the attach volume form. Fixed bug 913863. Reworked syspanel usage views. Fixed bug 904861. Table displays have much more useful data. Fixed bug 905065 and fixed bug 907512. New modals fixed bug 898867. Lots of additional code cleanup and fixes. Change-Id: I407d3ec70a080883c137a963fa0ee22124b53dc2 --- horizon/horizon/api/nova.py | 44 ++- .../security_groups/forms.py | 4 +- .../security_groups/tables.py | 19 +- .../security_groups/tests.py | 16 +- .../security_groups/views.py | 4 + .../nova/images_and_snapshots/images/forms.py | 2 +- .../nova/images_and_snapshots/images/tests.py | 4 +- .../images_and_snapshots/snapshots/forms.py | 4 +- .../images_and_snapshots/snapshots/tests.py | 6 +- .../images_and_snapshots/snapshots/views.py | 4 +- .../instances_and_volumes/instances/forms.py | 149 +------- .../instances_and_volumes/instances/tables.py | 202 +++++++++++ .../instances_and_volumes/instances/tests.py | 244 ++++--------- .../instances_and_volumes/instances/urls.py | 11 +- .../instances_and_volumes/instances/views.py | 300 ++++------------ .../nova/instances_and_volumes/tests.py | 8 +- .../nova/instances_and_volumes/urls.py | 13 +- .../nova/instances_and_volumes/views.py | 101 ++---- .../instances_and_volumes/volumes/forms.py | 51 +-- .../instances_and_volumes/volumes/tables.py | 141 ++++++++ .../instances_and_volumes/volumes/urls.py | 9 +- .../instances_and_volumes/volumes/views.py | 100 +++--- .../horizon/dashboards/nova/overview/tests.py | 132 +++++++ .../horizon/dashboards/nova/overview/urls.py | 4 +- .../horizon/dashboards/nova/overview/views.py | 98 ++++++ .../security_groups/_edit_rules.html | 40 +-- .../images_and_snapshots/images/_launch.html | 2 +- .../nova/images_and_snapshots/index.html | 2 +- .../nova/instances_and_volumes/index.html | 22 +- .../instances/_instance_ips.html | 10 + .../instances/_list.html | 83 ----- .../instances/_update.html | 2 +- .../instances/index.html | 57 --- .../instances/update.html | 25 +- .../volumes/_attach.html | 14 +- .../volumes/_create.html | 2 +- .../instances_and_volumes/volumes/_list.html | 70 ---- .../instances_and_volumes/volumes/attach.html | 6 +- .../instances_and_volumes/volumes/create.html | 2 +- .../instances_and_volumes/volumes/index.html | 25 -- .../instances => overview}/usage.csv | 0 .../instances => overview}/usage.html | 0 .../dashboards/syspanel/instances/urls.py | 13 +- .../dashboards/syspanel/instances/views.py | 333 +----------------- .../dashboards/syspanel/overview/urls.py | 4 +- .../dashboards/syspanel/overview/views.py | 176 +++++++++ .../templates/syspanel/instances/_list.html | 4 +- .../templates/syspanel/instances/index.html | 55 +-- .../syspanel/instances/tenant_usage.csv | 11 - .../syspanel/instances/tenant_usage.html | 91 ----- .../syspanel/{instances => tenants}/usage.csv | 0 .../{instances => tenants}/usage.html | 6 +- .../dashboards/syspanel/tenants/tables.py | 8 +- .../dashboards/syspanel/tenants/urls.py | 4 +- .../dashboards/syspanel/tenants/views.py | 62 ++++ horizon/horizon/exceptions.py | 4 +- horizon/horizon/forms/__init__.py | 2 +- horizon/horizon/forms/base.py | 132 +------ horizon/horizon/tables/actions.py | 38 +- horizon/horizon/tables/base.py | 60 +++- .../templates/horizon/common/_data_table.html | 2 +- .../templates/horizon/common/_modal_form.html | 14 +- horizon/horizon/views/__init__.py | 17 + horizon/horizon/views/base.py | 46 +++ .../dashboard/static/dashboard/css/style.css | 54 +-- 65 files changed, 1402 insertions(+), 1766 deletions(-) create mode 100644 horizon/horizon/dashboards/nova/instances_and_volumes/instances/tables.py create mode 100644 horizon/horizon/dashboards/nova/instances_and_volumes/volumes/tables.py create mode 100644 horizon/horizon/dashboards/nova/overview/tests.py create mode 100644 horizon/horizon/dashboards/nova/overview/views.py create mode 100644 horizon/horizon/dashboards/nova/templates/nova/instances_and_volumes/instances/_instance_ips.html delete mode 100644 horizon/horizon/dashboards/nova/templates/nova/instances_and_volumes/instances/_list.html delete mode 100644 horizon/horizon/dashboards/nova/templates/nova/instances_and_volumes/instances/index.html delete mode 100644 horizon/horizon/dashboards/nova/templates/nova/instances_and_volumes/volumes/_list.html delete mode 100644 horizon/horizon/dashboards/nova/templates/nova/instances_and_volumes/volumes/index.html rename horizon/horizon/dashboards/nova/templates/nova/{instances_and_volumes/instances => overview}/usage.csv (100%) rename horizon/horizon/dashboards/nova/templates/nova/{instances_and_volumes/instances => overview}/usage.html (100%) create mode 100644 horizon/horizon/dashboards/syspanel/overview/views.py delete mode 100644 horizon/horizon/dashboards/syspanel/templates/syspanel/instances/tenant_usage.csv delete mode 100644 horizon/horizon/dashboards/syspanel/templates/syspanel/instances/tenant_usage.html rename horizon/horizon/dashboards/syspanel/templates/syspanel/{instances => tenants}/usage.csv (100%) rename horizon/horizon/dashboards/syspanel/templates/syspanel/{instances => tenants}/usage.html (95%) create mode 100644 horizon/horizon/views/base.py diff --git a/horizon/horizon/api/nova.py b/horizon/horizon/api/nova.py index 6405fb32979..e804d0032a6 100644 --- a/horizon/horizon/api/nova.py +++ b/horizon/horizon/api/nova.py @@ -25,6 +25,7 @@ from django.contrib import messages from novaclient.v1_1 import client as nova_client +from novaclient.v1_1 import security_group_rules as nova_rules from novaclient.v1_1.servers import REBOOT_HARD from horizon.api.base import * @@ -87,7 +88,8 @@ class Server(APIResourceWrapper): """ _attrs = ['addresses', 'attrs', 'hostId', 'id', 'image', 'links', 'metadata', 'name', 'private_ip', 'public_ip', 'status', 'uuid', - 'image_name', 'VirtualInterfaces', 'flavor', 'key_name'] + 'image_name', 'VirtualInterfaces', 'flavor', 'key_name', + 'OS-EXT-STS:power_state', 'OS-EXT-STS:task_state'] def __init__(self, apiresource, request): super(Server, self).__init__(apiresource) @@ -135,12 +137,31 @@ class Usage(APIResourceWrapper): class SecurityGroup(APIResourceWrapper): """Simple wrapper around openstackx.extras.security_groups.SecurityGroup""" - _attrs = ['id', 'name', 'description', 'tenant_id', 'rules'] + _attrs = ['id', 'name', 'description', 'tenant_id'] + + @property + def rules(self): + """ 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 \ + rule in self._apiresource.rules] + return self._rules + + @rules.setter + def rules(self, value): + self._rules = value -class SecurityGroupRule(APIDictWrapper): +class SecurityGroupRule(APIResourceWrapper): """ Simple wrapper for individual rules in a SecurityGroup. """ - _attrs = ['ip_protocol', 'from_port', 'to_port', 'ip_range'] + _attrs = ['id', 'ip_protocol', 'from_port', 'to_port', 'ip_range'] + + def __unicode__(self): + vals = {'from': self.from_port, + 'to': self.to_port, + 'cidr': self.ip_range['cidr']} + return 'ALLOW %(from)s:%(to)s from %(cidr)s' % vals def novaclient(request): @@ -192,6 +213,7 @@ def floating_ip_pools_list(request): return [FloatingIpPool(pool) for pool in novaclient(request).floating_ip_pools.list()] + def tenant_floating_ip_get(request, floating_ip_id): """ Fetches a floating ip. @@ -348,13 +370,13 @@ def security_group_delete(request, security_group_id): def security_group_rule_create(request, parent_group_id, ip_protocol=None, from_port=None, to_port=None, cidr=None, group_id=None): - return SecurityGroup(novaclient(request).\ - security_group_rules.create(parent_group_id, - ip_protocol, - from_port, - to_port, - cidr, - group_id)) + return SecurityGroupRule(novaclient(request).\ + security_group_rules.create(parent_group_id, + ip_protocol, + from_port, + to_port, + cidr, + group_id)) def security_group_rule_delete(request, security_group_rule_id): diff --git a/horizon/horizon/dashboards/nova/access_and_security/security_groups/forms.py b/horizon/horizon/dashboards/nova/access_and_security/security_groups/forms.py index 6b49170de81..b19fc9002e3 100644 --- a/horizon/horizon/dashboards/nova/access_and_security/security_groups/forms.py +++ b/horizon/horizon/dashboards/nova/access_and_security/security_groups/forms.py @@ -81,9 +81,9 @@ def handle(self, request, data): data['to_port'], data['cidr']) messages.success(request, _('Successfully added rule: %s') \ - % rule.id) + % unicode(rule)) except novaclient_exceptions.ClientException, e: LOG.exception("ClientException in AddRule") messages.error(request, _('Error adding rule security group: %s') % e.message) - return shortcuts.redirect(request.build_absolute_uri()) + return shortcuts.redirect("horizon:nova:access_and_security:index") diff --git a/horizon/horizon/dashboards/nova/access_and_security/security_groups/tables.py b/horizon/horizon/dashboards/nova/access_and_security/security_groups/tables.py index 1f59c01ff48..32f6f569615 100644 --- a/horizon/horizon/dashboards/nova/access_and_security/security_groups/tables.py +++ b/horizon/horizon/dashboards/nova/access_and_security/security_groups/tables.py @@ -52,6 +52,7 @@ class EditRules(tables.LinkAction): name = "edit_rules" verbose_name = _("Edit Rules") url = "horizon:nova:access_and_security:security_groups:edit_rules" + attrs = {"class": "ajax-modal"} class SecurityGroupsTable(tables.DataTable): @@ -69,19 +70,24 @@ class Meta: class DeleteRule(tables.DeleteAction): - data_type_singular = _("Security Group Rule") - data_type_plural = _("Security Group Rules") + data_type_singular = _("Rule") + data_type_plural = _("Rules") def delete(self, request, obj_id): api.security_group_rule_delete(request, obj_id) + def get_success_url(self, request): + return reverse("horizon:nova:access_and_security:index") + def get_cidr(rule): return rule.ip_range['cidr'] class RulesTable(tables.DataTable): - protocol = tables.Column("ip_protocol", verbose_name=_("IP Protocol")) + protocol = tables.Column("ip_protocol", + verbose_name=_("IP Protocol"), + filters=(unicode.upper,)) from_port = tables.Column("from_port", verbose_name=_("From Port")) to_port = tables.Column("to_port", verbose_name=_("To Port")) cidr = tables.Column(get_cidr, verbose_name=_("CIDR")) @@ -89,12 +95,11 @@ class RulesTable(tables.DataTable): def sanitize_id(self, obj_id): return int(obj_id) - def get_object_display(self, datum): - #FIXME (PaulM) Do something prettier here - return ', '.join([':'.join((k, str(v))) for - k, v in datum._apidict.iteritems()]) + def get_object_display(self, rule): + return unicode(rule) class Meta: name = "rules" verbose_name = _("Security Group Rules") + table_actions = (DeleteRule,) row_actions = (DeleteRule,) diff --git a/horizon/horizon/dashboards/nova/access_and_security/security_groups/tests.py b/horizon/horizon/dashboards/nova/access_and_security/security_groups/tests.py index 6c4674b6eeb..d7b78670040 100644 --- a/horizon/horizon/dashboards/nova/access_and_security/security_groups/tests.py +++ b/horizon/horizon/dashboards/nova/access_and_security/security_groups/tests.py @@ -24,6 +24,7 @@ from glance.common import exception as glance_exception from openstackx.api import exceptions as api_exceptions from novaclient import exceptions as novaclient_exceptions +from novaclient.v1_1 import security_group_rules as nova_rules from mox import IgnoreArg, IsA from horizon import api @@ -56,12 +57,15 @@ def setUp(self): sg2.name = 'group_2' rule = {'id': 1, - 'ip_protocol': "tcp", + 'ip_protocol': u"tcp", 'from_port': "80", 'to_port': "80", 'parent_group_id': "2", 'ip_range': {'cidr': "0.0.0.0/32"}} - self.rules = [api.nova.SecurityGroupRule(rule)] + manager = nova_rules.SecurityGroupRuleManager + rule_obj = nova_rules.SecurityGroupRule(manager, rule) + self.rules = [rule_obj] + sg1.rules = self.rules sg2.rules = self.rules self.security_groups = (sg1, sg2) @@ -179,7 +183,7 @@ def test_edit_rules_add_rule(self): res = self.client.post(SG_EDIT_RULE_URL, formData) - self.assertRedirectsNoFollow(res, SG_EDIT_RULE_URL) + self.assertRedirectsNoFollow(res, INDEX_URL) def test_edit_rules_add_rule_exception(self): exception = novaclient_exceptions.ClientException('ClientException', @@ -208,7 +212,7 @@ def test_edit_rules_add_rule_exception(self): res = self.client.post(SG_EDIT_RULE_URL, formData) - self.assertRedirectsNoFollow(res, SG_EDIT_RULE_URL) + self.assertRedirectsNoFollow(res, INDEX_URL) def test_edit_rules_delete_rule(self): RULE_ID = 1 @@ -224,7 +228,7 @@ def test_edit_rules_delete_rule(self): handled = table.maybe_handle() self.assertEqual(strip_absolute_base(handled['location']), - SG_EDIT_RULE_URL) + INDEX_URL) def test_edit_rules_delete_rule_exception(self): RULE_ID = 1 @@ -244,7 +248,7 @@ def test_edit_rules_delete_rule_exception(self): handled = table.maybe_handle() self.assertEqual(strip_absolute_base(handled['location']), - SG_EDIT_RULE_URL) + INDEX_URL) def test_delete_group(self): self.mox.StubOutWithMock(api, 'security_group_delete') diff --git a/horizon/horizon/dashboards/nova/access_and_security/security_groups/views.py b/horizon/horizon/dashboards/nova/access_and_security/security_groups/views.py index 00ed558da14..4dca8df41d4 100644 --- a/horizon/horizon/dashboards/nova/access_and_security/security_groups/views.py +++ b/horizon/horizon/dashboards/nova/access_and_security/security_groups/views.py @@ -73,6 +73,10 @@ def get(self, request, *args, **kwargs): context = self.get_context_data(**kwargs) context['form'] = form context['security_group'] = self.object + if request.is_ajax(): + context['hide'] = True + self.template_name = ('nova/access_and_security/security_groups' + '/_edit_rules.html') return self.render_to_response(context) def post(self, request, *args, **kwargs): diff --git a/horizon/horizon/dashboards/nova/images_and_snapshots/images/forms.py b/horizon/horizon/dashboards/nova/images_and_snapshots/images/forms.py index b3a791acc74..3f7e6c9192c 100644 --- a/horizon/horizon/dashboards/nova/images_and_snapshots/images/forms.py +++ b/horizon/horizon/dashboards/nova/images_and_snapshots/images/forms.py @@ -164,7 +164,7 @@ def handle(self, request, data): LOG.info(msg) messages.success(request, msg) return redirect( - 'horizon:nova:instances_and_volumes:instances:index') + 'horizon:nova:instances_and_volumes:index') except: exceptions.handle(request, _('Unable to launch instance.')) diff --git a/horizon/horizon/dashboards/nova/images_and_snapshots/images/tests.py b/horizon/horizon/dashboards/nova/images_and_snapshots/images/tests.py index 0ff3a68d27b..cbe7dbf90de 100644 --- a/horizon/horizon/dashboards/nova/images_and_snapshots/images/tests.py +++ b/horizon/horizon/dashboards/nova/images_and_snapshots/images/tests.py @@ -29,7 +29,7 @@ from horizon import test -IMAGES_INDEX_URL = reverse('horizon:nova:images_and_snapshots:images:index') +IMAGES_INDEX_URL = reverse('horizon:nova:images_and_snapshots:index') class FakeQuota: @@ -169,7 +169,7 @@ def test_launch_post(self): form_data) self.assertRedirectsNoFollow(res, - reverse('horizon:nova:instances_and_volumes:instances:index')) + reverse('horizon:nova:instances_and_volumes:index')) def test_launch_flavorlist_error(self): IMAGE_ID = '1' diff --git a/horizon/horizon/dashboards/nova/images_and_snapshots/snapshots/forms.py b/horizon/horizon/dashboards/nova/images_and_snapshots/snapshots/forms.py index 1e91fb12ce2..aad9e6b9345 100644 --- a/horizon/horizon/dashboards/nova/images_and_snapshots/snapshots/forms.py +++ b/horizon/horizon/dashboards/nova/images_and_snapshots/snapshots/forms.py @@ -49,8 +49,8 @@ def handle(self, request, data): messages.info(request, _('Snapshot "%(name)s" created for instance "%(inst)s"') % {"name": data['name'], "inst": instance.name}) - return shortcuts.redirect('horizon:nova:images_and_snapshots' - ':snapshots:index') + return shortcuts.redirect('horizon:nova:images_and_snapshots:' + 'index') except api_exceptions.ApiException, e: msg = _('Error Creating Snapshot: %s') % e.message LOG.exception(msg) diff --git a/horizon/horizon/dashboards/nova/images_and_snapshots/snapshots/tests.py b/horizon/horizon/dashboards/nova/images_and_snapshots/snapshots/tests.py index aebdef55bac..2482517fb4e 100644 --- a/horizon/horizon/dashboards/nova/images_and_snapshots/snapshots/tests.py +++ b/horizon/horizon/dashboards/nova/images_and_snapshots/snapshots/tests.py @@ -91,7 +91,7 @@ def test_create_snapshot_get_with_invalid_status(self): args=[self.bad_server.id])) self.assertRedirectsNoFollow(res, - reverse('horizon:nova:instances_and_volumes:instances:index')) + reverse('horizon:nova:instances_and_volumes:index')) def test_create_get_server_exception(self): self.mox.StubOutWithMock(api, 'server_get') @@ -106,7 +106,7 @@ def test_create_get_server_exception(self): args=[self.good_server.id])) self.assertRedirectsNoFollow(res, - reverse('horizon:nova:instances_and_volumes:instances:index')) + reverse('horizon:nova:instances_and_volumes:index')) def test_create_snapshot_post(self): SNAPSHOT_NAME = 'snappy' @@ -136,7 +136,7 @@ def test_create_snapshot_post(self): formData) self.assertRedirectsNoFollow(res, - reverse('horizon:nova:images_and_snapshots:snapshots:index')) + reverse('horizon:nova:images_and_snapshots:index')) def test_create_snapshot_post_exception(self): SNAPSHOT_NAME = 'snappy' diff --git a/horizon/horizon/dashboards/nova/images_and_snapshots/snapshots/views.py b/horizon/horizon/dashboards/nova/images_and_snapshots/snapshots/views.py index bd76492c71c..2eb03b003e7 100644 --- a/horizon/horizon/dashboards/nova/images_and_snapshots/snapshots/views.py +++ b/horizon/horizon/dashboards/nova/images_and_snapshots/snapshots/views.py @@ -76,7 +76,7 @@ def create(request, instance_id): LOG.exception(msg) messages.error(request, msg) return shortcuts.redirect( - 'horizon:nova:instances_and_volumes:instances:index') + 'horizon:nova:instances_and_volumes:index') valid_states = ['ACTIVE'] if instance.status not in valid_states: @@ -84,7 +84,7 @@ def create(request, instance_id): one of the following: %s") % ', '.join(valid_states)) return shortcuts.redirect( - 'horizon:nova:instances_and_volumes:instances:index') + 'horizon:nova:instances_and_volumes:index') return shortcuts.render(request, 'nova/images_and_snapshots/snapshots/create.html', diff --git a/horizon/horizon/dashboards/nova/instances_and_volumes/instances/forms.py b/horizon/horizon/dashboards/nova/instances_and_volumes/instances/forms.py index c3fa54b2123..81fbc5eecc0 100644 --- a/horizon/horizon/dashboards/nova/instances_and_volumes/instances/forms.py +++ b/horizon/horizon/dashboards/nova/instances_and_volumes/instances/forms.py @@ -26,145 +26,13 @@ import openstackx.api.exceptions as api_exceptions from horizon import api +from horizon import exceptions from horizon import forms LOG = logging.getLogger(__name__) -class TerminateInstance(forms.SelfHandlingForm): - instance = forms.CharField(required=True) - - def handle(self, request, data): - instance_id = data['instance'] - instance = api.server_get(request, instance_id) - - try: - api.server_delete(request, instance) - except api_exceptions.ApiException, e: - LOG.exception(_('ApiException while terminating instance "%s"') % - instance_id) - messages.error(request, - _('Unable to terminate %(inst)s: %(message)s') % - {"inst": instance_id, "message": e.message}) - else: - msg = _('Instance %s has been terminated.') % instance_id - LOG.info(msg) - messages.success(request, msg) - - return shortcuts.redirect(request.build_absolute_uri()) - - -class PauseInstance(forms.SelfHandlingForm): - instance = forms.CharField(required=True) - - def handle(self, request, data): - instance_id = data['instance'] - try: - server = api.server_pause(request, instance_id) - messages.success(request, _("Instance pausing")) - except api_exceptions.ApiException, e: - LOG.exception(_('ApiException while pausing instance "%s"') % - instance_id) - messages.error(request, - _('Unable to pause instance: %s') % e.message) - - else: - msg = _('Instance %s has been paused.') % instance_id - LOG.info(msg) - messages.success(request, msg) - - return shortcuts.redirect(request.build_absolute_uri()) - - -class UnpauseInstance(forms.SelfHandlingForm): - instance = forms.CharField(required=True) - - def handle(self, request, data): - instance_id = data['instance'] - try: - server = api.server_unpause(request, instance_id) - messages.success(request, _("Instance unpausing")) - except api_exceptions.ApiException, e: - LOG.exception(_('ApiException while unpausing instance "%s"') % - instance_id) - messages.error(request, - _('Unable to unpause instance: %s') % e.message) - - else: - msg = _('Instance %s has been unpaused.') % instance_id - LOG.info(msg) - messages.success(request, msg) - - return shortcuts.redirect(request.build_absolute_uri()) - - -class SuspendInstance(forms.SelfHandlingForm): - instance = forms.CharField(required=True) - - def handle(self, request, data): - instance_id = data['instance'] - try: - server = api.server_suspend(request, instance_id) - messages.success(request, _("Instance pausing")) - except api_exceptions.ApiException, e: - LOG.exception(_('ApiException while pausing instance "%s"') % - instance_id) - messages.error(request, - _('Unable to suspend instance: %s') % e.message) - - else: - msg = _('Instance %s has been suspended.') % instance_id - LOG.info(msg) - messages.success(request, msg) - - return shortcuts.redirect(request.build_absolute_uri()) - - -class ResumeInstance(forms.SelfHandlingForm): - instance = forms.CharField(required=True) - - def handle(self, request, data): - instance_id = data['instance'] - try: - server = api.server_resume(request, instance_id) - messages.success(request, _("Instance resuming")) - except api_exceptions.ApiException, e: - LOG.exception(_('ApiException while resuming instance "%s"') % - instance_id) - messages.error(request, - _('Unable to resuming instance: %s') % e.message) - - else: - msg = _('Instance %s has been resumed.') % instance_id - LOG.info(msg) - messages.success(request, msg) - - return shortcuts.redirect(request.build_absolute_uri()) - - -class RebootInstance(forms.SelfHandlingForm): - instance = forms.CharField(required=True) - - def handle(self, request, data): - instance_id = data['instance'] - try: - server = api.server_reboot(request, instance_id) - messages.success(request, _("Instance rebooting")) - except api_exceptions.ApiException, e: - LOG.exception(_('ApiException while rebooting instance "%s"') % - instance_id) - messages.error(request, - _('Unable to reboot instance: %s') % e.message) - - else: - msg = _('Instance %s has been rebooted.') % instance_id - LOG.info(msg) - messages.success(request, msg) - - return shortcuts.redirect(request.build_absolute_uri()) - - class UpdateInstance(forms.SelfHandlingForm): tenant_id = forms.CharField(widget=forms.HiddenInput()) instance = forms.CharField(widget=forms.TextInput( @@ -174,14 +42,11 @@ class UpdateInstance(forms.SelfHandlingForm): def handle(self, request, data): tenant_id = data['tenant_id'] try: - api.server_update(request, - data['instance'], - data['name']) - messages.success(request, _("Instance '%s' updated") % - data['name']) - except api_exceptions.ApiException, e: - messages.error(request, - _('Unable to update instance: %s') % e.message) + api.server_update(request, data['instance'], data['name']) + messages.success(request, + _('Instance "%s" updated.') % data['name']) + except: + exceptions.handle(request, _('Unable to update instance.')) return shortcuts.redirect( - 'horizon:nova:instances_and_volumes:instances:index') + 'horizon:nova:instances_and_volumes:index') diff --git a/horizon/horizon/dashboards/nova/instances_and_volumes/instances/tables.py b/horizon/horizon/dashboards/nova/instances_and_volumes/instances/tables.py new file mode 100644 index 00000000000..248829e2691 --- /dev/null +++ b/horizon/horizon/dashboards/nova/instances_and_volumes/instances/tables.py @@ -0,0 +1,202 @@ +# vim: tabstop=4 shiftwidth=4 softtabstop=4 + +# Copyright 2011 Nebula, Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +import logging + +from django import shortcuts +from django import template +from django.contrib import messages +from django.core.urlresolvers import reverse +from django.template.defaultfilters import title +from novaclient import exceptions as novaclient_exceptions + +from horizon import api +from horizon import tables +from horizon.templatetags import sizeformat + + +LOG = logging.getLogger(__name__) + +ACTIVE_STATES = ("ACTIVE",) + +POWER_STATES = { + 0: "NO STATE", + 1: "RUNNING", + 2: "BLOCKED", + 3: "PAUSED", + 4: "SHUTDOWN", + 5: "SHUTOFF", + 6: "CRASHED", + 7: "SUSPENDED", + 8: "FAILED", + 9: "BUILDING", +} + + +class TerminateInstance(tables.BatchAction): + name = "terminate" + action_present = _("Terminate") + action_past = _("Terminated") + data_type_singular = _("Instance") + data_type_plural = _("Instances") + classes = ('danger',) + + def action(self, request, obj_id): + api.server_delete(request, obj_id) + + +class RebootInstance(tables.BatchAction): + name = "reboot" + action_present = _("Reboot") + action_past = _("Rebooted") + data_type_singular = _("Instance") + data_type_plural = _("Instances") + classes = ('danger',) + + def allowed(self, request, instance=None): + return instance.status in ACTIVE_STATES + + def action(self, request, obj_id): + api.server_reboot(request, obj_id) + + +class TogglePause(tables.BatchAction): + name = "pause" + action_present = _("Pause") + action_past = _("Paused") + data_type_singular = _("Instance") + data_type_plural = _("Instances") + + def allowed(self, request, instance=None): + if not instance: + return True + self.paused = instance.status == "PAUSED" + if self.paused: + self.action_present = _("Unpause") + self.action_past = _("Unpaused") + return instance.status in ACTIVE_STATES + + def action(self, request, obj_id): + if getattr(self, 'paused', False): + api.server_pause(request, obj_id) + else: + api.server_unpause(request, obj_id) + + +class ToggleSuspend(tables.BatchAction): + name = "suspend" + action_present = _("Suspend") + action_past = _("Suspended") + data_type_singular = _("Instance") + data_type_plural = _("Instances") + + def allowed(self, request, instance=None): + if not instance: + return True + self.suspended = instance.status == "SUSPENDED" + if self.suspended: + self.action_present = _("Resume") + self.action_past = _("Resumed") + return instance.status in ACTIVE_STATES + + def action(self, request, obj_id): + if getattr(self, 'suspended', False): + api.server_suspend(request, obj_id) + else: + api.server_resume(request, obj_id) + + +class LaunchLink(tables.LinkAction): + name = "launch" + verbose_name = _("Launch Instance") + url = "horizon:nova:images_and_snapshots:index" + attrs = {"class": "btn small"} + + +class EditInstance(tables.LinkAction): + name = "edit" + verbose_name = _("Edit Instance") + url = "horizon:nova:instances_and_volumes:instances:update" + attrs = {"class": "ajax-modal"} + + +class SnapshotLink(tables.LinkAction): + name = "snapshot" + verbose_name = _("Snapshot") + url = "horizon:nova:images_and_snapshots:snapshots:create" + + def allowed(self, request, instance=None): + return instance.status in ACTIVE_STATES + + +class ConsoleLink(tables.LinkAction): + name = "console" + verbose_name = _("VNC Console") + url = "horizon:nova:instances_and_volumes:instances:vnc" + + def allowed(self, request, instance=None): + return instance.status in ACTIVE_STATES + + +class LogLink(tables.LinkAction): + name = "log" + verbose_name = _("View Log") + url = "horizon:nova:instances_and_volumes:instances:console" + + def allowed(self, request, instance=None): + return instance.status in ACTIVE_STATES + + +def get_ips(instance): + template_name = 'nova/instances_and_volumes/instances/_instance_ips.html' + context = {"instance": instance} + return template.loader.render_to_string(template_name, context) + + +def get_size(instance): + if hasattr(instance, "full_flavor"): + size_string = _("%(RAM)s RAM | %(VCPU)s VCPU | %(disk)s Disk") + vals = {'RAM': sizeformat.mbformat(instance.full_flavor.ram), + 'VCPU': instance.full_flavor.vcpus, + 'disk': sizeformat.diskgbformat(instance.full_flavor.disk)} + return size_string % vals + return _("Not available") + + +def get_power_state(instance): + return POWER_STATES.get(getattr(instance, "OS-EXT-STS:power_state", 0), '') + + +class InstancesTable(tables.DataTable): + name = tables.Column("name", link="horizon:nova:instances_and_volumes:" \ + "instances:detail") + ip = tables.Column(get_ips, verbose_name=_("IP Address")) + size = tables.Column(get_size, verbose_name=_("Size")) + status = tables.Column("status", filters=(title,)) + task = tables.Column("OS-EXT-STS:task_state", + verbose_name=_("Task"), + filters=(title,)) + state = tables.Column(get_power_state, + filters=(title,), + verbose_name=_("Power State")) + + class Meta: + name = "instances" + verbose_name = _("Instances") + table_actions = (LaunchLink, TerminateInstance) + row_actions = (EditInstance, ConsoleLink, LogLink, SnapshotLink, + TogglePause, ToggleSuspend, RebootInstance, + TerminateInstance) diff --git a/horizon/horizon/dashboards/nova/instances_and_volumes/instances/tests.py b/horizon/horizon/dashboards/nova/instances_and_volumes/instances/tests.py index 5cccc2aea6b..ff7a4621c31 100644 --- a/horizon/horizon/dashboards/nova/instances_and_volumes/instances/tests.py +++ b/horizon/horizon/dashboards/nova/instances_and_volumes/instances/tests.py @@ -24,208 +24,97 @@ from django.contrib import messages from django.core.urlresolvers import reverse from mox import IsA, IgnoreArg -from openstackx.api import exceptions as api_exceptions +from novaclient import exceptions as nova_exceptions from horizon import api from horizon import test +INDEX_URL = reverse('horizon:nova:instances_and_volumes:index') + + class InstanceViewTests(test.BaseViewTests): def setUp(self): super(InstanceViewTests, self).setUp() + self.now = self.override_times() + server = api.Server(None, self.request) - server.id = 1 + server.id = "1" server.name = 'serverName' + server.status = "ACTIVE" volume = api.Volume(self.request) - volume.id = 1 + volume.id = "1" self.servers = (server,) self.volumes = (volume,) - def test_terminate_instance(self): - formData = {'method': 'TerminateInstance', - 'instance': self.servers[0].id, - } + def tearDown(self): + super(InstanceViewTests, self).tearDown() + self.reset_times() - self.mox.StubOutWithMock(api, 'server_get') - api.server_get(IsA(http.HttpRequest), - str(self.servers[0].id)).AndReturn(self.servers[0]) + def test_terminate_instance(self): + self.mox.StubOutWithMock(api, 'server_list') + self.mox.StubOutWithMock(api, 'flavor_list') self.mox.StubOutWithMock(api, 'server_delete') + + api.server_list(IsA(http.HttpRequest)).AndReturn(self.servers) + api.flavor_list(IgnoreArg()).AndReturn([]) api.server_delete(IsA(http.HttpRequest), - self.servers[0]) + self.servers[0].id) self.mox.ReplayAll() - res = self.client.post( - reverse('horizon:nova:instances_and_volumes:instances:index'), - formData) + formData = {'action': 'instances__terminate__%s' % self.servers[0].id} + res = self.client.post(INDEX_URL, formData) - self.assertRedirectsNoFollow(res, - reverse('horizon:nova:instances_and_volumes:instances:index')) + self.assertRedirectsNoFollow(res, INDEX_URL) def test_terminate_instance_exception(self): - formData = {'method': 'TerminateInstance', - 'instance': self.servers[0].id, - } - - self.mox.StubOutWithMock(api, 'server_get') - api.server_get(IsA(http.HttpRequest), - str(self.servers[0].id)).AndReturn(self.servers[0]) - - exception = api_exceptions.ApiException('ApiException', - message='apiException') + self.mox.StubOutWithMock(api, 'server_list') + self.mox.StubOutWithMock(api, 'flavor_list') self.mox.StubOutWithMock(api, 'server_delete') - api.server_delete(IsA(http.HttpRequest), - self.servers[0]).AndRaise(exception) - self.mox.StubOutWithMock(messages, 'error') - messages.error(IsA(http.HttpRequest), IsA(unicode)) + api.server_list(IsA(http.HttpRequest)).AndReturn(self.servers) + api.flavor_list(IgnoreArg()).AndReturn([]) + exception = nova_exceptions.ClientException(500) + api.server_delete(IsA(http.HttpRequest), + self.servers[0].id).AndRaise(exception) self.mox.ReplayAll() - res = self.client.post( - reverse('horizon:nova:instances_and_volumes:instances:index'), - formData) + formData = {'action': 'instances__terminate__%s' % self.servers[0].id} + res = self.client.post(INDEX_URL, formData) - self.assertRedirectsNoFollow(res, - reverse('horizon:nova:instances_and_volumes:instances:index')) + self.assertRedirectsNoFollow(res, INDEX_URL) def test_reboot_instance(self): - formData = {'method': 'RebootInstance', - 'instance': self.servers[0].id, - } - self.mox.StubOutWithMock(api, 'server_reboot') + self.mox.StubOutWithMock(api, 'server_list') + api.server_list(IsA(http.HttpRequest)).AndReturn(self.servers) api.server_reboot(IsA(http.HttpRequest), unicode(self.servers[0].id)) self.mox.ReplayAll() - res = self.client.post( - reverse('horizon:nova:instances_and_volumes:instances:index'), - formData) + formData = {'action': 'instances__reboot__%s' % self.servers[0].id} + res = self.client.post(INDEX_URL, formData) - self.assertRedirectsNoFollow(res, - reverse('horizon:nova:instances_and_volumes:instances:index')) + self.assertRedirectsNoFollow(res, INDEX_URL) def test_reboot_instance_exception(self): - formData = {'method': 'RebootInstance', - 'instance': self.servers[0].id, - } - self.mox.StubOutWithMock(api, 'server_reboot') - exception = api_exceptions.ApiException('ApiException', - message='apiException') + self.mox.StubOutWithMock(api, 'server_list') + api.server_list(IsA(http.HttpRequest)).AndReturn(self.servers) + exception = nova_exceptions.ClientException(500) api.server_reboot(IsA(http.HttpRequest), unicode(self.servers[0].id)).AndRaise(exception) - self.mox.StubOutWithMock(messages, 'error') - messages.error(IsA(http.HttpRequest), IsA(basestring)) - self.mox.ReplayAll() - res = self.client.post( - reverse('horizon:nova:instances_and_volumes:instances:index'), - formData) + formData = {'action': 'instances__reboot__%s' % self.servers[0].id} + res = self.client.post(INDEX_URL, formData) - self.assertRedirectsNoFollow(res, - reverse('horizon:nova:instances_and_volumes:instances:index')) - - def test_instance_usage(self): - TEST_RETURN = 'testReturn' - - now = self.override_times() - - self.mox.StubOutWithMock(api, 'usage_get') - api.usage_get(IsA(http.HttpRequest), self.TEST_TENANT, - datetime.datetime(now.year, now.month, 1, - now.hour, now.minute, now.second), - now).AndReturn(TEST_RETURN) - - self.mox.ReplayAll() - - res = self.client.get( - reverse('horizon:nova:instances_and_volumes:instances:usage')) - - self.assertTemplateUsed(res, - 'nova/instances_and_volumes/instances/usage.html') - - self.assertEqual(res.context['usage'], TEST_RETURN) - - self.reset_times() - - def test_instance_csv_usage(self): - TEST_RETURN = 'testReturn' - - now = self.override_times() - - self.mox.StubOutWithMock(api, 'usage_get') - api.usage_get(IsA(http.HttpRequest), self.TEST_TENANT, - datetime.datetime(now.year, now.month, 1, - now.hour, now.minute, now.second), - now).AndReturn(TEST_RETURN) - - self.mox.ReplayAll() - - res = self.client.get( - reverse('horizon:nova:instances_and_volumes:instances:usage') + - "?format=csv") - - self.assertTemplateUsed(res, - 'nova/instances_and_volumes/instances/usage.csv') - - self.assertEqual(res.context['usage'], TEST_RETURN) - - self.reset_times() - - def test_instance_usage_exception(self): - now = self.override_times() - - exception = api_exceptions.ApiException('apiException', - message='apiException') - self.mox.StubOutWithMock(api, 'usage_get') - api.usage_get(IsA(http.HttpRequest), self.TEST_TENANT, - datetime.datetime(now.year, now.month, 1, - now.hour, now.minute, now.second), - now).AndRaise(exception) - - self.mox.StubOutWithMock(messages, 'error') - messages.error(IsA(http.HttpRequest), IsA(basestring)) - - self.mox.ReplayAll() - - res = self.client.get( - reverse('horizon:nova:instances_and_volumes:instances:usage')) - - self.assertTemplateUsed(res, - 'nova/instances_and_volumes/instances/usage.html') - - self.assertEqual(res.context['usage'], {}) - - self.reset_times() - - def test_instance_usage_default_tenant(self): - TEST_RETURN = 'testReturn' - - now = self.override_times() - - self.mox.StubOutWithMock(api, 'usage_get') - api.usage_get(IsA(http.HttpRequest), self.TEST_TENANT, - datetime.datetime(now.year, now.month, 1, - now.hour, now.minute, now.second), - now).AndReturn(TEST_RETURN) - - self.mox.ReplayAll() - - res = self.client.get( - reverse('horizon:nova:instances_and_volumes:instances:usage')) - - self.assertTemplateUsed(res, - 'nova/instances_and_volumes/instances/usage.html') - - self.assertEqual(res.context['usage'], TEST_RETURN) - - self.reset_times() + self.assertRedirectsNoFollow(res, INDEX_URL) def test_instance_console(self): CONSOLE_OUTPUT = 'output' @@ -272,8 +161,7 @@ def test_instance_vnc(self): def test_instance_vnc_exception(self): INSTANCE_ID = self.servers[0].id - exception = api_exceptions.ApiException('apiException', - message='apiException') + exception = nova_exceptions.ClientException(500) self.mox.StubOutWithMock(api, 'console_create') api.console_create(IsA(http.HttpRequest), @@ -286,8 +174,7 @@ def test_instance_vnc_exception(self): reverse('horizon:nova:instances_and_volumes:instances:vnc', args=[INSTANCE_ID])) - self.assertRedirectsNoFollow(res, - reverse('horizon:nova:instances_and_volumes:instances:index')) + self.assertRedirectsNoFollow(res, INDEX_URL) def test_instance_update_get(self): INSTANCE_ID = self.servers[0].id @@ -308,7 +195,7 @@ def test_instance_update_get(self): def test_instance_update_get_server_get_exception(self): INSTANCE_ID = self.servers[0].id - exception = api_exceptions.ApiException('apiException') + exception = nova_exceptions.ClientException(500) self.mox.StubOutWithMock(api, 'server_get') api.server_get(IsA(http.HttpRequest), unicode(INSTANCE_ID)).AndRaise(exception) @@ -319,8 +206,7 @@ def test_instance_update_get_server_get_exception(self): reverse('horizon:nova:instances_and_volumes:instances:update', args=[INSTANCE_ID])) - self.assertRedirectsNoFollow(res, - reverse('horizon:nova:instances_and_volumes:instances:index')) + self.assertRedirectsNoFollow(res, INDEX_URL) def test_instance_update_post(self): INSTANCE_ID = self.servers[0].id @@ -344,32 +230,28 @@ def test_instance_update_post(self): reverse('horizon:nova:instances_and_volumes:instances:update', args=[INSTANCE_ID]), formData) - self.assertRedirectsNoFollow(res, - reverse('horizon:nova:instances_and_volumes:instances:index')) + self.assertRedirectsNoFollow(res, INDEX_URL) def test_instance_update_post_api_exception(self): - INSTANCE_ID = self.servers[0].id - NAME = 'myname' - formData = {'method': 'UpdateInstance', - 'instance': INSTANCE_ID, - 'name': NAME, - 'tenant_id': self.TEST_TENANT} + SERVER = self.servers[0] self.mox.StubOutWithMock(api, 'server_get') - api.server_get(IsA(http.HttpRequest), - unicode(INSTANCE_ID)).AndReturn(self.servers[0]) - - exception = api_exceptions.ApiException('apiException') self.mox.StubOutWithMock(api, 'server_update') - api.server_update(IsA(http.HttpRequest), - str(INSTANCE_ID), NAME).\ - AndRaise(exception) + + api.server_get(IsA(http.HttpRequest), unicode(SERVER.id)) \ + .AndReturn(self.servers[0]) + exception = nova_exceptions.ClientException(500) + api.server_update(IsA(http.HttpRequest), str(SERVER.id), SERVER.name) \ + .AndRaise(exception) self.mox.ReplayAll() - res = self.client.post( - reverse('horizon:nova:instances_and_volumes:instances:update', - args=[INSTANCE_ID]), formData) + formData = {'method': 'UpdateInstance', + 'instance': SERVER.id, + 'name': SERVER.name, + 'tenant_id': self.TEST_TENANT} + url = reverse('horizon:nova:instances_and_volumes:instances:update', + args=[SERVER.id]) + res = self.client.post(url, formData) - self.assertRedirectsNoFollow(res, - reverse('horizon:nova:instances_and_volumes:instances:index')) + self.assertRedirectsNoFollow(res, INDEX_URL) diff --git a/horizon/horizon/dashboards/nova/instances_and_volumes/instances/urls.py b/horizon/horizon/dashboards/nova/instances_and_volumes/instances/urls.py index 2b613d5738b..e4b0b6aadf4 100644 --- a/horizon/horizon/dashboards/nova/instances_and_volumes/instances/urls.py +++ b/horizon/horizon/dashboards/nova/instances_and_volumes/instances/urls.py @@ -20,15 +20,16 @@ from django.conf.urls.defaults import patterns, url +from .views import UpdateView, DetailView + + INSTANCES = r'^(?P[^/]+)/%s$' + urlpatterns = patterns( 'horizon.dashboards.nova.instances_and_volumes.instances.views', - url(r'^$', 'index', name='index'), - url(r'^usage/$', 'usage', name='usage'), - url(r'^refresh$', 'refresh', name='refresh'), - url(INSTANCES % 'detail', 'detail', name='detail'), + url(INSTANCES % 'detail', DetailView.as_view(), name='detail'), url(INSTANCES % 'console', 'console', name='console'), url(INSTANCES % 'vnc', 'vnc', name='vnc'), - url(INSTANCES % 'update', 'update', name='update'), + url(INSTANCES % 'update', UpdateView.as_view(), name='update'), ) diff --git a/horizon/horizon/dashboards/nova/instances_and_volumes/instances/views.py b/horizon/horizon/dashboards/nova/instances_and_volumes/instances/views.py index 45e7ecc15d3..f46c26b6916 100644 --- a/horizon/horizon/dashboards/nova/instances_and_volumes/instances/views.py +++ b/horizon/horizon/dashboards/nova/instances_and_volumes/instances/views.py @@ -27,174 +27,23 @@ from django import http from django import shortcuts from django.contrib import messages +from django.core.urlresolvers import reverse from django.utils.datastructures import SortedDict from django.utils.translation import ugettext as _ -import openstackx.api.exceptions as api_exceptions import horizon from horizon import api +from horizon import exceptions from horizon import forms from horizon import test -from horizon.dashboards.nova.instances_and_volumes.instances.forms import ( - TerminateInstance, PauseInstance, UnpauseInstance, SuspendInstance, - ResumeInstance, RebootInstance, UpdateInstance) +from horizon import views +from .forms import UpdateInstance LOG = logging.getLogger(__name__) -def index(request): - tenant_id = request.user.tenant_id - for f in (TerminateInstance, RebootInstance): - form, handled = f.maybe_handle(request) - if handled: - return handled - instances = [] - try: - instances = api.server_list(request) - except Exception as e: - LOG.exception(_('Exception in instance index')) - if not hasattr(e, 'message'): - e.message = str(e) - messages.error(request, _('Unable to get instance list: %s') - % e.message) - - # Gather our flavors and correlate our instances to them - try: - flavors = api.flavor_list(request) - full_flavors = SortedDict([(str(flavor.id), flavor) for \ - flavor in flavors]) - for instance in instances: - instance.full_flavor = full_flavors[instance.flavor["id"]] - except api_exceptions.Unauthorized, e: - LOG.exception('Unauthorized attempt to access flavor list.') - messages.error(request, _('Unauthorized.')) - except Exception, e: - LOG.exception('Exception while fetching flavor info') - if not hasattr(e, 'message'): - e.message = str(e) - messages.error(request, _('Unable to get flavor info: %s') % e.message) - - # We don't have any way of showing errors for these, so don't bother - # trying to reuse the forms from above - terminate_form = TerminateInstance() - pause_form = PauseInstance() - unpause_form = UnpauseInstance() - suspend_form = SuspendInstance() - resume_form = ResumeInstance() - reboot_form = RebootInstance() - - return shortcuts.render(request, - 'nova/instances_and_volumes/instances/index.html', { - 'instances': instances, - 'terminate_form': terminate_form, - 'pause_form': pause_form, - 'unpause_form': unpause_form, - 'suspend_form': suspend_form, - 'resume_form': resume_form, - 'reboot_form': reboot_form}) - - -def refresh(request): - tenant_id = request.user.tenant_id - instances = [] - try: - instances = api.server_list(request) - except Exception as e: - if not hasattr(e, 'message'): - e.message = str(e) - messages.error(request, - _('Unable to get instance list: %s') % e.message) - - # We don't have any way of showing errors for these, so don't bother - # trying to reuse the forms from above - terminate_form = TerminateInstance() - pause_form = PauseInstance() - unpause_form = UnpauseInstance() - suspend_form = SuspendInstance() - resume_form = ResumeInstance() - reboot_form = RebootInstance() - - return shortcuts.render(request, - 'nova/instances_and_volumes/instances/_list.html', { - 'instances': instances, - 'terminate_form': terminate_form, - 'pause_form': pause_form, - 'unpause_form': unpause_form, - 'suspend_form': suspend_form, - 'resume_form': resume_form, - 'reboot_form': reboot_form}) - - -def usage(request, tenant_id=None): - tenant_id = tenant_id or request.user.tenant_id - today = test.today() - date_start = datetime.date(today.year, today.month, 1) - datetime_start = datetime.datetime.combine(date_start, test.time()) - datetime_end = test.utcnow() - - show_terminated = request.GET.get('show_terminated', False) - - usage = {} - if not tenant_id: - tenant_id = request.user.tenant_id - - try: - usage = api.usage_get(request, tenant_id, datetime_start, datetime_end) - except api_exceptions.ApiException, e: - LOG.exception(_('ApiException in instance usage')) - - messages.error(request, _('Unable to get usage info: %s') % e.message) - - ram_unit = "MB" - total_ram = 0 - if hasattr(usage, 'total_active_ram_size'): - total_ram = usage.total_active_ram_size - if total_ram > 999: - ram_unit = "GB" - total_ram /= float(1024) - - running_instances = [] - terminated_instances = [] - if hasattr(usage, 'instances'): - now = datetime.datetime.now() - for i in usage.instances: - # this is just a way to phrase uptime in a way that is compatible - # with the 'timesince' filter. Use of local time intentional - i['uptime_at'] = now - datetime.timedelta(seconds=i['uptime']) - if i['ended_at']: - terminated_instances.append(i) - else: - running_instances.append(i) - - instances = running_instances - if show_terminated: - instances += terminated_instances - - if request.GET.get('format', 'html') == 'csv': - template_name = 'nova/instances_and_volumes/instances/usage.csv' - mimetype = "text/csv" - else: - template_name = 'nova/instances_and_volumes/instances/usage.html' - mimetype = "text/html" - - dash_url = horizon.get_dashboard('nova').get_absolute_url() - - return shortcuts.render(request, template_name, { - 'usage': usage, - 'ram_unit': ram_unit, - 'total_ram': total_ram, - 'csv_link': '?format=csv', - 'show_terminated': show_terminated, - 'datetime_start': datetime_start, - 'datetime_end': datetime_end, - 'instances': instances, - 'dash_url': dash_url}, - content_type=mimetype) - - def console(request, instance_id): - tenant_id = request.user.tenant_id try: # TODO(jakedahn): clean this up once the api supports tailing. length = request.GET.get('length', None) @@ -205,100 +54,81 @@ def console(request, instance_id): response.write(console) response.flush() return response - except api_exceptions.ApiException, e: - LOG.exception(_('ApiException while fetching instance console')) - messages.error(request, - _('Unable to get log for instance %(inst)s: %(msg)s') % - {"inst": instance_id, "msg": e.message}) - return shortcuts.redirect( - 'horizon:nova:instances_and_volumes:instances:index') + except: + msg = _('Unable to get log for instance "%s".') % instance_id + redirect = reverse('horizon:nova:instances_and_volumes:index') + exceptions.handle(request, msg, redirect=redirect) def vnc(request, instance_id): - tenant_id = request.user.tenant_id try: console = api.console_create(request, instance_id, 'vnc') instance = api.server_get(request, instance_id) return shortcuts.redirect(console.output + ("&title=%s(%s)" % (instance.name, instance_id))) - except api_exceptions.ApiException, e: - LOG.exception(_('ApiException while fetching instance vnc connection')) - messages.error(request, - _('Unable to get vnc console for instance %(inst)s: %(message)s') % - {"inst": instance_id, "message": e.message}) - return shortcuts.redirect( - 'horizon:nova:instances_and_volumes:instances:index') - - -def update(request, instance_id): - tenant_id = request.user.tenant_id - try: - instance = api.server_get(request, instance_id) - except api_exceptions.ApiException, e: - LOG.exception(_('ApiException while fetching instance info')) - messages.error(request, - _('Unable to get information for instance %(inst)s: %(message)s') % - {"inst": instance_id, "message": e.message}) - return shortcuts.redirect( - 'horizon:nova:instances_and_volumes:instances:index') - - form, handled = UpdateInstance.maybe_handle(request, initial={ - 'instance': instance_id, - 'tenant_id': tenant_id, - 'name': instance.name}) - - if handled: - return handled - - return shortcuts.render(request, - 'nova/instances_and_volumes/instances/update.html', { - 'instance': instance, - 'form': form}) - - -def detail(request, instance_id): - tenant_id = request.user.tenant_id - try: - instance = api.server_get(request, instance_id) - volumes = api.volume_instance_list(request, instance_id) + except: + redirect = reverse("horizon:nova:instances_and_volumes:index") + msg = _('Unable to get VNC console for instance "%s".') % instance_id + exceptions.handle(request, msg, redirect=redirect) + + +class UpdateView(forms.ModalFormView): + form_class = UpdateInstance + template_name = 'nova/instances_and_volumes/instances/update.html' + context_object_name = 'instance' + + def get_object(self, *args, **kwargs): + if not hasattr(self, "object"): + instance_id = self.kwargs['instance_id'] + try: + self.object = api.server_get(self.request, instance_id) + except: + redirect = reverse("horizon:nova:instances_and_volumes:index") + msg = _('Unable to retrieve instance details.') + exceptions.handle(self.request, msg, redirect=redirect) + return self.object + + def get_initial(self): + return {'instance': self.kwargs['instance_id'], + 'tenant_id': self.request.user.tenant_id, + 'name': getattr(self.object, 'name', '')} + + +class DetailView(views.APIView): + template_name = 'nova/instances_and_volumes/instances/detail.html' + + def get_data(self, request, context, *args, **kwargs): + instance_id = kwargs['instance_id'] + try: + instance = api.server_get(request, instance_id) + volumes = api.volume_instance_list(request, instance_id) + except: + instance = None + redirect = reverse('horizon:nova:instances_and_volumes:index') + exceptions.handle(request, + _('Unable to retrieve details for ' + 'instance "%s".') % instance_id, + redirect=redirect) try: console = api.console_create(request, instance_id, 'vnc') vnc_url = "%s&title=%s(%s)" % (console.output, - instance.name, + getattr(instance, "name", ""), instance_id) - except api_exceptions.ApiException, e: - LOG.exception(_('ApiException while fetching instance vnc \ - connection')) - messages.error(request, - _('Unable to get vnc console for instance %(inst)s: %(msg)s') % - {"inst": instance_id, "msg": e.message}) - return shortcuts.redirect( - 'horizon:nova:instances_and_volumes:instances:index') - except api_exceptions.ApiException, e: - LOG.exception(_('ApiException while fetching instance info')) - messages.error(request, - _('Unable to get information for instance %(inst)s: %(msg)s') % - {"inst": instance_id, "msg": e.message}) - return shortcuts.redirect( - 'horizon:nova:instances_and_volumes:instances:index') - - # Gather our flavors and images and correlate our instances to them - try: + except: + vnc_url = "" + exceptions.handle(request, + _('Unable to get vnc console for ' + 'instance "%s".') % instance_id) + + # Gather our flavors and images and correlate our instances to them + # Exception handling happens in the parent class. flavors = api.flavor_list(request) full_flavors = SortedDict([(str(flavor.id), flavor) for \ flavor in flavors]) instance.full_flavor = full_flavors[instance.flavor["id"]] - except api_exceptions.Unauthorized, e: - LOG.exception('Unauthorized attempt to access flavor list.') - messages.error(request, _('Unauthorized.')) - except Exception, e: - LOG.exception('Exception while fetching flavor info') - if not hasattr(e, 'message'): - e.message = str(e) - messages.error(request, _('Unable to get flavor info: %s') % e.message) - return shortcuts.render(request, - 'nova/instances_and_volumes/instances/detail.html', { - 'instance': instance, - 'vnc_url': vnc_url, - 'volumes': volumes}) + context.update({'instance': instance, + 'vnc_url': vnc_url, + 'volumes': volumes}) + + return context diff --git a/horizon/horizon/dashboards/nova/instances_and_volumes/tests.py b/horizon/horizon/dashboards/nova/instances_and_volumes/tests.py index 0961eb07f2e..9c07c95b6b6 100644 --- a/horizon/horizon/dashboards/nova/instances_and_volumes/tests.py +++ b/horizon/horizon/dashboards/nova/instances_and_volumes/tests.py @@ -36,9 +36,12 @@ def setUp(self): server = api.Server(None, self.request) server.id = 1 server.name = 'serverName' + server.status = "ACTIVE" volume = api.Volume(self.request) volume.id = 1 + volume.size = 10 + volume.attachments = [{}] self.servers = (server,) self.volumes = (volume,) @@ -56,7 +59,8 @@ def test_index(self): self.assertTemplateUsed(res, 'nova/instances_and_volumes/index.html') - self.assertItemsEqual(res.context['instances'], self.servers) + instances = res.context['instances_table'].data + self.assertItemsEqual(instances, self.servers) def test_index_server_list_exception(self): self.mox.StubOutWithMock(api, 'server_list') @@ -72,4 +76,4 @@ def test_index_server_list_exception(self): self.assertTemplateUsed(res, 'nova/instances_and_volumes/index.html') - self.assertEqual(len(res.context['instances']), 0) + self.assertEqual(len(res.context['instances_table'].data), 0) diff --git a/horizon/horizon/dashboards/nova/instances_and_volumes/urls.py b/horizon/horizon/dashboards/nova/instances_and_volumes/urls.py index d832ad76f8c..c022102e570 100644 --- a/horizon/horizon/dashboards/nova/instances_and_volumes/urls.py +++ b/horizon/horizon/dashboards/nova/instances_and_volumes/urls.py @@ -22,13 +22,12 @@ import horizon -from horizon.dashboards.nova.instances_and_volumes.instances import urls\ - as instance_urls -from horizon.dashboards.nova.instances_and_volumes.volumes import urls\ - as volume_urls +from .instances import urls as instance_urls +from .views import IndexView +from .volumes import urls as volume_urls urlpatterns = patterns('horizon.dashboards.nova.instances_and_volumes', - url(r'^$', 'views.index', name='index'), - url(r'', include(instance_urls, namespace='instances')), - url(r'', include(volume_urls, namespace='volumes')), + url(r'^$', IndexView.as_view(), name='index'), + url(r'^instances/', include(instance_urls, namespace='instances')), + url(r'^volumes/', include(volume_urls, namespace='volumes')), ) diff --git a/horizon/horizon/dashboards/nova/instances_and_volumes/views.py b/horizon/horizon/dashboards/nova/instances_and_volumes/views.py index 64bba0db6d7..91fb5e71162 100644 --- a/horizon/horizon/dashboards/nova/instances_and_volumes/views.py +++ b/horizon/horizon/dashboards/nova/instances_and_volumes/views.py @@ -35,76 +35,45 @@ from horizon import api from horizon import forms -from horizon import test -from horizon.dashboards.nova.instances_and_volumes.instances.forms import ( - TerminateInstance, PauseInstance, UnpauseInstance, SuspendInstance, - ResumeInstance, RebootInstance, UpdateInstance) -from horizon.dashboards.nova.instances_and_volumes.volumes.forms import ( -CreateForm, DeleteForm, AttachForm, DetachForm) +from horizon import tables +from .instances.tables import InstancesTable +from .volumes.tables import VolumesTable LOG = logging.getLogger(__name__) -def index(request): - for f in (TerminateInstance, PauseInstance, UnpauseInstance, - SuspendInstance, ResumeInstance, RebootInstance, - DeleteForm, DetachForm): - form, handled = f.maybe_handle(request) - if handled: - return handled +class IndexView(tables.MultiTableView): + table_classes = (InstancesTable, VolumesTable) + template_name = 'nova/instances_and_volumes/index.html' - # Gather our instances - try: - instances = api.server_list(request) - except api_exceptions.ApiException as e: - instances = [] - LOG.exception(_('Exception in instance index')) - messages.error(request, _('Unable to fetch instances: %s') % e.message) + def get_instances_data(self): + # Gather our instances + try: + instances = api.server_list(self.request) + except Exception as e: + instances = [] + LOG.exception(_('Exception while fetching instances.')) + messages.error(self.request, _('Unable to retrieve instances.')) + # Gather our flavors and correlate our instances to them + try: + flavors = api.flavor_list(self.request) + full_flavors = SortedDict([(str(flavor.id), flavor) for \ + flavor in flavors]) + for instance in instances: + instance.full_flavor = full_flavors[instance.flavor["id"]] + except Exception, e: + LOG.exception('Exception while fetching flavor info.') + messages.error(self.request, + _('Unable to retrieve instance size information.')) + return instances - # Gather our volumes - try: - volumes = api.volume_list(request) - except novaclient_exceptions.ClientException, e: - volumes = [] - LOG.exception("ClientException in volume index") - messages.error(request, _('Unable to fetch volumes: %s') % e.message) - - # Gather our flavors and correlate our instances to them - try: - flavors = api.flavor_list(request) - full_flavors = SortedDict([(str(flavor.id), flavor) for \ - flavor in flavors]) - for instance in instances: - instance.full_flavor = full_flavors[instance.flavor["id"]] - except api_exceptions.Unauthorized, e: - LOG.exception('Unauthorized attempt to access flavor list.') - messages.error(request, _('Unauthorized.')) - except Exception, e: - if not hasattr(e, 'message'): - e.message = str(e) - LOG.exception('Exception while fetching flavor info') - messages.error(request, _('Unable to get flavor info: %s') % e.message) - - terminate_form = TerminateInstance() - pause_form = PauseInstance() - unpause_form = UnpauseInstance() - suspend_form = SuspendInstance() - resume_form = ResumeInstance() - reboot_form = RebootInstance() - delete_form = DeleteForm() - detach_form = DetachForm() - create_form = CreateForm() - - return shortcuts.render(request, 'nova/instances_and_volumes/index.html', { - 'instances': instances, - 'terminate_form': terminate_form, - 'pause_form': pause_form, - 'unpause_form': unpause_form, - 'suspend_form': suspend_form, - 'resume_form': resume_form, - 'reboot_form': reboot_form, - 'volumes': volumes, - 'delete_form': delete_form, - 'create_form': create_form, - 'detach_form': detach_form}) + def get_volumes_data(self): + # Gather our volumes + try: + volumes = api.volume_list(self.request) + except novaclient_exceptions.ClientException, e: + volumes = [] + LOG.exception("ClientException in volume index") + messages.error(self.request, _('Unable to fetch volumes: %s') % e) + return volumes diff --git a/horizon/horizon/dashboards/nova/instances_and_volumes/volumes/forms.py b/horizon/horizon/dashboards/nova/instances_and_volumes/volumes/forms.py index dc458957e31..ef11115eab3 100644 --- a/horizon/horizon/dashboards/nova/instances_and_volumes/volumes/forms.py +++ b/horizon/horizon/dashboards/nova/instances_and_volumes/volumes/forms.py @@ -38,28 +38,13 @@ def handle(self, request, data): LOG.exception("ClientException in CreateVolume") messages.error(request, _('Error Creating Volume: %s') % e.message) - return shortcuts.redirect( - "horizon:nova:instances_and_volumes:volumes:index") - - -class DeleteForm(forms.SelfHandlingForm): - volume_id = forms.CharField(widget=forms.HiddenInput()) - volume_name = forms.CharField(widget=forms.HiddenInput()) - - def handle(self, request, data): - try: - api.volume_delete(request, data['volume_id']) - message = 'Deleting volume "%s"' % data['volume_id'] - LOG.info(message) - messages.info(request, message) - except novaclient_exceptions.ClientException, e: - LOG.exception("ClientException in DeleteVolume") - messages.error(request, - _('Error deleting volume: %s') % e.message) - return shortcuts.redirect(request.build_absolute_uri()) + return shortcuts.redirect("horizon:nova:instances_and_volumes:index") class AttachForm(forms.SelfHandlingForm): + instance = forms.ChoiceField(label="Attach to Instance", + help_text=_("Select an instance to " + "attach to.")) device = forms.CharField(label="Device Name", initial="/dev/vdb") def __init__(self, *args, **kwargs): @@ -75,10 +60,7 @@ def __init__(self, *args, **kwargs): for instance in instance_list: instances.append((instance.id, '%s (%s)' % (instance.name, instance.id))) - self.fields['instance'] = forms.ChoiceField( - choices=instances, - label="Attach to Instance", - help_text="Select an instance to attach to.") + self.fields['instance'].choices = instances def handle(self, request, data): try: @@ -99,25 +81,4 @@ def handle(self, request, data): messages.error(request, _('Error attaching volume: %s') % e.message) return shortcuts.redirect( - "horizon:nova:instances_and_volumes:volumes:index") - - -class DetachForm(forms.SelfHandlingForm): - volume_id = forms.CharField(widget=forms.HiddenInput()) - instance_id = forms.CharField(widget=forms.HiddenInput()) - attachment_id = forms.CharField(widget=forms.HiddenInput()) - - def handle(self, request, data): - try: - api.volume_detach(request, data['instance_id'], - data['attachment_id']) - message = (_('Detaching volume %(vol)s from instance %(inst)s') % - {"vol": data['volume_id'], "inst": data['instance_id']}) - LOG.info(message) - messages.info(request, message) - except novaclient_exceptions.ClientException, e: - LOG.exception("ClientException in DetachVolume") - messages.error(request, - _('Error detaching volume: %s') % e.message) - return shortcuts.redirect( - "horizon:nova:instances_and_volumes:volumes:index") + "horizon:nova:instances_and_volumes:index") diff --git a/horizon/horizon/dashboards/nova/instances_and_volumes/volumes/tables.py b/horizon/horizon/dashboards/nova/instances_and_volumes/volumes/tables.py new file mode 100644 index 00000000000..eee40e78aee --- /dev/null +++ b/horizon/horizon/dashboards/nova/instances_and_volumes/volumes/tables.py @@ -0,0 +1,141 @@ +# vim: tabstop=4 shiftwidth=4 softtabstop=4 + +# Copyright 2011 Nebula, Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +import logging + +from django import shortcuts +from django.contrib import messages +from django.core.urlresolvers import reverse +from django.template.defaultfilters import filesizeformat, title +from django.utils import safestring +from novaclient import exceptions as novaclient_exceptions + +from horizon import api +from horizon import tables + + +LOG = logging.getLogger(__name__) + +ACTIVE_STATES = ("ACTIVE",) + + +class DeleteVolume(tables.DeleteAction): + data_type_singular = _("Volume") + data_type_plural = _("Volumes") + classes = ('danger',) + + def delete(self, request, obj_id): + api.volume_delete(request, obj_id) + + +class CreateVolume(tables.LinkAction): + name = "create" + verbose_name = _("Create Volume") + url = "horizon:nova:instances_and_volumes:volumes:create" + attrs = {"class": "btn small ajax-modal"} + + +class EditAttachments(tables.LinkAction): + name = "attachments" + verbose_name = _("Edit Attachments") + url = "horizon:nova:instances_and_volumes:volumes:attach" + + def allowed(self, request, volume=None): + return volume.status in ("available", "in-use") + + +def get_size(volume): + return _("%s GB") % volume.size + + +def get_attachment(volume): + attachments = [] + link = 'Instance %(instance)s ' \ + '(%(dev)s)' + # Filter out "empty" attachments which the client returns... + for attachment in [att for att in volume.attachments if att]: + url = reverse("horizon:nova:instances_and_volumes:instances:detail", + args=(attachment["serverId"],)) + # TODO(jake): Make "instance" the instance name + vals = {"url": url, + "instance": attachment["serverId"], + "dev": attachment["device"]} + attachments.append(link % vals) + return safestring.mark_safe(", ".join(attachments)) + + +class VolumesTable(tables.DataTable): + name = tables.Column("displayName", + verbose_name=_("Name"), + link="horizon:nova:instances_and_volumes:" + "volumes:detail") + description = tables.Column("displayDescription", + verbose_name=("Description")) + size = tables.Column(get_size, verbose_name=_("Size")) + attachments = tables.Column(get_attachment, + verbose_name=_("Attachments"), + empty_value=_("-")) + status = tables.Column("status", filters=(title,)) + + class Meta: + name = "volumes" + verbose_name = _("Volumes") + table_actions = (CreateVolume, DeleteVolume,) + row_actions = (EditAttachments, DeleteVolume,) + + +class DetachVolume(tables.BatchAction): + name = "detach" + action_present = _("Detach") + action_past = _("Detached") + data_type_singular = _("Volume") + data_type_plural = _("Volumes") + classes = ('danger',) + + def action(self, request, obj_id): + instance_id = self.table.get_object_by_id(obj_id)['serverId'] + api.volume_detach(request, instance_id, obj_id) + + def get_success_url(self, request): + return reverse('horizon:nova:instances_and_volumes:index') + + +class AttachmentsTable(tables.DataTable): + instance = tables.Column("serverId", verbose_name=_("Instance")) + device = tables.Column("device") + + def sanitize_id(self, obj_id): + return int(obj_id) + + def get_object_id(self, obj): + return obj['id'] + + def get_object_display(self, obj): + vals = {"dev": obj['device'], + "instance": obj['serverId']} + return "Attachment %(dev)s on %(instance)s" % vals + + def get_object_by_id(self, obj_id): + for obj in self.data: + print self.get_object_id(obj) + if self.get_object_id(obj) == obj_id: + return obj + raise ValueError('No match found for the id "%s".' % obj_id) + + class Meta: + name = "attachments" + table_actions = (DetachVolume,) + row_actions = (DetachVolume,) diff --git a/horizon/horizon/dashboards/nova/instances_and_volumes/volumes/urls.py b/horizon/horizon/dashboards/nova/instances_and_volumes/volumes/urls.py index f39c432a235..fe2792a2a50 100644 --- a/horizon/horizon/dashboards/nova/instances_and_volumes/volumes/urls.py +++ b/horizon/horizon/dashboards/nova/instances_and_volumes/volumes/urls.py @@ -16,11 +16,14 @@ from django.conf.urls.defaults import patterns, url +from .views import CreateView, EditAttachmentsView + urlpatterns = patterns( 'horizon.dashboards.nova.instances_and_volumes.volumes.views', - url(r'^$', 'index', name='index'), - url(r'^create/$', 'create', name='create'), - url(r'^(?P[^/]+)/attach/$', 'attach', name='attach'), + url(r'^create/$', CreateView.as_view(), name='create'), + url(r'^(?P[^/]+)/attach/$', + EditAttachmentsView.as_view(), + name='attach'), url(r'^(?P[^/]+)/detail/$', 'detail', name='detail'), ) diff --git a/horizon/horizon/dashboards/nova/instances_and_volumes/volumes/views.py b/horizon/horizon/dashboards/nova/instances_and_volumes/volumes/views.py index 69c85abc351..e981bebdd47 100644 --- a/horizon/horizon/dashboards/nova/instances_and_volumes/volumes/views.py +++ b/horizon/horizon/dashboards/nova/instances_and_volumes/volumes/views.py @@ -26,37 +26,16 @@ from novaclient import exceptions as novaclient_exceptions from horizon import api -from horizon.dashboards.nova.instances_and_volumes.volumes.forms \ - import (CreateForm, DeleteForm, AttachForm, DetachForm) +from horizon import exceptions +from horizon import forms +from horizon import tables +from .forms import CreateForm, AttachForm +from .tables import AttachmentsTable LOG = logging.getLogger(__name__) -def index(request): - delete_form, handled = DeleteForm.maybe_handle(request) - detach_form, handled = DetachForm.maybe_handle(request) - - if handled: - return handled - - create_form = CreateForm() - - try: - volumes = api.volume_list(request) - except novaclient_exceptions.ClientException, e: - volumes = [] - LOG.exception("ClientException in volume index") - messages.error(request, _('Error fetching volumes: %s') % e.message) - - return shortcuts.render(request, - 'nova/instances_and_volumes/volumes/index.html', { - 'volumes': volumes, - 'delete_form': delete_form, - 'create_form': create_form, - 'detach_form': detach_form}) - - def detail(request, volume_id): try: volume = api.volume_get(request, volume_id) @@ -79,27 +58,48 @@ def detail(request, volume_id): 'instance': instance}) -def create(request): - create_form, handled = CreateForm.maybe_handle(request) - - if handled: - return handled - - return shortcuts.render(request, - 'nova/instances_and_volumes/volumes/create.html', { - 'create_form': create_form}) - - -def attach(request, volume_id): - instances = api.server_list(request) - attach_form, handled = AttachForm.maybe_handle(request, - initial={'volume_id': volume_id, - 'instances': instances}) - - if handled: - return handled - - return shortcuts.render(request, - 'nova/instances_and_volumes/volumes/attach.html', { - 'attach_form': attach_form, - 'volume_id': volume_id}) +class CreateView(forms.ModalFormView): + form_class = CreateForm + template_name = 'nova/instances_and_volumes/volumes/create.html' + + +class EditAttachmentsView(tables.DataTableView): + table_class = AttachmentsTable + template_name = 'nova/instances_and_volumes/volumes/attach.html' + + def get_data(self): + volume_id = self.kwargs['volume_id'] + try: + self.object = api.volume_get(self.request, volume_id) + attachments = [att for att in self.object.attachments if att] + except: + self.object = None + attachments = [] + exceptions.handle(self.request, + _('Unable to retrieve volume information.')) + return attachments + + def handle_form(self): + instances = api.nova.server_list(self.request) + initial = {'volume_id': self.kwargs["volume_id"], + 'instances': instances} + return AttachForm.maybe_handle(self.request, initial=initial) + + def get(self, request, *args, **kwargs): + form, handled = self.handle_form() + if handled: + return handled + tables = self.get_tables() + if not self.object: + return shortcuts.redirect("horizon:nova:instances_and_volumes:" + "index") + context = self.get_context_data(**kwargs) + context['form'] = form + context['volume'] = self.object + return self.render_to_response(context) + + def post(self, request, *args, **kwargs): + form, handled = self.handle_form() + if handled: + return handled + return super(EditAttachmentsView, self).post(request, *args, **kwargs) diff --git a/horizon/horizon/dashboards/nova/overview/tests.py b/horizon/horizon/dashboards/nova/overview/tests.py new file mode 100644 index 00000000000..95d7405154e --- /dev/null +++ b/horizon/horizon/dashboards/nova/overview/tests.py @@ -0,0 +1,132 @@ +# vim: tabstop=4 shiftwidth=4 softtabstop=4 + +# Copyright 2011 United States Government as represented by the +# Administrator of the National Aeronautics and Space Administration. +# All Rights Reserved. +# +# Copyright 2011 Nebula, Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +import datetime + +from django import http +from django.contrib import messages +from django.core.urlresolvers import reverse +from mox import IsA, IgnoreArg +from novaclient import exceptions as nova_exceptions + +from horizon import api +from horizon import test + + +INDEX_URL = reverse('horizon:nova:overview:index') + + +class InstanceViewTests(test.BaseViewTests): + def setUp(self): + super(InstanceViewTests, self).setUp() + self.now = self.override_times() + + server = api.Server(None, self.request) + server.id = "1" + server.name = 'serverName' + server.status = "ACTIVE" + + volume = api.Volume(self.request) + volume.id = "1" + + self.servers = (server,) + self.volumes = (volume,) + + def tearDown(self): + super(InstanceViewTests, self).tearDown() + self.reset_times() + + def test_usage(self): + TEST_RETURN = 'testReturn' + + now = self.override_times() + + self.mox.StubOutWithMock(api, 'usage_get') + api.usage_get(IsA(http.HttpRequest), self.TEST_TENANT, + datetime.datetime(now.year, now.month, 1, + now.hour, now.minute, now.second), + now).AndReturn(TEST_RETURN) + + self.mox.ReplayAll() + + res = self.client.get(reverse('horizon:nova:overview:index')) + + self.assertTemplateUsed(res, 'nova/overview/usage.html') + + self.assertEqual(res.context['usage'], TEST_RETURN) + + def test_usage_csv(self): + TEST_RETURN = 'testReturn' + + self.mox.StubOutWithMock(api, 'usage_get') + timestamp = datetime.datetime(self.now.year, self.now.month, 1, + self.now.hour, self.now.minute, + self.now.second) + api.usage_get(IsA(http.HttpRequest), + self.TEST_TENANT, + timestamp, + self.now).AndReturn(TEST_RETURN) + + self.mox.ReplayAll() + + res = self.client.get(reverse('horizon:nova:overview:index') + + "?format=csv") + + self.assertTemplateUsed(res, 'nova/overview/usage.csv') + + self.assertEqual(res.context['usage'], TEST_RETURN) + + def test_usage_exception(self): + self.mox.StubOutWithMock(api, 'usage_get') + + timestamp = datetime.datetime(self.now.year, self.now.month, 1, + self.now.hour, self.now.minute, + self.now.second) + exception = nova_exceptions.ClientException(500) + api.usage_get(IsA(http.HttpRequest), + self.TEST_TENANT, + timestamp, + self.now).AndRaise(exception) + + self.mox.ReplayAll() + + res = self.client.get(reverse('horizon:nova:overview:index')) + + self.assertTemplateUsed(res, 'nova/overview/usage.html') + self.assertEqual(res.context['usage']._apiresource, None) + + def test_usage_default_tenant(self): + TEST_RETURN = 'testReturn' + + self.mox.StubOutWithMock(api, 'usage_get') + timestamp = datetime.datetime(self.now.year, self.now.month, 1, + self.now.hour, self.now.minute, + self.now.second) + api.usage_get(IsA(http.HttpRequest), + self.TEST_TENANT, + timestamp, + self.now).AndReturn(TEST_RETURN) + + self.mox.ReplayAll() + + res = self.client.get(reverse('horizon:nova:overview:index')) + + self.assertTemplateUsed(res, 'nova/overview/usage.html') + self.assertEqual(res.context['usage'], TEST_RETURN) diff --git a/horizon/horizon/dashboards/nova/overview/urls.py b/horizon/horizon/dashboards/nova/overview/urls.py index 0362353677d..f168b79339c 100644 --- a/horizon/horizon/dashboards/nova/overview/urls.py +++ b/horizon/horizon/dashboards/nova/overview/urls.py @@ -21,6 +21,6 @@ from django.conf.urls.defaults import * -urlpatterns = patterns('horizon.dashboards.nova', - url(r'^$', 'instances_and_volumes.instances.views.usage', name='index'), +urlpatterns = patterns('horizon.dashboards.nova.overview.views', + url(r'^$', 'usage', name='index'), ) diff --git a/horizon/horizon/dashboards/nova/overview/views.py b/horizon/horizon/dashboards/nova/overview/views.py new file mode 100644 index 00000000000..47f165f4ff1 --- /dev/null +++ b/horizon/horizon/dashboards/nova/overview/views.py @@ -0,0 +1,98 @@ +# vim: tabstop=4 shiftwidth=4 softtabstop=4 + +# Copyright 2011 United States Government as represented by the +# Administrator of the National Aeronautics and Space Administration. +# All Rights Reserved. +# +# Copyright 2011 Nebula, Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +from __future__ import division + +import datetime +import logging + +from django import http +from django import shortcuts +from django.contrib import messages +from django.core.urlresolvers import reverse +from django.utils.datastructures import SortedDict +from django.utils.translation import ugettext as _ + +import horizon +from horizon import api +from horizon import exceptions +from horizon import forms +from horizon import test +from horizon import views + + +LOG = logging.getLogger(__name__) + + +def usage(request, tenant_id=None): + tenant_id = tenant_id or request.user.tenant_id + today = test.today() + date_start = datetime.date(today.year, today.month, 1) + datetime_start = datetime.datetime.combine(date_start, test.time()) + datetime_end = test.utcnow() + + show_terminated = request.GET.get('show_terminated', False) + + try: + usage = api.usage_get(request, tenant_id, datetime_start, datetime_end) + except: + usage = api.nova.Usage(None) + redirect = reverse("horizon:nova:overview:index") + exceptions.handle(request, + _('Unable to retrieve usage information.')) + + ram_unit = "MB" + total_ram = getattr(usage, 'total_active_ram_size', 0) + if total_ram >= 1024: + ram_unit = "GB" + total_ram /= 1024 + + instances = [] + terminated = [] + + if hasattr(usage, 'instances'): + now = datetime.datetime.now() + for i in usage.instances: + i['uptime_at'] = now - datetime.timedelta(seconds=i['uptime']) + if i['ended_at'] and not show_terminated: + terminated.append(i) + else: + instances.append(i) + + if request.GET.get('format', 'html') == 'csv': + template = 'nova/overview/usage.csv' + mimetype = "text/csv" + else: + template = 'nova/overview/usage.html' + mimetype = "text/html" + + dash_url = horizon.get_dashboard('nova').get_absolute_url() + + return shortcuts.render(request, template, { + 'usage': usage, + 'ram_unit': ram_unit, + 'total_ram': total_ram, + 'csv_link': '?format=csv', + 'show_terminated': show_terminated, + 'datetime_start': datetime_start, + 'datetime_end': datetime_end, + 'instances': instances, + 'dash_url': dash_url}, + content_type=mimetype) diff --git a/horizon/horizon/dashboards/nova/templates/nova/access_and_security/security_groups/_edit_rules.html b/horizon/horizon/dashboards/nova/templates/nova/access_and_security/security_groups/_edit_rules.html index 90601fb7eac..c51f1873e07 100644 --- a/horizon/horizon/dashboards/nova/templates/nova/access_and_security/security_groups/_edit_rules.html +++ b/horizon/horizon/dashboards/nova/templates/nova/access_and_security/security_groups/_edit_rules.html @@ -1,23 +1,21 @@ +{% extends "horizon/common/_modal_form.html" %} {% load i18n %} - +{% block form_id %}security_group_rule_form{% endblock %} +{% block form_action %}{% url horizon:nova:access_and_security:security_groups:edit_rules security_group.id %}{% endblock %} +{% block form_class %}{{ block.super }} horizontal split_quarter{% endblock %} + +{% block modal_id %}security_group_rule_modal{% endblock %} +{% block modal-header %}{% trans "Edit Security Group Rules" %}{% endblock %} + +{% block modal-body %} +

{% trans "Add Rule" %}

+
+ {% include "horizon/common/_form_fields.html" %} +
+{% endblock %} + +{% block modal-footer %} + + {% trans "Cancel" %} +{% endblock %} diff --git a/horizon/horizon/dashboards/nova/templates/nova/images_and_snapshots/images/_launch.html b/horizon/horizon/dashboards/nova/templates/nova/images_and_snapshots/images/_launch.html index 7e5534f6527..859ca21baf0 100644 --- a/horizon/horizon/dashboards/nova/templates/nova/images_and_snapshots/images/_launch.html +++ b/horizon/horizon/dashboards/nova/templates/nova/images_and_snapshots/images/_launch.html @@ -48,5 +48,5 @@

{% trans "Description:" %}

{% block modal-footer %} - {% trans "Cancel" %} + {% trans "Cancel" %} {% endblock %} diff --git a/horizon/horizon/dashboards/nova/templates/nova/images_and_snapshots/index.html b/horizon/horizon/dashboards/nova/templates/nova/images_and_snapshots/index.html index a041e0f2ece..576ea1eccb0 100644 --- a/horizon/horizon/dashboards/nova/templates/nova/images_and_snapshots/index.html +++ b/horizon/horizon/dashboards/nova/templates/nova/images_and_snapshots/index.html @@ -21,7 +21,7 @@

{% trans "Info: " %}{% trans "There are currently no snapshots. You can create snapshots from running instances." %}

{% endif %} diff --git a/horizon/horizon/dashboards/nova/templates/nova/instances_and_volumes/index.html b/horizon/horizon/dashboards/nova/templates/nova/instances_and_volumes/index.html index 00294942fe5..133c467376e 100644 --- a/horizon/horizon/dashboards/nova/templates/nova/instances_and_volumes/index.html +++ b/horizon/horizon/dashboards/nova/templates/nova/instances_and_volumes/index.html @@ -7,21 +7,11 @@ {% endblock page_header %} {% block dash_main %} - {% if instances %} - {% include 'nova/instances_and_volumes/instances/_list.html' %} - {% else %} - {% include 'nova/instances_and_volumes/instances/_no_instances.html' %} - {% endif %} +
+ {{ instances_table.render }} +
- {% if volumes %} - {% include 'nova/instances_and_volumes/volumes/_list.html' %} - {% else %} -
-

{% trans "Info: " %}{% trans "There are currently no volumes." %}

- -
- {% endif %} - {% include 'nova/instances_and_volumes/volumes/_create.html' with form=create_form hide=True %} +
+ {{ volumes_table.render }} +
{% endblock %} diff --git a/horizon/horizon/dashboards/nova/templates/nova/instances_and_volumes/instances/_instance_ips.html b/horizon/horizon/dashboards/nova/templates/nova/instances_and_volumes/instances/_instance_ips.html new file mode 100644 index 00000000000..72ed59fe636 --- /dev/null +++ b/horizon/horizon/dashboards/nova/templates/nova/instances_and_volumes/instances/_instance_ips.html @@ -0,0 +1,10 @@ +{% for ip_group, addresses in instance.addresses.items %} + {% if instance.addresses.items|length > 1 %} +

{{ ip_group }}

+ {% endif %} +
    + {% for address in addresses %} +
  • {{ address.addr }}
  • + {% endfor %} +
+{% endfor %} diff --git a/horizon/horizon/dashboards/nova/templates/nova/instances_and_volumes/instances/_list.html b/horizon/horizon/dashboards/nova/templates/nova/instances_and_volumes/instances/_list.html deleted file mode 100644 index b54463e5a45..00000000000 --- a/horizon/horizon/dashboards/nova/templates/nova/instances_and_volumes/instances/_list.html +++ /dev/null @@ -1,83 +0,0 @@ -{% load sizeformat %} -{% load i18n %} - -
-

{% trans "My Instances" %}

- -
- - - - - - - - - - - - - - {% for instance in instances %} - - - - - - - - - {% endfor %} - -
{% trans "Name" %}{% trans "IP" %}{% trans "Size" %}{% trans "State" %}{% trans "Actions" %}
- - - {{ instance.name }} - - {% for ip_group, addresses in instance.addresses.items %} - {% if instance.addresses.items|length > 1 %} -

{{ip_group}}

-
    - {% for address in addresses %} -
  • {{address.addr}}
  • - {% endfor %} -
- {% else %} -
    - {% for address in addresses %} -
  • {{address.addr}}
  • - {% endfor %} -
- {% endif %} - {% endfor %} -
- {{ instance.full_flavor.ram|mbformat }} Ram | {{ instance.full_flavor.vcpus }} VCPU | {{ instance.full_flavor.disk }}GB Disk - {{ instance.status|lower|capfirst }} - More -
    - {% if instance.status == 'PAUSED' %} -
  • {% include 'nova/instances_and_volumes/instances/_unpause.html' with form=unpause_form %}
  • - {% endif %} - {% if instance.status == 'SUSPENDED' %} -
  • {% include 'nova/instances_and_volumes/instances/_resume.html' with form=resume_form %}
  • - {% endif %} - {% if instance.status == "ACTIVE" %} -
  • {% trans 'VNC Console' %}
  • -
  • {% trans 'Log' %}
  • -
  • {% trans 'Snapshot' %}
  • -
  • {% include 'nova/instances_and_volumes/instances/_pause.html' with form=pause_form %}
  • -
  • {% include 'nova/instances_and_volumes/instances/_suspend.html' with form=suspend_form %}
  • -
  • {% include 'nova/instances_and_volumes/instances/_reboot.html' with form=reboot_form %}
  • - {% endif %} -
  • {% trans 'Edit' %}
  • -
  • {% include 'nova/instances_and_volumes/instances/_terminate.html' with form=terminate_form %}
  • -
-
diff --git a/horizon/horizon/dashboards/nova/templates/nova/instances_and_volumes/instances/_update.html b/horizon/horizon/dashboards/nova/templates/nova/instances_and_volumes/instances/_update.html index d616a3a22a2..361d1e22e24 100644 --- a/horizon/horizon/dashboards/nova/templates/nova/instances_and_volumes/instances/_update.html +++ b/horizon/horizon/dashboards/nova/templates/nova/instances_and_volumes/instances/_update.html @@ -20,5 +20,5 @@

{% trans "Description:" %}

{% block modal-footer %} - {% trans "Cancel" %} + {% trans "Cancel" %} {% endblock %} diff --git a/horizon/horizon/dashboards/nova/templates/nova/instances_and_volumes/instances/index.html b/horizon/horizon/dashboards/nova/templates/nova/instances_and_volumes/instances/index.html deleted file mode 100644 index 1ed47e80615..00000000000 --- a/horizon/horizon/dashboards/nova/templates/nova/instances_and_volumes/instances/index.html +++ /dev/null @@ -1,57 +0,0 @@ -{% extends 'nova/base.html' %} -{% load i18n %} -{% block title %}Instances{% endblock %} - -{% block page_header %} - {% url horizon:nova:instances_and_volumes:instances:index as refresh_link %} - {# to make searchable false, just remove it from the include statement #} - {% include "horizon/common/_page_header.html" with title=_("Instances") refresh_link=refresh_link searchable="true" %} -{% endblock page_header %} - -{% block dash_main %} - {% if instances %} - {% include 'nova/instances_and_volumes/instances/_list.html' %} - {% else %} - {% include 'nova/instances_and_volumes/instances/_no_instances.html' %} - {% endif %} -{% endblock %} - -{% block footer_js %} - -{% endblock footer_js %} diff --git a/horizon/horizon/dashboards/nova/templates/nova/instances_and_volumes/instances/update.html b/horizon/horizon/dashboards/nova/templates/nova/instances_and_volumes/instances/update.html index efcc162def4..e37361083df 100644 --- a/horizon/horizon/dashboards/nova/templates/nova/instances_and_volumes/instances/update.html +++ b/horizon/horizon/dashboards/nova/templates/nova/instances_and_volumes/instances/update.html @@ -3,32 +3,9 @@ {% block title %}Update Instance{% endblock %} {% block page_header %} - {# to make searchable false, just remove it from the include statement #} {% include "horizon/common/_page_header.html" with title=_("Update Instance") %} {% endblock page_header %} {% block dash_main %} - {% include 'nova/instances_and_volumes/instances/_update.html' with form=form %} + {% include 'nova/instances_and_volumes/instances/_update.html' %} {% endblock %} - -{% block footer_js %} - -{% endblock footer_js %} diff --git a/horizon/horizon/dashboards/nova/templates/nova/instances_and_volumes/volumes/_attach.html b/horizon/horizon/dashboards/nova/templates/nova/instances_and_volumes/volumes/_attach.html index 8fa9c15d4b8..358839fa448 100644 --- a/horizon/horizon/dashboards/nova/templates/nova/instances_and_volumes/volumes/_attach.html +++ b/horizon/horizon/dashboards/nova/templates/nova/instances_and_volumes/volumes/_attach.html @@ -2,24 +2,20 @@ {% load i18n %} {% block form_id %}attach_volume_form{% endblock %} -{% block form_action %}{% url horizon:nova:instances_and_volumes:volumes:attach volume_id %}{% endblock %} +{% block form_action %}{% url horizon:nova:instances_and_volumes:volumes:attach volume.id %}{% endblock %} +{% block form_class %}{{ block.super }} horizontal split_half{% endblock %} {% block modal_id %}attach_volume_modal{% endblock %} -{% block modal-header %}{% trans "Attach Volume" %}{% endblock %} +{% block modal-header %}{% trans "Manage Volume Attachments" %}{% endblock %} {% block modal-body %} -
+

{% trans "Attach To Instance" %}

{% include "horizon/common/_form_fields.html" %}
-
-
-

{% trans "Description" %}:

-

{% trans "Attach a volume to an instance." %}

-
{% endblock %} {% block modal-footer %} - {% trans "Cancel" %} + {% trans "Cancel" %} {% endblock %} diff --git a/horizon/horizon/dashboards/nova/templates/nova/instances_and_volumes/volumes/_create.html b/horizon/horizon/dashboards/nova/templates/nova/instances_and_volumes/volumes/_create.html index 384bbdab00d..93259618336 100644 --- a/horizon/horizon/dashboards/nova/templates/nova/instances_and_volumes/volumes/_create.html +++ b/horizon/horizon/dashboards/nova/templates/nova/instances_and_volumes/volumes/_create.html @@ -21,5 +21,5 @@

{% trans "Description" %}:

{% block modal-footer %} - {% trans "Cancel" %} + {% trans "Cancel" %} {% endblock %} diff --git a/horizon/horizon/dashboards/nova/templates/nova/instances_and_volumes/volumes/_list.html b/horizon/horizon/dashboards/nova/templates/nova/instances_and_volumes/volumes/_list.html deleted file mode 100644 index 920c4a8f22d..00000000000 --- a/horizon/horizon/dashboards/nova/templates/nova/instances_and_volumes/volumes/_list.html +++ /dev/null @@ -1,70 +0,0 @@ -{% load i18n %} -{% load parse_date %} - -
-

{% trans "Block Volumes" %}

- -
- - - - - - - - - - - - - {% for volume in volumes %} - - - - - - - - - {% endfor %} - -
{% trans "Name" %}{% trans "Size" %}{% trans "Instance" %}{% trans "Actions" %}
- - {{ volume.displayName }}{{ volume.size }} {% trans "GB" %} - {% for attachment in volume.attachments %} - {% if attachment %} - - {# TODO(jake): Make this the instance name #} - Instance {{ attachment.serverId }} - ({{ attachment.device }}) - - {% else %} - {% trans "Not Attached" %} - {% endif %} - {% endfor %} - - {% if volume.status == "in-use" or volume.status == "available" %} - More -
    - {% if volume.status == "in-use" %} - {% for attachment in volume.attachments %} -
  • {% include "nova/instances_and_volumes/volumes/_detach.html" with form=detach_form %}
  • - {% endfor %} - {% endif %} - {% if volume.status == "available" %} -
  • {% trans "Attach" %}
  • -
  • {% include "nova/instances_and_volumes/volumes/_delete.html" with form=delete_form %}
  • - {% endif %} -
- {% else %} - None - {% endif %} -
diff --git a/horizon/horizon/dashboards/nova/templates/nova/instances_and_volumes/volumes/attach.html b/horizon/horizon/dashboards/nova/templates/nova/instances_and_volumes/volumes/attach.html index 1b46f3c3e23..3417f1ee09c 100644 --- a/horizon/horizon/dashboards/nova/templates/nova/instances_and_volumes/volumes/attach.html +++ b/horizon/horizon/dashboards/nova/templates/nova/instances_and_volumes/volumes/attach.html @@ -1,11 +1,11 @@ {% extends 'nova/base.html' %} {% load i18n %} -{% block title %}Attach Volume{% endblock %} +{% block title %}Manage Volume Attachments{% endblock %} {% block page_header %} - {% include "horizon/common/_page_header.html" with title=_("Attach a Volume") %} + {% include "horizon/common/_page_header.html" with title=_("Manage Volume Attachments") %} {% endblock page_header %} {% block dash_main %} - {% include 'nova/instances_and_volumes/volumes/_attach.html' with form=attach_form %} + {% include 'nova/instances_and_volumes/volumes/_attach.html' %} {% endblock %} diff --git a/horizon/horizon/dashboards/nova/templates/nova/instances_and_volumes/volumes/create.html b/horizon/horizon/dashboards/nova/templates/nova/instances_and_volumes/volumes/create.html index 324cd9880df..ac8e233aef8 100644 --- a/horizon/horizon/dashboards/nova/templates/nova/instances_and_volumes/volumes/create.html +++ b/horizon/horizon/dashboards/nova/templates/nova/instances_and_volumes/volumes/create.html @@ -7,5 +7,5 @@ {% endblock page_header %} {% block dash_main %} - {% include 'nova/instances_and_volumes/volumes/_create.html' with form=create_form %} + {% include 'nova/instances_and_volumes/volumes/_create.html' %} {% endblock %} diff --git a/horizon/horizon/dashboards/nova/templates/nova/instances_and_volumes/volumes/index.html b/horizon/horizon/dashboards/nova/templates/nova/instances_and_volumes/volumes/index.html deleted file mode 100644 index 84e2e961fa8..00000000000 --- a/horizon/horizon/dashboards/nova/templates/nova/instances_and_volumes/volumes/index.html +++ /dev/null @@ -1,25 +0,0 @@ -{% extends 'nova/base.html' %} -{% load i18n %} -{% block title %}Volumes{% endblock %} - -{% block page_header %} - {% url horizon:nova:instances_and_volumes:volumes:index as refresh_link %} - {# to make searchable false, just remove it from the include statement #} - {% include "horizon/common/_page_header.html" with title=_("Volumes") refresh_link=refresh_link searchable="true" %} -{% endblock page_header %} - -{% block dash_main %} - {% if volumes %} - {% include 'nova/instances_and_volumes/volumes/_list.html' %} - {% else %} -
-

{% trans "Info: " %}{% trans "There are currently no volumes." %}

- -
- {% endif %} - - {% include 'nova/instances_and_volumes/volumes/_create.html' with form=create_form hide=True %} - -{% endblock %} diff --git a/horizon/horizon/dashboards/nova/templates/nova/instances_and_volumes/instances/usage.csv b/horizon/horizon/dashboards/nova/templates/nova/overview/usage.csv similarity index 100% rename from horizon/horizon/dashboards/nova/templates/nova/instances_and_volumes/instances/usage.csv rename to horizon/horizon/dashboards/nova/templates/nova/overview/usage.csv diff --git a/horizon/horizon/dashboards/nova/templates/nova/instances_and_volumes/instances/usage.html b/horizon/horizon/dashboards/nova/templates/nova/overview/usage.html similarity index 100% rename from horizon/horizon/dashboards/nova/templates/nova/instances_and_volumes/instances/usage.html rename to horizon/horizon/dashboards/nova/templates/nova/overview/usage.html diff --git a/horizon/horizon/dashboards/syspanel/instances/urls.py b/horizon/horizon/dashboards/syspanel/instances/urls.py index 369a3eb5371..f15c6b488a9 100644 --- a/horizon/horizon/dashboards/syspanel/instances/urls.py +++ b/horizon/horizon/dashboards/syspanel/instances/urls.py @@ -21,16 +21,15 @@ from django.conf.urls.defaults import * from django.conf import settings +from .views import DetailView, AdminIndexView + INSTANCES = r'^(?P[^/]+)/%s$' urlpatterns = patterns('horizon.dashboards.syspanel.instances.views', - url(r'^usage/(?P[^/]+)$', 'tenant_usage', name='tenant_usage'), - url(r'^$', 'index', name='index'), - url(r'^refresh$', 'refresh', name='refresh'), - url(INSTANCES % 'detail', 'detail', name='detail'), - # NOTE(termie): currently just using the 'dash' versions - #url(INSTANCES % 'console', 'console', name='instances_console'), - #url(INSTANCES % 'vnc', 'vnc', name='syspanel_instances_vnc'), + url(r'^$', AdminIndexView.as_view(), name='index'), + url(INSTANCES % 'detail', DetailView.as_view(), name='detail'), + url(INSTANCES % 'console', 'console', name='console'), + url(INSTANCES % 'vnc', 'vnc', name='vnc'), ) diff --git a/horizon/horizon/dashboards/syspanel/instances/views.py b/horizon/horizon/dashboards/syspanel/instances/views.py index 7e678acecd1..099df04b51f 100644 --- a/horizon/horizon/dashboards/syspanel/instances/views.py +++ b/horizon/horizon/dashboards/syspanel/instances/views.py @@ -29,328 +29,27 @@ from django.utils.translation import ugettext as _ from horizon import api +from horizon import exceptions from horizon import forms -from horizon.dashboards.nova.instances_and_volumes.instances.forms import ( - TerminateInstance, PauseInstance, UnpauseInstance, SuspendInstance, - ResumeInstance, RebootInstance) -from openstackx.api import exceptions as api_exceptions +from horizon import tables +from horizon.dashboards.nova.instances_and_volumes \ + .instances.tables import InstancesTable +from horizon.dashboards.nova.instances_and_volumes \ + .instances.views import console, DetailView, vnc LOG = logging.getLogger(__name__) -class GlobalSummary(object): - node_resources = ['vcpus', 'disk_size', 'ram_size'] - unit_mem_size = {'disk_size': ['GB', 'TB'], 'ram_size': ['MB', 'GB']} - node_resource_info = ['', 'active_', 'avail_'] +class AdminIndexView(tables.DataTableView): + table_class = InstancesTable + template_name = 'syspanel/instances/index.html' - def __init__(self, request): - self.summary = {} - for rsrc in GlobalSummary.node_resources: - for info in GlobalSummary.node_resource_info: - self.summary['total_' + info + rsrc] = 0 - self.request = request - self.service_list = [] - self.usage_list = [] - - def service(self): - try: - self.service_list = api.service_list(self.request) - except api_exceptions.ApiException, e: - self.service_list = [] - LOG.exception('ApiException fetching service list ' - 'in instance usage') - messages.error(self.request, - _('Unable to get service info: %s') % e.message) - return - - for service in self.service_list: - if service.type == 'nova-compute': - self.summary['total_vcpus'] += min(service.stats['max_vcpus'], - service.stats.get('vcpus', 0)) - self.summary['total_disk_size'] += min( - service.stats['max_gigabytes'], - service.stats.get('local_gb', 0)) - self.summary['total_ram_size'] += min( - service.stats['max_ram'], - service.stats['memory_mb']) if 'max_ram' \ - in service.stats \ - else service.stats.get('memory_mb', 0) - - def usage(self, datetime_start, datetime_end): - try: - self.usage_list = api.usage_list(self.request, datetime_start, - datetime_end) - except api_exceptions.ApiException, e: - self.usage_list = [] - LOG.exception('ApiException fetching usage list in instance usage' - ' on date range "%s to %s"' % (datetime_start, - datetime_end)) - messages.error(self.request, - _('Unable to get usage info: %s') % e.message) - return - - for usage in self.usage_list: - # FIXME: api needs a simpler dict interface (with iteration) - # - anthony - # NOTE(mgius): Changed this on the api end. Not too much - # neater, but at least its not going into private member - # data of an external class anymore - # usage = usage._info - for k in usage._attrs: - v = usage.__getattr__(k) - if isinstance(v, (float, int)): - if not k in self.summary: - self.summary[k] = 0 - self.summary[k] += v - - def human_readable(self, rsrc): - if self.summary['total_' + rsrc] > 1023: - self.summary['unit_' + rsrc] = GlobalSummary.unit_mem_size[rsrc][1] - mult = 1024.0 - else: - self.summary['unit_' + rsrc] = GlobalSummary.unit_mem_size[rsrc][0] - mult = 1.0 - - for kind in GlobalSummary.node_resource_info: - self.summary['total_' + kind + rsrc + '_hr'] = \ - self.summary['total_' + kind + rsrc] / mult - - def avail(self): - for rsrc in GlobalSummary.node_resources: - self.summary['total_avail_' + rsrc] = \ - self.summary['total_' + rsrc] - \ - self.summary['total_active_' + rsrc] - - -def _next_month(date_start): - y = date_start.year + (date_start.month + 1) / 13 - m = ((date_start.month + 1) % 13) - if m == 0: - m = 1 - return datetime.date(y, m, 1) - - -def _current_month(): - today = datetime.date.today() - return datetime.date(today.year, today.month, 1) - - -def _get_start_and_end_date(request): - try: - date_start = datetime.date( - int(request.GET['date_year']), - int(request.GET['date_month']), - 1) - except: - today = datetime.date.today() - date_start = datetime.date(today.year, today.month, 1) - - date_end = _next_month(date_start) - datetime_start = datetime.datetime.combine(date_start, datetime.time()) - datetime_end = datetime.datetime.combine(date_end, datetime.time()) - - if date_end > datetime.date.today(): - datetime_end = datetime.datetime.utcnow() - return (date_start, date_end, datetime_start, datetime_end) - - -def _csv_usage_link(date_start): - return "?date_month=%s&date_year=%s&format=csv" % (date_start.month, - date_start.year) - - -def usage(request): - (date_start, date_end, datetime_start, datetime_end) = \ - _get_start_and_end_date(request) - - global_summary = GlobalSummary(request) - if date_start > _current_month(): - messages.error(request, _('No data for the selected period')) - date_end = date_start - datetime_end = datetime_start - else: - global_summary.service() - global_summary.usage(datetime_start, datetime_end) - - dateform = forms.DateForm() - dateform['date'].field.initial = date_start - - global_summary.avail() - global_summary.human_readable('disk_size') - global_summary.human_readable('ram_size') - - if request.GET.get('format', 'html') == 'csv': - template_name = 'syspanel/instances/usage.csv' - mimetype = "text/csv" - else: - template_name = 'syspanel/instances/usage.html' - mimetype = "text/html" - - return render_to_response( - template_name, { - 'dateform': dateform, - 'datetime_start': datetime_start, - 'datetime_end': datetime_end, - 'usage_list': global_summary.usage_list, - 'csv_link': _csv_usage_link(date_start), - 'global_summary': global_summary.summary, - 'external_links': getattr(settings, 'EXTERNAL_MONITORING', []), - }, context_instance=template.RequestContext(request), mimetype=mimetype) - - -def tenant_usage(request): - tenant_id = request.user.tenant - (date_start, date_end, datetime_start, datetime_end) = \ - _get_start_and_end_date(request) - if date_start > _current_month(): - messages.error(request, _('No data for the selected period')) - date_end = date_start - datetime_end = datetime_start - - dateform = forms.DateForm() - dateform['date'].field.initial = date_start - - usage = {} - try: - usage = api.usage_get(request, tenant_id, datetime_start, datetime_end) - except api_exceptions.ApiException, e: - LOG.exception('ApiException getting usage info for tenant "%s"' - ' on date range "%s to %s"' % (tenant_id, - datetime_start, - datetime_end)) - messages.error(request, _('Unable to get usage info: %s') % e.message) - - running_instances = [] - terminated_instances = [] - if hasattr(usage, 'instances'): - now = datetime.datetime.now() - for i in usage.instances: - # this is just a way to phrase uptime in a way that is compatible - # with the 'timesince' filter. Use of local time intentional - i['uptime_at'] = now - datetime.timedelta(seconds=i['uptime']) - if i['ended_at']: - terminated_instances.append(i) - else: - running_instances.append(i) - - if request.GET.get('format', 'html') == 'csv': - template_name = 'syspanel/instances/tenant_usage.csv' - mimetype = "text/csv" - else: - template_name = 'syspanel/instances/tenant_usage.html' - mimetype = "text/html" - - return render_to_response(template_name, { - 'dateform': dateform, - 'datetime_start': datetime_start, - 'datetime_end': datetime_end, - 'usage': usage, - 'csv_link': _csv_usage_link(date_start), - 'instances': running_instances + terminated_instances, - 'tenant_id': tenant_id, - }, context_instance=template.RequestContext(request), mimetype=mimetype) - - -def index(request): - for f in (TerminateInstance, PauseInstance, UnpauseInstance, - SuspendInstance, ResumeInstance, RebootInstance): - form, handled = f.maybe_handle(request) - if handled: - return handled - - instances = [] - try: - instances = api.admin_server_list(request) - except Exception as e: - LOG.exception('Unspecified error in instance index') - if not hasattr(e, 'message'): - e.message = str(e) - messages.error(request, - _('Unable to get instance list: %s') % e.message) - - # We don't have any way of showing errors for these, so don't bother - # trying to reuse the forms from above - terminate_form = TerminateInstance() - pause_form = PauseInstance() - unpause_form = UnpauseInstance() - suspend_form = SuspendInstance() - resume_form = ResumeInstance() - reboot_form = RebootInstance() - - return render_to_response( - 'syspanel/instances/index.html', { - 'instances': instances, - 'terminate_form': terminate_form, - 'pause_form': pause_form, - 'unpause_form': unpause_form, - 'suspend_form': suspend_form, - 'resume_form': resume_form, - 'reboot_form': reboot_form, - }, context_instance=template.RequestContext(request)) - - -def refresh(request): - for f in (TerminateInstance, PauseInstance, UnpauseInstance, - SuspendInstance, ResumeInstance, RebootInstance): - form, handled = f.maybe_handle(request) - if handled: - return handled - - instances = [] - try: - instances = api.admin_server_list(request) - except Exception as e: - if not hasattr(e, 'message'): - e.message = str(e) - messages.error(request, - _('Unable to get instance list: %s') % e.message) - - # We don't have any way of showing errors for these, so don't bother - # trying to reuse the forms from above - terminate_form = TerminateInstance() - pause_form = PauseInstance() - unpause_form = UnpauseInstance() - suspend_form = SuspendInstance() - resume_form = ResumeInstance() - reboot_form = RebootInstance() - - return render_to_response( - 'syspanel/instances/_list.html', { - 'instances': instances, - 'terminate_form': terminate_form, - 'pause_form': pause_form, - 'unpause_form': unpause_form, - 'suspend_form': suspend_form, - 'resume_form': resume_form, - 'reboot_form': reboot_form, - }, context_instance=template.RequestContext(request)) - - -def detail(request, instance_id): - try: - instance = api.server_get(request, instance_id) + def get_data(self): + instances = [] try: - console = api.console_create(request, instance_id, 'vnc') - vnc_url = "%s&title=%s(%s)" % (console.output, - instance.name, - instance_id) - except api_exceptions.ApiException, e: - LOG.exception('ApiException while fetching instance vnc \ - connection') - messages.error(request, - _('Unable to get vnc console for instance %(inst)s: %(message)s') % - {"inst": instance_id, "message": e.message}) - return redirect('horizon:syspanel:instances:index', tenant_id) - except api_exceptions.ApiException, e: - LOG.exception('ApiException while fetching instance info') - messages.error(request, - _('Unable to get information for instance %(inst)s: %(message)s') % - {"inst": instance_id, "message": e.message}) - return redirect('horizon:syspanel:instances:index', tenant_id) - - return render_to_response( - 'syspanel/instances/detail.html', { - 'instance': instance, - 'vnc_url': vnc_url, - }, context_instance=template.RequestContext(request)) + instances = api.admin_server_list(self.request) + except: + exceptions.handle(self.request, + _('Unable to retrieve instance list.')) + return instances diff --git a/horizon/horizon/dashboards/syspanel/overview/urls.py b/horizon/horizon/dashboards/syspanel/overview/urls.py index 614f58be83c..36f3523f446 100644 --- a/horizon/horizon/dashboards/syspanel/overview/urls.py +++ b/horizon/horizon/dashboards/syspanel/overview/urls.py @@ -21,6 +21,6 @@ from django.conf.urls.defaults import * -urlpatterns = patterns('horizon.dashboards.syspanel', - url(r'^$', 'instances.views.usage', name='index'), +urlpatterns = patterns('horizon.dashboards.syspanel.overview.views', + url(r'^$', 'usage', name='index'), ) diff --git a/horizon/horizon/dashboards/syspanel/overview/views.py b/horizon/horizon/dashboards/syspanel/overview/views.py new file mode 100644 index 00000000000..458b2a5e4ff --- /dev/null +++ b/horizon/horizon/dashboards/syspanel/overview/views.py @@ -0,0 +1,176 @@ +# vim: tabstop=4 shiftwidth=4 softtabstop=4 + +# Copyright 2011 United States Government as represented by the +# Administrator of the National Aeronautics and Space Administration. +# All Rights Reserved. +# +# Copyright 2011 Nebula, Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +import datetime +import logging + +from dateutil.relativedelta import relativedelta +from django import template +from django import http +from django import shortcuts +from django.conf import settings +from django.contrib import messages +from django.utils.translation import ugettext as _ + +from horizon import api +from horizon import forms +from horizon import exceptions + + +LOG = logging.getLogger(__name__) + + +class GlobalSummary(object): + node_resources = ['vcpus', 'disk_size', 'ram_size'] + unit_mem_size = {'disk_size': ['GB', 'TB'], 'ram_size': ['MB', 'GB']} + node_resource_info = ['', 'active_', 'avail_'] + + def __init__(self, request): + self.summary = {} + for rsrc in GlobalSummary.node_resources: + for info in GlobalSummary.node_resource_info: + self.summary['total_' + info + rsrc] = 0 + self.request = request + self.service_list = [] + self.usage_list = [] + + def service(self): + try: + self.service_list = api.service_list(self.request) + except: + self.service_list = [] + exceptions.handle(self.request, + _('Unable to retrieve service information.')) + + for service in self.service_list: + if service.type == 'nova-compute': + self.summary['total_vcpus'] += min(service.stats['max_vcpus'], + service.stats.get('vcpus', 0)) + self.summary['total_disk_size'] += min( + service.stats['max_gigabytes'], + service.stats.get('local_gb', 0)) + self.summary['total_ram_size'] += min( + service.stats['max_ram'], + service.stats['memory_mb']) if 'max_ram' \ + in service.stats \ + else service.stats.get('memory_mb', 0) + + def usage(self, start, end): + try: + self.usage_list = api.usage_list(self.request, start, end) + except: + self.usage_list = [] + exceptions.handle(self.request, + _('Unable to retrieve usage information on date' + 'range %(start)s to %(end)s' % {"start": start, + "end": end})) + for usage in self.usage_list: + for key in usage._attrs: + val = getattr(usage, key) + if isinstance(val, (float, int)): + self.summary.setdefault(key, 0) + self.summary[key] += val + + def human_readable(self, rsrc): + if self.summary['total_' + rsrc] > 1023: + self.summary['unit_' + rsrc] = GlobalSummary.unit_mem_size[rsrc][1] + mult = 1024.0 + else: + self.summary['unit_' + rsrc] = GlobalSummary.unit_mem_size[rsrc][0] + mult = 1.0 + + for kind in GlobalSummary.node_resource_info: + self.summary['total_' + kind + rsrc + '_hr'] = \ + self.summary['total_' + kind + rsrc] / mult + + def avail(self): + for rsrc in GlobalSummary.node_resources: + self.summary['total_avail_' + rsrc] = \ + self.summary['total_' + rsrc] - \ + self.summary['total_active_' + rsrc] + + @staticmethod + def next_month(date_start): + return date_start + relativedelta(months=1) + + @staticmethod + def current_month(): + today = datetime.date.today() + return datetime.date(today.year, today.month, 1) + + @staticmethod + def get_start_and_end_date(year, month, day=1): + date_start = datetime.date(year, month, day) + date_end = GlobalSummary.next_month(date_start) + datetime_start = datetime.datetime.combine(date_start, datetime.time()) + datetime_end = datetime.datetime.combine(date_end, datetime.time()) + + if date_end > datetime.date.today(): + datetime_end = datetime.datetime.utcnow() + return date_start, date_end, datetime_start, datetime_end + + @staticmethod + def csv_link(date_start): + return "?date_month=%s&date_year=%s&format=csv" % (date_start.month, + date_start.year) + + +def usage(request): + today = datetime.date.today() + dateform = forms.DateForm(request.GET, initial={'year': today.year, + "month": today.month}) + if dateform.is_valid(): + req_year = int(dateform.cleaned_data['year']) + req_month = int(dateform.cleaned_data['month']) + else: + req_year = today.year + req_month = today.month + date_start, date_end, datetime_start, datetime_end = \ + GlobalSummary.get_start_and_end_date(req_year, req_month) + + global_summary = GlobalSummary(request) + if date_start > GlobalSummary.current_month(): + messages.error(request, _('No data for the selected period')) + date_end = date_start + datetime_end = datetime_start + else: + global_summary.service() + global_summary.usage(datetime_start, datetime_end) + + global_summary.avail() + global_summary.human_readable('disk_size') + global_summary.human_readable('ram_size') + + if request.GET.get('format', 'html') == 'csv': + template = 'syspanel/tenants/usage.csv' + mimetype = "text/csv" + else: + template = 'syspanel/tenants/usage.html' + mimetype = "text/html" + + context = {'dateform': dateform, + 'datetime_start': datetime_start, + 'datetime_end': datetime_end, + 'usage_list': global_summary.usage_list, + 'csv_link': GlobalSummary.csv_link(date_start), + 'global_summary': global_summary.summary, + 'external_links': getattr(settings, 'EXTERNAL_MONITORING', [])} + + return shortcuts.render(request, template, context, content_type=mimetype) diff --git a/horizon/horizon/dashboards/syspanel/templates/syspanel/instances/_list.html b/horizon/horizon/dashboards/syspanel/templates/syspanel/instances/_list.html index e80cb5cabca..7bf38345e3b 100644 --- a/horizon/horizon/dashboards/syspanel/templates/syspanel/instances/_list.html +++ b/horizon/horizon/dashboards/syspanel/templates/syspanel/instances/_list.html @@ -53,8 +53,8 @@

{{ ip_group }}

  • {% include 'nova/instances_and_volumes/instances/_pause.html' with form=pause_form %}
  • {% include 'nova/instances_and_volumes/instances/_suspend.html' with form=suspend_form %}
  • {% include "syspanel/instances/_reboot.html" with form=reboot_form %}
  • -
  • {% trans "Console Log" %}
  • -
  • {% trans "VNC Console" %}
  • +
  • {% trans "Console Log" %}
  • +
  • {% trans "VNC Console" %}
  • {% endif %}
  • {% include "syspanel/instances/_terminate.html" with form=terminate_form %}
  • diff --git a/horizon/horizon/dashboards/syspanel/templates/syspanel/instances/index.html b/horizon/horizon/dashboards/syspanel/templates/syspanel/instances/index.html index 9066afde868..7736b60310e 100644 --- a/horizon/horizon/dashboards/syspanel/templates/syspanel/instances/index.html +++ b/horizon/horizon/dashboards/syspanel/templates/syspanel/instances/index.html @@ -1,60 +1,11 @@ {% extends 'syspanel/base.html' %} {% load i18n %} -{% block title %}Instances{% endblock %} +{% block title %}{% trans "Instances" %}{% endblock %} {% block page_header %} - {% url horizon:syspanel:instances:index as refresh_link %} - {# to make searchable false, just remove it from the include statement #} - {% include "horizon/common/_page_header.html" with title=_("Instances") refresh_link=refresh_link searchable="true" %} + {% include "horizon/common/_page_header.html" with title=_("Instances") %} {% endblock page_header %} {% block syspanel_main %} - {% if instances %} - {% include 'syspanel/instances/_list.html' %} - {% else %} -
    - {% url horizon:nova:images_and_snapshots:images:index as dash_image_url%} -

    {% trans "Info: " %}{% blocktrans %}There are currently no instances. You can launch an instance from the Images Page.{% endblocktrans %}

    -
    - {% endif %} + {{ table.render }} {% endblock %} - -{% block footer_js %} - -{% endblock footer_js %} diff --git a/horizon/horizon/dashboards/syspanel/templates/syspanel/instances/tenant_usage.csv b/horizon/horizon/dashboards/syspanel/templates/syspanel/instances/tenant_usage.csv deleted file mode 100644 index d618b637033..00000000000 --- a/horizon/horizon/dashboards/syspanel/templates/syspanel/instances/tenant_usage.csv +++ /dev/null @@ -1,11 +0,0 @@ -Usage Report For Period:,{{datetime_start|date:"b. d Y H:i"}},/,{{datetime_end|date:"b. d Y H:i"}} -Tenant ID:,{{usage.tenant_id}} -Total Active VCPUs:,{{usage.total_active_vcpus}} -CPU-HRs Used:,{{usage.total_cpu_usage}} -Total Active Ram (MB):,{{usage.total_active_ram_size}} -Total Disk Size:,{{usage.total_active_disk_size}} -Total Disk Usage:,{{usage.total_disk_usage}} - -ID,Name,UserId,VCPUs,RamMB,DiskGB,Flavor,Usage(Hours),Uptime(Seconds),State -{% for instance in usage.instances %}{{instance.id}},{{instance.name|addslashes}},{{instance.user_id|addslashes}},{{instance.vcpus|addslashes}},{{instance.ram_size|addslashes}},{{instance.disk_size|addslashes}},{{instance.flavor|addslashes}},{{instance.hours}},{{instance.uptime}},{{instance.state|capfirst|addslashes}} -{% endfor %} diff --git a/horizon/horizon/dashboards/syspanel/templates/syspanel/instances/tenant_usage.html b/horizon/horizon/dashboards/syspanel/templates/syspanel/instances/tenant_usage.html deleted file mode 100644 index 9abbdcf8343..00000000000 --- a/horizon/horizon/dashboards/syspanel/templates/syspanel/instances/tenant_usage.html +++ /dev/null @@ -1,91 +0,0 @@ -{% extends 'syspanel/base.html' %} -{% load i18n parse_date sizeformat %} -{% block title %}Tenant Usage Overview{% endblock %} - -{% block page_header %} - {# to make searchable false, just remove it from the include statement #} - {% include "horizon/common/_page_header.html" with title=_("System Panel Overview") %} -{% endblock page_header %} - -{% block syspanel_main %} -
    - -

    Select a month to query its usage:

    -
    - {{ dateform.date }} - -
    -
    - -
    -
    -

    CPU

    -
      -
    • {{ usage.total_active_vcpus }}Cores Active
    • -
    • {{ usage.total_cpu_usage|floatformat }}CPU-HR Used
    • -
    -
    - -
    -

    RAM

    -
      -
    • {{ usage.total_active_ram_size }}MB Active
    • -
    -
    - -
    -

    Disk

    -
      -
    • {{ usage.total_active_disk_size }}GB Active
    • -
    • {{ usage.total_disk_usage|floatformat }}GB-HR Used
    • -
    -
    -
    -

    - {% trans "Active Instances" %}: {{ usage.total_active_instances }} - {% trans "This month's VCPU-Hours" %}: {{ usage.total_cpu_usage|floatformat }} - {% trans "This month's GB-Hours" %}: {{ usage.total_disk_usage|floatformat }} -

    - - - {% if usage.instances %} -
    - {% trans "Download CSV" %} » -

    {% trans "Tenant Usage" %}: {{ tenant_id }}

    -
    - - - - - - - - - - - - - - - {% for instance in instances %} - {% if instance.ended_at %} - - {% else %} - - {% endif %} - - - - - - - - - - - {% endfor %} - -
    {% trans "ID" %}{% trans "Name" %}{% trans "User" %}{% trans "VCPUs" %}{% trans "Ram Size" %}{% trans "Disk Size" %}{% trans "Flavor" %}{% trans "Uptime" %}{% trans "Status" %}
    {{ instance.id }}{{ instance.name }}{{ instance.user_id }}{{ instance.vcpus }}{{ instance.ram_size|mbformat }}{{ instance.disk_size }}GB{{ instance.flavor }}{{ instance.uptime_at|timesince }}{{ instance.state|capfirst }}
    - {% endif %} - -{% endblock %} diff --git a/horizon/horizon/dashboards/syspanel/templates/syspanel/instances/usage.csv b/horizon/horizon/dashboards/syspanel/templates/syspanel/tenants/usage.csv similarity index 100% rename from horizon/horizon/dashboards/syspanel/templates/syspanel/instances/usage.csv rename to horizon/horizon/dashboards/syspanel/templates/syspanel/tenants/usage.csv diff --git a/horizon/horizon/dashboards/syspanel/templates/syspanel/instances/usage.html b/horizon/horizon/dashboards/syspanel/templates/syspanel/tenants/usage.html similarity index 95% rename from horizon/horizon/dashboards/syspanel/templates/syspanel/instances/usage.html rename to horizon/horizon/dashboards/syspanel/templates/syspanel/tenants/usage.html index 681aca73c96..ae9f08806b6 100644 --- a/horizon/horizon/dashboards/syspanel/templates/syspanel/instances/usage.html +++ b/horizon/horizon/dashboards/syspanel/templates/syspanel/tenants/usage.html @@ -20,10 +20,10 @@

    {% trans "Monitoring" %}:

    {% endif %}
    -

    {% trans "Select a month to query its usage" %}:

    - {{ dateform.date }} + {{ dateform.month }} + {{ dateform.year }}
    @@ -72,7 +72,7 @@

    {% trans "Server Usage Summary" %}

    {% for usage in usage_list %} - {{ usage.tenant_id }} + {{ usage.tenant_id }} {{ usage.total_active_instances }} {{ usage.total_active_vcpus }} {{ usage.total_active_disk_size|diskgbformat }} diff --git a/horizon/horizon/dashboards/syspanel/tenants/tables.py b/horizon/horizon/dashboards/syspanel/tenants/tables.py index 63cf5010c10..1320b25e794 100644 --- a/horizon/horizon/dashboards/syspanel/tenants/tables.py +++ b/horizon/horizon/dashboards/syspanel/tenants/tables.py @@ -24,6 +24,12 @@ class ViewMembersLink(tables.LinkAction): url = "horizon:syspanel:tenants:users" +class UsageLink(tables.LinkAction): + name = "usage" + verbose_name = _("View Usage") + url = "horizon:syspanel:tenants:usage" + + class EditLink(tables.LinkAction): name = "update" verbose_name = _("Edit") @@ -69,6 +75,6 @@ class TenantsTable(tables.DataTable): class Meta: name = "tenants" verbose_name = _("Tenants") - row_actions = (EditLink, ViewMembersLink, ModifyQuotasLink, + row_actions = (EditLink, UsageLink, ViewMembersLink, ModifyQuotasLink, DeleteTenantsAction) table_actions = (TenantFilterAction, CreateLink, DeleteTenantsAction) diff --git a/horizon/horizon/dashboards/syspanel/tenants/urls.py b/horizon/horizon/dashboards/syspanel/tenants/urls.py index 1583717671e..ef68c1388b9 100644 --- a/horizon/horizon/dashboards/syspanel/tenants/urls.py +++ b/horizon/horizon/dashboards/syspanel/tenants/urls.py @@ -30,4 +30,6 @@ UpdateView.as_view(), name='update'), url(r'^(?P[^/]+)/users/$', 'users', name='users'), url(r'^(?P[^/]+)/quotas/$', - QuotasView.as_view(), name='quotas')) + QuotasView.as_view(), name='quotas'), + url(r'^(?P[^/]+)/usage/$', 'usage', name='usage') +) diff --git a/horizon/horizon/dashboards/syspanel/tenants/views.py b/horizon/horizon/dashboards/syspanel/tenants/views.py index 4077404f116..560e28b0e56 100644 --- a/horizon/horizon/dashboards/syspanel/tenants/views.py +++ b/horizon/horizon/dashboards/syspanel/tenants/views.py @@ -18,6 +18,7 @@ # License for the specific language governing permissions and limitations # under the License. +import datetime import logging from django import shortcuts @@ -34,6 +35,8 @@ UpdateQuotas) from .tables import TenantsTable +from horizon.dashboards.syspanel.overview.views import GlobalSummary + LOG = logging.getLogger(__name__) @@ -131,3 +134,62 @@ def get_initial(self): 'instances': quotas.instances, 'injected_files': quotas.injected_files, 'cores': quotas.cores} + + +def usage(request, tenant_id): + today = datetime.date.today() + dateform = forms.DateForm(request.GET, initial={'year': today.year, + "month": today.month}) + if dateform.is_valid(): + req_year = int(dateform.cleaned_data['year']) + req_month = int(dateform.cleaned_data['month']) + else: + req_year = today.year + req_month = today.month + date_start, date_end, datetime_start, datetime_end = \ + GlobalSummary.get_start_and_end_date(req_year, req_month) + + if date_start > GlobalSummary.current_month(): + messages.error(request, _('No data for the selected period')) + date_end = date_start + datetime_end = datetime_start + + usage = {} + try: + usage = api.usage_get(request, tenant_id, datetime_start, datetime_end) + except api_exceptions.ApiException, e: + LOG.exception('ApiException getting usage info for tenant "%s"' + ' on date range "%s to %s"' % (tenant_id, + datetime_start, + datetime_end)) + messages.error(request, _('Unable to get usage info: %s') % e.message) + + running_instances = [] + terminated_instances = [] + if hasattr(usage, 'instances'): + now = datetime.datetime.now() + for i in usage.instances: + # this is just a way to phrase uptime in a way that is compatible + # with the 'timesince' filter. Use of local time intentional + i['uptime_at'] = now - datetime.timedelta(seconds=i['uptime']) + if i['ended_at']: + terminated_instances.append(i) + else: + running_instances.append(i) + + if request.GET.get('format', 'html') == 'csv': + template = 'syspanel/tenants/usage.csv' + mimetype = "text/csv" + else: + template = 'syspanel/tenants/usage.html' + mimetype = "text/html" + + context = {'dateform': dateform, + 'datetime_start': datetime_start, + 'datetime_end': datetime_end, + 'usage': usage, + 'csv_link': GlobalSummary.csv_link(date_start), + 'instances': running_instances + terminated_instances, + 'tenant_id': tenant_id} + + return shortcuts.render(request, template, context, content_type=mimetype) diff --git a/horizon/horizon/exceptions.py b/horizon/horizon/exceptions.py index ae526b69a14..6299d0fc537 100644 --- a/horizon/horizon/exceptions.py +++ b/horizon/horizon/exceptions.py @@ -18,8 +18,6 @@ Exceptions raised by the Horizon code and the machinery for handling them. """ -from __future__ import absolute_import - import logging import sys @@ -154,6 +152,8 @@ def handle(request, message=None, redirect=None, ignore=False, escalate=False): LOG.debug("Recoverable error: %s" % exc_value) messages.error(request, message or exc_value) wrap = True + if redirect: + raise Http302(redirect) if not escalate: return # return to normal code flow diff --git a/horizon/horizon/forms/__init__.py b/horizon/horizon/forms/__init__.py index fd7994af1e9..2859e511510 100644 --- a/horizon/horizon/forms/__init__.py +++ b/horizon/horizon/forms/__init__.py @@ -20,5 +20,5 @@ from django.forms import widgets # Convenience imports for public API components. -from .base import SelfHandlingForm, SelectDateWidget, DateForm +from .base import SelfHandlingForm, DateForm from .views import ModalFormView diff --git a/horizon/horizon/forms/base.py b/horizon/horizon/forms/base.py index 3f512b7b4a8..986c14ff140 100644 --- a/horizon/horizon/forms/base.py +++ b/horizon/horizon/forms/base.py @@ -18,7 +18,7 @@ # License for the specific language governing permissions and limitations # under the License. -import datetime +from datetime import date import logging import re @@ -35,120 +35,6 @@ LOG = logging.getLogger(__name__) -RE_DATE = re.compile(r'(\d{4})-(\d\d?)-(\d\d?)$') - - -class SelectDateWidget(widgets.Widget): - """ - A Widget that splits date input into three