A dialog for registering nodes

A dialog for registering new nodes, as described by the wireframe.

Implements: blueprint tripleo-node-registration-widget
Change-Id: I6c4566677a3ba5c2de0c6994d30133e1fdd5a60d
This commit is contained in:
Radomir Dopieralski 2013-12-06 15:59:50 +01:00
parent 7712bc059f
commit 378c296f85
15 changed files with 628 additions and 7 deletions

View File

@ -25,8 +25,8 @@ class MACField(forms.fields.Field):
def clean(self, value):
try:
return str(netaddr.EUI(
value, version=48, dialect=netaddr.mac_unix)).upper()
except netaddr.AddrFormatError:
value.strip(), version=48, dialect=netaddr.mac_unix)).upper()
except (netaddr.AddrFormatError, TypeError):
raise forms.ValidationError(_(u'Enter a valid MAC address.'))

View File

@ -0,0 +1,127 @@
# -*- 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 _
from horizon import exceptions
from tuskar_ui import api
import tuskar_ui.forms
class NodeForm(django.forms.Form):
id = django.forms.IntegerField(
label="",
required=False,
widget=django.forms.HiddenInput(),
)
ip_address = django.forms.IPAddressField(
label=_("IP Address"),
widget=django.forms.TextInput(attrs={'class': 'input input-medium'}),
)
ipmi_user = django.forms.CharField(
label=_("IPMI User"),
required=False,
widget=django.forms.TextInput(attrs={'class': 'input input-medium'}),
)
ipmi_password = django.forms.CharField(
label=_("IPMI Password"),
required=False,
widget=django.forms.PasswordInput(
render_value=False, attrs={'class': 'input input-medium'}),
)
mac_address = tuskar_ui.forms.MACField(
label=_("NIC MAC Address"),
widget=django.forms.Textarea(attrs={
'class': 'input input-medium',
'rows': 2,
}),
)
ipmi_user = django.forms.CharField(
label=_("IPMI User"),
required=False,
widget=django.forms.TextInput(attrs={'class': 'input input-medium'}),
)
ipmi_password = django.forms.CharField(
label=_("IPMI Password"),
required=False,
widget=django.forms.PasswordInput(
render_value=False, attrs={'class': 'input input-medium'}),
)
cpus = django.forms.IntegerField(
label=_("CPUs"),
required=True,
min_value=1,
initial=1,
widget=tuskar_ui.forms.NumberInput(
attrs={'class': 'input input-medium'}),
)
memory = django.forms.IntegerField(
label=_("Memory"),
required=True,
min_value=1,
initial=1,
widget=tuskar_ui.forms.NumberInput(
attrs={'class': 'input input-medium'}),
)
local_disk = django.forms.IntegerField(
label=_("Local Disk"),
required=True,
min_value=1,
initial=1,
widget=tuskar_ui.forms.NumberInput(
attrs={'class': 'input input-medium'}),
)
def get_name(self):
try:
name = self.fields['ip_address'].value()
except AttributeError:
# when the field is not bound
name = _("Undefined node")
return name
class BaseNodeFormset(django.forms.formsets.BaseFormSet):
def handle(self, request, data):
success = True
for form in self:
try:
api.Node.create(
request,
form.cleaned_data['ip_address'],
form.cleaned_data.get('cpus'),
form.cleaned_data.get('memory'),
form.cleaned_data.get('local_disk'),
[form.cleaned_data['mac_address']],
form.cleaned_data.get('ipmi_username'),
form.cleaned_data.get('ipmi_password'),
)
except Exception:
success = False
exceptions.handle(request, _('Unable to register node.'))
# TODO(rdopieralski) Somehow find out if any port creation
# failed and remove the mac addresses that succeeded from
# the form.
else:
# TODO(rdopieralski) Remove successful nodes from formset.
pass
return success
NodeFormset = django.forms.formsets.formset_factory(NodeForm, extra=1,
formset=BaseNodeFormset)

View File

@ -0,0 +1,107 @@
# -*- 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 import urlresolvers
from mock import patch, call # noqa
from openstack_dashboard.test.test_data import utils
from tuskar_ui.test import helpers as test
from tuskar_ui.test.test_data import tuskar_data
INDEX_URL = urlresolvers.reverse(
'horizon:infrastructure:nodes.overview:index')
REGISTER_URL = urlresolvers.reverse(
'horizon:infrastructure:nodes.overview:register')
TEST_DATA = utils.TestDataContainer()
tuskar_data.data(TEST_DATA)
class RegisterNodesTests(test.BaseAdminViewTests):
def test_index_get(self):
res = self.client.get(INDEX_URL)
self.assertTemplateUsed(
res, 'infrastructure/nodes/overview/index.html')
def test_register_get(self):
res = self.client.get(REGISTER_URL)
self.assertTemplateUsed(
res, 'infrastructure/nodes/overview/register.html')
def test_register_post(self):
node = TEST_DATA.ironicclient_nodes.first
data = {
'register_nodes-TOTAL_FORMS': 2,
'register_nodes-INITIAL_FORMS': 1,
'register_nodes-MAX_NUM_FORMS': 1000,
'register_nodes-0-ip_address': '127.0.0.1',
'register_nodes-0-mac_address': 'de:ad:be:ef:ca:fe',
'register_nodes-0-cpus': '1',
'register_nodes-0-memory': '2',
'register_nodes-0-local_disk': '3',
'register_nodes-1-ip_address': '127.0.0.2',
'register_nodes-1-mac_address': 'de:ad:be:ef:ca:ff',
'register_nodes-1-cpus': '4',
'register_nodes-1-memory': '5',
'register_nodes-1-local_disk': '6',
}
with patch('tuskar_ui.api.Node', **{
'spec_set': ['create'],
'create.return_value': node,
}) as Node:
res = self.client.post(REGISTER_URL, data)
request = Node.create.call_args_list[0][0][0] # This is a hack.
self.assertListEqual(Node.create.call_args_list, [
call(request, '127.0.0.1', 1, 2, 3,
['DE:AD:BE:EF:CA:FE'], None, u''),
call(request, '127.0.0.2', 4, 5, 6,
['DE:AD:BE:EF:CA:FF'], None, u''),
])
self.assertRedirectsNoFollow(res, INDEX_URL)
def test_register_post_exception(self):
data = {
'register_nodes-TOTAL_FORMS': 2,
'register_nodes-INITIAL_FORMS': 1,
'register_nodes-MAX_NUM_FORMS': 1000,
'register_nodes-0-ip_address': '127.0.0.1',
'register_nodes-0-mac_address': 'de:ad:be:ef:ca:fe',
'register_nodes-0-cpus': '1',
'register_nodes-0-memory': '2',
'register_nodes-0-local_disk': '3',
'register_nodes-1-ip_address': '127.0.0.2',
'register_nodes-1-mac_address': 'de:ad:be:ef:ca:ff',
'register_nodes-1-cpus': '4',
'register_nodes-1-memory': '5',
'register_nodes-1-local_disk': '6',
}
with patch('tuskar_ui.api.Node', **{
'spec_set': ['create'],
'create.side_effect': self.exceptions.tuskar,
}) as Node:
res = self.client.post(REGISTER_URL, data)
request = Node.create.call_args_list[0][0][0] # This is a hack.
self.assertListEqual(Node.create.call_args_list, [
call(request, '127.0.0.1', 1, 2, 3,
['DE:AD:BE:EF:CA:FE'], None, u''),
call(request, '127.0.0.2', 4, 5, 6,
['DE:AD:BE:EF:CA:FF'], None, u''),
])
self.assertTemplateUsed(
res, 'infrastructure/nodes/overview/register.html')

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,12 +12,14 @@
# 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.nodes.overview import views
urlpatterns = defaults.patterns(
urlpatterns = urls.patterns(
'',
defaults.url(r'^$', views.IndexView.as_view(), name='index'),
urls.url(r'^$', views.IndexView.as_view(), name='index'),
urls.url(r'^register/$', views.RegisterView.as_view(),
name='register'),
)

View File

@ -11,8 +11,29 @@
# 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_lazy
from django.views import generic
import horizon.forms
from tuskar_ui.infrastructure.nodes import forms
class IndexView(generic.TemplateView):
template_name = 'infrastructure/base.html'
template_name = 'infrastructure/nodes/overview/index.html'
class RegisterView(horizon.forms.ModalFormView):
form_class = forms.NodeFormset
form_prefix = 'register_nodes'
template_name = 'infrastructure/nodes/overview/register.html'
success_url = reverse_lazy(
'horizon:infrastructure:nodes.overview:index')
def get_data(self):
return []
def get_form(self, form_class):
return form_class(self.request.POST or None,
initial=self.get_data(),
prefix=self.form_prefix)

View File

@ -0,0 +1,107 @@
// Variables.less
// Variables to customize the look and feel of Bootstrap
// -----------------------------------------------------
// GLOBAL VALUES
// --------------------------------------------------
// Links
@linkColor: #08c;
@linkColorHover: darken(@linkColor, 15%);
// Grays
@black: #000;
@grayDarker: #222;
@grayDark: #333;
@gray: #555;
@grayLight: #999;
@grayLighter: #eee;
@white: #fff;
// Accent colors
@blue: #049cdb;
@blueDark: #0064cd;
@green: #46a546;
@red: #9d261d;
@yellow: #ffc40d;
@orange: #f89406;
@pink: #c3325f;
@purple: #7a43b6;
// Typography
@baseFontSize: 13px;
@baseFontFamily: "Helvetica Neue", Helvetica, Arial, sans-serif;
@baseLineHeight: 18px;
@textColor: @grayDark;
// Buttons
@primaryButtonBackground: @linkColor;
// COMPONENT VARIABLES
// --------------------------------------------------
// Z-index master list
// Used for a bird's eye view of components dependent on the z-axis
// Try to avoid customizing these :)
@zindexDropdown: 1000;
@zindexPopover: 1010;
@zindexTooltip: 1020;
@zindexFixedNavbar: 1030;
@zindexModalBackdrop: 1040;
@zindexModal: 1050;
// Sprite icons path
@iconSpritePath: "/static/bootstrap/img/glyphicons-halflings.png";
@iconWhiteSpritePath: "/static/bootstrap/img/glyphicons-halflings-white.png";
// Input placeholder text color
@placeholderText: @grayLight;
// Hr border color
@hrBorder: @grayLighter;
// Navbar
@navbarHeight: 40px;
@navbarBackground: @grayDarker;
@navbarBackgroundHighlight: @grayDark;
@navbarLinkBackgroundHover: transparent;
@navbarText: @grayLight;
@navbarLinkColor: @grayLight;
@navbarLinkColorHover: @white;
// Form states and alerts
@warningText: #c09853;
@warningBackground: #fcf8e3;
@warningBorder: darken(spin(@warningBackground, -10), 3%);
@errorText: #b94a48;
@errorBackground: #f2dede;
@errorBorder: darken(spin(@errorBackground, -10), 3%);
@successText: #468847;
@successBackground: #dff0d8;
@successBorder: darken(spin(@successBackground, -10), 5%);
@infoText: #3a87ad;
@infoBackground: #d9edf7;
@infoBorder: darken(spin(@infoBackground, -10), 7%);
// GRID
// --------------------------------------------------
// Default 940px grid
@gridColumns: 12;
@gridColumnWidth: 60px;
@gridGutterWidth: 20px;
@gridRowWidth: (@gridColumns * @gridColumnWidth) + (@gridGutterWidth * (@gridColumns - 1));
// Fluid grid
@fluidGridColumnWidth: 6.382978723%;
@fluidGridGutterWidth: 2.127659574%;

View File

@ -0,0 +1,74 @@
tuskar.menu_formset = (function () {
'use strict';
var module = {};
module.init = function (prefix, empty_form_html) {
var input_name_re = new RegExp('^' + prefix + '-(\\d+|__prefix__)-');
var input_id_re = new RegExp('^id_' + prefix + '-(\\d+|__prefix__)-');
var $content = $('#formset-' + prefix +' .tab-content');
var $nav = $('#formset-' + prefix + ' .nav');
var activated = false;
function renumber_form($form, prefix, count) {
$form.find('input, textarea, select').each(function () {
var input = $(this);
input.attr('name', input.attr('name').replace(
input_name_re, prefix + '-' + count + '-'));
input.attr('id', input.attr('id').replace(
input_id_re, 'id_' + prefix + '-' + count + '-'));
});
}
function reenumerate_forms($forms, prefix) {
var count = 0;
$forms.each(function () {
renumber_form($(this), prefix, count);
count += 1;
});
$('#id_' + prefix + '-TOTAL_FORMS').val(count);
return count;
}
function add_delete_link($nav_item) {
var $form = $content.find($nav_item.find('a').attr('href'));
$nav_item.prepend('<span class="btn-small pull-right delete-icon"><i class="icon-trash icon-white"></i></span>');
$nav_item.find('span.delete-icon:first').click(function () {
var count;
$form.remove();
$nav_item.remove();
count = reenumerate_forms($content.find('.tab-pane'), prefix);
if (count === 0) { add_node(); }
});
}
function add_node() {
var $new_form = $(empty_form_html);
var count, id, $new_nav;
$content.append($new_form);
$new_form = $content.find('.tab-pane:last');
count = reenumerate_forms($content.find('.tab-pane'), prefix);
id = 'tab-' + prefix + '-' + count;
$new_form.attr('id', id);
$nav.append('<li><a href="#' + id + '" data-toggle="tab">Undefined node</a></li>');
$new_nav = $nav.find('li > a:last');
add_delete_link($new_nav.parent());
$new_nav.click(function () { $(this).tab('show'); });
$new_nav.tab('show');
}
// Connect all signals.
$('a.add-node-link').click(add_node);
$nav.find('li').each(function () { add_delete_link($(this)); });
// Activate the first field that has errors.
$content.find('.control-group.error').each(function () {
if (!activated) {
$nav.find('a[href="#' + $(this).closest('.tab-pane').attr('id') + '"]').tab('show');
activated = true;
}
});
};
return module;
} ());

View File

@ -1,4 +1,5 @@
/* Additional CSS for infrastructure. */
@import "../../bootstrap/less/variables.less";
// global layout
html,
@ -524,3 +525,49 @@ input {
}
}
}
// Register nodes formset
.register-nodes-formset {
a.add-node-link {
display: block;
margin-top: 6px;
}
.nav-tabs > .active > a {
color: @white;
background-color: @linkColor;
}
ul.nav-tabs > li span.delete-icon {
display: none;
}
ul.nav-tabs > li.active span.delete-icon {
display: block;
margin: 4px 19px 4px 0;
cursor: pointer;
}
ul.nav-tabs > li {
position: relative;
}
ul.nav-tabs > li.active {
width: 107%;
}
ul.nav-tabs > li.active:after {
display: block;
content: '';
position: absolute;
top: 1px;
right: -7px;
border-top: 16px solid transparent;
border-bottom: 16px solid transparent;
border-left: 8px solid @linkColor;
}
.register-nav-head {
margin-top: 19px;
}
.form h4, .form h3 {
margin-bottom: 16px;
}
.form label.checkbox {
font-weight: normal;
}
}

View File

@ -0,0 +1,34 @@
{% load i18n %}
{{ formset.management_form }}
<div class="row-fluid register-nodes-formset" id="formset-{{ formset.prefix }}">
<div class="span5">
<div class="clearfix register-nav-head">
<a class="pull-right add-node-link"
href="#"><i class="icon-plus"></i> {% trans "Add Node" %}</a>
<h3>Nodes to register</h3>
</div>
<ul class="nav nav-tabs nav-stacked">
{% for form in formset %}
<li {% if forloop.first %}class="active"{% endif %}>
<a href="#tab-{{ form.prefix }}" data-toggle="tab">
{{ form.get_name }}
</a>
</li>
{% endfor %}
</ul>
</div>
<div class="span7">
<div class="tab-content">
{% for form in formset %}
{% include form_template with form=form active=forloop.first %}
{% endfor %}
</div>
</div>
</div>
<script type="text/javascript">
(window.$ || window.addHorizonLoadEvent)(function () {
var prefix = '{{ formset.prefix|escapejs }}';
var empty_form_html = '{% filter escapejs %}{% include form_template with form=formset.empty_form %}{% endfilter %}';
tuskar.menu_formset.init(prefix, empty_form_html);
});
</script>

View File

@ -6,6 +6,7 @@
<script src='{{ STATIC_URL }}infrastructure/js/tuskar.js' type='text/javascript' charset='utf-8'></script>
<script src='{{ STATIC_URL }}infrastructure/js/tuskar.templates.js' type='text/javascript' charset='utf-8'></script>
<script src='{{ STATIC_URL }}infrastructure/js/tuskar.formset_table.js' type='text/javascript' charset='utf-8'></script>
<script src='{{ STATIC_URL }}infrastructure/js/tuskar.menu_formset.js' type='text/javascript' charset='utf-8'></script>
{% endblock %}
{% comment %} Tuskar-UI Client-side Templates (These should *not* be inside the "compress" tag.) {% endcomment %}

View File

@ -0,0 +1,12 @@
<div class="row-fluid form-field control-group{% if field.errors %} error{% endif %}{% if required or field.required %} required{% endif %}">
<label class="span4 control-label">{{ field.label }}</label>
<div class="span6">{{ field }}</div>
<div class="span2 muted">{{ extra_text|default:'' }}</div>
</div>
{% if field.errors %}
<div class="row-fluid form-field control-group error">
{% for error in field.errors %}
<span class="help-inline">{{ error }}</span>
{% endfor %}
</div>
{% endif %}

View File

@ -0,0 +1,45 @@
<div class="well well-small tab-pane{% if active %} active{% endif %}"
id="tab-{{ form.prefix }}">
<div class="form form-inline">
<div class="row-fluid">
<h3 style="margin-bottom:16px">Node Detail</h3>
{% include 'infrastructure/nodes/overview/_nodes_formset_field.html' with field=form.node_tags %}
</div>
<div class="row-fluid">
<h4>Power Management</h4>
{% include 'infrastructure/nodes/overview/_nodes_formset_field.html' with field=form.ip_address required=True %}
{% include 'infrastructure/nodes/overview/_nodes_formset_field.html' with field=form.ipmi_user %}
{% include 'infrastructure/nodes/overview/_nodes_formset_field.html' with field=form.ipmi_password %}
</div>
<div class="row-fluid">
<h4>Networking</h4>
{% include 'infrastructure/nodes/overview/_nodes_formset_field.html' with field=form.mac_address required=True %}
</div>
<div class="row-fluid">
<div class="span4">
<h4>Hardware</h4>
</div>
<label class="span6 checkbox checkbox-inline">
{{ form.introspect_hardware }}<small> {{ form.introspect_hardware.label }}</small>
</label>
</div>
<div class="row-fluid" id="register-hardware-fields">
{% include 'infrastructure/nodes/overview/_nodes_formset_field.html' with field=form.cpus extra_text=_('units') required=True %}
{% include 'infrastructure/nodes/overview/_nodes_formset_field.html' with field=form.memory extra_text=_('MB') required=True %}
{% include 'infrastructure/nodes/overview/_nodes_formset_field.html' with field=form.local_disk extra_text=_('GB') required=True %}
</div>
</div>
</div>
<script type="text/javascript">
(window.$ || window.addHorizonLoadEvent)(function () {
var form_prefix = '{{ form.prefix|escapejs }}';
var $form = $('#tab-' + form_prefix);
var $nav_link = $('a[href="#' + $form.attr('id') + '"]');
var undefined_name = '{{ form.get_name|escapejs }}';
$form.find('input[name$="-ip_address"]').change(function () {
$nav_link.html($(this).val() || undefined_name);
});
});
</script>

View File

@ -0,0 +1,20 @@
{% extends "horizon/common/_modal_form.html" %}
{% load i18n %}
{% load url from future %}
{% block form_id %}register_nodes_form{% endblock %}
{% block form_action %}{% url 'horizon:infrastructure:nodes.overview:register' %}{% endblock %}
{% block modal_id %}register_nodes_modal{% endblock %}
{% block modal-header %}{% trans "Register Nodes" %}{% endblock %}
{% block modal-body %}
{% include "formset_table/menu_formset.html" with formset=form form_template="infrastructure/nodes/overview/_nodes_formset_form.html" %}
{% endblock %}
{% block modal-footer %}
<input class="btn btn-primary pull-right" type="submit"
value="{% trans "Register Nodes" %}" />
<a href="{% url 'horizon:infrastructure:nodes.overview:index' %}"
class="btn secondary cancel close">{% trans "Cancel" %}</a>
{% endblock %}

View File

@ -0,0 +1,13 @@
{% extends 'infrastructure/base.html' %}
{% load i18n %}
{% load url from future %}
{% block title %}{% trans 'Nodes Overview' %}{% endblock %}
{% block page_header %}
{% include 'horizon/common/_domain_page_header.html' with title=_('Nodes Overview') %}
{% endblock page_header %}
{% block main %}
<a href="{% url 'horizon:infrastructure:nodes.overview:register' %}"
class="btn ajax-modal">Register nodes</a>
{% endblock %}

View File

@ -0,0 +1,11 @@
{% extends "base.html" %}
{% load i18n %}
{% block title %}{% trans "Register Nodes" %}{% endblock %}
{% block page_header %}
{% include "horizon/common/_page_header.html" with title=_("Register Nodes") %}
{% endblock %}
{% block main %}
{% include "infrastructure/nodes/overview/_register.html" %}
{% endblock %}