Merge "Add latency constraint"
diff --git a/apps/sdnip/src/main/java/org/onlab/onos/sdnip/bgp/BgpConstants.java b/apps/sdnip/src/main/java/org/onlab/onos/sdnip/bgp/BgpConstants.java
index 92f4f07..596720c 100644
--- a/apps/sdnip/src/main/java/org/onlab/onos/sdnip/bgp/BgpConstants.java
+++ b/apps/sdnip/src/main/java/org/onlab/onos/sdnip/bgp/BgpConstants.java
@@ -119,6 +119,31 @@
 
             /** BGP UPDATE ORIGIN: INCOMPLETE. */
             public static final int INCOMPLETE = 2;
+
+            /**
+             * Gets the BGP UPDATE origin type as a string.
+             *
+             * @param type the BGP UPDATE origin type
+             * @return the BGP UPDATE origin type as a string
+             */
+            public static String typeToString(int type) {
+                String typeString = "UNKNOWN";
+
+                switch (type) {
+                case IGP:
+                    typeString = "IGP";
+                    break;
+                case EGP:
+                    typeString = "EGP";
+                    break;
+                case INCOMPLETE:
+                    typeString = "INCOMPLETE";
+                    break;
+                default:
+                    break;
+                }
+                return typeString;
+            }
         }
 
         /**
@@ -142,6 +167,28 @@
 
             /** BGP UPDATE AS_PATH Type: AS_SEQUENCE. */
             public static final int AS_SEQUENCE = 2;
+
+            /**
+             * Gets the BGP AS_PATH type as a string.
+             *
+             * @param type the BGP AS_PATH type
+             * @return the BGP AS_PATH type as a string
+             */
+            public static String typeToString(int type) {
+                String typeString = "UNKNOWN";
+
+                switch (type) {
+                case AS_SET:
+                    typeString = "AS_SET";
+                    break;
+                case AS_SEQUENCE:
+                    typeString = "AS_SEQUENCE";
+                    break;
+                default:
+                    break;
+                }
+                return typeString;
+            }
         }
 
         /**
diff --git a/apps/sdnip/src/main/java/org/onlab/onos/sdnip/bgp/BgpRouteEntry.java b/apps/sdnip/src/main/java/org/onlab/onos/sdnip/bgp/BgpRouteEntry.java
index cd36f72..203e03c 100644
--- a/apps/sdnip/src/main/java/org/onlab/onos/sdnip/bgp/BgpRouteEntry.java
+++ b/apps/sdnip/src/main/java/org/onlab/onos/sdnip/bgp/BgpRouteEntry.java
@@ -309,7 +309,7 @@
         @Override
         public String toString() {
             return MoreObjects.toStringHelper(getClass())
-                .add("type", this.type)
+                .add("type", BgpConstants.Update.AsPath.typeToString(type))
                 .add("segmentAsNumbers", this.segmentAsNumbers)
                 .toString();
         }
@@ -444,7 +444,7 @@
             .add("prefix", prefix())
             .add("nextHop", nextHop())
             .add("bgpId", bgpSession.getRemoteBgpId())
-            .add("origin", origin)
+            .add("origin", BgpConstants.Update.Origin.typeToString(origin))
             .add("asPath", asPath)
             .add("localPref", localPref)
             .add("multiExitDisc", multiExitDisc)
diff --git a/apps/sdnip/src/main/java/org/onlab/onos/sdnip/cli/BgpRoutesListCommand.java b/apps/sdnip/src/main/java/org/onlab/onos/sdnip/cli/BgpRoutesListCommand.java
index 260cfe6..15cb554 100644
--- a/apps/sdnip/src/main/java/org/onlab/onos/sdnip/cli/BgpRoutesListCommand.java
+++ b/apps/sdnip/src/main/java/org/onlab/onos/sdnip/cli/BgpRoutesListCommand.java
@@ -15,10 +15,18 @@
  */
 package org.onlab.onos.sdnip.cli;
 
+import java.util.Collection;
+
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.fasterxml.jackson.databind.node.ArrayNode;
+import com.fasterxml.jackson.databind.node.ObjectNode;
 import org.apache.karaf.shell.commands.Command;
+import org.apache.karaf.shell.commands.Option;
 import org.onlab.onos.cli.AbstractShellCommand;
 import org.onlab.onos.sdnip.SdnIpService;
-import org.onlab.onos.sdnip.bgp.BgpConstants;
+import org.onlab.onos.sdnip.bgp.BgpConstants.Update.AsPath;
+import org.onlab.onos.sdnip.bgp.BgpConstants.Update.Origin;
 import org.onlab.onos.sdnip.bgp.BgpRouteEntry;
 
 /**
@@ -27,46 +35,134 @@
 @Command(scope = "onos", name = "bgp-routes",
          description = "Lists all routes received from BGP")
 public class BgpRoutesListCommand extends AbstractShellCommand {
+    @Option(name = "-s", aliases = "--summary",
+            description = "BGP routes summary",
+            required = false, multiValued = false)
+    private boolean routesSummary = false;
 
-    private static final String FORMAT =
+    private static final String FORMAT_SUMMARY = "Total BGP routes = %d";
+    private static final String FORMAT_ROUTE =
             "prefix=%s, nexthop=%s, origin=%s, localpref=%s, med=%s, aspath=%s, bgpid=%s";
 
     @Override
     protected void execute() {
         SdnIpService service = get(SdnIpService.class);
 
-        for (BgpRouteEntry route : service.getBgpRoutes()) {
-            printRoute(route);
+        // Print summary of the routes
+        if (routesSummary) {
+            printSummary(service.getBgpRoutes());
+            return;
+        }
+
+        // Print all routes
+        printRoutes(service.getBgpRoutes());
+    }
+
+    /**
+     * Prints summary of the routes.
+     *
+     * @param routes the routes
+     */
+    private void printSummary(Collection<BgpRouteEntry> routes) {
+        if (outputJson()) {
+            ObjectMapper mapper = new ObjectMapper();
+            ObjectNode result = mapper.createObjectNode();
+            result.put("totalRoutes", routes.size());
+            print("%s", result);
+        } else {
+            print(FORMAT_SUMMARY, routes.size());
         }
     }
 
+    /**
+     * Prints all routes.
+     *
+     * @param routes the routes to print
+     */
+    private void printRoutes(Collection<BgpRouteEntry> routes) {
+        if (outputJson()) {
+            print("%s", json(routes));
+        } else {
+            for (BgpRouteEntry route : routes) {
+                printRoute(route);
+            }
+        }
+    }
+
+    /**
+     * Prints a BGP route.
+     *
+     * @param route the route to print
+     */
     private void printRoute(BgpRouteEntry route) {
         if (route != null) {
-            print(FORMAT, route.prefix(), route.nextHop(),
-                    originToString(route.getOrigin()), route.getLocalPref(),
-                    route.getMultiExitDisc(), route.getAsPath(),
-                    route.getBgpSession().getRemoteBgpId());
+            print(FORMAT_ROUTE, route.prefix(), route.nextHop(),
+                  Origin.typeToString(route.getOrigin()),
+                  route.getLocalPref(), route.getMultiExitDisc(),
+                  route.getAsPath(), route.getBgpSession().getRemoteBgpId());
         }
     }
 
-    private static String originToString(int origin) {
-        String originString = "UNKNOWN";
+    /**
+     * Produces a JSON array of routes.
+     *
+     * @param routes the routes with the data
+     * @return JSON array with the routes
+     */
+    private JsonNode json(Collection<BgpRouteEntry> routes) {
+        ObjectMapper mapper = new ObjectMapper();
+        ArrayNode result = mapper.createArrayNode();
 
-        switch (origin) {
-        case BgpConstants.Update.Origin.IGP:
-            originString = "IGP";
-            break;
-        case BgpConstants.Update.Origin.EGP:
-            originString = "EGP";
-            break;
-        case BgpConstants.Update.Origin.INCOMPLETE:
-            originString = "INCOMPLETE";
-            break;
-        default:
-            break;
+        for (BgpRouteEntry route : routes) {
+            result.add(json(mapper, route));
         }
-
-        return originString;
+        return result;
     }
 
+    /**
+     * Produces JSON object for a route.
+     *
+     * @param mapper the JSON object mapper to use
+     * @param route the route with the data
+     * @return JSON object for the route
+     */
+    private ObjectNode json(ObjectMapper mapper, BgpRouteEntry route) {
+        ObjectNode result = mapper.createObjectNode();
+
+        result.put("prefix", route.prefix().toString());
+        result.put("nextHop", route.nextHop().toString());
+        result.put("bgpId", route.getBgpSession().getRemoteBgpId().toString());
+        result.put("origin", Origin.typeToString(route.getOrigin()));
+        result.put("asPath", json(mapper, route.getAsPath()));
+        result.put("localPref", route.getLocalPref());
+        result.put("multiExitDisc", route.getMultiExitDisc());
+
+        return result;
+    }
+
+    /**
+     * Produces JSON object for an AS path.
+     *
+     * @param mapper the JSON object mapper to use
+     * @param asPath the AS path with the data
+     * @return JSON object for the AS path
+     */
+    private ObjectNode json(ObjectMapper mapper, BgpRouteEntry.AsPath asPath) {
+        ObjectNode result = mapper.createObjectNode();
+        ArrayNode pathSegmentsJson = mapper.createArrayNode();
+        for (BgpRouteEntry.PathSegment pathSegment : asPath.getPathSegments()) {
+            ObjectNode pathSegmentJson = mapper.createObjectNode();
+            pathSegmentJson.put("type",
+                                AsPath.typeToString(pathSegment.getType()));
+            ArrayNode segmentAsNumbersJson = mapper.createArrayNode();
+            for (Long asNumber : pathSegment.getSegmentAsNumbers()) {
+                segmentAsNumbersJson.add(asNumber);
+            }
+            pathSegmentJson.put("segmentAsNumbers", segmentAsNumbersJson);
+            pathSegmentsJson.add(pathSegmentJson);
+        }
+        result.put("pathSegments", pathSegmentsJson);
+
+        return result;
+    }
 }
diff --git a/apps/sdnip/src/main/java/org/onlab/onos/sdnip/cli/RoutesListCommand.java b/apps/sdnip/src/main/java/org/onlab/onos/sdnip/cli/RoutesListCommand.java
index 0c44453..e9c25c4 100644
--- a/apps/sdnip/src/main/java/org/onlab/onos/sdnip/cli/RoutesListCommand.java
+++ b/apps/sdnip/src/main/java/org/onlab/onos/sdnip/cli/RoutesListCommand.java
@@ -15,7 +15,14 @@
  */
 package org.onlab.onos.sdnip.cli;
 
+import java.util.Collection;
+
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.fasterxml.jackson.databind.node.ArrayNode;
+import com.fasterxml.jackson.databind.node.ObjectNode;
 import org.apache.karaf.shell.commands.Command;
+import org.apache.karaf.shell.commands.Option;
 import org.onlab.onos.cli.AbstractShellCommand;
 import org.onlab.onos.sdnip.RouteEntry;
 import org.onlab.onos.sdnip.SdnIpService;
@@ -26,22 +33,100 @@
 @Command(scope = "onos", name = "routes",
         description = "Lists all routes known to SDN-IP")
 public class RoutesListCommand extends AbstractShellCommand {
+    @Option(name = "-s", aliases = "--summary",
+            description = "SDN-IP routes summary",
+            required = false, multiValued = false)
+    private boolean routesSummary = false;
 
-    private static final String FORMAT =
+    private static final String FORMAT_SUMMARY = "Total SDN-IP routes = %d";
+    private static final String FORMAT_ROUTE =
             "prefix=%s, nexthop=%s";
 
     @Override
     protected void execute() {
         SdnIpService service = get(SdnIpService.class);
 
-        for (RouteEntry route : service.getRoutes()) {
-            printRoute(route);
+        // Print summary of the routes
+        if (routesSummary) {
+            printSummary(service.getRoutes());
+            return;
+        }
+
+        // Print all routes
+        printRoutes(service.getRoutes());
+    }
+
+    /**
+     * Prints summary of the routes.
+     *
+     * @param routes the routes
+     */
+    private void printSummary(Collection<RouteEntry> routes) {
+        if (outputJson()) {
+            ObjectMapper mapper = new ObjectMapper();
+            ObjectNode result = mapper.createObjectNode();
+            result.put("totalRoutes", routes.size());
+            print("%s", result);
+        } else {
+            print(FORMAT_SUMMARY, routes.size());
         }
     }
 
+    /**
+     * Prints all routes.
+     *
+     * @param routes the routes to print
+     */
+    private void printRoutes(Collection<RouteEntry> routes) {
+        if (outputJson()) {
+            print("%s", json(routes));
+        } else {
+            for (RouteEntry route : routes) {
+                printRoute(route);
+            }
+        }
+    }
+
+    /**
+     * Prints a route.
+     *
+     * @param route the route to print
+     */
     private void printRoute(RouteEntry route) {
         if (route != null) {
-            print(FORMAT, route.prefix(), route.nextHop());
+            print(FORMAT_ROUTE, route.prefix(), route.nextHop());
         }
     }
+
+    /**
+     * Produces a JSON array of routes.
+     *
+     * @param routes the routes with the data
+     * @return JSON array with the routes
+     */
+    private JsonNode json(Collection<RouteEntry> routes) {
+        ObjectMapper mapper = new ObjectMapper();
+        ArrayNode result = mapper.createArrayNode();
+
+        for (RouteEntry route : routes) {
+            result.add(json(mapper, route));
+        }
+        return result;
+    }
+
+    /**
+     * Produces JSON object for a route.
+     *
+     * @param mapper the JSON object mapper to use
+     * @param route the route with the data
+     * @return JSON object for the route
+     */
+    private ObjectNode json(ObjectMapper mapper, RouteEntry route) {
+        ObjectNode result = mapper.createObjectNode();
+
+        result.put("prefix", route.prefix().toString());
+        result.put("nextHop", route.nextHop().toString());
+
+        return result;
+    }
 }
diff --git a/apps/sdnip/src/test/java/org/onlab/onos/sdnip/bgp/AsPathTest.java b/apps/sdnip/src/test/java/org/onlab/onos/sdnip/bgp/AsPathTest.java
index fd5a017..fddd895 100644
--- a/apps/sdnip/src/test/java/org/onlab/onos/sdnip/bgp/AsPathTest.java
+++ b/apps/sdnip/src/test/java/org/onlab/onos/sdnip/bgp/AsPathTest.java
@@ -66,8 +66,8 @@
 
         String expectedString =
             "AsPath{pathSegments=" +
-            "[PathSegment{type=2, segmentAsNumbers=[1, 2, 3]}, " +
-            "PathSegment{type=1, segmentAsNumbers=[4, 5, 6]}]}";
+            "[PathSegment{type=AS_SEQUENCE, segmentAsNumbers=[1, 2, 3]}, " +
+            "PathSegment{type=AS_SET, segmentAsNumbers=[4, 5, 6]}]}";
         assertThat(asPath.toString(), is(expectedString));
     }
 
@@ -177,8 +177,8 @@
 
         String expectedString =
             "AsPath{pathSegments=" +
-            "[PathSegment{type=2, segmentAsNumbers=[1, 2, 3]}, " +
-            "PathSegment{type=1, segmentAsNumbers=[4, 5, 6]}]}";
+            "[PathSegment{type=AS_SEQUENCE, segmentAsNumbers=[1, 2, 3]}, " +
+            "PathSegment{type=AS_SET, segmentAsNumbers=[4, 5, 6]}]}";
         assertThat(asPath.toString(), is(expectedString));
     }
 }
diff --git a/apps/sdnip/src/test/java/org/onlab/onos/sdnip/bgp/BgpRouteEntryTest.java b/apps/sdnip/src/test/java/org/onlab/onos/sdnip/bgp/BgpRouteEntryTest.java
index d1ad3d4..318008f 100644
--- a/apps/sdnip/src/test/java/org/onlab/onos/sdnip/bgp/BgpRouteEntryTest.java
+++ b/apps/sdnip/src/test/java/org/onlab/onos/sdnip/bgp/BgpRouteEntryTest.java
@@ -130,9 +130,9 @@
 
         String expectedString =
             "BgpRouteEntry{prefix=1.2.3.0/24, nextHop=5.6.7.8, " +
-            "bgpId=10.0.0.1, origin=0, asPath=AsPath{pathSegments=" +
-            "[PathSegment{type=2, segmentAsNumbers=[1, 2, 3]}, " +
-            "PathSegment{type=1, segmentAsNumbers=[4, 5, 6]}]}, " +
+            "bgpId=10.0.0.1, origin=IGP, asPath=AsPath{pathSegments=" +
+            "[PathSegment{type=AS_SEQUENCE, segmentAsNumbers=[1, 2, 3]}, " +
+            "PathSegment{type=AS_SET, segmentAsNumbers=[4, 5, 6]}]}, " +
             "localPref=100, multiExitDisc=20}";
         assertThat(bgpRouteEntry.toString(), is(expectedString));
     }
@@ -504,9 +504,9 @@
 
         String expectedString =
             "BgpRouteEntry{prefix=1.2.3.0/24, nextHop=5.6.7.8, " +
-            "bgpId=10.0.0.1, origin=0, asPath=AsPath{pathSegments=" +
-            "[PathSegment{type=2, segmentAsNumbers=[1, 2, 3]}, " +
-            "PathSegment{type=1, segmentAsNumbers=[4, 5, 6]}]}, " +
+            "bgpId=10.0.0.1, origin=IGP, asPath=AsPath{pathSegments=" +
+            "[PathSegment{type=AS_SEQUENCE, segmentAsNumbers=[1, 2, 3]}, " +
+            "PathSegment{type=AS_SET, segmentAsNumbers=[4, 5, 6]}]}, " +
             "localPref=100, multiExitDisc=20}";
         assertThat(bgpRouteEntry.toString(), is(expectedString));
     }
diff --git a/apps/sdnip/src/test/java/org/onlab/onos/sdnip/bgp/PathSegmentTest.java b/apps/sdnip/src/test/java/org/onlab/onos/sdnip/bgp/PathSegmentTest.java
index f11d668..27579de 100644
--- a/apps/sdnip/src/test/java/org/onlab/onos/sdnip/bgp/PathSegmentTest.java
+++ b/apps/sdnip/src/test/java/org/onlab/onos/sdnip/bgp/PathSegmentTest.java
@@ -52,7 +52,7 @@
         BgpRouteEntry.PathSegment pathSegment = generatePathSegment();
 
         String expectedString =
-            "PathSegment{type=2, segmentAsNumbers=[1, 2, 3]}";
+            "PathSegment{type=AS_SEQUENCE, segmentAsNumbers=[1, 2, 3]}";
         assertThat(pathSegment.toString(), is(expectedString));
     }
 
@@ -124,7 +124,7 @@
         BgpRouteEntry.PathSegment pathSegment = generatePathSegment();
 
         String expectedString =
-            "PathSegment{type=2, segmentAsNumbers=[1, 2, 3]}";
+            "PathSegment{type=AS_SEQUENCE, segmentAsNumbers=[1, 2, 3]}";
         assertThat(pathSegment.toString(), is(expectedString));
     }
 }
diff --git a/features/features.xml b/features/features.xml
index b8ef86f..efd31cf 100644
--- a/features/features.xml
+++ b/features/features.xml
@@ -119,6 +119,7 @@
              description="ONOS GUI console components">
         <feature>onos-api</feature>
         <feature>onos-thirdparty-web</feature>
+        <bundle>mvn:org.eclipse.jetty/jetty-websocket/8.1.15.v20140411</bundle>
         <bundle>mvn:org.onlab.onos/onos-gui/1.0.0-SNAPSHOT</bundle>
     </feature>
 
diff --git a/web/gui/pom.xml b/web/gui/pom.xml
index 1b061a1..ab5b62c 100644
--- a/web/gui/pom.xml
+++ b/web/gui/pom.xml
@@ -35,4 +35,16 @@
         <web.context>/onos/ui</web.context>
     </properties>
 
+    <dependencies>
+        <dependency>
+            <groupId>org.eclipse.jetty</groupId>
+            <artifactId>jetty-websocket</artifactId>
+            <version>8.1.15.v20140411</version>
+        </dependency>
+        <dependency>
+            <groupId>javax.servlet</groupId>
+            <artifactId>servlet-api</artifactId>
+            <version>2.5</version>
+        </dependency>
+    </dependencies>
 </project>
diff --git a/web/gui/src/main/java/org/onlab/onos/gui/GuiWebSocketServlet.java b/web/gui/src/main/java/org/onlab/onos/gui/GuiWebSocketServlet.java
new file mode 100644
index 0000000..02b46ed
--- /dev/null
+++ b/web/gui/src/main/java/org/onlab/onos/gui/GuiWebSocketServlet.java
@@ -0,0 +1,38 @@
+/*
+ * Copyright 2014 Open Networking Laboratory
+ *
+ * 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.
+ */
+package org.onlab.onos.gui;
+
+import org.eclipse.jetty.websocket.WebSocket;
+import org.eclipse.jetty.websocket.WebSocketServlet;
+import org.onlab.osgi.DefaultServiceDirectory;
+import org.onlab.osgi.ServiceDirectory;
+
+import javax.servlet.http.HttpServletRequest;
+
+/**
+ * Web socket servlet capable of creating various sockets for the user interface.
+ */
+public class GuiWebSocketServlet extends WebSocketServlet {
+
+    private ServiceDirectory directory = new DefaultServiceDirectory();
+
+    @Override
+    public WebSocket doWebSocketConnect(HttpServletRequest request, String protocol) {
+
+        return new TopologyWebSocket(directory);
+    }
+
+}
diff --git a/web/gui/src/main/java/org/onlab/onos/gui/TopologyWebSocket.java b/web/gui/src/main/java/org/onlab/onos/gui/TopologyWebSocket.java
new file mode 100644
index 0000000..7828043
--- /dev/null
+++ b/web/gui/src/main/java/org/onlab/onos/gui/TopologyWebSocket.java
@@ -0,0 +1,106 @@
+/*
+ * Copyright 2014 Open Networking Laboratory
+ *
+ * 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.
+ */
+package org.onlab.onos.gui;
+
+import org.eclipse.jetty.websocket.WebSocket;
+import org.onlab.onos.net.device.DeviceService;
+import org.onlab.onos.net.topology.Topology;
+import org.onlab.onos.net.topology.TopologyEdge;
+import org.onlab.onos.net.topology.TopologyEvent;
+import org.onlab.onos.net.topology.TopologyGraph;
+import org.onlab.onos.net.topology.TopologyListener;
+import org.onlab.onos.net.topology.TopologyService;
+import org.onlab.onos.net.topology.TopologyVertex;
+import org.onlab.osgi.ServiceDirectory;
+
+import java.io.IOException;
+
+/**
+ * Web socket capable of interacting with the GUI topology view.
+ */
+public class TopologyWebSocket implements WebSocket.OnTextMessage, TopologyListener {
+
+    private final ServiceDirectory directory;
+    private final TopologyService topologyService;
+    private final DeviceService deviceService;
+
+    private Connection connection;
+
+    /**
+     * Creates a new web-socket for serving data to GUI topology view.
+     *
+     * @param directory service directory
+     */
+    public TopologyWebSocket(ServiceDirectory directory) {
+        this.directory = directory;
+        topologyService = directory.get(TopologyService.class);
+        deviceService = directory.get(DeviceService.class);
+    }
+
+    @Override
+    public void onOpen(Connection connection) {
+        this.connection = connection;
+
+        // Register for topology events...
+        if (topologyService != null && deviceService != null) {
+            topologyService.addListener(this);
+
+            sendMessage("Yo!!!");
+
+            Topology topology = topologyService.currentTopology();
+            TopologyGraph graph = topologyService.getGraph(topology);
+            for (TopologyVertex vertex : graph.getVertexes()) {
+                sendMessage(deviceService.getDevice(vertex.deviceId()).toString());
+            }
+
+            for (TopologyEdge edge : graph.getEdges()) {
+                sendMessage(edge.link().toString());
+            }
+
+            sendMessage("That's what we're starting with...");
+
+        } else {
+            sendMessage("No topology service!!!");
+        }
+    }
+
+    @Override
+    public void onClose(int closeCode, String message) {
+        TopologyService topologyService = directory.get(TopologyService.class);
+        if (topologyService != null) {
+            topologyService.removeListener(this);
+        }
+    }
+
+    @Override
+    public void onMessage(String data) {
+        System.out.println("Received: " + data);
+    }
+
+    public void sendMessage(String data) {
+        try {
+            connection.sendMessage(data);
+        } catch (IOException e) {
+            e.printStackTrace();
+        }
+    }
+
+    @Override
+    public void event(TopologyEvent event) {
+        sendMessage(event.toString());
+    }
+}
+
diff --git a/web/gui/src/main/webapp/WEB-INF/web.xml b/web/gui/src/main/webapp/WEB-INF/web.xml
index c5c6e28..ab7a550 100644
--- a/web/gui/src/main/webapp/WEB-INF/web.xml
+++ b/web/gui/src/main/webapp/WEB-INF/web.xml
@@ -39,4 +39,16 @@
         <url-pattern>/rs/*</url-pattern>
     </servlet-mapping>
 
+    <servlet>
+        <servlet-name>Web Socket Service</servlet-name>
+        <servlet-class>org.onlab.onos.gui.GuiWebSocketServlet</servlet-class>
+        <load-on-startup>2</load-on-startup>
+    </servlet>
+
+    <servlet-mapping>
+        <servlet-name>Web Socket Service</servlet-name>
+        <url-pattern>/ws/*</url-pattern>
+    </servlet-mapping>
+
+
 </web-app>
\ No newline at end of file
diff --git a/web/gui/src/main/webapp/d3Utils.js b/web/gui/src/main/webapp/d3Utils.js
new file mode 100644
index 0000000..51651fa
--- /dev/null
+++ b/web/gui/src/main/webapp/d3Utils.js
@@ -0,0 +1,115 @@
+/*
+ * Copyright 2014 Open Networking Laboratory
+ *
+ * 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.
+ */
+
+/*
+ Utility functions for D3 visualizations.
+
+ @author Simon Hunt
+ */
+
+(function (onos) {
+    'use strict';
+
+    function createDragBehavior(force, selectCb, atDragEnd) {
+        var draggedThreshold = d3.scale.linear()
+                .domain([0, 0.1])
+                .range([5, 20])
+                .clamp(true),
+            drag;
+
+        // TODO: better validation of parameters
+        if (!$.isFunction(selectCb)) {
+            alert('d3util.createDragBehavior(): selectCb is not a function')
+        }
+        if (!$.isFunction(atDragEnd)) {
+            alert('d3util.createDragBehavior(): atDragEnd is not a function')
+        }
+
+        function dragged(d) {
+            var threshold = draggedThreshold(force.alpha()),
+                dx = d.oldX - d.px,
+                dy = d.oldY - d.py;
+            if (Math.abs(dx) >= threshold || Math.abs(dy) >= threshold) {
+                d.dragged = true;
+            }
+            return d.dragged;
+        }
+
+        drag = d3.behavior.drag()
+            .origin(function(d) { return d; })
+            .on('dragstart', function(d) {
+                d.oldX = d.x;
+                d.oldY = d.y;
+                d.dragged = false;
+                d.fixed |= 2;
+            })
+            .on('drag', function(d) {
+                d.px = d3.event.x;
+                d.py = d3.event.y;
+                if (dragged(d)) {
+                    if (!force.alpha()) {
+                        force.alpha(.025);
+                    }
+                }
+            })
+            .on('dragend', function(d) {
+                if (!dragged(d)) {
+                    // consider this the same as a 'click' (selection of node)
+                    selectCb(d, this); // TODO: set 'this' context instead of param
+                }
+                d.fixed &= ~6;
+
+                // hook at the end of a drag gesture
+                atDragEnd(d, this); // TODO: set 'this' context instead of param
+            });
+
+        return drag;
+    }
+
+    function appendGlow(svg) {
+        // TODO: parameterize color
+
+        var glow = svg.append('filter')
+            .attr('x', '-50%')
+            .attr('y', '-50%')
+            .attr('width', '200%')
+            .attr('height', '200%')
+            .attr('id', 'blue-glow');
+
+        glow.append('feColorMatrix')
+            .attr('type', 'matrix')
+            .attr('values', '0 0 0 0  0 ' +
+            '0 0 0 0  0 ' +
+            '0 0 0 0  .7 ' +
+            '0 0 0 1  0 ');
+
+        glow.append('feGaussianBlur')
+            .attr('stdDeviation', 3)
+            .attr('result', 'coloredBlur');
+
+        glow.append('feMerge').selectAll('feMergeNode')
+            .data(['coloredBlur', 'SourceGraphic'])
+            .enter().append('feMergeNode')
+            .attr('in', String);
+    }
+
+    // === register the functions as a library
+    onos.ui.addLib('d3util', {
+        createDragBehavior: createDragBehavior,
+        appendGlow: appendGlow
+    });
+
+}(ONOS));
diff --git a/web/gui/src/main/webapp/index2.html b/web/gui/src/main/webapp/index2.html
index 1ddc318..235c1d7 100644
--- a/web/gui/src/main/webapp/index2.html
+++ b/web/gui/src/main/webapp/index2.html
@@ -64,6 +64,9 @@
         <div id="overlays">
             <!-- NOTE: overlays injected here, as needed -->
         </div>
+        <div id="alerts">
+            <!-- NOTE: alert content injected here, as needed -->
+        </div>
     </div>
 
     <!-- Initialize the UI...-->
@@ -76,6 +79,9 @@
         });
     </script>
 
+    <!-- Library module files included here -->
+    <script src="d3Utils.js"></script>
+
     <!-- Framework module files included here -->
     <script src="mast2.js"></script>
 
diff --git a/web/gui/src/main/webapp/json/eventTest_11.json b/web/gui/src/main/webapp/json/eventTest_11.json
index e907444..3b361d5 100644
--- a/web/gui/src/main/webapp/json/eventTest_11.json
+++ b/web/gui/src/main/webapp/json/eventTest_11.json
@@ -10,8 +10,8 @@
       "?"
     ],
     "metaUi": {
-      "x": 832,
-      "y": 223
+      "Zx": 832,
+      "Zy": 223
     }
   }
 }
diff --git a/web/gui/src/main/webapp/json/eventTest_15.json b/web/gui/src/main/webapp/json/eventTest_15.json
index 3622122..30ba9f3 100644
--- a/web/gui/src/main/webapp/json/eventTest_15.json
+++ b/web/gui/src/main/webapp/json/eventTest_15.json
@@ -10,8 +10,8 @@
       "?"
     ],
     "metaUi": {
-      "x": 840,
-      "y": 290
+      "Zx": 840,
+      "Zy": 290
     }
   }
 }
diff --git a/web/gui/src/main/webapp/json/eventTest_17.json b/web/gui/src/main/webapp/json/eventTest_17.json
index e217456..82272a4 100644
--- a/web/gui/src/main/webapp/json/eventTest_17.json
+++ b/web/gui/src/main/webapp/json/eventTest_17.json
@@ -6,7 +6,7 @@
     "dst": "of:0000ffffffffff05",
     "dstPort": "10",
     "type": "optical",
-    "linkWidth": 2,
+    "linkWidth": 6,
     "props" : {
       "BW": "80 G"
     }
diff --git a/web/gui/src/main/webapp/json/eventTest_23.json b/web/gui/src/main/webapp/json/eventTest_23.json
index 54383bc..fff0f2b 100644
--- a/web/gui/src/main/webapp/json/eventTest_23.json
+++ b/web/gui/src/main/webapp/json/eventTest_23.json
@@ -6,7 +6,7 @@
     "dst": "of:0000ffffffffff05",
     "dstPort": "30",
     "type": "optical",
-    "linkWidth": 2,
+    "linkWidth": 6,
     "props" : {
       "BW": "70 G"
     }
diff --git a/web/gui/src/main/webapp/json/eventTest_24.json b/web/gui/src/main/webapp/json/eventTest_24.json
index 2287f6c..756b6c1 100644
--- a/web/gui/src/main/webapp/json/eventTest_24.json
+++ b/web/gui/src/main/webapp/json/eventTest_24.json
@@ -6,7 +6,7 @@
     "dst": "of:0000ffffffffff08",
     "dstPort": "20",
     "type": "optical",
-    "linkWidth": 2,
+    "linkWidth": 6,
     "props" : {
       "BW": "70 G"
     }
diff --git a/web/gui/src/main/webapp/json/eventTest_30.json b/web/gui/src/main/webapp/json/eventTest_30.json
index ae2d4c1..a617f45 100644
--- a/web/gui/src/main/webapp/json/eventTest_30.json
+++ b/web/gui/src/main/webapp/json/eventTest_30.json
@@ -6,7 +6,7 @@
     "dst": "of:0000ffffffffff08",
     "dstPort": "30",
     "type": "optical",
-    "linkWidth": 2,
+    "linkWidth": 6,
     "props" : {
       "BW": "70 G"
     }
diff --git a/web/gui/src/main/webapp/json/eventTest_34.json b/web/gui/src/main/webapp/json/eventTest_34.json
index 96015b5..fa5e3bc 100644
--- a/web/gui/src/main/webapp/json/eventTest_34.json
+++ b/web/gui/src/main/webapp/json/eventTest_34.json
@@ -6,7 +6,7 @@
     "dst": "of:0000ffffffffff08",
     "dstPort": "10",
     "type": "optical",
-    "linkWidth": 2,
+    "linkWidth": 6,
     "props" : {
       "BW": "70 G"
     }
diff --git a/web/gui/src/main/webapp/json/eventTest_35.json b/web/gui/src/main/webapp/json/eventTest_35.json
new file mode 100644
index 0000000..c579e59
--- /dev/null
+++ b/web/gui/src/main/webapp/json/eventTest_35.json
@@ -0,0 +1,14 @@
+{
+  "event": "addLink",
+  "payload": {
+    "src": "of:0000ffffffffff04",
+    "srcPort": "27",
+    "dst": "of:0000ffffffffff08",
+    "dstPort": "10",
+    "type": "optical",
+    "linkWidth": 2,
+    "props" : {
+      "BW": "30 G"
+    }
+  }
+}
diff --git a/web/gui/src/main/webapp/onos2.css b/web/gui/src/main/webapp/onos2.css
index 983f288..748cc97 100644
--- a/web/gui/src/main/webapp/onos2.css
+++ b/web/gui/src/main/webapp/onos2.css
@@ -32,6 +32,34 @@
     display: block;
 }
 
+div#alerts {
+    display: none;
+    position: absolute;
+    z-index: 2000;
+    opacity: 0.65;
+    background-color: #006;
+    color: white;
+    top: 80px;
+    left: 40px;
+    padding: 3px 6px;
+    box-shadow: 4px 6px 12px #777;
+}
+
+div#alerts pre {
+    margin: 0.2em 6px;
+}
+
+div#alerts span.close {
+    color: #6af;
+    float: right;
+    right: 2px;
+    cursor: pointer;
+}
+
+div#alerts span.close:hover {
+    color: #fff;
+}
+
 /*
  * ==============================================================
  * END OF NEW ONOS.JS file
@@ -54,12 +82,6 @@
  * Network Graph elements ======================================
  */
 
-svg .link {
-    opacity: .7;
-}
-
-svg .link.host {
-}
 
 svg g.portLayer rect.port {
     fill: #ccc;
@@ -70,33 +92,13 @@
     pointer-events: none;
 }
 
-svg .node.device rect {
-    stroke-width: 1.5px;
-}
 
-svg .node.device.fixed rect {
-    stroke-width: 1.5;
-    stroke: #ccc;
-}
-
-svg .node.device.roadm rect {
-    fill: #03c;
-}
-
-svg .node.device.switch rect {
-    fill: #06f;
-}
 
 svg .node.host circle {
     fill: #c96;
     stroke: #000;
 }
 
-svg .node text {
-    fill: white;
-    font: 10pt sans-serif;
-    pointer-events: none;
-}
 
 /* for debugging */
 svg .node circle.debug {
@@ -110,10 +112,6 @@
 }
 
 
-svg .node.selected rect,
-svg .node.selected circle {
-    filter: url(#blue-glow);
-}
 
 svg .link.inactive,
 svg .port.inactive,
diff --git a/web/gui/src/main/webapp/onos2.js b/web/gui/src/main/webapp/onos2.js
index 375fe6b..31d89fa 100644
--- a/web/gui/src/main/webapp/onos2.js
+++ b/web/gui/src/main/webapp/onos2.js
@@ -32,7 +32,8 @@
     $.onos = function (options) {
         var uiApi,
             viewApi,
-            navApi;
+            navApi,
+            libApi;
 
         var defaultOptions = {
             trace: false,
@@ -331,6 +332,58 @@
             }
         }
 
+        var alerts = {
+            open: false,
+            count: 0
+        };
+
+        function createAlerts() {
+            var al = d3.select('#alerts')
+                .style('display', 'block');
+            al.append('span')
+                .attr('class', 'close')
+                .text('X')
+                .on('click', closeAlerts);
+            al.append('pre');
+            alerts.open = true;
+            alerts.count = 0;
+        }
+
+        function closeAlerts() {
+            d3.select('#alerts')
+                .style('display', 'none');
+            d3.select('#alerts span').remove();
+            d3.select('#alerts pre').remove();
+            alerts.open = false;
+        }
+
+        function addAlert(msg) {
+            var lines,
+                oldContent;
+
+            if (alerts.count) {
+                oldContent = d3.select('#alerts pre').html();
+            }
+
+            lines = msg.split('\n');
+            lines[0] += '  '; // spacing so we don't crowd 'X'
+            lines = lines.join('\n');
+
+            if (oldContent) {
+                lines += '\n----\n' + oldContent;
+            }
+
+            d3.select('#alerts pre').html(lines);
+            alerts.count++;
+        }
+
+        function doAlert(msg) {
+            if (!alerts.open) {
+                createAlerts();
+            }
+            addAlert(msg);
+        }
+
         function keyIn() {
             var event = d3.event,
                 keyCode = event.keyCode,
@@ -408,7 +461,8 @@
                     uid: this.uid,
                     setRadio: this.setRadio,
                     setKeys: this.setKeys,
-                    dataLoadError: this.dataLoadError
+                    dataLoadError: this.dataLoadError,
+                    alert: this.alert
                 }
             },
 
@@ -501,14 +555,20 @@
                 return makeUid(this, id);
             },
 
-            // TODO : implement custom dialogs (don't use alerts)
+            // TODO : implement custom dialogs
+
+            // Consider enhancing alert mechanism to handle multiples
+            // as individually closable.
+            alert: function (msg) {
+                doAlert(msg);
+            },
 
             dataLoadError: function (err, url) {
                 var msg = 'Data Load Error\n\n' +
                     err.status + ' -- ' + err.statusText + '\n\n' +
                     'relative-url: "' + url + '"\n\n' +
                     'complete-url: "' + err.responseURL + '"';
-                alert(msg);
+                this.alert(msg);
             }
 
             // TODO: consider schedule, clearTimer, etc.
@@ -521,6 +581,12 @@
         // UI API
 
         uiApi = {
+            addLib: function (libName, api) {
+                // TODO: validation of args
+                libApi[libName] = api;
+            },
+
+            // TODO: it remains to be seen whether we keep this style of docs
             /** @api ui addView( vid, nid, cb )
              * Adds a view to the UI.
              * <p>
@@ -590,6 +656,12 @@
         };
 
         // ..........................................................
+        // Library API
+        libApi = {
+
+        };
+
+        // ..........................................................
         // Exported API
 
         // function to be called from index.html to build the ONOS UI
@@ -623,7 +695,8 @@
         // export the api and build-UI function
         return {
             ui: uiApi,
-            view: viewApi,
+            lib: libApi,
+            //view: viewApi,
             nav: navApi,
             buildUi: buildOnosUi
         };
diff --git a/web/gui/src/main/webapp/topo2.css b/web/gui/src/main/webapp/topo2.css
index 8293ce4..c4d9a2d 100644
--- a/web/gui/src/main/webapp/topo2.css
+++ b/web/gui/src/main/webapp/topo2.css
@@ -24,12 +24,18 @@
     opacity: 0.5;
 }
 
+/* NODES */
+
 svg .node.device {
     stroke: none;
     stroke-width: 1.5px;
     cursor: pointer;
 }
 
+svg .node.device rect {
+    stroke-width: 1.5px;
+}
+
 svg .node.device.fixed rect {
     stroke-width: 1.5;
     stroke: #ccc;
@@ -50,6 +56,17 @@
     pointer-events: none;
 }
 
+svg .node.selected rect,
+svg .node.selected circle {
+    filter: url(#blue-glow);
+}
+
+/* LINKS */
+
+svg .link {
+    opacity: .7;
+}
+
 /* for debugging */
 svg .node circle.debug {
     fill: white;
diff --git a/web/gui/src/main/webapp/topo2.js b/web/gui/src/main/webapp/topo2.js
index bd5a577..e7444c9 100644
--- a/web/gui/src/main/webapp/topo2.js
+++ b/web/gui/src/main/webapp/topo2.js
@@ -23,6 +23,9 @@
 (function (onos) {
     'use strict';
 
+    // shorter names for library APIs
+    var d3u = onos.lib.d3util;
+
     // configuration data
     var config = {
         useLiveData: false,
@@ -37,6 +40,7 @@
             showBackground: true
         },
         backgroundUrl: 'img/us-map.png',
+        webSockUrl: 'ws/topology',
         data: {
             live: {
                 jsonUrl: 'rs/topology/graph',
@@ -61,6 +65,10 @@
                 height: 14
             }
         },
+        topo: {
+            linkInColor: '#66f',
+            linkInWidth: 14
+        },
         icons: {
             w: 28,
             h: 28,
@@ -106,7 +114,9 @@
     // key bindings
     var keyDispatch = {
         space: injectTestEvent,     // TODO: remove (testing only)
- //       M: testMe,                  // TODO: remove (testing only)
+        S: injectStartupEvents,     // TODO: remove (testing only)
+        A: testAlert,               // TODO: remove (testing only)
+        M: testMe,                  // TODO: remove (testing only)
 
         B: toggleBg,
         G: toggleLayout,
@@ -121,6 +131,7 @@
             links: [],
             lookup: {}
         },
+        webSock,
         labelIdx = 0,
         selected = {},
         highlighted = null,
@@ -141,7 +152,8 @@
     // For Debugging / Development
 
     var eventPrefix = 'json/eventTest_',
-        eventNumber = 0;
+        eventNumber = 0,
+        alertNumber = 0;
 
     function note(label, msg) {
         console.log('NOTE: ' + label + ': ' + msg);
@@ -155,22 +167,12 @@
     // ==============================
     // Key Callbacks
 
+    function testAlert(view) {
+        alertNumber++;
+        view.alert("Test me! -- " + alertNumber);
+    }
+
     function testMe(view) {
-        svg.append('line')
-            .attr({
-                x1: 100,
-                y1: 100,
-                x2: 500,
-                y2: 400,
-                stroke: '#2f3',
-                'stroke-width': 8
-            })
-            .transition()
-            .duration(1200)
-            .attr({
-                stroke: '#666',
-                'stroke-width': 6
-            });
     }
 
     function injectTestEvent(view) {
@@ -187,6 +189,13 @@
         });
     }
 
+    function injectStartupEvents(view) {
+        var lastStartupEvent = 32;
+        while (eventNumber < lastStartupEvent) {
+            injectTestEvent(view);
+        }
+    }
+
     function toggleBg() {
         var vis = bgImg.style('visibility');
         bgImg.style('visibility', (vis === 'hidden') ? 'visible' : 'hidden');
@@ -370,6 +379,11 @@
         return lnk;
     }
 
+    function linkWidth(w) {
+        // w is number of links between nodes. Scale appropriately.
+        return w * 1.2;
+    }
+
     function updateLinks() {
         link = linkG.selectAll('.link')
             .data(network.links, function (d) { return d.id; });
@@ -387,12 +401,12 @@
                 y1: function (d) { return d.y1; },
                 x2: function (d) { return d.x2; },
                 y2: function (d) { return d.y2; },
-                stroke: '#66f',
-                'stroke-width': 10
+                stroke: config.topo.linkInColor,
+                'stroke-width': config.topo.linkInWidth
             })
             .transition().duration(1000)
             .attr({
-                'stroke-width': function (d) { return d.width; },
+                'stroke-width': function (d) { return linkWidth(d.width); },
                 stroke: '#666'      // TODO: remove explicit stroke, rather...
             });
 
@@ -461,6 +475,10 @@
         return box;
     }
 
+    function mkSvgClass(d) {
+        return d.fixed ? d.svgClass + ' fixed' : d.svgClass;
+    }
+
     function updateNodes() {
         node = nodeG.selectAll('.node')
             .data(network.nodes, function (d) { return d.id; });
@@ -473,11 +491,11 @@
             .append('g')
             .attr({
                 id: function (d) { return safeId(d.id); },
-                class: function (d) { return d.svgClass; },
+                class: mkSvgClass,
                 transform: function (d) { return translate(d.x, d.y); },
                 opacity: 0
             })
-            //.call(network.drag)
+            .call(network.drag)
             //.on('mouseover', function (d) {})
             //.on('mouseover', function (d) {})
             .transition()
@@ -563,6 +581,52 @@
     }
 
     // ==============================
+    // Web-Socket for live data
+
+    function webSockUrl() {
+        return document.location.toString()
+            .replace(/\#.*/, '')
+            .replace('http://', 'ws://')
+            .replace('https://', 'wss://')
+            .replace('index2.html', config.webSockUrl);
+    }
+
+    webSock = {
+        ws : null,
+
+        connect : function() {
+            webSock.ws = new WebSocket(webSockUrl());
+
+            webSock.ws.onopen = function() {
+                webSock._send("Hi there!");
+            };
+
+            webSock.ws.onmessage = function(m) {
+                if (m.data) {
+                    console.log(m.data);
+                }
+            };
+
+            webSock.ws.onclose = function(m) {
+                webSock.ws = null;
+            };
+        },
+
+        send : function(text) {
+            if (text != null && text.length > 0) {
+                webSock._send(text);
+            }
+        },
+
+        _send : function(message) {
+            if (webSock.ws) {
+                webSock.ws.send(message);
+            }
+        }
+
+    };
+
+    // ==============================
     // View life-cycle callbacks
 
     function preload(view, ctx) {
@@ -578,6 +642,9 @@
         svg = view.$div.append('svg');
         setSize(svg, view);
 
+        // add blue glow filter to svg layer
+        d3u.appendGlow(svg);
+
         // load the background image
         bgImg = svg.append('svg:image')
             .attr({
@@ -612,6 +679,20 @@
             return fcfg.charge[d.class] || -200;
         }
 
+        function selectCb(d, self) {
+            // TODO: selectObject(d, self);
+        }
+
+        function atDragEnd(d, self) {
+            // once we've finished moving, pin the node in position,
+            // if it is a device (not a host)
+            if (d.class === 'device') {
+                d.fixed = true;
+                d3.select(self).classed('fixed', true)
+                // TODO: send new [x,y] back to server, via websocket.
+            }
+        }
+
         // set up the force layout
         network.force = d3.layout.force()
             .size(forceDim)
@@ -621,8 +702,10 @@
             .linkDistance(ldist)
             .linkStrength(lstrg)
             .on('tick', tick);
-    }
 
+        network.drag = d3u.createDragBehavior(network.force, selectCb, atDragEnd);
+        webSock.connect();
+    }
 
     function load(view, ctx) {
         // cache the view token, so network topo functions can access it
diff --git a/web/gui/src/main/webapp/ws.html b/web/gui/src/main/webapp/ws.html
new file mode 100644
index 0000000..cac99b2
--- /dev/null
+++ b/web/gui/src/main/webapp/ws.html
@@ -0,0 +1,54 @@
+<html>
+<head>
+    <title>Web Sockets Demo</title>
+
+    <script src="libs/jquery-2.1.1.min.js"></script>
+
+    <script type='text/javascript'>
+        if (!window.WebSocket)
+            alert("WebSocket not supported by this browser");
+
+        var server = {
+            connect : function() {
+                var location = document.location.toString().replace('http://',
+                        'ws://').replace('https://', 'wss://').replace('ws.html','ws/topology');
+                this.ws = new WebSocket(location);
+
+                this.ws.onopen = function() {
+                    server._send("Hi there!");
+                };
+
+                this.ws.onmessage = function(m) {
+                    if (m.data) {
+                        $('#log').append(m.data).append($('<br/>'));
+                    }
+                };
+
+                this.ws.onclose = function(m) {
+                    this.ws = null;
+                };
+            },
+
+            _send : function(message) {
+                if (this.ws) {
+                    this.ws.send(message);
+                }
+            },
+
+            send : function(text) {
+                if (text != null && text.length > 0) {
+                    server._send(text);
+                }
+            }
+        };
+    </script>
+</head>
+<body>
+<pre id='log'></pre>
+
+<script type='text/javascript'>
+    server.connect();
+</script>
+
+</body>
+</html>
diff --git a/web/pom.xml b/web/pom.xml
index f846078..aad2134 100644
--- a/web/pom.xml
+++ b/web/pom.xml
@@ -131,6 +131,7 @@
                             com.fasterxml.jackson.databind,
                             com.fasterxml.jackson.databind.node,
                             com.google.common.base.*,
+                            org.eclipse.jetty.websocket.*,
                             org.onlab.api.*,
                             org.onlab.osgi.*,
                             org.onlab.packet.*,