Added Overview panel

The overview panel is a combination of the former overcloud and plan
panels.

The assumption here is that a plan already exists; different view states
will be determined by whether an associated stack exists.

Future enhancements will include a reorganization of the templates used
and updating the mock data to simulate the creation of a Heat stack

Change-Id: I37d0943d3dbe0cb2849af82a459bc5b2af17881c
This commit is contained in:
Tzu-Mainn Chen 2014-08-05 05:48:50 +02:00
parent 907210928c
commit 222891cf83
32 changed files with 213 additions and 1067 deletions

View File

@ -20,8 +20,7 @@ class BasePanels(horizon.PanelGroup):
slug = "infrastructure"
name = _("Infrastructure")
panels = (
'overcloud',
'plans',
'overview',
'parameters',
'roles',
'nodes',
@ -37,7 +36,7 @@ class Infrastructure(horizon.Dashboard):
panels = (
BasePanels,
)
default_panel = 'overcloud'
default_panel = 'overview'
permissions = ('openstack.roles.admin',)

View File

@ -1,27 +0,0 @@
# -*- coding: utf8 -*-
#
# 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 _
import horizon
from tuskar_ui.infrastructure import dashboard
class Overcloud(horizon.Panel):
name = _("OpenStack Deployment")
slug = "overcloud"
dashboard.Infrastructure.register(Overcloud)

View File

@ -1,35 +0,0 @@
# -*- coding: utf8 -*-
#
# 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 tables
class ConfigurationTable(tables.DataTable):
key = tables.Column(lambda parameter: parameter[0],
verbose_name=_("Attribute Name"))
value = tables.Column(lambda parameter: parameter[1],
verbose_name=_("Attribute Value"))
class Meta:
name = "configuration"
verbose_name = _("Configuration")
multi_select = False
table_actions = ()
row_actions = ()
def get_object_id(self, datum):
return datum[0]

View File

@ -1,161 +0,0 @@
# 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.utils.translation import ugettext_lazy as _
import heatclient
from horizon import tabs
from tuskar_ui.infrastructure.overcloud import tables
from tuskar_ui.utils import utils
def _get_role_data(stack, role):
"""Gathers data about a single deployment role from the related Overcloud
and OvercloudRole objects, and presents it in the form convenient for use
from the template.
:param stack: Stack object
:type stack: tuskar_ui.api.heat.Stack
:param role: Role object
:type role: tuskar_ui.api.tuskar.OvercloudRole
:return: dict with information about the role, to be used by template
:rtype: dict
"""
resources = stack.resources_by_role(role, with_joins=True)
nodes = [r.node for r in resources]
node_count = len(nodes)
data = {
'role': role,
'name': role.name,
'total_node_count': node_count,
}
deployed_node_count = 0
deploying_node_count = 0
error_node_count = 0
waiting_node_count = node_count
if nodes:
deployed_node_count = sum(1 for node in nodes
if node.instance.status == 'ACTIVE')
deploying_node_count = sum(1 for node in nodes
if node.instance.status == 'BUILD')
error_node_count = sum(1 for node in nodes
if node.instance.status == 'ERROR')
waiting_node_count = (node_count - deployed_node_count -
deploying_node_count - error_node_count)
data.update({
'deployed_node_count': deployed_node_count,
'deploying_node_count': deploying_node_count,
'waiting_node_count': waiting_node_count,
'error_node_count': error_node_count,
})
# TODO(rdopieralski) get this from ceilometer
# data['capacity'] = 20
return data
class OverviewTab(tabs.Tab):
name = _("Overview")
slug = "overview"
template_name = "infrastructure/overcloud/_detail_overview.html"
preload = False
def get_context_data(self, request, **kwargs):
stack = self.tab_group.kwargs['stack']
roles = stack.plan.role_list
role_data = [_get_role_data(stack, role) for role in roles]
total = sum(d['total_node_count'] for d in role_data)
progress = 100 * sum(d.get('deployed_node_count', 0)
for d in role_data) // (total or 1)
events = stack.events
last_failed_events = [e for e in events
if e.resource_status == 'CREATE_FAILED'][-3:]
return {
'stack': stack,
'plan': stack.plan,
'roles': role_data,
'progress': max(5, progress),
'dashboard_urls': stack.dashboard_urls,
'last_failed_events': last_failed_events,
}
class UndeployInProgressTab(tabs.Tab):
name = _("Overview")
slug = "undeploy_in_progress_tab"
template_name = "infrastructure/overcloud/_undeploy_in_progress.html"
preload = False
def get_context_data(self, request, **kwargs):
stack = self.tab_group.kwargs['stack']
# TODO(lsmola) since at this point we don't have total number of nodes
# we will hack this around, till API can show this information. So it
# will actually show progress like the total number is 10, or it will
# show progress of 5%. Ugly, but workable.
total_num_nodes_count = 10
try:
resources_count = len(
stack.resources(with_joins=False))
except heatclient.exc.HTTPNotFound:
# Immediately after undeploying has started, heat returns this
# exception so we can take it as kind of init of undeploying.
resources_count = total_num_nodes_count
# TODO(lsmola) same as hack above
total_num_nodes_count = max(resources_count, total_num_nodes_count)
delete_progress = max(
5, 100 * (total_num_nodes_count - resources_count))
events = stack.events
last_failed_events = [e for e in events
if e.resource_status == 'DELETE_FAILED'][-3:]
return {
'stack': stack,
'plan': stack.plan,
'progress': delete_progress,
'last_failed_events': last_failed_events,
}
class ConfigurationTab(tabs.TableTab):
table_classes = (tables.ConfigurationTable,)
name = _("Configuration")
slug = "configuration"
template_name = "horizon/common/_detail_table.html"
preload = False
def get_configuration_data(self):
stack = self.tab_group.kwargs['stack']
return [(utils.de_camel_case(key), value) for key, value in
stack.parameters.items()]
class UndeployInProgressTabs(tabs.TabGroup):
slug = "undeploy_in_progress"
tabs = (UndeployInProgressTab,)
sticky = True
class DetailTabs(tabs.TabGroup):
slug = "detail"
tabs = (OverviewTab, ConfigurationTab,)
sticky = True

View File

@ -1,31 +0,0 @@
# -*- coding: utf8 -*-
#
# 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 import urls
from tuskar_ui.infrastructure.overcloud import views
urlpatterns = urls.patterns(
'',
urls.url(r'^$', views.IndexView.as_view(), name='index'),
urls.url(r'^(?P<stack_id>[^/]+)/undeploy-in-progress$',
views.UndeployInProgressView.as_view(),
name='undeploy_in_progress'),
urls.url(r'^(?P<stack_id>[^/]+)/$', views.DetailView.as_view(),
name='detail'),
urls.url(r'^(?P<stack_id>[^/]+)/undeploy-confirmation$',
views.UndeployConfirmationView.as_view(),
name='undeploy_confirmation'),
)

View File

@ -1,144 +0,0 @@
# -*- coding: utf8 -*-
#
# 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.core.urlresolvers import reverse
from django.utils.translation import ugettext_lazy as _
from django.views.generic import base as base_views
from horizon import exceptions as horizon_exceptions
import horizon.forms
from horizon import messages
from horizon import tabs as horizon_tabs
from horizon.utils import memoized
from tuskar_ui import api
from tuskar_ui.infrastructure.overcloud import forms
from tuskar_ui.infrastructure.overcloud import tabs
INDEX_URL = 'horizon:infrastructure:overcloud:index'
DETAIL_URL = 'horizon:infrastructure:overcloud:detail'
PLAN_CREATE_URL = 'horizon:infrastructure:plans:create'
UNDEPLOY_IN_PROGRESS_URL = (
'horizon:infrastructure:overcloud:undeploy_in_progress')
class StackMixin(object):
@memoized.memoized
def get_stack(self, redirect=None):
if redirect is None:
redirect = reverse(INDEX_URL)
stack_id = self.kwargs['stack_id']
stack = api.heat.Stack.get(self.request, stack_id,
_error_redirect=redirect)
return stack
class IndexView(base_views.RedirectView):
permanent = False
def get_redirect_url(self):
plan = api.tuskar.OvercloudPlan.get_the_plan(self.request)
redirect = reverse(PLAN_CREATE_URL)
if plan is not None:
stacks = api.heat.Stack.list(self.request)
for stack in stacks:
if stack.plan.id == plan.id:
break
else:
stack = None
if stack is not None:
if stack.is_deleting or stack.is_delete_failed:
redirect = reverse(UNDEPLOY_IN_PROGRESS_URL,
args=(stack.id,))
else:
redirect = reverse(DETAIL_URL,
args=(stack.id,))
return redirect
class DetailView(horizon_tabs.TabView, StackMixin):
tab_group_class = tabs.DetailTabs
template_name = 'infrastructure/overcloud/detail.html'
def get_tabs(self, request, **kwargs):
stack = self.get_stack()
return self.tab_group_class(request, stack=stack, **kwargs)
def get_context_data(self, **kwargs):
context = super(DetailView, self).get_context_data(**kwargs)
context['stack'] = self.get_stack()
context['plan'] = self.get_stack().plan
return context
class UndeployConfirmationView(horizon.forms.ModalFormView):
form_class = forms.UndeployOvercloud
template_name = 'infrastructure/overcloud/undeploy_confirmation.html'
def get_success_url(self):
return reverse(INDEX_URL)
def get_context_data(self, **kwargs):
context = super(UndeployConfirmationView,
self).get_context_data(**kwargs)
context['stack_id'] = self.kwargs['stack_id']
return context
def get_initial(self, **kwargs):
initial = super(UndeployConfirmationView, self).get_initial(**kwargs)
initial['stack_id'] = self.kwargs['stack_id']
return initial
class UndeployInProgressView(horizon_tabs.TabView, StackMixin, ):
tab_group_class = tabs.UndeployInProgressTabs
template_name = 'infrastructure/overcloud/detail.html'
def get_stack_or_redirect(self):
plan = api.tuskar.OvercloudPlan.get_the_plan(self.request)
stack = None
if plan is not None:
stack = None
stacks = api.heat.Stack.list(self.request)
for s in stacks:
if s.plan.id == plan.id:
stack = s
break
if stack is None:
redirect = reverse(INDEX_URL)
messages.success(self.request,
_("Undeploying of the Overcloud has finished."))
raise horizon_exceptions.Http302(redirect)
elif stack.is_deleting or stack.is_delete_failed:
return stack
else:
messages.error(self.request,
_("Overcloud is not being undeployed."))
redirect = reverse(DETAIL_URL,
args=(stack.id,))
raise horizon_exceptions.Http302(redirect)
def get_tabs(self, request, **kwargs):
stack = self.get_stack_or_redirect()
return self.tab_group_class(request, stack=stack, **kwargs)
def get_context_data(self, **kwargs):
context = super(UndeployInProgressView,
self).get_context_data(**kwargs)
context['stack'] = self.get_stack_or_redirect()
return context

View File

@ -19,10 +19,9 @@ import horizon
from tuskar_ui.infrastructure import dashboard
class Plans(horizon.Panel):
name = _("Plans")
slug = "plans"
nav = False
class Overview(horizon.Panel):
name = _("Overview")
slug = "overview"
dashboard.Infrastructure.register(Plans)
dashboard.Infrastructure.register(Overview)

View File

@ -3,7 +3,7 @@
{% load url from future %}
{% block form_id %}provision_form{% endblock %}
{% block form_action %}{% url 'horizon:infrastructure:overcloud:undeploy_confirmation' stack_id %}{% endblock %}
{% block form_action %}{% url 'horizon:infrastructure:overview:undeploy_confirmation' %}{% endblock %}
{% block modal_id %}provision_modal{% endblock %}
{% block modal-header %}{% trans "Provisioning Confirmation" %}{% endblock %}
@ -21,5 +21,5 @@
{% block modal-footer %}
<input class="btn btn-primary" type="submit" value="{% trans "Undeploy" %}" />
<a href="{% url 'horizon:infrastructure:overcloud:index' %}" class="btn secondary cancel close">{% trans "Cancel" %}</a>
<a href="{% url 'horizon:infrastructure:overview:index' %}" class="btn secondary cancel close">{% trans "Cancel" %}</a>
{% endblock %}

View File

@ -41,7 +41,7 @@
</div>
{% endfor %}
{% endif %}
<a href="?tab=undeploy_in_progress__log" data-toggle="tab" data-target="#undeploy_in_progress__log" class="pull-right">See full log</a>
<a href="{% url 'horizon:infrastructure:history:index' %}" class="pull-right">See full log</a>
</div>
</div>
</div>

View File

@ -19,14 +19,22 @@
<div class="row">
<div class="col-xs-12">
<div class="actions page-actions pull-right">
<a href="{% url 'horizon:infrastructure:overcloud:undeploy_confirmation' stack.id %}"
<a href="{% url 'horizon:infrastructure:overview:undeploy_confirmation' %}"
class="btn btn-danger ajax-modal
{% if not stack.is_deployed and not stack.is_failed %}disabled{% endif %}">
<i class="icon-fire icon-white"></i>
{% trans "Undeploy" %}
</a>
</div>
{{ tab_group.render }}
{% if stack %}
{% if stack.is_deleting or stack.is_delete_failed %}
{% include "infrastructure/overview/_undeploy_in_progress.html" %}
{% else %}
{% include "infrastructure/overview/_plan_overview.html" %}
{% endif %}
{% else %}
{% include "infrastructure/overview/_role_nodes.html" %}
{% endif %}
</div>
</div>
{% endblock %}

View File

@ -7,5 +7,5 @@
{% endblock page_header %}
{% block infrastructure_main %}
{% include "infrastructure/overcloud/_undeploy_confirmation.html" %}
{% include "infrastructure/overview/_undeploy_confirmation.html" %}
{% endblock %}

View File

@ -20,29 +20,15 @@ from openstack_dashboard.test.test_data import utils
from tuskar_ui import api
from tuskar_ui.test import helpers as test
from tuskar_ui.test.test_data import flavor_data
from tuskar_ui.test.test_data import heat_data
from tuskar_ui.test.test_data import node_data
from tuskar_ui.test.test_data import tuskar_data
INDEX_URL = urlresolvers.reverse(
'horizon:infrastructure:overcloud:index')
DETAIL_URL = urlresolvers.reverse(
'horizon:infrastructure:overcloud:detail', args=('stack-id-1',))
UNDEPLOY_IN_PROGRESS_URL = urlresolvers.reverse(
'horizon:infrastructure:overcloud:undeploy_in_progress',
args=('overcloud',))
DETAIL_URL_CONFIGURATION_TAB = (DETAIL_URL +
"?tab=detail__configuration")
'horizon:infrastructure:overview:index')
DELETE_URL = urlresolvers.reverse(
'horizon:infrastructure:overcloud:undeploy_confirmation',
args=('stack-id-1',))
PLAN_CREATE_URL = urlresolvers.reverse(
'horizon:infrastructure:plans:create')
'horizon:infrastructure:overview:undeploy_confirmation')
TEST_DATA = utils.TestDataContainer()
flavor_data.data(TEST_DATA)
node_data.data(TEST_DATA)
heat_data.data(TEST_DATA)
tuskar_data.data(TEST_DATA)
@ -79,80 +65,42 @@ def _mock_plan(**kwargs):
class OvercloudTests(test.BaseAdminViewTests):
def test_index_overcloud_undeployed_get(self):
with _mock_plan(**{'get_the_plan.side_effect': None,
'get_the_plan.return_value': None}):
res = self.client.get(INDEX_URL)
self.assertRedirectsNoFollow(res, PLAN_CREATE_URL)
def test_index_overcloud_deployed_stack_not_created(self):
def test_index_stack_not_created(self):
with contextlib.nested(
_mock_plan(),
patch('tuskar_ui.api.heat.Stack.is_deployed',
return_value=False),
patch('tuskar_ui.api.heat.Stack.list',
return_value=[]),
):
res = self.client.get(INDEX_URL)
request = api.tuskar.OvercloudPlan.get_the_plan. \
call_args_list[0][0][0]
self.assertListEqual(
api.tuskar.OvercloudPlan.get_the_plan.call_args_list,
[call(request)])
self.assertRedirectsNoFollow(res, DETAIL_URL)
[call(request), call(request)])
self.assertTemplateUsed(
res, 'infrastructure/overview/index.html')
self.assertTemplateUsed(
res, 'infrastructure/overview/_role_nodes.html')
def test_index_overcloud_deployed(self):
with _mock_plan() as OvercloudPlan:
def test_index_stack_deployed(self):
with contextlib.nested(
_mock_plan(),
patch('tuskar_ui.api.heat.Stack.get',
return_value=None),
patch('tuskar_ui.api.heat.Stack.events',
return_value=[]),
) as (OvercloudPlan, stack_get_mock, stack_events_mock):
res = self.client.get(INDEX_URL)
request = OvercloudPlan.get_the_plan.call_args_list[0][0][0]
self.assertListEqual(OvercloudPlan.get_the_plan.call_args_list,
[call(request)])
self.assertRedirectsNoFollow(res, DETAIL_URL)
def test_detail_get(self):
roles = [api.tuskar.OvercloudRole(role)
for role in TEST_DATA.tuskarclient_roles.list()]
with contextlib.nested(
_mock_plan(),
patch('tuskar_ui.api.tuskar.OvercloudRole', **{
'spec_set': ['list'],
'list.return_value': roles,
}),
patch('tuskar_ui.api.heat.Stack.events',
return_value=[]),
):
res = self.client.get(DETAIL_URL)
[call(request), call(request)])
self.assertTemplateUsed(
res, 'infrastructure/overcloud/detail.html')
self.assertTemplateNotUsed(
res, 'horizon/common/_detail_table.html')
res, 'infrastructure/overview/index.html')
self.assertTemplateUsed(
res, 'infrastructure/overcloud/_detail_overview.html')
res, 'infrastructure/overview/_plan_overview.html')
def test_detail_get_configuration_tab(self):
with _mock_plan():
res = self.client.get(DETAIL_URL_CONFIGURATION_TAB)
self.assertTemplateUsed(
res, 'infrastructure/overcloud/detail.html')
self.assertTemplateNotUsed(
res, 'infrastructure/overcloud/_detail_overview.html')
self.assertTemplateUsed(
res, 'horizon/common/_detail_table.html')
def test_delete_get(self):
res = self.client.get(DELETE_URL)
self.assertTemplateUsed(
res, 'infrastructure/overcloud/undeploy_confirmation.html')
def test_delete_post(self):
with _mock_plan():
res = self.client.post(DELETE_URL)
self.assertRedirectsNoFollow(res, INDEX_URL)
def test_undeploy_in_progress(self):
def test_index_stack_undeploy_in_progress(self):
with contextlib.nested(
_mock_plan(),
patch('tuskar_ui.api.heat.Stack.is_deleting',
@ -162,24 +110,19 @@ class OvercloudTests(test.BaseAdminViewTests):
patch('tuskar_ui.api.heat.Stack.events',
return_value=[]),
):
res = self.client.get(UNDEPLOY_IN_PROGRESS_URL)
res = self.client.get(INDEX_URL)
self.assertTemplateUsed(
res, 'infrastructure/overcloud/detail.html')
res, 'infrastructure/overview/index.html')
self.assertTemplateUsed(
res, 'infrastructure/overcloud/_undeploy_in_progress.html')
self.assertTemplateNotUsed(
res, 'horizon/common/_detail_table.html')
res, 'infrastructure/overview/_undeploy_in_progress.html')
def test_undeploy_in_progress_finished(self):
with _mock_plan(**{'get_the_plan.side_effect': None,
'get_the_plan.return_value': None}):
res = self.client.get(UNDEPLOY_IN_PROGRESS_URL)
def test_delete_get(self):
res = self.client.get(DELETE_URL)
self.assertTemplateUsed(
res, 'infrastructure/overview/undeploy_confirmation.html')
self.assertRedirectsNoFollow(res, INDEX_URL)
def test_undeploy_in_progress_invalid(self):
def test_delete_post(self):
with _mock_plan():
res = self.client.get(UNDEPLOY_IN_PROGRESS_URL)
self.assertRedirectsNoFollow(res, DETAIL_URL)
res = self.client.post(DELETE_URL)
self.assertRedirectsNoFollow(res, INDEX_URL)

View File

@ -14,13 +14,13 @@
from django.conf import urls
from tuskar_ui.infrastructure.plans import views
from tuskar_ui.infrastructure.overview import views
urlpatterns = urls.patterns(
'',
urls.url(r'^$', views.IndexView.as_view(), name='index'),
urls.url(r'^create/$', views.CreateView.as_view(), name='create'),
urls.url(r'^(?P<plan_id>[^/]+)/scale$', views.Scale.as_view(),
name='scale'),
urls.url(r'^undeploy-confirmation$',
views.UndeployConfirmationView.as_view(),
name='undeploy_confirmation'),
)

View File

@ -0,0 +1,157 @@
# -*- coding: utf8 -*-
#
# 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.core.urlresolvers import reverse
import heatclient
import horizon.forms
from horizon.utils import memoized
from horizon import views as horizon_views
from tuskar_ui import api
from tuskar_ui.infrastructure.overview import forms
INDEX_URL = 'horizon:infrastructure:overview:index'
def _get_role_data(stack, role):
"""Gathers data about a single deployment role from the related Overcloud
and OvercloudRole objects, and presents it in the form convenient for use
from the template.
:param stack: Stack object
:type stack: tuskar_ui.api.heat.Stack
:param role: Role object
:type role: tuskar_ui.api.tuskar.OvercloudRole
:return: dict with information about the role, to be used by template
:rtype: dict
"""
resources = stack.resources_by_role(role, with_joins=True)
nodes = [r.node for r in resources]
node_count = len(nodes)
data = {
'role': role,
'name': role.name,
'total_node_count': node_count,
}
deployed_node_count = 0
deploying_node_count = 0
error_node_count = 0
waiting_node_count = node_count
if nodes:
deployed_node_count = sum(1 for node in nodes
if node.instance.status == 'ACTIVE')
deploying_node_count = sum(1 for node in nodes
if node.instance.status == 'BUILD')
error_node_count = sum(1 for node in nodes
if node.instance.status == 'ERROR')
waiting_node_count = (node_count - deployed_node_count -
deploying_node_count - error_node_count)
data.update({
'deployed_node_count': deployed_node_count,
'deploying_node_count': deploying_node_count,
'waiting_node_count': waiting_node_count,
'error_node_count': error_node_count,
})
# TODO(rdopieralski) get this from ceilometer
# data['capacity'] = 20
return data
class StackMixin(object):
@memoized.memoized
def get_stack(self, redirect=None):
if redirect is None:
redirect = reverse(INDEX_URL)
plan = api.tuskar.OvercloudPlan.get_the_plan(self.request)
stack = api.heat.Stack.get_by_plan(self.request, plan)
return stack
class IndexView(horizon_views.APIView, StackMixin):
template_name = 'infrastructure/overview/index.html'
def get_data(self, request, context, *args, **kwargs):
plan = api.tuskar.OvercloudPlan.get_the_plan(request)
stack = self.get_stack()
context['plan'] = plan
context['stack'] = stack
if stack:
context['last_failed_events'] = [
e for e in stack.events
if e.resource_status == 'DELETE_FAILED'][-3:]
if stack.is_deleting or stack.is_delete_failed:
# stack is being deleted
# TODO(lsmola) since at this point we don't have total number
# of nodes we will hack this around, till API can show this
# information. So it will actually show progress like the total
# number is 10, or it will show progress of 5%. Ugly, but
# workable.
total_num_nodes_count = 10
try:
resources_count = len(
stack.resources(with_joins=False))
except heatclient.exc.HTTPNotFound:
# Immediately after undeploying has started, heat returns
# this exception so we can take it as kind of init of
# undeploying.
resources_count = total_num_nodes_count
# TODO(lsmola) same as hack above
total_num_nodes_count = max(
resources_count, total_num_nodes_count)
context['progress'] = max(
5, 100 * (total_num_nodes_count - resources_count))
else:
# stack is active
roles = [_get_role_data(stack, role)
for role in stack.plan.role_list]
total = sum(d['total_node_count'] for d in roles)
context['roles'] = roles
context['progress'] = 100 * sum(d.get('deployed_node_count', 0)
for d in roles) // (total or 1)
context['dashboard_urls'] = stack.dashboard_urls
return context
class UndeployConfirmationView(horizon.forms.ModalFormView, StackMixin):
form_class = forms.UndeployOvercloud
template_name = 'infrastructure/overview/undeploy_confirmation.html'
def get_success_url(self):
return reverse(INDEX_URL)
def get_context_data(self, **kwargs):
context = super(UndeployConfirmationView,
self).get_context_data(**kwargs)
context['stack_id'] = self.get_stack().id
return context
def get_initial(self, **kwargs):
initial = super(UndeployConfirmationView, self).get_initial(**kwargs)
initial['stack_id'] = self.get_stack().id
return initial

View File

@ -1,66 +0,0 @@
# -*- coding: utf8 -*-
#
# 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 django.forms
from django.utils.translation import ugettext_lazy as _
import horizon.exceptions
import horizon.forms
import horizon.messages
from openstack_dashboard import api as horizon_api
from tuskar_ui import api
def get_flavor_choices(request):
empty = [('', '----')]
try:
flavors = horizon_api.nova.flavor_list(request, None)
except Exception:
horizon.exceptions.handle(request,
_('Unable to retrieve flavor list.'))
return empty
return empty + [(flavor.id, flavor.name) for flavor in flavors]
class OvercloudRoleForm(horizon.forms.SelfHandlingForm):
id = django.forms.IntegerField(
widget=django.forms.HiddenInput)
name = django.forms.CharField(
label=_("Name"), required=False,
widget=django.forms.TextInput(
attrs={'readonly': 'readonly', 'disabled': 'disabled'}))
description = django.forms.CharField(
label=_("Description"), required=False,
widget=django.forms.Textarea(
attrs={'readonly': 'readonly', 'disabled': 'disabled'}))
image_name = django.forms.CharField(
label=_("Image"), required=False,
widget=django.forms.TextInput(
attrs={'readonly': 'readonly', 'disabled': 'disabled'}))
flavor_id = django.forms.ChoiceField(
label=_("Flavor"), required=False, choices=())
def __init__(self, *args, **kwargs):
super(OvercloudRoleForm, self).__init__(*args, **kwargs)
self.fields['flavor_id'].choices = get_flavor_choices(self.request)
def handle(self, request, context):
try:
role = api.tuskar.OvercloudRole.get(request, context['id'])
role.update(request, flavor_id=context['flavor_id'])
except Exception:
horizon.exceptions.handle(request,
_('Unable to update the role.'))
return False
return True

View File

@ -1,35 +0,0 @@
# -*- coding: utf8 -*-
#
# 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 tables
class ConfigurationTable(tables.DataTable):
key = tables.Column(lambda parameter: parameter[0],
verbose_name=_("Attribute Name"))
value = tables.Column(lambda parameter: parameter[1],
verbose_name=_("Attribute Value"))
class Meta:
name = "configuration"
verbose_name = _("Configuration")
multi_select = False
table_actions = ()
row_actions = ()
def get_object_id(self, datum):
return datum[0]

View File

@ -1,9 +0,0 @@
{% load i18n %}
<noscript><h3>{{ step }}</h3></noscript>
<p>{{ step.get_help_text }}</p>
<div class="row">
<div class="col-xs-12">
{% include 'horizon/common/_form_fields.html' with form=form %}
</div>
</div>

View File

@ -1,16 +0,0 @@
{% load i18n %}
{% load url from future%}
<noscript><h3>{{ step }}</h3></noscript>
<div class="widget muted">{{ step.get_help_text }}</div>
<div class="row">
<div class="col-xs-8">
<h2>{% trans "Roles" %}</h2>
<div class="widget">
<td class="actions">
{% include "horizon/common/_form_fields.html" %}
</td>
</div>
</div>
</div>

View File

@ -1,5 +0,0 @@
{% load i18n %}
<noscript><h3>{{ step }}</h3></noscript>
{% include 'infrastructure/overcloud/node_counts.html' with form=form show_change=True %}

View File

@ -1,65 +0,0 @@
# -*- coding: utf8 -*-
#
# 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 contextlib
from django.core import urlresolvers
from mock import patch, call # noqa
from openstack_dashboard.test.test_data import utils
from tuskar_ui import api
from tuskar_ui.test import helpers as test
from tuskar_ui.test.test_data import flavor_data
from tuskar_ui.test.test_data import heat_data
from tuskar_ui.test.test_data import node_data
from tuskar_ui.test.test_data import tuskar_data
INDEX_URL = urlresolvers.reverse(
'horizon:infrastructure:plans:index')
CREATE_URL = urlresolvers.reverse(
'horizon:infrastructure:plans:create')
OVERCLOUD_INDEX_URL = urlresolvers.reverse(
'horizon:infrastructure:overcloud:index')
TEST_DATA = utils.TestDataContainer()
flavor_data.data(TEST_DATA)
node_data.data(TEST_DATA)
heat_data.data(TEST_DATA)
tuskar_data.data(TEST_DATA)
class OvercloudTests(test.BaseAdminViewTests):
def test_index_no_plan_get(self):
with contextlib.nested(
patch('tuskar_ui.api.tuskar.OvercloudPlan.get_the_plan',
return_value=None),
):
res = self.client.get(INDEX_URL)
self.assertRedirectsNoFollow(res, CREATE_URL)
def test_index_with_plan_get(self):
plan = api.tuskar.OvercloudPlan(TEST_DATA.tuskarclient_plans.first())
with contextlib.nested(
patch('tuskar_ui.api.tuskar.OvercloudPlan.get_the_plan',
return_value=plan),
):
res = self.client.get(INDEX_URL)
request = api.tuskar.OvercloudPlan.get_the_plan. \
call_args_list[0][0][0]
self.assertListEqual(
api.tuskar.OvercloudPlan.get_the_plan.call_args_list,
[call(request)])
self.assertRedirectsNoFollow(res, OVERCLOUD_INDEX_URL)

View File

@ -1,91 +0,0 @@
# -*- coding: utf8 -*-
#
# 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.core.urlresolvers import reverse
from django.views.generic import base as base_views
from horizon.utils import memoized
import horizon.workflows
from tuskar_ui import api
from tuskar_ui.infrastructure.plans.workflows import create
from tuskar_ui.infrastructure.plans.workflows import scale
INDEX_URL = 'horizon:infrastructure:plans:index'
CREATE_URL = 'horizon:infrastructure:plans:create'
OVERCLOUD_INDEX_URL = 'horizon:infrastructure:overcloud:index'
class OvercloudPlanMixin(object):
@memoized.memoized
def get_plan(self, redirect=None):
if redirect is None:
redirect = reverse(INDEX_URL)
plan_id = self.kwargs['plan_id']
plan = api.tuskar.OvercloudPlan.get(self.request, plan_id,
_error_redirect=redirect)
return plan
class OvercloudRoleMixin(object):
@memoized.memoized
def get_role(self, redirect=None):
role_id = self.kwargs['role_id']
role = api.tuskar.OvercloudRole.get(self.request, role_id,
_error_redirect=redirect)
return role
class IndexView(base_views.RedirectView):
permanent = False
def get_redirect_url(self):
plan = api.tuskar.OvercloudPlan.get_the_plan(self.request)
if plan is None:
redirect = reverse(CREATE_URL)
else:
redirect = reverse(OVERCLOUD_INDEX_URL)
return redirect
class CreateView(horizon.workflows.WorkflowView):
workflow_class = create.Workflow
template_name = 'infrastructure/_fullscreen_workflow_base.html'
class Scale(horizon.workflows.WorkflowView, OvercloudPlanMixin):
workflow_class = scale.Workflow
def get_context_data(self, **kwargs):
context = super(Scale, self).get_context_data(**kwargs)
context['plan_id'] = self.kwargs['plan_id']
return context
def get_initial(self):
plan = self.get_plan()
overcloud_roles = dict((overcloud_role.id, overcloud_role)
for overcloud_role in
api.tuskar.OvercloudRole.list(self.request))
role_counts = dict((
(count['overcloud_role_id'],
overcloud_roles[count['overcloud_role_id']].flavor_id),
count['num_nodes'],
) for count in plan.counts)
return {
'plan_id': plan.id,
'role_counts': role_counts,
}

View File

@ -1,51 +0,0 @@
# -*- coding: utf8 -*-
#
# 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
import django.forms
from django.utils.translation import ugettext_lazy as _
import horizon.workflows
from tuskar_ui import api
from tuskar_ui.infrastructure.plans.workflows import create_configuration
from tuskar_ui.infrastructure.plans.workflows import create_overview
LOG = logging.getLogger(__name__)
class Workflow(horizon.workflows.Workflow):
slug = 'create_plan'
name = _("My OpenStack Deployment Plan")
default_steps = (
create_overview.Step,
create_configuration.Step,
)
finalize_button_name = _("Deploy")
success_message = _("OpenStack deployment launched")
success_url = 'horizon:infrastructure:overcloud:index'
def handle(self, request, context):
try:
api.tuskar.OvercloudPlan.create(
self.request, 'overcloud', 'overcloud')
except Exception as e:
# Showing error in both workflow tabs, because from the exception
# type we can't recognize where it should show
msg = unicode(e)
self.add_error_to_step(msg, 'create_overview')
self.add_error_to_step(msg, 'create_configuration')
LOG.exception('Error creating overcloud plan')
raise django.forms.ValidationError(msg)
return True

View File

@ -1,89 +0,0 @@
# -*- coding: utf8 -*-
#
# 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 django.forms
from django.utils.translation import ugettext_lazy as _
import horizon.workflows
from openstack_dashboard.api import neutron
from tuskar_ui.utils import utils
def make_field(name, Type, NoEcho, Default, Description, AllowedValues=None,
**kwargs):
"""Create a form field using the parameters from a Heat template."""
label = utils.de_camel_case(name)
Widget = django.forms.TextInput
attrs = {}
widget_kwargs = {}
if Default == 'unset':
attrs['placeholder'] = _("auto-generate")
if Type == 'Json':
# TODO(lsmola) this should eventually be a textarea
Field = django.forms.CharField
else:
# TODO(lsmola) we should use Horizon code for generating of the form.
# There it should have list of all supported types according to Heat
# specification.
Field = django.forms.CharField
if NoEcho == 'true':
Widget = django.forms.PasswordInput
widget_kwargs['render_value'] = True
if AllowedValues is not None:
return django.forms.ChoiceField(initial=Default, choices=[
(value, value) for value in AllowedValues
], help_text=Description, required=False, label=label)
return Field(widget=Widget(attrs=attrs, **widget_kwargs), initial=Default,
help_text=Description, required=False, label=label)
class Action(horizon.workflows.Action):
class Meta:
slug = 'deployed_configuration'
name = _("Configuration")
def __init__(self, request, *args, **kwargs):
super(Action, self).__init__(request, *args, **kwargs)
params = []
params.sort()
for name, data in params:
# workaround for this parameter, which needs a preset taken from
# neutron
if name == 'NeutronControlPlaneID':
networks = neutron.network_list(request)
for network in networks:
if network.name == 'ctlplane':
data['Default'] = network.id
break
self.fields[name] = make_field(name, **data)
def clean(self):
# this is a workaround for a single parameter
if 'GlanceLogFile' in self.cleaned_data:
if not self.cleaned_data['GlanceLogFile']:
self.cleaned_data['GlanceLogFile'] = u"''"
return self.cleaned_data
class Step(horizon.workflows.Step):
action_class = Action
contributes = ('configuration',)
template_name = 'infrastructure/plans/create_configuration.html'
def contribute(self, data, context):
context['configuration'] = data
return context

View File

@ -1,53 +0,0 @@
# -*- coding: utf8 -*-
#
# 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 forms
import horizon.workflows
from tuskar_ui import api
class Action(horizon.workflows.Action):
role_ids = forms.MultipleChoiceField(
label=_("Roles"),
required=True,
widget=forms.CheckboxSelectMultiple(),
help_text=_("Select roles for this plan."))
class Meta:
slug = 'create_overview'
name = _("Overview")
def __init__(self, *args, **kwargs):
super(Action, self).__init__(*args, **kwargs)
role_ids_choices = []
roles = api.tuskar.OvercloudRole.list(self.request)
for r in roles:
role_ids_choices.append((r.id, r.name))
self.fields['role_ids'].choices = sorted(
role_ids_choices)
class Step(horizon.workflows.Step):
action_class = Action
contributes = ('role_ids',)
template_name = 'infrastructure/plans/create_overview.html'
help_text = _("Nothing deployed yet. Design your first deployment.")
def contribute(self, data, context):
context = super(Step, self).contribute(data, context)
return context

View File

@ -1,47 +0,0 @@
# -*- coding: utf8 -*-
#
# 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.core.urlresolvers import reverse
from django.utils.translation import ugettext_lazy as _
from horizon import exceptions
import horizon.workflows
from tuskar_ui import api
from tuskar_ui.infrastructure.plans.workflows import scale_node_counts
class Workflow(horizon.workflows.Workflow):
slug = 'scale_overcloud'
name = _("Scale Deployment")
default_steps = (
scale_node_counts.Step,
)
finalize_button_name = _("Apply Changes")
def handle(self, request, context):
plan_id = context['plan_id']
try:
# TODO(lsmola) when updates are fixed in Heat, figure out whether
# we need to send also parameters, right now we send {}
api.tuskar.OvercloudPlan.update(request, plan_id,
context['role_counts'], {})
except Exception:
exceptions.handle(request, _('Unable to update deployment.'))
return False
return True
def get_success_url(self):
plan_id = self.context.get('plan_id', 1)
return reverse('horizon:infrastructure:overcloud:detail',
args=(plan_id,))

View File

@ -1,35 +0,0 @@
# -*- coding: utf8 -*-
#
# 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 tuskar_ui.infrastructure.plans.workflows import create_overview
class Action(create_overview.Action):
class Meta:
slug = 'scale_node_counts'
name = _("Node Counts")
class Step(create_overview.Step):
action_class = Action
contributes = ('role_counts', 'plan_id')
template_name = 'infrastructure/plans/scale_node_counts.html'
def prepare_action_context(self, request, context):
for (role_id, flavor_id), count in context['role_counts'].items():
name = 'count__%s__%s' % (role_id, flavor_id)
context[name] = count
return context