diff --git a/horizon/static/horizon/js/horizon.networktopology.js b/horizon/static/horizon/js/horizon.networktopology.js
new file mode 100644
index 000000000..9007ddb84
--- /dev/null
+++ b/horizon/static/horizon/js/horizon.networktopology.js
@@ -0,0 +1,255 @@
+/* Namespace for core functionality related to Network Topology. */
+horizon.network_topology = {
+ model: null,
+ network_margin: 270,
+ min_network_height:500,
+ port_margin: 20,
+ device_initial_position : 40,
+ device_last_position : 0,
+ device_left_position : 90,
+ device_margin : 20,
+ device_min_height : 45,
+ port_initial_position: 1,
+ network_index: {},
+ network_color_unit: 0,
+ network_saturation: 1,
+ network_lightness: 0.7,
+ reload_duration: 10000,
+ spinner:null,
+ init:function(){
+ var self = this;
+ $("#topologyCanvas").spin(horizon.conf.spinner_options.modal);
+ self.retrieve_network_info();
+ setInterval(horizon.network_topology.retrieve_network_info,
+ horizon.network_topology.reload_duration);
+ },
+ retrieve_network_info: function(){
+ var self = this;
+ if(!$("#networktopology")) {
+ return;
+ }
+ $.getJSON($("#networktopology").data('networktopology'),
+ function(data) {
+ self.draw_graph(data);
+ }
+ );
+ },
+ draw_loading: function () {
+ $("#topologyCanvas").spin(horizon.conf.spinner_options.modal);
+ },
+ draw_graph: function(data){
+ var canvas = $("#topologyCanvas");
+ var networks = $("#topologyCanvas > .networks");
+ canvas.spin(false);
+ networks.empty();
+ this.model = data;
+ this.device_last_position = this.device_initial_position;
+ this.draw_networks();
+ this.draw_routers();
+ this.draw_servers();
+ canvas.height(
+ Math.max(this.device_last_position,this.min_network_height)
+ );
+ networks.width(
+ this.model.networks.length * this.network_margin
+ );
+ },
+ network_color: function(network_id){
+ var max_hue = 360;
+ var num_network = this.model.networks.length;
+ if(num_network <= 0){
+ return;
+ }
+ num_network ++;
+ var hue = Math.floor(
+ max_hue/num_network*(this.network_index(network_id) + 1));
+ return this.hsv2rgb(
+ hue, this.network_saturation, this.network_lightness);
+ },
+ //see http://en.wikipedia.org/wiki/HSL_and_HSV
+ hsv2rgb:function (h, s, v) {
+ var hi = Math.round(h/60) % 6;
+ var f = h/60 - hi;
+ var p = v*(1 - s);
+ var q = v*(1 - f*s);
+ var t = v*(1 - (1 - f)*s);
+ switch(hi){
+ case 0:
+ r = v;
+ g = t;
+ b = p;
+ break;
+ case 1:
+ r = q;
+ g = v;
+ b = p;
+ break;
+ case 2:
+ r = p;
+ g = v;
+ b = t;
+ break;
+ case 3:
+ r = p;
+ g = q;
+ b = v;
+ break;
+ case 4:
+ r = t;
+ g = p;
+ b = v;
+ break;
+ case 5:
+ r = v;
+ g = p;
+ b = q;
+ break;
+ }
+ return "rgb(" + Math.round(r*255) + "," + Math.round(g*255) + "," + Math.round(b*255) + ")";
+ },
+ draw_networks: function(){
+ var self = this;
+ var networks = $("#topologyCanvas > .networks");
+ $.each(self.model.networks, function(index, network){
+ var label = (network.name != "")? network.name : network.id;
+ if(network['router:external']){
+ label += " (external) ";
+ }
+ label += self.select_cidr(network.id);
+ self.network_index[network.id] = index;
+ var network_html = $("
").attr("id", network.id);
+ var nicname_html = $("
" + label + " ");
+ nicname_html
+ .click(function (){
+ window.location.href = network.url;})
+ .css (
+ {'background-color':self.network_color(network.id)})
+ .appendTo(network_html);
+ networks.append(network_html);
+ });
+ },
+ select_cidr:function(network_id){
+ var cidr = "";
+ $.each(this.model.subnets, function(index, subnet){
+ if(subnet.network_id != network_id){
+ return;
+ }
+ cidr += " [ " + subnet.cidr + " ] ";
+ });
+ return cidr;
+ },
+ draw_devices: function(type){
+ var self = this;
+ $.each(self.model[type + 's'], function(index, device){
+ var id = device.id;
+ var name = (device.name != "")? device.name : device.id;
+ var ports = self.select_port(id);
+ if(ports.length <= 0){
+ return;
+ }
+ var main_port = self.select_main_port(ports);
+ var parent_network = main_port.network_id;
+ var device_html = $("
");
+ device_html
+ .attr('id', device.id)
+ .css({top: self.device_last_position, position: 'absolute'})
+ .append($(" " + type + " "))
+ .click(function (e){
+ e.stopPropagation();
+ window.location.href = device.url;
+ });
+ var name_html = $(" ")
+ .html(device.name)
+ .attr('title', device.name)
+ .appendTo(device_html);
+ var port_position = self.port_initial_position;
+ $.each(ports, function(){
+ var port = this;
+ var port_html = self.port_html(port);
+ port_position += self.port_margin;
+ self.port_css(port_html, port_position, parent_network, port.network_id);
+ device_html.append(port_html);
+ });
+ port_position += self.port_margin;
+ device_html.css(
+ {height: Math.max(self.device_min_height, port_position) + "px"});
+ self.device_last_position += device_html.height() + self.device_margin;
+ $("#" + parent_network).append(device_html);
+ });
+ },
+ sum_port_length: function(network_id, ports){
+ var self = this;
+ var sum_port_length = 0;
+ var base_index = self.network_index(network_id);
+ $.each(ports, function(index, port){
+ sum_port_length += base_index - self.network_index(port.network_id);
+ });
+ return sum_port_length;
+ },
+ select_main_port: function(ports){
+ var main_port_index = 0;
+ var MAX_INT = 4294967295;
+ var min_port_length = MAX_INT;
+ $.each(ports, function(index, port){
+ port_length = horizon.network_topology.sum_port_length(port.network_id, ports)
+ if(port_length < min_port_length){
+ min_port_length = port_length;
+ main_port_index = index;
+ }
+ })
+ return ports[main_port_index];
+ },
+ draw_routers: function(){
+ this.draw_devices('router');
+ },
+ draw_servers: function(){
+ this.draw_devices('server');
+ },
+ select_port: function(device_id){
+ return $.map(this.model.ports,function(port, index){
+ if (port.device_id == device_id) {
+ return port;
+ }
+ });
+ },
+ port_html: function(port){
+ var self = this;
+ var port_html = $('');
+ var ip_label = "";
+ $.each(port.fixed_ips, function(){
+ ip_label += this.ip_address + " ";
+ })
+ var ip_html = $(' ').html(ip_label);
+ port_html
+ .append(ip_html)
+ .css({'background-color':self.network_color(port.network_id)})
+ .click(function (e){
+ e.stopPropagation();
+ window.location.href = port.url;
+ });
+ return port_html;
+ },
+ port_css: function(port_html, position, network_a, network_b){
+ var self = this;
+ var index_diff = self.network_index(network_a) - self.network_index(network_b);
+ var width = self.network_margin * index_diff;
+ var direction = "left";
+ if(width < 0){
+ direction = "right";
+ width += self.network_margin;
+ }
+ width = Math.abs(width) + self.device_left_position;
+ var port_css = {};
+ port_css['width'] = width + "px";
+ port_css['top'] = position + "px";
+ port_css[direction] = (-width -3) + "px";
+ port_html.addClass(direction).css(port_css);
+ },
+ network_index: function(network_id){
+ return horizon.network_topology.network_index[network_id];
+ }
+}
+
+horizon.addInitFunction(function () {
+ horizon.network_topology.init();
+});
diff --git a/horizon/templates/horizon/_scripts.html b/horizon/templates/horizon/_scripts.html
index 0055b8794..a76106f5a 100644
--- a/horizon/templates/horizon/_scripts.html
+++ b/horizon/templates/horizon/_scripts.html
@@ -32,6 +32,7 @@
+
{% endcompress %}
{% comment %} Client-side Templates (These should *not* be inside the "compress" tag.) {% endcomment %}
diff --git a/openstack_dashboard/dashboards/project/dashboard.py b/openstack_dashboard/dashboards/project/dashboard.py
index 79df86a64..419b0e66c 100644
--- a/openstack_dashboard/dashboards/project/dashboard.py
+++ b/openstack_dashboard/dashboards/project/dashboard.py
@@ -28,7 +28,8 @@ class BasePanels(horizon.PanelGroup):
'images_and_snapshots',
'access_and_security',
'networks',
- 'routers')
+ 'routers',
+ 'network_topology')
class ObjectStorePanels(horizon.PanelGroup):
diff --git a/openstack_dashboard/dashboards/project/network_topology/__init__.py b/openstack_dashboard/dashboards/project/network_topology/__init__.py
new file mode 100644
index 000000000..3b67d1057
--- /dev/null
+++ b/openstack_dashboard/dashboards/project/network_topology/__init__.py
@@ -0,0 +1,19 @@
+# vim: tabstop=4 shiftwidth=4 softtabstop=4
+
+# Copyright 2012 United States Government as represented by the
+# Administrator of the National Aeronautics and Space Administration.
+# All Rights Reserved.
+#
+# Copyright 2013 NTT MCL, 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.
diff --git a/openstack_dashboard/dashboards/project/network_topology/panel.py b/openstack_dashboard/dashboards/project/network_topology/panel.py
new file mode 100644
index 000000000..4277da735
--- /dev/null
+++ b/openstack_dashboard/dashboards/project/network_topology/panel.py
@@ -0,0 +1,34 @@
+# vim: tabstop=4 shiftwidth=4 softtabstop=4
+
+# Copyright 2012 United States Government as represented by the
+# Administrator of the National Aeronautics and Space Administration.
+# All Rights Reserved.
+#
+# Copyright 2013 NTT MCL, 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 horizon
+
+from openstack_dashboard.dashboards.project import dashboard
+
+
+class NetworkTopology(horizon.Panel):
+ name = _("Network Topology")
+ slug = 'network_topology'
+ permissions = ('openstack.services.network', )
+
+
+dashboard.Project.register(NetworkTopology)
diff --git a/openstack_dashboard/dashboards/project/network_topology/templates/network_topology/index.html b/openstack_dashboard/dashboards/project/network_topology/templates/network_topology/index.html
new file mode 100644
index 000000000..f5dd563b3
--- /dev/null
+++ b/openstack_dashboard/dashboards/project/network_topology/templates/network_topology/index.html
@@ -0,0 +1,35 @@
+{% extends 'base.html' %}
+{% load i18n %}
+{% block title %}{% trans "Network Topology" %}{% endblock %}
+
+{% block page_header %}
+ {% include "horizon/common/_page_header.html" with title=_("Network Topology") %}
+{% endblock page_header %}
+
+{% block main %}
+
+
+{%trans "This pane needs javascript support." %}
+
+
+
+
+
+{% endblock %}
diff --git a/openstack_dashboard/dashboards/project/network_topology/urls.py b/openstack_dashboard/dashboards/project/network_topology/urls.py
new file mode 100644
index 000000000..95c5e9fb0
--- /dev/null
+++ b/openstack_dashboard/dashboards/project/network_topology/urls.py
@@ -0,0 +1,31 @@
+# vim: tabstop=4 shiftwidth=4 softtabstop=4
+
+# Copyright 2012 United States Government as represented by the
+# Administrator of the National Aeronautics and Space Administration.
+# All Rights Reserved.
+#
+# Copyright 2013 NTT MCL, 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.conf.urls.defaults import url, patterns
+
+from .views import (NetworkTopology, JSONView)
+
+
+urlpatterns = patterns(
+ 'openstack_dashboard.dashboards.project.network_topology.views',
+ url(r'^$', NetworkTopology.as_view(), name='index'),
+ url(r'^json$', JSONView.as_view(), name='json'),
+)
diff --git a/openstack_dashboard/dashboards/project/network_topology/views.py b/openstack_dashboard/dashboards/project/network_topology/views.py
new file mode 100644
index 000000000..d96ea32b9
--- /dev/null
+++ b/openstack_dashboard/dashboards/project/network_topology/views.py
@@ -0,0 +1,97 @@
+# vim: tabstop=4 shiftwidth=4 softtabstop=4
+
+# Copyright 2012 United States Government as represented by the
+# Administrator of the National Aeronautics and Space Administration.
+# All Rights Reserved.
+#
+# Copyright 2013 NTT MCL 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.core.urlresolvers import reverse
+from django.http import HttpResponse
+from django.utils import simplejson
+from django.views.generic import TemplateView
+from django.views.generic import View
+
+from openstack_dashboard import api
+
+
+class NetworkTopology(TemplateView):
+ template_name = 'project/network_topology/index.html'
+
+
+class JSONView(View):
+ def add_resource_url(self, view, resources):
+ for resource in resources:
+ resource['url'] = reverse(view, None, [str(resource['id'])])
+
+ def _select_port_by_network_id(self, ports, network_id):
+ for port in ports:
+ if port['network_id'] == network_id:
+ return True
+ return False
+
+ def get(self, request, *args, **kwargs):
+ data = {}
+ # Get nova data
+ novaclient = api.nova.novaclient(request)
+ servers = novaclient.servers.list()
+ data['servers'] = [{'name': server.name,
+ 'status': server.status,
+ 'id': server.id} for server in servers]
+ self.add_resource_url('horizon:project:instances:detail',
+ data['servers'])
+ # Get quantum data
+ quantumclient = api.quantum.quantumclient(request)
+ networks = quantumclient.list_networks()
+ subnets = quantumclient.list_subnets()
+ ports = quantumclient.list_ports()
+ routers = quantumclient.list_routers()
+ data['networks'] = sorted(networks.get('networks', []),
+ key=lambda x: x.get('router:external'),
+ reverse=True)
+ self.add_resource_url('horizon:project:networks:detail',
+ data['networks'])
+ data['subnets'] = subnets.get('subnets', [])
+ data['ports'] = ports.get('ports', [])
+ self.add_resource_url('horizon:project:networks:ports:detail',
+ data['ports'])
+ data['routers'] = routers.get('routers', [])
+ # user can't see port on shared network. so we are
+ # adding fake port based on router information
+ for router in data['routers']:
+ external_gateway_info = router.get('external_gateway_info')
+ if not external_gateway_info:
+ continue
+ external_network = external_gateway_info.get(
+ 'network_id')
+ if not external_network:
+ continue
+ if self._select_port_by_network_id(data['ports'],
+ external_network):
+ continue
+ fake_port = {'id': 'fake%s' % external_network,
+ 'network_id': external_network,
+ 'url': reverse(
+ 'horizon:project:networks:detail',
+ None,
+ [external_network]),
+ 'device_id': router['id'],
+ 'fixed_ips': []}
+ data['ports'].append(fake_port)
+
+ self.add_resource_url('horizon:project:routers:detail',
+ data['routers'])
+ json_string = simplejson.dumps(data, ensure_ascii=False)
+ return HttpResponse(json_string, mimetype='text/json')
diff --git a/openstack_dashboard/static/dashboard/img/router.png b/openstack_dashboard/static/dashboard/img/router.png
new file mode 100644
index 000000000..2726dd9f3
Binary files /dev/null and b/openstack_dashboard/static/dashboard/img/router.png differ
diff --git a/openstack_dashboard/static/dashboard/img/server.png b/openstack_dashboard/static/dashboard/img/server.png
new file mode 100644
index 000000000..c0a236d9a
Binary files /dev/null and b/openstack_dashboard/static/dashboard/img/server.png differ
diff --git a/openstack_dashboard/static/dashboard/less/horizon.less b/openstack_dashboard/static/dashboard/less/horizon.less
index afd8d1fcc..c0f58466b 100644
--- a/openstack_dashboard/static/dashboard/less/horizon.less
+++ b/openstack_dashboard/static/dashboard/less/horizon.less
@@ -1744,6 +1744,311 @@ label.log-length {
}
+/* Styling for network topology */
+.box-sizing(@box: border-box) {
+ -webkit-box-sizing: @box;
+ -moz-box-sizing: @box;
+ -ms-box-sizing: @box;
+ -o-box-sizing: @box;
+ box-sizing: @box;
+}
+
+@-webkit-keyframes progress-bar-stripes {
+ from {
+ background-position: 20px 0;
+ }
+ to {
+ background-position: 0 0;
+ }
+}
+
+@-moz-keyframes progress-bar-stripes {
+ from {
+ background-position: 20px 0;
+ }
+ to {
+ background-position: 0 0;
+ }
+}
+
+@-ms-keyframes progress-bar-stripes {
+ from {
+ background-position: 20px 0;
+ }
+ to {
+ background-position: 0 0;
+ }
+}
+
+@-o-keyframes progress-bar-stripes {
+ from {
+ background-position: 0 0;
+ }
+ to {
+ background-position: 20px 0;
+ }
+}
+
+@keyframes progress-bar-stripes {
+ from {
+ background-position: 20px 0;
+ }
+ to {
+ background-position: 0 0;
+ }
+}
+
+#topologyCanvas {
+ .box-sizing();
+ width: 100%;
+ height: 500px;
+ padding: 25px;
+ padding-left: 50px;
+ background: #efefef;
+}
+div.networks {
+ height: 100%;
+}
+div.network {
+ .box-sizing();
+ float: left;
+ width: 270px;
+ height: 100%;
+ position: relative;
+ .nicname {
+ .box-sizing();
+ height: 100%;
+ width: 17px;
+ border-radius: 17px;
+ z-index: 200;
+ color:#fff;
+ position: absolute;
+ left: -8px;
+ top: 0px;
+ cursor: pointer;
+ &:hover {
+ background-image: -webkit-linear-gradient(rgba(0, 0, 0, 0.15), rgba(0, 0, 0, 0.15));
+ background-image: -moz-linear-gradient(rgba(0, 0, 0, 0.15), rgba(0, 0, 0, 0.15));
+ background-image: -ms-linear-gradient(rgba(0, 0, 0, 0.15), rgba(0, 0, 0, 0.15));
+ background-image: -o-linear-gradient(rgba(0, 0, 0, 0.15), rgba(0, 0, 0, 0.15));
+ background-image: linear-gradient(rgba(0, 0, 0, 0.15), rgba(0, 0, 0, 0.15));
+ background-size: 10px 10px;
+ }
+ h3 {
+ font-size: 12px;
+ line-height: 1;
+ position: relative;
+ font-weight: normal;
+ top:60%;
+ color:#fff;
+ left:-1px;
+ letter-spacing: 0.2em;
+ -webkit-transform: rotate(-90deg);
+ -moz-transform: rotate(-90deg);
+ -ms-transform: rotate(-90deg);
+ -o-transform: rotate(-90deg);
+ transform: rotate(-90deg);
+ white-space: nowrap;
+ text-shadow: 0px 0px 5px #000;
+ span.ip {
+ margin-left: 0.5em;
+ color: #000;
+ font-weight: normal;
+ font-size: 90%;
+ text-shadow: 0px 0px 0px #000;
+ }
+ }
+ }
+ .router, .server, .device {
+ .box-sizing();
+ cursor: pointer;
+ width: 90px;
+ border: 3px solid #666;
+ position: absolute;
+ top:30px;
+ left:90px;
+ padding: 0 3px;
+ background: #666;
+ margin-bottom: 20px;
+ &:before,&:after {
+ content: "";
+ width: 90px;
+ height: 34px;
+ text-align: center;
+ position: absolute;
+ border: 3px solid #666;
+ .box-sizing();
+ background: #fff;
+ border-radius:50%;
+ top:-19px;
+ left:-3px;
+ }
+ &:after {
+ content:"";
+ color: #fff;
+ background:#666;
+ border-radius:50%;
+ top:auto;
+ bottom:-19px;
+ font-size: 11px;
+ line-height: 30px;
+ }
+ span.devicename {
+ position: absolute;
+ color: #fff;
+ bottom: -10px;
+ font-size: 12px;
+ line-height: 14px;
+ left:-4px;
+ width: 100%;
+ text-align: center;
+ z-index:300;
+ i {
+ display: inline-block;
+ width: 14px;
+ height:14px;
+ background: #fff url(/static/dashboard/img/router.png) no-repeat center center;
+ background-size: 12px 12px;
+ margin-right: 3px;
+ vertical-align: middle;
+ border-radius: 20px;
+ }
+ }
+ span.name {
+ overflow: hidden;
+ white-space: nowrap;
+ text-overflow: ellipsis;
+ display: block;
+ font-size : 12px;
+ position: relative;
+ z-index:10;
+ text-align: center;
+ top:-10px;
+ padding: 0 3px;
+ }
+ div.port {
+ text-align: right;
+ min-width: 90px;
+ height: 10px;
+ font:0px/0px sans-serif;
+ position: absolute;
+ left:-91px;
+ top:8px;
+ background-color: #37a9e3;
+ background-image: none;
+ -webkit-background-size: 20px 20px;
+ -moz-background-size: 20px 20px;
+ -o-background-size: 20px 20px;
+ background-size: 20px 20px;
+ z-index:100;
+ span.ip {
+ .box-sizing();
+ font-size: 9px;
+ line-height: 1;
+ text-shadow: 0px -1px #fff;
+ position: relative;
+ top:-1em;
+ width: 90px;
+ display: inline-block;
+ padding-right:8px;
+ padding-left: 8px;
+ word-wrap:break-word;
+ word-break:break-all;
+ }
+ &.right {
+ left:auto;
+ right:-92px;
+ width: 92px;
+ text-align: left;
+ span.ip {
+ }
+ }
+ }
+ &:hover {
+ div.port {
+ cursor:pointer;
+ background-color: #2688c0;
+ -webkit-animation: progress-bar-stripes 1s linear infinite;
+ -moz-animation: progress-bar-stripes 1s linear infinite;
+ -ms-animation: progress-bar-stripes 1s linear infinite;
+ -o-animation: progress-bar-stripes 1s linear infinite;
+ animation: progress-bar-stripes 1s linear infinite;
+ &:hover {
+ -webkit-animation: progress-bar-stripes 0.3s linear infinite;
+ -moz-animation: progress-bar-stripes 0.3s linear infinite;
+ -ms-animation: progress-bar-stripes 0.3s linear infinite;
+ -o-animation: progress-bar-stripes 0.3s linear infinite;
+ animation: progress-bar-stripes 0.3s linear infinite;
+ }
+ }
+ background-color: #444;
+ border-color: #444;
+ &:before {
+ border-color: #444;
+ }
+ &:after {
+ background-color: #444;
+ border-color: #444;
+ }
+ }
+ }
+ .device {
+ border:none;
+ background: transparent;
+ }
+ .server {
+ border-radius: 5px;
+ background: #fff;
+ span.devicename {
+ bottom: 0px;
+ font-size: 12px;
+ line-height: 14px;
+ left:-4px;
+ i {
+ display: inline-block;
+ width: 14px;
+ height:14px;
+ background: url(/static/dashboard/img/server.png) no-repeat center center;
+ background-size: 12px 12px;
+ margin-right: 3px;
+ vertical-align: middle;
+ }
+ }
+ span.name {
+ top:5px;
+ padding: 0 3px;
+ }
+ &:before {
+ border:none;
+ background: transparent;
+ }
+ &:after {
+ content: "";
+ width: 100%;
+ line-height: 1.2;
+ position: absolute;
+ text-align: center;
+ border-radius: 0;
+ background: #666;
+ color: #fff;
+ font-size: 11px;
+ height: 1.5em;
+ bottom:0px;
+ left: 0px;
+ }
+ &:hover {
+ background: #fff;
+ }
+ }
+}
+
+.launchButtons {
+ text-align: right;
+ margin: 10px 0px 15px 10px;
+ a.btn {
+ margin-left: 10px;
+ }
+}