You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@qpid.apache.org by ea...@apache.org on 2019/09/18 00:33:24 UTC

[qpid-dispatch] branch eallen-DISPATCH-1385 updated: adding react console

This is an automated email from the ASF dual-hosted git repository.

eallen pushed a commit to branch eallen-DISPATCH-1385
in repository https://gitbox.apache.org/repos/asf/qpid-dispatch.git


The following commit(s) were added to refs/heads/eallen-DISPATCH-1385 by this push:
     new 37a30eb  adding react console
37a30eb is described below

commit 37a30ebc0210a0b94e73533d3329b205d11a816d
Author: Ernest Allen <ea...@redhat.com>
AuthorDate: Tue Sep 17 20:32:44 2019 -0400

    adding react console
---
 .gitignore                                         |     2 +-
 console/react/LICENSE                              |   201 +
 console/react/package.json                         |    49 +
 console/react/public/app.js                        |   171 +
 console/react/public/favicon.ico                   |   Bin 0 -> 3870 bytes
 console/react/public/index.html                    |    38 +
 console/react/public/manifest.json                 |    15 +
 console/react/public/server.js                     |     5 +
 console/react/src/App.css                          |   623 +
 console/react/src/App.js                           |    23 +
 console/react/src/App.test.js                      |     9 +
 console/react/src/REST.js                          |   169 +
 console/react/src/activeAddressesCard.js           |    66 +
 console/react/src/alert-saved.js                   |    39 +
 console/react/src/amqp/connection.js               |   354 +
 console/react/src/amqp/correlator.js               |    50 +
 console/react/src/amqp/management.js               |    63 +
 console/react/src/amqp/topology.js                 |   613 +
 console/react/src/amqp/utilities.js                |   235 +
 console/react/src/assets/brokers.ttf               |   Bin 0 -> 2272 bytes
 console/react/src/assets/img_avatar.svg            |    33 +
 console/react/src/brokers.ttf                      |     0
 console/react/src/chord/data.js                    |   253 +
 console/react/src/chord/filters.js                 |    61 +
 console/react/src/chord/layout/README.md           |    85 +
 console/react/src/chord/layout/layout.js           |   147 +
 console/react/src/chord/matrix.js                  |   215 +
 console/react/src/chord/qdrChord.js                |   842 ++
 console/react/src/chord/ribbon/README.md           |    40 +
 console/react/src/chord/ribbon/ribbon.js           |   165 +
 console/react/src/config.json                      |     3 +
 console/react/src/confirm.js                       |    74 +
 console/react/src/connect-form.js                  |   164 +
 console/react/src/connectPage.js                   |    51 +
 console/react/src/edge-table-pagination.js         |    24 +
 console/react/src/edge-table-toolbar.js            |   133 +
 console/react/src/edge-table.js                    |   289 +
 console/react/src/empty-edge-class-table.js        |    43 +
 console/react/src/empty-selection.js               |    33 +
 console/react/src/field-details.js                 |   225 +
 console/react/src/graph.js                         |   358 +
 console/react/src/inFlightCard.js                  |   101 +
 console/react/src/index.css                        |    13 +
 console/react/src/index.js                         |    11 +
 console/react/src/layout.js                        |   381 +
 console/react/src/network-name.js                  |    67 +
 console/react/src/nodes.js                         |   260 +
 console/react/src/nodeslinks.js                    |   144 +
 console/react/src/overviewChartsPage.js            |    50 +
 console/react/src/overviewTable.js                 |   118 +
 console/react/src/overviewTablePage.js             |    51 +
 console/react/src/qdrGlobals.js                    |    66 +
 console/react/src/qdrPopup.js                      |    14 +
 console/react/src/qdrService.js                    |   100 +
 console/react/src/serviceWorker.js                 |   135 +
 console/react/src/show-d3-svg.js                   |   236 +
 console/react/src/throughputCard.js                |   101 +
 console/react/src/topology-context.js              |   139 +
 console/react/src/topology/arrowsComponent.js      |    53 +
 console/react/src/topology/clientInfoComponent.js  |   534 +
 .../src/topology/clientInfoDetailsComponent.jsx    |    31 +
 console/react/src/topology/legend.js               |   194 +
 console/react/src/topology/legendComponent.js      |   130 +
 console/react/src/topology/links.js                |   317 +
 console/react/src/topology/map.js                  |   283 +
 console/react/src/topology/nodes.js                |   504 +
 console/react/src/topology/qdrTopology.js          |   938 ++
 console/react/src/topology/routerInfoComponent.js  |    70 +
 console/react/src/topology/svgUtils.js             |   250 +
 console/react/src/topology/topoUtils.js            |   284 +
 console/react/src/topology/traffic.js              |   675 +
 console/react/src/topology/trafficComponent.js     |   168 +
 console/react/yarn-error.log                       | 12687 +++++++++++++++++++
 console/react/yarn.lock                            | 12625 ++++++++++++++++++
 74 files changed, 37692 insertions(+), 1 deletion(-)

diff --git a/.gitignore b/.gitignore
index aa0aaa8..b4dc769 100644
--- a/.gitignore
+++ b/.gitignore
@@ -18,7 +18,7 @@ tests/system_tests_handle_failover.py
 .history/
 .tox
 .vscode
-console/stand-alone/node_modules/
+node_modules/
 console/stand-alone/dist/
 console/stand-alone/package-lock.json
 tools/qdmanage
diff --git a/console/react/LICENSE b/console/react/LICENSE
new file mode 100644
index 0000000..261eeb9
--- /dev/null
+++ b/console/react/LICENSE
@@ -0,0 +1,201 @@
+                                 Apache License
+                           Version 2.0, January 2004
+                        http://www.apache.org/licenses/
+
+   TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
+
+   1. Definitions.
+
+      "License" shall mean the terms and conditions for use, reproduction,
+      and distribution as defined by Sections 1 through 9 of this document.
+
+      "Licensor" shall mean the copyright owner or entity authorized by
+      the copyright owner that is granting the License.
+
+      "Legal Entity" shall mean the union of the acting entity and all
+      other entities that control, are controlled by, or are under common
+      control with that entity. For the purposes of this definition,
+      "control" means (i) the power, direct or indirect, to cause the
+      direction or management of such entity, whether by contract or
+      otherwise, or (ii) ownership of fifty percent (50%) or more of the
+      outstanding shares, or (iii) beneficial ownership of such entity.
+
+      "You" (or "Your") shall mean an individual or Legal Entity
+      exercising permissions granted by this License.
+
+      "Source" form shall mean the preferred form for making modifications,
+      including but not limited to software source code, documentation
+      source, and configuration files.
+
+      "Object" form shall mean any form resulting from mechanical
+      transformation or translation of a Source form, including but
+      not limited to compiled object code, generated documentation,
+      and conversions to other media types.
+
+      "Work" shall mean the work of authorship, whether in Source or
+      Object form, made available under the License, as indicated by a
+      copyright notice that is included in or attached to the work
+      (an example is provided in the Appendix below).
+
+      "Derivative Works" shall mean any work, whether in Source or Object
+      form, that is based on (or derived from) the Work and for which the
+      editorial revisions, annotations, elaborations, or other modifications
+      represent, as a whole, an original work of authorship. For the purposes
+      of this License, Derivative Works shall not include works that remain
+      separable from, or merely link (or bind by name) to the interfaces of,
+      the Work and Derivative Works thereof.
+
+      "Contribution" shall mean any work of authorship, including
+      the original version of the Work and any modifications or additions
+      to that Work or Derivative Works thereof, that is intentionally
+      submitted to Licensor for inclusion in the Work by the copyright owner
+      or by an individual or Legal Entity authorized to submit on behalf of
+      the copyright owner. For the purposes of this definition, "submitted"
+      means any form of electronic, verbal, or written communication sent
+      to the Licensor or its representatives, including but not limited to
+      communication on electronic mailing lists, source code control systems,
+      and issue tracking systems that are managed by, or on behalf of, the
+      Licensor for the purpose of discussing and improving the Work, but
+      excluding communication that is conspicuously marked or otherwise
+      designated in writing by the copyright owner as "Not a Contribution."
+
+      "Contributor" shall mean Licensor and any individual or Legal Entity
+      on behalf of whom a Contribution has been received by Licensor and
+      subsequently incorporated within the Work.
+
+   2. Grant of Copyright License. Subject to the terms and conditions of
+      this License, each Contributor hereby grants to You a perpetual,
+      worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+      copyright license to reproduce, prepare Derivative Works of,
+      publicly display, publicly perform, sublicense, and distribute the
+      Work and such Derivative Works in Source or Object form.
+
+   3. Grant of Patent License. Subject to the terms and conditions of
+      this License, each Contributor hereby grants to You a perpetual,
+      worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+      (except as stated in this section) patent license to make, have made,
+      use, offer to sell, sell, import, and otherwise transfer the Work,
+      where such license applies only to those patent claims licensable
+      by such Contributor that are necessarily infringed by their
+      Contribution(s) alone or by combination of their Contribution(s)
+      with the Work to which such Contribution(s) was submitted. If You
+      institute patent litigation against any entity (including a
+      cross-claim or counterclaim in a lawsuit) alleging that the Work
+      or a Contribution incorporated within the Work constitutes direct
+      or contributory patent infringement, then any patent licenses
+      granted to You under this License for that Work shall terminate
+      as of the date such litigation is filed.
+
+   4. Redistribution. You may reproduce and distribute copies of the
+      Work or Derivative Works thereof in any medium, with or without
+      modifications, and in Source or Object form, provided that You
+      meet the following conditions:
+
+      (a) You must give any other recipients of the Work or
+          Derivative Works a copy of this License; and
+
+      (b) You must cause any modified files to carry prominent notices
+          stating that You changed the files; and
+
+      (c) You must retain, in the Source form of any Derivative Works
+          that You distribute, all copyright, patent, trademark, and
+          attribution notices from the Source form of the Work,
+          excluding those notices that do not pertain to any part of
+          the Derivative Works; and
+
+      (d) If the Work includes a "NOTICE" text file as part of its
+          distribution, then any Derivative Works that You distribute must
+          include a readable copy of the attribution notices contained
+          within such NOTICE file, excluding those notices that do not
+          pertain to any part of the Derivative Works, in at least one
+          of the following places: within a NOTICE text file distributed
+          as part of the Derivative Works; within the Source form or
+          documentation, if provided along with the Derivative Works; or,
+          within a display generated by the Derivative Works, if and
+          wherever such third-party notices normally appear. The contents
+          of the NOTICE file are for informational purposes only and
+          do not modify the License. You may add Your own attribution
+          notices within Derivative Works that You distribute, alongside
+          or as an addendum to the NOTICE text from the Work, provided
+          that such additional attribution notices cannot be construed
+          as modifying the License.
+
+      You may add Your own copyright statement to Your modifications and
+      may provide additional or different license terms and conditions
+      for use, reproduction, or distribution of Your modifications, or
+      for any such Derivative Works as a whole, provided Your use,
+      reproduction, and distribution of the Work otherwise complies with
+      the conditions stated in this License.
+
+   5. Submission of Contributions. Unless You explicitly state otherwise,
+      any Contribution intentionally submitted for inclusion in the Work
+      by You to the Licensor shall be under the terms and conditions of
+      this License, without any additional terms or conditions.
+      Notwithstanding the above, nothing herein shall supersede or modify
+      the terms of any separate license agreement you may have executed
+      with Licensor regarding such Contributions.
+
+   6. Trademarks. This License does not grant permission to use the trade
+      names, trademarks, service marks, or product names of the Licensor,
+      except as required for reasonable and customary use in describing the
+      origin of the Work and reproducing the content of the NOTICE file.
+
+   7. Disclaimer of Warranty. Unless required by applicable law or
+      agreed to in writing, Licensor provides the Work (and each
+      Contributor provides its Contributions) on an "AS IS" BASIS,
+      WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
+      implied, including, without limitation, any warranties or conditions
+      of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
+      PARTICULAR PURPOSE. You are solely responsible for determining the
+      appropriateness of using or redistributing the Work and assume any
+      risks associated with Your exercise of permissions under this License.
+
+   8. Limitation of Liability. In no event and under no legal theory,
+      whether in tort (including negligence), contract, or otherwise,
+      unless required by applicable law (such as deliberate and grossly
+      negligent acts) or agreed to in writing, shall any Contributor be
+      liable to You for damages, including any direct, indirect, special,
+      incidental, or consequential damages of any character arising as a
+      result of this License or out of the use or inability to use the
+      Work (including but not limited to damages for loss of goodwill,
+      work stoppage, computer failure or malfunction, or any and all
+      other commercial damages or losses), even if such Contributor
+      has been advised of the possibility of such damages.
+
+   9. Accepting Warranty or Additional Liability. While redistributing
+      the Work or Derivative Works thereof, You may choose to offer,
+      and charge a fee for, acceptance of support, warranty, indemnity,
+      or other liability obligations and/or rights consistent with this
+      License. However, in accepting such obligations, You may act only
+      on Your own behalf and on Your sole responsibility, not on behalf
+      of any other Contributor, and only if You agree to indemnify,
+      defend, and hold each Contributor harmless for any liability
+      incurred by, or claims asserted against, such Contributor by reason
+      of your accepting any such warranty or additional liability.
+
+   END OF TERMS AND CONDITIONS
+
+   APPENDIX: How to apply the Apache License to your work.
+
+      To apply the Apache License to your work, attach the following
+      boilerplate notice, with the fields enclosed by brackets "[]"
+      replaced with your own identifying information. (Don't include
+      the brackets!)  The text should be enclosed in the appropriate
+      comment syntax for the file format. We also recommend that a
+      file or class name and description of purpose be included on the
+      same "printed page" as the copyright notice for easier
+      identification within third-party archives.
+
+   Copyright [yyyy] [name of copyright owner]
+
+   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/console/react/package.json b/console/react/package.json
new file mode 100644
index 0000000..434e2da
--- /dev/null
+++ b/console/react/package.json
@@ -0,0 +1,49 @@
+{
+  "name": "network-creator",
+  "version": "0.1.0",
+  "private": true,
+  "dependencies": {
+    "@patternfly/patternfly": "^2.13.0",
+    "@patternfly/react-charts": "^4.1.5",
+    "@patternfly/react-core": "^3.38.1",
+    "@patternfly/react-icons": "^3.10.4",
+    "@patternfly/react-styles": "^3.3.3",
+    "@patternfly/react-table": "^2.11.1",
+    "@patternfly/react-topology": "^2.7.31",
+    "body-parser": "^1.19.0",
+    "d3-queue": "^3.0.7",
+    "express": "^4.17.1",
+    "lodash-es": "^4.17.11",
+    "patternfly-react": "^2.36.1",
+    "prettier": "^1.18.2",
+    "prop-types": "^15.7.2",
+    "react": "^16.8.6",
+    "react-dom": "^16.8.6",
+    "react-router-dom": "^5.0.1",
+    "react-scripts": "3.0.1",
+    "redux": "^4.0.1",
+    "rhea": "^1.0.8",
+    "typescript": "^3.5.2"
+  },
+  "scripts": {
+    "start": "react-scripts start",
+    "build": "react-scripts build",
+    "test": "react-scripts test",
+    "eject": "react-scripts eject"
+  },
+  "eslintConfig": {
+    "extends": "react-app"
+  },
+  "browserslist": {
+    "production": [
+      ">0.2%",
+      "not dead",
+      "not op_mini all"
+    ],
+    "development": [
+      "last 1 chrome version",
+      "last 1 firefox version",
+      "last 1 safari version"
+    ]
+  }
+}
diff --git a/console/react/public/app.js b/console/react/public/app.js
new file mode 100644
index 0000000..0fa2c52
--- /dev/null
+++ b/console/react/public/app.js
@@ -0,0 +1,171 @@
+const express = require("express");
+const app = express();
+const path = require("path");
+const bodyParser = require("body-parser");
+
+app.use(bodyParser.json());
+app.use(bodyParser.urlencoded({ extended: false }));
+
+/**
+ * API
+ */
+// Not used at this time
+app.get("/api", function(req, res) {
+  console.log("GET request");
+  console.log(req.query);
+  let response = { message: "unknown get request" };
+  let query = req.query;
+  if (query.n && query.nn) {
+    // get the state of router with name n from network with name nn
+    response = { state: 1 };
+  }
+  res.status(200).send(response);
+});
+
+// handle a POST.
+app.post("/api", function(req, res) {
+  console.log("POST request received");
+  let status = 200;
+  let response = { message: "unable to parse request" };
+  // figure out what to do based on what is in data
+
+  // get the info that was passed in
+  let { what } = req.body;
+  if (what === "saveNetwork") {
+    response = saveNetwork(req.body);
+  } else if (what === "getState") {
+    response = getState(req.body);
+  } else {
+    response = {
+      status: 404,
+      response: { message: "Missing 'what' parameter in POST request" }
+    };
+    console.log(response.message);
+  }
+  if (response.status) {
+    status = response.status;
+    response = response.response;
+  }
+  res.status(status).send(response);
+});
+
+const edgeDeployment = body => {
+  // network is an object that has a nodes array, a links array, and a network name
+  // nodeName is the node.Name of the router we are requesting data for
+  const { router } = body;
+  const nodeName = router.Name;
+  console.log(`request was for edge-deployment info for ${nodeName}`);
+  return { deployment: `handle edge-deployment for ${nodeName}` };
+};
+
+const routerDeployment = body => {
+  console.log("request was for router deployment info");
+  let { router } = body;
+  // router is the object that contains the info entered about a router
+  // router should contain Name, State
+
+  // do some validation and construct the response
+  if (router && router.Name && router.Name !== "") {
+    // This is NOT really what we want to return. This is just an example.
+    const response = {
+      apiVersion: "interconnectedcloud.github.io/v1alpha1",
+      kind: "Interconnect",
+      metadata: {
+        name: "example-interconnect"
+      },
+      spec: {
+        deploymentPlan: {
+          image: "quay.io/interconnectedcloud/qdrouterd:1.7.0",
+          role: "interior",
+          size: 3,
+          placement: "Any"
+        }
+      }
+    };
+    return response;
+  }
+  return { status: 404, response: { message: "Unable to find router" } };
+};
+
+/*
+saveNetwork creates yaml that looks like the following:
+Router $Name inter-router.$PROJECT.$ROUTING_SUFFIX
+EdgeRouter $Name
+Connect $Name $Name
+# Console $Name console.$PROJECT.$ROUTING_SUFFIX
+Console PVT console.skuba.127.0.0.1.nip.io
+*/ const saveNetwork = body => {
+  console.log("request was to save current network");
+  const { network } = body;
+  const yaml = [];
+  let consoleCreated = false;
+  network.nodes.forEach(n => {
+    if (n.type === "interior") {
+      // namespace and suffix are optional
+      yaml.push(`Router ${n.Name} inter-router`);
+      // create a console for the 1st interior router
+      if (!consoleCreated) {
+        consoleCreated = true;
+        yaml.push(`Console ${n.Name} console`);
+      }
+    } else if (n.type === "edgeClass") {
+      if (n.rows) {
+        n.rows.forEach(r => {
+          yaml.push(`EdgeRouter ${r.name}`);
+        });
+      }
+    }
+  });
+  network.links.forEach(l => {
+    if (l.source.type === "interior" && l.target.type === "interior") {
+      yaml.push(`Connect ${l.source.Name} ${l.target.Name}`);
+    } else if (l.target.type === "edgeClass") {
+      // target is an edgeClass
+      // push a link for each edge router in the edgeclass
+      const edgeClass = l.target;
+      edgeClass.rows.forEach(r => {
+        yaml.push(`Connect ${r.name} ${l.source.Name}`);
+      });
+    }
+  });
+  console.log("Current network yaml:");
+  console.log(yaml.join("\\n"));
+  return {
+    yaml: yaml.join("\n")
+  };
+};
+
+const getState = body => {
+  const { router } = body;
+  let deployment;
+  let status = 200;
+  console.log("getState requested for");
+  let state = router.type === "edge" ? router.row.state : router.state;
+  // if the router is NEW, get the deployment yaml/text
+  if (state === 0) {
+    console.log(router);
+    deployment =
+      router.type === "edge" ? edgeDeployment(body) : routerDeployment(body);
+    if (deployment.status) {
+      status = deployment.status;
+      deployment = deployment.response;
+    }
+  }
+  // TODO: get the router state using the router info
+  return {
+    status: status,
+    response: { state: state, deployment: deployment }
+  };
+};
+
+/**
+ * STATIC FILES
+ */
+app.use("/", express.static("./"));
+
+// Default every route except the above to serve the index.html
+app.get("*", function(req, res) {
+  res.sendFile(path.join(__dirname + "/index.html"));
+});
+
+module.exports = app;
diff --git a/console/react/public/favicon.ico b/console/react/public/favicon.ico
new file mode 100644
index 0000000..a11777c
Binary files /dev/null and b/console/react/public/favicon.ico differ
diff --git a/console/react/public/index.html b/console/react/public/index.html
new file mode 100644
index 0000000..49d1072
--- /dev/null
+++ b/console/react/public/index.html
@@ -0,0 +1,38 @@
+<!DOCTYPE html>
+<html lang="en">
+  <head>
+    <meta charset="utf-8" />
+    <link rel="shortcut icon" href="%PUBLIC_URL%/favicon.ico" />
+    <meta name="viewport" content="width=device-width, initial-scale=1" />
+    <meta name="theme-color" content="#000000" />
+    <!--
+      manifest.json provides metadata used when your web app is installed on a
+      user's mobile device or desktop. See https://developers.google.com/web/fundamentals/web-app-manifest/
+    -->
+    <link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
+    <!--
+      Notice the use of %PUBLIC_URL% in the tags above.
+      It will be replaced with the URL of the `public` folder during the build.
+      Only files inside the `public` folder can be referenced from the HTML.
+
+      Unlike "/favicon.ico" or "favicon.ico", "%PUBLIC_URL%/favicon.ico" will
+      work correctly both with client-side routing and a non-root public URL.
+      Learn how to configure a non-root public URL by running `npm run build`.
+    -->
+    <title>Apache Qpid Dispatch Console</title>
+  </head>
+  <body>
+    <noscript>You need to enable JavaScript to run this app.</noscript>
+    <div id="root"></div>
+    <!--
+      This HTML file is a template.
+      If you open it directly in the browser, you will see an empty page.
+
+      You can add webfonts, meta tags, or analytics to this file.
+      The build step will place the bundled scripts into the <body> tag.
+
+      To begin the development, run `npm start` or `yarn start`.
+      To create a production bundle, use `npm run build` or `yarn build`.
+    -->
+  </body>
+</html>
diff --git a/console/react/public/manifest.json b/console/react/public/manifest.json
new file mode 100644
index 0000000..1f2f141
--- /dev/null
+++ b/console/react/public/manifest.json
@@ -0,0 +1,15 @@
+{
+  "short_name": "React App",
+  "name": "Create React App Sample",
+  "icons": [
+    {
+      "src": "favicon.ico",
+      "sizes": "64x64 32x32 24x24 16x16",
+      "type": "image/x-icon"
+    }
+  ],
+  "start_url": ".",
+  "display": "standalone",
+  "theme_color": "#000000",
+  "background_color": "#ffffff"
+}
diff --git a/console/react/public/server.js b/console/react/public/server.js
new file mode 100644
index 0000000..d186837
--- /dev/null
+++ b/console/react/public/server.js
@@ -0,0 +1,5 @@
+var app = require("./app");
+var port = 6060;
+app.listen(port, function() {
+  console.log("Express server listening on port " + port);
+});
diff --git a/console/react/src/App.css b/console/react/src/App.css
new file mode 100644
index 0000000..f2a7a12
--- /dev/null
+++ b/console/react/src/App.css
@@ -0,0 +1,623 @@
+.App {
+  text-align: center;
+}
+
+.App-logo {
+  animation: App-logo-spin infinite 20s linear;
+  height: 40vmin;
+  pointer-events: none;
+}
+
+.App-header {
+  background-color: #282c34;
+  min-height: 100vh;
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  justify-content: center;
+  font-size: calc(10px + 2vmin);
+  color: white;
+}
+
+.App-link {
+  color: #61dafb;
+}
+
+@keyframes App-logo-spin {
+  from {
+    transform: rotate(0deg);
+  }
+  to {
+    transform: rotate(360deg);
+  }
+}
+
+.pf-c-avatar {
+  --pf-c-avatar--Width: initial !important;
+}
+
+.donut-chart-sm {
+  width: 8em;
+  height: 8em;
+}
+
+.donut-chart-sm tspan {
+  fill: #000000 !important;
+}
+
+.deployment-donut {
+  display: flex;
+  flex-direction: column;
+  justify-content: center;
+}
+
+.deployment-donut .deployment-donut-row {
+  display: flex;
+}
+
+.deployment-donut .deployment-donut-column {
+  display: flex;
+  flex-direction: column;
+}
+
+.deployment-donut .scaling-controls {
+  justify-content: center;
+  font-size: 24px;
+}
+
+.deployment-donut .scaling-controls a {
+  color: #bbb;
+}
+
+.topo-middle {
+  margin: auto;
+}
+
+.topo-name {
+  padding-top: 30px;
+  padding-bottom: 30px;
+}
+
+.force-graph {
+  min-height: 480px;
+  height: 800px;
+}
+
+.node.interior path {
+  fill: green;
+  stroke-width: 1;
+  stroke: green;
+}
+
+.node circle {
+  fill: #fff;
+  stroke: #000;
+  stroke-width: 2px;
+}
+
+.node.selected circle,
+.node.edgeClass.selected circle {
+  stroke-width: 4px;
+  stroke: green;
+  fill: #eaeaea;
+}
+
+.node text {
+  fill: #000;
+  stroke: none;
+  font-size: 0.8em;
+}
+
+g.over line {
+  stroke: #000;
+  stroke-width: 4;
+}
+
+g.selected line {
+  stroke: green;
+  stroke-width: 4;
+}
+
+.link {
+  stroke: #000;
+}
+.topology-header path.link {
+  stroke: #fff;
+}
+
+path.hittarget {
+  stroke-width: 15px;
+  stroke: transparent;
+}
+
+.node.client rect {
+  stroke: black;
+  fill: white;
+}
+
+.node.edgeClass circle {
+  stroke: black;
+  fill: white;
+  stroke-width: 2;
+  stroke-dasharray: 4;
+}
+
+.tag-line {
+  font-size: 0.8em;
+  color: #888888;
+}
+
+.deployment-donut.table-cell button {
+  padding-top: 0;
+  padding-bottom: 0;
+}
+
+.deployment-donut.table-cell .scaling-controls {
+  font-size: 14px;
+}
+
+.address-table-container {
+  width: 31em;
+}
+
+.no-addresses {
+  margin-top: 1em;
+}
+
+.form-sub-group {
+  margin-left: 3em;
+}
+
+.sm-input {
+  width: 6em !important;
+}
+
+.incdec-label {
+  padding-left: 1em;
+}
+
+.incdec-label.disabled {
+  color: #555555;
+}
+
+.messages-receivers {
+  display: none !important;
+}
+
+.topology-header line {
+  stroke: white;
+}
+
+.network-name {
+  width: 48em;
+  margin: auto !important;
+}
+
+.not-editing-cancel {
+  display: none !important;
+}
+
+.not-editing {
+  border: 0 0 1px 0 !important;
+}
+
+path.cloud-outline {
+  stroke-width: 3px;
+  stroke: #ccc;
+  fill: white;
+}
+.cluster.selected path.cloud-outline {
+  stroke-width: 4px;
+  stroke: #cfc;
+  fill: #fcfcfc;
+}
+
+.network-toolbar {
+  padding-bottom: 1em;
+  border-bottom: 1px solid #cccccc;
+}
+
+.context-form {
+  text-align: left;
+  min-width: 34em;
+  padding-left: 2em;
+  margin-right: 0;
+  border-left: 1px solid lightgray;
+}
+
+.context-form .pf-c-form__group.pf-m-action {
+  margin-top: 0;
+}
+
+.enter-prompt {
+  padding-top: 0.25em;
+}
+
+table.edge-table td[data-label="State"] svg {
+  position: relative;
+  top: 10px;
+}
+
+.edge-table-actions {
+  position: absolute;
+  right: 1em;
+}
+
+.empty-selection {
+  max-width: 30em;
+}
+
+.link-label {
+  margin-left: 1em;
+  font-weight: bold;
+}
+
+.logo-text {
+  text-decoration: none;
+  color: white;
+  font-weight: bold;
+}
+
+.state-copy input {
+  display: none;
+}
+
+.state-copy {
+  display: inline-block;
+}
+.state-container svg {
+  position: relative;
+  top: 0.5em;
+}
+
+.state-placeholder {
+  width: 16px;
+  height: 24px;
+  display: inline-block;
+}
+
+.state-text {
+  display: inline-block;
+}
+
+div.state-container button.pf-c-clipboard-copy__group-copy {
+  display: inline-block;
+  padding: 0;
+  margin: 0;
+  position: relative;
+  top: -0.5em;
+}
+
+.pf-c-clipboard-copy__group input {
+  border: 0;
+}
+
+.pf-c-clipboard-copy {
+  --pf-c-clipboard-copy__group-copy--PaddingRight: 0;
+  --pf-c-clipboard-copy__group-copy--PaddingLeft: 0;
+  --pf-c-clipboard-copy__group-copy--BorderWidth: 0;
+}
+
+.over-alert {
+  position: absolute;
+  width: 95%;
+  z-index: 1;
+}
+
+.force-right {
+  position: absolute;
+  right: 1em;
+}
+
+.connect-modal {
+  width: 40em !important;
+  position: absolute !important;
+  right: 0;
+  padding: 2em;
+  background-color: #fafafa;
+  border: 1px solid #d1d1d1;
+  box-shadow: 0 6px 12px rgba(3, 3, 3, 0.175);
+  z-index: 2;
+}
+
+.connect-modal * {
+  color: black !important;
+}
+
+.connect-modal .pf-m-primary {
+  background-color: #06c !important;
+  color: white !important;
+}
+
+.qdr-sidebar {
+  text-align: left;
+  height: 100vh;
+}
+
+.connect-page {
+  background-color: black !important;
+  height: 100vh;
+}
+
+.connect-page * {
+  color: white;
+}
+
+.connect-page p {
+  margin-top: 4em;
+}
+.left-content {
+  width: 60%;
+  padding: 6em;
+  text-align: left;
+}
+
+.console-banner {
+  text-transform: uppercase;
+  letter-spacing: 0.3em;
+}
+
+.overview-title::first-letter {
+  text-transform: uppercase;
+}
+.overview-charts-page,
+.overview-table {
+  background-color: #eaeaea !important;
+  padding: 3em;
+}
+
+.overview-table-page {
+  padding-left: 0 !important;
+  padding-right: 0 !important;
+}
+.overview-header {
+  padding: 0.5em;
+  font-size: 5em;
+  text-align: left;
+}
+.fill-card {
+  width: 100%;
+}
+
+.qdrTopology dl.pf-c-accordion {
+  position: absolute;
+  width: 20em;
+  right: 1em;
+  padding-top: 0;
+  padding-bottom: 0;
+  margin-top: 1em;
+}
+
+.qdrTopology dl.pf-c-accordion * {
+  border-left-color: transparent;
+}
+
+.qdrTopology dl.pf-c-accordion dt {
+  background-image: linear-gradient(to bottom, #fafafa 0, #ededed 100%);
+  background-repeat: repeat-x;
+}
+
+.qdrTopology dl.pf-c-accordion h3 {
+  margin-top: 0;
+  margin-bottom: 0;
+}
+
+.pf-c-page {
+  background-color: white !important;
+}
+.topology-header {
+  background-color: #0e0e0e;
+}
+
+.qdrPopup {
+  position: absolute;
+  z-index: 200;
+  border-radius: 4px;
+  border: 1px solid gray;
+  background-color: white;
+  color: black;
+  opacity: 1;
+  padding: 12px;
+  font-size: 14px;
+}
+
+table.popupTable td {
+  padding-right: 5px;
+  font-size: 10px;
+}
+
+g text {
+  pointer-events: none;
+}
+
+svg.address-svg g text {
+  cursor: pointer;
+  pointer-events: auto;
+}
+
+svg {
+  background-color: transparent;
+  cursor: default;
+  -webkit-user-select: none;
+  -moz-user-select: none;
+  -ms-user-select: none;
+  -o-user-select: none;
+  user-select: none;
+}
+
+svg:not(.active):not(.ctrl) {
+  cursor: crosshair;
+}
+
+svg.address-svg {
+  cursor: pointer;
+}
+path.link.selected:not(.traffic) {
+  /* stroke-dasharray: 10,2; */
+  stroke: #33f !important;
+}
+
+path.link {
+  fill: #000;
+  /* stroke: #000; */
+  stroke-width: 2.5px;
+  cursor: default;
+}
+
+path.link.unknown {
+  stroke-dasharray: 4 3;
+  stroke: #888888;
+}
+path:not(.traffic) {
+  stroke: #000;
+}
+svg:not(.active):not(.ctrl) path.link {
+  cursor: pointer;
+}
+
+path.hittarget {
+  stroke-width: 15px;
+  stroke: transparent;
+}
+
+path.link.small {
+  stroke-width: 2.5;
+}
+path.link.small:not(.traffic) {
+  stroke: #000;
+}
+path.link.highlighted:not(.traffic) {
+  stroke: #6f6 !important;
+}
+marker {
+  fill: #000;
+  stroke-width: 0;
+}
+path.link.dragline {
+  pointer-events: none;
+}
+
+path.link.hidden {
+  stroke-width: 0;
+}
+
+circle.node {
+  stroke-width: 1.5px;
+  cursor: pointer;
+  stroke: darkgray;
+}
+
+circle.node.reflexive {
+  stroke: #f00 !important;
+  stroke-width: 2.5px;
+}
+circle.node.selected {
+  stroke: #6f6 !important;
+  stroke-width: 2px;
+  fill: #e0e0ff !important;
+}
+circle.node.highlighted {
+  stroke: #6f6;
+}
+circle.node.inter-router {
+  fill: #eaeaea;
+}
+circle.node.normal.in {
+  fill: #f0f000;
+}
+circle.node.normal.out {
+  fill: #c0f0c0;
+}
+circle.node.edge {
+  fill: #e0e0ff;
+}
+circle.node.on-demand {
+  fill: #c0ffc0;
+}
+circle.node.on-demand.artemis {
+  fill: #fcc;
+  /*opacity: 0.2; */
+}
+
+circle.node.normal.console {
+  fill: lightcyan;
+}
+circle.node.artemis {
+  fill: lightgreen;
+}
+circle.node.route-container {
+  fill: orange;
+}
+
+text.console,
+text.on-demand,
+text.normal,
+text.edge,
+text.address-checkbox {
+  font-family: FontAwesome;
+  font-weight: normal;
+  font-size: 16px;
+}
+
+text.address-checkbox {
+  fill: white;
+  cursor: pointer;
+}
+text.id {
+  text-anchor: middle;
+  font-weight: bold;
+}
+
+text.label {
+  text-anchor: start;
+  font-weight: bold;
+}
+
+@font-face {
+  font-family: "Brokers";
+  src: url("./assets/brokers.ttf"); /* TTF file for CSS3 browsers */
+}
+
+text.artemis {
+  font-family: Brokers;
+  font-size: 20px;
+  font-weight: bold;
+}
+
+text.qpid-cpp {
+  font-family: Brokers;
+  font-size: 18px;
+  font-weight: bold;
+}
+
+svg#svglegend {
+  max-width: 280px;
+}
+#legend-expand,
+#traffic-expand,
+#map-expand,
+#arrows-expand {
+  max-height: 20em;
+}
+
+div.qdrTopology {
+  text-align: left;
+}
+
+#arrows-expand label,
+#traffic-dots label,
+#traffic-congestion label {
+  position: relative;
+  top: 4px;
+}
+
+#traffic-address .pf-c-check {
+  padding-bottom: 0.5em;
+}
+.address-svg {
+  margin-left: 1.5em;
+}
+
+.popup-info table.popupTable td {
+  font-size: 14px;
+}
diff --git a/console/react/src/App.js b/console/react/src/App.js
new file mode 100644
index 0000000..32b9479
--- /dev/null
+++ b/console/react/src/App.js
@@ -0,0 +1,23 @@
+import React, { Component } from "react";
+import "@patternfly/react-core/dist/styles/base.css";
+import "@patternfly/patternfly/patternfly.css";
+import "patternfly/dist/css/patternfly.css";
+import "patternfly/dist/css/patternfly-additions.css";
+import "patternfly-react/dist/css/patternfly-react.css";
+
+import "./App.css";
+import PageLayout from "./layout";
+
+class App extends Component {
+  state = {};
+
+  render() {
+    return (
+      <div className="App">
+        <PageLayout />
+      </div>
+    );
+  }
+}
+
+export default App;
diff --git a/console/react/src/App.test.js b/console/react/src/App.test.js
new file mode 100644
index 0000000..a754b20
--- /dev/null
+++ b/console/react/src/App.test.js
@@ -0,0 +1,9 @@
+import React from 'react';
+import ReactDOM from 'react-dom';
+import App from './App';
+
+it('renders without crashing', () => {
+  const div = document.createElement('div');
+  ReactDOM.render(<App />, div);
+  ReactDOM.unmountComponentAtNode(div);
+});
diff --git a/console/react/src/REST.js b/console/react/src/REST.js
new file mode 100644
index 0000000..427ac22
--- /dev/null
+++ b/console/react/src/REST.js
@@ -0,0 +1,169 @@
+/*
+ * Copyright 2019 Red Hat Inc. A division of IBM
+ *
+ * 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.
+ */
+
+/* The web front-end will use this class to talk to the backend (/public/app.js)
+ */
+class REST {
+  constructor() {
+    this.url = `${window.location.protocol}//${window.location.host}`;
+  }
+
+  getRouterState = router => {
+    const body = {
+      what: "getState",
+      router: router
+    };
+    return this.doPost(body);
+  };
+
+  saveNetwork = networkInfo => {
+    const body = {
+      what: "saveNetwork",
+      network: networkInfo
+    };
+    return this.doPost(body);
+  };
+
+  doPost = body =>
+    new Promise((resolve, reject) => {
+      fetch(`${this.url}/api`, {
+        headers: {
+          Accept: "application/json",
+          "Content-Type": "application/json"
+        },
+        method: "POST",
+        body: JSON.stringify(body)
+      })
+        .then(response => {
+          if (response.status < 200 || response.status > 299) {
+            reject(response.statusText);
+            return {};
+          }
+          return response.json();
+        })
+        .then(myJson => {
+          resolve(myJson);
+        })
+        // network error?
+        .catch(error => reject(error));
+    });
+
+  // example of how to periodically do a GET request
+  examplePoll = () =>
+    new Promise((resolve, reject) => {
+      // strategy defines how we want to handle various return codes
+      // 200 means OK, in this example we want to resolve
+      // 404 means NOT_FOUND, in this example we want to resolve so caller knows it wasn't found
+      // 500 means there was a communication error, in this example we want to reject
+      const strategy = { "200": "resolve", "404": "resolve", "500": "reject" };
+      // call GET until the return code is what you want or the timeout is reached
+      poll(`${this.url}/api`, strategy).then(
+        res => {
+          resolve(res);
+        },
+        e => {
+          reject(e);
+        }
+      );
+    });
+
+  exampleDelete = name =>
+    new Promise((resolve, reject) => {
+      // console.log(` *** deleting ${name} ***`);
+      fetch(`${this.url}/api/${name}`, {
+        method: "DELETE"
+      }).then(() => {
+        // status 200 means the thing we want to delete is still there, so keep waiting
+        // status 404 means the thing we want to delete is now gone, so resolve
+        // status 500 is an error
+        const strategy = { "200": "wait", "404": "resolve", "500": "reject" };
+        // call GET for name until it returns that the name is gone or times out
+        poll(`${this.url}/api/${name}`, strategy).then(
+          res => {
+            resolve(res);
+          },
+          e => {
+            reject(e);
+          }
+        );
+      });
+    });
+
+  exampleBatch(names) {
+    return new Promise((resolve, reject) => {
+      Promise.all(names.map(name => this.exampleDelete(name))).then(
+        () => {
+          resolve();
+        },
+        firstError => {
+          reject(firstError);
+        }
+      );
+    });
+  }
+}
+
+// poll for a condition
+const poll = (url, strategy, timeout, interval) => {
+  const endTime = Number(new Date()) + (timeout || 10000);
+  interval = interval || 1000;
+  const s200 = strategy["200"];
+  const s404 = strategy["404"];
+  const s500 = strategy["500"];
+  let lastStatus = 0;
+
+  const checkCondition = (resolve, reject) => {
+    // If the condition is met, we're done!
+    fetch(url)
+      .then(res => {
+        lastStatus = res.status;
+        const ret = {};
+        // decide whether to resolve, reject, or wait
+        if (res.status >= 200 && res.status <= 299) {
+          ret[s200] = res.json();
+          return ret;
+        } else if (res.status === 404) {
+          ret[s404] = [];
+          return ret;
+        }
+        ret[s500] = res.status;
+        return ret;
+      })
+      .then(json => {
+        if (json.resolve) {
+          resolve(json.resolve);
+        } else if (json.reject) {
+          reject(json.reject);
+        }
+        // If the condition isn't met but the timeout hasn't elapsed, go again
+        else if (Number(new Date()) < endTime) {
+          setTimeout(checkCondition, interval, resolve, reject);
+        }
+        // Didn't match and too much time, reject!
+        else {
+          const msg = { message: "timeout", status: lastStatus };
+          reject(new Error(JSON.stringify(msg)));
+        }
+      })
+      .catch(e => {
+        console.log(`poll caught error ${e}`);
+        reject(e);
+      });
+  };
+  return new Promise(checkCondition);
+};
+
+export default REST;
diff --git a/console/react/src/activeAddressesCard.js b/console/react/src/activeAddressesCard.js
new file mode 100644
index 0000000..b356c13
--- /dev/null
+++ b/console/react/src/activeAddressesCard.js
@@ -0,0 +1,66 @@
+import React from "react";
+import {
+  Table,
+  TableHeader,
+  TableBody,
+  textCenter
+} from "@patternfly/react-table";
+
+class ActiveAddressesCard extends React.Component {
+  constructor(props) {
+    super(props);
+    this.state = {
+      columns: [
+        { title: "Repositories" },
+        "Branches",
+        { title: "Pull requests" },
+        "Workspaces",
+        {
+          title: "Last Commit",
+          transforms: [textCenter],
+          cellTransforms: [textCenter]
+        }
+      ],
+      rows: [
+        {
+          cells: ["one", "two", "three", "four", "five"]
+        },
+        {
+          cells: [
+            {
+              title: <div>one - 2</div>,
+              props: { title: "hover title", colSpan: 3 }
+            },
+            "four - 2",
+            "five - 2"
+          ]
+        },
+        {
+          cells: [
+            "one - 3",
+            "two - 3",
+            "three - 3",
+            "four - 3",
+            {
+              title: "five - 3 (not centered)",
+              props: { textCenter: false }
+            }
+          ]
+        }
+      ]
+    };
+  }
+
+  render() {
+    const { columns, rows } = this.state;
+
+    return (
+      <Table caption="Simple Table" cells={columns} rows={rows}>
+        <TableHeader />
+        <TableBody />
+      </Table>
+    );
+  }
+}
+
+export default ActiveAddressesCard;
diff --git a/console/react/src/alert-saved.js b/console/react/src/alert-saved.js
new file mode 100644
index 0000000..b7ea14c
--- /dev/null
+++ b/console/react/src/alert-saved.js
@@ -0,0 +1,39 @@
+import React from "react";
+import {
+  Alert,
+  AlertActionCloseButton,
+  ClipboardCopy
+} from "@patternfly/react-core";
+
+class AlertSaved extends React.Component {
+  constructor(props) {
+    super(props);
+    this.state = {};
+  }
+  render() {
+    return (
+      <Alert
+        className="over-alert"
+        variant="success"
+        title="Success"
+        action={<AlertActionCloseButton onClose={this.props.handelHideAlert} />}
+      >
+        Network saved.{" "}
+        <ClipboardCopy
+          className="state-copy"
+          onClick={(event, text) => {
+            const clipboard = event.currentTarget.parentElement;
+            const el = document.createElement("input");
+            el.value = JSON.stringify(this.props.networkYaml);
+            clipboard.appendChild(el);
+            el.select();
+            document.execCommand("copy");
+            clipboard.removeChild(el);
+          }}
+        />
+      </Alert>
+    );
+  }
+}
+
+export default AlertSaved;
diff --git a/console/react/src/amqp/connection.js b/console/react/src/amqp/connection.js
new file mode 100644
index 0000000..80c976c
--- /dev/null
+++ b/console/react/src/amqp/connection.js
@@ -0,0 +1,354 @@
+/*
+ * Copyright 2017 Red Hat 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.
+ */
+/* global Promise */
+import Correlator from "./correlator.js";
+
+const rhea = require("rhea");
+//import { on, websocket_connect, removeListener, once, connect } from 'rhea';
+
+class ConnectionManager {
+  constructor(protocol) {
+    this.sender = undefined;
+    this.receiver = undefined;
+    this.connection = undefined;
+    this.version = undefined;
+    this.errorText = undefined;
+    this.protocol = protocol;
+    this.schema = undefined;
+    this.connectActions = [];
+    this.disconnectActions = [];
+    this.correlator = new Correlator();
+    this.on_message = function(context) {
+      this.correlator.resolve(context);
+    }.bind(this);
+    this.on_disconnected = function() {
+      this.errorText = "Disconnected";
+      this.executeDisconnectActions(this.errorText);
+    }.bind(this);
+    this.on_connection_open = function() {
+      this.executeConnectActions();
+    }.bind(this);
+  }
+  versionCheck(minVer) {
+    var verparts = this.version.split(".");
+    var minparts = minVer.split(".");
+    try {
+      for (var i = 0; i < minparts.length; ++i) {
+        if (parseInt(minVer[i] > parseInt(verparts[i]))) return false;
+      }
+    } catch (e) {
+      return false;
+    }
+    return true;
+  }
+  addConnectAction(action) {
+    if (typeof action === "function") {
+      this.delConnectAction(action);
+      this.connectActions.push(action);
+    }
+  }
+  addDisconnectAction(action) {
+    if (typeof action === "function") {
+      this.delDisconnectAction(action);
+      this.disconnectActions.push(action);
+    }
+  }
+  delConnectAction(action) {
+    if (typeof action === "function") {
+      var index = this.connectActions.indexOf(action);
+      if (index >= 0) this.connectActions.splice(index, 1);
+    }
+  }
+  delDisconnectAction(action) {
+    if (typeof action === "function") {
+      var index = this.disconnectActions.indexOf(action);
+      if (index >= 0) this.disconnectActions.splice(index, 1);
+    }
+  }
+  executeConnectActions() {
+    this.connectActions.forEach(function(action) {
+      try {
+        action();
+      } catch (e) {
+        // in case the page that registered the handler has been unloaded
+      }
+    });
+    this.connectActions = [];
+  }
+  executeDisconnectActions(message) {
+    this.disconnectActions.forEach(function(action) {
+      try {
+        action(message);
+      } catch (e) {
+        // in case the page that registered the handler has been unloaded
+      }
+    });
+    this.disconnectActions = [];
+  }
+  on(eventType, fn) {
+    if (eventType === "connected") {
+      this.addConnectAction(fn);
+    } else if (eventType === "disconnected") {
+      this.addDisconnectAction(fn);
+    } else {
+      console.log("unknown event type " + eventType);
+    }
+  }
+  setSchema(schema) {
+    this.schema = schema;
+  }
+  is_connected() {
+    return (
+      this.connection &&
+      this.sender &&
+      this.receiver &&
+      this.receiver.remote &&
+      this.receiver.remote.attach &&
+      this.receiver.remote.attach.source &&
+      this.receiver.remote.attach.source.address &&
+      this.connection.is_open()
+    );
+  }
+  disconnect() {
+    if (this.sender) this.sender.close();
+    if (this.receiver) this.receiver.close();
+    if (this.connection) this.connection.close();
+  }
+  createSenderReceiver(options) {
+    return new Promise(
+      function(resolve, reject) {
+        var timeout = options.timeout || 10000;
+        // set a timer in case the setup takes too long
+        var giveUp = function() {
+          this.connection.removeListener("receiver_open", receiver_open);
+          this.connection.removeListener("sendable", sendable);
+          this.errorText = "timed out creating senders and receivers";
+          reject(Error(this.errorText));
+        }.bind(this);
+        var timer = setTimeout(giveUp, timeout);
+        // register an event hander for when the setup is complete
+        var sendable = function(context) {
+          clearTimeout(timer);
+          this.version = this.connection.properties
+            ? this.connection.properties.version
+            : "0.1.0";
+          // in case this connection dies
+          rhea.on("disconnected", this.on_disconnected);
+          // in case this connection dies and is then reconnected automatically
+          rhea.on("connection_open", this.on_connection_open);
+          // receive messages here
+          this.connection.on("message", this.on_message);
+          resolve(context);
+        }.bind(this);
+        this.connection.once("sendable", sendable);
+        // Now actually createt the sender and receiver.
+        // register an event handler for when the receiver opens
+        var receiver_open = function() {
+          // once the receiver is open, create the sender
+          if (options.sender_address)
+            this.sender = this.connection.open_sender(options.sender_address);
+          else this.sender = this.connection.open_sender();
+        }.bind(this);
+        this.connection.once("receiver_open", receiver_open);
+        // create a dynamic receiver
+        this.receiver = this.connection.open_receiver({
+          source: { dynamic: true }
+        });
+      }.bind(this)
+    );
+  }
+  connect(options) {
+    return new Promise(
+      function(resolve, reject) {
+        var finishConnecting = function() {
+          this.createSenderReceiver(options).then(
+            function(results) {
+              resolve(results);
+            },
+            function(error) {
+              reject(error);
+            }
+          );
+        };
+        if (!this.connection) {
+          options.test = false; // if you didn't want a connection, you should have called testConnect() and not connect()
+          this.testConnect(options).then(
+            function() {
+              finishConnecting.call(this);
+            }.bind(this),
+            function() {
+              // connect failed or timed out
+              this.errorText = "Unable to connect";
+              this.executeDisconnectActions(this.errorText);
+              reject(Error(this.errorText));
+            }.bind(this)
+          );
+        } else {
+          finishConnecting.call(this);
+        }
+      }.bind(this)
+    );
+  }
+  getReceiverAddress() {
+    return this.receiver.remote.attach.source.address;
+  }
+  // Try to connect using the options.
+  // if options.test === true -> close the connection if it succeeded and resolve the promise
+  // if the connection attempt fails or times out, reject the promise regardless of options.test
+  testConnect(options, callback) {
+    return new Promise(
+      function(resolve, reject) {
+        var timeout = options.timeout || 10000;
+        var reconnect = options.reconnect || false; // in case options.reconnect is undefined
+        var baseAddress = options.address + ":" + options.port;
+        if (options.linkRouteAddress) {
+          baseAddress += "/" + options.linkRouteAddress;
+        }
+        var wsprotocol = window.location.protocol === "https:" ? "wss" : "ws";
+        if (this.connection) {
+          delete this.connection;
+          this.connection = null;
+        }
+        var ws = rhea.websocket_connect(WebSocket);
+        var c = {
+          connection_details: new ws(wsprotocol + "://" + baseAddress, [
+            "binary"
+          ]),
+          reconnect: reconnect,
+          properties: options.properties || {
+            console_identifier: "Dispatch console"
+          }
+        };
+        if (options.hostname) c.hostname = options.hostname;
+        if (options.username && options.username !== "") {
+          c.username = options.username;
+        }
+        if (options.password && options.password !== "") {
+          c.password = options.password;
+        }
+        // set a timeout
+        var disconnected = function() {
+          clearTimeout(timer);
+          rhea.removeListener("disconnected", disconnected);
+          rhea.removeListener("connection_open", connection_open);
+          this.connection = null;
+          var rej = "failed to connect";
+          if (callback) callback({ error: rej });
+          reject(Error(rej));
+        }.bind(this);
+        var timer = setTimeout(disconnected, timeout);
+        // the event handler for when the connection opens
+        var connection_open = function(context) {
+          clearTimeout(timer);
+          // prevent future disconnects from calling reject
+          rhea.removeListener("disconnected", disconnected);
+          // we were just checking. we don't really want a connection
+          if (options.test) {
+            context.connection.close();
+            this.connection = null;
+          } else this.on_connection_open();
+          var res = { context: context };
+          if (callback) callback(res);
+          resolve(res);
+        }.bind(this);
+        // register an event handler for when the connection opens
+        rhea.once("connection_open", connection_open);
+        // register an event handler for if the connection fails to open
+        rhea.once("disconnected", disconnected);
+        // attempt the connection
+        this.connection = rhea.connect(c);
+      }.bind(this)
+    );
+  }
+  sendMgmtQuery(operation, to) {
+    to = to || "/$management";
+    return this.send([], to, operation);
+  }
+  sendQuery(toAddr, entity, attrs, operation) {
+    operation = operation || "QUERY";
+    var fullAddr = this._fullAddr(toAddr);
+    var body = { attributeNames: attrs || [] };
+    return this.send(
+      body,
+      fullAddr,
+      operation,
+      this.schema.entityTypes[entity].fullyQualifiedType
+    );
+  }
+  send(body, to, operation, entityType) {
+    var application_properties = {
+      operation: operation,
+      type: "org.amqp.management",
+      name: "self"
+    };
+    if (entityType) application_properties.entityType = entityType;
+    return this._send(body, to, application_properties);
+  }
+  sendMethod(toAddr, entity, attrs, operation, props) {
+    var fullAddr = this._fullAddr(toAddr);
+    var application_properties = {
+      operation: operation
+    };
+    if (entity) {
+      application_properties.type = this.schema.entityTypes[
+        entity
+      ].fullyQualifiedType;
+    }
+    if (attrs.name) application_properties.name = attrs.name;
+    else if (attrs.identity) application_properties.identity = attrs.identity;
+    if (props) {
+      for (var attrname in props) {
+        application_properties[attrname] = props[attrname];
+      }
+    }
+    return this._send(attrs, fullAddr, application_properties);
+  }
+  _send(body, to, application_properties) {
+    var _correlationId = this.correlator.corr();
+    var self = this;
+    return new Promise(function(resolve, reject) {
+      self.correlator.register(_correlationId, resolve, reject);
+      self.sender.send({
+        body: body,
+        to: to,
+        reply_to: self.receiver.remote.attach.source.address,
+        correlation_id: _correlationId,
+        application_properties: application_properties
+      });
+    });
+  }
+  _fullAddr(toAddr) {
+    var toAddrParts = toAddr.split("/");
+    toAddrParts.shift();
+    var fullAddr = toAddrParts.join("/");
+    return fullAddr;
+  }
+  availableQeueuDepth() {
+    return this.correlator.depth();
+  }
+}
+
+class ConnectionException {
+  constructor(message) {
+    this.message = message;
+    this.name = "ConnectionException";
+  }
+}
+
+const _ConnectionManager = ConnectionManager;
+export { _ConnectionManager as ConnectionManager };
+const _ConnectionException = ConnectionException;
+export { _ConnectionException as ConnectionException };
diff --git a/console/react/src/amqp/correlator.js b/console/react/src/amqp/correlator.js
new file mode 100644
index 0000000..bf34f93
--- /dev/null
+++ b/console/react/src/amqp/correlator.js
@@ -0,0 +1,50 @@
+/*
+  * Copyright 2017 Red Hat 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.
+  */
+
+import { utils } from './utilities.js';
+
+class Correlator {
+  constructor() {
+    this._objects = {};
+    this._correlationID = 0;
+    this.maxCorrelatorDepth = 10;
+  }
+  corr() {
+    return ++(this._correlationID) + '';
+  }
+  // Associate this correlation id with the promise's resolve and reject methods
+  register(id, resolve, reject) {
+    this._objects[id] = { resolver: resolve, rejector: reject };
+  }
+  // Call the promise's resolve method.
+  // This is called by rhea's receiver.on('message') function
+  resolve(context) {
+    var correlationID = context.message.correlation_id;
+    // call the promise's resolve function with a copy of the rhea response (so we don't keep any references to internal rhea data)
+    this._objects[correlationID].resolver({ response: utils.copy(context.message.body), context: context });
+    delete this._objects[correlationID];
+  }
+  reject(id, error) {
+    this._objects[id].rejector(error);
+    delete this._objects[id];
+  }
+  // Return the number of requests that can be sent before we start queuing requests
+  depth() {
+    return Math.max(1, this.maxCorrelatorDepth - Object.keys(this._objects).length);
+  }
+}
+
+export default Correlator;
diff --git a/console/react/src/amqp/management.js b/console/react/src/amqp/management.js
new file mode 100644
index 0000000..4b3bb32
--- /dev/null
+++ b/console/react/src/amqp/management.js
@@ -0,0 +1,63 @@
+/*
+ * Copyright 2015 Red Hat 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.
+ */
+
+/* global Promise */
+
+import { ConnectionManager } from './connection.js';
+import Topology from './topology.js';
+
+export class Management {
+  constructor(protocol) {
+    this.connection = new ConnectionManager(protocol);
+    this.topology = new Topology(this.connection);
+  }
+  getSchema(callback) {
+    var self = this;
+    return new Promise(function (resolve, reject) {
+      self.connection.sendMgmtQuery('GET-SCHEMA')
+        .then(function (responseAndContext) {
+          var response = responseAndContext.response;
+          for (var entityName in response.entityTypes) {
+            var entity = response.entityTypes[entityName];
+            if (entity.deprecated) {
+              // deprecated entity
+              delete response.entityTypes[entityName];
+            }
+            else {
+              for (var attributeName in entity.attributes) {
+                var attribute = entity.attributes[attributeName];
+                if (attribute.deprecated) {
+                  // deprecated attribute
+                  delete response.entityTypes[entityName].attributes[attributeName];
+                }
+              }
+            }
+          }
+          self.connection.setSchema(response);
+          if (callback)
+            callback(response);
+          resolve(response);
+        }, function (error) {
+          if (callback)
+            callback(error);
+          reject(error);
+        });
+    });
+  }
+  schema() {
+    return this.connection.schema;
+  }
+}
diff --git a/console/react/src/amqp/topology.js b/console/react/src/amqp/topology.js
new file mode 100644
index 0000000..9b467b0
--- /dev/null
+++ b/console/react/src/amqp/topology.js
@@ -0,0 +1,613 @@
+/*
+ * Copyright 2015 Red Hat 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.
+ */
+
+import { utils } from "./utilities.js";
+
+const { queue } = require("d3-queue");
+
+class Topology {
+  constructor(connectionManager, interval) {
+    this.connection = connectionManager;
+    this.updatedActions = {};
+    this.entities = []; // which entities to request each topology update
+    this.entityAttribs = { connection: [] };
+    this._nodeInfo = {}; // info about all known nodes and entities
+    this.filtering = false; // filter out nodes that don't have connection info
+    this.timeout = 5000;
+    this.updateInterval = interval;
+    this._getTimer = null;
+    this.updating = false;
+  }
+  addUpdatedAction(key, action) {
+    if (typeof action === "function") {
+      this.updatedActions[key] = action;
+    }
+  }
+  delUpdatedAction(key) {
+    if (key in this.updatedActions) delete this.updatedActions[key];
+  }
+  executeUpdatedActions(error) {
+    for (var action in this.updatedActions) {
+      this.updatedActions[action].apply(this, [error]);
+    }
+  }
+  setUpdateEntities(entities) {
+    this.entities = entities;
+    for (var i = 0; i < entities.length; i++) {
+      this.entityAttribs[entities[i]] = [];
+    }
+  }
+  addUpdateEntities(entityAttribs) {
+    if (Object.prototype.toString.call(entityAttribs) !== "[object Array]") {
+      entityAttribs = [entityAttribs];
+    }
+    for (var i = 0; i < entityAttribs.length; i++) {
+      var entity = entityAttribs[i].entity;
+      this.entityAttribs[entity] = entityAttribs[i].attrs || [];
+    }
+  }
+  on(eventName, fn, key) {
+    if (eventName === "updated") this.addUpdatedAction(key, fn);
+  }
+  unregister(eventName, key) {
+    if (eventName === "updated") this.delUpdatedAction(key);
+  }
+  nodeInfo() {
+    return this._nodeInfo;
+  }
+  saveResults(workInfo) {
+    let workSet = new Set(Object.keys(workInfo));
+    for (let rId in this._nodeInfo) {
+      if (!workSet.has(rId)) {
+        // mark any routers that went away since the last request as removed
+        this._nodeInfo[rId]["removed"] = true;
+      } else {
+        if (this._nodeInfo[rId]["removed"])
+          delete this._nodeInfo[rId]["removed"];
+        // copy entities
+        for (let entity in workInfo[rId]) {
+          if (
+            !this._nodeInfo[rId][entity] ||
+            workInfo[rId][entity]["timestamp"] + "" >
+              this._nodeInfo[rId][entity]["timestamp"] + ""
+          ) {
+            this._nodeInfo[rId][entity] = utils.copy(workInfo[rId][entity]);
+          }
+        }
+      }
+    }
+    // add any new routers
+    let nodeSet = new Set(Object.keys(this._nodeInfo));
+    for (let rId in workInfo) {
+      if (!nodeSet.has(rId)) {
+        this._nodeInfo[rId] = utils.copy(workInfo[rId]);
+      }
+    }
+  }
+  // remove any nodes that don't have connection info
+  purge() {
+    for (let id in this._nodeInfo) {
+      let node = this._nodeInfo[id];
+      if (node.removed) {
+        delete this._nodeInfo[id];
+      }
+    }
+  }
+  get() {
+    return new Promise(
+      function(resolve, reject) {
+        this.connection.sendMgmtQuery("GET-MGMT-NODES").then(
+          function(results) {
+            let routerIds = results.response;
+            if (
+              Object.prototype.toString.call(routerIds) === "[object Array]"
+            ) {
+              // if there is only one node, it will not be returned
+              if (routerIds.length === 0) {
+                var parts = this.connection.getReceiverAddress().split("/");
+                parts[parts.length - 1] = "$management";
+                routerIds.push(parts.join("/"));
+              }
+              let finish = function(workInfo) {
+                this.saveResults(workInfo);
+                this.onDone(this._nodeInfo);
+                resolve(this._nodeInfo);
+              };
+              let connectedToEdge = function(response, workInfo) {
+                let routerId = null;
+                if (response.length === 1) {
+                  let parts = response[0].split("/");
+                  // we are connected to an edge router
+                  if (parts[1] === "_edge") {
+                    // find the role:edge connection
+                    let conn = workInfo[response[0]].connection;
+                    if (conn) {
+                      let roleIndex = conn.attributeNames.indexOf("role");
+                      for (let i = 0; i < conn.results.length; i++) {
+                        if (conn.results[i][roleIndex] === "edge") {
+                          let container = utils.valFor(
+                            conn.attributeNames,
+                            conn.results[i],
+                            "container"
+                          );
+                          return utils.idFromName(container, "_topo");
+                        }
+                      }
+                    }
+                  }
+                }
+                return routerId;
+              };
+              this.doget(routerIds).then(
+                function(workInfo) {
+                  // test for edge case
+                  let routerId = connectedToEdge(routerIds, workInfo);
+                  if (routerId) {
+                    this.connection
+                      .sendMgmtQuery("GET-MGMT-NODES", routerId)
+                      .then(
+                        function(results) {
+                          let response = results.response;
+                          if (
+                            Object.prototype.toString.call(response) ===
+                            "[object Array]"
+                          ) {
+                            // special case of edge case:
+                            // we are connected to an edge router that is connected to
+                            // a router that is not connected to any other interior routers
+                            if (response.length === 0) {
+                              response = [routerId];
+                            }
+                            this.doget(response).then(
+                              function(workInfo) {
+                                finish.call(this, workInfo);
+                              }.bind(this)
+                            );
+                          }
+                        }.bind(this)
+                      );
+                  } else {
+                    finish.call(this, workInfo);
+                  }
+                }.bind(this)
+              );
+            }
+          }.bind(this),
+          function(error) {
+            reject(error);
+          }
+        );
+      }.bind(this)
+    );
+  }
+  doget(ids) {
+    return new Promise(
+      function(resolve) {
+        let workInfo = {};
+        for (var i = 0; i < ids.length; ++i) {
+          workInfo[ids[i]] = {};
+        }
+        var gotResponse = function(nodeName, entity, response) {
+          workInfo[nodeName][entity] = response;
+          workInfo[nodeName][entity]["timestamp"] = new Date();
+        };
+        var q = queue(this.connection.availableQeueuDepth());
+        for (var id in workInfo) {
+          for (var entity in this.entityAttribs) {
+            q.defer(
+              this.q_fetchNodeInfo.bind(this),
+              id,
+              entity,
+              this.entityAttribs[entity],
+              q,
+              gotResponse
+            );
+          }
+        }
+        q.await(
+          function() {
+            // filter out nodes that have no connection info
+            if (this.filtering) {
+              for (var id in workInfo) {
+                if (!workInfo[id].connection) {
+                  this.flux = true;
+                  delete workInfo[id];
+                }
+              }
+            }
+            resolve(workInfo);
+          }.bind(this)
+        );
+      }.bind(this)
+    );
+  }
+
+  onDone(result) {
+    clearTimeout(this._getTimer);
+    if (this.updating)
+      this._getTimer = setTimeout(this.get.bind(this), this.updateInterval);
+    this.executeUpdatedActions(result);
+  }
+  startUpdating(filter) {
+    this.stopUpdating();
+    this.updating = true;
+    this.filtering = filter;
+    this.get();
+  }
+  stopUpdating() {
+    this.updating = false;
+    if (this._getTimer) {
+      clearTimeout(this._getTimer);
+      this._getTimer = null;
+    }
+  }
+  fetchEntity(node, entity, attrs, callback) {
+    var results = {};
+    var gotResponse = function(nodeName, dotentity, response) {
+      results = response;
+    };
+    var q = queue(this.connection.availableQeueuDepth());
+    q.defer(
+      this.q_fetchNodeInfo.bind(this),
+      node,
+      entity,
+      attrs,
+      q,
+      gotResponse
+    );
+    q.await(function() {
+      callback(node, entity, results);
+    });
+  }
+  // called from queue.defer so the last argument (callback) is supplied by d3
+  q_fetchNodeInfo(nodeId, entity, attrs, q, heartbeat, callback) {
+    this.getNodeInfo(nodeId, entity, attrs, q, function(
+      nodeName,
+      dotentity,
+      response
+    ) {
+      heartbeat(nodeName, dotentity, response);
+      callback(null);
+    });
+  }
+  // get all the requested entities/attributes for a single router
+  fetchEntities(node, entityAttribs, doneCallback, resultCallback) {
+    var q = queue(this.connection.availableQeueuDepth());
+    var results = {};
+    if (!resultCallback) {
+      resultCallback = function(nodeName, dotentity, response) {
+        if (!results[nodeName]) results[nodeName] = {};
+        results[nodeName][dotentity] = response;
+      };
+    }
+    var gotAResponse = function(nodeName, dotentity, response) {
+      resultCallback(nodeName, dotentity, response);
+    };
+    if (Object.prototype.toString.call(entityAttribs) !== "[object Array]") {
+      entityAttribs = [entityAttribs];
+    }
+    for (var i = 0; i < entityAttribs.length; ++i) {
+      var ea = entityAttribs[i];
+      q.defer(
+        this.q_fetchNodeInfo.bind(this),
+        node,
+        ea.entity,
+        ea.attrs || [],
+        q,
+        gotAResponse
+      );
+    }
+    q.await(function() {
+      doneCallback(results);
+    });
+  }
+  // get all the requested entities for all known routers
+  fetchAllEntities(entityAttribs, doneCallback, resultCallback) {
+    var q = queue(this.connection.availableQeueuDepth());
+    var results = {};
+    if (!resultCallback) {
+      resultCallback = function(nodeName, dotentity, response) {
+        if (!results[nodeName]) results[nodeName] = {};
+        results[nodeName][dotentity] = response;
+      };
+    }
+    var gotAResponse = function(nodeName, dotentity, response) {
+      resultCallback(nodeName, dotentity, response);
+    };
+    if (Object.prototype.toString.call(entityAttribs) !== "[object Array]") {
+      entityAttribs = [entityAttribs];
+    }
+    var nodes = Object.keys(this._nodeInfo);
+    for (var n = 0; n < nodes.length; ++n) {
+      for (var i = 0; i < entityAttribs.length; ++i) {
+        var ea = entityAttribs[i];
+        q.defer(
+          this.q_fetchNodeInfo.bind(this),
+          nodes[n],
+          ea.entity,
+          ea.attrs || [],
+          q,
+          gotAResponse
+        );
+      }
+    }
+    q.await(function() {
+      doneCallback(results);
+    });
+  }
+  // enusre all the topology nones have all these entities
+  ensureAllEntities(entityAttribs, callback, extra) {
+    this.ensureEntities(
+      Object.keys(this._nodeInfo),
+      entityAttribs,
+      callback,
+      extra
+    );
+  }
+  // ensure these nodes have all these entities. don't fetch unless forced to
+  ensureEntities(nodes, entityAttribs, callback, extra) {
+    if (Object.prototype.toString.call(nodes) !== "[object Array]") {
+      nodes = [nodes];
+    }
+    this.addUpdateEntities(entityAttribs);
+    this.doget(nodes).then(
+      function(results) {
+        this.saveResults(results);
+        callback(extra, results);
+      }.bind(this)
+    );
+  }
+  addNodeInfo(id, entity, values) {
+    // save the results in the nodeInfo object
+    if (id) {
+      if (!(id in this._nodeInfo)) {
+        this._nodeInfo[id] = {};
+      }
+      // copy the values to allow garbage collection
+      this._nodeInfo[id][entity] = values;
+      this._nodeInfo[id][entity]["timestamp"] = new Date();
+    }
+  }
+  isLargeNetwork() {
+    return Object.keys(this._nodeInfo).length >= 12;
+  }
+  getConnForLink(link) {
+    // find the connection for this link
+    var conns = this._nodeInfo[link.nodeId].connection;
+    if (!conns) return {};
+    var connIndex = conns.attributeNames.indexOf("identity");
+    var linkCons = conns.results.filter(function(conn) {
+      return conn[connIndex] === link.connectionId;
+    });
+    return utils.flatten(conns.attributeNames, linkCons[0]);
+  }
+  nodeNameList() {
+    var nl = [];
+    for (var id in this._nodeInfo) {
+      nl.push(utils.nameFromId(id));
+    }
+    return nl.sort();
+  }
+  nodeIdList() {
+    var nl = [];
+    for (var id in this._nodeInfo) {
+      //if (this._nodeInfo['connection'])
+      nl.push(id);
+    }
+    return nl.sort();
+  }
+  nodeList() {
+    var nl = [];
+    for (var id in this._nodeInfo) {
+      nl.push({
+        name: utils.nameFromId(id),
+        id: id
+      });
+    }
+    return nl;
+  }
+  // queue'd function to make a management query for entities/attributes
+  q_ensureNodeInfo(nodeId, entity, attrs, q, callback) {
+    this.getNodeInfo(
+      nodeId,
+      entity,
+      attrs,
+      q,
+      function(nodeName, dotentity, response) {
+        this.addNodeInfo(nodeName, dotentity, response);
+        callback(null);
+      }.bind(this)
+    );
+    return {
+      abort: function() {
+        delete this._nodeInfo[nodeId];
+      }
+    };
+  }
+  getSingelRouterNode(nodeName, attrs) {
+    let node = {
+      id: utils.nameFromId(nodeName),
+      protocolVersion: 1,
+      instance: 0,
+      linkState: [],
+      nextHop: "(self)",
+      validOrigins: [],
+      address: nodeName,
+      routerLink: "",
+      cost: 0,
+      lastTopoChange: 0,
+      index: 0,
+      name: nodeName,
+      identity: nodeName,
+      type: "org.apache.qpid.dispatch.router.node"
+    };
+    let result = [];
+    if (attrs.length === 0) {
+      attrs = Object.keys(node);
+    }
+    attrs.forEach(attr => {
+      result.push(node[attr]);
+    });
+    return result;
+  }
+
+  getNodeInfo(nodeName, entity, attrs, q, callback) {
+    const self = this;
+    var timedOut = function(q) {
+      q.abort();
+    };
+    var atimer = setTimeout(timedOut, this.timeout, q);
+    this.connection.sendQuery(nodeName, entity, attrs).then(
+      function(response) {
+        clearTimeout(atimer);
+        if (
+          entity === "router.node" &&
+          response.response.results.length === 0 &&
+          Object.keys(self._nodeInfo).length === 1
+        ) {
+          response.response.results = [
+            self.getSingelRouterNode(nodeName, attrs)
+          ];
+        }
+        callback(nodeName, entity, response.response);
+      },
+      function() {
+        q.abort();
+      }
+    );
+  }
+  getMultipleNodeInfo(
+    nodeNames,
+    entity,
+    attrs,
+    callback,
+    selectedNodeId,
+    aggregate
+  ) {
+    var self = this;
+    if (typeof aggregate === "undefined") aggregate = true;
+    var responses = {};
+    var gotNodesResult = function(nodeName, dotentity, response) {
+      responses[nodeName] = response;
+    };
+    var q = queue(this.connection.availableQeueuDepth());
+    nodeNames.forEach(function(id) {
+      q.defer(
+        self.q_fetchNodeInfo.bind(self),
+        id,
+        entity,
+        attrs,
+        q,
+        gotNodesResult
+      );
+    });
+    q.await(function() {
+      if (aggregate)
+        self.aggregateNodeInfo(
+          nodeNames,
+          entity,
+          selectedNodeId,
+          responses,
+          callback
+        );
+      else {
+        callback(nodeNames, entity, responses);
+      }
+    });
+  }
+  quiesceLink(nodeId, name) {
+    var attributes = {
+      adminStatus: "disabled",
+      name: name
+    };
+    return this.connection.sendMethod(
+      nodeId,
+      "router.link",
+      attributes,
+      "UPDATE"
+    );
+  }
+  aggregateNodeInfo(nodeNames, entity, selectedNodeId, responses, callback) {
+    // aggregate the responses
+    var self = this;
+    var newResponse = {};
+    var thisNode = responses[selectedNodeId];
+    newResponse.attributeNames = thisNode.attributeNames;
+    newResponse.results = thisNode.results;
+    newResponse.aggregates = [];
+    // initialize the aggregates
+    for (var i = 0; i < thisNode.results.length; ++i) {
+      // there is a result for each unique entity found (ie addresses, links, etc.)
+      var result = thisNode.results[i];
+      var vals = [];
+      // there is a val for each attribute in this entity
+      result.forEach(function(val) {
+        vals.push({
+          sum: val,
+          detail: []
+        });
+      });
+      newResponse.aggregates.push(vals);
+    }
+    var nameIndex = thisNode.attributeNames.indexOf("name");
+    var ent = self.connection.schema.entityTypes[entity];
+    var ids = Object.keys(responses);
+    ids.sort();
+    ids.forEach(function(id) {
+      var response = responses[id];
+      var results = response.results;
+      results.forEach(function(result) {
+        // find the matching result in the aggregates
+        var found = newResponse.aggregates.some(function(aggregate) {
+          if (aggregate[nameIndex].sum === result[nameIndex]) {
+            // result and aggregate are now the same record, add the graphable values
+            newResponse.attributeNames.forEach(function(key, i) {
+              if (ent.attributes[key] && ent.attributes[key].graph) {
+                if (id !== selectedNodeId) aggregate[i].sum += result[i];
+              }
+              aggregate[i].detail.push({
+                node: utils.nameFromId(id) + ":",
+                val: result[i]
+              });
+            });
+            return true; // stop looping
+          }
+          return false; // continute looking for the aggregate record
+        });
+        if (!found) {
+          // this attribute was not found in the aggregates yet
+          // because it was not in the selectedNodeId's results
+          var vals = [];
+          result.forEach(function(val) {
+            vals.push({
+              sum: val,
+              detail: [
+                {
+                  node: utils.nameFromId(id),
+                  val: val
+                }
+              ]
+            });
+          });
+          newResponse.aggregates.push(vals);
+        }
+      });
+    });
+    callback(nodeNames, entity, newResponse);
+  }
+}
+
+export default Topology;
diff --git a/console/react/src/amqp/utilities.js b/console/react/src/amqp/utilities.js
new file mode 100644
index 0000000..cf98478
--- /dev/null
+++ b/console/react/src/amqp/utilities.js
@@ -0,0 +1,235 @@
+/*
+ * Copyright 2018 Red Hat 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.
+ */
+
+/* global d3 Uint8Array */
+var ddd = typeof window === "undefined" ? require("d3") : d3;
+
+var utils = {
+  isAConsole: function(properties, connectionId, nodeType, key) {
+    return this.isConsole({
+      properties: properties,
+      connectionId: connectionId,
+      nodeType: nodeType,
+      key: key
+    });
+  },
+  isConsole: function(d) {
+    return (
+      d &&
+      d.properties &&
+      d.properties.console_identifier === "Dispatch console"
+    );
+  },
+  isArtemis: function(d) {
+    return (
+      (d.nodeType === "route-container" || d.nodeType === "on-demand") &&
+      (d.properties && d.properties.product === "apache-activemq-artemis")
+    );
+  },
+
+  isQpid: function(d) {
+    return (
+      (d.nodeType === "route-container" || d.nodeType === "on-demand") &&
+      (d.properties && d.properties.product === "qpid-cpp")
+    );
+  },
+
+  clientName: function(d) {
+    let name = "client";
+    if (d.container) name = d.container;
+    if (d.properties) {
+      if (d.properties.product) name = d.properties.product;
+      else if (d.properties.console_identifier)
+        name = d.properties.console_identifier;
+      else if (d.properties.name) name = d.properties.name;
+    }
+    return name;
+  },
+  flatten: function(attributes, result) {
+    if (!attributes || !result) return {};
+    var flat = {};
+    attributes.forEach(function(attr, i) {
+      if (result && result.length > i) flat[attr] = result[i];
+    });
+    return flat;
+  },
+  flattenAll: function(entity, filter) {
+    if (!filter)
+      filter = function(e) {
+        return e;
+      };
+    let results = [];
+    for (let i = 0; i < entity.results.length; i++) {
+      let f = filter(this.flatten(entity.attributeNames, entity.results[i]));
+      if (f) results.push(f);
+    }
+    return results;
+  },
+  copy: function(obj) {
+    if (obj) return JSON.parse(JSON.stringify(obj));
+  },
+  identity_clean: function(identity) {
+    if (!identity) return "-";
+    var pos = identity.indexOf("/");
+    if (pos >= 0) return identity.substring(pos + 1);
+    return identity;
+  },
+  addr_text: function(addr) {
+    if (!addr) return "-";
+    if (addr[0] === addr[0].toLowerCase()) return addr;
+    if (addr[0] === "M") return addr.substring(2);
+    else return addr.substring(1);
+  },
+  addr_class: function(addr) {
+    if (!addr) return "-";
+    if (addr[0] === "M") return "mobile";
+    if (addr[0] === "R") return "router";
+    if (addr[0] === "A") return "area";
+    if (addr[0] === "L") return "local";
+    if (addr[0] === "H") return "edge";
+    if (addr[0] === "C") return "link-incoming";
+    if (addr[0] === "E") return "link-incoming";
+    if (addr[0] === "D") return "link-outgoing";
+    if (addr[0] === "F") return "link-outgoing";
+    if (addr[0] === "T") return "topo";
+    if (addr === "queue.waypoint") return "mobile";
+    if (addr === "link") return "link";
+    return "unknown: " + addr[0];
+  },
+  humanify: function(s) {
+    if (!s || s.length === 0) return s;
+    var t = s.charAt(0).toUpperCase() + s.substr(1).replace(/[A-Z]/g, " $&");
+    return t.replace(".", " ");
+  },
+  pretty: function(v, format = ",") {
+    var formatComma = ddd.format(format);
+    if (!isNaN(parseFloat(v)) && isFinite(v)) return formatComma(v);
+    return v;
+  },
+  isMSIE: function() {
+    return document.documentMode || /Edge/.test(navigator.userAgent);
+  },
+  // return the value for a field
+  valFor: function(aAr, vAr, key) {
+    var idx = aAr.indexOf(key);
+    if (idx > -1 && idx < vAr.length) {
+      return vAr[idx];
+    }
+    return null;
+  },
+  // return a map with unique values and their counts for a field
+  countsFor: function(aAr, vAr, key) {
+    let counts = {};
+    let idx = aAr.indexOf(key);
+    for (let i = 0; i < vAr.length; i++) {
+      if (!counts[vAr[i][idx]]) counts[vAr[i][idx]] = 0;
+      counts[vAr[i][idx]]++;
+    }
+    return counts;
+  },
+  // extract the name of the router from the router id
+  nameFromId: function(id) {
+    // the router id looks like
+    //  amqp:/_topo/0/routerName/$management'
+    //  amqp:/_topo/0/router/Name/$management'
+    //  amqp:/_edge/routerName/$management'
+    //  amqp:/_edge/router/Name/$management'
+
+    var parts = id.split("/");
+    // remove $management
+    parts.pop();
+
+    // remove the area if present
+    if (parts[2] === "0") parts.splice(2, 1);
+
+    // remove amqp/(_topo or _edge)
+    parts.splice(0, 2);
+    return parts.join("/");
+  },
+
+  // construct a router id given a router name and type (_topo or _edge)
+  idFromName: function(name, type) {
+    let parts = ["amqp:", type, name, "$management"];
+    if (type === "_topo") parts.splice(2, 0, "0");
+    return parts.join("/");
+  },
+
+  // calculate the average rate of change per second for a list of fields on the given obj
+  // store the historical raw values in storage[key] for future rate calcs
+  // keep 'history' number of historical values
+  rates: function(obj, fields, storage, key, history = 1) {
+    let list = storage[key];
+    if (!list) {
+      list = storage[key] = [];
+    }
+    // expire old entries
+    while (list.length > history) {
+      list.shift();
+    }
+    let rates = {};
+    list.push({
+      date: new Date(),
+      val: Object.assign({}, obj)
+    });
+
+    for (let i = 0; i < fields.length; i++) {
+      let cumulative = 0;
+      let field = fields[i];
+      for (let j = 0; j < list.length - 1; j++) {
+        let elapsed = list[j + 1].date - list[j].date;
+        let diff = list[j + 1].val[field] - list[j].val[field];
+        if (elapsed > 100) cumulative += diff / (elapsed / 1000);
+      }
+      rates[field] = list.length > 1 ? cumulative / (list.length - 1) : 0;
+    }
+    return rates;
+  },
+  connSecurity: function(conn) {
+    if (!conn.isEncrypted) return "no-security";
+    if (conn.sasl === "GSSAPI") return "Kerberos";
+    return conn.sslProto + "(" + conn.sslCipher + ")";
+  },
+  connAuth: function(conn) {
+    if (!conn.isAuthenticated) return "no-auth";
+    let sasl = conn.sasl;
+    if (sasl === "GSSAPI") sasl = "Kerberos";
+    else if (sasl === "EXTERNAL") sasl = "x.509";
+    else if (sasl === "ANONYMOUS") return "anonymous-user";
+    if (!conn.user) return sasl;
+    return conn.user + "(" + sasl + ")";
+  },
+  connTenant: function(conn) {
+    if (!conn.tenant) {
+      return "";
+    }
+    if (conn.tenant.length > 1) return conn.tenant.replace(/\/$/, "");
+  },
+  uuidv4: function() {
+    return ([1e7] + -1e3 + -4e3 + -8e3 + -1e11).replace(/[018]/g, c =>
+      (
+        c ^
+        (crypto.getRandomValues(new Uint8Array(1))[0] & (15 >> (c / 4)))
+      ).toString(16)
+    );
+  },
+  getUrlParts: function(fullUrl) {
+    fullUrl = fullUrl || window.location;
+    const url = document.createElement("a");
+    url.setAttribute("href", fullUrl);
+    return url;
+  }
+};
+export { utils };
diff --git a/console/react/src/assets/brokers.ttf b/console/react/src/assets/brokers.ttf
new file mode 100644
index 0000000..ae83968
Binary files /dev/null and b/console/react/src/assets/brokers.ttf differ
diff --git a/console/react/src/assets/img_avatar.svg b/console/react/src/assets/img_avatar.svg
new file mode 100644
index 0000000..11c80b8
--- /dev/null
+++ b/console/react/src/assets/img_avatar.svg
@@ -0,0 +1,33 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<svg enable-background="new 0 0 36 36" version="1.1" viewBox="0 0 36 36" xml:space="preserve" xmlns="http://www.w3.org/2000/svg">
+<style type="text/css">
+	/*stylelint-disable*/
+	.st0{fill-rule:evenodd;clip-rule:evenodd;fill:#FFFFFF;}
+	.st1{filter:url(#b);}
+	.st2{mask:url(#a);}
+	.st3{fill-rule:evenodd;clip-rule:evenodd;fill:#BBBBBB;}
+	.st4{opacity:0.1;fill-rule:evenodd;clip-rule:evenodd;enable-background:new    ;}
+	.st5{opacity:8.000000e-02;fill-rule:evenodd;clip-rule:evenodd;fill:#231F20;enable-background:new    ;}
+	/*stylelint-enable*/
+</style>
+			<circle class="st0" cx="18" cy="18.5" r="18"/>
+		<defs>
+			<filter id="b" x="5.2" y="7.2" width="25.6" height="53.6" filterUnits="userSpaceOnUse">
+				<feColorMatrix values="1 0 0 0 0  0 1 0 0 0  0 0 1 0 0  0 0 0 1 0"/>
+			</filter>
+		</defs>
+		<mask id="a" x="5.2" y="7.2" width="25.6" height="53.6" maskUnits="userSpaceOnUse">
+			<g class="st1">
+				<circle class="st0" cx="18" cy="18.5" r="18"/>
+			</g>
+		</mask>
+		<g class="st2">
+			<g transform="translate(5.04 6.88)">
+				<path class="st3" d="m22.6 18.1c-1.1-1.4-2.3-2.2-3.5-2.6s-1.8-0.6-6.3-0.6-6.1 0.7-6.1 0.7 0 0 0 0c-1.2 0.4-2.4 1.2-3.4 2.6-2.3 2.8-3.2 12.3-3.2 14.8 0 3.2 0.4 12.3 0.6 15.4 0 0-0.4 5.5 4 5.5l-0.3-6.3-0.4-3.5 0.2-0.9c0.9 0.4 3.6 1.2 8.6 1.2 5.3 0 8-0.9 8.8-1.3l0.2 1-0.2 3.6-0.3 6.3c3 0.1 3.7-3 3.8-4.4s0.6-12.6 0.6-16.5c0.1-2.6-0.8-12.1-3.1-15z"/>
+				<path class="st4" d="m22.5 26c-0.1-2.1-1.5-2.8-4.8-2.8l2.2 9.6s1.8-1.7 3-1.8c0 0-0.4-4.6-0.4-5z"/>
+				<path class="st3" d="m12.7 13.2c-3.5 0-6.4-2.9-6.4-6.4s2.9-6.4 6.4-6.4 6.4 2.9 6.4 6.4-2.8 6.4-6.4 6.4z"/>
+				<path class="st5" d="m9.4 6.8c0-3 2.1-5.5 4.9-6.3-0.5-0.1-1-0.2-1.6-0.2-3.5 0-6.4 2.9-6.4 6.4s2.9 6.4 6.4 6.4c0.6 0 1.1-0.1 1.6-0.2-2.8-0.6-4.9-3.1-4.9-6.1z"/>
+				<path class="st4" d="m8.3 22.4c-2 0.4-2.9 1.4-3.1 3.5l-0.6 18.6s1.7 0.7 3.6 0.9l0.1-23z"/>
+			</g>
+		</g>
+</svg>
diff --git a/console/react/src/brokers.ttf b/console/react/src/brokers.ttf
new file mode 100644
index 0000000..e69de29
diff --git a/console/react/src/chord/data.js b/console/react/src/chord/data.js
new file mode 100644
index 0000000..053521c
--- /dev/null
+++ b/console/react/src/chord/data.js
@@ -0,0 +1,253 @@
+/*
+Licensed to the Apache Software Foundation (ASF) under one
+or more contributor license agreements.  See the NOTICE file
+distributed with this work for additional information
+regarding copyright ownership.  The ASF licenses this file
+to you 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.
+*/
+
+import { MIN_CHORD_THRESHOLD } from "./matrix.js";
+
+const SAMPLES = 5; // number of snapshots to use for rate calculations
+
+class ChordData {
+  // eslint-disable-line no-unused-vars
+  constructor(QDRService, isRate, converter) {
+    this.QDRService = QDRService;
+    this.last_matrix = undefined;
+    this.last_values = { values: undefined, timestamp: undefined };
+    this.rateValues = undefined;
+    this.snapshots = []; // last N values used for calculating rate
+    this.isRate = isRate;
+    // fn to convert raw data to matrix
+    this.converter = converter;
+    // object that determines which addresses are excluded
+    this.filter = [];
+  }
+  setRate(isRate) {
+    this.isRate = isRate;
+  }
+  setConverter(converter) {
+    this.converter = converter;
+  }
+  setFilter(filter) {
+    this.filter = filter;
+  }
+  getAddresses() {
+    let addresses = {};
+    let outer = this.snapshots;
+    if (outer.length === 0) outer = outer = [this.last_values];
+    outer.forEach(function(snap) {
+      snap.values.forEach(function(lv) {
+        if (!(lv.address in addresses)) {
+          addresses[lv.address] = this.filter.indexOf(lv.address) < 0;
+        }
+      }, this);
+    }, this);
+    return addresses;
+  }
+  getRouters() {
+    let routers = {};
+    let outer = this.snapshots;
+    if (outer.length === 0) outer = [this.last_values];
+    outer.forEach(function(snap) {
+      snap.values.forEach(function(lv) {
+        routers[lv.egress] = true;
+        routers[lv.ingress] = true;
+      });
+    });
+    return Object.keys(routers).sort();
+  }
+  applyFilter(filter) {
+    if (filter) this.setFilter(filter);
+    return new Promise(function(resolve) {
+      resolve(convert(this, this.last_values));
+    });
+  }
+  // construct a square matrix of the number of messages each router has egressed from each router
+  getMatrix() {
+    let self = this;
+    return new Promise(function(resolve, reject) {
+      // get the router.node and router.link info
+      self.QDRService.management.topology.fetchAllEntities(
+        [
+          { entity: "router.node", attrs: ["id", "index"] },
+          {
+            entity: "router.link",
+            attrs: ["linkType", "linkDir", "owningAddr", "ingressHistogram"]
+          }
+        ],
+        function(results) {
+          if (!results) {
+            reject(Error("unable to fetch entities"));
+            return;
+          }
+          // the raw data received from the routers
+          let values = [];
+          // for each router in the network
+          for (let nodeId in results) {
+            // get a map of router ids to index into ingressHistogram for the links for this router.
+            // each routers has a different order for the routers
+            let ingressRouters = [];
+            let routerNode = results[nodeId]["router.node"];
+            if (!routerNode) {
+              continue;
+            }
+            let idIndex = routerNode.attributeNames.indexOf("id");
+            // ingressRouters is an array of router names in the same order that the ingressHistogram values will be in
+            for (let i = 0; i < routerNode.results.length; i++) {
+              ingressRouters.push(routerNode.results[i][idIndex]);
+            }
+            // the name of the router we are working on
+            let egressRouter = self.QDRService.utilities.nameFromId(nodeId);
+            // loop through the router links for this router looking for out/endpoint/non-console links
+            let routerLinks = results[nodeId]["router.link"];
+            for (
+              let i = 0;
+              routerLinks && i < routerLinks.results.length;
+              i++
+            ) {
+              let link = self.QDRService.utilities.flatten(
+                routerLinks.attributeNames,
+                routerLinks.results[i]
+              );
+              // if the link is an outbound/enpoint/non console
+              if (
+                link.linkType === "endpoint" &&
+                link.linkDir === "out" &&
+                (link.owningAddr &&
+                  !link.owningAddr.startsWith("Ltemp.") &&
+                  !link.owningAddr.startsWith("M0$"))
+              ) {
+                // keep track of the raw egress values as well as their ingress and egress routers and the address
+                for (let j = 0; j < ingressRouters.length; j++) {
+                  let messages = link.ingressHistogram
+                    ? link.ingressHistogram[j]
+                    : 0;
+                  if (messages) {
+                    values.push({
+                      ingress: ingressRouters[j],
+                      egress: egressRouter,
+                      address: self.QDRService.utilities.addr_text(
+                        link.owningAddr
+                      ),
+                      messages: messages
+                    });
+                  }
+                }
+              }
+            }
+          }
+          // values is an array of objects like [{ingress: 'xxx', egress: 'xxx', address: 'xxx', messages: ###}, ....]
+          // convert the raw values array into a matrix object
+          let matrix = convert(self, values);
+          // resolve the promise
+          resolve(matrix);
+        }
+      );
+    });
+  }
+  convertUsing(converter) {
+    let values = this.isRate ? this.rateValues : this.last_values.values;
+    // convert the values to a matrix using the requested converter and the current filter
+    return converter(values, this.filter);
+  }
+}
+
+// Private functions
+
+// compare the current values to the last_values and return the rate/second
+let calcRate = function(values, last_values, snapshots) {
+  let now = Date.now();
+  if (!last_values.values) {
+    last_values.values = values;
+    last_values.timestamp = now - 1000;
+  }
+
+  // ensure the snapshots are initialized
+  if (snapshots.length < SAMPLES) {
+    for (let i = 0; i < SAMPLES; i++) {
+      if (snapshots.length < i + 1) {
+        snapshots[i] = JSON.parse(JSON.stringify(last_values));
+        snapshots[i].timestamp = now - 1000 * (SAMPLES - i);
+      }
+    }
+  }
+  // remove oldest sample
+  snapshots.shift();
+  // add the new values to the end.
+
+  snapshots.push(JSON.parse(JSON.stringify(last_values)));
+
+  let oldest = snapshots[0];
+  let newest = snapshots[snapshots.length - 1];
+  let rateValues = [];
+  let elapsed = (newest.timestamp - oldest.timestamp) / 1000;
+  let getValueFor = function(snap, value) {
+    for (let i = 0; i < snap.values.length; i++) {
+      if (
+        snap.values[i].ingress === value.ingress &&
+        snap.values[i].egress === value.egress &&
+        snap.values[i].address === value.address
+      )
+        return snap.values[i].messages;
+    }
+  };
+  values.forEach(function(value) {
+    let first = getValueFor(oldest, value);
+    let last = getValueFor(newest, value);
+    let rate = (last - first) / elapsed;
+
+    rateValues.push({
+      ingress: value.ingress,
+      egress: value.egress,
+      address: value.address,
+      messages: Math.max(rate, MIN_CHORD_THRESHOLD)
+    });
+  });
+  return rateValues;
+};
+
+let genKeys = function(values) {
+  values.forEach(function(value) {
+    value.key = value.egress + value.ingress + value.address;
+  });
+};
+let sortByKeys = function(values) {
+  return values.sort(function(a, b) {
+    return a.key > b.key ? 1 : a.key < b.key ? -1 : 0;
+  });
+};
+let convert = function(self, values) {
+  // sort the raw data by egress router name
+  genKeys(values);
+  sortByKeys(values);
+
+  self.last_values.values = JSON.parse(JSON.stringify(values));
+  self.last_values.timestamp = Date.now();
+  if (self.isRate) {
+    self.rateValues = values = calcRate(
+      values,
+      self.last_values,
+      self.snapshots
+    );
+  }
+  // convert the raw data to a matrix
+  let matrix = self.converter(values, self.filter);
+  self.last_matrix = matrix;
+
+  return matrix;
+};
+
+export { ChordData };
diff --git a/console/react/src/chord/filters.js b/console/react/src/chord/filters.js
new file mode 100644
index 0000000..d184221
--- /dev/null
+++ b/console/react/src/chord/filters.js
@@ -0,0 +1,61 @@
+/*
+Licensed to the Apache Software Foundation (ASF) under one
+or more contributor license agreements.  See the NOTICE file
+distributed with this work for additional information
+regarding copyright ownership.  The ASF licenses this file
+to you 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.
+*/
+
+import { valuesMatrix } from './matrix.js';
+// this filter will show an arc per router with the addresses aggregated
+export const aggregateAddresses = function (values, filter) {
+  let m = new valuesMatrix(true);
+  values.forEach (function (value) {
+    if (filter.indexOf(value.address) < 0) {
+      let chordName = value.egress;
+      let egress = value.ingress;
+      let row = m.indexOf(chordName);
+      if (row < 0) {
+        row = m.addRow(chordName, value.ingress, value.egress, value.address);
+      }
+      let col = m.indexOf(egress);
+      if (col < 0) {
+        col = m.addRow(egress, value.ingress, value.egress, value.address);
+      }
+      m.addValue(row, col, value);
+    }
+  });
+  return m.sorted();
+};
+
+
+export const separateAddresses = function (values, filter) {
+  let m = new valuesMatrix(false);
+  values.forEach( function (value) {
+    if (filter.indexOf(value.address) < 0) {
+      let egressChordName = value.egress + value.ingress + value.address;
+      let r = m.indexOf(egressChordName);
+      if (r < 0) {
+        r = m.addRow(egressChordName, value.ingress, value.egress, value.address);
+      }
+      let ingressChordName = value.ingress + value.egress + value.address;
+      let c = m.indexOf(ingressChordName);
+      if (c < 0) {
+        c = m.addRow(ingressChordName, value.egress, value.ingress, value.address);
+      }
+      m.addValue(r, c, value);
+    }
+  });
+  return m.sorted();
+};
diff --git a/console/react/src/chord/layout/README.md b/console/react/src/chord/layout/README.md
new file mode 100644
index 0000000..f56ac56
--- /dev/null
+++ b/console/react/src/chord/layout/README.md
@@ -0,0 +1,85 @@
+#
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you 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.
+#
+
+  This is a replacement for d3.layout.chord().
+  It implements a groubBy feature that allows arcs to be grouped.
+  It does not implement the sortBy features of d3.layout.chord.
+
+  API: 
+  qrdlayoutChord().padding(ARCPADDING).groupBy(groupByIndexes).matrix(matrix);
+  where groupByIndexes is an array of integers.
+
+  When grouping arcs together, you are taking multiple source arcs and combining them into a single target arc.
+  With grouping you can end up with multiple chords that start and stop on the same arc[s].
+
+  Each element in the groupByIndexes array corresponds to a row in the matrix, therefore the array
+  should be matrix.length long. The position in the groupByIndexes array specifies the 
+  source arc. The value at that position determines the target arc. If the groupByIndexes array has 2 unique
+  values (0 and 1) then there will be 2 groups returned.
+
+  For example: With a matrix of 
+     [[1,2], 
+      [3,4]]
+  that represents the trips between 2 neighbourhoods: A and B.
+  d3 would normally generate 2 arcs and 3 chords.
+  The 1st arc corresponds to A with data of [1.2], and the 2nd to B with data of [3,4].
+  Chord 1 would be from A to A with a value of 1.
+  Chord 2 would be between A and B with B having a value of 3 and A having a value of 2
+  Chord 3 would be from B to B with a value of 4.
+
+  If you had data that splits those same trips into by bike and on foot,
+  you could generate a more detailed matrix like:
+      [[0,0,0,1],
+       [0,1,1,0],
+       [0,2,3,0],
+       [1,0,0,1]
+      ]
+
+  This would generate 4 arcs and 5 chords.
+  The chords would be:
+  A foot - A foot value 1
+  A bike - B bike values 1 and 1
+  B foot - A foot values 1 and 2
+  B bike - B bike value 3
+  B foot - B foot value 1
+
+  But you don't want 4 arcs: A bike, A foot, B bike, and B foot. You want 2 arcs A and B with chords
+  between them that represent the bike and foot trips. 
+  Even though you could color the A bike and A foot arcs the same, there would still be a gap between them
+  and if you switched between the detailed matrix and the aggregate matrix, the arcs would move.
+  Also, with 4 arcs, the arc labels could get unruly.
+
+  One possible kludge would be to generate the detailed diagram with 0 padding between arcs and 
+  insert dummy rows and columns between the groups.
+  The values for the dummy entries would need to be calculated so that their arc size exactly corresponded 
+  to the normal padding.
+  The arcs and chords for the dummy data would have opacity 0 and not respond to mouse events. You'd also have to 
+  create and position the labels separatly from the arcs.
+
+  Or... you could use groupBy.
+  The detail matrix would stay the same. The output chords would be the same. The only change would be
+  that the arcs A bike and A foot would be combined, and the arcs B bike and B foot would be combined. 
+  In the above example you set the groupBy array to  [0,0,1,1].
+  This says the 1st two arcs get grouped together into a new arc 0, and the 2nd two arcs get grouped into
+  a new arc 1.
+
+  Since there can be chords that have the same source.index and source.subindex and the same target.index and 
+  target.subindex, two additional data values  are returned in the chord's source and target data structures: 
+  orgindex and orgsubindex. This will let you determine whether the chord is for 
+  bike trips or foot trips.
diff --git a/console/react/src/chord/layout/layout.js b/console/react/src/chord/layout/layout.js
new file mode 100644
index 0000000..907e9a8
--- /dev/null
+++ b/console/react/src/chord/layout/layout.js
@@ -0,0 +1,147 @@
+/*
+Licensed to the Apache Software Foundation (ASF) under one
+or more contributor license agreements.  See the NOTICE file
+distributed with this work for additional information
+regarding copyright ownership.  The ASF licenses this file
+to you 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.
+*/
+/* global d3 */
+
+var qdrlayoutChord = function() { // eslint-disable-line no-unused-vars
+  var chord = {}, chords, groups, matrix, n, padding = 0, Ï„ = Math.PI*2, groupBy;
+  function relayout() {
+    groupBy = groupBy || d3.range(n);
+    // number of unique values in the groupBy array. This will be the number
+    // of groups generated.
+    var groupLen = unique(groupBy);
+    var subgroups = {}, groupSums = fill(0, groupLen), k, x, x0, i, j, di, ldi;
+
+    chords = [];
+    groups = [];
+
+    // calculate the sum of the values for each group
+    k = 0, i = -1;
+    while (++i < n) {
+      x = 0, j = -1;
+      while (++j < n) {
+        x += matrix[i][j];
+      }
+      groupSums[groupBy[i]] += x;
+      k += x;
+    }
+    // the fraction of the circle for each incremental value
+    k = (Ï„ - padding * groupLen) / k;
+    // for each row
+    x = 0, i = -1, ldi = groupBy[0];
+    while (++i < n) {
+      di = groupBy[i];
+      // insert padding after each group
+      if (di !== ldi) {
+        x += padding;
+        ldi = di;
+      }
+      // for each column
+      x0 = x, j = -1;
+      while (++j < n) {
+        var dj = groupBy[j], v = matrix[i][j], a0 = x, a1 = x += v * k;
+        // create a structure for each cell in the matrix. these are the potential chord ends
+        subgroups[i + '-' + j] = {
+          index: di,
+          subindex: dj,
+          orgindex: i,
+          orgsubindex: j,
+          startAngle: a0,
+          endAngle: a1,
+          value: v
+        };
+      }
+      if (!groups[di]) {
+        // create a new group (arc)
+        groups[di] = {
+          index: di,
+          startAngle: x0,
+          endAngle: x,
+          value: groupSums[di]
+        };
+      } else {
+        // bump up the ending angle of the combined arc
+        groups[di].endAngle = x;
+      }
+    }
+
+    // put the chord ends together into a chords.
+    i = -1;
+    while (++i < n) {
+      j = i - 1;
+      while (++j < n) {
+        var source = subgroups[i + '-' + j], target = subgroups[j + '-' + i];
+        // Only make a chord if there is a value at one of the two ends
+        if (source.value || target.value) {
+          chords.push(source.value < target.value ? {
+            source: target,
+            target: source
+          } : {
+            source: source,
+            target: target
+          });
+        }
+      }
+    }
+  }
+  chord.matrix = function(x) {
+    if (!arguments.length) return matrix;
+    n = (matrix = x) && matrix.length;
+    chords = groups = null;
+    return chord;
+  };
+  chord.padding = function(x) {
+    if (!arguments.length) return padding;
+    padding = x;
+    chords = groups = null;
+    return chord;
+  };
+  chord.groupBy = function (x) {
+    if (!arguments.length) return groupBy;
+    groupBy = x;
+    chords = groups = null;
+    return chord;
+  };
+  chord.chords = function() {
+    if (!chords) relayout();
+    return chords;
+  };
+  chord.groups = function() {
+    if (!groups) relayout();
+    return groups;
+  };
+  return chord;
+};
+
+let fill = function (value, length) {
+  var i=0, array = []; 
+  array.length = length; 
+  while(i < length) 
+    array[i++] = value;
+  return array;
+};
+
+let unique = function (arr) {
+  var counts = {};
+  for (var i = 0; i < arr.length; i++) {
+    counts[arr[i]] = 1 + (counts[arr[i]] || 0);
+  }
+  return Object.keys(counts).length;
+};
+
+export { qdrlayoutChord };
\ No newline at end of file
diff --git a/console/react/src/chord/matrix.js b/console/react/src/chord/matrix.js
new file mode 100644
index 0000000..156deb3
--- /dev/null
+++ b/console/react/src/chord/matrix.js
@@ -0,0 +1,215 @@
+/*
+Licensed to the Apache Software Foundation (ASF) under one
+or more contributor license agreements.  See the NOTICE file
+distributed with this work for additional information
+regarding copyright ownership.  The ASF licenses this file
+to you 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.
+*/
+/* global d3 */
+
+const MIN_CHORD_THRESHOLD = 0.01;
+
+// public Matrix object
+function valuesMatrix(aggregate) {
+  this.rows = [];
+  this.aggregate = aggregate;
+}
+// a matrix row
+let valuesMatrixRow = function(r, chordName, ingress, egress) {
+  this.chordName = chordName || '';
+  this.ingress = ingress || '';
+  this.egress = egress || '';
+  this.index = r;
+  this.cols = [];
+  for (let c=0; c<r; c++) {
+    this.addCol(0);
+  }
+};
+// a matrix column
+let valuesMatrixCol = function(messages, row, c, address) {
+  this.messages = messages;
+  this.address = address;
+  this.index = c;
+  this.row = row;
+};
+
+// initialize a matrix with empty data with size rows and columns
+valuesMatrix.prototype.zeroInit = function (size) {
+  for (let r=0; r<size; r++) {
+    this.addRow();
+  }
+};
+
+valuesMatrix.prototype.setRowCol = function (r, c, ingress, egress, address, value) {
+  this.rows[r].ingress = ingress;
+  this.rows[r].egress = egress;
+  this.rows[r].cols[c].messages = value;
+  this.rows[r].cols[c].address = address;
+};
+
+valuesMatrix.prototype.setColMessages = function (r, c, messages) {
+  this.rows[r].cols[c].messages = messages;
+};
+
+// return true if any of the matrix cells have messages
+valuesMatrix.prototype.hasValues = function () {
+  return this.rows.some(function (row) {
+    return row.cols.some(function (col) {
+      return col.messages > MIN_CHORD_THRESHOLD;
+    });
+  });
+};
+valuesMatrix.prototype.getMinMax = function () {
+  let min = Number.MAX_VALUE, max = Number.MIN_VALUE;
+  this.rows.forEach( function (row) {
+    row.cols.forEach( function (col) {
+      if (col.messages > MIN_CHORD_THRESHOLD) {
+        max = Math.max(max, col.messages);
+        min = Math.min(min, col.messages);
+      }
+    });
+  });
+  return [min, max];
+};
+// extract a square matrix with just the values from the object matrix
+valuesMatrix.prototype.matrixMessages = function () {
+  let m = emptyMatrix(this.rows.length);
+  this.rows.forEach( function (row, r) {
+    row.cols.forEach( function (col, c) {
+      m[r][c] = col.messages;
+    });
+  });
+  return m;
+};
+
+valuesMatrix.prototype.getGroupBy = function () {
+  if (!this.aggregate && this.rows.length) {
+    let groups = [];
+    let lastName = this.rows[0].egress, groupIndex = 0;
+    this.rows.forEach( function (row) {
+      if (row.egress !== lastName) {
+        groupIndex++;
+        lastName = row.egress;
+      }
+      groups.push(groupIndex);
+    });
+    return groups;
+  }
+  else 
+    return d3.range(this.rows.length);
+};
+
+valuesMatrix.prototype.chordName = function (i, ingress) {
+  if (this.aggregate)
+    return this.rows[i].chordName;
+  return (ingress ? this.rows[i].ingress : this.rows[i].egress);
+};
+valuesMatrix.prototype.routerName = function (i) {
+  if (this.aggregate)
+    return this.rows[i].chordName;
+  return getAttribute(this, 'egress', i);
+};
+valuesMatrix.prototype.getEgress = function (i) {
+  return getAttribute(this, 'egress', i);
+};
+valuesMatrix.prototype.getIngress = function (i) {
+  return getAttribute(this, 'ingress', i);
+};
+valuesMatrix.prototype.getAddress = function (r, c) {
+  return this.rows[r].cols[c].address;
+};
+valuesMatrix.prototype.getAddresses = function (r) {
+  let addresses = {};
+  this.rows[r].cols.forEach( function (c) {
+    if (c.address && c.messages)
+      addresses[c.address] = true;
+  });
+  return Object.keys(addresses);
+};
+let getAttribute = function (self, attr, i) {
+  if (self.aggregate)
+    return self.rows[i][attr];
+  let groupByIndex = self.getGroupBy().indexOf(i);
+  if (groupByIndex < 0) {
+    groupByIndex = i;
+  }
+  return self.rows[groupByIndex][attr];
+};
+valuesMatrix.prototype.addRow = function (chordName, ingress, egress, address) {
+  let rowIndex = this.rows.length;
+  let newRow = new valuesMatrixRow(rowIndex, chordName, ingress, egress);
+  this.rows.push(newRow);
+  // add new column to all rows
+  for (let r=0; r<=rowIndex; r++) {
+    this.rows[r].addCol(0, address);
+  }
+  return rowIndex;
+};
+valuesMatrix.prototype.indexOf = function (chordName) {
+  return this.rows.findIndex( function (row) {
+    return row.chordName === chordName;
+  });
+};
+valuesMatrix.prototype.addValue = function (r, c, value) {
+  this.rows[r].cols[c].addMessages(value.messages);
+  this.rows[r].cols[c].setAddress(value.address);
+};
+valuesMatrixRow.prototype.addCol = function (messages, address) {
+  this.cols.push(new valuesMatrixCol(messages, this, this.cols.length, address));
+};
+valuesMatrixCol.prototype.addMessages = function (messages) {
+  if (!(this.messages === MIN_CHORD_THRESHOLD && messages === MIN_CHORD_THRESHOLD))
+    this.messages += messages;
+};
+valuesMatrixCol.prototype.setAddress = function (address) {
+  this.address = address;
+};
+valuesMatrix.prototype.getChordList = function () {
+  return this.rows.map( function (row) {
+    return row.chordName;
+  });
+};
+valuesMatrix.prototype.sorted = function () {
+  let newChordList = this.getChordList();
+  newChordList.sort();
+  let m = new valuesMatrix(this.aggregate);
+  m.zeroInit(this.rows.length);
+  this.rows.forEach( function (row) {
+    let chordName = row.chordName;
+    row.cols.forEach( function (col, c) {
+      let newRow = newChordList.indexOf(chordName);
+      let newCol = newChordList.indexOf(this.rows[c].chordName);
+      m.rows[newRow].chordName = chordName;
+      m.rows[newRow].ingress = row.ingress;
+      m.rows[newRow].egress = row.egress;
+      m.rows[newRow].cols[newCol].messages = col.messages;
+      m.rows[newRow].cols[newCol].address = col.address;
+    }.bind(this));
+  }.bind(this));
+  return m;
+};
+
+// private helper function
+let emptyMatrix = function (size) {
+  let matrix = [];
+  for(let i=0; i<size; i++) {
+    matrix[i] = [];
+    for(let j=0; j<size; j++) {
+      matrix[i][j] = 0;
+    }
+  }
+  return matrix;
+};
+
+export { MIN_CHORD_THRESHOLD, valuesMatrix };
diff --git a/console/react/src/chord/qdrChord.js b/console/react/src/chord/qdrChord.js
new file mode 100644
index 0000000..edb1b3a
--- /dev/null
+++ b/console/react/src/chord/qdrChord.js
@@ -0,0 +1,842 @@
+/*
+Licensed to the Apache Software Foundation (ASF) under one
+or more contributor license agreements.  See the NOTICE file
+distributed with this work for additional information
+regarding copyright ownership.  The ASF licenses this file
+to you 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.
+*/
+/* global angular d3 */
+
+import { QDRRedirectWhenConnected } from '../qdrGlobals.js';
+import { separateAddresses, aggregateAddresses } from './filters.js';
+import { ChordData } from './data.js';
+import { qdrRibbon } from './ribbon/ribbon.js';
+import { qdrlayoutChord } from './layout/layout.js';
+
+export class ChordController {
+  constructor(QDRService, $scope, $location, $timeout, $sce) {
+    this.controllerName = 'QDR.ChordController';
+    // if we get here and there is no connection, redirect to the connect page and then 
+    // return here once we are connected
+    if (!QDRService.management.connection.is_connected()) {
+      QDRRedirectWhenConnected($location, 'chord');
+      return;
+    }
+
+    const CHORDOPTIONSKEY = 'chordOptions';
+    const CHORDFILTERKEY =  'chordFilter';
+    const DOUGHNUT =        '#chord svg .empty';
+    const ERROR_RENDERING = 'Error while rendering ';
+    const ARCPADDING = .06;
+    const SMALL_OFFSET = 210;
+    const MIN_RADIUS = 200;
+
+    // flag to show/hide the router section of the legend
+    $scope.noValues = true;
+    // state of the option checkboxes
+    $scope.legendOptions = angular.fromJson(localStorage[CHORDOPTIONSKEY]) || {isRate: false, byAddress: false};
+    // remember which addresses were last selected and restore them when page loads
+    let excludedAddresses = angular.fromJson(localStorage[CHORDFILTERKEY]) || [];
+    // colors for the legend and the diagram
+    $scope.chordColors = {};
+    $scope.arcColors = {};
+
+    $scope.legend = {status: {addressesOpen: true, routersOpen: true, optionsOpen: true}};
+    // get notified when the byAddress checkbox is toggled
+    let switchedByAddress = false;
+    $scope.$watch('legendOptions.byAddress', function (newValue, oldValue) {
+      if (newValue !== oldValue) {
+        d3.select('#legend')
+          .classed('byAddress', newValue);
+        chordData.setConverter($scope.legendOptions.byAddress ? separateAddresses: aggregateAddresses);
+        switchedByAddress = true;
+        updateNow();
+        localStorage[CHORDOPTIONSKEY] = JSON.stringify($scope.legendOptions);
+      }
+    });
+    // get notified when the 'by rate' checkbox is toggled
+    $scope.$watch('legendOptions.isRate', function (n, o) {
+      if (n !== o) {
+        chordData.setRate($scope.legendOptions.isRate);
+
+        let doughnut = d3.select(DOUGHNUT);
+        if (!doughnut.empty()) {
+          fadeDoughnut();
+        }
+        updateNow();
+
+        localStorage[CHORDOPTIONSKEY] = JSON.stringify($scope.legendOptions);
+      }
+    });
+    $scope.arcColorsEmpty = function () {
+      return Object.keys($scope.arcColors).length === 0;
+    };
+
+    // event notification that an address checkbox has changed
+    $scope.addressFilterChanged = function () {
+      fadeDoughnut();
+
+      excludedAddresses = [];
+      for (let address in $scope.addresses) {
+        if (!$scope.addresses[address])
+          excludedAddresses.push(address);
+      }
+      localStorage[CHORDFILTERKEY] = JSON.stringify(excludedAddresses);
+      if (chordData) 
+        chordData.setFilter(excludedAddresses);
+      updateNow();
+    };
+
+    // called by angular when mouse enters one of the address legends
+    $scope.enterLegend = function (addr) {
+      if (!$scope.legendOptions.byAddress)
+        return;
+      // fade all chords that don't have this address 
+      let indexes = [];
+      chordData.last_matrix.rows.forEach( function (row, r) {
+        let addresses = chordData.last_matrix.getAddresses(r);
+        if (addresses.indexOf(addr) >= 0)
+          indexes.push(r);
+      });
+      d3.selectAll('path.chord').classed('fade', function(p) {
+        return indexes.indexOf(p.source.orgindex) < 0 && indexes.indexOf(p.target.orgindex) < 0;
+      });
+    };
+
+    // called by angular when mouse enters one of the router legends
+    $scope.enterRouter = function (router) {
+      let indexes = [];
+      // fade all chords that are not associated with this router
+      let agg = chordData.last_matrix.aggregate;
+      chordData.last_matrix.rows.forEach( function (row, r) {
+        if (agg) {
+          if (row.chordName === router)
+            indexes.push(r);
+        } else {
+          if (row.ingress === router || row.egress === router)
+            indexes.push(r);
+        }
+      });
+      d3.selectAll('path.chord').classed('fade', function(p) {
+        return indexes.indexOf(p.source.orgindex) < 0 && indexes.indexOf(p.target.orgindex) < 0;
+      });
+    };
+    $scope.leaveLegend = function () {
+      showAllChords();
+    };
+    // clicked on the address name. toggle the address checkbox
+    $scope.addressClick = function (address) {
+      $scope.addresses[address] = !$scope.addresses[address];
+      $scope.addressFilterChanged();
+    };
+
+    // fade out the empty circle that is shown when there is no traffic
+    let fadeDoughnut = function () {
+      d3.select(DOUGHNUT)
+        .transition()
+        .duration(200)
+        .attr('opacity', 0)
+        .remove();
+    };
+
+    // create an object that will be used to fetch the data
+    let chordData = new ChordData(QDRService, 
+      $scope.legendOptions.isRate, 
+      $scope.legendOptions.byAddress ? separateAddresses: aggregateAddresses);
+    chordData.setFilter(excludedAddresses);
+
+    // get the data now in response to a user event (as opposed to periodically)
+    let updateNow = function () {
+      clearInterval(interval);
+      chordData.getMatrix().then(render, function (e) { console.log(ERROR_RENDERING + e);});
+      interval = setInterval(doUpdate, transitionDuration);
+    };
+
+    // size the diagram based on the browser window size
+    let getRadius = function () {
+      let w = window,
+        d = document,
+        e = d.documentElement,
+        b = d.getElementsByTagName('body')[0],
+        x = w.innerWidth || e.clientWidth || b.clientWidth,
+        y = w.innerHeight|| e.clientHeight|| b.clientHeight;
+      return Math.max(Math.floor((Math.min(x, y) * 0.9) / 2), MIN_RADIUS);
+    };
+
+    // diagram sizes that change when browser is resized
+    let outerRadius, innerRadius, textRadius;
+    let setSizes = function () {
+      // size of circle + text
+      outerRadius = getRadius();
+      // size of chords
+      innerRadius = outerRadius - 130;
+      // arc ring around chords
+      textRadius = Math.min(innerRadius * 1.1, innerRadius + 15);
+    };
+    setSizes();
+
+    $scope.navbutton_toggle = function () {
+      let legendPos = $('#legend').position();
+      console.log(legendPos);
+      if (legendPos.left === 0)
+        setTimeout(windowResized, 10);
+      else
+        $('#switches').css({left: -legendPos.left, opacity: 1});
+    };
+    // TODO: handle window resizes
+    //let updateWindow  = function () {
+    //setSizes();
+    //startOver();
+    //};
+    //d3.select(window).on('resize.updatesvg', updateWindow);
+    let windowResized = function () {
+      let legendPos = $('#legend').position();
+      let switches = $('#switches');
+      let outerWidth = switches.outerWidth();
+      if (switches && legendPos)
+        switches.css({left: (legendPos.left - outerWidth), opacity: 1});
+    };
+    window.addEventListener('resize', function () {
+      windowResized();
+      setTimeout(windowResized, 1);
+    });
+
+    // used for animation duration and the data refresh interval 
+    let transitionDuration = 1000;
+    // format with commas
+    let formatNumber = d3.format(',.1f');
+
+    // colors
+    let colorGen = d3.scale.category20();
+    // The colorGen funtion is not random access. 
+    // To get the correct color[19] you first have to get all previous colors
+    // I suspect some caching is going on in d3
+    for (let i=0; i<20; i++) {
+      colorGen(i);
+    }
+    // arc colors are taken from every other color starting at 0
+    let getArcColor = function (n) {
+      if (!(n in $scope.arcColors)) {
+        let ci = Object.keys($scope.arcColors).length * 2;
+        $scope.arcColors[n] = colorGen(ci);
+      }
+      return $scope.arcColors[n];
+    };
+    // chord colors are taken from every other color starting at 19 and going backwards
+    let getChordColor = function (n) {
+      if (!(n in $scope.chordColors)) {
+        let ci = 19 - Object.keys($scope.chordColors).length * 2;
+        let c = colorGen(ci);
+        $scope.chordColors[n] = c;
+      }
+      return $scope.chordColors[n];
+    };
+    // return the color associated with a router
+    let fillArc = function (matrixValues, row) {
+      let router = matrixValues.routerName(row);
+      return getArcColor(router);
+    };
+    // return the color associated with a chord.
+    // if viewing by address, the color will be the address color.
+    // if viewing aggregate, the color will be the router color of the largest chord ending
+    let fillChord = function (matrixValues, d) {
+      // aggregate
+      if (matrixValues.aggregate) {
+        return fillArc(matrixValues, d.source.index);
+      }
+      // by address
+      let addr = matrixValues.getAddress(d.source.orgindex, d.source.orgsubindex);
+      return getChordColor(addr);
+    };
+
+    // keep track of previous chords so we can animate to the new values
+    let last_chord, last_labels;
+
+    // global pointer to the diagram
+    let svg;
+
+    // called once when the page loads
+    let initSvg = function () {
+      d3.select('#chord svg').remove();
+
+      let xtrans = outerRadius === MIN_RADIUS ? SMALL_OFFSET : outerRadius;
+      svg = d3.select('#chord').append('svg')
+        .attr('width', outerRadius * 2)
+        .attr('height', outerRadius * 2)
+        .append('g')
+        .attr('id', 'circle')
+        .attr('transform', 'translate(' + xtrans + ',' + outerRadius + ')');
+
+      // mouseover target for when the mouse leaves the diagram
+      svg.append('circle')
+        .attr('r', innerRadius * 2)
+        .on('mouseover', showAllChords);
+
+      // background circle. will only get a mouseover event if the mouse is between chords
+      svg.append('circle')
+        .attr('r', innerRadius)
+        .on('mouseover', function() { d3.event.stopPropagation(); });
+
+      svg = svg.append('g')
+        .attr('class', 'chart-container');
+    };
+    initSvg();
+
+    let emptyCircle = function () {
+      $scope.noValues = false;
+      d3.select(DOUGHNUT).remove();
+
+      let arc = d3.svg.arc()
+        .innerRadius(innerRadius)
+        .outerRadius(textRadius)
+        .startAngle(0)
+        .endAngle(Math.PI * 2);
+
+      d3.select('#circle').append('path')
+        .attr('class', 'empty')
+        .attr('d', arc);
+    };
+
+    let genArcColors = function () {
+      //$scope.arcColors = {};
+      let routers = chordData.getRouters();
+      routers.forEach( function (router) {
+        getArcColor(router);
+      });
+    };
+    let genChordColors = function () {
+      $scope.chordColors = {};
+      if ($scope.legendOptions.byAddress) {
+        Object.keys($scope.addresses).forEach( function (address) {
+          getChordColor(address);
+        });
+      }
+    };
+    let chordKey = function (d, matrix) {
+      // sort so that if the soure and target are flipped, the chord doesn't
+      // get destroyed and recreated
+      return getRouterNames(d, matrix).sort().join('-');
+    };
+    let popoverChord = null;
+    let popoverArc = null;
+
+    let getRouterNames = function (d, matrix) {
+      let egress, ingress, address = '';
+      // for arcs d will have an index, for chords d will have a source.index and target.index
+      let eindex = angular.isDefined(d.index) ? d.index : d.source.index;
+      let iindex = angular.isDefined(d.index) ? d.index : d.source.subindex;
+      if (matrix.aggregate) {
+        egress = matrix.rows[eindex].chordName;
+        ingress = matrix.rows[iindex].chordName;
+      } else {
+        egress = matrix.routerName(eindex);
+        ingress = matrix.routerName(iindex);
+        // if a chord
+        if (d.source) {
+          address = matrix.getAddress(d.source.orgindex, d.source.orgsubindex);
+        }
+      }
+      return [ingress, egress, address];
+    };
+    // popup title when mouse is over a chord
+    // shows the address, from and to routers, and the values
+    let chordTitle = function (d, matrix) {
+      let rinfo = getRouterNames(d, matrix);
+      let from = rinfo[0], to = rinfo[1], address = rinfo[2];
+      if (!matrix.aggregate) {
+        address += '<br/>';
+      }
+      let title = address + from
+      + ' → ' + to
+      + ': ' + formatNumber(d.source.value);
+      if (d.target.value > 0 && to !== from) {
+        title += ('<br/>' + to
+        + ' → ' + from
+        + ': ' + formatNumber(d.target.value));
+      }
+      return title;
+    };
+    let arcTitle = function (d, matrix) {
+      let egress, value = 0;
+      if (matrix.aggregate) {
+        egress = matrix.rows[d.index].chordName;
+        value = d.value;
+      }
+      else {
+        egress = matrix.routerName(d.index);
+        value = d.value;
+      }
+      return egress + ': ' + formatNumber(value);
+    };
+
+    let decorateChordData = function (rechord, matrix) {
+      let data = rechord.chords();
+      data.forEach( function (d, i) {
+        d.key = chordKey(d, matrix, false);
+        d.orgIndex = i;
+        d.color = fillChord(matrix, d);
+      });
+      return data;
+    };
+
+    let decorateArcData = function (fn, matrix) {
+      let fixedGroups = fn();
+      fixedGroups.forEach( function (fg) {
+        fg.orgIndex = fg.index;
+        fg.angle = (fg.endAngle + fg.startAngle)/2;
+        fg.key = matrix.routerName(fg.index);
+        fg.components = [fg.index];
+        fg.router = matrix.aggregate ? fg.key : matrix.getEgress(fg.index);
+        fg.color = getArcColor(fg.router);
+      });
+      return fixedGroups;
+    };
+
+    let theyveBeenWarned = false;
+    // create and/or update the chord diagram
+    function render(matrix) {
+      $scope.addresses = chordData.getAddresses();
+      // populate the arcColors object with a color for each router
+      genArcColors();
+      genChordColors();
+
+      // if all the addresses are excluded, update the message
+      let addressLen = Object.keys($scope.addresses).length;
+      $scope.allAddressesFiltered = false;
+      if (addressLen > 0 && excludedAddresses.length === addressLen) {
+        $scope.allAddressesFiltered = true;
+      }
+
+      $scope.noValues = false;
+      let matrixMessages, duration = transitionDuration;
+
+      // if there is no data, show an empty circle and a message
+      if (!matrix.hasValues()) {
+        $timeout( function () {
+          $scope.noValues = $scope.arcColors.length === 0;
+          if (!theyveBeenWarned) {
+            theyveBeenWarned = true;
+            let msg = 'There is no message traffic';
+            if (addressLen !== 0)
+              msg += ' for the selected addresses';
+            let autoHide = outerRadius === MIN_RADIUS;
+            $.notify($('#noTraffic'), msg, {clickToHide: autoHide, autoHide: autoHide, arrowShow: false, className: 'Warning'});
+            $('.notifyjs-wrapper').css('z-index', autoHide ? 3 : 0);
+          }
+        });
+        emptyCircle();
+        matrixMessages = [];
+      } else {
+        matrixMessages = matrix.matrixMessages();
+        $('.notifyjs-wrapper').hide();
+        theyveBeenWarned = false;
+        fadeDoughnut();
+      }
+
+      // create a new chord layout so we can animate between the last one and this one
+      let groupBy = matrix.getGroupBy();
+      let rechord = qdrlayoutChord().padding(ARCPADDING).groupBy(groupBy).matrix(matrixMessages);
+
+      // The chord layout has a function named .groups() that returns the
+      // data for the arcs. We decorate this data with a unique key.
+      rechord.arcData = decorateArcData(rechord.groups, matrix);
+
+      // join the decorated data with a d3 selection
+      let arcsGroup = svg.selectAll('g.arc')
+        .data(rechord.arcData, function (d) {return d.key;});
+
+      // get a d3 selection of all the new arcs that have been added
+      let newArcs = arcsGroup.enter().append('svg:g')
+        .attr('class', 'arc');
+
+      // each new arc is an svg:path that has a fixed color
+      newArcs.append('svg:path')
+        .style('fill', function(d) { return d.color; })
+        .style('stroke', function(d) { return d.color; });
+
+      newArcs.append('svg:text')
+        .attr('dy', '.35em')
+        .text(function (d) {
+          return d.router;
+        });
+
+      // attach event listeners to all arcs (new or old)
+      arcsGroup
+        .on('mouseover', mouseoverArc)
+        .on('mousemove', function (d) {
+          popoverArc = d;
+          let top = $('#chord').offset().top - 5;
+          $timeout(function () {
+            $scope.trustedpopoverContent = $sce.trustAsHtml(arcTitle(d, matrix));
+          });
+          d3.select('#popover-div')
+            .style('display', 'block')
+            .style('left', (d3.event.pageX+5)+'px')
+            .style('top', (d3.event.pageY-top)+'px');
+        })
+        .on('mouseout', function () {
+          popoverArc = null;
+          d3.select('#popover-div')
+            .style('display', 'none');
+        });
+
+      // animate the arcs path to it's new location
+      arcsGroup.select('path')
+        .transition()
+        .duration(duration)
+        //.ease('linear')
+        .attrTween('d', arcTween(last_chord));
+      arcsGroup.select('text')
+        .attr('text-anchor', function (d) {
+          return d.angle > Math.PI ? 'end' : 'begin';
+        })
+        .transition()
+        .duration(duration)
+        .attrTween('transform', tickTween(last_labels));
+
+      // check if the mouse is hovering over an arc. if so, update the tooltip
+      arcsGroup
+        .each(function(d) {
+          if (popoverArc && popoverArc.index === d.index) {
+            $scope.trustedpopoverContent = $sce.trustAsHtml(arcTitle(d, matrix));
+          }
+        });
+
+      // animate the removal of any arcs that went away
+      let exitingArcs = arcsGroup.exit();
+
+      exitingArcs.selectAll('text')
+        .transition()
+        .duration(duration/2)
+        .attrTween('opacity', function () {return function (t) {return 1 - t;};});
+
+      exitingArcs.selectAll('path')
+        .transition()
+        .duration(duration/2)
+        .attrTween('d', arcTweenExit)
+        .each('end', function () {d3.select(this).node().parentNode.remove();});
+
+      // decorate the chord layout's .chord() data with key, color, and orgIndex
+      rechord.chordData = decorateChordData(rechord, matrix);
+      let chordPaths = svg.selectAll('path.chord')
+        .data(rechord.chordData, function (d) { return d.key;});
+
+      // new chords are paths
+      chordPaths.enter().append('path')
+        .attr('class', 'chord');
+
+      if (!switchedByAddress) {
+        // do multiple concurrent tweens on the chords
+        chordPaths
+          .call(tweenChordEnds, duration, last_chord)
+          .call(tweenChordColor, duration, last_chord, 'stroke')
+          .call(tweenChordColor, duration, last_chord, 'fill');
+      } else {
+        // switchByAddress is only true when we have new chords
+        chordPaths
+          .attr('d', function (d) {return chordReference(d);})
+          .attr('stroke', function (d) {return d3.rgb(d.color).darker(1);})
+          .attr('fill', function (d) {return d.color;})
+          .attr('opacity', 1e-6)
+          .transition()
+          .duration(duration/2)
+          .attr('opacity', .67);
+      }
+  
+      // if the mouse is hovering over a chord, update it's tooltip
+      chordPaths
+        .each(function(d) {
+          if (popoverChord && 
+            popoverChord.source.orgindex === d.source.orgindex && 
+            popoverChord.source.orgsubindex === d.source.orgsubindex) {
+            $scope.trustedpopoverContent = $sce.trustAsHtml(chordTitle(d, matrix));
+          }
+        });
+
+      // attach mouse event handlers to the chords
+      chordPaths
+        .on('mouseover', mouseoverChord)
+        .on('mousemove', function (d) {
+          popoverChord = d;
+          let top = $('#chord').offset().top - 5;
+          $timeout(function () {
+            $scope.trustedpopoverContent = $sce.trustAsHtml(chordTitle(d, matrix));
+          });
+          d3.select('#popover-div')
+            .style('display', 'block')
+            .style('left', (d3.event.pageX+5)+'px')
+            .style('top', (d3.event.pageY-top)+'px');
+        })
+        .on('mouseout', function () {
+          popoverChord = null;
+          d3.select('#popover-div')
+            .style('display', 'none');
+        });
+
+      let exitingChords = chordPaths.exit()
+        .attr('class', 'exiting-chord');
+
+      if (!switchedByAddress) {
+        // shrink chords to their center point upon removal
+        exitingChords
+          .transition()
+          .duration(duration/2)
+          .attrTween('d', chordTweenExit)
+          .remove();
+      } else {
+        // just fade them out if we are switching between byAddress and aggregate
+        exitingChords
+          .transition()
+          .duration(duration/2)
+          .ease('linear')
+          .attr('opacity', 1e-6)
+          .remove();
+      }
+
+      // keep track of this layout so we can animate from this layout to the next layout
+      last_chord = rechord;
+      last_labels = last_chord.arcData;
+      switchedByAddress = false;
+
+      // update the UI for any $scope variables that changed
+      if(!$scope.$$phase) $scope.$apply();
+    }
+
+    // used to transition chords along a circular path instead of linear.
+    // qdrRibbon is a replacement for d3.svg.chord() that avoids the twists
+    let chordReference = qdrRibbon().radius(innerRadius);
+
+    // used to transition arcs along a curcular path instead of linear
+    let arcReference = d3.svg.arc()
+      .startAngle(function(d) { return d.startAngle; })
+      .endAngle(function(d) { return d.endAngle; })
+      .innerRadius(innerRadius)
+      .outerRadius(textRadius);
+
+    // animate the disappearance of an arc by shrinking it to its center point
+    function arcTweenExit(d) {
+      let angle = (d.startAngle+d.endAngle)/2;
+      let to = {startAngle: angle, endAngle: angle, value: 0};
+      let from = {startAngle: d.startAngle, endAngle: d.endAngle, value: d.value};
+      let tween = d3.interpolate(from, to);
+      return function (t) {
+        return arcReference( tween(t) );
+      };
+    }
+    // animate the exit of a chord by shrinking it to the center points of its arcs
+    function chordTweenExit(d) {
+      let angle = function (d) {
+        return (d.startAngle + d.endAngle) / 2;
+      };
+      let from = {source: {startAngle: d.source.startAngle, endAngle: d.source.endAngle}, 
+        target: {startAngle: d.target.startAngle, endAngle: d.target.endAngle}};
+      let to = {source: {startAngle: angle(d.source), endAngle: angle(d.source)},
+        target: {startAngle: angle(d.target), endAngle: angle(d.target)}};
+      let tween = d3.interpolate(from, to);
+
+      return function (t) {
+        return chordReference( tween(t) );
+      };
+    }
+
+    // Animate an arc from its old location to its new.
+    // If the arc is new, grow the arc from its startAngle to its full size
+    function arcTween(oldLayout) {
+      var oldGroups = {};
+      if (oldLayout) {
+        oldLayout.arcData.forEach( function(groupData) {
+          oldGroups[ groupData.index ] = groupData;
+        });
+      }
+      return function (d) {
+        var tween;
+        var old = oldGroups[d.index];
+        if (old) { //there's a matching old group
+          tween = d3.interpolate(old, d);
+        }
+        else {
+          //create a zero-width arc object
+          let mid = (d.startAngle + d.endAngle) / 2;
+          var emptyArc = {startAngle: mid, endAngle: mid};
+          tween = d3.interpolate(emptyArc, d);
+        }
+            
+        return function (t) {
+          return arcReference( tween(t) );
+        };
+      };
+    }
+
+    // animate all the chords to their new positions
+    function tweenChordEnds(chords, duration, last_layout) {
+      let oldChords = {};
+      if (last_layout) {
+        last_layout.chordData.forEach( function(d) {
+          oldChords[ d.key ] = d;
+        });
+      }
+      chords.each(function (d) {
+        let chord = d3.select(this);
+        // This version of d3 doesn't support multiple concurrent transitions on the same selection.
+        // Since we want to animate the chord's path as well as its color, we create a dummy selection
+        // and use that to directly transition each chord
+        d3.select({})
+          .transition()
+          .duration(duration)
+          .tween('attr:d', function () {
+            let old = oldChords[ d.key ], interpolate;
+            if (old) {
+              // avoid swapping the end of cords where the source/target have been flipped
+              // Note: the chord's colors will be swapped in a different tween
+              if (old.source.index === d.target.index &&
+                  old.source.subindex === d.target.subindex) {
+                let s = old.source;
+                old.source = old.target;
+                old.target = s;
+              }
+            } else {
+              // there was no old chord so make a fake one
+              let midStart = (d.source.startAngle + d.source.endAngle) / 2;
+              let midEnd = (d.target.startAngle + d.target.endAngle) / 2;
+              old = {
+                source: { startAngle: midStart,
+                  endAngle: midStart},
+                target: { startAngle: midEnd,
+                  endAngle: midEnd}
+              };
+            }
+            interpolate = d3.interpolate(old, d);
+            return function(t) {
+              chord.attr('d', chordReference(interpolate(t)));
+            };
+          });
+      });
+    }
+
+    // animate a chord to its new color
+    function tweenChordColor(chords, duration, last_layout, style) {
+      let oldChords = {};
+      if (last_layout) {
+        last_layout.chordData.forEach( function(d) {
+          oldChords[ d.key ] = d;
+        });
+      }
+      chords.each(function (d) {
+        let chord = d3.select(this);
+        d3.select({})
+          .transition()
+          .duration(duration)
+          .tween('style:'+style, function () {
+            let old = oldChords[ d.key ], interpolate;
+            let oldColor = '#CCCCCC', newColor = d.color;
+            if (old) {
+              oldColor = old.color;
+            }
+            if (style === 'stroke') {
+              oldColor = d3.rgb(oldColor).darker(1);
+              newColor = d3.rgb(newColor).darker(1);
+            }
+            interpolate = d3.interpolate(oldColor, newColor);
+            return function(t) {
+              chord.style(style, interpolate(t));
+            };
+          });
+      });
+    }
+
+    // animate the arc labels to their new locations
+    function tickTween(oldArcs) {
+      var oldTicks = {};
+      if (oldArcs) {
+        oldArcs.forEach( function(d) {
+          oldTicks[ d.key ] = d;
+        });
+      }
+      let angle = function (d) {
+        return (d.startAngle + d.endAngle) / 2;
+      };
+      return function (d) {
+        var tween;
+        var old = oldTicks[d.key];
+        let start = angle(d);
+        let startTranslate = textRadius - 40;
+        let orient = d.angle > Math.PI ? 'rotate(180)' : '';
+        if (old) { //there's a matching old group
+          start = angle(old);
+          startTranslate = textRadius;
+        }
+        tween = d3.interpolateNumber(start, angle(d));
+        let same = start === angle(d);
+        let tsame = startTranslate === textRadius;
+
+        let transTween = d3.interpolateNumber(startTranslate, textRadius + 10);
+
+        return function (t) {
+          let rot = same ? start : tween(t);
+          if (isNaN(rot))
+            rot = 0;
+          let tra = tsame ? (textRadius + 10) : transTween(t);
+          return 'rotate(' + (rot * 180 / Math.PI - 90) + ') '
+          + 'translate(' + tra + ',0)' + orient;
+        };
+      };
+    }
+
+    // fade all chords that don't belong to the given arc index
+    function mouseoverArc(d) {
+      d3.selectAll('path.chord').classed('fade', function(p) {
+        return d.index !== p.source.index && d.index !== p.target.index;
+      });
+    }
+
+    // fade all chords except the given one
+    function mouseoverChord(d) {
+      svg.selectAll('path.chord').classed('fade', function(p) {
+        return !(p.source.orgindex === d.source.orgindex && p.target.orgindex === d.target.orgindex);
+      });
+    }
+
+    function showAllChords() {
+      svg.selectAll('path.chord').classed('fade', false);
+    }
+
+    // when the page is exited
+    $scope.$on('$destroy', function() {
+      // stop updated the data
+      clearInterval(interval);
+      // clean up memory associated with the svg
+      d3.select('#chord').remove();
+      d3.select(window).on('resize.updatesvg', null);
+      window.removeEventListener('resize', windowResized);
+    });
+
+    // get the raw data and render the svg
+    chordData.getMatrix().then(function (matrix) {
+      // now that we have the routers and addresses, move the control switches and legend
+      $timeout(windowResized);
+      render(matrix);
+    }, function (e) {
+      console.log(ERROR_RENDERING + e);
+    });
+    // called periodically to refresh the data
+    function doUpdate() {
+      chordData.getMatrix().then(render, function (e) {
+        console.log(ERROR_RENDERING + e);
+      });
+    }
+    let interval = setInterval(doUpdate, transitionDuration);
+
+  }
+}
+ChordController.$inject = ['QDRService', '$scope', '$location', '$timeout', '$sce'];
diff --git a/console/react/src/chord/ribbon/README.md b/console/react/src/chord/ribbon/README.md
new file mode 100644
index 0000000..0be7451
--- /dev/null
+++ b/console/react/src/chord/ribbon/README.md
@@ -0,0 +1,40 @@
+#
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you 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.
+#
+
+  This is a replacement for the d3.svg.chord() ribbon generator.
+  The native d3 implementation is efficient, but its chords can become 'twisted'
+  in certain curcumatances.
+
+  A chord has up to 4 components:
+  1. A beginning arc along the edge of a circle
+  2. A quadratic bezier curve across the circle to the start of another arc
+  3. A 2nd arc along the edge of the circle
+  4. A quadratic bezier curve to the start of the 1st arc
+
+  Components 2 and 3 are dropped if the chord has only one endpoint.
+
+  The problem arises when the arcs are very close to each other and one arc is significantly
+  larger than the other. The inner bezier curve connecting the arcs extends towards the center
+  of the circle. The outer bezier curve connecting the outer ends of the arc crosses the inner
+  bezier curve causing the chords to look twisted.
+
+  The solution implemented here is to adjust the inner bezier curve to not extend into the circle very far.
+  That is done by changing its control point. Instead of the control point being at the center
+  of the circle, it is moved towards the edge of the circle in the direction of the midpoint of 
+  the bezier curve's end points.
diff --git a/console/react/src/chord/ribbon/ribbon.js b/console/react/src/chord/ribbon/ribbon.js
new file mode 100644
index 0000000..fb6996f
--- /dev/null
+++ b/console/react/src/chord/ribbon/ribbon.js
@@ -0,0 +1,165 @@
+/*
+Licensed to the Apache Software Foundation (ASF) under one
+or more contributor license agreements.  See the NOTICE file
+distributed with this work for additional information
+regarding copyright ownership.  The ASF licenses this file
+to you 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.
+*/
+/* global d3 */
+const halfPI = Math.PI / 2.0;
+const twoPI = Math.PI * 2.0;
+
+// These are scales to interpolate how the bezier control point should be adjusted.
+// These numbers were determined emperically by adjusting a chord and discovering
+// the relationship between the width of the inner bezier and the lengths of the arcs.
+// If we were just drawing the chord diagram once, we wouldn't need to use scales. 
+// But since we are animating chords, we need to smoothly chnage the control point from
+// [0, 0] to 1/2 way to the center of the bezier curve. 
+const dom = [.06, .98, Math.PI];
+const ys = d3.scale.linear().domain(dom).range([.18, 0, 0]);
+const x0s = d3.scale.linear().domain(dom).range([.03, .24, .24]);
+const x1s = d3.scale.linear().domain(dom).range([.24, .6, .6]);
+const x2s = d3.scale.linear().domain(dom).range([1.32, .8, .8]);
+const x3s = d3.scale.linear().domain(dom).range([3, 2, 2]);
+
+function qdrRibbon() { // eslint-disable-line no-unused-vars
+  var r = 200;  // random default. this will be set later
+
+  // This is the function that gets called to produce a path for a chord.
+  // The path should end up looking like 
+  // M[start point]A[arc options][arc end point]Q[control point][end points]A[arc options][arc end point]Q[control point][end points]Z
+  var ribbon = function (d) {
+    let sa0 = d.source.startAngle - halfPI,
+      sa1 = d.source.endAngle - halfPI,
+      ta0 = d.target.startAngle - halfPI,
+      ta1 = d.target.endAngle - halfPI;
+
+    // The control points for the bezier curves
+    let cp1 = [0, 0];
+    let cp2 = [0, 0];
+    // the span of the two arcs
+    let arc1 = Math.abs(sa0 - sa1);
+    let arc2 = Math.abs(ta0 - ta1);
+    let largeArc = Math.max(arc1, arc2);
+    let smallArc = Math.min(arc1, arc2);
+    // the gaps between the arcs
+    let gap1 = Math.abs(sa1 - ta0);
+    if (gap1 > Math.PI) gap1 = twoPI - gap1;
+    let gap2 = Math.abs(sa0 - ta1);
+    if (gap2 > Math.PI) gap2 = twoPI - gap2;
+    let sgap = Math.min(gap1, gap2);
+
+    // if the bezier curves intersect, ratiocp will be > 0
+    let ratiocp = cpRatio(sgap, largeArc, smallArc);
+
+    // x, y points for the start and end of the arcs
+    let s0x = r * Math.cos(sa0),
+      s0y = r * Math.sin(sa0),
+      t0x = r * Math.cos(ta0),
+      t0y = r * Math.sin(ta0);
+    
+    if (ratiocp > 0) {
+      // determine which control point to calculate
+      if ((Math.abs(gap1-gap2) < 1e-2) || (gap1 < gap2)) {
+        let s1x = r * Math.cos(sa1),
+          s1y = r * Math.sin(sa1);
+        cp1 = [ratiocp*(s1x + t0x)/2, ratiocp*(s1y + t0y)/2];
+      } else {
+        let t1x = r * Math.cos(ta1),
+          t1y = r * Math.sin(ta1);
+        cp2 = [ratiocp*(t1x + s0x)/2, ratiocp*(t1y + s0y)/2];
+      }
+    }
+
+    // construct the path using the control points
+    let path = d3.path();
+    path.moveTo(s0x, s0y);
+    path.arc(0, 0, r, sa0, sa1);
+    if (sa0 != ta0 || sa1 !== ta1) {
+      path.quadraticCurveTo(cp1[0], cp1[1], t0x, t0y);
+      path.arc(0, 0, r, ta0, ta1);
+    }
+    path.quadraticCurveTo(cp2[0], cp2[1], s0x, s0y);
+    path.closePath();
+    return path + '';
+
+  };
+  ribbon.radius = function (radius) {
+    if (!arguments.length) return r;
+    r = radius;
+    return ribbon;
+  };
+  return ribbon;
+}
+
+let sqr = function (n) { return n * n; };
+let dist = function (p1x, p1y, p2x, p2y) { return sqr(p1x - p2x) + sqr(p1y - p2y);};
+// distance from a point to a line segment
+let distToLine = function (vx, vy, wx, wy, px, py) {
+  let vlen = dist(vx, vy, wx, wy);
+  if (vlen === 0) return dist(px, py, vx, vy);
+  var t = ((px-vx)*(wx-vx) + (py-vy)*(wy-vy)) / vlen;
+  t = Math.max(0, Math.min(1, t)); // clamp t to between 0 and 1
+  return Math.sqrt(dist(px, py, vx + t*(wx-vx), vy + t*(wy-vy)));
+};
+
+// See if x, y is contained in trapezoid.
+// gap is the smallest gap in the chord
+// x is the size of the longest arc
+// y is the size of the smallest arc
+// the trapezoid is defined by [x0, 0] [x1, top] [x2, top] [x3, 0]
+// these points are determined by the gap
+let cpRatio = function (gap, x, y) {
+  let top = ys(gap);
+  if (y >= top)
+    return 0;
+
+  // get the xpoints of the trapezoid
+  let x0 = x0s(gap);
+  if (x <= x0)
+    return 0;
+  let x3 = x3s(gap);
+  if (x > x3)
+    return 0;
+
+  let x1 = x1s(gap);
+  let x2 = x2s(gap);
+  
+  // see if the point is to the right of (inside) the leftmost diagonal
+  // compute the outer product of the left diagonal and the point
+  let op = (x-x0)*top - y*(x1-x0);
+  if (op <= 0)
+    return 0;
+  // see if the point is to the left of the right diagonal
+  op = (x-x3)*top - y*(x2-x3);
+  if (op >= 0)
+    return 0;
+
+  // the point is in the trapezoid. see how far in
+  let dist = 0;
+  if (x < x1) {
+    // left side. get distance to left diagonal
+    dist = distToLine(x0, 0, x1, top, x, y);
+  } else if (x > x2) {
+    // right side. get distance to right diagonal
+    dist = distToLine(x3, 0, x2, top, x, y);
+  } else {
+    // middle. get distance to top
+    dist = top - y;
+  }
+  let distScale = d3.scale.linear().domain([0, top/8, top/2, top]).range([0, .3, .4, .5]);
+  return distScale(dist);
+};
+
+export { qdrRibbon };
\ No newline at end of file
diff --git a/console/react/src/config.json b/console/react/src/config.json
new file mode 100644
index 0000000..6f7d7a7
--- /dev/null
+++ b/console/react/src/config.json
@@ -0,0 +1,3 @@
+{
+  "title": "The Apache Qpid Dispach Console"
+}
diff --git a/console/react/src/confirm.js b/console/react/src/confirm.js
new file mode 100644
index 0000000..f865d33
--- /dev/null
+++ b/console/react/src/confirm.js
@@ -0,0 +1,74 @@
+import React from "react";
+import { Modal, Button } from "@patternfly/react-core";
+import PropTypes from "prop-types";
+
+class Confirm extends React.Component {
+  static propTypes = {
+    handleConfirm: PropTypes.func.isRequired,
+    buttonText: PropTypes.string.isRequired,
+    children: PropTypes.object.isRequired,
+    title: PropTypes.string.isRequired,
+    isDeleteDisabled: PropTypes.bool,
+    variant: PropTypes.string
+  };
+
+  constructor(props) {
+    super(props);
+    this.state = {
+      isModalOpen: false
+    };
+  }
+
+  handleModalToggle = () => {
+    this.setState({ isModalOpen: !this.state.isModalOpen });
+  };
+
+  handleModalConfirm = () => {
+    this.props.handleConfirm();
+    this.handleModalToggle();
+  };
+  handleModalCancel = () => {
+    this.handleModalToggle();
+  };
+  render() {
+    const { isModalOpen } = this.state;
+    const variant = this.props.variant || "primary";
+    return (
+      <React.Fragment>
+        <Button
+          isDisabled={this.props.isDeleteDisabled}
+          variant={variant}
+          onClick={this.handleModalToggle}
+        >
+          {this.props.buttonText}
+        </Button>
+        <Modal
+          isSmall
+          title={this.props.title}
+          isOpen={isModalOpen}
+          onClose={this.handleModalCancel}
+          actions={[
+            <Button
+              key="cancel"
+              variant="secondary"
+              onClick={this.handleModalCancel}
+            >
+              Cancel
+            </Button>,
+            <Button
+              key="confirm"
+              variant="primary"
+              onClick={this.handleModalConfirm}
+            >
+              Confirm
+            </Button>
+          ]}
+        >
+          {this.props.children}
+        </Modal>
+      </React.Fragment>
+    );
+  }
+}
+
+export default Confirm;
diff --git a/console/react/src/connect-form.js b/console/react/src/connect-form.js
new file mode 100644
index 0000000..4735b4c
--- /dev/null
+++ b/console/react/src/connect-form.js
@@ -0,0 +1,164 @@
+/*
+ * Copyright 2019 Red Hat 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.
+ */
+import React from "react";
+import {
+  Form,
+  FormGroup,
+  TextInput,
+  ActionGroup,
+  Button,
+  ButtonVariant,
+  TextContent,
+  Text,
+  TextVariants
+} from "@patternfly/react-core";
+
+import { PowerOffIcon } from "@patternfly/react-icons";
+
+class ConnectForm extends React.Component {
+  constructor(props) {
+    super(props);
+
+    this.state = {
+      value: "please choose",
+      value1: "",
+      value2: "",
+      value3: "",
+      value4: "",
+      formVisible: !this.props.buttonHidden,
+      buttonVisible: this.props.buttonHidden ? false : true
+    };
+    this.handleTextInputChange1 = value1 => {
+      this.setState({ value1 });
+    };
+    this.handleTextInputChange2 = value2 => {
+      this.setState({ value2 });
+    };
+    this.handleTextInputChange3 = value3 => {
+      this.setState({ value3 });
+    };
+    this.handleTextInputChange4 = value4 => {
+      this.setState({ value4 });
+    };
+  }
+
+  handleConnect = () => {
+    this.toggleDrawerHide();
+    this.props.handleConnect();
+  };
+
+  toggleDrawerHide = () => {
+    this.setState({ formVisible: !this.state.formVisible });
+  };
+
+  render() {
+    const { value1, value2, value3, value4 } = this.state;
+
+    return (
+      <div>
+        <Button
+          id="notificationButton"
+          onClick={this.toggleDrawerHide}
+          aria-label="Notifications actions"
+          variant={ButtonVariant.plain}
+          className={this.state.buttonVisible ? "" : "hidden"}
+        >
+          <PowerOffIcon />
+        </Button>
+        <div
+          className={
+            this.state.formVisible ? "connect-modal" : "connect-modal hidden"
+          }
+        >
+          <div className="">
+            <Form isHorizontal>
+              <TextContent className="connect-title">
+                <Text component={TextVariants.h1}>Connect</Text>
+                <Text component={TextVariants.p}>
+                  Enter the address and an HTTP-enabled port of a qpid dispatch
+                  router.
+                </Text>
+              </TextContent>
+              <FormGroup
+                label="Address"
+                isRequired
+                fieldId={`form-address-${this.props.prefix}`}
+              >
+                <TextInput
+                  value={value1}
+                  isRequired
+                  type="text"
+                  id={`form-address-${this.props.prefix}`}
+                  aria-describedby="horizontal-form-address-helper"
+                  name="form-address"
+                  onChange={this.handleTextInputChange1}
+                />
+              </FormGroup>
+              <FormGroup
+                label="Port"
+                isRequired
+                fieldId={`form-port-${this.props.prefix}`}
+              >
+                <TextInput
+                  value={value2}
+                  onChange={this.handleTextInputChange2}
+                  isRequired
+                  type="number"
+                  id={`form-port-${this.props.prefix}`}
+                  name="form-port"
+                />
+              </FormGroup>
+              <FormGroup
+                label="User name"
+                fieldId={`form-user-${this.props.prefix}`}
+              >
+                <TextInput
+                  value={value3}
+                  onChange={this.handleTextInputChange3}
+                  isRequired
+                  id={`form-user-${this.props.prefix}`}
+                  name="form-user"
+                />
+              </FormGroup>
+              <FormGroup
+                label="Password"
+                fieldId={`form-password-${this.props.prefix}`}
+              >
+                <TextInput
+                  value={value4}
+                  onChange={this.handleTextInputChange4}
+                  type="password"
+                  id={`form-password-${this.props.prefix}`}
+                  name="form-password"
+                />
+              </FormGroup>
+              <ActionGroup>
+                <Button variant="primary" onClick={this.handleConnect}>
+                  Connect
+                </Button>
+                <Button variant="secondary" onClick={this.toggleDrawerHide}>
+                  Cancel
+                </Button>
+              </ActionGroup>
+            </Form>
+          </div>
+        </div>
+      </div>
+    );
+  }
+}
+
+export default ConnectForm;
diff --git a/console/react/src/connectPage.js b/console/react/src/connectPage.js
new file mode 100644
index 0000000..50b0e9b
--- /dev/null
+++ b/console/react/src/connectPage.js
@@ -0,0 +1,51 @@
+import React from "react";
+import {
+  PageSection,
+  PageSectionVariants,
+  TextContent,
+  Text
+} from "@patternfly/react-core";
+import ConnectForm from "./connect-form";
+
+class ConnectPage extends React.Component {
+  constructor(props) {
+    super(props);
+    this.state = {};
+  }
+
+  render() {
+    return (
+      <React.Fragment>
+        <PageSection
+          variant={PageSectionVariants.light}
+          className="connect-page"
+        >
+          <div className="left-content">
+            <TextContent>
+              <Text component="h1" className="console-banner">
+                Apache Qpid Dispatch Console
+              </Text>
+            </TextContent>
+            <TextContent>
+              <Text component="p">
+                The console provides limited information about the clients that
+                are attached to the router network and is therefore more
+                appropriate for administrators needing to know the layout and
+                health of the router network.
+              </Text>
+            </TextContent>
+          </div>
+        </PageSection>
+        <PageSection>
+          <ConnectForm
+            prefix="form"
+            handleConnect={this.props.handleConnect}
+            buttonHidden={true}
+          />
+        </PageSection>
+      </React.Fragment>
+    );
+  }
+}
+
+export default ConnectPage;
diff --git a/console/react/src/edge-table-pagination.js b/console/react/src/edge-table-pagination.js
new file mode 100644
index 0000000..9ddac74
--- /dev/null
+++ b/console/react/src/edge-table-pagination.js
@@ -0,0 +1,24 @@
+import React from "react";
+import { Pagination } from "@patternfly/react-core";
+
+class EdgeTablePagination extends React.Component {
+  constructor(props) {
+    super(props);
+    this.state = {};
+  }
+
+  render() {
+    return (
+      <Pagination
+        itemCount={this.props.rows}
+        perPage={this.props.perPage}
+        page={this.props.page}
+        onSetPage={this.props.onSetPage}
+        widgetId="pagination-options-menu-top"
+        onPerPageSelect={this.props.onPerPageSelect}
+      />
+    );
+  }
+}
+
+export default EdgeTablePagination;
diff --git a/console/react/src/edge-table-toolbar.js b/console/react/src/edge-table-toolbar.js
new file mode 100644
index 0000000..2afec75
--- /dev/null
+++ b/console/react/src/edge-table-toolbar.js
@@ -0,0 +1,133 @@
+import React from "react";
+import {
+  Button,
+  ButtonVariant,
+  InputGroup,
+  TextInput,
+  Toolbar,
+  ToolbarGroup,
+  ToolbarItem
+} from "@patternfly/react-core";
+import {
+  SearchIcon,
+  SortAlphaDownIcon,
+  SortAlphaUpIcon
+} from "@patternfly/react-icons";
+import Confirm from "./confirm";
+
+class EdgeTableToolbar extends React.Component {
+  constructor(props) {
+    super(props);
+    this.state = {
+      isDropDownOpen: false,
+      isKebabOpen: false,
+      searchValue: ""
+    };
+    this.handleTextInputChange = searchValue => {
+      this.setState({ searchValue });
+    };
+
+    this.onDropDownToggle = isOpen => {
+      this.setState({
+        isDropDownOpen: isOpen
+      });
+    };
+
+    this.onDropDownSelect = event => {
+      this.setState({
+        isDropDownOpen: !this.state.isDropDownOpen
+      });
+    };
+
+    this.onKebabToggle = isOpen => {
+      this.setState({
+        isKebabOpen: isOpen
+      });
+    };
+
+    this.onKebabSelect = event => {
+      this.setState({
+        isKebabOpen: !this.state.isKebabOpen
+      });
+    };
+
+    this.buildSearchBox = () => {
+      return (
+        <InputGroup>
+          <TextInput
+            value={this.props.filterText}
+            type="search"
+            onChange={this.props.handleChangeFilter}
+            aria-label="search text input"
+            placeholder="search for edge namespaces"
+          />
+          <Button
+            variant={ButtonVariant.tertiary}
+            aria-label="search button for search input"
+          >
+            <SearchIcon />
+          </Button>
+        </InputGroup>
+      );
+    };
+  }
+
+  isDeleteDisabled = () => this.props.rows.every(r => !r.selected);
+  deleteText = () => {
+    return (
+      <React.Fragment>
+        <h2>Are you sure you want to delete:</h2>
+        <ul>
+          {this.props.rows
+            .filter(r => r.selected)
+            .map((r, i) => {
+              return <li key={`key-${i}`}>{r.name}</li>;
+            })}
+        </ul>
+      </React.Fragment>
+    );
+  };
+  render() {
+    return (
+      <Toolbar className="pf-l-toolbar pf-u-justify-content-space-between pf-u-mx-xl pf-u-my-md">
+        <ToolbarGroup>
+          <ToolbarItem className="pf-u-mr-md">
+            {this.buildSearchBox()}
+          </ToolbarItem>
+          <ToolbarItem>
+            <Button
+              variant="plain"
+              onClick={this.props.toggleAlphaSort}
+              aria-label="Sort A-Z"
+            >
+              {this.props.sortDown ? (
+                <SortAlphaDownIcon />
+              ) : (
+                <SortAlphaUpIcon />
+              )}
+            </Button>
+          </ToolbarItem>
+        </ToolbarGroup>
+        <ToolbarGroup className="edge-table-actions">
+          <ToolbarItem className="pf-u-mx-sm">
+            <Confirm
+              handleConfirm={this.props.handleDeleteEdge}
+              buttonText="Delete"
+              title="Confirm delete"
+              isDeleteDisabled={this.isDeleteDisabled()}
+            >
+              {this.deleteText()}
+            </Confirm>
+          </ToolbarItem>
+          <ToolbarItem className="pf-u-mx-sm">
+            <Button aria-label="Add" onClick={this.props.handleAddEdge}>
+              Add
+            </Button>
+          </ToolbarItem>
+        </ToolbarGroup>
+      </Toolbar>
+    );
+  }
+}
+
+export default EdgeTableToolbar;
diff --git a/console/react/src/edge-table.js b/console/react/src/edge-table.js
new file mode 100644
index 0000000..ef9d7ed
--- /dev/null
+++ b/console/react/src/edge-table.js
@@ -0,0 +1,289 @@
+import React from "react";
+import { Button, ClipboardCopy, TextInput } from "@patternfly/react-core";
+import {
+  cellWidth,
+  Table,
+  TableHeader,
+  TableBody,
+  TableVariant
+} from "@patternfly/react-table";
+import EdgeTableToolbar from "./edge-table-toolbar";
+import EdgeTablePagination from "./edge-table-pagination";
+import EmptyEdgeClassTable from "./empty-edge-class-table";
+import Graph from "./graph";
+import { RouterStates } from "./nodes";
+
+const YAML = 0;
+const STATE_ICON = 1;
+const STATE_TEXT = 2;
+const NAME = 3;
+
+class EdgeTable extends React.Component {
+  constructor(props) {
+    super(props);
+    this.state = {
+      columns: [
+        {
+          title: "yaml",
+          cellFormatters: [this.formatYaml],
+          transforms: [cellWidth(5)]
+        },
+        {
+          title: "State",
+          cellFormatters: [this.formatState],
+          transforms: [cellWidth(5)]
+        },
+        { title: "", cellFormatters: [this.formatStateDescription] },
+        { title: "Name", cellFormatters: [this.formatName] }
+      ],
+      filterText: "",
+      sortDown: true,
+      editingEdgeRow: -1,
+      page: 1,
+      perPage: 5
+    };
+
+    this.rows = [];
+  }
+
+  onSelect = (event, isSelected, rowId) => {
+    // the internal rows array may be different from the props.rows array
+    const realRowIndex =
+      rowId >= 0
+        ? this.props.rows.findIndex(r => r.key === this.rows[rowId].key)
+        : rowId;
+    this.props.handleSelectEdgeRow(realRowIndex, isSelected);
+  };
+
+  handleEdgeNameBlur = () => {
+    this.onSelect("", false, -1);
+    this.setState({ editingEdgeRow: -1 });
+  };
+
+  handleEdgeNameClick = rowIndex => {
+    this.onSelect("", true, rowIndex);
+    this.setState({ editingEdgeRow: rowIndex });
+  };
+
+  handleEdgeKeyPress = event => {
+    if (event.key === "Enter") {
+      this.handleEdgeNameBlur();
+    }
+  };
+
+  formatYaml = (value, _xtraInfo) => {
+    const cells = _xtraInfo.rowData.cells;
+    let yaml = <div className="state-placeholder"></div>;
+    if (cells[0] && cells[1] === 1) {
+      yaml = (
+        <ClipboardCopy
+          className="state-copy"
+          onClick={(event, text) => {
+            const clipboard = event.currentTarget.parentElement;
+            const el = document.createElement("input");
+            el.value = JSON.stringify(cells[0]);
+            clipboard.appendChild(el);
+            el.select();
+            document.execCommand("copy");
+            clipboard.removeChild(el);
+          }}
+        />
+      );
+    }
+    return yaml;
+  };
+
+  formatStateDescription = (value, _xtraInfo) => {
+    return <div className="state-text">{RouterStates[value]}</div>;
+  };
+
+  formatState = (value, _xtraInfo) => {
+    return (
+      <Graph
+        id={`State-${_xtraInfo.rowIndex}`}
+        thumbNail={true}
+        legend={true}
+        dimensions={{ width: 30, height: 30 }}
+        nodes={[
+          {
+            key: `edge-key-${_xtraInfo.rowIndex}-${value}`,
+            r: 10,
+            type: "interior",
+            state: value,
+            x: 0,
+            y: 0
+          }
+        ]}
+        links={[]}
+        notifyCurrentRouter={() => {}}
+      />
+    );
+  };
+  formatName = (value, _xtraInfo) => {
+    const realRowIndex = this.props.rows.findIndex(
+      r => r.key === _xtraInfo.rowData.key
+    );
+    if (this.state.editingEdgeRow === _xtraInfo.rowIndex) {
+      // the internal rows array may be different from the props.rows array
+      return (
+        <TextInput
+          value={this.props.rows[realRowIndex].name}
+          type="text"
+          autoFocus
+          onChange={val => this.props.handleEdgeNameChange(val, realRowIndex)}
+          onBlur={this.handleEdgeNameBlur}
+          onKeyPress={this.handleEdgeKeyPress}
+          aria-label="text input example"
+        />
+      );
+    }
+    return (
+      <Button
+        variant="link"
+        isInline
+        onClick={() => this.handleEdgeNameClick(_xtraInfo.rowIndex)}
+      >
+        {this.rows[_xtraInfo.rowIndex].cells[NAME]}
+      </Button>
+    );
+  };
+
+  onSelect = (event, isSelected, rowId) => {
+    // the internal rows array may be different from the props.rows array
+    const realRowIndex =
+      rowId >= 0
+        ? this.props.rows.findIndex(r => r.key === this.rows[rowId].key)
+        : rowId;
+    this.props.handleSelectEdgeRow(realRowIndex, isSelected);
+  };
+
+  toggleAlphaSort = () => {
+    this.setState({ sortDown: !this.state.sortDown });
+  };
+
+  genTable = () => {
+    const { columns, filterText } = this.state;
+    if (this.props.rows.length > 0) {
+      if (this.state.editingEdgeRow === -1 || this.rows.length === 0) {
+        this.rows = this.props.rows.map(r => ({
+          cells: [r.yaml, r.state, r.state, r.name],
+          selected: r.selected,
+          key: r.key
+        }));
+        // sort the rows
+        this.rows = this.rows.sort((a, b) =>
+          a.cells[NAME] < b.cells[NAME]
+            ? -1
+            : a.cells[NAME] > b.cells[NAME]
+            ? 1
+            : 0
+        );
+        if (!this.state.sortDown) {
+          this.rows = this.rows.reverse();
+        }
+        // filter the rows
+        if (filterText !== "") {
+          this.rows = this.rows.filter(
+            r => r.cells[NAME].indexOf(filterText) >= 0
+          );
+        }
+        // only show rows on current page
+        const start = (this.state.page - 1) * this.state.perPage;
+        const end = Math.min(this.rows.length, start + this.state.perPage);
+        this.rows = this.rows.slice(start, end);
+      } else {
+        // pickup any changed info
+        this.rows.forEach(r => {
+          const rrow = this.props.rows.find(rr => rr.key === r.key);
+          if (rrow) {
+            r.selected = rrow.selected;
+            r.cells[YAML] = rrow.yaml;
+            r.cells[STATE_ICON] = rrow.state;
+            r.cells[STATE_TEXT] = rrow.state;
+            r.cells[NAME] = rrow.name;
+          }
+        });
+      }
+
+      return (
+        <React.Fragment>
+          <Table
+            className="edge-table"
+            variant={TableVariant.compact}
+            onSelect={this.onSelect}
+            cells={columns}
+            rows={this.rows}
+          >
+            <TableHeader />
+            <TableBody />
+          </Table>
+        </React.Fragment>
+      );
+    }
+    return <EmptyEdgeClassTable handleAddEdge={this.props.handleAddEdge} />;
+  };
+
+  handleChangeFilter = filterText => {
+    let { page } = this.state;
+    if (filterText !== "") {
+      page = 1;
+    }
+    this.setState({ filterText, page });
+  };
+
+  genToolbar = () => {
+    if (this.props.rows.length > 0) {
+      return (
+        <React.Fragment>
+          <label>Edge namespaces</label>
+          <EdgeTableToolbar
+            handleAddEdge={this.props.handleAddEdge}
+            handleDeleteEdge={this.props.handleDeleteEdge}
+            handleChangeFilter={this.handleChangeFilter}
+            toggleAlphaSort={this.toggleAlphaSort}
+            filterText={this.state.filterText}
+            sortDown={this.state.sortDown}
+            rows={this.props.rows}
+          />
+        </React.Fragment>
+      );
+    }
+  };
+
+  onSetPage = (_event, pageNumber) => {
+    this.setState({
+      page: pageNumber
+    });
+  };
+
+  onPerPageSelect = (_event, perPage) => {
+    this.setState({
+      perPage
+    });
+  };
+
+  genPagination = () => {
+    if (this.props.rows.length > 0) {
+      return (
+        <EdgeTablePagination
+          rows={this.props.rows.length}
+          perPage={this.state.perPage}
+          page={this.state.page}
+          onPerPageSelect={this.onPerPageSelect}
+          onSetPage={this.onSetPage}
+        />
+      );
+    }
+  };
+  render() {
+    return (
+      <React.Fragment>
+        {this.genToolbar()}
+        {this.genTable()}
+        {this.genPagination()}
+      </React.Fragment>
+    );
+  }
+}
+
+export default EdgeTable;
diff --git a/console/react/src/empty-edge-class-table.js b/console/react/src/empty-edge-class-table.js
new file mode 100644
index 0000000..a4defc1
--- /dev/null
+++ b/console/react/src/empty-edge-class-table.js
@@ -0,0 +1,43 @@
+import React from "react";
+import {
+  Title,
+  Button,
+  EmptyState,
+  EmptyStateVariant,
+  EmptyStateIcon,
+  EmptyStateBody,
+  EmptyStateSecondaryActions
+} from "@patternfly/react-core";
+import { CubesIcon } from "@patternfly/react-icons";
+
+class EmptyEdgeClassTable extends React.Component {
+  constructor(props) {
+    super(props);
+    this.state = {};
+  }
+
+  render() {
+    return (
+      <EmptyState variant={EmptyStateVariant.small}>
+        <EmptyStateIcon icon={CubesIcon} />
+        <Title headingLevel="h5" size="lg">
+          No edge namespaces
+        </Title>
+        <EmptyStateBody>
+          This edge class does not contain any edge namespaces yet.
+        </EmptyStateBody>
+        <EmptyStateSecondaryActions>
+          <Button
+            variant="primary"
+            aria-label="Add"
+            onClick={this.props.handleAddEdge}
+          >
+            Add an edge namespace
+          </Button>
+        </EmptyStateSecondaryActions>
+      </EmptyState>
+    );
+  }
+}
+
+export default EmptyEdgeClassTable;
diff --git a/console/react/src/empty-selection.js b/console/react/src/empty-selection.js
new file mode 100644
index 0000000..73a6eef
--- /dev/null
+++ b/console/react/src/empty-selection.js
@@ -0,0 +1,33 @@
+import React from "react";
+import {
+  Title,
+  EmptyState,
+  EmptyStateVariant,
+  EmptyStateIcon,
+  EmptyStateBody
+} from "@patternfly/react-core";
+import { CubesIcon } from "@patternfly/react-icons";
+
+class EmptySelection extends React.Component {
+  constructor(props) {
+    super(props);
+    this.state = {};
+  }
+
+  render() {
+    return (
+      <EmptyState variant={EmptyStateVariant.small} className="empty-selection">
+        <EmptyStateIcon icon={CubesIcon} />
+        <Title headingLevel="h5" size="lg">
+          Nothing selected
+        </Title>
+        <EmptyStateBody>
+          Select a cluster, edge class, or line between them to view/edit their
+          information.
+        </EmptyStateBody>
+      </EmptyState>
+    );
+  }
+}
+
+export default EmptySelection;
diff --git a/console/react/src/field-details.js b/console/react/src/field-details.js
new file mode 100644
index 0000000..f2318eb
--- /dev/null
+++ b/console/react/src/field-details.js
@@ -0,0 +1,225 @@
+import React from "react";
+import {
+  ActionGroup,
+  Button,
+  ClipboardCopy,
+  Form,
+  FormGroup,
+  TextInput,
+  Radio
+} from "@patternfly/react-core";
+import EdgeTable from "./edge-table";
+import Graph from "./graph";
+import Confirm from "./confirm";
+
+class FieldDetails extends React.Component {
+  constructor(props) {
+    super(props);
+    this.state = {};
+  }
+
+  currentNode = () => {
+    let current = this.props.networkInfo.nodes.find(
+      n => n.key === this.props.selectedKey
+    );
+    return current;
+  };
+
+  formElement = (field, currentNode) => {
+    if (field.type === "text") {
+      let isRequired = field.isRequired;
+      if (typeof field.isRequired === "function")
+        isRequired = field.isRequired(
+          field.title,
+          this.props.networkInfo,
+          this.props.selectedKey
+        );
+      return (
+        <TextInput
+          isRequired={isRequired}
+          type="text"
+          id={field.title}
+          name={field.title}
+          aria-describedby="simple-form-name-helper"
+          value={currentNode[field.title]}
+          onChange={newVal =>
+            this.props.handleEditField(newVal, field.title, currentNode.key)
+          }
+        />
+      );
+    }
+    if (field.type === "radio") {
+      return field.options.map((o, i) => {
+        return (
+          <Radio
+            key={`key-radio-${o}-${i}`}
+            id={o}
+            value={o}
+            name={field.title}
+            label={o}
+            aria-label={o}
+            onChange={() => this.props.handleRadioChange(o, field.title)}
+            isChecked={currentNode[field.title] === o}
+          />
+        );
+      });
+    } else if (field.type === "states") {
+      return field.options.map((o, i) => {
+        let yaml = <div className="state-placeholder"></div>;
+        if (currentNode.yaml && i === 1) {
+          yaml = (
+            <ClipboardCopy
+              className="state-copy"
+              onClick={(event, text) => {
+                const clipboard = event.currentTarget.parentElement;
+                const el = document.createElement("input");
+                el.value = JSON.stringify(currentNode.yaml);
+                clipboard.appendChild(el);
+                el.select();
+                document.execCommand("copy");
+                clipboard.removeChild(el);
+              }}
+            />
+          );
+        }
+        return (
+          <div
+            className="state-container"
+            key={`key-checkbox-${o}-${i}`}
+            id={`${field.title}-${i}`}
+          >
+            {yaml}
+            <Graph
+              id={`State-${i}`}
+              thumbNail={true}
+              legend={true}
+              dimensions={{ width: 30, height: 30 }}
+              nodes={[
+                {
+                  key: `legend-key-${i}`,
+                  r: 10,
+                  type: "interior",
+                  state: i,
+                  x: 0,
+                  y: 0
+                }
+              ]}
+              links={[]}
+              notifyCurrentRouter={() => {}}
+            />
+            <div className="state-text">{o}</div>
+          </div>
+        );
+      });
+    } else if (field.type === "label") {
+      const currentLink = this.props.networkInfo.links.find(
+        n => n.key === this.props.selectedKey
+      );
+      return (
+        <span className="link-label">
+          {typeof currentLink[field.title] === "function"
+            ? currentLink[field.title]()
+            : currentLink[field.title]}
+        </span>
+      );
+    }
+  };
+
+  extra = currentNode => {
+    if (this.props.details.extra) {
+      return (
+        <EdgeTable
+          rows={currentNode.rows}
+          networkInfo={this.props.networkInfo}
+          handleAddEdge={this.props.handleAddEdge}
+          handleDeleteEdge={this.props.handleDeleteEdge}
+          handleEdgeNameChange={this.props.handleEdgeNameChange}
+          handleSelectEdgeRow={this.props.handleSelectEdgeRow}
+        />
+      );
+    }
+  };
+
+  render() {
+    const currentNode = this.currentNode();
+    return (
+      <Form
+        onSubmit={e => {
+          e.preventDefault();
+          return false;
+        }}
+      >
+        <h1>{this.props.details.title}</h1>
+        <ActionGroup>
+          {this.props.details.actions.map(action => {
+            if (action.confirm) {
+              return (
+                <Confirm
+                  key={action.title}
+                  handleConfirm={action.onClick}
+                  variant="secondary"
+                  isDeleteDisabled={
+                    action.isDisabled
+                      ? action.isDisabled(
+                          action.title,
+                          this.props.networkInfo,
+                          this.props.selectedKey
+                        )
+                      : false
+                  }
+                  buttonText={action.title}
+                  title={`Confirm ${action.title}`}
+                >
+                  <h2>Are you sure?</h2>
+                </Confirm>
+              );
+            } else
+              return (
+                <Button
+                  key={action.title}
+                  variant="secondary"
+                  onClick={action.onClick}
+                  isDisabled={
+                    action.isDisabled
+                      ? action.isDisabled(
+                          action.title,
+                          this.props.networkInfo,
+                          this.props.selectedKey
+                        )
+                      : false
+                  }
+                >
+                  {action.title}
+                </Button>
+              );
+          })}
+        </ActionGroup>
+        {this.props.details.fields.map(field => {
+          return (
+            <FormGroup
+              key={field.title}
+              label={field.title}
+              isRequired={
+                typeof field.isRequired === "function"
+                  ? field.isRequired(
+                      field.title,
+                      this.props.networkInfo,
+                      this.props.selectedKey
+                    )
+                  : field.isRequired
+              }
+              fieldId={field.title}
+              helperText={field.help}
+              isInline={field.type === "radio"}
+            >
+              {this.formElement(field, currentNode)}
+            </FormGroup>
+          );
+        })}
+        <FormGroup fieldId="extra">{this.extra(currentNode)}</FormGroup>
+      </Form>
+    );
+  }
+}
+
+export default FieldDetails;
diff --git a/console/react/src/graph.js b/console/react/src/graph.js
new file mode 100644
index 0000000..d629d1d
--- /dev/null
+++ b/console/react/src/graph.js
@@ -0,0 +1,358 @@
+import React from "react";
+
+import * as d3 from "d3";
+import { addDefs } from "./nodes";
+
+class Graph extends React.Component {
+  constructor(props) {
+    super(props);
+
+    this.state = {};
+    this.force = d3.layout
+      .force()
+      .size([this.props.dimensions.width, this.props.dimensions.height])
+      .linkDistance(l => {
+        if (this.props.thumbNail) return 40;
+        else if (l.type === "router") return 150;
+        else if (l.type === "edge") return 20;
+        return 50;
+      })
+      .charge(-800)
+      .friction(0.1)
+      .gravity(0.001);
+
+    this.mouse_down_position = null;
+  }
+
+  // called only once when the component is initialized
+  componentDidMount() {
+    const svg = d3.select(this.svg);
+    if (!this.props.thumbNail && !this.props.legend) {
+      addDefs(svg);
+    }
+
+    this.force.on("tick", () => {
+      // after force calculation starts, call updateGraph
+      // which uses d3 to manipulate the attributes,
+      // and React doesn't have to go through lifecycle on each tick
+      d3.select(this.svgg).call(this.updateGraph);
+    });
+    // call this manually to create svg circles and lines
+    this.shouldComponentUpdate(this.props);
+  }
+
+  // called each time one of the properties changes
+  shouldComponentUpdate(nextProps) {
+    this.d3Graph = d3.select(this.svgg);
+    this.appendNodes(this.d3Graph, nextProps, nextProps.nodes);
+    this.appendLinks(this.d3Graph, nextProps.links, "connector", ".node");
+    this.d3Graph.call(this.updateGraph);
+
+    // warning: d3's force function modifies the nodes and links arrays
+    this.force.nodes(nextProps.nodes).links(nextProps.links);
+    this.force.start();
+    if (nextProps) this.refresh(nextProps);
+    return false;
+  }
+
+  appendNodes = (selection, nextProps, nodes) => {
+    const subNodes = selection.selectAll(".node").data(nodes, node => node.key);
+    subNodes
+      .enter()
+      .append("g")
+      .attr("class", "node")
+      .attr("id", n => n.key)
+      .call(selection => this.enterNode(selection, nextProps));
+    subNodes.exit().remove();
+    subNodes.call(this.updateNode);
+    subNodes.call(this.force.drag);
+  };
+
+  appendLinks = (selection, clinks, linkClass, before) => {
+    const subLinks = selection
+      .selectAll(`.${linkClass}`)
+      .data(clinks, link => link.key);
+    subLinks
+      .enter()
+      .insert("g", before)
+      .attr("class", linkClass)
+      .call(this.enterLink);
+    subLinks.exit().remove();
+    subLinks.call(this.updateLink);
+  };
+
+  // new node/nodes are present
+  // append all the stuff and set the attributes that don't change
+  enterNode = (selection, props) => {
+    const graph = this;
+    const routers = selection.filter(d => d.type === "interior");
+    const edges = selection.filter(d => d.type === "edgeClass");
+
+    selection.append("circle").attr("r", d => d.r);
+
+    routers.append("path").attr("d", d =>
+      d3.svg
+        .arc()
+        .innerRadius(0)
+        .outerRadius(d.r)({
+        startAngle: 0,
+        endAngle: (d.state * 2.0 * Math.PI) / 3.0
+      })
+    );
+
+    selection
+      .classed("edgeClass", d => d.type === "edgeClass")
+      .classed("interior", d => d.type === "interior");
+
+    if (!props.thumbNail || props.legend) {
+      selection.classed("network", true);
+    }
+    if (!props.thumbNail) {
+      selection
+        .append("text")
+        .attr("x", d => d.r + 5)
+        .attr("dy", ".35em")
+        .text(d => d.Name);
+      edges
+        .append("text")
+        .classed("edge-count", true)
+        .attr("x", -24)
+        .attr("dy", "0.35em")
+        .text(""); // updated in refresh
+    }
+    /* // this creates an octagon
+    const sqr2o2 = Math.sqrt(2.0) / 2.0;
+    const points = `1 0 ${sqr2o2} ${sqr2o2} 0 1 -${sqr2o2} ${sqr2o2} -1 0 -${sqr2o2} -${sqr2o2} 0 -1 ${sqr2o2} -${sqr2o2}`;
+    selection
+      .filter(d => d.type === "edgeClass")
+      .append("polygon")
+      .attr("points", points)
+      .attr("transform", `scale(60) rotate(22.5)`);
+*/
+    selection
+      .on("mouseover", function(n) {
+        if (graph.props.thumbNail) return;
+        n.over = true;
+        graph.updateNode(d3.select(this));
+      })
+      .on("mouseout", function(n) {
+        if (graph.props.thumbNail) return;
+        n.over = false;
+        graph.updateNode(d3.select(this));
+      })
+      .on("click", function(n) {
+        if (graph.props.thumbNail) return;
+        // if there was a selected node and it was not the one we just clicked on:
+        // create a link between the selected node and the clicked on node
+        if (graph.props.selectedKey && graph.props.selectedKey !== n.key) {
+          graph.props.notifyCreateLink(n.key, graph.props.selectedKey);
+        }
+
+        // see if the node was dragged (same === false)
+        const same = graph.samePos(
+          d3.mouse(this.parentNode),
+          graph.mouse_down_position,
+          "node"
+        );
+        if (same) {
+          if (graph.props.selectedKey === n.key) {
+            graph.props.notifyCurrentRouter(null);
+          } else {
+            graph.props.notifyCurrentRouter(n.key);
+          }
+        } else {
+          graph.props.notifyCurrentRouter(n.key);
+        }
+        graph.refresh(graph.props);
+      })
+      .on("mousedown", function(n) {
+        graph.mouse_down_position = d3.mouse(this.parentNode);
+        graph.draggingNode = true;
+      })
+      .on("mouseup", n => {
+        if (graph.props.thumbNail) return;
+        if (n.type !== "edge") n.fixed = true;
+      });
+  };
+
+  samePos = (pos1, pos2, where) => {
+    if (pos1 && pos2) {
+      if (pos1[0] === pos2[0] && pos1[1] === pos2[1]) return true;
+    }
+    return false;
+  };
+
+  arcTween = (oldData, newData, arc) => {
+    const copy = { ...oldData };
+    return function() {
+      const interpolateStartAngle = d3.interpolate(
+          oldData.startAngle,
+          newData.startAngle
+        ),
+        interpolateEndAngle = d3.interpolate(
+          oldData.endAngle,
+          newData.endAngle
+        );
+
+      return function(t) {
+        copy.startAngle = interpolateStartAngle(t);
+        copy.endAngle = interpolateEndAngle(t);
+        return arc(copy);
+      };
+    };
+  };
+  // called each time a property changes
+  // update the classes/text based on the new properties
+  refresh = props => {
+    if (props.thumbNail) return;
+    d3.selectAll("g.node.network").classed(
+      "selected",
+      d => d.key === props.selectedKey
+    );
+
+    // update the interior node state
+    d3.selectAll("g.node.network.interior path").attr("d", d =>
+      d3.svg
+        .arc()
+        .innerRadius(0)
+        .outerRadius(d.r)({
+        startAngle: 0,
+        endAngle: (d.state * 2.0 * Math.PI) / 3.0
+      })
+    );
+
+    d3.selectAll("svg text").each(function(d) {
+      d3.select(this).text(d.Name);
+    });
+    d3.selectAll("g.connector").classed(
+      "selected",
+      d => d.key === props.selectedKey
+    );
+    d3.selectAll("text.edge-count").text(d => {
+      return d.rows.length > 0 ? `Edges: ${d.rows.length}` : "";
+    });
+  };
+
+  // update the node's positions
+  updateNode = selection => {
+    selection.attr("transform", d => {
+      let container = {
+        width: this.props.dimensions.width,
+        height: this.props.dimensions.height
+      };
+      let r = 15;
+      d.x = Math.max(Math.min(d.x, container.width - r), r);
+      d.y = Math.max(Math.min(d.y, container.height - r), r);
+      return `translate(${d.x || 0},${d.y || 0}) ${d.over ? "scale(1.1)" : ""}`;
+    });
+  };
+
+  markerId = (link, end) => {
+    return `--${end === "end" ? link.size : link.size}`;
+  };
+
+  // called with a selection that represents all the new links between nodes
+  // here we add the lines and set their attributes
+  enterLink = selection => {
+    const graph = this;
+
+    // add a visible line with an arrow
+    selection
+      .append("path")
+      .classed("link", true)
+      .attr("stroke-width", d => d.size)
+      .attr("marker-end", d => {
+        return d.right ? `url(#end--20)` : null;
+      })
+      .attr("marker-start", d => {
+        if (d.type === "edge") return null;
+        if (this.props.thumbNail) return null;
+        return d.left || (!d.left && !d.right) ? `url(#start--20)` : null;
+      });
+
+    if (!this.props.thumbNail && !this.props.legend) {
+      // add an invisible wide path to make it easier to mouseover
+      selection
+        .append("path")
+        .classed("hittarget", true)
+        .on("click", function(d) {
+          d3.select(this.parentNode).classed("selected", true);
+          graph.notifyCurrentConnector(d);
+          graph.refresh(graph.props);
+        })
+        .on("mouseover", function(n) {
+          d3.select(this.parentNode).classed("over", true);
+        })
+        .on("mouseout", function(n) {
+          d3.select(this.parentNode).classed("over", false);
+        });
+    }
+    this.refresh(this.props);
+  };
+
+  notifyCurrentConnector = d => {
+    if (this.props.notifyCurrentConnector) this.props.notifyCurrentConnector(d);
+  };
+
+  // update the links' positions
+  updateLink = selection => {
+    const stxy = d => {
+      let sx = d.source.x || this.props.nodes[d.source].x;
+      let tx = d.target.x || this.props.nodes[d.target].x;
+      let sy = d.source.y || this.props.nodes[d.source].y;
+      let ty = d.target.y || this.props.nodes[d.target].y;
+
+      if (d.source.parentKey !== d.target.parentKey) {
+        const snode = this.props.nodes.find(n => n.key === d.source.parentKey);
+        const tnode = this.props.nodes.find(n => n.key === d.target.parentKey);
+        if (snode && tnode) {
+          sx += snode.kx;
+          tx += tnode.kx;
+          sy += snode.ky;
+          ty += tnode.ky;
+        }
+      }
+      const deltaX = tx - sx;
+      const deltaY = ty - sy;
+      const dist = Math.sqrt(deltaX * deltaX + deltaY * deltaY);
+      const normX = deltaX / dist;
+      const normY = deltaY / dist;
+      const sourcePadding = d.source.r || this.props.nodes[d.source].r;
+      const targetPadding = d.target.r || this.props.nodes[d.target].r;
+      const sourceX = sx + sourcePadding * normX;
+      const sourceY = sy + sourcePadding * normY;
+      const targetX = tx - targetPadding * normX;
+      const targetY = ty - targetPadding * normY;
+      return { x1: sourceX, y1: sourceY, x2: targetX, y2: targetY };
+    };
+    selection.attr("d", d => {
+      const endp = stxy(d);
+      return `M${endp.x1},${endp.y1}L${endp.x2},${endp.y2}`;
+    });
+  };
+
+  // called each animation tick to update the positions
+  updateGraph = selection => {
+    selection.selectAll(".node").call(this.updateNode);
+    selection.selectAll(".link").call(this.updateLink);
+    selection.selectAll(".hittarget").call(this.updateLink);
+  };
+
+  render() {
+    const { width, height } = this.props.dimensions;
+    return (
+      <React.Fragment>
+        <svg
+          width={width}
+          height={height}
+          ref={el => (this.svg = el)}
+          xmlns="http://www.w3.org/2000/svg"
+        >
+          <g ref={el => (this.svgg = el)} />
+        </svg>
+      </React.Fragment>
+    );
+  }
+}
+
+export default Graph;
diff --git a/console/react/src/inFlightCard.js b/console/react/src/inFlightCard.js
new file mode 100644
index 0000000..4e7bad0
--- /dev/null
+++ b/console/react/src/inFlightCard.js
@@ -0,0 +1,101 @@
+import React from "react";
+import {
+  Chart,
+  ChartArea,
+  ChartAxis,
+  ChartGroup,
+  ChartThemeColor,
+  ChartVoronoiContainer
+} from "@patternfly/react-charts";
+
+class InFlightCard extends React.Component {
+  constructor(props) {
+    super(props);
+    this.containerRef = React.createRef();
+    this.state = {
+      width: 0
+    };
+    this.handleResize = () => {
+      this.setState({ width: this.containerRef.current.clientWidth });
+    };
+  }
+
+  componentDidMount() {
+    setTimeout(() => {
+      this.setState({ width: this.containerRef.current.clientWidth });
+      window.addEventListener("resize", this.handleResize);
+    });
+  }
+
+  componentWillUnmount() {
+    window.removeEventListener("resize", this.handleResize);
+  }
+
+  render() {
+    const { width } = this.state;
+
+    return (
+      <div ref={this.containerRef}>
+        <div className="area-chart-legend-bottom-responsive">
+          <Chart
+            ariaDesc="Average number of pets"
+            ariaTitle="Area chart example"
+            containerComponent={
+              <ChartVoronoiContainer
+                labels={datum => `${datum.name}: ${datum.y}`}
+              />
+            }
+            legendData={[{ name: "Cats" }, { name: "Birds" }, { name: "Dogs" }]}
+            legendPosition="bottom-left"
+            height={225}
+            padding={{
+              bottom: 75, // Adjusted to accomodate legend
+              left: 50,
+              right: 50,
+              top: 50
+            }}
+            maxDomain={{ y: 9 }}
+            themeColor={ChartThemeColor.multiUnordered}
+            width={width}
+          >
+            <ChartAxis />
+            <ChartAxis dependentAxis showGrid />
+            <ChartGroup>
+              <ChartArea
+                data={[
+                  { name: "Cats", x: 1, y: 3 },
+                  { name: "Cats", x: 2, y: 4 },
+                  { name: "Cats", x: 3, y: 8 },
+                  { name: "Cats", x: 4, y: 6 }
+                ]}
+                interpolation="basis"
+              />
+              <ChartArea
+                data={[
+                  { name: "Birds", x: 1, y: 2 },
+                  { name: "Birds", x: 2, y: 3 },
+                  { name: "Birds", x: 3, y: 4 },
+                  { name: "Birds", x: 4, y: 5 },
+                  { name: "Birds", x: 5, y: 6 }
+                ]}
+                interpolation="basis"
+              />
+              <ChartArea
+                data={[
+                  { name: "Dogs", x: 1, y: 1 },
+                  { name: "Dogs", x: 2, y: 2 },
+                  { name: "Dogs", x: 3, y: 3 },
+                  { name: "Dogs", x: 4, y: 2 },
+                  { name: "Dogs", x: 5, y: 4 }
+                ]}
+                interpolation="basis"
+              />
+            </ChartGroup>
+          </Chart>
+        </div>
+      </div>
+    );
+  }
+}
+
+export default InFlightCard;
diff --git a/console/react/src/index.css b/console/react/src/index.css
new file mode 100644
index 0000000..4a1df4d
--- /dev/null
+++ b/console/react/src/index.css
@@ -0,0 +1,13 @@
+body {
+  margin: 0;
+  font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", "Oxygen",
+    "Ubuntu", "Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue",
+    sans-serif;
+  -webkit-font-smoothing: antialiased;
+  -moz-osx-font-smoothing: grayscale;
+}
+
+code {
+  font-family: source-code-pro, Menlo, Monaco, Consolas, "Courier New",
+    monospace;
+}
diff --git a/console/react/src/index.js b/console/react/src/index.js
new file mode 100644
index 0000000..c1684e8
--- /dev/null
+++ b/console/react/src/index.js
@@ -0,0 +1,11 @@
+import React from "react";
+import ReactDOM from "react-dom";
+import App from "./App";
+import * as serviceWorker from "./serviceWorker";
+
+ReactDOM.render(<App />, document.getElementById("root"));
+
+// If you want your app to work offline and load faster, you can change
+// unregister() to register() below. Note this comes with some pitfalls.
+// Learn more about service workers: https://bit.ly/CRA-PWA
+serviceWorker.unregister();
diff --git a/console/react/src/layout.js b/console/react/src/layout.js
new file mode 100644
index 0000000..2b43e9a
--- /dev/null
+++ b/console/react/src/layout.js
@@ -0,0 +1,381 @@
+import React from "react";
+import {
+  Avatar,
+  Button,
+  ButtonVariant,
+  Dropdown,
+  DropdownToggle,
+  DropdownItem,
+  DropdownSeparator,
+  KebabToggle,
+  Page,
+  PageHeader,
+  SkipToContent,
+  Toolbar,
+  ToolbarGroup,
+  ToolbarItem,
+  Nav,
+  NavExpandable,
+  NavItem,
+  NavList,
+  PageSidebar
+} from "@patternfly/react-core";
+// make sure you've installed @patternfly/patternfly
+import accessibleStyles from "@patternfly/patternfly/utilities/Accessibility/accessibility.css";
+import spacingStyles from "@patternfly/patternfly/utilities/Spacing/spacing.css";
+import { css } from "@patternfly/react-styles";
+import { BellIcon, CogIcon } from "@patternfly/react-icons";
+import ShowD3SVG from "./show-d3-svg";
+import ConnectForm from "./connect-form";
+import ConnectPage from "./connectPage";
+import OverviewChartsPage from "./overviewChartsPage";
+import OverviewTablePage from "./overviewTablePage";
+import TopologyPage from "./topology/qdrTopology";
+import { QDRService } from "./qdrService";
+const avatarImg = require("./assets/img_avatar.svg");
+
+class PageLayout extends React.Component {
+  constructor(props) {
+    super(props);
+    this.state = {
+      connected: false,
+      isDropdownOpen: false,
+      isKebabDropdownOpen: false,
+      activeGroup: "overview",
+      activeItem: "charts"
+    };
+    this.tableInfo = {
+      routers: [
+        { fieldName: "name", displayName: "Router" },
+        { name: "role" },
+        { name: "mode" },
+        {
+          fieldName: "addresses",
+          displayName: "Addresses",
+          getter: this.getAddresses
+        }
+      ]
+    };
+    this.hooks = { setLocation: this.setLocation };
+    this.service = new QDRService(this.hooks);
+  }
+
+  setLocation = where => {
+    //console.log(`setLocation to ${where}`);
+  };
+  getAddresses = (field, data) => {
+    return new Promise((resolve, reject) => {
+      resolve("2");
+    });
+  };
+
+  onDropdownToggle = isDropdownOpen => {
+    this.setState({
+      isDropdownOpen
+    });
+  };
+
+  onDropdownSelect = event => {
+    this.setState({
+      isDropdownOpen: !this.state.isDropdownOpen
+    });
+  };
+
+  onKebabDropdownToggle = isKebabDropdownOpen => {
+    this.setState({
+      isKebabDropdownOpen
+    });
+  };
+
+  onKebabDropdownSelect = event => {
+    this.setState({
+      isKebabDropdownOpen: !this.state.isKebabDropdownOpen
+    });
+  };
+
+  handleConnect = event => {
+    this.service
+      .connect({ address: "localhost", port: 5673, reconnect: true })
+      .then(
+        r => {
+          //console.log(r);
+        },
+        e => {
+          console.log(e);
+        }
+      );
+    this.setState({
+      connected: true
+    });
+  };
+
+  onNavSelect = result => {
+    this.setState({
+      activeItem: result.itemId,
+      activeGroup: result.groupId
+    });
+  };
+
+  render() {
+    const {
+      isDropdownOpen,
+      isKebabDropdownOpen,
+      activeItem,
+      activeGroup
+    } = this.state;
+
+    const PageNav = (
+      <Nav onSelect={this.onNavSelect} aria-label="Nav">
+        <NavList>
+          <NavExpandable
+            title="Overview"
+            groupId="overview"
+            isActive={activeGroup === "overview"}
+            isExpanded
+          >
+            <NavItem
+              groupId="overview"
+              itemId="charts"
+              isActive={activeItem === "charts"}
+            >
+              Charts
+            </NavItem>
+            <NavItem
+              groupId="overview"
+              itemId="routers"
+              isActive={activeItem === "routers"}
+            >
+              Routers
+            </NavItem>
+            <NavItem
+              groupId="overview"
+              itemId="addresses"
+              isActive={activeItem === "addresses"}
+            >
+              Addresses
+            </NavItem>
+            <NavItem
+              groupId="overview"
+              itemId="links"
+              isActive={activeItem === "links"}
+            >
+              Links
+            </NavItem>
+            <NavItem
+              groupId="overview"
+              itemId="connections"
+              isActive={activeItem === "connections"}
+            >
+              Connections
+            </NavItem>
+            <NavItem
+              groupId="overview"
+              itemId="logs"
+              isActive={activeItem === "logs"}
+            >
+              Logs
+            </NavItem>
+          </NavExpandable>
+          <NavExpandable
+            title="Visualizations"
+            groupId="visualizations"
+            isActive={activeGroup === "visualizations"}
+          >
+            <NavItem
+              groupId="visualizations"
+              itemId="topology"
+              isActive={activeItem === "topology"}
+            >
+              Topology
+            </NavItem>
+            <NavItem
+              groupId="visualizations"
+              itemId="flow"
+              isActive={activeItem === "flow"}
+            >
+              Message flow
+            </NavItem>
+          </NavExpandable>
+          <NavExpandable
+            title="Details"
+            groupId="grp-3"
+            isActive={activeGroup === "grp-3"}
+          >
+            <NavItem
+              groupId="grp-3"
+              itemId="grp-3_itm-1"
+              isActive={activeItem === "grp-3_itm-1"}
+            >
+              Entities
+            </NavItem>
+            <NavItem
+              groupId="grp-3"
+              itemId="grp-3_itm-2"
+              isActive={activeItem === "grp-3_itm-2"}
+            >
+              Schema
+            </NavItem>
+          </NavExpandable>
+        </NavList>
+      </Nav>
+    );
+    const kebabDropdownItems = [
+      <DropdownItem key="notif">
+        <BellIcon /> Notifications
+      </DropdownItem>,
+      <DropdownItem key="sett">
+        <CogIcon /> Settings
+      </DropdownItem>
+    ];
+    const userDropdownItems = [
+      <DropdownItem key="link">Link</DropdownItem>,
+      <DropdownItem component="button" key="action">
+        Action
+      </DropdownItem>,
+      <DropdownItem isDisabled key="dis">
+        Disabled Link
+      </DropdownItem>,
+      <DropdownItem isDisabled component="button" key="button">
+        Disabled Action
+      </DropdownItem>,
+      <DropdownSeparator key="sep0" />,
+      <DropdownItem key="sep">Separated Link</DropdownItem>,
+      <DropdownItem component="button" key="sep1">
+        Separated Action
+      </DropdownItem>
+    ];
+    const PageToolbar = (
+      <Toolbar>
+        <ToolbarGroup
+          className={css(
+            accessibleStyles.screenReader,
+            accessibleStyles.visibleOnLg
+          )}
+        >
+          <ToolbarItem>
+            <ConnectForm prefix="toolbar" handleConnect={this.handleConnect} />
+          </ToolbarItem>
+          <ToolbarItem>
+            <Button
+              id="default-example-uid-01"
+              aria-label="Notifications actions"
+              variant={ButtonVariant.plain}
+            >
+              <BellIcon />
+            </Button>
+          </ToolbarItem>
+          <ToolbarItem>
+            <Button
+              id="default-example-uid-02"
+              aria-label="Settings actions"
+              variant={ButtonVariant.plain}
+            >
+              <CogIcon />
+            </Button>
+          </ToolbarItem>
+        </ToolbarGroup>
+        <ToolbarGroup>
+          <ToolbarItem
+            className={css(accessibleStyles.hiddenOnLg, spacingStyles.mr_0)}
+          >
+            <Dropdown
+              isPlain
+              position="right"
+              onSelect={this.onKebabDropdownSelect}
+              toggle={<KebabToggle onToggle={this.onKebabDropdownToggle} />}
+              isOpen={isKebabDropdownOpen}
+              dropdownItems={kebabDropdownItems}
+            />
+          </ToolbarItem>
+          <ToolbarItem
+            className={css(
+              accessibleStyles.screenReader,
+              accessibleStyles.visibleOnMd
+            )}
+          >
+            <Dropdown
+              isPlain
+              position="right"
+              onSelect={this.onDropdownSelect}
+              isOpen={isDropdownOpen}
+              toggle={
+                <DropdownToggle onToggle={this.onDropdownToggle}>
+                  anonymous
+                </DropdownToggle>
+              }
+              dropdownItems={userDropdownItems}
+            />
+          </ToolbarItem>
+        </ToolbarGroup>
+      </Toolbar>
+    );
+
+    const Header = (
+      <PageHeader
+        className="topology-header"
+        logo={
+          <React.Fragment>
+            <ShowD3SVG
+              className="topology-logo"
+              topology="ted"
+              routers={6}
+              center={false}
+              dimensions={{ width: 200, height: 75 }}
+              radius={6}
+              thumbNail={true}
+              notifyCurrentRouter={() => {}}
+            />
+            <span className="logo-text">Apache Qpid Dispatch Console</span>
+          </React.Fragment>
+        }
+        toolbar={PageToolbar}
+        avatar={<Avatar src={avatarImg} alt="Avatar image" />}
+      />
+    );
+    const Sidebar = <PageSidebar nav={PageNav} className="qdr-sidebar" />;
+    const pageId = "main-content-page-layout-expandable-nav";
+    const PageSkipToContent = (
+      <SkipToContent href={`#${pageId}`}>Skip to Content</SkipToContent>
+    );
+    const activeItemToPage = () => {
+      if (this.state.activeGroup === "overview") {
+        if (this.state.activeItem === "charts") {
+          return <OverviewChartsPage />;
+        }
+        return (
+          <OverviewTablePage
+            entity={this.state.activeItem}
+            tableInfo={this.tableInfo[this.state.activeItem]}
+          />
+        );
+      } else if (this.state.activeGroup === "visualizations") {
+        return <TopologyPage service={this.service} />;
+      }
+      //console.log("using overview charts page");
+      return <OverviewChartsPage />;
+    };
+
+    if (!this.state.connected) {
+      return (
+        <Page header={Header} skipToContent={PageSkipToContent}>
+          <ConnectPage handleConnect={this.handleConnect} />
+        </Page>
+      );
+    }
+
+    return (
+      <React.Fragment>
+        <Page
+          header={Header}
+          sidebar={Sidebar}
+          isManagedSidebar
+          skipToContent={PageSkipToContent}
+        >
+          {activeItemToPage()}
+        </Page>
+      </React.Fragment>
+    );
+  }
+}
+
+export default PageLayout;
diff --git a/console/react/src/network-name.js b/console/react/src/network-name.js
new file mode 100644
index 0000000..c1e817d
--- /dev/null
+++ b/console/react/src/network-name.js
@@ -0,0 +1,67 @@
+import React from "react";
+import {
+  FormGroup,
+  Text,
+  TextContent,
+  TextInput,
+  Split,
+  SplitItem
+} from "@patternfly/react-core";
+
+class NetworkName extends React.Component {
+  constructor(props) {
+    super(props);
+    this.state = {
+      editing: true,
+      editingName: this.props.networkInfo.name
+    };
+  }
+
+  handleSaveClick = () => {
+    let editing = !this.state.editing;
+    this.setState({ editing });
+    if (!editing) this.props.handleNetworkNameChange(this.state.editingName);
+  };
+
+  handleCancelClick = () => {
+    let { editing, editingName } = this.state;
+    editing = false;
+    editingName = this.props.networkInfo.name;
+    this.setState({ editing, editingName });
+  };
+
+  handleLocalEditName = editingName => {
+    this.setState({ editingName });
+  };
+
+  render() {
+    return (
+      <React.Fragment>
+        <Split gutter="md" className="network-name">
+          <SplitItem>
+            <TextContent className="enter-prompt">
+              <Text component="h3">Enter a network name to get started</Text>
+            </TextContent>
+          </SplitItem>
+          <SplitItem isFilled>
+            <FormGroup label="" isRequired fieldId="simple-form-name">
+              <TextInput
+                className={this.state.editing ? "editing" : "not-editing"}
+                isRequired
+                isDisabled={!this.state.editing}
+                type="text"
+                id="network-name"
+                name="network-name"
+                aria-describedby="network-name-helper"
+                value={this.state.editingName}
+                onChange={this.handleLocalEditName}
+              />
+            </FormGroup>
+          </SplitItem>
+        </Split>
+      </React.Fragment>
+    );
+  }
+}
+
+export default NetworkName;
diff --git a/console/react/src/nodes.js b/console/react/src/nodes.js
new file mode 100644
index 0000000..9127d6a
--- /dev/null
+++ b/console/react/src/nodes.js
@@ -0,0 +1,260 @@
+/*
+Licensed to the Apache Software Foundation (ASF) under one
+or more contributor license agreements.  See the NOTICE file
+distributed with this work for additional information
+regarding copyright ownership.  The ASF licenses this file
+to you 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.
+*/
+
+import * as d3 from "d3";
+
+export const RouterStates = [
+  "NEW",
+  "READY TO DEPLOY",
+  "CLUSTER HEARD FROM",
+  "IN NETWORK"
+];
+
+const nodeProperties = {
+  // router types
+  "inter-router": {
+    radius: 28,
+    refX: {
+      end: 32,
+      start: -19
+    },
+    linkDistance: [150, 70],
+    charge: [-1800, -900]
+  },
+  edge: {
+    radius: 20,
+    refX: {
+      end: 24,
+      start: -14
+    },
+    linkDistance: [110, 55],
+    charge: [-1350, -900]
+  },
+  // generated nodes from connections. key is from connection.role
+  normal: {
+    radius: 15,
+    refX: {
+      end: 20,
+      start: -7
+    },
+    linkDistance: [75, 40],
+    charge: [-900, -900]
+  }
+};
+// aliases
+nodeProperties._topo = nodeProperties["inter-router"];
+nodeProperties._edge = nodeProperties["edge"];
+nodeProperties["on-demand"] = nodeProperties["normal"];
+nodeProperties["route-container"] = nodeProperties["normal"];
+
+export class Nodes {
+  constructor() {
+    this.nodes = [];
+  }
+  static radius(type) {
+    if (nodeProperties[type].radius) return nodeProperties[type].radius;
+    return 15;
+  }
+  static maxRadius() {
+    let max = 0;
+    for (let key in nodeProperties) {
+      max = Math.max(max, nodeProperties[key].radius);
+    }
+    return max;
+  }
+  static refX(end, r) {
+    for (let key in nodeProperties) {
+      if (nodeProperties[key].radius === parseInt(r)) {
+        return nodeProperties[key].refX[end];
+      }
+    }
+    return 0;
+  }
+  // return all possible values of node radii
+  static discrete() {
+    let values = {};
+    for (let key in nodeProperties) {
+      values[nodeProperties[key].radius] = true;
+    }
+    return Object.keys(values);
+  }
+  // vary the following force graph attributes based on nodeCount
+  static forceScale(nodeCount, minmax) {
+    let count = Math.max(Math.min(nodeCount, 80), 6);
+    let x = d3.scale
+      .linear()
+      .domain([6, 80])
+      .range(minmax);
+    return x(count);
+  }
+  linkDistance(d, nodeCount) {
+    let range = nodeProperties[d.target.nodeType].linkDistance;
+    return Nodes.forceScale(nodeCount, range);
+  }
+  charge(d, nodeCount) {
+    let charge = nodeProperties[d.nodeType].charge;
+    return Nodes.forceScale(nodeCount, charge);
+  }
+  gravity(d, nodeCount) {
+    return Nodes.forceScale(nodeCount, [0.0001, 0.1]);
+  }
+  setFixed(d, fixed) {
+    let n = this.find(d.container, d.properties, d.name);
+    if (n) {
+      n.fixed = fixed;
+    }
+    d.setFixed(fixed);
+  }
+  getLength() {
+    return this.nodes.length;
+  }
+  get(index) {
+    if (index < this.getLength()) {
+      return this.nodes[index];
+    }
+    return undefined;
+  }
+  nodeFor(name) {
+    for (let i = 0; i < this.nodes.length; ++i) {
+      if (this.nodes[i].name === name) return this.nodes[i];
+    }
+    return null;
+  }
+  nodeExists(connectionContainer) {
+    return this.nodes.findIndex(function(node) {
+      return node.container === connectionContainer;
+    });
+  }
+
+  find(connectionContainer, properties, name) {
+    properties = properties || {};
+    for (let i = 0; i < this.nodes.length; ++i) {
+      if (
+        this.nodes[i].name === name ||
+        this.nodes[i].container === connectionContainer
+      ) {
+        if (properties.product) this.nodes[i].properties = properties;
+        return this.nodes[i];
+      }
+    }
+    return undefined;
+  }
+  clearHighlighted() {
+    for (let i = 0; i < this.nodes.length; ++i) {
+      this.nodes[i].highlighted = false;
+    }
+  }
+}
+
+// Generate a marker for each combination of:
+//  start|end, ''|selected highlighted, and each possible node radius
+export function addDefs(svg) {
+  let sten = ["start", "end"];
+  let states = [""];
+  let radii = Nodes.discrete();
+  let defs = [];
+  for (let isten = 0; isten < sten.length; isten++) {
+    for (let istate = 0; istate < states.length; istate++) {
+      for (let iradii = 0; iradii < radii.length; iradii++) {
+        defs.push({
+          sten: sten[isten],
+          state: states[istate],
+          r: radii[iradii]
+        });
+      }
+    }
+  }
+
+  svg
+    .insert("svg:defs", "svg g")
+    .attr("class", "marker-defs")
+    .selectAll("marker")
+    .data(defs)
+    .enter()
+    .append("svg:marker")
+    .attr("id", function(d) {
+      return [d.sten, d.state, d.r].join("-");
+    })
+    .attr("viewBox", "0 -5 10 10")
+    .attr("refX", function(d) {
+      return -1;
+      //return Nodes.refX(d.sten, d.r);
+    })
+    .attr("markerWidth", 14)
+    .attr("markerHeight", 14)
+    .attr("markerUnits", "userSpaceOnUse")
+    .attr("orient", "auto")
+    .append("svg:path")
+    .attr("d", function(d) {
+      return d.sten === "end"
+        ? "M 0 -5 L 10 0 L 0 5 z"
+        : "M 10 -5 L 0 0 L 10 5 z";
+    })
+    .attr("fill", "#000000");
+
+  addStyles(
+    sten,
+    {
+      selected: "#33F",
+      highlighted: "#6F6",
+      unknown: "#888"
+    },
+    radii
+  );
+}
+export function addGradient(svg) {
+  // gradient for sender/receiver client
+  let grad = svg
+    .append("svg:defs")
+    .append("linearGradient")
+    .attr("id", "half-circle")
+    .attr("x1", "0%")
+    .attr("x2", "0%")
+    .attr("y1", "100%")
+    .attr("y2", "0%");
+  grad
+    .append("stop")
+    .attr("offset", "50%")
+    .style("stop-color", "#C0F0C0");
+  grad
+    .append("stop")
+    .attr("offset", "50%")
+    .style("stop-color", "#F0F000");
+}
+
+function addStyles(stend, stateColor, radii) {
+  // the <style>
+  let element = document.querySelector("style");
+  // Reference to the stylesheet
+  let sheet = element.sheet;
+
+  let states = Object.keys(stateColor);
+  // create styles for each combo of 'stend-state-radii'
+  for (let istend = 0; istend < stend.length; istend++) {
+    for (let istate = 0; istate < states.length; istate++) {
+      let selectors = [];
+      for (let iradii = 0; iradii < radii.length; iradii++) {
+        selectors.push(`#${stend[istend]}-${states[istate]}-${radii[iradii]}`);
+      }
+      let color = stateColor[states[istate]];
+      let sels = `${selectors.join(",")} {fill: ${color}; stroke: ${color};}`;
+      sheet.insertRule(sels, 0);
+    }
+  }
+}
diff --git a/console/react/src/nodeslinks.js b/console/react/src/nodeslinks.js
new file mode 100644
index 0000000..0249ecb
--- /dev/null
+++ b/console/react/src/nodeslinks.js
@@ -0,0 +1,144 @@
+class NodesLinks {
+  static nextNodeIndex = 1;
+  static nextLinkIndex = 1;
+  static nextEdgeIndex = 1;
+  static nextEdgeClassIndex = 1;
+  link = (s, t, i) => ({
+    source: s,
+    target: t,
+    key: `link-${i}`,
+    size: 2,
+    type: "connector",
+    left: true,
+    right: false
+  });
+  setLinks = (start, count) => {
+    let links = [];
+    for (let i = start; i < start + count - 1; i++) {
+      links.push(this.link(i, i + 1, NodesLinks.nextLinkIndex++));
+    }
+    return links;
+  };
+
+  node = (type, i, x, y) => ({
+    key: `key_${type}_${i}`,
+    val: i,
+    r: 20,
+    x: x,
+    y: y,
+    type: type
+  });
+
+  getXY = (start, count, midX, midY, rotate, displace) => {
+    const ang = (start * 2.0 * Math.PI) / count + rotate;
+    const x = midX + Math.cos(ang) * displace;
+    const y = midY + Math.sin(ang) * displace;
+    return { x, y };
+  };
+
+  addNodesInCircle = (nodes, start, count, midX, midY, displace, rotate) => {
+    rotate = rotate || 0;
+    for (let i = start; i < start + count; i++) {
+      const { x, y } = this.getXY(
+        i - start,
+        count,
+        midX,
+        midY,
+        rotate,
+        displace
+      );
+      nodes.push(this.node("R", i, x, y));
+    }
+  };
+
+  addNode = (type, networkInfo, dimensions) => {
+    let i;
+    if (type === "edgeClass") {
+      i = NodesLinks.nextEdgeClassIndex++;
+    } else {
+      i = NodesLinks.nextNodeIndex++;
+    }
+    const x = dimensions.width / 2;
+    const y = dimensions.height / 2;
+    const newNode = this.node(type, i, x, y);
+    newNode.type = type;
+    if (type === "interior") {
+      newNode.Name = `hub-${i}`;
+      newNode["Route-suffix"] = "";
+      newNode.Namespace = "";
+      newNode.state = 0;
+    } else if (type === "edgeClass") {
+      newNode.Name = `EC-${i}`;
+      newNode.rows = [];
+      newNode.r = 60;
+    }
+    networkInfo.nodes.push(newNode);
+    return newNode;
+  };
+
+  addLink = (toIndex, fromIndex, links, nodes) => {
+    if (!this.linkBetween(links, toIndex, fromIndex)) {
+      const link = this.link(fromIndex, toIndex, NodesLinks.nextLinkIndex++);
+      if (
+        nodes[toIndex].type === "interior" &&
+        nodes[fromIndex].type === "interior"
+      ) {
+        link["connector type"] = "inter-router";
+      } else {
+        link["connector type"] = "edge";
+      }
+      link.connector = () => nodes[toIndex].Name;
+      link.listener = () => nodes[fromIndex].Name;
+      links.push(link);
+      return link;
+    }
+  };
+
+  getEdgeName = () => {
+    return `edge-${NodesLinks.nextEdgeIndex++}`;
+  };
+  getEdgeKey = () => {
+    return NodesLinks.nextEdgeIndex;
+  };
+
+  // return true if there are any links between toIndex and fromIndex
+  linkBetween = (links, toIndex, fromIndex) => {
+    return links.some(
+      l =>
+        (l.source.index === toIndex && l.target.index === fromIndex) ||
+        (l.source.index === fromIndex && l.target.index === toIndex)
+    );
+  };
+  linkIndex = (links, nodeIndex) => {
+    return links.findIndex(
+      l => l.source.index === nodeIndex || l.target.index === nodeIndex
+    );
+  };
+
+  setNodesLinks = (networkInfo, dimensions) => {
+    const nodes = [];
+    let links = [];
+    const midX = dimensions.width / 2;
+    const midY = dimensions.height / 2;
+    const displace = dimensions.height / 2;
+    // create the routers
+    // set their starting positions in a circle
+    if (networkInfo.routers.length === 1) {
+      nodes.push(this.node("R", 0, midX, midY));
+    } else {
+      this.addNodesInCircle(
+        nodes,
+        0,
+        networkInfo.routers.length,
+        midX,
+        midY,
+        displace
+      );
+    }
+    if (networkInfo.routers.length > 1)
+      links = this.setLinks(0, networkInfo.routers.length);
+    return { nodes, links };
+  };
+}
+
+export default NodesLinks;
diff --git a/console/react/src/overviewChartsPage.js b/console/react/src/overviewChartsPage.js
new file mode 100644
index 0000000..9f7d48d
--- /dev/null
+++ b/console/react/src/overviewChartsPage.js
@@ -0,0 +1,50 @@
+import React from "react";
+import { PageSection, PageSectionVariants } from "@patternfly/react-core";
+import { Stack, StackItem } from "@patternfly/react-core";
+import { Card, CardHeader, CardBody } from "@patternfly/react-core";
+import { Split, SplitItem } from "@patternfly/react-core";
+import ThroughputCard from "./throughputCard";
+import InFlightCard from "./inFlightCard";
+import ActiveAddressesCard from "./activeAddressesCard";
+
+class OverviewChartsPage extends React.Component {
+  constructor(props) {
+    super(props);
+    this.state = {};
+  }
+
+  render() {
+    return (
+      <React.Fragment>
+        <PageSection
+          variant={PageSectionVariants.light}
+          className="overview-charts-page"
+        >
+          <Stack gutter="md">
+            <StackItem>
+              <Card>
+                <CardHeader>Header</CardHeader>
+                <CardBody>
+                  <ThroughputCard />
+                  <InFlightCard />
+                </CardBody>
+              </Card>
+            </StackItem>
+            <StackItem>
+              <Split gutter="md">
+                <SplitItem>
+                  <ActiveAddressesCard />
+                </SplitItem>
+                <SplitItem className="fill-card">
+                  <ActiveAddressesCard />
+                </SplitItem>
+              </Split>
+            </StackItem>
+          </Stack>
+        </PageSection>
+      </React.Fragment>
+    );
+  }
+}
+
+export default OverviewChartsPage;
diff --git a/console/react/src/overviewTable.js b/console/react/src/overviewTable.js
new file mode 100644
index 0000000..bfb47a9
--- /dev/null
+++ b/console/react/src/overviewTable.js
@@ -0,0 +1,118 @@
+import React from "react";
+import {
+  Table,
+  TableHeader,
+  TableBody,
+  textCenter
+} from "@patternfly/react-table";
+
+import { Pagination, Title } from "@patternfly/react-core";
+
+class OverviewTable extends React.Component {
+  constructor(props) {
+    super(props);
+    this.state = {
+      res: [],
+      perPage: 20,
+      total: 100,
+      page: 1,
+      error: null,
+      loading: true,
+      columns: [
+        { title: "Router" },
+        "Area",
+        { title: "Mode" },
+        "Addresses",
+        {
+          title: "Links",
+          transforms: [textCenter],
+          cellTransforms: [textCenter]
+        },
+        {
+          title: "External connections",
+          transforms: [textCenter],
+          cellTransforms: [textCenter]
+        }
+      ],
+      rows: [
+        {
+          cells: ["QDR.A", "0", "interior", "1", "2", "3"]
+        },
+        {
+          cells: [
+            {
+              title: <div>QDR.B</div>,
+              props: { title: "hover title", colSpan: 3 }
+            },
+            "2",
+            "3",
+            "4"
+          ]
+        },
+        {
+          cells: [
+            "QDR.C",
+            "0",
+            "interior",
+            "3",
+            {
+              title: "four",
+              props: { textCenter: false }
+            },
+            "5"
+          ]
+        }
+      ]
+    };
+  }
+
+  fetch(page, perPage) {
+    this.setState({ loading: true });
+    fetch(
+      `https://jsonplaceholder.typicode.com/posts?_page=${page}&_limit=${perPage}`
+    )
+      .then(resp => resp.json())
+      .then(resp => this.setState({ res: resp, perPage, page, loading: false }))
+      .catch(err => this.setState({ error: err, loading: false }));
+  }
+
+  componentDidMount() {
+    this.fetch(this.state.page, this.state.perPage);
+  }
+
+  renderPagination(variant = "top") {
+    const { page, perPage, total } = this.state;
+    return (
+      <Pagination
+        itemCount={total}
+        page={page}
+        perPage={perPage}
+        onSetPage={(_evt, value) => this.fetch(value, perPage)}
+        onPerPageSelect={(_evt, value) => this.fetch(1, value)}
+        variant={variant}
+      />
+    );
+  }
+
+  render() {
+    const { loading } = this.state;
+    return (
+      <React.Fragment>
+        {this.renderPagination()}
+        {!loading && (
+          <Table cells={this.state.columns} rows={this.state.rows}>
+            <TableHeader />
+            <TableBody />
+          </Table>
+        )}
+        {loading && (
+          <center>
+            <Title size="3xl">Please wait while loading data</Title>
+          </center>
+        )}
+      </React.Fragment>
+    );
+  }
+}
+
+export default OverviewTable;
diff --git a/console/react/src/overviewTablePage.js b/console/react/src/overviewTablePage.js
new file mode 100644
index 0000000..0b9cb2e
--- /dev/null
+++ b/console/react/src/overviewTablePage.js
@@ -0,0 +1,51 @@
+import React from "react";
+import { PageSection, PageSectionVariants } from "@patternfly/react-core";
+import {
+  Stack,
+  StackItem,
+  TextContent,
+  Text,
+  TextVariants
+} from "@patternfly/react-core";
+import { Card, CardBody } from "@patternfly/react-core";
+import OverviewTable from "./overviewTable";
+
+class OverviewChartsPage extends React.Component {
+  constructor(props) {
+    super(props);
+    this.state = {};
+  }
+
+  render() {
+    return (
+      <React.Fragment>
+        <PageSection
+          variant={PageSectionVariants.light}
+          className="overview-table-page"
+        >
+          <Stack>
+            <StackItem className="overview-header">
+              <TextContent>
+                <Text className="overview-title" component={TextVariants.h1}>
+                  {this.props.entity}
+                </Text>
+              </TextContent>
+            </StackItem>
+            <StackItem className="overview-table">
+              <Card>
+                <CardBody>
+                  <OverviewTable
+                    tableInfo={this.props.tableInfo}
+                    entity={this.props.entity}
+                  />
+                </CardBody>
+              </Card>
+            </StackItem>
+          </Stack>
+        </PageSection>
+      </React.Fragment>
+    );
+  }
+}
+
+export default OverviewChartsPage;
diff --git a/console/react/src/qdrGlobals.js b/console/react/src/qdrGlobals.js
new file mode 100644
index 0000000..b43aabc
--- /dev/null
+++ b/console/react/src/qdrGlobals.js
@@ -0,0 +1,66 @@
+/*
+Licensed to the Apache Software Foundation (ASF) under one
+or more contributor license agreements.  See the NOTICE file
+distributed with this work for additional information
+regarding copyright ownership.  The ASF licenses this file
+to you 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.
+*/
+
+import config from "./config.json";
+/* globals Promise */
+export var QDRFolder = (function() {
+  function Folder(title) {
+    this.title = title;
+    this.children = [];
+    this.folder = true;
+  }
+  return Folder;
+})();
+export var QDRLeaf = (function() {
+  function Leaf(title) {
+    this.title = title;
+  }
+  return Leaf;
+})();
+
+export class QDRLogger {
+  constructor(log, source) {
+    this.log = function(msg) {
+      log.log(
+        " % c % s % s % s",
+        "color: yellow; background - color: black;",
+        "QDR-",
+        source,
+        msg
+      );
+    };
+    this.debug = this.log;
+    this.error = this.log;
+    this.info = this.log;
+    this.warn = this.log;
+  }
+}
+
+export const QDRTemplatePath = "html/";
+export const QDR_SETTINGS_KEY = "QDRSettings";
+export const QDR_LAST_LOCATION = "QDRLastLocation";
+export const QDR_INTERVAL = "QDRInterval";
+
+export var getConfigVars = () =>
+  new Promise(resolve => {
+    const s = {};
+    s.QDR_CONSOLE_TITLE = config.title;
+    document.title = s.QDR_CONSOLE_TITLE;
+    resolve(s);
+  });
diff --git a/console/react/src/qdrPopup.js b/console/react/src/qdrPopup.js
new file mode 100644
index 0000000..5ee9b62
--- /dev/null
+++ b/console/react/src/qdrPopup.js
@@ -0,0 +1,14 @@
+import React from "react";
+
+class QDRPopup extends React.Component {
+  constructor(props) {
+    super(props);
+    this.state = {};
+  }
+
+  render() {
+    return <div dangerouslySetInnerHTML={{ __html: this.props.content }} />;
+  }
+}
+
+export default QDRPopup;
diff --git a/console/react/src/qdrService.js b/console/react/src/qdrService.js
new file mode 100644
index 0000000..93bb7b5
--- /dev/null
+++ b/console/react/src/qdrService.js
@@ -0,0 +1,100 @@
+/*
+Licensed to the Apache Software Foundation (ASF) under one
+  or more contributor license agreements.  See the NOTICE file
+  distributed with this work for additional information
+  regarding copyright ownership.  The ASF licenses this file
+  to you 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.
+*/
+
+import { Management as dm } from "./amqp/management.js";
+import { utils } from "./amqp/utilities.js";
+
+import { QDR_LAST_LOCATION, QDR_INTERVAL } from "./qdrGlobals.js";
+
+// number of milliseconds between topology updates
+const DEFAULT_INTERVAL = 5000;
+export class QDRService {
+  constructor(hooks) {
+    const url = utils.getUrlParts(window.location);
+    this.management = new dm(
+      url.protocol,
+      localStorage[QDR_INTERVAL] || DEFAULT_INTERVAL
+    );
+    this.utilities = utils;
+    this.hooks = hooks;
+  }
+
+  onReconnect() {
+    this.management.connection.on("disconnected", this.onDisconnect.bind(this));
+    let org = localStorage[QDR_LAST_LOCATION] || "charts";
+    this.hooks.setLocation(org);
+  }
+  onDisconnect() {
+    this.hooks.setLocation("connect");
+    this.management.connection.on("connected", this.onReconnect.bind(this));
+  }
+  connect(connectOptions) {
+    let self = this;
+    return new Promise((resolve, reject) => {
+      self.management.connection.connect(connectOptions).then(
+        r => {
+          // if we are ever disconnected, show the connect page and wait for a reconnect
+          self.management.connection.on(
+            "disconnected",
+            self.onDisconnect.bind(self)
+          );
+
+          self.management.getSchema().then(() => {
+            //console.log("got schema after connection");
+            self.management.topology.setUpdateEntities([]);
+            //console.log("requesting a topology");
+            self.management.topology
+              .get() // gets the list of routers
+              .then(t => {
+                //console.log("got topology");
+                const url = utils.getUrlParts(window.location);
+                let curPath = url.pathname;
+                let parts = curPath.split("/");
+                let org = parts[parts.length - 1];
+                if (org === "" || org === "connect") {
+                  org = localStorage[QDR_LAST_LOCATION] || "/overview";
+                }
+                self.hooks.setLocation(org);
+              });
+          });
+          resolve(r);
+        },
+        e => {
+          reject(e);
+        }
+      );
+    });
+  }
+  disconnect() {
+    this.management.connection.disconnect();
+    delete this.management;
+    this.management = new dm(
+      this.$location.protocol(),
+      localStorage[QDR_INTERVAL] || DEFAULT_INTERVAL
+    );
+  }
+}
+
+(function() {
+  console.dump = function(o) {
+    if (window.JSON && window.JSON.stringify)
+      console.log(JSON.stringify(o, undefined, 2));
+    else console.log(o);
+  };
+})();
diff --git a/console/react/src/serviceWorker.js b/console/react/src/serviceWorker.js
new file mode 100644
index 0000000..f8c7e50
--- /dev/null
+++ b/console/react/src/serviceWorker.js
@@ -0,0 +1,135 @@
+// This optional code is used to register a service worker.
+// register() is not called by default.
+
+// This lets the app load faster on subsequent visits in production, and gives
+// it offline capabilities. However, it also means that developers (and users)
+// will only see deployed updates on subsequent visits to a page, after all the
+// existing tabs open on the page have been closed, since previously cached
+// resources are updated in the background.
+
+// To learn more about the benefits of this model and instructions on how to
+// opt-in, read https://bit.ly/CRA-PWA
+
+const isLocalhost = Boolean(
+  window.location.hostname === 'localhost' ||
+    // [::1] is the IPv6 localhost address.
+    window.location.hostname === '[::1]' ||
+    // 127.0.0.1/8 is considered localhost for IPv4.
+    window.location.hostname.match(
+      /^127(?:\.(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)){3}$/
+    )
+);
+
+export function register(config) {
+  if (process.env.NODE_ENV === 'production' && 'serviceWorker' in navigator) {
+    // The URL constructor is available in all browsers that support SW.
+    const publicUrl = new URL(process.env.PUBLIC_URL, window.location.href);
+    if (publicUrl.origin !== window.location.origin) {
+      // Our service worker won't work if PUBLIC_URL is on a different origin
+      // from what our page is served on. This might happen if a CDN is used to
+      // serve assets; see https://github.com/facebook/create-react-app/issues/2374
+      return;
+    }
+
+    window.addEventListener('load', () => {
+      const swUrl = `${process.env.PUBLIC_URL}/service-worker.js`;
+
+      if (isLocalhost) {
+        // This is running on localhost. Let's check if a service worker still exists or not.
+        checkValidServiceWorker(swUrl, config);
+
+        // Add some additional logging to localhost, pointing developers to the
+        // service worker/PWA documentation.
+        navigator.serviceWorker.ready.then(() => {
+          console.log(
+            'This web app is being served cache-first by a service ' +
+              'worker. To learn more, visit https://bit.ly/CRA-PWA'
+          );
+        });
+      } else {
+        // Is not localhost. Just register service worker
+        registerValidSW(swUrl, config);
+      }
+    });
+  }
+}
+
+function registerValidSW(swUrl, config) {
+  navigator.serviceWorker
+    .register(swUrl)
+    .then(registration => {
+      registration.onupdatefound = () => {
+        const installingWorker = registration.installing;
+        if (installingWorker == null) {
+          return;
+        }
+        installingWorker.onstatechange = () => {
+          if (installingWorker.state === 'installed') {
+            if (navigator.serviceWorker.controller) {
+              // At this point, the updated precached content has been fetched,
+              // but the previous service worker will still serve the older
+              // content until all client tabs are closed.
+              console.log(
+                'New content is available and will be used when all ' +
+                  'tabs for this page are closed. See https://bit.ly/CRA-PWA.'
+              );
+
+              // Execute callback
+              if (config && config.onUpdate) {
+                config.onUpdate(registration);
+              }
+            } else {
+              // At this point, everything has been precached.
+              // It's the perfect time to display a
+              // "Content is cached for offline use." message.
+              console.log('Content is cached for offline use.');
+
+              // Execute callback
+              if (config && config.onSuccess) {
+                config.onSuccess(registration);
+              }
+            }
+          }
+        };
+      };
+    })
+    .catch(error => {
+      console.error('Error during service worker registration:', error);
+    });
+}
+
+function checkValidServiceWorker(swUrl, config) {
+  // Check if the service worker can be found. If it can't reload the page.
+  fetch(swUrl)
+    .then(response => {
+      // Ensure service worker exists, and that we really are getting a JS file.
+      const contentType = response.headers.get('content-type');
+      if (
+        response.status === 404 ||
+        (contentType != null && contentType.indexOf('javascript') === -1)
+      ) {
+        // No service worker found. Probably a different app. Reload the page.
+        navigator.serviceWorker.ready.then(registration => {
+          registration.unregister().then(() => {
+            window.location.reload();
+          });
+        });
+      } else {
+        // Service worker found. Proceed as normal.
+        registerValidSW(swUrl, config);
+      }
+    })
+    .catch(() => {
+      console.log(
+        'No internet connection found. App is running in offline mode.'
+      );
+    });
+}
+
+export function unregister() {
+  if ('serviceWorker' in navigator) {
+    navigator.serviceWorker.ready.then(registration => {
+      registration.unregister();
+    });
+  }
+}
diff --git a/console/react/src/show-d3-svg.js b/console/react/src/show-d3-svg.js
new file mode 100644
index 0000000..4144b04
--- /dev/null
+++ b/console/react/src/show-d3-svg.js
@@ -0,0 +1,236 @@
+import React from "react";
+import Graph from "./graph";
+
+class ShowD3SVG extends React.Component {
+  constructor(props) {
+    super(props);
+    this.state = this.setNodesLinks();
+  }
+
+  // if the number of routers has changed
+  // recreate the nodes and links arrays
+  componentDidUpdate(prevProps) {
+    if (this.props.routers !== prevProps.routers) {
+      this.setState(this.addClients(this.setNodesLinks()));
+    }
+  }
+
+  addClients = state => {
+    const { nodes, links } = state;
+    const midX = this.props.dimensions.width / 2;
+    const midY = this.props.dimensions.height / 2;
+    for (const r in this.props.routerInfo) {
+      if (this.props.routerInfo.hasOwnProperty(r)) {
+        const info = this.props.routerInfo[r];
+        const parent = nodes.find(n => n.name === r);
+        if (parent) {
+          // addNodesInCircle = (nodes, start, count, midX, midY, displace, rotate) => {
+          info.forEach((inf, i) => {
+            const node = this.node(nodes.length, midX, midY);
+            node.parent = parent.val;
+            node.key = `${node.key}.C${i}`;
+            node.name = `C${i}`;
+            node.type = inf.client;
+            const { x, y } = this.getXY(
+              i,
+              info.length,
+              parent.x,
+              parent.y,
+              0,
+              40
+            );
+            node.x = x;
+            node.y = y;
+            nodes.push(node);
+            const l = this.link(parent.val, node.val);
+            l.type = "client";
+            links.push(l);
+          });
+        }
+      }
+    }
+    return state;
+  };
+
+  link = (s, t) => ({
+    source: s,
+    target: t,
+    key: `${s}:${t}`,
+    size: 2,
+    type: "router"
+  });
+  setLinks = (topology, start, count) => {
+    let links = [];
+    if (topology === "linear") {
+      for (let i = start; i < start + count - 1; i++) {
+        links.push(this.link(i, i + 1));
+      }
+    } else if (topology === "mesh") {
+      for (let i = start; i < start + count - 1; i++) {
+        for (let j = i + 1; j < start + count; j++) {
+          links.push(this.link(i, j));
+        }
+      }
+    } else if (topology === "star") {
+      for (let i = start; i < start + count; i++) {
+        links.push(this.link(start, i));
+      }
+    } else if (topology === "ring") {
+      for (let i = start; i < start + count - 1; i++) {
+        links.push(this.link(i, i + 1));
+      }
+      if (start + count > 2) links.push(this.link(start + count - 1, start));
+    } else if (topology === "ted") {
+      if (count < 3) {
+        links = this.setLinks("linear", 0, count);
+      } else {
+        links = this.setLinks("mesh", 2, count - 2);
+        links.push(this.link(0, 2));
+        links.push(this.link(0, count - 1));
+        links.push(this.link(1, Math.floor((count - 2) / 2) + 1));
+        links.push(this.link(1, Math.floor((count - 2) / 2) + 2));
+      }
+    } else if (topology === "bar_bell") {
+      links.push(this.link(0, Math.ceil(count / 2)));
+      links = links.concat(this.setLinks("ring", 0, Math.ceil(count / 2)));
+      links = links.concat(
+        this.setLinks("ring", Math.ceil(count / 2), Math.floor(count / 2))
+      );
+    } else if (topology === "random") {
+      // random int from min to max inclusive
+      let randomIntFromInterval = (min, max) =>
+        Math.floor(Math.random() * (max - min + 1) + min);
+      // are two nodes already connected
+      let isConnected = (s, t) => {
+        if (s === t) return true;
+        return links.some(l => {
+          return (
+            (l.source === s && l.target === t) ||
+            (l.target === s && l.source === t)
+          );
+        });
+      };
+
+      // connect all nodes
+      for (let i = 1; i < this.props.routers; i++) {
+        let source = randomIntFromInterval(0, i - 1);
+        links.push(this.link(source, i));
+      }
+      // randomly add n-1 connections
+      for (let i = 0; i < this.props.routers - 1; i++) {
+        let source = randomIntFromInterval(0, this.props.routers - 1);
+        let target = randomIntFromInterval(0, this.props.routers - 1);
+        if (!isConnected(source, target)) {
+          links.push(this.link(source, target));
+        }
+      }
+    }
+    return links;
+  };
+
+  node = (i, x, y) => ({
+    key: `key_${i}`,
+    name: `R${i}`,
+    val: i,
+    size: this.props.radius ? this.props.radius : 15,
+    x: x,
+    y: y,
+    parentKey: "cluster",
+    r: 8
+  });
+
+  getXY = (start, count, midX, midY, rotate, displace) => {
+    const ang = (start * 2.0 * Math.PI) / count + rotate;
+    const x = midX + Math.cos(ang) * displace;
+    const y = midY + Math.sin(ang) * displace;
+    return { x, y };
+  };
+
+  addNodesInCircle = (nodes, start, count, midX, midY, displace, rotate) => {
+    rotate = rotate || 0;
+    for (let i = start; i < start + count; i++) {
+      const { x, y } = this.getXY(
+        i - start,
+        count,
+        midX,
+        midY,
+        rotate,
+        displace
+      );
+      nodes.push(this.node(i, x, y));
+    }
+  };
+  setNodesLinks = () => {
+    const nodes = [];
+    let links = [];
+    const midX = this.props.dimensions.width / 2;
+    const midY = this.props.dimensions.height / 2;
+    const displace = this.props.dimensions.height / 2;
+
+    // create the routers
+    // set their starting positions in a circle
+    if (this.props.topology === "ted" && this.props.routers > 1) {
+      nodes.push(this.node(0, this.props.dimensions.width, midY));
+      nodes.push(this.node(1, 0, midY));
+      this.addNodesInCircle(
+        nodes,
+        2,
+        this.props.routers - 2,
+        midX,
+        midY,
+        displace,
+        Math.PI / (this.props.routers - 2)
+      );
+    } else if (this.props.topology === "bar_bell" && this.props.routers > 1) {
+      this.addNodesInCircle(
+        nodes,
+        0, // start
+        Math.ceil(this.props.routers / 2), // count
+        this.props.dimensions.width / 4, // midX
+        midY, // midY
+        displace / 3 // displace
+      );
+      this.addNodesInCircle(
+        nodes,
+        Math.ceil(this.props.routers / 2),
+        Math.floor(this.props.routers / 2),
+        this.props.dimensions.width * 0.75,
+        midY,
+        displace / 3,
+        Math.PI
+      );
+    } else if (this.props.center && this.props.routers > 1) {
+      nodes.push(this.node(0, midX, midY));
+      this.addNodesInCircle(
+        nodes,
+        1,
+        this.props.routers - 1,
+        midX,
+        midY,
+        displace
+      );
+    } else if (this.props.routers === 1) {
+      nodes.push(this.node(0, midX, midY));
+    } else {
+      this.addNodesInCircle(nodes, 0, this.props.routers, midX, midY, displace);
+    }
+    if (this.props.routers > 1)
+      links = this.setLinks(this.props.topology, 0, this.props.routers);
+    return { nodes: nodes, links: links };
+  };
+
+  render() {
+    const { nodes, links } = this.state;
+    return (
+      <Graph
+        nodes={nodes}
+        links={links}
+        dimensions={this.props.dimensions}
+        thumbNail={this.props.thumbNail}
+        notifyCurrentRouter={this.props.notifyCurrentRouter}
+      />
+    );
+  }
+}
+
+export default ShowD3SVG;
diff --git a/console/react/src/throughputCard.js b/console/react/src/throughputCard.js
new file mode 100644
index 0000000..f53e9c9
--- /dev/null
+++ b/console/react/src/throughputCard.js
@@ -0,0 +1,101 @@
+import React from "react";
+import {
+  Chart,
+  ChartArea,
+  ChartAxis,
+  ChartGroup,
+  ChartThemeColor,
+  ChartVoronoiContainer
+} from "@patternfly/react-charts";
+
+class ThroughputChart extends React.Component {
+  constructor(props) {
+    super(props);
+    this.containerRef = React.createRef();
+    this.state = {
+      width: 0
+    };
+    this.handleResize = () => {
+      this.setState({ width: this.containerRef.current.clientWidth });
+    };
+  }
+
+  componentDidMount() {
+    setTimeout(() => {
+      this.setState({ width: this.containerRef.current.clientWidth });
+      window.addEventListener("resize", this.handleResize);
+    });
+  }
+
+  componentWillUnmount() {
+    window.removeEventListener("resize", this.handleResize);
+  }
+
+  render() {
+    const { width } = this.state;
+
+    return (
+      <div ref={this.containerRef}>
+        <div className="area-chart-legend-bottom-responsive">
+          <Chart
+            ariaDesc="Average number of pets"
+            ariaTitle="Area chart example"
+            containerComponent={
+              <ChartVoronoiContainer
+                labels={datum => `${datum.name}: ${datum.y}`}
+              />
+            }
+            legendData={[{ name: "Cats" }, { name: "Birds" }, { name: "Dogs" }]}
+            legendPosition="bottom-left"
+            height={225}
+            padding={{
+              bottom: 75, // Adjusted to accomodate legend
+              left: 50,
+              right: 50,
+              top: 50
+            }}
+            maxDomain={{ y: 9 }}
+            themeColor={ChartThemeColor.multiUnordered}
+            width={width}
+          >
+            <ChartAxis />
+            <ChartAxis dependentAxis showGrid />
+            <ChartGroup>
+              <ChartArea
+                data={[
+                  { name: "Cats", x: 1, y: 3 },
+                  { name: "Cats", x: 2, y: 4 },
+                  { name: "Cats", x: 3, y: 8 },
+                  { name: "Cats", x: 4, y: 6 }
+                ]}
+                interpolation="basis"
+              />
+              <ChartArea
+                data={[
+                  { name: "Birds", x: 1, y: 2 },
+                  { name: "Birds", x: 2, y: 3 },
+                  { name: "Birds", x: 3, y: 4 },
+                  { name: "Birds", x: 4, y: 5 },
+                  { name: "Birds", x: 5, y: 6 }
+                ]}
+                interpolation="basis"
+              />
+              <ChartArea
+                data={[
+                  { name: "Dogs", x: 1, y: 1 },
+                  { name: "Dogs", x: 2, y: 2 },
+                  { name: "Dogs", x: 3, y: 3 },
+                  { name: "Dogs", x: 4, y: 2 },
+                  { name: "Dogs", x: 5, y: 4 }
+                ]}
+                interpolation="basis"
+              />
+            </ChartGroup>
+          </Chart>
+        </div>
+      </div>
+    );
+  }
+}
+
+export default ThroughputChart;
diff --git a/console/react/src/topology-context.js b/console/react/src/topology-context.js
new file mode 100644
index 0000000..85d7951
--- /dev/null
+++ b/console/react/src/topology-context.js
@@ -0,0 +1,139 @@
+import React from "react";
+import FieldDetails from "./field-details";
+import { RouterStates } from "./nodes";
+import EmptySelection from "./empty-selection";
+
+class TopologyContext extends React.Component {
+  constructor(props) {
+    super(props);
+    this.state = {};
+
+    this.contexts = {
+      interior: {
+        title: "Namespace",
+        fields: [
+          { title: "Name", type: "text", isRequired: true },
+          {
+            title: "State",
+            type: "states",
+            options: RouterStates
+          },
+          {
+            title: "Type",
+            type: "radio",
+            options: ["Kube", "okd", "OC 3.11", "OC 4.1", "unknown"]
+          },
+          { title: "Route-suffix", type: "text" },
+          { title: "Namespace", type: "text" }
+        ],
+        actions: [
+          {
+            title: "Delete",
+            onClick: this.props.handleDeleteRouter,
+            confirm: true
+          }
+        ]
+      },
+      edgeClass: {
+        title: "Edge class",
+        fields: [{ title: "Name", type: "text", isRequired: true }],
+        actions: [
+          {
+            title: "Delete",
+            onClick: this.props.handleDeleteRouter,
+            confirm: true
+          }
+        ],
+        extra: { title: "Edge namespaces", type: "edgeTable" }
+      },
+      edge: {
+        title: "Edge namespace",
+        fields: [{ title: "Name", type: "text", isRequired: true }],
+        actions: [
+          {
+            title: "Delete",
+            onClick: this.props.handleDeleteRouter,
+            confirm: true
+          }
+        ]
+      },
+      connector: {
+        title: "Connection",
+        fields: [
+          { title: "connector type", type: "label" },
+          { title: "connector", type: "label" },
+          { title: "listener", type: "label" }
+        ],
+        actions: [
+          {
+            title: "Delete",
+            onClick: this.props.handleDeleteConnection,
+            confirm: true
+          },
+          {
+            title: "Reverse",
+            onClick: this.props.handleReverseConnection,
+            isDisabled: this.isActionDisabled
+          }
+        ]
+      }
+    };
+  }
+
+  isActionDisabled = (title, networkInfo, selectedKey) => {
+    if (title === "Reverse") {
+      const currentLink = networkInfo.links.find(l => l.key === selectedKey);
+      if (currentLink) {
+        if (currentLink["connector type"] === "edge") return true;
+      }
+    }
+    return false;
+  };
+
+  isRequired = (title, networkInfo, selectedKey) => {
+    // if there are any links going to this node, suffix and namespace are required
+    if (title === "Route-suffix" || title === "Namespace")
+      return networkInfo.links.some(l => l.source.key === selectedKey);
+    return false;
+  };
+
+  render() {
+    let currentContext = null;
+    const currentNode = this.props.networkInfo.nodes.find(
+      n => n.key === this.props.selectedKey
+    );
+    if (currentNode) {
+      currentContext = this.contexts[currentNode.type];
+    } else {
+      const currentLink = this.props.networkInfo.links.find(
+        l => l.key === this.props.selectedKey
+      );
+      if (currentLink) {
+        currentContext = this.contexts[currentLink.type];
+      }
+    }
+
+    if (!currentContext) {
+      return (
+        <div>
+          <EmptySelection />
+        </div>
+      );
+    }
+    return (
+      <FieldDetails
+        details={currentContext}
+        networkInfo={this.props.networkInfo}
+        selectedKey={this.props.selectedKey}
+        handleEditField={this.props.handleEditField}
+        handleAddEdge={this.props.handleAddEdge}
+        handleDeleteEdge={this.props.handleDeleteEdge}
+        handleEdgeNameChange={this.props.handleEdgeNameChange}
+        handleSelectEdgeRow={this.props.handleSelectEdgeRow}
+        handleRadioChange={this.props.handleRadioChange}
+      />
+    );
+  }
+}
+
+export default TopologyContext;
diff --git a/console/react/src/topology/arrowsComponent.js b/console/react/src/topology/arrowsComponent.js
new file mode 100644
index 0000000..d04498e
--- /dev/null
+++ b/console/react/src/topology/arrowsComponent.js
@@ -0,0 +1,53 @@
+/*
+Licensed to the Apache Software Foundation (ASF) under one
+or more contributor license agreements.  See the NOTICE file
+distributed with this work for additional information
+regarding copyright ownership.  The ASF licenses this file
+to you 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.
+*/
+
+import React, { Component } from "react";
+import { Checkbox } from "@patternfly/react-core";
+
+class ArrowsComponent extends Component {
+  constructor(props) {
+    super(props);
+    this.state = {};
+  }
+
+  render() {
+    return (
+      <React.Fragment>
+        <Checkbox
+          label="Show arrows between routers"
+          isChecked={this.props.routerArrows}
+          onChange={this.props.handleChangeArrows}
+          aria-label="show router arrows"
+          id="check-router-arrows"
+          name="routerArrows"
+        />
+        <Checkbox
+          label="Show arrows to clients"
+          isChecked={this.props.clientArrows}
+          onChange={this.props.handleChangeArrows}
+          aria-label="show client arrows"
+          id="check-client-arrows"
+          name="clientArrows"
+        />
+      </React.Fragment>
+    );
+  }
+}
+
+export default ArrowsComponent;
diff --git a/console/react/src/topology/clientInfoComponent.js b/console/react/src/topology/clientInfoComponent.js
new file mode 100644
index 0000000..f5dde19
--- /dev/null
+++ b/console/react/src/topology/clientInfoComponent.js
@@ -0,0 +1,534 @@
+/*
+Licensed to the Apache Software Foundation (ASF) under one
+or more contributor license agreements.  See the NOTICE file
+distributed with this work for additional information
+regarding copyright ownership.  The ASF licenses this file
+to you 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.
+*/
+
+import React, { Component } from "react";
+import { Modal } from "@patternfly/react-core";
+import {
+  Table,
+  TableHeader,
+  TableBody,
+  TableVariant,
+  compoundExpand
+} from "@patternfly/react-table";
+import { CodeBranchIcon } from "@patternfly/react-icons";
+
+import DetailsTable from "./clientInfoDetailsComponent";
+import { utils } from "../amqp/utilities.js";
+const { queue } = require("d3-queue");
+
+class ClientInfoComponent extends Component {
+  constructor(props) {
+    super(props);
+    this.state = {
+      toolTip: null,
+      detail: null,
+      columns: [
+        "Container",
+        "Encrypted",
+        "Host",
+        {
+          title: "Links",
+          cellTransforms: [compoundExpand]
+        }
+      ],
+      rows: [
+        {
+          isOpen: false,
+          cells: [
+            {
+              title: <span>container</span>,
+              props: { component: "th" }
+            },
+            {
+              title: <span>False</span>
+            },
+            {
+              title: <span>host</span>
+            },
+            {
+              title: (
+                <React.Fragment>
+                  <CodeBranchIcon key="icon" /> 1
+                </React.Fragment>
+              ),
+              props: {
+                isOpen: false,
+                ariaControls: "compound-expansion-table-1"
+              }
+            }
+          ]
+        },
+        {
+          parent: 0,
+          compoundParent: 3,
+          cells: [
+            {
+              title: (
+                <DetailsTable
+                  rows={[1, 2, 3, 4, 5, 6]}
+                  id="compound-expansion-table-1"
+                />
+              ),
+              props: { colSpan: 4, className: "pf-m-no-padding" }
+            }
+          ]
+        }
+      ]
+    };
+    this.timer = null;
+    this.rates = {};
+    this.expandedRows = new Set();
+    this.d = this.props.d; // the node object
+
+    this.dStart = 0;
+    this.dStop = Math.min(this.d.normals.length, 10);
+    this.cachedInfo = [];
+    this.updateTimer = null;
+
+    // which attributes to fetch and display
+    this.fields = {
+      detailFields: {
+        cols: [
+          "version",
+          "mode",
+          "presettledDeliveries",
+          "droppedPresettledDeliveries",
+          "acceptedDeliveries",
+          "rejectedDeliveries",
+          "releasedDeliveries",
+          "modifiedDeliveries",
+          "deliveriesIngress",
+          "deliveriesEgress",
+          "deliveriesTransit",
+          "deliveriesIngressRouteContainer",
+          "deliveriesEgressRouteContainer"
+        ]
+      },
+      linkFields: {
+        attrs: [
+          "linkType",
+          "owningAddr",
+          "settleRate",
+          "deliveriesDelayed1Sec",
+          "deliveriesDelayed10Sec",
+          "unsettledCount",
+          "capacity"
+        ],
+        cols: [
+          "linkType",
+          "addr",
+          "settleRate",
+          "delayed1",
+          "delayed10",
+          "usage"
+        ],
+        calc: {
+          addr: link => {
+            return utils.addr_text(link.owningAddr);
+          },
+          delayed1: link => {
+            return link.deliveriesDelayed1Sec;
+          },
+          delayed10: link => {
+            return link.deliveriesDelayed10Sec;
+          },
+          usage: link => {
+            return link.unsettledCount / link.capacity;
+          }
+        }
+      },
+      linkRouteFields: {
+        cols: ["prefix", "direction", "containerId"]
+      },
+      autoLinkFields: {
+        cols: ["addr", "direction", "containerId"]
+      },
+      addressFields: {
+        cols: ["prefix", "distribution"]
+      }
+    };
+  }
+
+  componentDidMount = () => {
+    this.timer = setInterval(this.getTooltip, 5000);
+    this.getTooltip();
+    this.doUpdateDetail();
+  };
+
+  componentWillUnmount = () => {
+    if (this.timer) {
+      clearInterval(this.timer);
+      this.timer = null;
+    }
+    if (this.updateTimer) {
+      clearTimeout(this.updateTimer);
+      this.updateTimer = null;
+    }
+  };
+  getTooltip = () => {
+    this.props.d.toolTip(this.props.topology, true).then(toolTip => {
+      this.setState({ toolTip });
+    });
+  };
+
+  // called for each expanded row to get further details about the edge router
+  moreInfo = (id, infoPerId) => {
+    let nodeId = utils.idFromName(id, "_edge");
+    this.props.topology.fetchEntities(
+      nodeId,
+      [
+        { entity: "router.link", attrs: [] },
+        {
+          entity: "linkRoute",
+          attrs: this.fields.linkRouteFields.cols
+        },
+        {
+          entity: "autoLink",
+          attrs: this.fields.autoLinkFields.cols
+        },
+        { entity: "address", attrs: [] }
+      ],
+      results => {
+        // save the results for each entity requested
+        if (infoPerId[id]) {
+          infoPerId[id].linkRoutes = utils.flattenAll(
+            results[nodeId].linkRoute
+          );
+          infoPerId[id].autoLinks = utils.flattenAll(results[nodeId].autoLink);
+          infoPerId[id].addresses = utils.flattenAll(results[nodeId].address);
+        }
+      }
+    );
+  };
+
+  // get the detail info for the popup
+  groupDetail = () => {
+    // queued function to get the .router info for an edge router
+    const q_getEdgeInfo = (n, infoPerId, callback) => {
+      const nodeId = utils.idFromName(n.container, "_edge");
+      this.props.topology.fetchEntities(
+        nodeId,
+        [{ entity: "router", attrs: [] }],
+        results => {
+          let r = results[nodeId].router;
+          infoPerId[n.container] = utils.flatten(
+            r.attributeNames,
+            r.results[0]
+          );
+          let rates = utils.rates(
+            infoPerId[n.container],
+            ["acceptedDeliveries"],
+            this.rates,
+            n.container,
+            1
+          );
+          infoPerId[n.container].acceptedDeliveriesRate = Math.round(
+            rates.acceptedDeliveries,
+            2
+          );
+          infoPerId[n.container].linkRoutes = [];
+          infoPerId[n.container].autoLinks = [];
+          infoPerId[n.container].addresses = [];
+          callback(null);
+        }
+      );
+    };
+    return new Promise(resolve => {
+      let infoPerId = {};
+      // we are getting info for an edge router
+      if (this.d.nodeType === "edge") {
+        // async send up to 10 requests
+        let q = queue(10);
+        for (let n = this.dStart; n < this.dStop; n++) {
+          q.defer(q_getEdgeInfo, this.d.normals[n], infoPerId);
+          if (this.expandedRows.has(this.d.normals[n].container)) {
+            this.moreInfo(this.d.normals[n].container, infoPerId);
+          }
+        }
+        // await until all sent requests have completed
+        q.await(() => {
+          this.setState({
+            detail: { template: "edgeRouters", title: "edge router" }
+          });
+          // send the results
+          resolve({
+            description: "Select an edge router to see more info",
+            infoPerId: infoPerId
+          });
+        });
+      } else {
+        // we are getting info for a group of clients or consoles
+        let attrs = utils.copy(this.fields.linkFields.attrs);
+        attrs.unshift("connectionId");
+        this.props.topology.fetchEntities(
+          this.d.key,
+          [{ entity: "router.link", attrs: attrs }],
+          results => {
+            let links = results[this.d.key]["router.link"];
+            for (let i = 0; i < this.d.normals.length; i++) {
+              let n = this.d.normals[i];
+              let conn = {};
+              infoPerId[n.container] = conn;
+              conn.container = n.container;
+              conn.encrypted = n.encrypted ? "True" : "False";
+              conn.host = n.host;
+              conn.links = utils.flattenAll(links, link => {
+                this.fields.linkFields.cols.forEach(col => {
+                  if (this.fields.linkFields.calc[col]) {
+                    link[col] = this.fields.linkFields.calc[col](link);
+                  }
+                });
+                if (link.connectionId === n.connectionId) {
+                  link.owningAddr = utils.addr_text(link.owningAddr);
+                  link.addr = link.owningAddr;
+                  return link;
+                } else {
+                  return null;
+                }
+              });
+              conn.linkCount = conn.links.length;
+            }
+            let dir =
+              this.d.cdir === "in"
+                ? "inbound"
+                : this.d.cdir === "both"
+                ? "in and outbound"
+                : "outbound";
+            let count = this.d.normals.length;
+            let verb = count > 1 ? "are" : "is";
+            let preposition =
+              this.d.cdir === "in"
+                ? "to"
+                : this.d.cdir === "both"
+                ? "for"
+                : "from";
+            let plural = count > 1 ? "s" : "";
+            this.setState({
+              detail: { template: "clients", title: "client" }
+            });
+            resolve({
+              description: `There ${verb} ${count} ${dir} connection${plural} ${preposition} ${this.d.routerId} with role ${this.d.nodeType}`,
+              infoPerId: infoPerId
+            });
+          }
+        );
+      }
+    });
+  };
+
+  doUpdateDetail = () => {
+    this.cachedInfo = [];
+    this.updateDetail();
+  };
+  updateDetail = () => {
+    this.groupDetail().then(det => {
+      Object.keys(det.infoPerId).forEach(id => {
+        this.cachedInfo.push(det.infoPerId[id]);
+      });
+      if (this.dStop < this.d.normals.length) {
+        this.dStart = this.dStop;
+        this.dStop = Math.min(this.d.normals.length, this.dStart + 10);
+        setTimeout(this.updateDetail, 1);
+      } else {
+        const infoPerId = this.cachedInfo.sort((a, b) => {
+          return a.name > b.name ? 1 : -1;
+        });
+        const rows = this.getRows(infoPerId);
+        this.setState({
+          detail: {
+            title: `for ${this.d.normals.length} ${this.state.detail.title}${
+              this.d.normals.length > 1 ? "s" : ""
+            }`,
+            description: det.description,
+            infoPerId: infoPerId
+          },
+          rows: rows
+        });
+        this.dStart = 0;
+        this.dStop = Math.min(this.d.normals.length, 10);
+        this.updateTimer = setTimeout(this.doUpdateDetail, 2000);
+
+        console.log(` ------- detail -------`);
+        console.log(this.state.detail);
+        console.log(rows);
+        console.log(` --------------`);
+      }
+    });
+  };
+
+  // load the columns array from infoPerId
+  getRows = infoPerId => {
+    let newRows = [];
+    const oldRows = this.state.rows;
+    for (let i = 0; i < infoPerId.length; i++) {
+      let row = infoPerId[i];
+      let oldRow = oldRows.find(r => r.cells[0].title === row.container);
+      let cells = [];
+      cells.push({ title: row.container, props: { component: "th" } });
+      cells.push({ title: row.encrypted });
+      cells.push({ title: row.host });
+      cells.push({
+        title: (
+          <React.Fragment>
+            <CodeBranchIcon key="icon" /> {row.links.length}
+          </React.Fragment>
+        ),
+        props: {
+          isOpen: oldRow ? oldRow.cells[3].props.isOpen : false,
+          ariaControls: "compound-expansion-table-1"
+        }
+      });
+      newRows.push({
+        isOpen: oldRow ? oldRow.isOpen : false,
+        cells: cells
+      });
+      let subRows = [];
+      row.links.forEach(link => {
+        let subCells = [];
+        this.fields.linkFields.cols.forEach(col => {
+          subCells.push(link[col]);
+        });
+        subRows.push({ cells: subCells });
+      });
+      console.log(" === subRows ===");
+      console.log(subRows);
+
+      newRows.push({
+        parent: i * 2,
+        compoundParent: 3,
+        cells: [
+          {
+            title: (
+              <DetailsTable
+                rows={subRows}
+                subRows={subRows}
+                tst={`there are ${subRows.length} sub rows`}
+                id="compound-expansion-table-1"
+              />
+            ),
+            props: { colSpan: 4, className: "pf-m-no-padding" }
+          }
+        ]
+      });
+    }
+    console.log(newRows);
+    return newRows;
+  };
+
+  /*
+  {
+    isOpen: false,
+    cells: [
+      {
+        title: <span>container</span>,
+        props: { component: "th" }
+      },
+      {
+        title: <span>False</span>
+      },
+      {
+        title: <span>host</span>
+      },
+      {
+        title: (
+          <React.Fragment>
+            <CodeBranchIcon key="icon" /> 1
+          </React.Fragment>
+        ),
+        props: {
+          isOpen: false,
+          ariaControls: "compound-expansion-table-1"
+        }
+      }
+    ]
+  },
+  {
+    parent: 0,
+    compoundParent: 3,
+    cells: [
+      {
+        title: (
+          <DetailsTable
+            firstColumnRows={[
+              "parent-0",
+              "compound-1",
+              "three",
+              "four",
+              "five"
+            ]}
+            id="compound-expansion-table-1"
+          />
+        ),
+        props: { colSpan: 4, className: "pf-m-no-padding" }
+      }
+    ]
+  }
+*/
+
+  onExpand = (event, rowIndex, colIndex, isOpen, rowData, extraData) => {
+    const { rows } = this.state;
+    if (!isOpen) {
+      //set all other expanded cells false in this row if we are expanding
+      rows[rowIndex].cells.forEach(cell => {
+        if (cell.props) cell.props.isOpen = false;
+      });
+      rows[rowIndex].cells[colIndex].props.isOpen = true;
+      rows[rowIndex].isOpen = true;
+    } else {
+      rows[rowIndex].cells[colIndex].props.isOpen = false;
+      rows[rowIndex].isOpen = rows[rowIndex].cells.some(
+        cell => cell.props && cell.props.isOpen
+      );
+    }
+    this.setState({
+      rows
+    });
+  };
+
+  render() {
+    const { detail } = this.state;
+    const { columns, rows } = this.state;
+    return (
+      <Modal
+        isSmall
+        title={`Details ${detail ? detail.title : ""}`}
+        isOpen={true}
+        onClose={this.props.handleCloseClientInfo}
+      >
+        {rows && detail ? (
+          <Table
+            caption={detail.description}
+            variant={TableVariant.compact}
+            onExpand={this.onExpand}
+            borders={false}
+            rows={rows}
+            cells={columns}
+          >
+            <TableHeader />
+            <TableBody />
+          </Table>
+        ) : (
+          <div>Loading...</div>
+        )}
+      </Modal>
+    );
+  }
+}
+
+export default ClientInfoComponent;
diff --git a/console/react/src/topology/clientInfoDetailsComponent.jsx b/console/react/src/topology/clientInfoDetailsComponent.jsx
new file mode 100644
index 0000000..327b118
--- /dev/null
+++ b/console/react/src/topology/clientInfoDetailsComponent.jsx
@@ -0,0 +1,31 @@
+import React from "react";
+import { Table, TableHeader, TableBody } from "@patternfly/react-table";
+
+class DetailsTable extends React.Component {
+  constructor(props) {
+    super(props);
+    this.state = {
+      columns: [
+        "Link type",
+        "Addr",
+        "Settle rate",
+        "Delayed1",
+        "Delayed10",
+        "Usage"
+      ]
+    };
+  }
+
+  render() {
+    const { columns } = this.state;
+    let subRows = this.props.subRows || [];
+    return (
+      <Table cells={columns} rows={subRows}>
+        <TableHeader />
+        <TableBody />
+      </Table>
+    );
+  }
+}
+
+export default DetailsTable;
diff --git a/console/react/src/topology/legend.js b/console/react/src/topology/legend.js
new file mode 100644
index 0000000..2f62ea6
--- /dev/null
+++ b/console/react/src/topology/legend.js
@@ -0,0 +1,194 @@
+/*
+ * Copyright 2018 Red Hat 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.
+ */
+
+import { Nodes } from "./nodes.js";
+import { appendCircle, appendContent, appendTitle } from "./svgUtils.js";
+import * as d3 from "d3";
+
+const lookFor = [
+  {
+    role: "_topo",
+    title: "Router",
+    text: "",
+    cmp: n => n.nodeType === "_topo"
+  },
+  {
+    role: "edge",
+    title: "Router",
+    text: "Edge",
+    cmp: n => n.nodeType === "edge"
+  },
+  {
+    role: "normal",
+    title: "Console",
+    text: "console",
+    cmp: n => n.isConsole,
+    props: { console_identifier: "Dispatch console" }
+  },
+  {
+    role: "normal",
+    title: "Sender",
+    text: "Sender",
+    cmp: n => n.nodeType === "normal" && n.cdir === "in",
+    cdir: "in"
+  },
+  {
+    role: "normal",
+    title: "Receiver",
+    text: "Receiver",
+    cmp: n => n.nodeType === "normal" && n.cdir === "out",
+    cdir: "out"
+  },
+  {
+    role: "normal",
+    title: "Sender/Receiver",
+    text: "Sender/Receiver",
+    cmp: n => n.nodeType === "normal" && n.cdir === "both" && !n.isConsole,
+    cdir: "both"
+  },
+  {
+    role: "route-container",
+    title: "Artemis broker",
+    text: "Artemis broker",
+    cmp: n => n.isArtemis,
+    props: { product: "apache-activemp-artemis" }
+  },
+  {
+    role: "route-container",
+    title: "Qpid broker",
+    text: "Qpid broker",
+    cmp: n =>
+      n.nodeType === "route-container" && n.properties.product === "qpid-cpp",
+    props: { product: "qpid-cpp" }
+  },
+  {
+    role: "route-container",
+    title: "Service",
+    text: "Service",
+    cmp: n =>
+      n.nodeType === "route-container" &&
+      n.properties.product === "External Service",
+    props: { product: " External Service" }
+  }
+];
+
+export class Legend {
+  constructor(nodes, QDRLog) {
+    this.nodes = nodes;
+    this.log = QDRLog;
+  }
+
+  // create a new legend container svg
+  init() {
+    return d3
+      .select("#topo_svg_legend")
+      .append("svg")
+      .attr("id", "svglegend")
+      .append("svg:g")
+      .attr(
+        "transform",
+        `translate(${Nodes.maxRadius()}, ${Nodes.maxRadius()})`
+      )
+      .selectAll("g");
+  }
+
+  // create or update the legend
+  update() {
+    let lsvg;
+    if (d3.select("#topo_svg_legend svg").empty()) {
+      lsvg = this.init();
+    } else {
+      lsvg = d3.select("#topo_svg_legend svg g").selectAll("g");
+    }
+    const isNew = look => this.nodes.nodes.some(n => look.cmp(n));
+    // add a node to legendNodes for each node type that is currently in the svg
+    let legendNodes = new Nodes(this.log);
+    lookFor.forEach(function(node, i) {
+      // if we haven't already added a node of this cls to the nodes
+      if (isNew(node)) {
+        let lnode = legendNodes.addUsing(
+          node.title,
+          node.text,
+          node.role,
+          undefined,
+          0,
+          0,
+          i,
+          0,
+          false,
+          node.props ? node.props : {}
+        );
+        if (node.cdir) lnode.cdir = node.cdir;
+      }
+    }, this);
+
+    // determine the y coordinate of the last existing node in the legend
+    let cury = 0;
+    lsvg.each(function(d) {
+      cury += Nodes.radius(d.nodeType) * 2 + 10;
+    });
+
+    // associate the legendNodes with lsvg
+    lsvg = lsvg.data(legendNodes.nodes, function(d) {
+      return d.uid();
+    });
+
+    // add any new nodes
+    let legendEnter = lsvg
+      .enter()
+      .append("svg:g")
+      .attr("transform", function(d) {
+        let t = `translate(0, ${cury})`;
+        cury += Nodes.radius(d.nodeType) * 2 + 10;
+        return t;
+      });
+    appendCircle(legendEnter, this.urlPrefix);
+    appendContent(legendEnter);
+    appendTitle(legendEnter);
+    legendEnter
+      .append("svg:text")
+      .attr("x", 35)
+      .attr("y", 6)
+      .attr("class", "label")
+      .text(function(d) {
+        return d.key;
+      });
+
+    // remove any nodes that dropped out of legendNodes
+    lsvg.exit().remove();
+
+    /*
+    // position the legend based on it's size
+    let svgEl = document.getElementById("svglegend");
+    if (svgEl) {
+      let bb;
+      // firefox can throw an exception on getBBox on an svg element
+      try {
+        bb = svgEl.getBBox();
+      } catch (e) {
+        bb = {
+          y: 0,
+          height: 200,
+          x: 0,
+          width: 200
+        };
+      }
+      svgEl.style.height = bb.y + bb.height + "px";
+      svgEl.style.width = bb.x + bb.width + "px";
+    }
+    */
+  }
+}
diff --git a/console/react/src/topology/legendComponent.js b/console/react/src/topology/legendComponent.js
new file mode 100644
index 0000000..20795d3
--- /dev/null
+++ b/console/react/src/topology/legendComponent.js
@@ -0,0 +1,130 @@
+/*
+Licensed to the Apache Software Foundation (ASF) under one
+or more contributor license agreements.  See the NOTICE file
+distributed with this work for additional information
+regarding copyright ownership.  The ASF licenses this file
+to you 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.
+*/
+
+import React, { Component } from "react";
+import {
+  Accordion,
+  AccordionItem,
+  AccordionContent,
+  AccordionToggle
+} from "@patternfly/react-core";
+
+import ArrowsComponent from "./arrowsComponent";
+import TrafficComponent from "./trafficComponent";
+
+class LegendComponent extends Component {
+  constructor(props) {
+    super(props);
+    this.state = {};
+  }
+
+  render() {
+    const toggle = id => {
+      const idOpen = this.props[`${id}Open`];
+      this.props.handleOpenChange(id, !idOpen);
+    };
+
+    return (
+      <Accordion>
+        <AccordionItem>
+          <AccordionToggle
+            onClick={() => toggle("traffic")}
+            isExpanded={this.props.trafficOpen}
+            id="traffic"
+          >
+            Traffic
+          </AccordionToggle>
+          <AccordionContent
+            id="traffic-expand"
+            isHidden={!this.props.trafficOpen}
+            isFixed
+          >
+            <TrafficComponent
+              addresses={this.props.addresses}
+              addressColors={this.props.addressColors}
+              open={this.props.trafficOpen}
+              handleChangeTrafficAnimation={
+                this.props.handleChangeTrafficAnimation
+              }
+              handleChangeTrafficFlowAddress={
+                this.props.handleChangeTrafficFlowAddress
+              }
+              dots={this.props.dots}
+              congestion={this.props.congestion}
+            />
+          </AccordionContent>
+        </AccordionItem>
+        <AccordionItem>
+          <AccordionToggle
+            onClick={() => toggle("legend")}
+            isExpanded={this.props.legendOpen}
+            id="legend"
+          >
+            Legend
+          </AccordionToggle>
+          <AccordionContent
+            id="legend-expand"
+            isHidden={!this.props.legendOpen}
+            isFixed
+          >
+            <div id="topo_svg_legend"></div>
+          </AccordionContent>
+        </AccordionItem>
+        <AccordionItem>
+          <AccordionToggle
+            onClick={() => toggle("map")}
+            isExpanded={this.props.mapOpen}
+            id="map"
+          >
+            Background map
+          </AccordionToggle>
+          <AccordionContent
+            id="map-expand"
+            isHidden={!this.props.mapOpen}
+            isFixed
+          >
+            <p>Map options go here</p>
+          </AccordionContent>
+        </AccordionItem>
+        <AccordionItem>
+          <AccordionToggle
+            onClick={() => toggle("arrows")}
+            isExpanded={this.props.arrowsOpen}
+            id="arrows"
+          >
+            Arrows
+          </AccordionToggle>
+          <AccordionContent
+            id="arrows-expand"
+            isHidden={!this.props.arrowsOpen}
+            isFixed
+          >
+            <ArrowsComponent
+              handleChangeArrows={this.props.handleChangeArrows}
+              routerArrows={this.props.routerArrows}
+              clientArrows={this.props.clientArrows}
+            />
+          </AccordionContent>
+        </AccordionItem>
+      </Accordion>
+    );
+  }
+}
+
+export default LegendComponent;
diff --git a/console/react/src/topology/links.js b/console/react/src/topology/links.js
new file mode 100644
index 0000000..1e54288
--- /dev/null
+++ b/console/react/src/topology/links.js
@@ -0,0 +1,317 @@
+/*
+Licensed to the Apache Software Foundation (ASF) under one
+or more contributor license agreements.  See the NOTICE file
+distributed with this work for additional information
+regarding copyright ownership.  The ASF licenses this file
+to you 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.
+*/
+
+import { utils } from "../amqp/utilities.js";
+
+class Link {
+  constructor(source, target, dir, cls, uid) {
+    this.source = source;
+    this.target = target;
+    this.left = dir === "in" || dir === "both";
+    this.right = dir === "out" || dir === "both";
+    this.cls = cls;
+    this.uid = uid;
+  }
+  markerId(end) {
+    let selhigh = this.highlighted
+      ? "highlighted"
+      : this.selected
+      ? "selected"
+      : "";
+    if (selhigh === "" && (!this.left && !this.right)) selhigh = "unknown";
+    return `-${selhigh}-${
+      end === "end" ? this.target.radius() : this.source.radius()
+    }`;
+  }
+}
+
+export class Links {
+  constructor(logger) {
+    this.links = [];
+    this.logger = logger;
+  }
+  reset() {
+    this.links.length = 0;
+  }
+  getLinkSource(nodesIndex) {
+    for (let i = 0; i < this.links.length; ++i) {
+      if (this.links[i].target === nodesIndex) return i;
+    }
+    return -1;
+  }
+  getLink(_source, _target, dir, cls, uid) {
+    for (let i = 0; i < this.links.length; i++) {
+      let s = this.links[i].source,
+        t = this.links[i].target;
+      if (typeof this.links[i].source === "object") {
+        s = s.id;
+        t = t.id;
+      }
+      if (s === _source && t === _target) {
+        return i;
+      }
+      // same link, just reversed
+      if (s === _target && t === _source) {
+        return -i;
+      }
+    }
+    //this.logger.debug("creating new link (" + (links.length) + ") between " + nodes[_source].name + " and " + nodes[_target].name);
+    if (
+      this.links.some(function(l) {
+        return l.uid === uid;
+      })
+    )
+      uid = uid + "." + this.links.length;
+    return this.links.push(new Link(_source, _target, dir, cls, uid)) - 1;
+  }
+  linkFor(source, target) {
+    for (let i = 0; i < this.links.length; ++i) {
+      if (this.links[i].source === source && this.links[i].target === target)
+        return this.links[i];
+      if (this.links[i].source === target && this.links[i].target === source)
+        return this.links[i];
+    }
+    // the selected node was a client/broker
+    return null;
+  }
+
+  getPosition(name, nodes, source, client, height, localStorage) {
+    let position = localStorage[name]
+      ? JSON.parse(localStorage[name])
+      : undefined;
+    if (typeof position === "undefined") {
+      position = {
+        x: Math.round(
+          nodes.get(source).x + 40 * Math.sin(client / (Math.PI * 2.0))
+        ),
+        y: Math.round(
+          nodes.get(source).y + 40 * Math.cos(client / (Math.PI * 2.0))
+        ),
+        fixed: false,
+        animate: true
+      };
+    } else position.animate = false;
+    if (position.y > height) {
+      position.y = Math.round(
+        nodes.get(source).y + 40 + Math.cos(client / (Math.PI * 2.0))
+      );
+    }
+    if (position.x === null || position.y === null) {
+      position.x = Math.round(
+        nodes.get(source).x + 40 * Math.sin(client / (Math.PI * 2.0))
+      );
+      position.y = Math.round(
+        nodes.get(source).y + 40 * Math.cos(client / (Math.PI * 2.0))
+      );
+    }
+    position.fixed = position.fixed ? true : false;
+    return position;
+  }
+
+  initialize(nodeInfo, nodes, unknowns, height, localStorage) {
+    this.reset();
+    let connectionsPerContainer = {};
+    let nodeIds = Object.keys(nodeInfo);
+    // collect connection info for each router
+    for (let source = 0; source < nodeIds.length; source++) {
+      let onode = nodeInfo[nodeIds[source]];
+      // skip any routers without connections
+      if (
+        !onode.connection ||
+        !onode.connection.results ||
+        onode.connection.results.length === 0
+      )
+        continue;
+
+      const suid = nodes.get(source).uid();
+      for (let c = 0; c < onode.connection.results.length; c++) {
+        let connection = utils.flatten(
+          onode.connection.attributeNames,
+          onode.connection.results[c]
+        );
+
+        // we need a unique connection.container
+        if (connection.container === "") {
+          connection.container = connection.name
+            .replace("/", "")
+            .replace(":", "-");
+          //utils.uuidv4();
+        }
+        // this is a connection to another interior router
+        if (connection.role === "inter-router") {
+          const target = getContainerIndex(connection.container, nodeInfo);
+          if (target >= 0) {
+            const tuid = nodes.get(target).uid();
+            this.getLink(source, target, connection.dir, "", `${suid}-${tuid}`);
+          }
+          continue;
+        }
+        if (!connectionsPerContainer[connection.container])
+          connectionsPerContainer[connection.container] = [];
+        let linksDir = getLinkDir(connection, onode);
+        if (linksDir === "unknown") unknowns.push(nodeIds[source]);
+        connectionsPerContainer[connection.container].push({
+          source: source,
+          linksDir: linksDir,
+          connection: connection,
+          resultsIndex: c
+        });
+      }
+    }
+    let unique = {};
+    // create map of type:id:dir to [containers]
+    for (let container in connectionsPerContainer) {
+      let key = getKey(connectionsPerContainer[container]);
+      if (!unique[key])
+        unique[key] = {
+          c: [],
+          nodes: []
+        };
+      unique[key].c.push(container);
+    }
+    for (let key in unique) {
+      let containers = unique[key].c;
+      for (let i = 0; i < containers.length; i++) {
+        let containerId = containers[i];
+        let connections = connectionsPerContainer[containerId];
+        let container = connections[0];
+        let name =
+          utils.nameFromId(nodeIds[container.source]) +
+          "." +
+          container.connection.identity;
+        let position = this.getPosition(
+          name,
+          nodes,
+          container.source,
+          container.resultsIndex,
+          height,
+          localStorage
+        );
+        let node = nodes.getOrCreateNode(
+          nodeIds[container.source],
+          name,
+          container.connection.role,
+          nodes.getLength(),
+          position.x,
+          position.y,
+          container.connection.container,
+          container.resultsIndex,
+          position.fixed,
+          container.connection.properties
+        );
+        node.host = container.connection.host;
+        node.cdir = container.linksDir;
+        node.user = container.connection.user;
+        node.isEncrypted = container.connection.isEncrypted;
+        node.connectionId = container.connection.identity;
+        node.uuid = `${containerId}-${node.routerId}-${node.nodeType}-${node.cdir}`;
+        // in case a created node (or group) is connected to multiple
+        // routers, we need to remember all the routers for traffic animations
+        for (let c = 1; c < connections.length; c++) {
+          if (!node.alsoConnectsTo) node.alsoConnectsTo = [];
+          node.alsoConnectsTo.push({
+            key: nodeIds[connections[c].source],
+            cdir: connections[c].linksDir,
+            connectionId: connections[c].connection.identity
+          });
+        }
+        unique[key].nodes.push(node);
+      }
+    }
+    for (let key in unique) {
+      nodes.add(unique[key].nodes[0]);
+      let target = nodes.nodes.length - 1;
+      unique[key].nodes[0].normals = [unique[key].nodes[0]];
+      for (let n = 1; n < unique[key].nodes.length; n++) {
+        unique[key].nodes[0].normals.push(unique[key].nodes[n]);
+      }
+      let containerId = unique[key].c[0];
+      let links = connectionsPerContainer[containerId];
+      for (let l = 0; l < links.length; l++) {
+        let source = links[l].source;
+        const suid = nodes.get(source).uid();
+        const tuid = nodes.get(target).uid();
+        this.getLink(
+          links[l].source,
+          target,
+          links[l].linksDir,
+          "small",
+          `${suid}-${tuid}`
+        );
+      }
+    }
+  }
+
+  clearHighlighted() {
+    for (let i = 0; i < this.links.length; ++i) {
+      this.links[i].highlighted = false;
+    }
+  }
+}
+
+var getContainerIndex = function(_id, nodeInfo) {
+  let nodeIndex = 0;
+  for (let id in nodeInfo) {
+    if (utils.nameFromId(id) === _id) return nodeIndex;
+    ++nodeIndex;
+  }
+  return -1;
+};
+
+var getLinkDir = function(connection, onode) {
+  let links = onode["router.link"];
+  if (!links) {
+    return "unknown";
+  }
+  let inCount = 0,
+    outCount = 0;
+  let typeIndex = links.attributeNames.indexOf("linkType");
+  let connectionIdIndex = links.attributeNames.indexOf("connectionId");
+  let dirIndex = links.attributeNames.indexOf("linkDir");
+  links.results.forEach(function(linkResult) {
+    if (
+      linkResult[typeIndex] === "endpoint" &&
+      linkResult[connectionIdIndex] === connection.identity
+    )
+      if (linkResult[dirIndex] === "in") ++inCount;
+      else ++outCount;
+  });
+  if (inCount > 0 && outCount > 0) return "both";
+  if (inCount > 0) return "in";
+  if (outCount > 0) return "out";
+  return "unknown";
+};
+var getKey = function(containers) {
+  let parts = {};
+  let connection = containers[0].connection;
+  let d = {
+    nodeType: connection.role,
+    properties: connection.properties || {}
+  };
+  let connectionType = "client";
+  if (utils.isConsole(connection)) connectionType = "console";
+  else if (utils.isArtemis(d)) connectionType = "artemis";
+  else if (utils.isQpid(d)) connectionType = "qpid";
+  else if (connection.role === "edge") connectionType = "edge";
+  for (let c = 0; c < containers.length; c++) {
+    let container = containers[c];
+    parts[`${container.source}-${container.linksDir}`] = true;
+  }
+  return `${connectionType}:${Object.keys(parts).join(":")}`;
+};
diff --git a/console/react/src/topology/map.js b/console/react/src/topology/map.js
new file mode 100644
index 0000000..2fd498b
--- /dev/null
+++ b/console/react/src/topology/map.js
@@ -0,0 +1,283 @@
+/*
+Licensed to the Apache Software Foundation (ASF) under one
+or more contributor license agreements.  See the NOTICE file
+distributed with this work for additional information
+regarding copyright ownership.  The ASF licenses this file
+to you 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.
+*/
+
+/* global angular d3 topojson Promise */
+const maxnorth = 84;
+const maxsouth = 60;
+const MAPOPTIONSKEY = "QDRMapOptions";
+const MAPPOSITIONKEY = "QDRMapPosition";
+const defaultLandColor = "#A3D3E0";
+const defaultOceanColor = "#FFFFFF";
+
+export class BackgroundMap {
+  // eslint-disable-line no-unused-vars
+  constructor($scope, notifyFn) {
+    this.$scope = $scope;
+    this.initialized = false;
+    this.notify = notifyFn;
+    $scope.mapOptions = angular.fromJson(localStorage[MAPOPTIONSKEY]) || {
+      areaColor: defaultLandColor,
+      oceanColor: defaultOceanColor
+    };
+    this.last = {
+      translate: [0, 0],
+      scale: null
+    };
+  }
+  updateLandColor(color) {
+    localStorage[MAPOPTIONSKEY] = JSON.stringify(this.$scope.mapOptions);
+    d3.select("g.geo path.land")
+      .style("fill", color)
+      .style("stroke", d3.rgb(color).darker());
+  }
+  updateOceanColor(color) {
+    localStorage[MAPOPTIONSKEY] = JSON.stringify(this.$scope.mapOptions);
+    if (!color) color = this.$scope.mapOptions.oceanColor;
+    d3.select("g.geo rect.ocean").style("fill", color);
+    if (this.$scope.legendOptions.map.open) {
+      d3.select("#main_container").style("background-color", color);
+    } else {
+      d3.select("#main_container").style("background-color", "#FFF");
+    }
+  }
+
+  init($scope, svg, width, height) {
+    return new Promise(
+      function(resolve, reject) {
+        if (this.initialized) {
+          resolve();
+          return;
+        }
+        this.svg = svg;
+        this.width = width;
+        this.height = height;
+        // track last translation and scale event we processed
+        this.rotate = 20;
+        this.scaleExtent = [1, 10];
+
+        // handle ui events to change the colors
+        $scope.$watch(
+          "mapOptions.areaColor",
+          function(newValue, oldValue) {
+            if (newValue !== oldValue) {
+              this.updateLandColor(newValue);
+            }
+          }.bind(this)
+        );
+        $scope.$watch(
+          "mapOptions.oceanColor",
+          function(newValue, oldValue) {
+            if (newValue !== oldValue) {
+              this.updateOceanColor(newValue);
+            }
+          }.bind(this)
+        );
+
+        // setup the projection with some defaults
+        this.projection = d3.geo
+          .mercator()
+          .rotate([this.rotate, 0])
+          .scale(1)
+          .translate([width / 2, height / 2]);
+
+        // this path will hold the land coordinates once they are loaded
+        this.geoPath = d3.geo.path().projection(this.projection);
+
+        // set up the scale extent and initial scale for the projection
+        var b = getMapBounds(this.projection, Math.max(maxnorth, maxsouth)),
+          s = width / (b[1][0] - b[0][0]);
+        this.scaleExtent = [s, 15 * s];
+
+        this.projection.scale(this.scaleExtent[0]);
+        this.lastProjection = angular.fromJson(
+          localStorage[MAPPOSITIONKEY]
+        ) || {
+          rotate: 20,
+          scale: this.scaleExtent[0],
+          translate: [width / 2, height / 2]
+        };
+
+        this.zoom = d3.behavior
+          .zoom()
+          .scaleExtent(this.scaleExtent)
+          .scale(this.projection.scale())
+          .translate([0, 0]) // not linked directly to projection
+          .on("zoom", this.zoomed.bind(this));
+
+        this.geo = svg
+          .append("g")
+          .attr("class", "geo")
+          .style("opacity", this.$scope.legendOptions.map.open ? "1" : "0");
+
+        this.geo
+          .append("rect")
+          .attr("class", "ocean")
+          .attr("width", width)
+          .attr("height", height)
+          .attr("fill", "#FFF");
+
+        if (this.$scope.legendOptions.map.open) {
+          this.svg.call(this.zoom).on("dblclick.zoom", null);
+        }
+
+        // async load of data file. calls resolve when this completes to let caller know
+        d3.json(
+          "plugin/data/countries.json",
+          function(error, world) {
+            if (error) reject(error);
+
+            this.geo
+              .append("path")
+              .datum(topojson.feature(world, world.objects.countries))
+              .attr("class", "land")
+              .attr("d", this.geoPath)
+              .style(
+                "stroke",
+                d3.rgb(this.$scope.mapOptions.areaColor).darker()
+              );
+
+            this.updateLandColor(this.$scope.mapOptions.areaColor);
+            this.updateOceanColor(this.$scope.mapOptions.oceanColor);
+
+            // restore map rotate, scale, translate
+            this.restoreState();
+
+            // draw with current positions
+            this.geo.selectAll(".land").attr("d", this.geoPath);
+
+            this.initialized = true;
+            resolve();
+          }.bind(this)
+        );
+      }.bind(this)
+    );
+  }
+
+  setMapOpacity(opacity) {
+    opacity = opacity ? 1 : 0;
+    if (this.width && this.width < 768) opacity = 0;
+    if (this.geo) this.geo.style("opacity", opacity);
+  }
+  restoreState() {
+    this.projection.rotate([this.lastProjection.rotate, 0]);
+    this.projection.translate(this.lastProjection.translate);
+    this.projection.scale(this.lastProjection.scale);
+    this.zoom.scale(this.lastProjection.scale);
+    this.zoom.translate(this.lastProjection.translate);
+  }
+
+  // stop responding to pan/zoom events
+  cancelZoom() {
+    this.saveProjection();
+  }
+
+  // tell the svg to respond to mouse pan/zoom events
+  restartZoom() {
+    this.svg.call(this.zoom).on("dblclick.zoom", null);
+    this.restoreState();
+    this.last.scale = null;
+  }
+
+  getXY(lon, lat) {
+    return this.projection([lon, lat]);
+  }
+  getLonLat(x, y) {
+    return this.projection.invert([x, y]);
+  }
+
+  zoomed() {
+    if (
+      d3.event &&
+      !this.$scope.current_node &&
+      !this.$scope.mousedown_node &&
+      this.$scope.legendOptions.map.open
+    ) {
+      let scale = d3.event.scale,
+        t = d3.event.translate,
+        dx = t[0] - this.last.translate[0],
+        dy = t[1] - this.last.translate[1],
+        yaw = this.projection.rotate()[0],
+        tp = this.projection.translate();
+      // zoomed
+      if (scale !== this.last.scale) {
+        // get the mouse's x,y relative to the svg
+        let top = d3.select("#main_container").node().offsetTop;
+        let left = d3.select("#main_container").node().offsetLeft;
+        let mx = d3.event.sourceEvent.clientX - left;
+        let my = d3.event.sourceEvent.clientY - top - 1;
+
+        // get the lon,lat at the mouse position
+        let lonlat = this.projection.invert([mx, my]);
+
+        // do the requested scale operation
+        this.projection.scale(scale);
+
+        // get the lonlat that is under the mouse after the scale
+        let lonlat1 = this.projection.invert([mx, my]);
+        // calc the distance to rotate based on change in longitude
+        dx = lonlat1[0] - lonlat[0];
+        // calc the distance to translate based on change in lattitude
+        dy = my - this.projection([0, lonlat[1]])[1];
+
+        // rotate the map so that the longitude under the mouse is where it was before the scale
+        this.projection.rotate([yaw + dx, 0, 0]);
+
+        // translate the map so that the lattitude under the mouse is where it was before the scale
+        this.projection.translate([tp[0], tp[1] + dy]);
+      } else {
+        // rotate instead of translate in the x direction
+        this.projection.rotate([
+          yaw + (((360.0 * dx) / this.width) * this.scaleExtent[0]) / scale,
+          0,
+          0
+        ]);
+        // translate only in the y direction. don't translate beyond the max lattitude north or south
+        var bnorth = getMapBounds(this.projection, maxnorth),
+          bsouth = getMapBounds(this.projection, maxsouth);
+        if (bnorth[0][1] + dy > 0) dy = -bnorth[0][1];
+        else if (bsouth[1][1] + dy < this.height)
+          dy = this.height - bsouth[1][1];
+        this.projection.translate([tp[0], tp[1] + dy]);
+      }
+      this.last.scale = scale;
+      this.last.translate = t;
+      this.saveProjection();
+      this.notify();
+    }
+    // update the land path with our current projection
+    this.geo.selectAll(".land").attr("d", this.geoPath);
+  }
+  saveProjection() {
+    if (this.projection) {
+      this.lastProjection.rotate = this.projection.rotate()[0];
+      this.lastProjection.scale = this.projection.scale();
+      this.lastProjection.translate = this.projection.translate();
+      localStorage[MAPPOSITIONKEY] = JSON.stringify(this.lastProjection);
+    }
+  }
+}
+
+// find the top left and bottom right of current projection
+function getMapBounds(projection, maxlat) {
+  var yaw = projection.rotate()[0],
+    xymax = projection([-yaw + 180 - 1e-6, -maxlat]),
+    xymin = projection([-yaw - 180 + 1e-6, maxlat]);
+
+  return [xymin, xymax];
+}
diff --git a/console/react/src/topology/nodes.js b/console/react/src/topology/nodes.js
new file mode 100644
index 0000000..6919b2d
--- /dev/null
+++ b/console/react/src/topology/nodes.js
@@ -0,0 +1,504 @@
+/*
+Licensed to the Apache Software Foundation (ASF) under one
+or more contributor license agreements.  See the NOTICE file
+distributed with this work for additional information
+regarding copyright ownership.  The ASF licenses this file
+to you 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.
+*/
+
+import { utils } from "../amqp/utilities.js";
+
+/* global d3 Promise */
+export class Node {
+  constructor(
+    id,
+    name,
... 28223 lines suppressed ...


---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@qpid.apache.org
For additional commands, e-mail: commits-help@qpid.apache.org