Merge "Move Security Groups into its own panel"

This commit is contained in:
Jenkins 2017-02-02 17:12:53 +00:00 committed by Gerrit Code Review
commit 7d5b6ebca6
25 changed files with 187 additions and 361 deletions

View File

@ -1,55 +0,0 @@
# Copyright 2012 United States Government as represented by the
# Administrator of the National Aeronautics and Space Administration.
# All Rights Reserved.
#
# Copyright 2012 Nebula, Inc.
# Copyright 2012 OpenStack Foundation
#
# 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 django.utils.translation import ugettext_lazy as _
from horizon import exceptions
from horizon import tabs
from neutronclient.common import exceptions as neutron_exc
from openstack_dashboard.api import network
from openstack_dashboard.dashboards.project.access_and_security.\
security_groups.tables import SecurityGroupsTable
class SecurityGroupsTab(tabs.TableTab):
table_classes = (SecurityGroupsTable,)
name = _("Security Groups")
slug = "security_groups_tab"
template_name = "horizon/common/_detail_table.html"
permissions = ('openstack.services.compute',)
def get_security_groups_data(self):
try:
security_groups = network.security_group_list(self.request)
except neutron_exc.ConnectionFailed:
security_groups = []
exceptions.handle(self.request)
except Exception:
security_groups = []
exceptions.handle(self.request,
_('Unable to retrieve security groups.'))
return sorted(security_groups, key=lambda group: group.name)
class AccessAndSecurityTabs(tabs.TabGroup):
slug = "access_security_tabs"
tabs = (SecurityGroupsTab,)
sticky = True

View File

@ -1,11 +0,0 @@
{% extends 'base.html' %}
{% load i18n %}
{% block title %}{% trans "Access & Security" %}{% endblock %}
{% block main %}
<div class="row">
<div class="col-sm-12">
{{ tab_group.render }}
</div>
</div>
{% endblock %}

View File

@ -1,8 +0,0 @@
{% extends 'base.html' %}
{% load i18n %}
{% block title %}{% trans "Add Rule" %}{% endblock %}
{% block main %}
{% include 'project/access_and_security/security_groups/_add_rule.html' %}
{% endblock %}

View File

@ -1,7 +0,0 @@
{% extends 'base.html' %}
{% load i18n %}
{% block title %}{% trans "Edit Security Group" %}{% endblock %}
{% block main %}
{% include 'project/access_and_security/security_groups/_update.html' %}
{% endblock %}

View File

@ -1,155 +0,0 @@
# Copyright 2012 United States Government as represented by the
# Administrator of the National Aeronautics and Space Administration.
# All Rights Reserved.
#
# Copyright 2012 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 copy import deepcopy # noqa
from django.core.urlresolvers import reverse
from django import http
from mox3.mox import IsA # noqa
import six
from openstack_dashboard import api
from openstack_dashboard.test import helpers as test
from openstack_dashboard.usage import quotas
INDEX_URL = reverse('horizon:project:access_and_security:index')
class AccessAndSecurityTests(test.TestCase):
def setUp(self):
super(AccessAndSecurityTests, self).setUp()
@test.create_stubs({api.network: ('security_group_list',),
api.base: ('is_service_enabled',),
quotas: ('tenant_quota_usages',)})
def _test_index(self):
sec_groups = self.security_groups.list()
quota_data = self.quota_usages.first()
quota_data['security_groups']['available'] = 10
api.network.security_group_list(IsA(http.HttpRequest)) \
.AndReturn(sec_groups)
quotas.tenant_quota_usages(IsA(http.HttpRequest)).MultipleTimes() \
.AndReturn(quota_data)
api.base.is_service_enabled(IsA(http.HttpRequest), 'network') \
.MultipleTimes().AndReturn(True)
self.mox.ReplayAll()
res = self.client.get(INDEX_URL)
self.assertTemplateUsed(res, 'project/access_and_security/index.html')
# Security groups
sec_groups_from_ctx = res.context['security_groups_table'].data
# Context data needs to contains all items from the test data.
self.assertItemsEqual(sec_groups_from_ctx,
sec_groups)
# Sec groups in context need to be sorted by their ``name`` attribute.
# This assertion is somewhat weak since it's only meaningful as long as
# the sec groups in the test data are *not* sorted by name (which is
# the case as of the time of this addition).
self.assertTrue(
all([sec_groups_from_ctx[i].name <= sec_groups_from_ctx[i + 1].name
for i in range(len(sec_groups_from_ctx) - 1)]))
def test_index(self):
self._test_index()
class SecurityGroupTabTests(test.TestCase):
def setUp(self):
super(SecurityGroupTabTests, self).setUp()
@test.create_stubs({api.network: ('security_group_list',),
quotas: ('tenant_quota_usages',),
api.base: ('is_service_enabled',)})
def test_create_button_attributes(self):
sec_groups = self.security_groups.list()
quota_data = self.quota_usages.first()
quota_data['security_groups']['available'] = 10
api.network.security_group_list(
IsA(http.HttpRequest)) \
.AndReturn(sec_groups)
quotas.tenant_quota_usages(
IsA(http.HttpRequest)).MultipleTimes() \
.AndReturn(quota_data)
api.base.is_service_enabled(
IsA(http.HttpRequest), 'network').MultipleTimes() \
.AndReturn(True)
self.mox.ReplayAll()
res = self.client.get(INDEX_URL +
"?tab=access_security_tabs__security_groups_tab")
security_groups = res.context['security_groups_table'].data
self.assertItemsEqual(security_groups, self.security_groups.list())
create_action = self.getAndAssertTableAction(res, 'security_groups',
'create')
self.assertEqual('Create Security Group',
six.text_type(create_action.verbose_name))
self.assertIsNone(create_action.policy_rules)
self.assertEqual(set(['ajax-modal']), set(create_action.classes))
url = 'horizon:project:access_and_security:security_groups:create'
self.assertEqual(url, create_action.url)
@test.create_stubs({api.network: ('security_group_list',),
quotas: ('tenant_quota_usages',),
api.base: ('is_service_enabled',)})
def _test_create_button_disabled_when_quota_exceeded(self,
network_enabled):
sec_groups = self.security_groups.list()
quota_data = self.quota_usages.first()
quota_data['security_groups']['available'] = 0
api.network.security_group_list(
IsA(http.HttpRequest)) \
.AndReturn(sec_groups)
quotas.tenant_quota_usages(
IsA(http.HttpRequest)).MultipleTimes() \
.AndReturn(quota_data)
api.base.is_service_enabled(
IsA(http.HttpRequest), 'network').MultipleTimes() \
.AndReturn(network_enabled)
self.mox.ReplayAll()
res = self.client.get(INDEX_URL +
"?tab=access_security_tabs__security_groups_tab")
security_groups = res.context['security_groups_table'].data
self.assertItemsEqual(security_groups, self.security_groups.list())
create_action = self.getAndAssertTableAction(res, 'security_groups',
'create')
self.assertIn('disabled', create_action.classes,
'The create button should be disabled')
def test_create_button_disabled_when_quota_exceeded_neutron_disabled(self):
self._test_create_button_disabled_when_quota_exceeded(False)
def test_create_button_disabled_when_quota_exceeded_neutron_enabled(self):
self._test_create_button_disabled_when_quota_exceeded(True)

View File

@ -1,31 +0,0 @@
# Copyright 2012 United States Government as represented by the
# Administrator of the National Aeronautics and Space Administration.
# All Rights Reserved.
#
# Copyright 2012 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 django.conf.urls import include
from django.conf.urls import url
from openstack_dashboard.dashboards.project.access_and_security.\
security_groups import urls as sec_group_urls
from openstack_dashboard.dashboards.project.access_and_security import views
urlpatterns = [
url(r'^$', views.IndexView.as_view(), name='index'),
url(r'security_groups/',
include(sec_group_urls, namespace='security_groups')),
]

View File

@ -1,35 +0,0 @@
# Copyright 2012 United States Government as represented by the
# Administrator of the National Aeronautics and Space Administration.
# All Rights Reserved.
#
# Copyright 2012 Nebula, Inc.
# Copyright 2012 OpenStack Foundation
#
# 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.
"""
Views for Instances and Volumes.
"""
from django.utils.translation import ugettext_lazy as _
from horizon import tabs
from openstack_dashboard.dashboards.project.access_and_security \
import tabs as project_tabs
class IndexView(tabs.TabbedTableView):
tab_group_class = project_tabs.AccessAndSecurityTabs
template_name = 'project/access_and_security/index.html'
page_title = _("Access & Security")

View File

@ -73,7 +73,7 @@ class GroupBase(forms.SelfHandlingForm):
messages.success(request, self.success_message % sg.name)
return sg
except Exception as e:
redirect = reverse("horizon:project:access_and_security:index")
redirect = reverse("horizon:project:security_groups:index")
error_msg = self.error_message % e
exceptions.handle(request, error_msg, redirect=redirect)
@ -407,8 +407,8 @@ class AddRule(forms.SelfHandlingForm):
return cleaned_data
def handle(self, request, data):
redirect = reverse("horizon:project:access_and_security:"
"security_groups:detail", args=[data['id']])
redirect = reverse("horizon:project:security_groups:detail",
args=[data['id']])
try:
rule = api.network.security_group_rule_create(
request,

View File

@ -1,5 +1,4 @@
# Copyright 2012 Nebula, Inc.
# Copyright 2012 OpenStack Foundation
# Copyright 2017 Cisco Systems, 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
@ -14,10 +13,9 @@
# under the License.
from django.utils.translation import ugettext_lazy as _
import horizon
class AccessAndSecurity(horizon.Panel):
name = _("Access & Security")
slug = 'access_and_security'
class SecurityGroups(horizon.Panel):
name = _("Security Groups")
slug = 'security_groups'

View File

@ -65,7 +65,7 @@ class DeleteGroup(policy.PolicyTargetMixin, tables.DeleteAction):
class CreateGroup(tables.LinkAction):
name = "create"
verbose_name = _("Create Security Group")
url = "horizon:project:access_and_security:security_groups:create"
url = "horizon:project:security_groups:create"
classes = ("ajax-modal",)
icon = "plus"
@ -90,7 +90,7 @@ class CreateGroup(tables.LinkAction):
class EditGroup(policy.PolicyTargetMixin, tables.LinkAction):
name = "edit"
verbose_name = _("Edit Security Group")
url = "horizon:project:access_and_security:security_groups:update"
url = "horizon:project:security_groups:update"
classes = ("ajax-modal",)
icon = "pencil"
@ -112,7 +112,7 @@ class EditGroup(policy.PolicyTargetMixin, tables.LinkAction):
class ManageRules(policy.PolicyTargetMixin, tables.LinkAction):
name = "manage_rules"
verbose_name = _("Manage Rules")
url = "horizon:project:access_and_security:security_groups:detail"
url = "horizon:project:security_groups:detail"
icon = "pencil"
def allowed(self, request, security_group=None):
@ -151,7 +151,7 @@ class SecurityGroupsTable(tables.DataTable):
class CreateRule(tables.LinkAction):
name = "add_rule"
verbose_name = _("Add Rule")
url = "horizon:project:access_and_security:security_groups:add_rule"
url = "horizon:project:security_groups:add_rule"
classes = ("ajax-modal",)
icon = "plus"
@ -197,8 +197,7 @@ class DeleteRule(tables.DeleteAction):
def get_success_url(self, request):
sg_id = self.table.kwargs['security_group_id']
return reverse("horizon:project:access_and_security:"
"security_groups:detail", args=[sg_id])
return reverse("horizon:project:security_groups:detail", args=[sg_id])
def get_remote_ip_prefix(rule):

View File

@ -0,0 +1,6 @@
{% extends 'base.html' %}
{% block main %}
{% include 'project/security_groups/_add_rule.html' %}
{% endblock %}

View File

@ -1,6 +1,4 @@
{% extends 'base.html' %}
{% load i18n %}
{% block title %}{% trans "Create Security Group" %}{% endblock %}
{% block main %}
{% include 'project/access_and_security/security_groups/_create.html' %}

View File

@ -1,7 +1,4 @@
{% extends 'base.html' %}
{% load i18n %}
{% block title %}{% trans "Manage Security Group Rules" %}{% endblock %}
{% block page_header %}
{% include "horizon/common/_detail_header.html" %}

View File

@ -0,0 +1,5 @@
{% extends 'base.html' %}
{% block main %}
{% include 'project/security_groups/_update.html' %}
{% endblock %}

View File

@ -17,6 +17,7 @@
# under the License.
import cgi
import six
import django
from django.conf import settings
@ -30,21 +31,20 @@ from horizon import forms
from openstack_dashboard import api
from openstack_dashboard.test import helpers as test
from openstack_dashboard.usage import quotas
from openstack_dashboard.dashboards.project.access_and_security.\
security_groups import tables
from openstack_dashboard.dashboards.project.security_groups import tables
INDEX_URL = reverse('horizon:project:access_and_security:index')
SG_CREATE_URL = reverse('horizon:project:access_and_security:'
'security_groups:create')
INDEX_URL = reverse('horizon:project:security_groups:index')
SG_CREATE_URL = reverse('horizon:project:security_groups:create')
SG_VIEW_PATH = 'horizon:project:access_and_security:security_groups:%s'
SG_VIEW_PATH = 'horizon:project:security_groups:%s'
SG_DETAIL_VIEW = SG_VIEW_PATH % 'detail'
SG_UPDATE_VIEW = SG_VIEW_PATH % 'update'
SG_ADD_RULE_VIEW = SG_VIEW_PATH % 'add_rule'
SG_TEMPLATE_PATH = 'project/access_and_security/security_groups/%s'
SG_TEMPLATE_PATH = 'project/security_groups/%s'
SG_DETAIL_TEMPLATE = SG_TEMPLATE_PATH % 'detail.html'
SG_CREATE_TEMPLATE = SG_TEMPLATE_PATH % 'create.html'
SG_UPDATE_TEMPLATE = SG_TEMPLATE_PATH % '_update.html'
@ -64,6 +64,116 @@ class SecurityGroupsViewTests(test.TestCase):
self.edit_url = reverse(SG_ADD_RULE_VIEW, args=[sec_group.id])
self.update_url = reverse(SG_UPDATE_VIEW, args=[sec_group.id])
@test.create_stubs({api.network: ('security_group_list',),
api.base: ('is_service_enabled',),
quotas: ('tenant_quota_usages',)})
def test_index(self):
sec_groups = self.security_groups.list()
quota_data = self.quota_usages.first()
quota_data['security_groups']['available'] = 10
api.network.security_group_list(IsA(http.HttpRequest)) \
.AndReturn(sec_groups)
quotas.tenant_quota_usages(IsA(http.HttpRequest)).MultipleTimes() \
.AndReturn(quota_data)
api.base.is_service_enabled(IsA(http.HttpRequest), 'network') \
.MultipleTimes().AndReturn(True)
self.mox.ReplayAll()
res = self.client.get(INDEX_URL)
self.assertTemplateUsed(res, 'horizon/common/_data_table_view.html')
# Security groups
sec_groups_from_ctx = res.context['security_groups_table'].data
# Context data needs to contains all items from the test data.
self.assertItemsEqual(sec_groups_from_ctx,
sec_groups)
# Sec groups in context need to be sorted by their ``name`` attribute.
# This assertion is somewhat weak since it's only meaningful as long as
# the sec groups in the test data are *not* sorted by name (which is
# the case as of the time of this addition).
self.assertTrue(
all([sec_groups_from_ctx[i].name <= sec_groups_from_ctx[i + 1].name
for i in range(len(sec_groups_from_ctx) - 1)]))
@test.create_stubs({api.network: ('security_group_list',),
quotas: ('tenant_quota_usages',),
api.base: ('is_service_enabled',)})
def test_create_button_attributes(self):
sec_groups = self.security_groups.list()
quota_data = self.quota_usages.first()
quota_data['security_groups']['available'] = 10
api.network.security_group_list(
IsA(http.HttpRequest)) \
.AndReturn(sec_groups)
quotas.tenant_quota_usages(
IsA(http.HttpRequest)).MultipleTimes() \
.AndReturn(quota_data)
api.base.is_service_enabled(
IsA(http.HttpRequest), 'network').MultipleTimes() \
.AndReturn(True)
self.mox.ReplayAll()
res = self.client.get(INDEX_URL)
security_groups = res.context['security_groups_table'].data
self.assertItemsEqual(security_groups, self.security_groups.list())
create_action = self.getAndAssertTableAction(res, 'security_groups',
'create')
self.assertEqual('Create Security Group',
six.text_type(create_action.verbose_name))
self.assertIsNone(create_action.policy_rules)
self.assertEqual(set(['ajax-modal']), set(create_action.classes))
url = 'horizon:project:security_groups:create'
self.assertEqual(url, create_action.url)
@test.create_stubs({api.network: ('security_group_list',),
quotas: ('tenant_quota_usages',),
api.base: ('is_service_enabled',)})
def _test_create_button_disabled_when_quota_exceeded(self,
network_enabled):
sec_groups = self.security_groups.list()
quota_data = self.quota_usages.first()
quota_data['security_groups']['available'] = 0
api.network.security_group_list(
IsA(http.HttpRequest)) \
.AndReturn(sec_groups)
quotas.tenant_quota_usages(
IsA(http.HttpRequest)).MultipleTimes() \
.AndReturn(quota_data)
api.base.is_service_enabled(
IsA(http.HttpRequest), 'network').MultipleTimes() \
.AndReturn(network_enabled)
self.mox.ReplayAll()
res = self.client.get(INDEX_URL)
security_groups = res.context['security_groups_table'].data
self.assertItemsEqual(security_groups, self.security_groups.list())
create_action = self.getAndAssertTableAction(res, 'security_groups',
'create')
self.assertIn('disabled', create_action.classes,
'The create button should be disabled')
def test_create_button_disabled_when_quota_exceeded_neutron_disabled(self):
self._test_create_button_disabled_when_quota_exceeded(False)
def test_create_button_disabled_when_quota_exceeded_neutron_enabled(self):
self._test_create_button_disabled_when_quota_exceeded(True)
@test.create_stubs({api.network: ('security_group_rule_create',
'security_group_list',
'security_group_backend')})

View File

@ -17,12 +17,10 @@
# under the License.
from django.conf.urls import url
from openstack_dashboard.dashboards.project.access_and_security.\
security_groups import views
from openstack_dashboard.dashboards.project.security_groups import views
urlpatterns = [
url(r'^$', views.IndexView.as_view(), name='index'),
url(r'^create/$', views.CreateView.as_view(), name='create'),
url(r'^(?P<security_group_id>[^/]+)/$',
views.DetailView.as_view(),

View File

@ -28,18 +28,19 @@ from horizon import forms
from horizon import tables
from horizon.utils import memoized
from openstack_dashboard import api
from openstack_dashboard.utils import filters
from neutronclient.common import exceptions as neutron_exc
from openstack_dashboard.dashboards.project.access_and_security.\
security_groups import forms as project_forms
from openstack_dashboard.dashboards.project.access_and_security.\
security_groups import tables as project_tables
from openstack_dashboard import api
from openstack_dashboard.dashboards.project.security_groups \
import forms as project_forms
from openstack_dashboard.dashboards.project.security_groups \
import tables as project_tables
from openstack_dashboard.utils import filters
class DetailView(tables.DataTableView):
table_class = project_tables.RulesTable
template_name = 'project/access_and_security/security_groups/detail.html'
template_name = 'project/security_groups/detail.html'
page_title = _("Manage Security Group Rules: "
"{{ security_group.name }} ({{ security_group.id }})")
@ -49,7 +50,7 @@ class DetailView(tables.DataTableView):
try:
return api.network.security_group_get(self.request, sg_id)
except Exception:
redirect = reverse('horizon:project:access_and_security:index')
redirect = reverse('horizon:project:security_groups:index')
exceptions.handle(self.request,
_('Unable to retrieve security group.'),
redirect=redirect)
@ -71,10 +72,10 @@ class UpdateView(forms.ModalFormView):
form_class = project_forms.UpdateGroup
form_id = "update_security_group_form"
modal_id = "update_security_group_modal"
template_name = 'project/access_and_security/security_groups/update.html'
template_name = 'project/security_groups/update.html'
submit_label = _("Edit Security Group")
submit_url = "horizon:project:access_and_security:security_groups:update"
success_url = reverse_lazy('horizon:project:access_and_security:index')
submit_url = "horizon:project:security_groups:update"
success_url = reverse_lazy('horizon:project:security_groups:index')
page_title = _("Edit Security Group")
@memoized.memoized_method
@ -105,10 +106,10 @@ class AddRuleView(forms.ModalFormView):
form_class = project_forms.AddRule
form_id = "create_security_group_rule_form"
modal_id = "create_security_group_rule_modal"
template_name = 'project/access_and_security/security_groups/add_rule.html'
template_name = 'project/security_groups/add_rule.html'
submit_label = _("Add")
submit_url = "horizon:project:access_and_security:security_groups:add_rule"
url = "horizon:project:access_and_security:security_groups:detail"
submit_url = "horizon:project:security_groups:add_rule"
url = "horizon:project:security_groups:detail"
page_title = _("Add Rule")
def get_success_url(self):
@ -152,9 +153,26 @@ class CreateView(forms.ModalFormView):
form_class = project_forms.CreateGroup
form_id = "create_security_group_form"
modal_id = "create_security_group_modal"
template_name = 'project/access_and_security/security_groups/create.html'
template_name = 'project/security_groups/create.html'
submit_label = _("Create Security Group")
submit_url = reverse_lazy(
"horizon:project:access_and_security:security_groups:create")
success_url = reverse_lazy('horizon:project:access_and_security:index')
"horizon:project:security_groups:create")
success_url = reverse_lazy('horizon:project:security_groups:index')
page_title = _("Create Security Group")
class IndexView(tables.DataTableView):
table_class = project_tables.SecurityGroupsTable
page_title = _("Security Groups")
def get_data(self):
try:
security_groups = api.network.security_group_list(self.request)
except neutron_exc.ConnectionFailed:
security_groups = []
exceptions.handle(self.request)
except Exception:
security_groups = []
exceptions.handle(self.request,
_('Unable to retrieve security groups.'))
return sorted(security_groups, key=lambda group: group.name)

View File

@ -1,10 +0,0 @@
# The slug of the panel to be added to HORIZON_CONFIG. Required.
PANEL = 'access_and_security'
# The slug of the dashboard the PANEL associated with. Required.
PANEL_DASHBOARD = 'project'
# The slug of the panel group the PANEL is associated with.
PANEL_GROUP = 'compute'
# Python panel class of the PANEL to be added.
ADD_PANEL = ('openstack_dashboard.dashboards.project.'
'access_and_security.panel.AccessAndSecurity')

View File

@ -0,0 +1,6 @@
PANEL_DASHBOARD = 'project'
PANEL_GROUP = 'network'
PANEL = 'security_groups'
ADD_PANEL = ('openstack_dashboard.dashboards.project.security_groups'
'.panel.SecurityGroups')

View File

@ -2,4 +2,7 @@
features:
- The Access & Security panel's tabs have been moved to their own panels for
clearer navigation and better performance. API Access and Key Pairs now
reside in the Compute panel group.
reside in the Compute panel group. Floating IPs and Security Groups are
now in the Network panel group.
- Download buttons for OpenStack RC files have been added to the user
dropdown menu in the top right of Horizon.