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