Add Topology Tab for Services

This makes the following changes on Environment->Service
page:
 * Adds Tab for Services List inside Environment
 * Adds a new Tab for Topology Graph

Added new API function to render d3 data for topology graph.
Currently heat images are used.

Change-Id: Icae70a3d2668a9b18d1c716d7a60ebdcf2faf5ea
Implements: blueprint environment-topology-view
This commit is contained in:
Georgy Okrokvertskhov
2014-02-25 20:43:39 -08:00
parent ed20acc48a
commit 85e9f272bf
13 changed files with 560 additions and 36 deletions

View File

@@ -14,6 +14,7 @@
import logging
import bunch
import json
from django.conf import settings
from horizon.exceptions import ServiceCatalogException
from openstack_dashboard.api.base import url_for
@@ -23,6 +24,8 @@ from muranoclient.common.exceptions import HTTPForbidden, HTTPNotFound
from consts import STATUS_ID_READY, STATUS_ID_NEW
from .network import get_network_params
from muranodashboard.environments import format
log = logging.getLogger(__name__)
@@ -330,3 +333,68 @@ def get_deployment_descr(request, environment_id, deployment_id):
request, service['type'])
return descr
return None
def load_environment_data(request, environment_id):
environment = environment_get(request, environment_id)
return render_d3_data(environment, environment.services)
def render_d3_data(environment, services):
ext_net_name = None
d3_data = {"nodes": [], "environment": {}}
if environment:
environment_image = '/static/dashboard/img/stack-green.svg'
in_progress, status_message = \
format.get_environment_status_message(environment)
environment_node = format.create_empty_node()
environment_node['id'] = environment.id
environment_node['name'] = environment.name
environment_node['status'] = status_message
environment_node['image'] = environment_image
environment_node['in_progress'] = in_progress
environment_node['info_box'] = \
format.environment_info(environment, status_message)
d3_data['environment'] = environment_node
if services:
for service in services:
service_image = '/static/dashboard/img/stack-green.svg'
in_progress, status_message = \
format.get_environment_status_message(service)
required_by = None
if service.get('assignFloatingIP', False):
if ext_net_name:
required_by = ext_net_name
else:
ext_net_name = 'External_Network'
d3_data['nodes'].append(format.create_ext_network_node(
ext_net_name))
required_by = ext_net_name
service_node = format.create_empty_node()
service_node['name'] = service['name']
service_node['status'] = status_message
service_node['image'] = service_image
service_node['link_type'] = "unit"
service_node['in_progress'] = in_progress
service_node['info_box'] = format.appication_info(service,
service_image,
status_message)
if required_by:
service_node['required_by'] = [required_by]
d3_data['nodes'].append(service_node)
for unit in service['units']:
unit_image = '/static/dashboard/img/server-green.svg'
node = format.create_empty_node()
node['name'] = unit['name']
node['id'] = unit['id']
node['required_by'] = [service['name']]
node['flavor'] = service['flavor']
node['info_box'] = \
format.unit_info(service, unit, unit_image)
node['image'] = unit_image
node['link_type'] = "unit"
node['in_progress'] = in_progress
d3_data['nodes'].append(node)
return json.dumps(d3_data)

View File

@@ -0,0 +1,86 @@
# 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.template.loader import render_to_string
def get_environment_status_message(entity):
try:
status = entity['status']
except TypeError:
status = entity.status
in_progress = True
if status in ('pending', 'ready'):
in_progress = False
if status == 'pending':
status_message = 'Waiting for deployment'
elif status == 'ready':
status_message = 'Deployed'
elif status == 'deploying':
status_message = 'Deployment is in progress'
return in_progress, status_message
def appication_info(application, app_image, status):
context = {}
context['name'] = application['name']
context['type'] = application['type']
context['status'] = status
context['app_image'] = app_image
return render_to_string('services/_application_info.html',
context)
def unit_info(service, unit, unit_image):
context = {}
context['name'] = unit['name']
context['os'] = service['osImage']['type']
context['image'] = service['osImage']['name']
context['flavor'] = service['flavor']
context['unit_image'] = unit_image
return render_to_string('services/_unit_info.html',
context)
def environment_info(environment, status):
context = {}
context['name'] = environment.name
context['status'] = status
return render_to_string('services/_environment_info.html',
context)
def create_empty_node():
node = {
'name': '',
'status': 'ready',
'image': '',
'image_size': 60,
'required_by': [],
'image_x': -30,
'image_y': -30,
'text_x': 40,
'text_y': ".35em",
'link_type': "relation",
'in_progress': False,
'info_box': ''
}
return node
def create_ext_network_node(name):
node = create_empty_node()
node['name'] = name
node['image'] = '/static/dashboard/img/lb-green.svg'
node['link_type'] = "relation"
return node

View File

@@ -14,17 +14,21 @@
import logging
from django.core.urlresolvers import reverse
from django.utils.translation import ugettext_lazy as _
from django.utils.datastructures import SortedDict
from horizon import exceptions
from horizon import tabs
from muranodashboard.environments.consts import LOG_LEVEL_TO_COLOR
from muranodashboard.environments.consts import LOG_LEVEL_TO_TEXT
from openstack_dashboard.api import nova as nova_api
from openstack_dashboard.api import heat as heat_api
from muranoclient.common import exceptions as exc
from muranodashboard.environments import api
from muranodashboard.environments.consts import LOG_LEVEL_TO_COLOR
from muranodashboard.environments.consts import LOG_LEVEL_TO_TEXT
from muranodashboard.environments.tables import STATUS_DISPLAY_CHOICES
from muranodashboard.environments.tables import EnvConfigTable
from muranodashboard.environments.tables import EnvConfigTable, ServicesTable
LOG = logging.getLogger(__name__)
@@ -166,6 +170,54 @@ class EnvConfigTab(tabs.TableTab):
return deployment.get('services')
class EnvironmentTopologyTab(tabs.Tab):
name = _("Topology")
slug = "topology"
template_name = "services/_detail_topology.html"
preload = False
def get_context_data(self, request):
context = {}
environment_id = self.tab_group.kwargs['environment_id']
context['environment_id'] = environment_id
d3_data = api.load_environment_data(self.request, environment_id)
context['d3_data'] = d3_data
return context
class EnvironmentServicesTab(tabs.TableTab):
name = _("Services")
slug = "serviceslist"
table_classes = (ServicesTable,)
template_name = "services/_service_list.html"
preload = False
def get_services_data(self):
services = []
self.environment_id = self.tab_group.kwargs['environment_id']
ns_url = "horizon:murano:environments:index"
try:
services = api.services_list(self.request, self.environment_id)
except exc.HTTPForbidden:
msg = _('Unable to retrieve list of services. This environment '
'is deploying or already deployed by other user.')
exceptions.handle(self.request, msg, redirect=reverse(ns_url))
except (exc.HTTPInternalServerError, exc.HTTPNotFound):
msg = _("Environment with id %s doesn't exist anymore"
% self.environment_id)
exceptions.handle(self.request, msg, redirect=reverse(ns_url))
except exc.HTTPUnauthorized:
exceptions.handle(self.request)
return services
class EnvironmentDetailsTabs(tabs.TabGroup):
slug = "environemnt_details"
tabs = (EnvironmentServicesTab, EnvironmentTopologyTab)
class ServicesTabs(tabs.TabGroup):
slug = "services_details"
tabs = (OverviewTab, ServiceLogsTab)

View File

@@ -15,7 +15,7 @@
from django.conf.urls import patterns, url
from views import IndexView, DeploymentDetailsView
from views import Services
from views import JSONView, EnvironmentDetails
from views import CreateEnvironmentView
from views import DetailServiceView
from views import DeploymentsView
@@ -50,9 +50,12 @@ urlpatterns = patterns(
EditEnvironmentView.as_view(),
name='update_environment'),
url(ENVIRONMENT_ID + r'/services$', Services.as_view(),
url(ENVIRONMENT_ID + r'/services$', EnvironmentDetails.as_view(),
name='services'),
url(ENVIRONMENT_ID + r'/services/get_d3_data$',
JSONView.as_view(), name='d3_data'),
url(ENVIRONMENT_ID + r'/(?P<service_id>[^/]+)/$',
DetailServiceView.as_view(),
name='service_details'),

View File

@@ -21,7 +21,9 @@ from functools import update_wrapper
from django.core.urlresolvers import reverse, reverse_lazy
from django.utils.translation import ugettext_lazy as _
from django.contrib.formtools.wizard.views import SessionWizardView
from django.http import HttpResponseRedirect
from django.http import HttpResponseRedirect # noqa
from django.http import HttpResponse # noqa
from django.views import generic
from horizon import exceptions
from horizon import tabs
from horizon import tables
@@ -33,7 +35,7 @@ from tables import ServicesTable
from tables import DeploymentsTable
from tables import EnvConfigTable
from workflows import CreateEnvironment, UpdateEnvironment
from tabs import ServicesTabs, DeploymentTabs
from tabs import ServicesTabs, DeploymentTabs, EnvironmentDetailsTabs
from . import api
from muranoclient.common.exceptions import HTTPUnauthorized, \
CommunicationError, HTTPInternalServerError, HTTPForbidden, HTTPNotFound
@@ -193,46 +195,24 @@ class IndexView(tables.DataTableView):
return environments
class Services(tables.DataTableView):
table_class = ServicesTable
class EnvironmentDetails(tabs.TabbedTableView):
tab_group_class = EnvironmentDetailsTabs
template_name = 'services/index.html'
def get_context_data(self, **kwargs):
context = super(Services, self).get_context_data(**kwargs)
context = super(EnvironmentDetails, self).get_context_data(**kwargs)
try:
self.environment_id = self.kwargs['environment_id']
env = api.environment_get(self.request, self.environment_id)
context['environment_name'] = env.name
except:
msg = _("Sorry, this environment does't exist anymore")
msg = _("Sorry, this environment doesn't exist anymore")
redirect = reverse("horizon:murano:environments:index")
exceptions.handle(self.request, msg, redirect=redirect)
return context
def get_data(self):
services = []
self.environment_id = self.kwargs['environment_id']
ns_url = "horizon:murano:environments:index"
try:
services = api.services_list(self.request, self.environment_id)
except HTTPForbidden:
msg = _('Unable to retrieve list of services. This environment '
'is deploying or already deployed by other user.')
exceptions.handle(self.request, msg, redirect=reverse(ns_url))
except HTTPInternalServerError:
msg = _("Environment with id %s doesn't exist anymore"
% self.environment_id)
exceptions.handle(self.request, msg, redirect=reverse(ns_url))
except HTTPUnauthorized:
exceptions.handle(self.request)
except HTTPNotFound:
msg = _("Environment with id %s doesn't exist anymore"
% self.environment_id)
exceptions.handle(self.request, msg, redirect=reverse(ns_url))
return services
class DetailServiceView(tabs.TabView):
tab_group_class = ServicesTabs
@@ -396,3 +376,12 @@ class DeploymentDetailsView(tabs.TabbedTableView):
return self.tab_group_class(request, deployment=deployment, logs=logs,
**kwargs)
class JSONView(generic.View):
def get(self, request, **kwargs):
self.environment_id = kwargs['environment_id']
data = api.load_environment_data(request, self.environment_id)
return HttpResponse(data,
content_type="application/json")

View File

@@ -0,0 +1,294 @@
/**
* Adapted for Murano js topology generator.
* Based on:
* HeatTop JS Framework
* Dependencies: jQuery 1.7.1 or later, d3 v3 or later
* Date: June 2013
* Description: JS Framework that subclasses the D3 Force Directed Graph library to create
* Heat-specific objects and relationships with the purpose of displaying
* Stacks, Resources, and related Properties in a Resource Topology Graph.
*
* 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.
*/
var murano_container = "#murano_application_topology";
var diagonal = d3.svg.diagonal()
.projection(function(d) { return [d.y, d.x]; });
function update(){
node = node.data(nodes, function(d) { return d.name; });
link = link.data(links);
var nodeEnter = node.enter().append("g")
.attr("class", "node")
.attr("node_name", function(d) { return d.name; })
.attr("node_id", function(d) { return d.instance; })
.call(force.drag);
nodeEnter.append("image")
.attr("xlink:href", function(d) { return d.image; })
.attr("id", function(d){ return "image_"+ d.name; })
.attr("x", function(d) { return d.image_x; })
.attr("y", function(d) { return d.image_y; })
.attr("width", function(d) { return d.image_size; })
.attr("height", function(d) { return d.image_size; });
node.exit().remove();
link.enter().insert("path", "g.node")
.attr("class", function(d) { return "link " + d.link_type; });
link.exit().remove();
//Setup click action for all nodes
node.on("mouseover", function(d) {
$("#info_box").html(d.info_box);
current_info = d.name;
});
node.on("mouseout", function(d) {
$("#info_box").html('');
});
force.start();
}
function tick() {
link.attr("d", linkArc);
node.attr("transform", function(d) { return "translate(" + d.x + "," + d.y + ")"; });
}
function linkArc(d) {
var dx = d.target.x - d.source.x,
dy = d.target.y - d.source.y,
dr = Math.sqrt(dx * dx + dy * dy);
return "M" + d.source.x + "," + d.source.y + "A" + dr + "," + dr + " 0 0,1 " + d.target.x + "," + d.target.y;
}
function set_in_progress(stack, nodes) {
if (stack.in_progress === true) { in_progress = true; }
for (var i = 0; i < nodes.length; i++) {
var d = nodes[i];
if (d.in_progress === true){ in_progress = true; return false; }
}
}
function findNode(name) {
for (var i = 0; i < nodes.length; i++) {
if (nodes[i].name === name){ return nodes[i]; }
}
}
function findNodeIndex(name) {
for (var i = 0; i < nodes.length; i++) {
if (nodes[i].name === name){ return i; }
}
}
function addNode (node) {
nodes.push(node);
needs_update = true;
}
function removeNode (name) {
var i = 0;
var n = findNode(name);
while (i < links.length) {
if (links[i].source === n || links[i].target === n) {
links.splice(i, 1);
} else {
i++;
}
}
nodes.splice(findNodeIndex(name),1);
needs_update = true;
}
function remove_nodes(old_nodes, new_nodes){
//Check for removed nodes
for (var i=0;i<old_nodes.length;i++) {
var remove_node = true;
for (var j=0;j<new_nodes.length;j++) {
if (old_nodes[i].name === new_nodes[j].name){
remove_node = false;
break;
}
}
if (remove_node === true){
removeNode(old_nodes[i].name);
}
}
}
function build_links(){
for (var i=0;i<nodes.length;i++){
build_node_links(nodes[i]);
build_reverse_links(nodes[i]);
}
}
function build_node_links(node){
for (var j=0;j<node.required_by.length;j++){
var push_link = true;
var target_idx = '';
var source_idx = findNodeIndex(node.name);
//make sure target node exists
try {
target_idx = findNodeIndex(node.required_by[j]);
} catch(err) {
console.log(err);
push_link =false;
}
//check for duplicates
for (var lidx=0;lidx<links.length;lidx++) {
if (links[lidx].source === source_idx && links[lidx].target === target_idx) {
push_link=false;
break;
}
}
if (push_link === true && (source_idx && target_idx)){
links.push({
'source':source_idx,
'target':target_idx,
'value':1,
'link_type': node.link_type
});
}
}
}
function build_reverse_links(node){
for (var i=0;i<nodes.length;i++){
if(nodes[i].required_by){
for (var j=0;j<nodes[i].required_by.length;j++){
var dependency = nodes[i].required_by[j];
//if new node is required by existing node, push new link
if(node.name === dependency){
links.push({
'source':findNodeIndex(nodes[i].name),
'target':findNodeIndex(node.name),
'value':1,
'link_type': node.link_type
});
}
}
}
}
}
function ajax_poll(poll_time){
setTimeout(function() {
$.getJSON(ajax_url, function(json) {
//update d3 data element
$("#d3_data").attr("data-d3_data", JSON.stringify(json));
//update stack
$("#stack_box").html(json.environment.info_box);
set_in_progress(json.environment, json.nodes);
needs_update = false;
//Check Remove nodes
remove_nodes(nodes, json.nodes);
//Check for updates and new nodes
json.nodes.forEach(function(d){
current_node = findNode(d.name);
//Check if node already exists
if (current_node) {
//Node already exists, just update it
current_node.status = d.status;
//Status has changed, image should be updated
if (current_node.image !== d.image){
current_node.image = d.image;
var this_image = d3.select("#image_"+current_node.name);
this_image
.transition()
.attr("x", function(d) { return d.image_x + 5; })
.duration(100)
.transition()
.attr("x", function(d) { return d.image_x - 5; })
.duration(100)
.transition()
.attr("x", function(d) { return d.image_x + 5; })
.duration(100)
.transition()
.attr("x", function(d) { return d.image_x - 5; })
.duration(100)
.transition()
.attr("xlink:href", d.image)
.transition()
.attr("x", function(d) { return d.image_x; })
.duration(100)
.ease("bounce");
}
//Status has changed, update info_box
current_node.info_box = d.info_box;
} else {
addNode(d);
build_links();
}
});
//if any updates needed, do update now
if (needs_update === true){
update();
}
});
//if no nodes still in progress, slow AJAX polling
if (in_progress === false) { poll_time = 30000; }
else { poll_time = 3000; }
ajax_poll(poll_time);
}, poll_time);
}
if ($(murano_container).length){
var width = $(murano_container).width(),
height = 500,
environment_id = $("#environment_id").data("environment_id"),
ajax_url = '/murano/'+environment_id+'/services/get_d3_data',
graph = $("#d3_data").data("d3_data"),
force = d3.layout.force()
.nodes(graph.nodes)
.links([])
.gravity(0.3)
.charge(-2000)
.linkDistance(100)
.size([width, height])
.on("tick", tick),
svg = d3.select(murano_container).append("svg")
.attr("width", width)
.attr("height", height),
node = svg.selectAll(".node"),
link = svg.selectAll(".link"),
needs_update = false,
nodes = force.nodes(),
links = force.links();
build_links();
update();
//Load initial Stack box
$("#stack_box").html(graph.environment.info_box);
//On Page load, set Action In Progress
var in_progress = false;
set_in_progress(graph.environment, node);
//If status is In Progress, start AJAX polling
var poll_time = 0;
if (in_progress === true) { poll_time = 3000; }
else { poll_time = 30000; }
ajax_poll(poll_time);
}

View File

@@ -0,0 +1,4 @@
<img src="{{ app_image }}" width="35px" height="35px" />
<h3>Name: {{ name }}</h3>
<p>Type: {{ type }}</p>
<p>Status: {{ status }}</p>

View File

@@ -0,0 +1,10 @@
{% load i18n sizeformat %}
{% load static %}
<div id="resource_container">
<script type="text/javascript" src="{% static 'muranodashboard/js/horizon.muranotopology.js' %}"></script>
<div id="info_box"></div>
<div id="stack_box"></div>
<div id="murano_application_topology"></div>
<div id="environment_id" data-environment_id="{{ environment_id }}"></div>
<div id="d3_data" data-d3_data="{{ d3_data }}"></div>
</div>

View File

@@ -0,0 +1,2 @@
<h3>Environment: {{ name }}</h3>
<p>Status: {{ status }}</p>

View File

@@ -1,6 +1,8 @@
{% load i18n %}
{% load url from future %}
{% load static %}
{% block page_header %}
<link rel="stylesheet" type="text/css" href="{% static 'muranodashboard/css/topology.css' %}" />
<div class='page-header'>
<h2>
{% blocktrans %}Environment: {{ environment_name }}{% endblocktrans %}

View File

@@ -0,0 +1,5 @@
{% load i18n %}
{% block main %}
{{ table.render }}
{% endblock %}

View File

@@ -0,0 +1,5 @@
<img src="{{ unit_image }}" width="35px" height="35px" />
<h3>Name: {{ name }}</h3>
<p>OS: {{ os }}</p>
<p>Image: {{ image }}</p>
<p>Flavor: {{ flavor }}</p>

View File

@@ -5,7 +5,11 @@
{% block page_header %}
{% include "services/_page_header.html" %}
{% endblock page_header %}
{% block main %}
{{ table.render }}
<div class="row-fluid">
<div class="span12">
{{ tab_group.render }}
</div>
</div>
{% endblock %}