Update the deployment design page

* Real free node count.
* Role list taken from API, not hardcoded.
* Dialog for associating roles with hardware profiles.

TODO:
* Get hardware profiles list.
* Actually update the role.flavor_id.
* Update the free node count with JavaScript.

Change-Id: Ieeb100f25ec40ab8bc260ea542f098019d5cd04b
Implements: blueprint tripleo-ui-deployment-design
This commit is contained in:
Radomir Dopieralski 2014-02-13 02:14:36 -05:00
parent 1fc6db3187
commit 02e71c472e
12 changed files with 304 additions and 155 deletions

View File

@ -114,11 +114,10 @@ class Overcloud(base.APIResourceWrapper):
# TODO(lsmola) for now we have to transform the sizing to simpler
# format, till API will accept the more complex with flavors,
# then we delete this
transformed_sizing = []
for role_flavor_list, sizing in overcloud_sizing.items():
transformed_sizing.append(
{'overcloud_role_id': role_flavor_list[0],
'num_nodes': sizing})
transformed_sizing = [{
'overcloud_role_id': role,
'num_nodes': sizing,
} for (role, flavor), sizing in overcloud_sizing.items()]
overcloud = tuskarclient(request).overclouds.create(
name='overcloud', description="Openstack cloud providing VMs",

View File

@ -1,4 +1,4 @@
# vim: tabstop=4 shiftwidth=4 softtabstop=4
# -*- 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
@ -12,23 +12,54 @@
# License for the specific language governing permissions and limitations
# under the License.
from django.utils.translation import ugettext_lazy as _ # noqa
from horizon import exceptions
from horizon import forms
from horizon import messages
import django.forms
from django.utils.translation import ugettext_lazy as _
import horizon.exceptions
import horizon.forms
import horizon.messages
from tuskar_ui import api
class UndeployOvercloud(forms.SelfHandlingForm):
class UndeployOvercloud(horizon.forms.SelfHandlingForm):
def handle(self, request, data):
try:
api.Overcloud.delete(request, self.initial['overcloud_id'])
except Exception:
exceptions.handle(request, _("Unable to undeploy overcloud."))
horizon.exceptions.handle(request,
_("Unable to undeploy overcloud."))
return False
else:
msg = _('Undeployment in progress.')
messages.success(request, msg)
horizon.messages.success(request, msg)
return True
# TODO(rdopieralski) Get the list of flavors
def get_flavors():
yield (None, '----')
yield ('xxx', 'Some Hardware Profile')
yield ('yyy', 'Other Hardware Profile')
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=_("Node Profile"), required=False, choices=get_flavors())
def handle(self, request, context):
# TODO(rdopieralski) Associate the flavor with the role
return True

View File

@ -0,0 +1,15 @@
{% extends "horizon/common/_modal_form.html" %}
{% load i18n %}
{% load url from future %}
{% block form_id %}role_edit_form{% endblock %}
{% block modal_id %}role_edit_modal{% endblock %}
{% block modal-header %}{% trans "Edit Deployment Role" %}{% endblock %}
{% block form_action %}{% url 'horizon:infrastructure:overcloud:role_edit' form.id.value %}{% endblock %}
{% block modal-footer %}
<input class="btn btn-primary pull-right" type="submit"
value="{% trans "Apply Changes" %}" />
<a href="{% url 'horizon:infrastructure:overcloud:create' %}"
class="btn secondary cancel close">{% trans "Cancel" %}</a>
{% endblock %}

View File

@ -16,12 +16,29 @@
<tr>
{% if forloop.first %}
<td rowspan="{{ fields|length }}">
<i class="icon-pencil"></i>
<a
href="{% url 'horizon:infrastructure:overcloud:role_edit' role_id %}"
class="ajax-modal"
><i class="icon-pencil"></i></a>
{{ label }}
</td>
{% endif %}
<td>{{ field.label }}</td>
<td>{{ field }}</td>
<td>
{% if field.field.label %}
{{ field.label }}
{% else %}
(<a
href="{% url 'horizon:infrastructure:overcloud:role_edit' role_id %}"
class="ajax-modal"
>{% trans "Add a node profile" %}</a>)
{% endif %}
</td>
<td>
{{ field }}
{% for error in field.errors %}
<span class="help-inline">{{ error }}</span>
{% endfor %}
</td>
{% if show_change %}
<td class="changed"><span class="muted">no change</span></td>
{% endif %}

View File

@ -0,0 +1,11 @@
{% extends "base.html" %}
{% load i18n %}
{% block title %}{% trans "Edit Deployment Role" %}{% endblock %}
{% block page_header %}
{% include "horizon/common/_page_header.html" with title=_("Edit Deployment Role") %}
{% endblock %}
{% block main %}
{% include "infrastructure/overcloud/_role_edit.html" %}
{% endblock %}

View File

@ -1,11 +1,13 @@
{% load i18n %}
{% load url from future%}
<noscript><h3>{{ step }}</h3></noscript>
<div class="widget muted">{{ step.get_help_text }}</div>
<div class="row-fluid">
<div class="span8">
<div class="pull-right">
{{ free_nodes|default:0 }} {% trans "free nodes" %}
<span id="free-nodes">{{ step.get_free_nodes }}</span> {% trans "free nodes" %}
</div>
<h2>{% trans "Roles" %}</h2>
<div class="widget">

View File

@ -98,10 +98,13 @@ class OvercloudTests(test.BaseAdminViewTests):
def test_create_get(self):
roles = TEST_DATA.tuskarclient_overcloud_roles.list()
with patch('tuskar_ui.api.OvercloudRole', **{
with contextlib.nested(patch('tuskar_ui.api.OvercloudRole', **{
'spec_set': ['list'],
'list.side_effect': lambda request: roles,
}):
'list.return_value': roles,
}), patch('tuskar_ui.api.Node', **{
'spec_set': ['list'],
'list.return_value': [],
})):
res = self.client.get(CREATE_URL)
self.assertTemplateUsed(
res, 'infrastructure/_fullscreen_workflow_base.html')
@ -109,7 +112,7 @@ class OvercloudTests(test.BaseAdminViewTests):
res, 'infrastructure/overcloud/undeployed_overview.html')
def test_create_post(self):
oc = api.Overcloud(TEST_DATA.tuskarclient_overclouds.first())
oc = None
roles = TEST_DATA.tuskarclient_overcloud_roles.list()
data = {
'count__1__default': '1',
@ -117,14 +120,17 @@ class OvercloudTests(test.BaseAdminViewTests):
'count__3__default': '0',
'count__4__default': '0',
}
with patch('tuskar_ui.api.OvercloudRole', **{
with contextlib.nested(
patch('tuskar_ui.api.OvercloudRole', **{
'spec_set': ['list'],
'list.side_effect': lambda request: roles,
}):
with patch('tuskar_ui.api.Overcloud', **{
}),
patch('tuskar_ui.api.Overcloud', **{
'spec_set': ['create'],
'create.return_value': oc,
}) as Overcloud:
}),
) as (OvercloudRole, Overcloud):
oc = Overcloud
res = self.client.post(CREATE_URL, data)
request = Overcloud.create.call_args_list[0][0][0]
self.assertListEqual(
@ -192,7 +198,7 @@ class OvercloudTests(test.BaseAdminViewTests):
'stack_events': [],
}), patch('tuskar_ui.api.OvercloudRole', **{
'spec_set': ['list'],
'list.side_effect': lambda request: roles,
'list.return_value': roles,
})) as (Overcloud, OvercloudRole):
oc = Overcloud
res = self.client.get(DETAIL_URL)
@ -285,3 +291,33 @@ class OvercloudTests(test.BaseAdminViewTests):
# }),
# ])
self.assertRedirectsNoFollow(res, DETAIL_URL)
def test_role_edit_get(self):
role = TEST_DATA.tuskarclient_overcloud_roles.first()
url = urlresolvers.reverse(
'horizon:infrastructure:overcloud:role_edit', args=(role.id,))
with patch('tuskar_ui.api.OvercloudRole', **{
'spec_set': ['get'],
'get.return_value': role,
}):
res = self.client.get(url)
self.assertTemplateUsed(
res, 'infrastructure/overcloud/role_edit.html')
self.assertTemplateUsed(
res, 'infrastructure/overcloud/_role_edit.html')
def test_role_edit_post(self):
role = TEST_DATA.tuskarclient_overcloud_roles.first()
url = urlresolvers.reverse(
'horizon:infrastructure:overcloud:role_edit', args=(role.id,))
data = {
'id': '1',
'flavor_id': 'xxx',
}
with patch('tuskar_ui.api.OvercloudRole', **{
'spec_set': ['get'],
'get.return_value': role,
}):
# TODO(rdopieralski) Check if the role got associated with flavor.
res = self.client.post(url, data)
self.assertRedirectsNoFollow(res, CREATE_URL)

View File

@ -12,25 +12,24 @@
# License for the specific language governing permissions and limitations
# under the License.
from django.conf.urls import defaults
from django.conf import urls
from tuskar_ui.infrastructure.overcloud import views
urlpatterns = defaults.patterns(
urlpatterns = urls.patterns(
'',
defaults.url(r'^$', views.IndexView.as_view(), name='index'),
defaults.url(r'^create/$', views.CreateView.as_view(),
name='create'),
defaults.url(r'^(?P<overcloud_id>[^/]+)/$',
views.DetailView.as_view(), name='detail'),
defaults.url(r'^(?P<overcloud_id>[^/]+)/scale$',
views.Scale.as_view(), name='scale'),
defaults.url(r'^(?P<overcloud_id>[^/]+)/role/'
'(?P<role_id>[^/]+)$',
views.OvercloudRoleView.as_view(),
name='role'),
defaults.url(r'^(?P<overcloud_id>[^/]+)/undeploy-confirmation$',
urls.url(r'^$', views.IndexView.as_view(), name='index'),
urls.url(r'^create/$', views.CreateView.as_view(), name='create'),
urls.url(r'^create/role-edit/(?P<role_id>[^/]+)$',
views.OvercloudRoleEdit.as_view(), name='role_edit'),
urls.url(r'^(?P<overcloud_id>[^/]+)/$', views.DetailView.as_view(),
name='detail'),
urls.url(r'^(?P<overcloud_id>[^/]+)/scale$', views.Scale.as_view(),
name='scale'),
urls.url(r'^(?P<overcloud_id>[^/]+)/role/(?P<role_id>[^/]+)$',
views.OvercloudRoleView.as_view(), name='role'),
urls.url(r'^(?P<overcloud_id>[^/]+)/undeploy-confirmation$',
views.UndeployConfirmationView.as_view(),
name='undeploy_confirmation'),
)

View File

@ -17,6 +17,7 @@ from django.utils.translation import ugettext_lazy as _
from django.views.generic import base as base_views
from horizon import exceptions
import horizon.forms
from horizon import tables as horizon_tables
from horizon import tabs as horizon_tabs
from horizon.utils import memoized
@ -48,6 +49,18 @@ class OvercloudMixin(object):
return overcloud
class OvercloudRoleMixin(object):
@memoized.memoized
def get_role(self, redirect=None):
role_id = self.kwargs['role_id']
try:
role = api.OvercloudRole.get(self.request, role_id)
except Exception:
msg = _("Unable to retrieve overcloud role.")
exceptions.handle(self.request, msg, redirect=redirect)
return role
class IndexView(base_views.RedirectView):
permanent = False
@ -125,56 +138,49 @@ class Scale(horizon.workflows.WorkflowView, OvercloudMixin):
}
class OvercloudRoleView(horizon_tables.DataTableView):
class OvercloudRoleView(horizon_tables.DataTableView,
OvercloudRoleMixin, OvercloudMixin):
table_class = tables.OvercloudRoleNodeTable
template_name = 'infrastructure/overcloud/overcloud_role.html'
def get_data(self):
overcloud = self._get_overcloud()
role = self._get_role(overcloud)
return self._get_nodes(overcloud, role)
def get_context_data(self, **kwargs):
context = super(OvercloudRoleView, self).get_context_data(**kwargs)
overcloud = self._get_overcloud()
role = self._get_role(overcloud)
context['role'] = role
context['image_name'] = role.image_name
context['nodes'] = self._get_nodes(overcloud, role)
return context
@memoized.memoized
def _get_nodes(self, overcloud, role):
resources = overcloud.resources(role, with_joins=True)
return [r.node for r in resources]
@memoized.memoized
def _get_overcloud(self):
overcloud_id = self.kwargs['overcloud_id']
try:
overcloud = api.Overcloud.get(self.request, overcloud_id)
except Exception:
msg = _("Unable to retrieve deployment.")
redirect = reverse('horizon:infrastructure:overcloud:index')
exceptions.handle(self.request, msg, redirect=redirect)
return overcloud
@memoized.memoized
def _get_role(self, overcloud):
role_id = self.kwargs['role_id']
try:
role = api.OvercloudRole.get(self.request, role_id)
except Exception:
msg = _("Unable to retrieve overcloud role.")
def get_data(self):
overcloud = self.get_overcloud()
redirect = reverse('horizon:infrastructure:overcloud:detail',
args=(overcloud.id,))
exceptions.handle(self.request, msg, redirect=redirect)
role = self.get_role(redirect)
return self._get_nodes(overcloud, role)
return role
def get_context_data(self, **kwargs):
context = super(OvercloudRoleView, self).get_context_data(**kwargs)
overcloud = self.get_overcloud()
redirect = reverse('horizon:infrastructure:overcloud:detail',
args=(overcloud.id,))
role = self.get_role(redirect)
context['role'] = role
context['image_name'] = role.image_name
context['nodes'] = self._get_nodes(overcloud, role)
return context
class OvercloudRoleEdit(horizon.forms.ModalFormView, OvercloudRoleMixin):
form_class = forms.OvercloudRoleForm
template_name = 'infrastructure/overcloud/role_edit.html'
def get_success_url(self):
return reverse('horizon:infrastructure:overcloud:create')
def get_initial(self):
role = self.get_role()
return {
'id': role.id,
'name': role.name,
'description': role.description,
'image_name': role.image_name,
'flavor_id': role.flavor_id,
}

View File

@ -17,7 +17,7 @@ from django.utils.translation import ugettext_lazy as _
from horizon import exceptions
import horizon.workflows
from tuskar_ui import api
# from tuskar_ui import api
from tuskar_ui.infrastructure.overcloud.workflows import scale_node_counts
@ -30,19 +30,18 @@ class Workflow(horizon.workflows.Workflow):
finalize_button_name = _("Apply Changes")
def handle(self, request, context):
success = True
overcloud_id = self.context['overcloud_id']
# overcloud_id = self.context['overcloud_id']
try:
# TODO(rdopieralski) Actually update it when possible.
overcloud = api.Overcloud.get(request, overcloud_id) # noqa
# overcloud = api.Overcloud.get(request, overcloud_id)
# overcloud.update(self.request, context['role_counts'])
pass
except Exception:
success = False
exceptions.handle(request, _('Unable to update deployment.'))
return success
return False
return True
def get_success_url(self):
overcloud_id = self.context.get('overcloud_id')
overcloud_id = self.context.get('overcloud_id', 1)
return reverse('horizon:infrastructure:overcloud:detail',
args=(overcloud_id,))

View File

@ -21,6 +21,19 @@ from tuskar_ui import api
import tuskar_ui.forms
def get_role_id_and_profile_id_from_field_name(field_name):
"""Extract the ids of overcloud role and node profile from the field
name.
"""
_count, role_id, profile_id = field_name.split('__', 2)
return role_id, profile_id
def get_field_name_from_role_id_and_profile_id(role_id, profile_id=''):
"""Compose the ids of overcloud role and node profile into a field name."""
return 'count__%s__%s' % (role_id, profile_id)
class Action(horizon.workflows.Action):
class Meta:
slug = 'undeployed_overview'
@ -29,41 +42,60 @@ class Action(horizon.workflows.Action):
def __init__(self, *args, **kwargs):
super(Action, self).__init__(*args, **kwargs)
for role in self._get_roles():
# TODO(rdopieralski) Get a list of hardware profiles for each
# role here.
name = 'count__%s__%s' % (str(role.id), 'default')
if role.name == 'Controller':
initial = 1
self.fields[name] = django.forms.IntegerField(
label=_("Default"), initial=initial, min_value=initial,
widget=tuskar_ui.forms.NumberPickerInput(attrs={
'readonly': 'readonly',
}))
attrs = {'readonly': 'readonly'}
else:
initial = 0
attrs = {}
# TODO(rdopieralski) Get a list of hardware profiles for each
# role here.
profiles = [(_("Default"), 'default')]
if not profiles:
name = get_field_name_from_role_id_and_profile_id(str(role.id))
attrs = {'readonly': 'readonly'}
self.fields[name] = django.forms.IntegerField(
label=_("Default"), initial=initial, min_value=initial,
widget=tuskar_ui.forms.NumberPickerInput)
label='', initial=initial, min_value=initial,
widget=tuskar_ui.forms.NumberPickerInput(attrs=attrs))
for label, profile in profiles:
name = get_field_name_from_role_id_and_profile_id(
str(role.id), profile)
self.fields[name] = django.forms.IntegerField(
label=label, initial=initial, min_value=initial,
widget=tuskar_ui.forms.NumberPickerInput(attrs=attrs))
def roles_fieldset(self):
"""Iterates over lists of fields for each role."""
for role in self._get_roles():
yield (
role.id,
role.name,
list(tuskar_ui.forms.fieldset(
self, prefix='count__%s__' % str(role.id))),
self, prefix=get_field_name_from_role_id_and_profile_id(
str(role.id)))),
)
@memoized.memoized
def _get_roles(self):
"""Retrieve the list of all overcloud roles."""
return api.OvercloudRole.list(self.request)
def clean(self):
for key, value in self.cleaned_data.iteritems():
if not key.startswith('count_'):
continue
role_id, profile = get_role_id_and_profile_id_from_field_name(key)
if int(value) and not profile:
raise django.forms.ValidationError(
_("Can't deploy nodes without a node profile assigned."))
return self.cleaned_data
def handle(self, request, context):
counts = {}
for key, value in self.cleaned_data.iteritems():
if not key.startswith('count_'):
continue
_count, role_id, profile = key.split('__', 2)
count, role_id, profile = key.split('__', 2)
counts[role_id, profile] = int(value)
context['role_counts'] = counts
return context
@ -75,7 +107,6 @@ class Step(horizon.workflows.Step):
template_name = 'infrastructure/overcloud/undeployed_overview.html'
help_text = _("Nothing deployed yet. Design your first deployment.")
def get_context_data(self, *args, **kwargs):
context = super(Step, self).get_context_data(*args, **kwargs)
context['free_nodes'] = 3
return context
def get_free_nodes(self):
"""Get the count of nodes that are not assigned yet."""
return len(api.Node.list(self.workflow.request, False))

View File

@ -360,10 +360,13 @@ def data(TEST):
TEST.tuskarclient_overcloud_roles = test_data_utils.TestDataContainer()
r_1 = overcloud_roles.OvercloudRole(
overcloud_roles.OvercloudRoleManager(None),
{'id': 1,
{
'id': 1,
'name': 'Controller',
'description': 'controller overcloud role',
'image_name': 'overcloud-control'})
'image_name': 'overcloud-control',
'flavor_id': None,
})
r_2 = overcloud_roles.OvercloudRole(
overcloud_roles.OvercloudRoleManager(None),
{'id': 2,