diff --git a/ryu/app/gui_topology/__init__.py b/ryu/app/gui_topology/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/ryu/app/gui_topology/gui_topology.py b/ryu/app/gui_topology/gui_topology.py new file mode 100644 index 00000000..ed4857a5 --- /dev/null +++ b/ryu/app/gui_topology/gui_topology.py @@ -0,0 +1,68 @@ +# Copyright (C) 2014 Nippon Telegraph and Telephone Corporation. +# +# 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. + +""" +Usage example + +1. Join switches (use your favorite method): +$ sudo mn --controller remote --topo tree,depth=3 + +2. Run this application: +$ PYTHONPATH=. ./bin/ryu run \ + --observe-links ryu/app/gui_topology/gui_topology.py + +3. Access http://:8080 with your web browser. +""" + +import os + +from webob.static import DirectoryApp + +from ryu.app.wsgi import ControllerBase, WSGIApplication, route +from ryu.base import app_manager + + +PATH = os.path.dirname(__file__) + + +# Serving static files +class GUIServerApp(app_manager.RyuApp): + _CONTEXTS = { + 'wsgi': WSGIApplication, + } + + def __init__(self, *args, **kwargs): + super(GUIServerApp, self).__init__(*args, **kwargs) + + wsgi = kwargs['wsgi'] + wsgi.register(GUIServerController) + + +class GUIServerController(ControllerBase): + def __init__(self, req, link, data, **config): + super(GUIServerController, self).__init__(req, link, data, **config) + path = "%s/html/" % PATH + self.static_app = DirectoryApp(path) + + @route('topology', '/{filename:.*}') + def static_handler(self, req, **kwargs): + if kwargs['filename']: + req.path_info = kwargs['filename'] + return self.static_app(req) + + +app_manager.require_app('ryu.app.rest_topology') +app_manager.require_app('ryu.app.ws_topology') +app_manager.require_app('ryu.app.ofctl_rest') diff --git a/ryu/app/gui_topology/html/index.html b/ryu/app/gui_topology/html/index.html new file mode 100644 index 00000000..47f05e3a --- /dev/null +++ b/ryu/app/gui_topology/html/index.html @@ -0,0 +1,12 @@ + + + + + + + + +

Ryu Topology Viewer

+ + + diff --git a/ryu/app/gui_topology/html/router.svg b/ryu/app/gui_topology/html/router.svg new file mode 100755 index 00000000..128a1265 --- /dev/null +++ b/ryu/app/gui_topology/html/router.svg @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ryu/app/gui_topology/html/ryu.topology.css b/ryu/app/gui_topology/html/ryu.topology.css new file mode 100644 index 00000000..b921f867 --- /dev/null +++ b/ryu/app/gui_topology/html/ryu.topology.css @@ -0,0 +1,30 @@ +#topology { + border: 1px solid #000000; +} + +.node { +} + +.node.fixed { + fill: #C0C0C0; +} + +.node text { + font-size: 14px; +} + +.link { + stroke: #090909; + stroke-opacity: .6; + stroke-width: 2px; +} + +.port circle { + stroke: black; + fill: #C5F9F9; +} + +.port text { + font-size: 10px; +} + diff --git a/ryu/app/gui_topology/html/ryu.topology.js b/ryu/app/gui_topology/html/ryu.topology.js new file mode 100644 index 00000000..9d1102e7 --- /dev/null +++ b/ryu/app/gui_topology/html/ryu.topology.js @@ -0,0 +1,250 @@ +var CONF = { + image: { + width: 50, + height: 40 + }, + force: { + width: 960, + height: 500, + dist: 200, + charge: -600 + } +}; + +var ws = new WebSocket("ws://" + location.host + "/v1.0/topology/ws"); +ws.onmessage = function(event) { + var data = JSON.parse(event.data); + + var result = rpc[data.method](data.params); + + var ret = {"id": data.id, "jsonrpc": "2.0", "result": result}; + this.send(JSON.stringify(ret)); +} + +function trim_zero(s) { + return s.replace(/^0+/, ""); +} + +function dpid_to_int(dpid) { + return Number("0x" + dpid); +} + +var elem = { + force: d3.layout.force() + .size([CONF.force.width, CONF.force.height]) + .charge(CONF.force.charge) + .linkDistance(CONF.force.dist) + .on("tick", _tick), + svg: d3.select("body").append("svg") + .attr("id", "topology") + .attr("width", CONF.force.width) + .attr("height", CONF.force.height), + console: d3.select("body").append("div") + .attr("id", "console") + .attr("width", CONF.force.width) +}; +function _tick() { + elem.link.attr("x1", function(d) { return d.source.x; }) + .attr("y1", function(d) { return d.source.y; }) + .attr("x2", function(d) { return d.target.x; }) + .attr("y2", function(d) { return d.target.y; }); + + elem.node.attr("transform", function(d) { return "translate(" + d.x + "," + d.y + ")"; }); + + elem.port.attr("transform", function(d) { + var p = topo.get_port_point(d); + return "translate(" + p.x + "," + p.y + ")"; + }); +} +elem.drag = elem.force.drag().on("dragstart", _dragstart); +function _dragstart(d) { + var dpid = dpid_to_int(d.dpid) + d3.json("/stats/flow/" + dpid, function(e, data) { + flows = data[dpid]; + console.log(flows); + elem.console.selectAll("ul").remove(); + li = elem.console.append("ul") + .selectAll("li"); + li.data(flows).enter().append("li") + .text(function (d) { return JSON.stringify(d, null, " "); }); + }); + d3.select(this).classed("fixed", d.fixed = true); +} +elem.node = elem.svg.selectAll(".node"); +elem.link = elem.svg.selectAll(".link"); +elem.port = elem.svg.selectAll(".port"); +elem.update = function () { + this.force + .nodes(topo.nodes) + .links(topo.links) + .start(); + + this.link = this.link.data(topo.links); + this.link.exit().remove(); + this.link.enter().append("line") + .attr("class", "link"); + + this.node = this.node.data(topo.nodes); + // NOTE: Removing node is not supported. + var nodeEnter = this.node.enter().append("g") + .attr("class", "node") + .on("dblclick", function(d) { d3.select(this).classed("fixed", d.fixed = false); }) + .call(this.drag); + nodeEnter.append("image") + .attr("xlink:href", "./router.svg") + .attr("x", -CONF.image.width/2) + .attr("y", -CONF.image.height/2) + .attr("width", CONF.image.width) + .attr("height", CONF.image.height); + nodeEnter.append("text") + .attr("dx", -CONF.image.width/2) + .attr("dy", CONF.image.height-10) + .text(function(d) { return "dpid: " + trim_zero(d.dpid); }); + + var ports = topo.get_ports(); + this.port.remove(); + this.port = this.svg.selectAll(".port").data(ports); + var portEnter = this.port.enter().append("g") + .attr("class", "port"); + portEnter.append("circle") + .attr("r", 8); + portEnter.append("text") + .attr("dx", -3) + .attr("dy", 3) + .text(function(d) { return trim_zero(d.port_no); }); +}; + +function is_valid_link(link) { + return (link.src.dpid < link.dst.dpid) +} + +var topo = { + nodes: [], + links: [], + node_index: {}, // dpid -> index of nodes array + initialize: function (data) { + this.add_nodes(data.switches); + this.add_links(data.links); + }, + add_nodes: function (switches) { + for (var i = 0; i < switches.length; i++) { + this.nodes[i] = switches[i]; + } + + this.node_index = {}; + for (var i = 0; i < this.nodes.length; i++) { + this.node_index[this.nodes[i].dpid] = i; + } + }, + add_links: function (links) { + for (var i = 0; i < links.length; i++) { + if (!is_valid_link(links[i])) continue; + console.log("add link: " + JSON.stringify(links[i])); + + var src_dpid = links[i].src.dpid; + var dst_dpid = links[i].dst.dpid; + var src_index = this.node_index[src_dpid]; + var dst_index = this.node_index[dst_dpid]; + var link = { + source: src_index, + target: dst_index, + port: { + src: links[i].src, + dst: links[i].dst + } + } + this.links.push(link); + } + }, + delete_links: function (links) { + for (var i = 0; i < links.length; i++) { + if (!is_valid_link(links[i])) continue; + console.log("delete link: " + JSON.stringify(links[i])); + + link_index = this.get_link_index(links[i]); + this.links.splice(link_index, 1); + } + }, + get_link_index: function (link) { + for (var i = 0; i < this.links.length; i++) { + if (link.src.dpid == this.links[i].port.src.dpid && + link.src.port_no == this.links[i].port.src.port_no && + link.dst.dpid == this.links[i].port.dst.dpid && + link.dst.port_no == this.links[i].port.dst.port_no) { + return i; + } + } + return null; + }, + get_ports: function () { + var ports = []; + var pushed = {}; + for (var i = 0; i < this.links.length; i++) { + function _push(p, dir) { + key = p.dpid + ":" + p.port_no + if (key in pushed) { + return 0; + } + + pushed[key] = true; + p.link_idx = i; + p.link_dir = dir; + return ports.push(p); + } + _push(this.links[i].port.src, "source"); + _push(this.links[i].port.dst, "target"); + } + + return ports; + }, + get_port_point: function (d) { + var weight = 0.88; + + var link = this.links[d.link_idx]; + var x1 = link.source.x; + var y1 = link.source.y; + var x2 = link.target.x; + var y2 = link.target.y; + + if (d.link_dir == "target") weight = 1.0 - weight; + + var x = x1 * weight + x2 * (1.0 - weight); + var y = y1 * weight + y2 * (1.0 - weight); + + return {x: x, y: y}; + }, +} + +var rpc = { + event_switch_enter: function (params) { + console.log("Not Implemented: event_switch_enter, " + JSON.stringify(params)); + }, + event_switch_leave: function (params) { + console.log("Not Implemented: event_switch_leave, " + JSON.stringify(params)); + }, + event_link_add: function (links) { + topo.add_links(links); + elem.update(); + return ""; + }, + event_link_delete: function (links) { + topo.delete_links(links); + elem.update(); + return ""; + }, +} + +function initialize_topology() { + d3.json("/v1.0/topology/switches", function(error, switches) { + d3.json("/v1.0/topology/links", function(error, links) { + topo.initialize({switches: switches, links: links}); + elem.update(); + }); + }); +} + +function main() { + initialize_topology(); +} + +main()