Update node index view to match Juno wireframes

For now, instead of having a single 'Registered' tab with a graphical/
table switch, we have two separate tabs.  The former is under 'Overview';
the latter under 'Registered'.

The graphical portion of the 'Overview' is left for a future patch.

'Discovered' tab is eliminated pending a better understanding of the
Ironic API that enables it.

Additional styling to come in future patches.

Partial-Implements: blueprint node-index-view-update
Change-Id: Id62493ab7dfb8cee97905c59d457e8b1bdc72ecf
This commit is contained in:
Tzu-Mainn Chen 2014-07-14 20:03:30 +02:00
parent e6f600e9f2
commit e35016314e
11 changed files with 118 additions and 226 deletions

View File

@ -12,7 +12,7 @@
# License for the specific language governing permissions and limitations
# under the License.
from django.template import defaultfilters
from django.core.urlresolvers import reverse
from django.utils.translation import ugettext_lazy as _
from horizon import tables
@ -50,24 +50,27 @@ class NodeFilterAction(tables.FilterAction):
return filter(comp, nodes)
class NodesTable(tables.DataTable):
node = tables.Column(lambda node: node.driver_info['ipmi_address'],
def get_role_link(datum):
# TODO(tzumainn): this could probably be done more efficiently
# by getting the resource for all nodes at once
if datum.role_id:
return reverse('horizon:infrastructure:overcloud:role',
kwargs={'stack_id': datum.stack_id,
'role_id': datum.role_id})
class RegisteredNodesTable(tables.DataTable):
node = tables.Column('uuid',
link="horizon:infrastructure:nodes:detail",
verbose_name=_("Node"))
# TODO(lsmola) waits for Ironic
# architecture = tables.Column(
# lambda node: "",
# verbose_name=_("Architecture"))
cpu = tables.Column(lambda node: node.properties['cpu'],
verbose_name=_("CPU (cores)"))
ram = tables.Column(lambda node: defaultfilters.filesizeformat(
node.properties['ram']),
verbose_name=_("RAM"))
local_disk = tables.Column(lambda node: defaultfilters.filesizeformat(
node.properties['local_disk']),
verbose_name=_("Local Disk"))
verbose_name=_("Node Name"))
instance_ip = tables.Column(lambda n:
n.instance.public_ip if n.instance else '-',
verbose_name=_("Instance IP"))
provisioning_status = tables.Column('provisioning_status',
verbose_name=_("Provisioned"))
role_name = tables.Column('role_name',
link=get_role_link,
verbose_name=_("Deployment Role"))
power_state = tables.Column("power_state",
verbose_name=_("Power"),
status=True,
@ -80,51 +83,11 @@ class NodesTable(tables.DataTable):
class Meta:
name = "nodes_table"
verbose_name = _("Nodes")
table_actions = ()
row_actions = ()
table_actions = (NodeFilterAction,)
row_actions = (DeleteNode,)
def get_object_id(self, datum):
return datum.uuid
def get_object_display(self, datum):
return datum.uuid
class FreeNodesTable(NodesTable):
# TODO(jtomasek): waits for Ironic to expose IP
# node = tables.Column(lambda node: node.driver_info['ipmi_address'],
# link="horizon:infrastructure:nodes:detail",
# verbose_name=_("Node"))
node = tables.Column("uuid",
link="horizon:infrastructure:nodes:detail",
verbose_name=_("Node"))
class Meta:
name = "free_nodes"
verbose_name = _("Free Nodes")
table_actions = (DeleteNode,
NodeFilterAction,)
row_actions = (DeleteNode,)
class DeployedNodesTable(NodesTable):
deployment_role = tables.Column("role_name",
verbose_name=_("Deployment Role"))
# TODO(lsmola) waits for Ceilometer baremetal metrics
# capacity = tables.Column(
# lambda node: "",
# verbose_name=_("Capacity"))
health = tables.Column('instance_status',
verbose_name=_("Health"))
class Meta:
name = "deployed_nodes"
verbose_name = _("Deployed Nodes")
table_actions = (NodeFilterAction,)
row_actions = ()
columns = ('node', 'deployment_role', 'capacity', 'architecture',
'cpu', 'ram', 'local_disk', 'health', 'power_state')

View File

@ -28,6 +28,10 @@ class OverviewTab(tabs.Tab):
template_name = "infrastructure/nodes/_overview.html"
def get_context_data(self, request):
nodes = api.node.Node.list(request)
cpus = sum(int(node.properties['cpu']) for node in nodes)
ram = sum(int(node.properties['ram']) for node in nodes)
local_disk = sum(int(node.properties['local_disk']) for node in nodes)
deployed_nodes = api.node.Node.list(request, associated=True)
free_nodes = api.node.Node.list(request, associated=False)
deployed_nodes_error = api.node.filter_nodes(
@ -38,6 +42,9 @@ class OverviewTab(tabs.Tab):
total_nodes_healthy = api.node.filter_nodes(total_nodes, healthy=True)
return {
'cpus': cpus,
'ram_gb': ram / 1024.0 ** 3,
'local_disk_gb': local_disk / 1024.0 ** 3,
'total_nodes_healthy': total_nodes_healthy,
'total_nodes_error': total_nodes_error,
'deployed_nodes': deployed_nodes,
@ -47,57 +54,38 @@ class OverviewTab(tabs.Tab):
}
class DeployedTab(tabs.TableTab):
table_classes = (tables.DeployedNodesTable,)
name = _("Deployed")
slug = "deployed"
class RegisteredTab(tabs.TableTab):
table_classes = (tables.RegisteredNodesTable,)
name = _("Registered")
slug = "registered"
template_name = "horizon/common/_detail_table.html"
def get_items_count(self):
return len(self.get_deployed_nodes_data())
return len(self.get_nodes_table_data())
def get_deployed_nodes_data(self):
def get_nodes_table_data(self):
redirect = urlresolvers.reverse('horizon:infrastructure:nodes:index')
deployed_nodes = api.node.Node.list(self.request, associated=True,
_error_redirect=redirect)
nodes = api.node.Node.list(self.request, _error_redirect=redirect)
if 'errors' in self.request.GET:
return api.node.filter_nodes(deployed_nodes, healthy=False)
return api.node.filter_nodes(nodes, healthy=False)
for node in deployed_nodes:
for node in nodes:
# TODO(tzumainn): this could probably be done more efficiently
# by getting the resource for all nodes at once
try:
resource = api.heat.Resource.get_by_node(self.request, node)
node.role_name = resource.role.name
node.role_id = resource.role.id
node.stack_id = resource.stack.id
except exceptions.NotFound:
node.role_name = '-'
return deployed_nodes
class FreeTab(tabs.TableTab):
table_classes = (tables.FreeNodesTable,)
name = _("Free")
slug = "free"
template_name = "horizon/common/_detail_table.html"
def get_items_count(self):
return len(self.get_free_nodes_data())
def get_free_nodes_data(self):
redirect = urlresolvers.reverse('horizon:infrastructure:nodes:index')
free_nodes = api.node.Node.list(self.request, associated=False,
_error_redirect=redirect)
if 'errors' in self.request.GET:
return api.node.filter_nodes(free_nodes, healthy=False)
return free_nodes
return nodes
class NodeTabs(tabs.TabGroup):
slug = "nodes"
tabs = (OverviewTab, DeployedTab, FreeTab)
tabs = (OverviewTab, RegisteredTab)
sticky = True
template_name = "horizon/common/_items_count_tab_group.html"

View File

@ -5,64 +5,32 @@
<div class="row-fluid">
<div class="span8">
<div class="widget">
<h2>{% trans 'Health Status' %}</h2>
<h2>{% trans 'Hardware Inventory' %}</h2>
<table class="table">
<tbody>
<tr>
<td>
<a href="{% url 'horizon:infrastructure:nodes:index' %}?tab=nodes__deployed">
{% trans 'Deployed Nodes' %}
({{ deployed_nodes|length|default:0 }})
</a>
</td>
<td>
{% if deployed_nodes_error %}
<i class="icon-exclamation-sign"></i>
{% if deployed_nodes_error|length == 1 %}
{% url 'horizon:infrastructure:nodes:detail' deployed_nodes_error.0.uuid as node_detail_url %}
{% else %}
{% url 'horizon:infrastructure:nodes:index' as nodes_index_url %}
{% endif %}
{% blocktrans count error_count=deployed_nodes_error|length %}
<a href="{{ node_detail_url }}">{{ error_count }} node</a> with erros
{% plural %}
<a href="{{ nodes_index_url }}?tab=nodes__deployed&errors">{{ error_count }} nodes</a> with errors
{% endblocktrans %}
{% else %}
<i class="icon-ok"></i>
{% trans 'All nodes are performing correctly' %}
{% endif %}
</td>
<td>{{ cpus }} {% trans 'CPU cores' %}</td>
</tr>
<tr>
<td>{{ ram_gb }} {% trans 'GB of memory' %}</td>
</tr>
<tr>
<td>{{ local_disk_gb }} {% trans 'GB of storage' %}</td>
</tr>
<tr>
<td>
<a href="{% url 'horizon:infrastructure:nodes:index' %}?tab=nodes__free">
<a href="{% url 'horizon:infrastructure:nodes:index' %}?tab=nodes__registered">
{% trans 'Free Nodes' %}
({{ free_nodes|length|default:0 }})
</a>
</td>
</tr>
<tr>
<td>
{% if free_nodes_error %}
<i class="icon-exclamation-sign"></i>
{% if free_nodes_error|length == 1 %}
{% comment %}
Replace id with uuid when ironicclient is used instead baremetalclient
{% endcomment %}
{% url 'horizon:infrastructure:nodes:detail' free_nodes_error.0.id as node_detail_url %}
{% else %}
{% url 'horizon:infrastructure:nodes:index' as nodes_index_url %}
{% endif %}
{% blocktrans count error_count=free_nodes_error|length %}
<a href="{{ node_detail_url }}">{{ error_count }} node</a> with errors
{% plural %}
<a href="{{ nodes_index_url }}?tab=nodes__free&errors">{{ error_count }} nodes</a> with errors
{% endblocktrans %}
{% else %}
<i class="icon-ok"></i>
{% trans 'All nodes are performing correctly' %}
{% endif %}
<a href="{% url 'horizon:infrastructure:nodes:index' %}?tab=nodes__registered">
{% trans 'Provisioned Nodes' %}
({{ deployed_nodes|length|default:0 }})
</a>
</td>
</tr>
</tbody>

View File

@ -1,7 +1,7 @@
{% extends 'infrastructure/base.html' %}
{% load i18n %}
{% load url from future %}
{% block title %}{% trans 'Registered Nodes' %}{% endblock %}
{% block title %}{% trans 'Nodes' %}{% endblock %}
{% block page_header %}
{% include 'horizon/common/_items_count_domain_page_header.html' with title=_('Registered Nodes') items_count=nodes_count %}

View File

@ -52,15 +52,15 @@ class NodesTests(test.BaseAdminViewTests, helpers.APITestCase):
}) as mock:
res = self.client.get(INDEX_URL)
# FIXME(lsmola) optimize, this should call 1 time, what the hell
self.assertEqual(mock.list.call_count, 8)
self.assertEqual(mock.list.call_count, 5)
self.assertTemplateUsed(
res, 'infrastructure/nodes/index.html')
self.assertTemplateUsed(res, 'infrastructure/nodes/_overview.html')
def test_free_nodes(self):
free_nodes = [api.node.Node(node)
for node in self.ironicclient_nodes.list()]
def test_registered_nodes(self):
registered_nodes = [api.node.Node(node)
for node in self.ironicclient_nodes.list()]
roles = [api.tuskar.OvercloudRole(r)
for r in TEST_DATA.tuskarclient_roles.list()]
instance = TEST_DATA.novaclient_servers.first()
@ -73,7 +73,7 @@ class NodesTests(test.BaseAdminViewTests, helpers.APITestCase):
}),
patch('tuskar_ui.api.node.Node', **{
'spec_set': ['list'],
'list.return_value': free_nodes,
'list.return_value': registered_nodes,
}),
patch('tuskar_ui.api.node.nova', **{
'spec_set': ['server_get'],
@ -84,71 +84,25 @@ class NodesTests(test.BaseAdminViewTests, helpers.APITestCase):
'image_get.return_value': image,
}),
) as (_OvercloudRole, Node, _nova, _glance):
res = self.client.get(INDEX_URL + '?tab=nodes__free')
res = self.client.get(INDEX_URL + '?tab=nodes__registered')
# FIXME(lsmola) horrible count, optimize
self.assertEqual(Node.list.call_count, 10)
self.assertTemplateUsed(res,
'infrastructure/nodes/index.html')
self.assertTemplateUsed(res, 'horizon/common/_detail_table.html')
self.assertItemsEqual(res.context['free_nodes_table'].data,
free_nodes)
def test_free_nodes_list_exception(self):
with patch('tuskar_ui.api.node.Node', **{
'spec_set': ['list'],
'list.side_effect': self._raise_tuskar_exception,
}) as mock:
res = self.client.get(INDEX_URL + '?tab=nodes__free')
self.assertEqual(mock.list.call_count, 3)
self.assertRedirectsNoFollow(res, INDEX_URL)
def test_deployed_nodes(self):
deployed_nodes = [api.node.Node(node)
for node in self.ironicclient_nodes.list()]
roles = [api.tuskar.OvercloudRole(r)
for r in TEST_DATA.tuskarclient_roles.list()]
instance = TEST_DATA.novaclient_servers.first()
image = TEST_DATA.glanceclient_images.first()
with contextlib.nested(
patch('tuskar_ui.api.tuskar.OvercloudRole', **{
'spec_set': ['list', 'name'],
'list.return_value': roles,
}),
patch('tuskar_ui.api.node.Node', **{
'spec_set': ['list'],
'list.return_value': deployed_nodes,
}),
patch('tuskar_ui.api.node.nova', **{
'spec_set': ['server_get'],
'server_get.return_value': instance,
}),
patch('tuskar_ui.api.node.glance', **{
'spec_set': ['image_get'],
'image_get.return_value': image,
}),
) as (_OvercloudRole, Node, _nova, _glance):
res = self.client.get(INDEX_URL + '?tab=nodes__deployed')
# FIXME(lsmola) horrible count, optimize
self.assertEqual(Node.list.call_count, 10)
self.assertEqual(Node.list.call_count, 6)
self.assertTemplateUsed(
res, 'infrastructure/nodes/index.html')
self.assertTemplateUsed(res, 'horizon/common/_detail_table.html')
self.assertItemsEqual(res.context['deployed_nodes_table'].data,
deployed_nodes)
self.assertItemsEqual(res.context['nodes_table_table'].data,
registered_nodes)
def test_deployed_nodes_list_exception(self):
def test_registered_nodes_list_exception(self):
instance = TEST_DATA.novaclient_servers.first()
with patch('tuskar_ui.api.node.Node', **{
'spec_set': ['list', 'instance'],
'instance': instance,
'list.side_effect': self._raise_tuskar_exception,
}) as mock:
res = self.client.get(INDEX_URL + '?tab=nodes__deployed')
self.assertEqual(mock.list.call_count, 3)
res = self.client.get(INDEX_URL + '?tab=nodes__registered')
self.assertEqual(mock.list.call_count, 4)
self.assertRedirectsNoFollow(res, INDEX_URL)

View File

@ -35,26 +35,6 @@ class IndexView(horizon_tabs.TabbedTableView):
tab_group_class = tabs.NodeTabs
template_name = 'infrastructure/nodes/index.html'
def get_free_nodes_count(self):
free_nodes_count = len(api.node.Node.list(
self.request, associated=False))
return free_nodes_count
def get_deployed_nodes_count(self):
deployed_nodes_count = len(api.node.Node.list(self.request,
associated=True))
return deployed_nodes_count
def get_context_data(self, **kwargs):
context = super(IndexView, self).get_context_data(**kwargs)
context['free_nodes_count'] = self.get_free_nodes_count()
context['deployed_nodes_count'] = self.get_deployed_nodes_count()
context['nodes_count'] = (context['free_nodes_count'] +
context['deployed_nodes_count'])
return context
class RegisterView(horizon_forms.ModalFormView):
form_class = forms.NodeFormset

View File

@ -19,7 +19,7 @@ from horizon import tables
from tuskar_ui.infrastructure.nodes import tables as nodes_tables
class OvercloudRoleNodeTable(nodes_tables.DeployedNodesTable):
class OvercloudRoleNodeTable(nodes_tables.RegisteredNodesTable):
class Meta:
name = "overcloud_role__nodetable"

View File

@ -171,6 +171,8 @@ class OvercloudRoleView(horizon_tables.DataTableView,
try:
resource = api.heat.Resource.get_by_node(self.request, node)
node.role_name = resource.role.name
node.role_id = resource.role.id
node.stack_id = resource.stack.id
except horizon_exceptions.NotFound:
node.role_name = '-'

View File

@ -85,7 +85,7 @@ class NodeAPITests(test.APITestCase):
for node in ret_val:
self.assertIsInstance(node, api.node.Node)
self.assertEqual(5, len(ret_val))
self.assertEqual(6, len(ret_val))
def test_node_delete(self):
node = self.baremetalclient_nodes.first()

View File

@ -162,7 +162,8 @@ def data(TEST):
'flavor': {
'id': '1',
},
'status': 'ACTIVE'})
'status': 'ACTIVE',
'public_ip': '192.168.1.1'})
s_2 = servers.Server(
servers.ServerManager(None),
{'id': 'bb',
@ -172,7 +173,8 @@ def data(TEST):
'flavor': {
'id': '2',
},
'status': 'ACTIVE'})
'status': 'ACTIVE',
'public_ip': '192.168.1.2'})
s_3 = servers.Server(
servers.ServerManager(None),
{'id': 'cc',
@ -182,7 +184,8 @@ def data(TEST):
'flavor': {
'id': '1',
},
'status': 'BUILD'})
'status': 'BUILD',
'public_ip': '192.168.1.3'})
s_4 = servers.Server(
servers.ServerManager(None),
{'id': 'dd',
@ -192,7 +195,8 @@ def data(TEST):
'flavor': {
'id': '1',
},
'status': 'ERROR'})
'status': 'ERROR',
'public_ip': '192.168.1.4'})
TEST.novaclient_servers.add(s_1, s_2, s_3, s_4)
# Image

View File

@ -92,8 +92,22 @@ def data(TEST):
"pm_user": None,
"interfaces": [{"address": "52:54:00:90:38:01"}],
})
bm_node_6 = baremetal.BareMetalNode(
baremetal.BareMetalNodeManager(None),
{'id': '6',
'uuid': 'ff-66',
'instance_uuid': None,
"service_host": "undercloud",
"cpus": 1,
"memory_mb": 4096,
"local_gb": 20,
'task_state': None,
"pm_address": None,
"pm_user": None,
"interfaces": [{"address": "52:54:00:90:38:01"}],
})
TEST.baremetalclient_nodes.add(
bm_node_1, bm_node_2, bm_node_3, bm_node_4, bm_node_5)
bm_node_1, bm_node_2, bm_node_3, bm_node_4, bm_node_5, bm_node_6)
# IronicNode
TEST.ironicclient_nodes = test_data_utils.TestDataContainer()
@ -192,7 +206,26 @@ def data(TEST):
},
'power_state': 'error',
})
TEST.ironicclient_nodes.add(node_1, node_2, node_3, node_4, node_5)
node_6 = node.Node(
node.NodeManager(None),
{'id': '6',
'uuid': 'ff-66',
'instance_uuid': None,
'driver': 'pxe_ipmitool',
'driver_info': {
'ipmi_address': '5.5.5.5',
'ipmi_username': 'admin',
'ipmi_password': 'password',
'ip_address': '1.2.2.6'
},
'properties': {
'cpu': '8',
'ram': '16',
'local_disk': '10',
},
'power_state': 'on',
})
TEST.ironicclient_nodes.add(node_1, node_2, node_3, node_4, node_5, node_6)
# Ports
TEST.ironicclient_ports = test_data_utils.TestDataContainer()