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 2018/05/17 20:55:08 UTC

[1/4] qpid-dispatch git commit: DISPATCH-1002 Added optional message flow animation to topology page

Repository: qpid-dispatch
Updated Branches:
  refs/heads/master e9f8a852a -> 61df4890b


http://git-wip-us.apache.org/repos/asf/qpid-dispatch/blob/61df4890/console/stand-alone/plugin/js/topology/traffic.js
----------------------------------------------------------------------
diff --git a/console/stand-alone/plugin/js/topology/traffic.js b/console/stand-alone/plugin/js/topology/traffic.js
new file mode 100644
index 0000000..883cb2f
--- /dev/null
+++ b/console/stand-alone/plugin/js/topology/traffic.js
@@ -0,0 +1,278 @@
+/*
+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.
+*/
+'use strict';
+
+/* global d3 ChordData MIN_CHORD_THRESHOLD */
+
+/* Create animated dots moving along the links between routers
+   to show that there is message flow between routers.
+   */
+const transitionDuration = 1000;
+const CHORDFILTERKEY =  'chordFilter';
+
+function Traffic ($scope, $timeout, QDRService, converter, radius, topology, nextHop, excluded) {
+  this.QDRService = QDRService;
+  this.radius = radius;       // the radius of a router circle
+  this.topology = topology;   // contains the list of router nodes
+  this.nextHop = nextHop;     // fn that returns the route through the network between two routers
+  this.$scope = $scope;
+  this.$timeout = $timeout;
+  $scope.addressColors = {};
+  this.excludedAddresses = JSON.parse(localStorage[CHORDFILTERKEY]) || [];
+
+  // internal variables
+  this.interval = null;       // setInterval handle
+  this.lastFlows = {};        // the number of dots animated between routers
+  this.chordData = new ChordData(this.QDRService, true, converter); // gets ingressHistogram data
+  this.chordData.setFilter(this.excludedAddresses);
+  this.$scope.addresses = {};
+  this.chordData.getMatrix().then( function () {
+    $timeout( function () {
+      this.$scope.addresses = this.chordData.getAddresses();
+      for (let address in this.$scope.addresses) {
+        this.fillColor(address);
+      }
+    }.bind(this));
+  }.bind(this));
+}
+
+/* Public methods on Traffic object */
+
+// stop updating the traffic data
+Traffic.prototype.stop = function () {
+  if (this.interval) {
+    clearInterval(this.interval);
+    this.interval = null;
+  }
+};
+// start updating the traffic data
+Traffic.prototype.start = function () {
+  this.doUpdate(this);
+  this.interval = setInterval(this.doUpdate.bind(this), transitionDuration);
+};
+// remove any animationions that are in progress
+Traffic.prototype.remove = function () {
+  for (let id in this.lastFlows) {
+    d3.select('#SVG_ID').selectAll('circle.flow' + id).remove();
+  }
+  this.lastFlows = {};
+};
+// called when one of the address checkboxes is toggled
+Traffic.prototype.updateAddresses = function () {
+  this.excludedAddresses = [];
+  for (let address in this.$scope.addresses) {
+    if (!this.$scope.addresses[address])
+      this.excludedAddresses.push(address);
+  }
+  localStorage[CHORDFILTERKEY] = JSON.stringify(this.excludedAddresses);
+  if (this.chordData) 
+    this.chordData.setFilter(this.excludedAddresses);
+  // don't wait for the next polling cycle. update now
+  this.stop();
+  this.start();
+};
+Traffic.prototype.toggleAddress = function (address) {
+  this.$scope.addresses[address] = !this.$scope.addresses[address];
+  this.updateAddresses();
+};
+Traffic.prototype.fadeOtherAddresses = function (address) {
+  d3.selectAll('circle.flow').classed('fade', function(d) {
+    return d.address !== address;
+  });
+};
+Traffic.prototype.unFadeAll = function () {
+  d3.selectAll('circle.flow').classed('fade', false);
+};
+
+/* The following don't need to be public, but they are for simplicity sake */
+
+// called periodically to refresh the traffic flow
+Traffic.prototype.doUpdate = function () {
+  let self = this;
+  // we need the nextHop data to show traffic between routers that are connected by intermediaries
+  this.QDRService.management.topology.ensureAllEntities([{entity: 'router.node', attrs: ['id','nextHop']}], 
+    function () {
+      // get the ingressHistogram data for all routers
+      self.chordData.getMatrix().then(self.render.bind(self), function (e) {
+        console.log('Could not get message histogram' + e);
+      });
+    });
+};
+
+// calculate the translation for each dot along the path
+let translateDots = function (radius, path, count, back) {
+  let pnode = path.node();
+  // will be called for each element in the flow selection (for each dot)
+  return function(d) {
+    // will be called with t going from 0 to 1 for each dot
+    return function(t) {
+      // start the points at different positions depending on their value (d)
+      let tt = t * 1000;
+      let f = ((tt + (d.i*1000/count)) % 1000)/1000;
+      if (back)
+        f = 1 - f;
+      // l needs to be calculated each tick because the path's length might be changed during the animation
+      let l = pnode.getTotalLength();
+      let p = pnode.getPointAtLength(f * l);
+      return 'translate(' + p.x + ',' + p.y + ')';
+    };
+  };
+};
+// animate the d3 selection (flow) along the given path
+Traffic.prototype.animateFlow = function (flow, path, count, back, rate) {
+  let self = this;
+  let l = path.node().getTotalLength();
+  flow.transition()
+    .ease('easeLinear')
+    .duration(l*10/rate)
+    .attrTween('transform', translateDots(self.radius, path, count, back))
+    .each('end', function () {self.animateFlow(flow, path, count, back, rate);});
+};
+
+Traffic.prototype.render = function (matrix) {
+  this.$timeout(
+    function () {
+      this.$scope.addresses = this.chordData.getAddresses();
+    }.bind(this)
+  );
+  // get the rate of message flow between routers
+  let hops = {};  // every hop between routers that is involved in message flow
+  let matrixMessages = matrix.matrixMessages();
+  // the fastest traffic rate gets 3 times as many dots as the slowest
+  let minmax = matrix.getMinMax();
+  let flowScale = d3.scale.linear().domain(minmax).range([1,1.75]);
+
+  // row is ingress router, col is egress router. Value at [row][col] is the rate
+  matrixMessages.forEach( function (row, r) {
+    row.forEach(function (val, c) {
+      if (val > MIN_CHORD_THRESHOLD) {
+        // translate between matrix row/col and node index
+        let f = nodeIndexFor(this.topology.nodes, matrix.rows[r].egress);
+        let t = nodeIndexFor(this.topology.nodes, matrix.rows[r].ingress);
+        let address = matrix.getAddress(r, c);
+
+        if (r !== c) {
+          // move the dots along the links between the routers
+          this.nextHop(this.topology.nodes[f], this.topology.nodes[t], function (link, fnode, tnode) {
+            let key = '-' + link.uid;
+            let back = fnode.index < tnode.index;
+            if (!hops[key])
+              hops[key] = [];
+            hops[key].push({val: val, back: back, address: address});
+          });
+        }
+        // Find the senders connected to nodes[f] and the receivers connected to nodes[t]
+        // and add their links to the animation
+        addClients(hops, this.topology.nodes, f, val, true, address);
+        addClients(hops, this.topology.nodes, t, val, false, address);
+      }
+    }.bind(this));
+  }.bind(this));
+  // for each link between routers that has traffic, start an animation
+  let keep = {};
+  for (let id in hops) {
+    let hop = hops[id];
+    for (let h=0; h<hop.length; h++) {
+      let ahop = hop[h];
+      let flowId = id + '-' + addressIndex(this, ahop.address) + (ahop.back ? 'b' : '');
+      let path = d3.select('#path' + id);
+      // start the animation. If the animation is already running, this will have no effect
+      this.startDots(path, flowId, ahop, flowScale(ahop.val));
+      keep[flowId] = true;
+    }
+  }
+  // remove any existing animations that we don't have data for anymore
+  for (let id in this.lastFlows) {
+    if (this.lastFlows[id] && !keep[id]) {
+      this.lastFlows[id] = 0;
+      d3.select('#SVG_ID').selectAll('circle.flow' + id).remove();
+    }
+  }
+};
+
+// create dots along the path between routers
+Traffic.prototype.startDots = function (path, id, hop, rate) {
+  let back = hop.back, address = hop.address;
+  if (!path.node())
+    return;
+  // the density of dots is determined by the rate of this traffic relative to the other traffic
+  let len = Math.max(Math.floor(path.node().getTotalLength() / 50), 1);
+  let dots = [];
+  for (let i=0, offset=addressIndex(this, address); i<len; ++i) {
+    dots[i] = {i: i + 10 * offset, address: address};
+  }
+  // keep track of the number of dots for each link. If the length of the link is changed,
+  // re-create the animation
+  if (!this.lastFlows[id])
+    this.lastFlows[id] = len;
+  else {
+    if (this.lastFlows[id] !== len) {
+      this.lastFlows[id] = len;
+      d3.select('#SVG_ID').selectAll('circle.flow' + id).remove();
+    }
+  }
+  let flow = d3.select('#SVG_ID').selectAll('circle.flow' + id)
+    .data(dots, function (d) { return d.i + d.address; });
+
+  let circles = flow
+    .enter().append('circle')
+    .attr('class', 'flow flow' + id)
+    .attr('fill', this.fillColor(address))
+    .attr('r', 5);
+
+  this.animateFlow(circles, path, dots.length, back, rate);
+
+  flow.exit()
+    .remove();
+};
+
+// colors
+let colorGen = d3.scale.category10();
+Traffic.prototype.fillColor = function (n) {
+  if (!(n in this.$scope.addressColors)) {
+    let ci = Object.keys(this.$scope.addressColors).length;
+    this.$scope.addressColors[n] = colorGen(ci);
+  }
+  return this.$scope.addressColors[n];
+};
+// return the node index for a router name
+let nodeIndexFor = function (nodes, name) {
+  for (let i=0; i<nodes.length; i++) {
+    let node = nodes[i];
+    if (node.routerId === name)
+      return i;
+  }
+  return -1;
+};
+let addClients = function (hops, nodes, f, val, sender, address) {
+  let cdir = sender ? 'out' : 'in';
+  for (let n=0; n<nodes.length; n++) {
+    let node = nodes[n];
+    if (node.normals && node.key === nodes[f].key && node.cdir === cdir) {
+      let key = ['',f,n].join('-');
+      if (!hops[key])
+        hops[key] = [];
+      hops[key].push({val: val, back: node.cdir === 'in', address: address});
+      return;
+    }
+  }
+};
+let addressIndex = function (traffic, address) {
+  return Object.keys(traffic.$scope.addresses).indexOf(address);
+};
\ No newline at end of file


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


[3/4] qpid-dispatch git commit: DISPATCH-1002 Added optional message flow animation to topology page

Posted by ea...@apache.org.
http://git-wip-us.apache.org/repos/asf/qpid-dispatch/blob/61df4890/console/stand-alone/plugin/js/qdrTopology.js
----------------------------------------------------------------------
diff --git a/console/stand-alone/plugin/js/qdrTopology.js b/console/stand-alone/plugin/js/qdrTopology.js
deleted file mode 100644
index cd0cdf2..0000000
--- a/console/stand-alone/plugin/js/qdrTopology.js
+++ /dev/null
@@ -1,2098 +0,0 @@
-/*
-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.
-*/
-'use strict';
-
-/* global angular d3 */
-/**
- * @module QDR
- */
-var QDR = (function(QDR) {
-
-  QDR.module.controller('QDR.TopologyFormController', function($scope, $timeout) {
-
-    $scope.attributes = [];
-    $scope.attributesConnections = [];
-
-    $scope.form = 'Router';
-    $scope.$on('showEntityForm', function(event, args) {
-      let attributes = args.attributes;
-      // capitalize 1st letter
-      $scope.form = args.entity.charAt(0).toUpperCase() + args.entity.slice(1);
-
-      let H = '<div class="infoGrid">';
-      attributes.forEach( function (a, i) {
-        let even = (i % 2) ? 'even' : 'odd';
-        if (a.attributeName === 'Listening on')
-          even += ' listening-on';
-        H += ('<div class="'+ even +'"><span title="'+a.attributeName+'">' + a.attributeName + '</span><span title="'+a.attributeValue+'">' + a.attributeValue + '</span></div>');
-      });
-      H += '</div>';
-      $('#formInfo').html(H);
-
-      if (!$scope.$$phase) $scope.$apply();
-
-    });
-    $scope.infoStyle = function () {
-      return {
-        height: (Math.max($scope.attributes.length, 15) * 30 + 46) + 'px'
-      };
-    };
-
-    $scope.panelVisible = true;  // show/hide the panel on the left
-    $scope.hideLeftPane = function () {
-      d3.select('.page-menu')
-        .style('left' , '-360px')
-        .style('z-index', '1');
-
-      d3.select('.diagram')
-        .transition().duration(300).ease('sin-in')
-        .style('margin-left', '-360px')
-        .each('end', function () {
-          $timeout(function () {$scope.panelVisible = false;});
-          let div = d3.select(this);
-          div.style('margin-left', '0');
-          div.style('padding-left', 0);
-        });
-    };
-    $scope.showLeftPane = function () {
-      d3.select('.page-menu')
-        .style('left' , '0px');
-
-      $timeout(function () {$scope.panelVisible = true;});
-      d3.select('.diagram')
-        .style('margin-left', '0px')
-        .transition().duration(300).ease('sin-out')
-        .style('margin-left', '300px')
-        .each('end', function () {
-          let div = d3.select(this);
-          div.style('margin-left', '0');
-          div.style('padding-left', '300px');
-        });
-    };
-
-
-  });
-  /**
-   * @method TopologyController
-   *
-   * Controller that handles the QDR topology page
-   */
-  QDR.module.controller('QDR.TopologyController', ['$scope', '$rootScope', 'QDRService', '$location', '$timeout', '$uibModal', '$sce',
-    function($scope, $rootScope, QDRService, $location, $timeout, $uibModal, $sce) {
-
-      $scope.multiData = [];
-      $scope.quiesceState = {};
-      let dontHide = false;
-      $scope.crosshtml = $sce.trustAsHtml('');
-
-      $scope.quiesceConnection = function(row) {
-        let entity = row.entity;
-        let state = $scope.quiesceState[entity.connectionId].state;
-        if (state === 'enabled') {
-          // start quiescing all links
-          $scope.quiesceState[entity.connectionId].state = 'quiescing';
-        } else if (state === 'quiesced') {
-          // start reviving all links
-          $scope.quiesceState[entity.connectionId].state = 'reviving';
-        }
-        $scope.multiDetails.updateState(entity);
-        dontHide = true;
-        $scope.multiDetails.selectRow(row.rowIndex, true);
-        $scope.multiDetails.showLinksList(row);
-      };
-      $scope.quiesceDisabled = function(row) {
-        return $scope.quiesceState[row.entity.connectionId].buttonDisabled;
-      };
-      $scope.quiesceText = function(row) {
-        return $scope.quiesceState[row.entity.connectionId].buttonText;
-      };
-      $scope.quiesceClass = function(row) {
-        const stateClassMap = {
-          enabled: 'btn-primary',
-          quiescing: 'btn-warning',
-          reviving: 'btn-warning',
-          quiesced: 'btn-danger'
-        };
-        return stateClassMap[$scope.quiesceState[row.entity.connectionId].state];
-      };
-
-      // This is the grid that shows each connection when a client node that represents multiple connections is clicked
-      $scope.multiData = [];
-      $scope.multiDetails = {
-        data: 'multiData',
-        enableColumnResize: true,
-        enableHorizontalScrollbar: 0,
-        enableVerticalScrollbar: 0,
-        jqueryUIDraggable: true,
-        enablePaging: false,
-        multiSelect: false,
-        enableSelectAll: false,
-        enableSelectionBatchEvent: false,
-        enableRowHeaderSelection: false,
-        noUnselect: true,
-        onRegisterApi: function (gridApi) {
-          if (gridApi.selection) {
-            gridApi.selection.on.rowSelectionChanged($scope, function(row){
-              let detailsDiv = d3.select('#link_details');
-              let isVis = detailsDiv.style('display') === 'block';
-              if (!dontHide && isVis && $scope.connectionId === row.entity.connectionId) {
-                hideLinkDetails();
-                return;
-              }
-              dontHide = false;
-              $scope.multiDetails.showLinksList(row);
-            });
-          }
-        },
-        showLinksList: function(obj) {
-          $scope.linkData = obj.entity.linkData;
-          $scope.connectionId = obj.entity.connectionId;
-          let visibleLen = Math.min(obj.entity.linkData.length, 10);
-          //QDR.log.debug("visibleLen is " + visibleLen)
-          let left = parseInt(d3.select('#multiple_details').style('left'), 10);
-          let offset = $('#topology').offset();
-          let detailsDiv = d3.select('#link_details');
-          detailsDiv
-            .style({
-              display: 'block',
-              opacity: 1,
-              left: (left + 20) + 'px',
-              top: (mouseY - offset.top + 20 + $(document).scrollTop()) + 'px',
-              height: ((visibleLen + 1) * 30) + 40 + 'px', // +1 for the header row
-              'overflow-y': obj.entity.linkData > 10 ? 'scroll' : 'hidden'
-            });
-        },
-        updateState: function(entity) {
-          let state = $scope.quiesceState[entity.connectionId].state;
-
-          // count enabled and disabled links for this connection
-          let enabled = 0,
-            disabled = 0;
-          entity.linkData.forEach(function(link) {
-            if (link.adminStatus === 'enabled')
-              ++enabled;
-            if (link.adminStatus === 'disabled')
-              ++disabled;
-          });
-
-          let linkCount = entity.linkData.length;
-          // if state is quiescing and any links are enabled, button should say 'Quiescing' and be disabled
-          if (state === 'quiescing' && (enabled > 0)) {
-            $scope.quiesceState[entity.connectionId].buttonText = 'Quiescing';
-            $scope.quiesceState[entity.connectionId].buttonDisabled = true;
-          } else
-          // if state is enabled and all links are disabled, button should say Revive and be enabled. set state to quisced
-          // if state is quiescing and all links are disabled, button should say 'Revive' and be enabled. set state to quiesced
-          if ((state === 'quiescing' || state === 'enabled') && (disabled === linkCount)) {
-            $scope.quiesceState[entity.connectionId].buttonText = 'Revive';
-            $scope.quiesceState[entity.connectionId].buttonDisabled = false;
-            $scope.quiesceState[entity.connectionId].state = 'quiesced';
-          } else
-          // if state is reviving and any links are disabled, button should say 'Reviving' and be disabled
-          if (state === 'reviving' && (disabled > 0)) {
-            $scope.quiesceState[entity.connectionId].buttonText = 'Reviving';
-            $scope.quiesceState[entity.connectionId].buttonDisabled = true;
-          } else
-          // if state is reviving or quiesced and all links are enabled, button should say 'Quiesce' and be enabled. set state to enabled
-          if ((state === 'reviving' || state === 'quiesced') && (enabled === linkCount)) {
-            $scope.quiesceState[entity.connectionId].buttonText = 'Quiesce';
-            $scope.quiesceState[entity.connectionId].buttonDisabled = false;
-            $scope.quiesceState[entity.connectionId].state = 'enabled';
-          }
-        },
-        columnDefs: [{
-          field: 'host',
-          cellTemplate: 'titleCellTemplate.html',
-          //headerCellTemplate: 'titleHeaderCellTemplate.html',
-          displayName: 'Connection host'
-        }, {
-          field: 'user',
-          cellTemplate: 'titleCellTemplate.html',
-          //headerCellTemplate: 'titleHeaderCellTemplate.html',
-          displayName: 'User'
-        }, {
-          field: 'properties',
-          cellTemplate: 'titleCellTemplate.html',
-          //headerCellTemplate: 'titleHeaderCellTemplate.html',
-          displayName: 'Properties'
-        }
-          /*,
-                {
-                  cellClass: 'gridCellButton',
-                  cellTemplate: '<button title="{{quiesceText(row)}} the links" type="button" ng-class="quiesceClass(row)" class="btn" ng-click="$event.stopPropagation();quiesceConnection(row)" ng-disabled="quiesceDisabled(row)">{{quiesceText(row)}}</button>'
-                }*/
-        ]
-      };
-      $scope.quiesceLinkClass = function(row) {
-        const stateClassMap = {
-          enabled: 'btn-primary',
-          disabled: 'btn-danger'
-        };
-        return stateClassMap[row.entity.adminStatus];
-      };
-      $scope.quiesceLink = function(row) {
-        QDRService.management.topology.quiesceLink(row.entity.nodeId, row.entity.name)
-          .then( function (results, context) {
-            let statusCode = context.message.application_properties.statusCode;
-            if (statusCode < 200 || statusCode >= 300) {
-              QDR.Core.notification('error', context.message.statusDescription);
-              QDR.log.info('Error ' + context.message.statusDescription);
-            }
-          });
-      };
-      $scope.quiesceLinkDisabled = function(row) {
-        return (row.entity.operStatus !== 'up' && row.entity.operStatus !== 'down');
-      };
-      $scope.quiesceLinkText = function(row) {
-        return row.entity.operStatus === 'down' ? 'Revive' : 'Quiesce';
-      };
-      $scope.linkData = [];
-      $scope.linkDetails = {
-        data: 'linkData',
-        jqueryUIDraggable: true,
-        columnDefs: [{
-          field: 'adminStatus',
-          cellTemplate: 'titleCellTemplate.html',
-          headerCellTemplate: 'titleHeaderCellTemplate.html',
-          displayName: 'Admin state'
-        }, {
-          field: 'operStatus',
-          cellTemplate: 'titleCellTemplate.html',
-          headerCellTemplate: 'titleHeaderCellTemplate.html',
-          displayName: 'Oper state'
-        }, {
-          field: 'dir',
-          cellTemplate: 'titleCellTemplate.html',
-          headerCellTemplate: 'titleHeaderCellTemplate.html',
-          displayName: 'dir'
-        }, {
-          field: 'owningAddr',
-          cellTemplate: 'titleCellTemplate.html',
-          headerCellTemplate: 'titleHeaderCellTemplate.html',
-          displayName: 'Address'
-        }, {
-          field: 'deliveryCount',
-          displayName: 'Delivered',
-          headerCellTemplate: 'titleHeaderCellTemplate.html',
-          cellClass: 'grid-values'
-
-        }, {
-          field: 'uncounts',
-          displayName: 'Outstanding',
-          headerCellTemplate: 'titleHeaderCellTemplate.html',
-          cellClass: 'grid-values'
-        }
-          /*,
-                {
-                  cellClass: 'gridCellButton',
-                  cellTemplate: '<button title="{{quiesceLinkText(row)}} this link" type="button" ng-class="quiesceLinkClass(row)" class="btn" ng-click="quiesceLink(row)" ng-disabled="quiesceLinkDisabled(row)">{{quiesceLinkText(row)}}</button>'
-                }*/
-        ]
-      };
-
-      let urlPrefix = $location.absUrl();
-      urlPrefix = urlPrefix.split('#')[0];
-      QDR.log.debug('started QDR.TopologyController with urlPrefix: ' + urlPrefix);
-
-      // mouse event vars
-      let selected_node = null,
-        selected_link = null,
-        mousedown_link = null,
-        mousedown_node = null,
-        mouseover_node = null,
-        mouseup_node = null,
-        initial_mouse_down_position = null;
-
-      $scope.schema = 'Not connected';
-
-      $scope.contextNode = null; // node that is associated with the current context menu
-      $scope.isRight = function(mode) {
-        return mode.right;
-      };
-
-      var setNodesFixed = function (name, b) {
-        nodes.some(function (n) {
-          if (n.name === name) {
-            n.fixed = b;
-            return true;
-          }
-        });
-      };
-      $scope.setFixed = function(b) {
-        if ($scope.contextNode) {
-          $scope.contextNode.fixed = b;
-          setNodesFixed($scope.contextNode.name, b);
-          savePositions();
-        }
-        restart();
-      };
-      $scope.isFixed = function() {
-        if (!$scope.contextNode)
-          return false;
-        return ($scope.contextNode.fixed & 1);
-      };
-
-      let mouseX, mouseY;
-      var relativeMouse = function () {
-        let offset = $('#main_container').offset();
-        return {left: (mouseX + $(document).scrollLeft()) - 1,
-          top: (mouseY  + $(document).scrollTop()) - 1,
-          offset: offset
-        };
-      };
-      // event handlers for popup context menu
-      $(document).mousemove(function(e) {
-        mouseX = e.clientX;
-        mouseY = e.clientY;
-      });
-      $(document).mousemove();
-      $(document).click(function() {
-        $scope.contextNode = null;
-        $('.contextMenu').fadeOut(200);
-      });
-
-      const radii = {
-        'inter-router': 25,
-        'normal': 15,
-        'on-demand': 15,
-        'route-container': 15,
-      };
-      let radius = 25;
-      let radiusNormal = 15;
-      let svg, lsvg;
-      let force;
-      let animate = false; // should the force graph organize itself when it is displayed
-      let path, circle;
-      let savedKeys = {};
-      let width = 0;
-      let height = 0;
-
-      var getSizes = function() {
-        let legendWidth = 143;
-        let display = $('#svg_legend').css('display');
-        if (display === 'none')
-          legendWidth = 0;
-        const gap = 5;
-        let width = $('#topology').width() - gap - legendWidth;
-        let top = $('#topology').offset().top;
-        let tpformHeight = $('#topologyForm').height();
-        let height = Math.max(window.innerHeight, tpformHeight + top) - top - gap;
-        if (width < 10) {
-          QDR.log.info('page width and height are abnormal w:' + width + ' height:' + height);
-          return [0, 0];
-        }
-        return [width, height];
-      };
-      var resize = function() {
-        if (!svg)
-          return;
-        let sizes = getSizes();
-        width = sizes[0];
-        height = sizes[1];
-        if (width > 0) {
-          // set attrs and 'resume' force
-          svg.attr('width', width);
-          svg.attr('height', height);
-          force.size(sizes).resume();
-        }
-      };
-
-      $scope.$on('panel-resized', function () {
-        resize();
-      });
-      window.addEventListener('resize', resize);
-      let sizes = getSizes();
-      width = sizes[0];
-      height = sizes[1];
-      if (width <= 0 || height <= 0)
-        return;
-
-      // set up initial nodes and links
-      //  - nodes are known by 'id', not by index in array.
-      //  - selected edges are indicated on the node (as a bold red circle).
-      //  - links are always source < target; edge directions are set by 'left' and 'right'.
-      let nodes = [];
-      let links = [];
-
-      var nodeExists = function (connectionContainer) {
-        return nodes.findIndex( function (node) {
-          return node.container === connectionContainer;
-        });
-      };
-      var normalExists = function (connectionContainer) {
-        let normalInfo = {};
-        for (let i=0; i<nodes.length; ++i) {
-          if (nodes[i].normals) {
-            if (nodes[i].normals.some(function (normal, j) {
-              if (normal.container === connectionContainer && i !== j) {
-                normalInfo = {nodesIndex: i, normalsIndex: j};
-                return true;
-              }
-              return false;
-            }))
-              break;
-          }
-        }
-        return normalInfo;
-      };
-      var getLinkSource = function (nodesIndex) {
-        for (let i=0; i<links.length; ++i) {
-          if (links[i].target === nodesIndex)
-            return i;
-        }
-        return -1;
-      };
-      var aNode = function(id, name, nodeType, nodeInfo, nodeIndex, x, y, connectionContainer, resultIndex, fixed, properties) {
-        properties = properties || {};
-        for (let i=0; i<nodes.length; ++i) {
-          if (nodes[i].name === name || nodes[i].container === connectionContainer) {
-            if (properties.product)
-              nodes[i].properties = properties;
-            return nodes[i];
-          }
-        }
-        let routerId = QDRService.management.topology.nameFromId(id);
-        return {
-          key: id,
-          name: name,
-          nodeType: nodeType,
-          properties: properties,
-          routerId: routerId,
-          x: x,
-          y: y,
-          id: nodeIndex,
-          resultIndex: resultIndex,
-          fixed: !!+fixed,
-          cls: '',
-          container: connectionContainer
-        };
-      };
-
-      var getLinkDir = function (id, connection, onode) {
-        let links = onode['router.link'];
-        if (!links) {
-          return 'unknown';
-        }
-        let inCount = 0, outCount = 0;
-        links.results.forEach( function (linkResult) {
-          let link = QDRService.utilities.flatten(links.attributeNames, linkResult);
-          if (link.linkType === 'endpoint' && link.connectionId === connection.identity)
-            if (link.linkDir === 'in')
-              ++inCount;
-            else
-              ++outCount;
-        });
-        if (inCount > 0 && outCount > 0)
-          return 'both';
-        if (inCount > 0)
-          return 'in';
-        if (outCount > 0)
-          return 'out';
-        return 'unknown';
-      };
-
-      var savePositions = function () {
-        nodes.forEach( function (d) {
-          localStorage[d.name] = angular.toJson({
-            x: Math.round(d.x),
-            y: Math.round(d.y),
-            fixed: (d.fixed & 1) ? 1 : 0,
-          });
-        });
-      };
-
-      var initializeNodes = function (nodeInfo) {
-        let nodeCount = Object.keys(nodeInfo).length;
-        let yInit = 50;
-        nodes = [];
-        for (let id in nodeInfo) {
-          let name = QDRService.management.topology.nameFromId(id);
-          // if we have any new nodes, animate the force graph to position them
-          let position = angular.fromJson(localStorage[name]);
-          if (!angular.isDefined(position)) {
-            animate = true;
-            position = {
-              x: Math.round(width / 4 + ((width / 2) / nodeCount) * nodes.length),
-              y: Math.round(height / 2 + Math.sin(nodes.length / (Math.PI*2.0)) * height / 4),
-              fixed: false,
-            };
-            //QDR.log.debug("new node pos (" + position.x + ", " + position.y + ")")
-          }
-          if (position.y > height) {
-            position.y = 200 - yInit;
-            yInit *= -1;
-          }
-          nodes.push(aNode(id, name, 'inter-router', nodeInfo, nodes.length, position.x, position.y, name, undefined, position.fixed));
-        }
-      };
-
-      var initializeLinks = function (nodeInfo, unknowns) {
-        links = [];
-        let source = 0;
-        let client = 1.0;
-        for (let id in nodeInfo) {
-          let onode = nodeInfo[id];
-          if (!onode['connection'])
-            continue;
-          let conns = onode['connection'].results;
-          let attrs = onode['connection'].attributeNames;
-          //QDR.log.debug("external client parent is " + parent);
-          let normalsParent = {}; // 1st normal node for this parent
-
-          for (let j = 0; j < conns.length; j++) {
-            let connection = QDRService.utilities.flatten(attrs, conns[j]);
-            let role = connection.role;
-            let properties = connection.properties || {};
-            let dir = connection.dir;
-            if (role == 'inter-router') {
-              let connId = connection.container;
-              let target = getContainerIndex(connId, nodeInfo);
-              if (target >= 0) {
-                getLink(source, target, dir, '', source + '-' + target);
-              }
-            } /* else if (role == "normal" || role == "on-demand" || role === "route-container")*/ {
-              // not an connection between routers, but an external connection
-              let name = QDRService.management.topology.nameFromId(id) + '.' + connection.identity;
-
-              // if we have any new clients, animate the force graph to position them
-              let position = angular.fromJson(localStorage[name]);
-              if (!angular.isDefined(position)) {
-                animate = true;
-                position = {
-                  x: Math.round(nodes[source].x + 40 * Math.sin(client / (Math.PI * 2.0))),
-                  y: Math.round(nodes[source].y + 40 * Math.cos(client / (Math.PI * 2.0))),
-                  fixed: false
-                };
-                //QDR.log.debug("new client pos (" + position.x + ", " + position.y + ")")
-              }// else QDR.log.debug("using previous location")
-              if (position.y > height) {
-                position.y = Math.round(nodes[source].y + 40 + Math.cos(client / (Math.PI * 2.0)));
-              }
-              let existingNodeIndex = nodeExists(connection.container);
-              let normalInfo = normalExists(connection.container);
-              let node = aNode(id, name, role, nodeInfo, nodes.length, position.x, position.y, connection.container, j, position.fixed, properties);
-              let nodeType = QDRService.utilities.isAConsole(properties, connection.identity, role, node.key) ? 'console' : 'client';
-              let cdir = getLinkDir(id, connection, onode);
-              if (existingNodeIndex >= 0) {
-                // make a link between the current router (source) and the existing node
-                getLink(source, existingNodeIndex, dir, 'small', connection.name);
-              } else if (normalInfo.nodesIndex) {
-                // get node index of node that contained this connection in its normals array
-                let normalSource = getLinkSource(normalInfo.nodesIndex);
-                if (normalSource >= 0) {
-                  if (cdir === 'unknown')
-                    cdir = dir;
-                  node.cdir = cdir;
-                  nodes.push(node);
-                  // create link from original node to the new node
-                  getLink(links[normalSource].source, nodes.length-1, cdir, 'small', connection.name);
-                  // create link from this router to the new node
-                  getLink(source, nodes.length-1, cdir, 'small', connection.name);
-                  // remove the old node from the normals list
-                  nodes[normalInfo.nodesIndex].normals.splice(normalInfo.normalsIndex, 1);
-                }
-              } else if (role === 'normal') {
-              // normal nodes can be collapsed into a single node if they are all the same dir
-                if (cdir !== 'unknown') {
-                  node.user = connection.user;
-                  node.isEncrypted = connection.isEncrypted;
-                  node.host = connection.host;
-                  node.connectionId = connection.identity;
-                  node.cdir = cdir;
-                  // determine arrow direction by using the link directions
-                  if (!normalsParent[nodeType+cdir]) {
-                    normalsParent[nodeType+cdir] = node;
-                    nodes.push(node);
-                    node.normals = [node];
-                    // now add a link
-                    getLink(source, nodes.length - 1, cdir, 'small', connection.name);
-                    client++;
-                  } else {
-                    normalsParent[nodeType+cdir].normals.push(node);
-                  }
-                } else {
-                  node.id = nodes.length - 1 + unknowns.length;
-                  unknowns.push(node);
-                }
-              } else {
-                nodes.push(node);
-                // now add a link
-                getLink(source, nodes.length - 1, dir, 'small', connection.name);
-                client++;
-              }
-            }
-          }
-          source++;
-        }
-      };
-
-      // vary the following force graph attributes based on nodeCount
-      // <= 6 routers returns min, >= 80 routers returns max, interpolate linearly
-      var forceScale = function(nodeCount, min, max) {
-        let count = nodeCount;
-        if (nodeCount < 6) count = 6;
-        if (nodeCount > 80) count = 80;
-        let x = d3.scale.linear()
-          .domain([6,80])
-          .range([min, max]);
-        //QDR.log.debug("forceScale(" + nodeCount + ", " + min + ", " + max + "  returns " + x(count) + " " + x(nodeCount))
-        return x(count);
-      };
-      var linkDistance = function (d, nodeCount) {
-        if (d.target.nodeType === 'inter-router')
-          return forceScale(nodeCount, 150, 70);
-        return forceScale(nodeCount, 75, 40);
-      };
-      var charge = function (d, nodeCount) {
-        if (d.nodeType === 'inter-router')
-          return forceScale(nodeCount, -1800, -900);
-        return -900;
-      };
-      var gravity = function (d, nodeCount) {
-        return forceScale(nodeCount, 0.0001, 0.1);
-      };
-      // initialize the nodes and links array from the QDRService.topology._nodeInfo object
-      var initForceGraph = function() {
-        nodes = [];
-        links = [];
-        let nodeInfo = QDRService.management.topology.nodeInfo();
-        let nodeCount = Object.keys(nodeInfo).length;
-
-        let oldSelectedNode = selected_node;
-        let oldMouseoverNode = mouseover_node;
-        mouseover_node = null;
-        selected_node = null;
-        selected_link = null;
-
-        savePositions();
-        d3.select('#SVG_ID').remove();
-        svg = d3.select('#topology')
-          .append('svg')
-          .attr('id', 'SVG_ID')
-          .attr('width', width)
-          .attr('height', height)
-          .on('click', function() {
-            removeCrosssection();
-          });
-
-        $(document).keyup(function(e) {
-          if (e.keyCode === 27) {
-            removeCrosssection();
-          }
-        });
-
-        // the legend
-        d3.select('#svg_legend svg').remove();
-        lsvg = d3.select('#svg_legend')
-          .append('svg')
-          .attr('id', 'svglegend');
-        lsvg = lsvg.append('svg:g')
-          .attr('transform', 'translate(' + (radii['inter-router'] + 2) + ',' + (radii['inter-router'] + 2) + ')')
-          .selectAll('g');
-
-        // mouse event vars
-        mousedown_link = null;
-        mousedown_node = null;
-        mouseup_node = null;
-
-        // initialize the list of nodes
-        initializeNodes(nodeInfo);
-        savePositions();
-
-        // initialize the list of links
-        let unknowns = [];
-        initializeLinks(nodeInfo, unknowns);
-        $scope.schema = QDRService.management.schema();
-        // init D3 force layout
-        force = d3.layout.force()
-          .nodes(nodes)
-          .links(links)
-          .size([width, height])
-          .linkDistance(function(d) { return linkDistance(d, nodeCount); })
-          .charge(function(d) { return charge(d, nodeCount); })
-          .friction(.10)
-          .gravity(function(d) { return gravity(d, nodeCount); })
-          .on('tick', tick)
-          .on('end', function () {savePositions();})
-          .start();
-
-        svg.append('svg:defs').selectAll('marker')
-          .data(['end-arrow', 'end-arrow-selected', 'end-arrow-small', 'end-arrow-highlighted']) // Different link/path types can be defined here
-          .enter().append('svg:marker') // This section adds in the arrows
-          .attr('id', String)
-          .attr('viewBox', '0 -5 10 10')
-          .attr('markerWidth', 4)
-          .attr('markerHeight', 4)
-          .attr('orient', 'auto')
-          .classed('small', function (d) {return d.indexOf('small') > -1;})
-          .append('svg:path')
-          .attr('d', 'M 0 -5 L 10 0 L 0 5 z');
-
-        svg.append('svg:defs').selectAll('marker')
-          .data(['start-arrow', 'start-arrow-selected', 'start-arrow-small', 'start-arrow-highlighted']) // Different link/path types can be defined here
-          .enter().append('svg:marker') // This section adds in the arrows
-          .attr('id', String)
-          .attr('viewBox', '0 -5 10 10')
-          .attr('refX', 5)
-          .attr('markerWidth', 4)
-          .attr('markerHeight', 4)
-          .attr('orient', 'auto')
-          .append('svg:path')
-          .attr('d', 'M 10 -5 L 0 0 L 10 5 z');
-
-        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');
-
-        // handles to link and node element groups
-        path = svg.append('svg:g').selectAll('path'),
-        circle = svg.append('svg:g').selectAll('g');
-
-        // app starts here
-        restart(false);
-        force.start();
-        if (oldSelectedNode) {
-          d3.selectAll('circle.inter-router').classed('selected', function (d) {
-            if (d.key === oldSelectedNode.key) {
-              selected_node = d;
-              return true;
-            }
-            return false;
-          });
-        }
-        if (oldMouseoverNode && selected_node) {
-          d3.selectAll('circle.inter-router').each(function (d) {
-            if (d.key === oldMouseoverNode.key) {
-              mouseover_node = d;
-              QDRService.management.topology.ensureAllEntities([{entity: 'router.node', attrs: ['id','nextHop']}], function () {
-                nextHop(selected_node, d);
-                restart();
-              });
-            }
-          });
-        }
-        setTimeout(function () {
-          updateForm(Object.keys(QDRService.management.topology.nodeInfo())[0], 'router', 0);
-        });
-
-        // if any clients don't yet have link directions, get the links for those nodes and restart the graph
-        if (unknowns.length > 0)
-          setTimeout(resolveUnknowns, 10, nodeInfo, unknowns);
-
-        var continueForce = function (extra) {
-          if (extra > 0) {
-            --extra;
-            force.start();
-            setTimeout(continueForce, 100, extra);
-          }
-        };
-        continueForce(forceScale(nodeCount, 0, 200));  // give graph time to settle down
-      };
-
-      var resolveUnknowns = function (nodeInfo, unknowns) {
-        let unknownNodes = {};
-        // collapse the unknown node.keys using an object
-        for (let i=0; i<unknowns.length; ++i) {
-          unknownNodes[unknowns[i].key] = 1;
-        }
-        unknownNodes = Object.keys(unknownNodes);
-        //QDR.log.info("-- resolveUnknowns: ensuring .connection and .router.link are present for each node")
-        QDRService.management.topology.ensureEntities(unknownNodes, [{entity: 'connection', force: true}, {entity: 'router.link', attrs: ['linkType','connectionId','linkDir'], force: true}], function () {
-          nodeInfo = QDRService.management.topology.nodeInfo();
-          initializeLinks(nodeInfo, []);
-          // collapse any router-container nodes that are duplicates
-          animate = true;
-          force.nodes(nodes).links(links).start();
-          restart(false);
-        });
-      };
-
-      function updateForm(key, entity, resultIndex) {
-        if (!angular.isDefined(resultIndex))
-          return;
-        let nodeList = QDRService.management.topology.nodeIdList();
-        if (nodeList.indexOf(key) > -1) {
-          QDRService.management.topology.fetchEntities(key, [
-            {entity: entity},
-            {entity: 'listener', attrs: ['role', 'port']}], function (results) {
-            let onode = results[key];
-            if (!onode[entity]) {
-              console.log('requested ' + entity + ' but didn\'t get it');
-              return;
-            }
-            let nodeResults = onode[entity].results[resultIndex];
-            let nodeAttributes = onode[entity].attributeNames;
-            let attributes = nodeResults.map(function(row, i) {
-              return {
-                attributeName: nodeAttributes[i],
-                attributeValue: row
-              };
-            });
-            // sort by attributeName
-            attributes.sort(function(a, b) {
-              return a.attributeName.localeCompare(b.attributeName);
-            });
-
-            // move the Name first
-            let nameIndex = attributes.findIndex(function(attr) {
-              return attr.attributeName === 'name';
-            });
-            if (nameIndex >= 0)
-              attributes.splice(0, 0, attributes.splice(nameIndex, 1)[0]);
-
-            // get the list of ports this router is listening on
-            if (entity === 'router') {
-              let listeners = onode['listener'].results;
-              let listenerAttributes = onode['listener'].attributeNames;
-              let normals = listeners.filter(function(listener) {
-                return QDRService.utilities.valFor(listenerAttributes, listener, 'role') === 'normal';
-              });
-              let ports = [];
-              normals.forEach(function(normalListener) {
-                ports.push(QDRService.utilities.valFor(listenerAttributes, normalListener, 'port'));
-              });
-              // add as 2nd row
-              if (ports.length) {
-                attributes.splice(1, 0, {
-                  attributeName: 'Listening on',
-                  attributeValue: ports,
-                  description: 'The port(s) on which this router is listening for connections'
-                });
-              }
-            }
-            $rootScope.$broadcast('showEntityForm', {
-              entity: entity,
-              attributes: attributes
-            });
-            if (!$scope.$$phase) $scope.$apply();
-          });
-        }
-      }
-
-      function getContainerIndex(_id, nodeInfo) {
-        let nodeIndex = 0;
-        for (let id in nodeInfo) {
-          if (QDRService.management.topology.nameFromId(id) === _id)
-            return nodeIndex;
-          ++nodeIndex;
-        }
-        return -1;
-      }
-
-      function getLink(_source, _target, dir, cls, uid) {
-        for (let i = 0; i < links.length; i++) {
-          let s = links[i].source,
-            t = links[i].target;
-          if (typeof 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;
-          }
-        }
-        //QDR.log.debug("creating new link (" + (links.length) + ") between " + nodes[_source].name + " and " + nodes[_target].name);
-        if (links.some( function (l) { return l.uid === uid;}))
-          uid = uid + '.' + links.length;
-        let link = {
-          source: _source,
-          target: _target,
-          left: dir != 'out',
-          right: (dir == 'out' || dir == 'both'),
-          cls: cls,
-          uid: uid,
-        };
-        return links.push(link) - 1;
-      }
-
-
-      function resetMouseVars() {
-        mousedown_node = null;
-        mouseover_node = null;
-        mouseup_node = null;
-        mousedown_link = null;
-      }
-
-      // update force layout (called automatically each iteration)
-      function tick() {
-        circle.attr('transform', function(d) {
-          let cradius;
-          if (d.nodeType == 'inter-router') {
-            cradius = d.left ? radius + 8 : radius;
-          } else {
-            cradius = d.left ? radiusNormal + 18 : radiusNormal;
-          }
-          d.x = Math.max(d.x, radiusNormal * 2);
-          d.y = Math.max(d.y, radiusNormal * 2);
-          d.x = Math.max(0, Math.min(width - cradius, d.x));
-          d.y = Math.max(0, Math.min(height - cradius, d.y));
-          return 'translate(' + d.x + ',' + d.y + ')';
-        });
-
-        // draw directed edges with proper padding from node centers
-        path.attr('d', function(d) {
-          let sourcePadding, targetPadding, r;
-
-          if (d.target.nodeType == 'inter-router') {
-            r = radius;
-            //                       right arrow  left line start
-            sourcePadding = d.left ? radius + 8 : radius;
-            //                      left arrow      right line start
-            targetPadding = d.right ? radius + 16 : radius;
-          } else {
-            r = radiusNormal - 18;
-            sourcePadding = d.left ? radiusNormal + 18 : radiusNormal;
-            targetPadding = d.right ? radiusNormal + 16 : radiusNormal;
-          }
-          let dtx = Math.max(targetPadding, Math.min(width - r, d.target.x)),
-            dty = Math.max(targetPadding, Math.min(height - r, d.target.y)),
-            dsx = Math.max(sourcePadding, Math.min(width - r, d.source.x)),
-            dsy = Math.max(sourcePadding, Math.min(height - r, d.source.y));
-
-          let deltaX = dtx - dsx,
-            deltaY = dty - dsy,
-            dist = Math.sqrt(deltaX * deltaX + deltaY * deltaY);
-          if (dist == 0)
-            dist = 0.001;
-          let normX = deltaX / dist,
-            normY = deltaY / dist;
-          let sourceX = dsx + (sourcePadding * normX),
-            sourceY = dsy + (sourcePadding * normY),
-            targetX = dtx - (targetPadding * normX),
-            targetY = dty - (targetPadding * normY);
-          sourceX = Math.max(0, sourceX);
-          sourceY = Math.max(0, sourceY);
-          targetX = Math.max(0, targetX);
-          targetY = Math.max(0, targetY);
-
-          return 'M' + sourceX + ',' + sourceY + 'L' + targetX + ',' + targetY;
-        });
-
-        if (!animate) {
-          animate = true;
-          force.stop();
-        }
-      }
-
-      // highlight the paths between the selected node and the hovered node
-      function findNextHopNode(from, d) {
-        // d is the node that the mouse is over
-        // from is the selected_node ....
-        if (!from)
-          return null;
-
-        if (from == d)
-          return selected_node;
-
-        //QDR.log.debug("finding nextHop from: " + from.name + " to " + d.name);
-        let sInfo = QDRService.management.topology.nodeInfo()[from.key];
-
-        if (!sInfo) {
-          QDR.log.warn('unable to find topology node info for ' + from.key);
-          return null;
-        }
-
-        // find the hovered name in the selected name's .router.node results
-        if (!sInfo['router.node'])
-          return null;
-        let aAr = sInfo['router.node'].attributeNames;
-        let vAr = sInfo['router.node'].results;
-        for (let hIdx = 0; hIdx < vAr.length; ++hIdx) {
-          let addrT = QDRService.utilities.valFor(aAr, vAr[hIdx], 'id');
-          if (addrT == d.name) {
-            //QDR.log.debug("found " + d.name + " at " + hIdx);
-            let nextHop = QDRService.utilities.valFor(aAr, vAr[hIdx], 'nextHop');
-            //QDR.log.debug("nextHop was " + nextHop);
-            return (nextHop == null) ? nodeFor(addrT) : nodeFor(nextHop);
-          }
-        }
-        return null;
-      }
-
-      function nodeFor(name) {
-        for (let i = 0; i < nodes.length; ++i) {
-          if (nodes[i].name == name)
-            return nodes[i];
-        }
-        return null;
-      }
-
-      function linkFor(source, target) {
-        for (let i = 0; i < links.length; ++i) {
-          if ((links[i].source == source) && (links[i].target == target))
-            return links[i];
-          if ((links[i].source == target) && (links[i].target == source))
-            return links[i];
-        }
-        // the selected node was a client/broker
-        //QDR.log.debug("failed to find a link between ");
-        //console.dump(source);
-        //QDR.log.debug(" and ");
-        //console.dump(target);
-        return null;
-      }
-
-      function clearPopups() {
-        d3.select('#crosssection').style('display', 'none');
-        $('.hastip').empty();
-        d3.select('#multiple_details').style('display', 'none');
-        d3.select('#link_details').style('display', 'none');
-        d3.select('#node_context_menu').style('display', 'none');
-
-      }
-
-      function removeCrosssection() {
-        d3.select('#crosssection svg g').transition()
-          .duration(1000)
-          .attr('transform', 'scale(0)')
-          .style('opacity', 0)
-          .each('end', function () {
-            d3.select('#crosssection svg').remove();
-            d3.select('#crosssection').style('display','none');
-          });
-        d3.select('#multiple_details').transition()
-          .duration(500)
-          .style('opacity', 0)
-          .each('end', function() {
-            d3.select('#multiple_details').style('display', 'none');
-            stopUpdateConnectionsGrid();
-          });
-        hideLinkDetails();
-      }
-
-      function hideLinkDetails() {
-        d3.select('#link_details').transition()
-          .duration(500)
-          .style('opacity', 0)
-          .each('end', function() {
-            d3.select('#link_details').style('display', 'none');
-          });
-      }
-
-      function clerAllHighlights() {
-        for (let i = 0; i < links.length; ++i) {
-          links[i]['highlighted'] = false;
-        }
-        for (let i = 0; i<nodes.length; ++i) {
-          nodes[i]['highlighted'] = false;
-        }
-      }
-      // takes the nodes and links array of objects and adds svg elements for everything that hasn't already
-      // been added
-      function restart(start) {
-        circle.call(force.drag);
-
-        // path (link) group
-        path = path.data(links, function(d) {return d.uid;});
-
-        // update existing links
-        path.classed('selected', function(d) {
-          return d === selected_link;
-        })
-          .classed('highlighted', function(d) {
-            return d.highlighted;
-          })
-          .attr('marker-start', function(d) {
-            let sel = d === selected_link ? '-selected' : (d.cls === 'small' ? '-small' : '');
-            if (d.highlighted)
-              sel = '-highlighted';
-            return d.left ? 'url(' + urlPrefix + '#start-arrow' + sel + ')' : '';
-          })
-          .attr('marker-end', function(d) {
-            let sel = d === selected_link ? '-selected' : (d.cls === 'small' ? '-small' : '');
-            if (d.highlighted)
-              sel = '-highlighted';
-            return d.right ? 'url(' + urlPrefix + '#end-arrow' + sel + ')' : '';
-          });
-        // add new links. if a link with a new uid is found in the data, add a new path
-        path.enter().append('svg:path')
-          .attr('class', 'link')
-          .attr('marker-start', function(d) {
-            let sel = d === selected_link ? '-selected' : (d.cls === 'small' ? '-small' : '');
-            return d.left ? 'url(' + urlPrefix + '#start-arrow' + sel + ')' : '';
-          })
-          .attr('marker-end', function(d) {
-            let sel = d === selected_link ? '-selected' : (d.cls === 'small' ? '-small' : '');
-            return d.right ? 'url(' + urlPrefix + '#end-arrow' + sel + ')' : '';
-          })
-          .classed('small', function(d) {
-            return d.cls == 'small';
-          })
-          .on('mouseover', function(d) { // mouse over a path
-            let resultIndex = 0; // the connection to use
-            let left = d.left ? d.target : d.source;
-            // right is the node that the arrow points to, left is the other node
-            let right = d.left ? d.source : d.target;
-            let onode = QDRService.management.topology.nodeInfo()[left.key];
-            // loop through all the connections for left, and find the one for right
-            if (!onode || !onode['connection'])
-              return;
-            // update the info dialog for the link the mouse is over
-            if (!selected_node && !selected_link) {
-              for (resultIndex = 0; resultIndex < onode['connection'].results.length; ++resultIndex) {
-                let conn = onode['connection'].results[resultIndex];
-                /// find the connection whose container is the right's name
-                let name = QDRService.utilities.valFor(onode['connection'].attributeNames, conn, 'container');
-                if (name == right.routerId) {
-                  break;
-                }
-              }
-              // did not find connection. this is a connection to a non-interrouter node
-              if (resultIndex === onode['connection'].results.length) {
-                // use the non-interrouter node's connection info
-                left = d.target;
-                resultIndex = left.resultIndex;
-              }
-              updateForm(left.key, 'connection', resultIndex);
-            }
-
-            mousedown_link = d;
-            selected_link = mousedown_link;
-            restart();
-          })
-          .on('mousemove', function (d) {
-            let top = $('#topology').offset().top - 5;
-            $timeout(function () {
-              $scope.trustedpopoverContent = $sce.trustAsHtml(connectionPopupHTML(d));
-            });
-            d3.select('#popover-div')
-              .style('display', 'block')
-              .style('left', (d3.event.pageX+5)+'px')
-              .style('top', (d3.event.pageY-top)+'px');
-          })
-          .on('mouseout', function() { // mouse out of a path
-            d3.select('#popover-div')
-              .style('display', 'none');
-            selected_link = null;
-            restart();
-          })
-          // left click a path
-          .on('click', function (d) {
-            let clickPos = d3.mouse(this);
-            d3.event.stopPropagation();
-            clearPopups();
-            var showCrossSection = function() {
-              const diameter = 400;
-              let pack = d3.layout.pack()
-                .size([diameter - 4, diameter - 4])
-                .padding(-10)
-                .value(function(d) { return d.size; });
-
-              d3.select('#crosssection svg').remove();
-              let svg = d3.select('#crosssection').append('svg')
-                .attr('width', diameter)
-                .attr('height', diameter);
-
-              let rg = svg.append('svg:defs')
-                .append('radialGradient')
-                .attr('id', 'cross-gradient')
-                .attr('gradientTransform', 'scale(2.0) translate(-0.5,-0.5)');
-
-              rg
-                .append('stop')
-                .attr('offset', '0%')
-                .attr('stop-color', '#feffff');
-              rg
-                .append('stop')
-                .attr('offset', '40%')
-                .attr('stop-color', '#cfe2f3');
-
-              let svgg = svg.append('g')
-                .attr('transform', 'translate(2,2)');
-
-              svgg
-                .append('rect')
-                .attr('x', 0)
-                .attr('y', 0)
-                .attr('width', 200)
-                .attr('height', 200)
-                .attr('class', 'cross-rect')
-                .attr('fill', 'url('+urlPrefix+'#cross-gradient)');
-
-              svgg
-                .append('line')
-                .attr('class', 'cross-line')
-                .attr({x1: 2, y1: 0, x2: 200, y2: 0});
-              svgg
-                .append('line')
-                .attr('class', 'cross-line')
-                .attr({x1: 2, y1: 0, x2: 0, y2: 200});
-
-              /*
-              let simpleLine = d3.svg.line();
-              svgg
-                .append('path')
-                .attr({
-                  d: simpleLine([[0,0],[0,200]]),
-                  stroke: '#000',
-                  'stroke-width': '4px'
-                });
-              svgg
-                .append('path')
-                .attr({
-                  d: simpleLine([[0,0],[200,0]]),
-                  stroke: '#000',
-                  'stroke-width': '4px'
-                });
-*/
-              let root = {
-                name: ' Links between ' + d.source.name + ' and ' + d.target.name,
-                children: []
-              };
-              let nodeInfo = QDRService.management.topology.nodeInfo();
-              let connections = nodeInfo[d.source.key]['connection'];
-              let containerIndex = connections.attributeNames.indexOf('container');
-              connections.results.some ( function (connection) {
-                if (connection[containerIndex] == d.target.routerId) {
-                  root.attributeNames = connections.attributeNames;
-                  root.obj = connection;
-                  root.desc = 'Connection';
-                  return true;    // stop looping after 1 match
-                }
-                return false;
-              });
-
-              // find router.links where link.remoteContainer is d.source.name
-              let links = nodeInfo[d.source.key]['router.link'];
-              let identityIndex = connections.attributeNames.indexOf('identity');
-              let roleIndex = connections.attributeNames.indexOf('role');
-              let connectionIdIndex = links.attributeNames.indexOf('connectionId');
-              let linkTypeIndex = links.attributeNames.indexOf('linkType');
-              let nameIndex = links.attributeNames.indexOf('name');
-              let linkDirIndex = links.attributeNames.indexOf('linkDir');
-
-              if (roleIndex < 0 || identityIndex < 0 || connectionIdIndex < 0
-                || linkTypeIndex < 0 || nameIndex < 0 || linkDirIndex < 0)
-                return;
-              links.results.forEach ( function (link) {
-                if (root.obj && link[connectionIdIndex] == root.obj[identityIndex] && link[linkTypeIndex] == root.obj[roleIndex])
-                  root.children.push (
-                    { name: ' ' + link[linkDirIndex] + ' ',
-                      size: 100,
-                      obj: link,
-                      desc: 'Link',
-                      attributeNames: links.attributeNames
-                    });
-              });
-              if (root.children.length == 0)
-                return;
-              let node = svgg.datum(root).selectAll('.node')
-                .data(pack.nodes)
-                .enter().append('g')
-                .attr('class', function(d) { return d.children ? 'parent node hastip' : 'leaf node hastip'; })
-                .attr('transform', function(d) { return 'translate(' + d.x + ',' + d.y + ')' + (!d.children ? 'scale(0.9)' : ''); });
-              node.append('circle')
-                .attr('r', function(d) { return d.r; });
-
-              node.on('mouseenter', function (d) {
-                let title = '<h4>' + d.desc + '</h4><table class=\'tiptable\'><tbody>';
-                if (d.attributeNames)
-                  d.attributeNames.forEach( function (n, i) {
-                    title += '<tr><td>' + n + '</td><td>';
-                    title += d.obj[i] != null ? d.obj[i] : '';
-                    title += '</td></tr>';
-                  });
-                title += '</tbody></table>';
-                $timeout( (function () {
-                  $scope.crosshtml = $sce.trustAsHtml(title);
-                  $('#crosshtml').show();
-                  let parent = $('#crosssection');
-                  let ppos = parent.position();
-                  let mleft = ppos.left + parent.width();
-                  $('#crosshtml').css({left: mleft, top: ppos.top});
-                }).bind(this));
-              });
-              node.on('mouseout', function () {
-                $('#crosshtml').hide();
-              });
-
-              node.append('text')
-                .attr('dy', function (d) { return d.children ? '-10em' : '.5em';})
-                .style('text-anchor', 'middle')
-                .text(function(d) {
-                  return d.name.substring(0, d.r / 3);
-                });
-              svgg.attr('transform', 'translate(2,2) scale(0.01)');
-
-              let bounds = $('#topology').position();
-              d3.select('#crosssection')
-                .style('display', 'block')
-                .style('left', (clickPos[0] + bounds.left) + 'px')
-                .style('top', (clickPos[1] + bounds.top) + 'px');
-
-              svgg.transition()
-                .attr('transform', 'translate(2,2) scale(1)')
-                .each('end', function ()  {
-                  d3.selectAll('#crosssection g.leaf text').attr('dy', '.3em');
-                });
-            };
-            QDRService.management.topology.ensureEntities(d.source.key, {entity: 'router.link', force: true}, showCrossSection);
-          });
-        // remove old links
-        path.exit().remove();
-
-
-        // circle (node) group
-        // nodes are known by id
-        circle = circle.data(nodes, function(d) {
-          return d.name;
-        });
-
-        // update existing nodes visual states
-        circle.selectAll('circle')
-          .classed('highlighted', function(d) {
-            return d.highlighted;
-          })
-          .classed('selected', function(d) {
-            return (d === selected_node);
-          })
-          .classed('fixed', function(d) {
-            return d.fixed & 1;
-          });
-
-        // add new circle nodes. if nodes[] is longer than the existing paths, add a new path for each new element
-        let g = circle.enter().append('svg:g')
-          .classed('multiple', function(d) {
-            return (d.normals && d.normals.length > 1);
-          });
-
-        var appendCircle = function(g) {
-          // add new circles and set their attr/class/behavior
-          return g.append('svg:circle')
-            .attr('class', 'node')
-            .attr('r', function(d) {
-              return radii[d.nodeType];
-            })
-            .attr('fill', function (d) {
-              if (d.cdir === 'both' && !QDRService.utilities.isConsole(d)) {
-                return 'url(' + urlPrefix + '#half-circle)';
-              }
-              return null;
-            })
-            .classed('fixed', function(d) {
-              return d.fixed & 1;
-            })
-            .classed('normal', function(d) {
-              return d.nodeType == 'normal' || QDRService.utilities.isConsole(d);
-            })
-            .classed('in', function(d) {
-              return d.cdir == 'in';
-            })
-            .classed('out', function(d) {
-              return d.cdir == 'out';
-            })
-            .classed('inout', function(d) {
-              return d.cdir == 'both';
-            })
-            .classed('inter-router', function(d) {
-              return d.nodeType == 'inter-router';
-            })
-            .classed('on-demand', function(d) {
-              return d.nodeType == 'on-demand';
-            })
-            .classed('console', function(d) {
-              return QDRService.utilities.isConsole(d);
-            })
-            .classed('artemis', function(d) {
-              return QDRService.utilities.isArtemis(d);
-            })
-            .classed('qpid-cpp', function(d) {
-              return QDRService.utilities.isQpid(d);
-            })
-            .classed('route-container', function (d) {
-              return (!QDRService.utilities.isArtemis(d) && !QDRService.utilities.isQpid(d) && d.nodeType === 'route-container');
-            })
-            .classed('client', function(d) {
-              return d.nodeType === 'normal' && !d.properties.console_identifier;
-            });
-        };
-        appendCircle(g)
-          .on('mouseover', function(d) {  // mouseover a circle
-            if (!selected_node && !mousedown_node) {
-              if (d.nodeType === 'inter-router') {
-                //QDR.log.debug("showing general form");
-                updateForm(d.key, 'router', 0);
-              } else if (d.nodeType === 'normal' || d.nodeType === 'on-demand' || d.nodeType === 'route-container') {
-                //QDR.log.debug("showing connections form");
-                updateForm(d.key, 'connection', d.resultIndex);
-              }
-            }
-
-            if (d === mousedown_node)
-              return;
-            //if (d === selected_node)
-            //    return;
-            // enlarge target node
-            d3.select(this).attr('transform', 'scale(1.1)');
-            // highlight the next-hop route from the selected node to this node
-            //mousedown_node = null;
-
-            if (!selected_node) {
-              return;
-            }
-            clerAllHighlights();
-            // we need .router.node info to highlight hops
-            QDRService.management.topology.ensureAllEntities([{entity: 'router.node', attrs: ['id','nextHop']}], function () {
-              mouseover_node = d;  // save this node in case the topology changes so we can restore the highlights
-              nextHop(selected_node, d);
-              restart();
-            });
-          })
-          .on('mouseout', function() { // mouse out for a circle
-            // unenlarge target node
-            d3.select(this).attr('transform', '');
-            clerAllHighlights();
-            mouseover_node = null;
-            restart();
-          })
-          .on('mousedown', function(d) { // mouse down for circle
-            if (d3.event.button !== 0) { // ignore all but left button
-              return;
-            }
-            mousedown_node = d;
-            // mouse position relative to svg
-            initial_mouse_down_position = d3.mouse(this.parentNode.parentNode.parentNode).slice();
-          })
-          .on('mouseup', function(d) {  // mouse up for circle
-            if (!mousedown_node)
-              return;
-
-            selected_link = null;
-            // unenlarge target node
-            d3.select(this).attr('transform', '');
-
-            // check for drag
-            mouseup_node = d;
-
-            let mySvg = this.parentNode.parentNode.parentNode;
-            // if we dragged the node, make it fixed
-            let cur_mouse = d3.mouse(mySvg);
-            if (cur_mouse[0] != initial_mouse_down_position[0] ||
-              cur_mouse[1] != initial_mouse_down_position[1]) {
-              d.fixed = true;
-              setNodesFixed(d.name, true);
-              resetMouseVars();
-              restart();
-              return;
-            }
-
-            // if this node was selected, unselect it
-            if (mousedown_node === selected_node) {
-              selected_node = null;
-            } else {
-              if (d.nodeType !== 'normal' && d.nodeType !== 'on-demand')
-                selected_node = mousedown_node;
-            }
-            clerAllHighlights();
-            mousedown_node = null;
-            if (!$scope.$$phase) $scope.$apply();
-            restart(false);
-
-          })
-          .on('dblclick', function(d) { // circle
-            if (d.fixed) {
-              d.fixed = false;
-              setNodesFixed(d.name, false);
-              restart(); // redraw the node without a dashed line
-              force.start(); // let the nodes move to a new position
-            }
-          })
-          .on('contextmenu', function(d) {  // circle
-            $(document).click();
-            d3.event.preventDefault();
-            let rm = relativeMouse();
-            d3.select('#node_context_menu')
-              .style({
-                display: 'block',
-                left: rm.left + 'px',
-                top: (rm.top - rm.offset.top) + 'px'
-              });
-            $timeout( function () {
-              $scope.contextNode = d;
-            });
-          })
-          .on('click', function(d) {  // circle
-            if (!mouseup_node)
-              return;
-            // clicked on a circle
-            clearPopups();
-            if (!d.normals) {
-              // circle was a router or a broker
-              if (QDRService.utilities.isArtemis(d)) {
-                const artemisPath = '/jmx/attributes?tab=artemis&con=Artemis';
-                if (QDR.isStandalone)
-                  window.location = $location.protocol() + '://localhost:8161/hawtio' + artemisPath;
-                else
-                  $location.path(artemisPath);
-              }
-              return;
-            }
-            d3.event.stopPropagation();
-            startUpdateConnectionsGrid(d);
-          });
-        //.attr("transform", function (d) {return "scale(" + (d.nodeType === 'normal' ? .5 : 1) + ")"})
-        //.transition().duration(function (d) {return d.nodeType === 'normal' ? 3000 : 0}).ease("elastic").attr("transform", "scale(1)")
-
-        var appendContent = function(g) {
-          // show node IDs
-          g.append('svg:text')
-            .attr('x', 0)
-            .attr('y', function(d) {
-              let y = 7;
-              if (QDRService.utilities.isArtemis(d))
-                y = 8;
-              else if (QDRService.utilities.isQpid(d))
-                y = 9;
-              else if (d.nodeType === 'inter-router')
-                y = 4;
-              else if (d.nodeType === 'route-container')
-                y = 5;
-              return y;
-            })
-            .attr('class', 'id')
-            .classed('console', function(d) {
-              return QDRService.utilities.isConsole(d);
-            })
-            .classed('normal', function(d) {
-              return d.nodeType === 'normal';
-            })
-            .classed('on-demand', function(d) {
-              return d.nodeType === 'on-demand';
-            })
-            .classed('artemis', function(d) {
-              return QDRService.utilities.isArtemis(d);
-            })
-            .classed('qpid-cpp', function(d) {
-              return QDRService.utilities.isQpid(d);
-            })
-            .text(function(d) {
-              if (QDRService.utilities.isConsole(d)) {
-                return '\uf108'; // icon-desktop for this console
-              } else if (QDRService.utilities.isArtemis(d)) {
-                return '\ue900';
-              } else if (QDRService.utilities.isQpid(d)) {
-                return '\ue901';
-              } else if (d.nodeType === 'route-container') {
-                return d.properties.product ? d.properties.product[0].toUpperCase() : 'S';
-              } else if (d.nodeType === 'normal')
-                return '\uf109'; // icon-laptop for clients
-              return d.name.length > 7 ? d.name.substr(0, 6) + '...' : d.name;
-            });
-        };
-
-        appendContent(g);
-
-        var appendTitle = function(g) {
-          g.append('svg:title').text(function(d) {
-            let x = '';
-            if (d.normals && d.normals.length > 1)
-              x = ' x ' + d.normals.length;
-            if (QDRService.utilities.isConsole(d)) {
-              return 'Dispatch console' + x;
-            } else if (QDRService.utilities.isArtemis(d)) {
-              return 'Broker - Artemis' + x;
-            } else if (d.properties.product == 'qpid-cpp') {
-              return 'Broker - qpid-cpp' + x;
-            } else if (d.properties.product) {
-              return d.properties.product;
-            } else if (d.cdir === 'in')
-              return 'Sender' + x;
-            else if (d.cdir === 'out')
-              return 'Receiver' + x;
-            else if (d.cdir === 'both')
-              return 'Sender/Receiver' + x;
-            return d.nodeType == 'normal' ? 'client' + x : (d.nodeType == 'on-demand' ? 'broker' : 'Router ' + d.name);
-          });
-        };
-        appendTitle(g);
-
-        // remove old nodes
-        circle.exit().remove();
-
-        // add subcircles
-        svg.selectAll('.subcircle').remove();
-        let multiples = svg.selectAll('.multiple');
-        multiples.each(function(d) {
-          d.normals.forEach(function(n, i) {
-            if (i < d.normals.length - 1 && i < 3) // only show a few shadow circles
-              this.insert('svg:circle', ':first-child')
-                .attr('class', 'subcircle node')
-                .attr('r', 15 - i)
-                .attr('transform', 'translate(' + 4 * (i + 1) + ', 0)');
-          }, d3.select(this));
-        });
-
-        // dynamically create the legend based on which node types are present
-        // the legend
-        d3.select('#svg_legend svg').remove();
-        lsvg = d3.select('#svg_legend')
-          .append('svg')
-          .attr('id', 'svglegend');
-        lsvg = lsvg.append('svg:g')
-          .attr('transform', 'translate(' + (radii['inter-router'] + 2) + ',' + (radii['inter-router'] + 2) + ')')
-          .selectAll('g');
-        let legendNodes = [];
-        legendNodes.push(aNode('Router', '', 'inter-router', '', undefined, 0, 0, 0, 0, false, {}));
-
-        if (!svg.selectAll('circle.console').empty()) {
-          legendNodes.push(aNode('Console', '', 'normal', '', undefined, 1, 0, 0, 0, false, {
-            console_identifier: 'Dispatch console'
-          }));
-        }
-        if (!svg.selectAll('circle.client.in').empty()) {
-          let node = aNode('Sender', '', 'normal', '', undefined, 2, 0, 0, 0, false, {});
-          node.cdir = 'in';
-          legendNodes.push(node);
-        }
-        if (!svg.selectAll('circle.client.out').empty()) {
-          let node = aNode('Receiver', '', 'normal', '', undefined, 3, 0, 0, 0, false, {});
-          node.cdir = 'out';
-          legendNodes.push(node);
-        }
-        if (!svg.selectAll('circle.client.inout').empty()) {
-          let node = aNode('Sender/Receiver', '', 'normal', '', undefined, 4, 0, 0, 0, false, {});
-          node.cdir = 'both';
-          legendNodes.push(node);
-        }
-        if (!svg.selectAll('circle.qpid-cpp').empty()) {
-          legendNodes.push(aNode('Qpid broker', '', 'route-container', '', undefined, 5, 0, 0, 0, false, {
-            product: 'qpid-cpp'
-          }));
-        }
-        if (!svg.selectAll('circle.artemis').empty()) {
-          legendNodes.push(aNode('Artemis broker', '', 'route-container', '', undefined, 6, 0, 0, 0, false,
-            {product: 'apache-activemq-artemis'}));
-        }
-        if (!svg.selectAll('circle.route-container').empty()) {
-          legendNodes.push(aNode('Service', '', 'route-container', 'external-service', undefined, 7, 0, 0, 0, false,
-            {product: ' External Service'}));
-        }
-        lsvg = lsvg.data(legendNodes, function(d) {
-          return d.key;
-        });
-        let lg = lsvg.enter().append('svg:g')
-          .attr('transform', function(d, i) {
-            // 45px between lines and add 10px space after 1st line
-            return 'translate(0, ' + (45 * i + (i > 0 ? 10 : 0)) + ')';
-          });
-
-        appendCircle(lg);
-        appendContent(lg);
-        appendTitle(lg);
-        lg.append('svg:text')
-          .attr('x', 35)
-          .attr('y', 6)
-          .attr('class', 'label')
-          .text(function(d) {
-            return d.key;
-          });
-        lsvg.exit().remove();
-        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';
-        }
-
-        if (!mousedown_node || !selected_node)
-          return;
-
-        if (!start)
-          return;
-        // set the graph in motion
-        //QDR.log.debug("mousedown_node is " + mousedown_node);
-        force.start();
-
-      }
-
-      var startUpdateConnectionsGrid = function(d) {
-        // called after each topology update
-        var extendConnections = function() {
-          // force a fetch of the links for this node
-          QDRService.management.topology.ensureEntities(d.key, {entity: 'router.link', force: true}, function () {
-            // the links for this node are now available
-            $scope.multiData = [];
-            let normals = d.normals;
-            // find updated normals for d
-            d3.selectAll('.normal')
-              .each(function(newd) {
-                if (newd.id == d.id && newd.name == d.name) {
-                  normals = newd.normals;
-                }
-              });
-            if (normals) {
-              normals.forEach(function(n) {
-                let nodeInfo = QDRService.management.topology.nodeInfo();
-                let links = nodeInfo[n.key]['router.link'];
-                let linkTypeIndex = links.attributeNames.indexOf('linkType');
-                let connectionIdIndex = links.attributeNames.indexOf('connectionId');
-                n.linkData = [];
-                links.results.forEach(function(link) {
-                  if (link[linkTypeIndex] === 'endpoint' && link[connectionIdIndex] === n.connectionId) {
-                    let l = {};
-                    let ll = QDRService.utilities.flatten(links.attributeNames, link);
-                    l.owningAddr = ll.owningAddr;
-                    l.dir = ll.linkDir;
-                    if (l.owningAddr && l.owningAddr.length > 2)
-                      if (l.owningAddr[0] === 'M')
-                        l.owningAddr = l.owningAddr.substr(2);
-                      else
-                        l.owningAddr = l.owningAddr.substr(1);
-
-                    l.deliveryCount = ll.deliveryCount;
-                    l.uncounts = QDRService.utilities.pretty(ll.undeliveredCount + ll.unsettledCount);
-                    l.adminStatus = ll.adminStatus;
-                    l.operStatus = ll.operStatus;
-                    l.identity = ll.identity;
-                    l.connectionId = ll.connectionId;
-                    l.nodeId = n.key;
-                    l.type = ll.type;
-                    l.name = ll.name;
-
-                    // TODO: remove this fake quiescing/reviving logic when the routers do the work
-                    initConnState(n.connectionId);
-                    if ($scope.quiesceState[n.connectionId].linkStates[l.identity])
-                      l.adminStatus = $scope.quiesceState[n.connectionId].linkStates[l.identity];
-                    if ($scope.quiesceState[n.connectionId].state == 'quiescing') {
-                      if (l.adminStatus === 'enabled') {
-                        // 25% chance of switching
-                        let chance = Math.floor(Math.random() * 2);
-                        if (chance == 1) {
-                          l.adminStatus = 'disabled';
-                          $scope.quiesceState[n.connectionId].linkStates[l.identity] = 'disabled';
-                        }
-                      }
-                    }
-                    if ($scope.quiesceState[n.connectionId].state == 'reviving') {
-                      if (l.adminStatus === 'disabled') {
-                        // 25% chance of switching
-                        let chance = Math.floor(Math.random() * 2);
-                        if (chance == 1) {
-                          l.adminStatus = 'enabled';
-                          $scope.quiesceState[n.connectionId].linkStates[l.identity] = 'enabled';
-                        }
-                      }
-                    }
-                    QDR.log.debug('pushing link state for ' + l.owningAddr + ' status: ' + l.adminStatus);
-
-                    n.linkData.push(l);
-                  }
-                });
-                $scope.multiData.push(n);
-                if (n.connectionId == $scope.connectionId)
-                  $scope.linkData = n.linkData;
-                initConnState(n.connectionId);
-                $scope.multiDetails.updateState(n);
-              });
-            }
-            $scope.$apply();
-
-            d3.select('#multiple_details')
-              .style({
-                height: ((normals.length + 1) * 30) + 40 + 'px',
-                'overflow-y': normals.length > 10 ? 'scroll' : 'hidden'
-              });
-          });
-        };
-        $scope.connectionsStyle = function () {
-          return {
-            height: ($scope.multiData.length * 30 + 40) + 'px'
-          };
-        };
-        $scope.linksStyle = function () {
-          return {
-            height: ($scope.linkData.length * 30 + 40) + 'px'
-          };
-        };
-        // register a notification function for when the topology is updated
-        QDRService.management.topology.addUpdatedAction('normalsStats', extendConnections);
-        // call the function that gets the links right now
-        extendConnections();
-        clearPopups();
-        let display = 'block';
-        if (d.normals.length === 1) {
-          display = 'none';
-          mouseY = mouseY - 20;
-        }
-        let rm = relativeMouse();
-        d3.select('#multiple_details')
-          .style({
-            display: display,
-            opacity: 1,
-            left: rm.left + 'px',
-            top: (rm.top - rm.offset.top) + 'px'
-          });
-        if (d.normals.length === 1) {
-          // simulate a click on the connection to popup the link details
-          QDRService.management.topology.ensureEntities(d.key, {entity: 'router.link', force: true}, function () {
-            $scope.multiDetails.showLinksList({
-              entity: d
-            });
-          });
-        }
-      };
-      var stopUpdateConnectionsGrid = function() {
-        QDRService.management.topology.delUpdatedAction('normalsStats');
-      };
-
-      var initConnState = function(id) {
-        if (!angular.isDefined($scope.quiesceState[id])) {
-          $scope.quiesceState[id] = {
-            state: 'enabled',
-            buttonText: 'Quiesce',
-            buttonDisabled: false,
-            linkStates: {}
-          };
-        }
-      };
-
-      function nextHop(thisNode, d) {
-        if ((thisNode) && (thisNode != d)) {
-          let target = findNextHopNode(thisNode, d);
-          //QDR.log.debug("highlight link from node ");
-          //console.dump(nodeFor(selected_node.name));
-          //console.dump(target);
-          if (target) {
-            let hnode = nodeFor(thisNode.name);
-            let hlLink = linkFor(hnode, target);
-            //QDR.log.debug("need to highlight");
-            //console.dump(hlLink);
-            if (hlLink) {
-              hlLink['highlighted'] = true;
-              hnode['highlighted'] = true;
-            }
-            else
-              target = null;
-          }
-          nextHop(target, d);
-        }
-        if (thisNode == d) {
-          let hnode = nodeFor(thisNode.name);
-          hnode['highlighted'] = true;
-        }
-      }
-
-      function hasChanged() {
-        // Don't update the underlying topology diagram if we are adding a new node.
-        // Once adding is completed, the topology will update automatically if it has changed
-        let nodeInfo = QDRService.management.topology.nodeInfo();
-        // don't count the nodes without connection info
-        let cnodes = Object.keys(nodeInfo).filter ( function (node) {
-          return (nodeInfo[node]['connection']);
-        });
-        let routers = nodes.filter( function (node) {
-          return node.nodeType === 'inter-router';
-        });
-        if (routers.length > cnodes.length) {
-          return -1;
-        }
-
-
-        if (cnodes.length != Object.keys(savedKeys).length) {
-          return cnodes.length > Object.keys(savedKeys).length ? 1 : -1;
-        }
-        // we may have dropped a node and added a different node in the same update cycle
-        for (let i=0; i<cnodes.length; i++) {
-          let key = cnodes[i];
-          // if this node isn't in the saved node list
-          if (!savedKeys.hasOwnProperty(key))
-            return 1;
-          // if the number of connections for this node chaanged
-          if (!nodeInfo[key]['connection'])
-            return -1;
-          if (nodeInfo[key]['connection'].results.length != savedKeys[key]) {
-            return -1;
-          }
-        }
-        return 0;
-      }
-
-      function saveChanged() {
-        savedKeys = {};
-        let nodeInfo = QDRService.management.topology.nodeInfo();
-        // save the number of connections per node
-        for (let key in nodeInfo) {
-          if (nodeInfo[key]['connection'])
-            savedKeys[key] = nodeInfo[key]['connection'].results.length;
-        }
-      }
-      // we are about to leave the page, save the node positions
-      $rootScope.$on('$locationChangeStart', function() {
-        //QDR.log.debug("locationChangeStart");
-        savePositions();
-      });
-      // When the DOM element is removed from the page,
-      // AngularJS will trigger the $destroy event on
-      // the scope
-      $scope.$on('$destroy', function() {
-        //QDR.log.debug("scope on destroy");
-        savePositions();
-        QDRService.management.topology.setUpdateEntities([]);
-        QDRService.management.topology.stopUpdating();
-        QDRService.management.topology.delUpdatedAction('normalsStats');
-        QDRService.management.topology.delUpdatedAction('topology');
-
-        d3.select('#SVG_ID').remove();
-        window.removeEventListener('resize', resize);
-      });
-
-      function handleInitialUpdate() {
-        // we only need to update connections during steady-state
-        QDRService.management.topology.setUpdateEntities(['connection']);
-        // we currently have all entities available on all routers
-        saveChanged();
-        initForceGraph();
-        // after the graph is displayed fetch all .router.node info. This is done so highlighting between nodes
-        // doesn't incur a delay
-        QDRService.management.topology.addUpdateEntities({entity: 'router.node', attrs: ['id','nextHop']});
-        // call this function every time a background update is done
-        QDRService.management.topology.addUpdatedAction('topology', function() {
-          let changed = hasChanged();
-          // there is a new node, we need to get all of it's entities before drawing the graph
-          if (changed > 0) {
-            QDRService.management.topology.delUpdatedAction('topology');
-            animate = true;
-            setupInitialUpdate();
-          } else if (changed === -1) {
-            // we lost a node (or a client), we can draw the new svg immediately
-            animate = false;
-            saveChanged();
-            let nodeInfo = QDRService.management.topology.nodeInfo();
-            initializeNodes(nodeInfo);
-
-            let unknowns = [];
-            initializeLinks(nodeInfo, unknowns);
-            if (unknowns.length > 0) {
-              resolveUnknowns(nodeInfo, unknowns);
-            }
-            else {
-              force.nodes(nodes).links(links).start();
-              restart();
-            }
-
-            //initForceGraph();
-          } else {
-            //QDR.log.debug("topology didn't change")
-          }
-
-        });
-      }
-
-      function setupInitialUpdate() {
-        // make sure all router nodes have .connection info. if not then fetch any missing info
-        QDRService.management.topology.ensureAllEntities(
-          //          [{entity: ".connection"}, {entity: ".router.lin.router.link", attrs: ["linkType","connectionId","linkDir"]}],
-          [{entity: 'connection'}],
-          //[{entity: ".connection"}],
-          handleInitialUpdate);
-      }
-      if (!QDRService.management.connection.is_connected()) {
-        // we are not connected. we probably got here from a bookmark or manual page reload
-        QDR.redirectWhenConnected($location, 'topology');
-        return;
-      }
-
-      let connectionPopupHTML = function (d) {
-        let left = d.left ? d.source : d.target;
-        // left is the connection with dir 'in'
-        let right = d.left ? d.target : d.source;
-        let onode = QDRService.management.topology.nodeInfo()[left.key];
-        let connSecurity = function (conn) {
-          if (!conn.isEncrypted)
-            return 'no-security';
-          if (conn.sasl === 'GSSAPI')
-            return 'Kerberos';
-          return conn.sslProto + '(' + conn.sslCipher + ')';
-        };
-        let 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 + ')';
-        };
-        let connTenant = function (conn) {
-          if (!conn.tenant) {
-            return '';
-          }
-          if (conn.tenant.length > 1)
-            return conn.tenant.replace(/\/$/, '');
-        };
-        // loop through all the connections for left, and find the one for right
-        let rightIndex = onode['connection'].results.findIndex( function (conn) {
-          return QDRService.utilities.valFor(onode['connection'].attributeNames, conn, 'container') === right.routerId;
-        });
-        if (rightIndex < 0) {
-          // we have a connection to a client/service
-          rightIndex = +left.connectionId;
-        }
-        if (isNaN(rightIndex)) {
-          // we have a connection to a console
-          rightIndex = +right.connectionId;
-        }
-        let HTML = '';
-        if (rightIndex >= 0) {
-          let conn = onode['connection'].results[rightIndex];
-          conn = QDRService.utilities.flatten(onode['connection'].attributeNames, conn);
-          HTML += '<table class="popupTable">';
-          HTML += ('<tr><td>Security</td><td>' + connSecurity(conn) + '</td></tr>');
-          HTML += ('<tr><td>Authentication</td><td>' + connAuth(conn) + '</td></tr>');
-          HTML += ('<tr><td>Tenant</td><td>' + connTenant(conn) + '</td></tr>');
-          HTML += '</table>';
-        }
-        return HTML;
-      };
-
-      animate = true;
-      setupInitialUpdate();
-      QDRService.management.topology.startUpdating(false);
-
-    }
-  ]);
-
-  return QDR;
-
-}(QDR || {}));
\ No newline at end of file


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


[2/4] qpid-dispatch git commit: DISPATCH-1002 Added optional message flow animation to topology page

Posted by ea...@apache.org.
http://git-wip-us.apache.org/repos/asf/qpid-dispatch/blob/61df4890/console/stand-alone/plugin/js/topology/qdrTopology.js
----------------------------------------------------------------------
diff --git a/console/stand-alone/plugin/js/topology/qdrTopology.js b/console/stand-alone/plugin/js/topology/qdrTopology.js
new file mode 100644
index 0000000..82af88a
--- /dev/null
+++ b/console/stand-alone/plugin/js/topology/qdrTopology.js
@@ -0,0 +1,2125 @@
+/*
+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.
+*/
+'use strict';
+
+/* global angular d3 separateAddresses Traffic */
+/**
+ * @module QDR
+ */
+var QDR = (function(QDR) {
+
+  QDR.module.controller('QDR.TopologyFormController', function($scope, $timeout) {
+
+    $scope.attributes = [];
+    $scope.attributesConnections = [];
+
+    $scope.form = 'Router';
+    $scope.$on('showEntityForm', function(event, args) {
+      let attributes = args.attributes;
+      // capitalize 1st letter
+      $scope.form = args.entity.charAt(0).toUpperCase() + args.entity.slice(1);
+
+      let H = '<div class="infoGrid">';
+      attributes.forEach( function (a, i) {
+        let even = (i % 2) ? 'even' : 'odd';
+        if (a.attributeName === 'Listening on')
+          even += ' listening-on';
+        H += ('<div class="'+ even +'"><span title="'+a.attributeName+'">' + a.attributeName + '</span><span title="'+a.attributeValue+'">' + a.attributeValue + '</span></div>');
+      });
+      H += '</div>';
+      $('#formInfo').html(H);
+
+      if (!$scope.$$phase) $scope.$apply();
+
+    });
+
+    $scope.panelVisible = true;  // show/hide the panel on the left
+    $scope.hideLeftPane = function () {
+      d3.select('.page-menu')
+        .style('left' , '-360px')
+        .style('z-index', '1');
+
+      d3.select('.diagram')
+        .transition().duration(300).ease('sin-in')
+        .style('margin-left', '-360px')
+        .each('end', function () {
+          $timeout(function () {$scope.panelVisible = false;});
+          let div = d3.select(this);
+          div.style('margin-left', '0');
+          div.style('padding-left', 0);
+        });
+    };
+    $scope.showLeftPane = function () {
+      d3.select('.page-menu')
+        .style('left' , '0px');
+
+      $timeout(function () {$scope.panelVisible = true;});
+      d3.select('.diagram')
+        .style('margin-left', '0px')
+        .transition().duration(300).ease('sin-out')
+        .style('margin-left', '300px')
+        .each('end', function () {
+          let div = d3.select(this);
+          div.style('margin-left', '0');
+          div.style('padding-left', '300px');
+        });
+    };
+  });
+  /**
+   * @method TopologyController
+   *
+   * Controller that handles the QDR topology page
+   */
+  QDR.module.controller('QDR.TopologyController', ['$scope', '$rootScope', 'QDRService', '$location', '$timeout', '$uibModal', '$sce',
+    function($scope, $rootScope, QDRService, $location, $timeout, $uibModal, $sce) {
+
+      const TOPOOPTIONSKEY = 'topoOptions';
+      const radius = 25;
+      const radiusNormal = 15;
+
+      //  - nodes is an array of router/client info. these are the circles
+      //  - links is an array of connections between the routers. these are the lines with arrows
+      let nodes = [];
+      let links = [];
+      let forceData = {nodes: nodes, links: links};
+
+      $scope.multiData = [];
+      $scope.quiesceState = {};
+      let dontHide = false;
+      $scope.crosshtml = $sce.trustAsHtml('');
+      $scope.legendOptions = angular.fromJson(localStorage[TOPOOPTIONSKEY]) || {showTraffic: false};
+      $scope.legend = {status: {legendOpen: true, optionsOpen: true}};
+      let traffic = new Traffic($scope, $timeout, QDRService, separateAddresses, 
+        radius, forceData, nextHop);
+
+      // the showTraaffic checkbox was just toggled (or initialized)
+      $scope.$watch('legendOptions.showTraffic', function () {
+        localStorage[TOPOOPTIONSKEY] = JSON.stringify($scope.legendOptions);
+        if ($scope.legendOptions.showTraffic) {
+          traffic.start();
+        } else {
+          traffic.stop();
+          traffic.remove();
+        }
+      });
+      // event notification that an address checkbox has changed
+      $scope.addressFilterChanged = function () {
+        traffic.updateAddresses();
+      };
+
+      // called by angular when mouse enters one of the address legends
+      $scope.enterLegend = function (address) {
+        // fade all flows that aren't for this address
+        traffic.fadeOtherAddresses(address);
+      };
+      // called when the mouse leaves one of the address legends
+      $scope.leaveLegend = function () {
+        traffic.unFadeAll();
+      };
+      // clicked on the address name. toggle the address checkbox
+      $scope.addressClick = function (address) {
+        traffic.toggleAddress(address);
+      };
+
+      $scope.quiesceConnection = function(row) {
+        let entity = row.entity;
+        let state = $scope.quiesceState[entity.connectionId].state;
+        if (state === 'enabled') {
+          // start quiescing all links
+          $scope.quiesceState[entity.connectionId].state = 'quiescing';
+        } else if (state === 'quiesced') {
+          // start reviving all links
+          $scope.quiesceState[entity.connectionId].state = 'reviving';
+        }
+        $scope.multiDetails.updateState(entity);
+        dontHide = true;
+        $scope.multiDetails.selectRow(row.rowIndex, true);
+        $scope.multiDetails.showLinksList(row);
+      };
+      $scope.quiesceDisabled = function(row) {
+        return $scope.quiesceState[row.entity.connectionId].buttonDisabled;
+      };
+      $scope.quiesceText = function(row) {
+        return $scope.quiesceState[row.entity.connectionId].buttonText;
+      };
+      $scope.quiesceClass = function(row) {
+        const stateClassMap = {
+          enabled: 'btn-primary',
+          quiescing: 'btn-warning',
+          reviving: 'btn-warning',
+          quiesced: 'btn-danger'
+        };
+        return stateClassMap[$scope.quiesceState[row.entity.connectionId].state];
+      };
+
+      // This is the grid that shows each connection when a client node that represents multiple connections is clicked
+      $scope.multiData = [];
+      $scope.multiDetails = {
+        data: 'multiData',
+        enableColumnResize: true,
+        enableHorizontalScrollbar: 0,
+        enableVerticalScrollbar: 0,
+        jqueryUIDraggable: true,
+        enablePaging: false,
+        multiSelect: false,
+        enableSelectAll: false,
+        enableSelectionBatchEvent: false,
+        enableRowHeaderSelection: false,
+        noUnselect: true,
+        onRegisterApi: function (gridApi) {
+          if (gridApi.selection) {
+            gridApi.selection.on.rowSelectionChanged($scope, function(row){
+              let detailsDiv = d3.select('#link_details');
+              let isVis = detailsDiv.style('display') === 'block';
+              if (!dontHide && isVis && $scope.connectionId === row.entity.connectionId) {
+                hideLinkDetails();
+                return;
+              }
+              dontHide = false;
+              $scope.multiDetails.showLinksList(row);
+            });
+          }
+        },
+        showLinksList: function(obj) {
+          $scope.linkData = obj.entity.linkData;
+          $scope.connectionId = obj.entity.connectionId;
+          let visibleLen = Math.min(obj.entity.linkData.length, 10);
+          //QDR.log.debug("visibleLen is " + visibleLen)
+          let left = parseInt(d3.select('#multiple_details').style('left'), 10);
+          let offset = $('#topology').offset();
+          let detailsDiv = d3.select('#link_details');
+          detailsDiv
+            .style({
+              display: 'block',
+              opacity: 1,
+              left: (left + 20) + 'px',
+              top: (mouseY - offset.top + 20 + $(document).scrollTop()) + 'px',
+              height: ((visibleLen + 1) * 30) + 40 + 'px', // +1 for the header row
+              'overflow-y': obj.entity.linkData > 10 ? 'scroll' : 'hidden'
+            });
+        },
+        updateState: function(entity) {
+          let state = $scope.quiesceState[entity.connectionId].state;
+
+          // count enabled and disabled links for this connection
+          let enabled = 0,
+            disabled = 0;
+          entity.linkData.forEach(function(link) {
+            if (link.adminStatus === 'enabled')
+              ++enabled;
+            if (link.adminStatus === 'disabled')
+              ++disabled;
+          });
+
+          let linkCount = entity.linkData.length;
+          // if state is quiescing and any links are enabled, button should say 'Quiescing' and be disabled
+          if (state === 'quiescing' && (enabled > 0)) {
+            $scope.quiesceState[entity.connectionId].buttonText = 'Quiescing';
+            $scope.quiesceState[entity.connectionId].buttonDisabled = true;
+          } else
+          // if state is enabled and all links are disabled, button should say Revive and be enabled. set state to quisced
+          // if state is quiescing and all links are disabled, button should say 'Revive' and be enabled. set state to quiesced
+          if ((state === 'quiescing' || state === 'enabled') && (disabled === linkCount)) {
+            $scope.quiesceState[entity.connectionId].buttonText = 'Revive';
+            $scope.quiesceState[entity.connectionId].buttonDisabled = false;
+            $scope.quiesceState[entity.connectionId].state = 'quiesced';
+          } else
+          // if state is reviving and any links are disabled, button should say 'Reviving' and be disabled
+          if (state === 'reviving' && (disabled > 0)) {
+            $scope.quiesceState[entity.connectionId].buttonText = 'Reviving';
+            $scope.quiesceState[entity.connectionId].buttonDisabled = true;
+          } else
+          // if state is reviving or quiesced and all links are enabled, button should say 'Quiesce' and be enabled. set state to enabled
+          if ((state === 'reviving' || state === 'quiesced') && (enabled === linkCount)) {
+            $scope.quiesceState[entity.connectionId].buttonText = 'Quiesce';
+            $scope.quiesceState[entity.connectionId].buttonDisabled = false;
+            $scope.quiesceState[entity.connectionId].state = 'enabled';
+          }
+        },
+        columnDefs: [{
+          field: 'host',
+          cellTemplate: 'titleCellTemplate.html',
+          //headerCellTemplate: 'titleHeaderCellTemplate.html',
+          displayName: 'Connection host'
+        }, {
+          field: 'user',
+          cellTemplate: 'titleCellTemplate.html',
+          //headerCellTemplate: 'titleHeaderCellTemplate.html',
+          displayName: 'User'
+        }, {
+          field: 'properties',
+          cellTemplate: 'titleCellTemplate.html',
+          //headerCellTemplate: 'titleHeaderCellTemplate.html',
+          displayName: 'Properties'
+        }
+          /*,
+                {
+                  cellClass: 'gridCellButton',
+                  cellTemplate: '<button title="{{quiesceText(row)}} the links" type="button" ng-class="quiesceClass(row)" class="btn" ng-click="$event.stopPropagation();quiesceConnection(row)" ng-disabled="quiesceDisabled(row)">{{quiesceText(row)}}</button>'
+                }*/
+        ]
+      };
+      $scope.quiesceLinkClass = function(row) {
+        const stateClassMap = {
+          enabled: 'btn-primary',
+          disabled: 'btn-danger'
+        };
+        return stateClassMap[row.entity.adminStatus];
+      };
+      $scope.quiesceLink = function(row) {
+        QDRService.management.topology.quiesceLink(row.entity.nodeId, row.entity.name)
+          .then( function (results, context) {
+            let statusCode = context.message.application_properties.statusCode;
+            if (statusCode < 200 || statusCode >= 300) {
+              QDR.Core.notification('error', context.message.statusDescription);
+              QDR.log.info('Error ' + context.message.statusDescription);
+            }
+          });
+      };
+      $scope.quiesceLinkDisabled = function(row) {
+        return (row.entity.operStatus !== 'up' && row.entity.operStatus !== 'down');
+      };
+      $scope.quiesceLinkText = function(row) {
+        return row.entity.operStatus === 'down' ? 'Revive' : 'Quiesce';
+      };
+      $scope.linkData = [];
+      $scope.linkDetails = {
+        data: 'linkData',
+        jqueryUIDraggable: true,
+        columnDefs: [{
+          field: 'adminStatus',
+          cellTemplate: 'titleCellTemplate.html',
+          headerCellTemplate: 'titleHeaderCellTemplate.html',
+          displayName: 'Admin state'
+        }, {
+          field: 'operStatus',
+          cellTemplate: 'titleCellTemplate.html',
+          headerCellTemplate: 'titleHeaderCellTemplate.html',
+          displayName: 'Oper state'
+        }, {
+          field: 'dir',
+          cellTemplate: 'titleCellTemplate.html',
+          headerCellTemplate: 'titleHeaderCellTemplate.html',
+          displayName: 'dir'
+        }, {
+          field: 'owningAddr',
+          cellTemplate: 'titleCellTemplate.html',
+          headerCellTemplate: 'titleHeaderCellTemplate.html',
+          displayName: 'Address'
+        }, {
+          field: 'deliveryCount',
+          displayName: 'Delivered',
+          headerCellTemplate: 'titleHeaderCellTemplate.html',
+          cellClass: 'grid-values'
+
+        }, {
+          field: 'uncounts',
+          displayName: 'Outstanding',
+          headerCellTemplate: 'titleHeaderCellTemplate.html',
+          cellClass: 'grid-values'
+        }
+          /*,
+                {
+                  cellClass: 'gridCellButton',
+                  cellTemplate: '<button title="{{quiesceLinkText(row)}} this link" type="button" ng-class="quiesceLinkClass(row)" class="btn" ng-click="quiesceLink(row)" ng-disabled="quiesceLinkDisabled(row)">{{quiesceLinkText(row)}}</button>'
+                }*/
+        ]
+      };
+
+      let urlPrefix = $location.absUrl();
+      urlPrefix = urlPrefix.split('#')[0];
+      QDR.log.debug('started QDR.TopologyController with urlPrefix: ' + urlPrefix);
+
+      // mouse event vars
+      let selected_node = null,
+        selected_link = null,
+        mousedown_link = null,
+        mousedown_node = null,
+        mouseover_node = null,
+        mouseup_node = null,
+        initial_mouse_down_position = null;
+
+      $scope.schema = 'Not connected';
+
+      $scope.contextNode = null; // node that is associated with the current context menu
+      $scope.isRight = function(mode) {
+        return mode.right;
+      };
+
+      var setNodesFixed = function (name, b) {
+        nodes.some(function (n) {
+          if (n.name === name) {
+            n.fixed = b;
+            return true;
+          }
+        });
+      };
+      $scope.setFixed = function(b) {
+        if ($scope.contextNode) {
+          $scope.contextNode.fixed = b;
+          setNodesFixed($scope.contextNode.name, b);
+          savePositions();
+        }
+        restart();
+      };
+      $scope.isFixed = function() {
+        if (!$scope.contextNode)
+          return false;
+        return ($scope.contextNode.fixed & 1);
+      };
+
+      let mouseX, mouseY;
+      var relativeMouse = function () {
+        let offset = $('#main_container').offset();
+        return {left: (mouseX + $(document).scrollLeft()) - 1,
+          top: (mouseY  + $(document).scrollTop()) - 1,
+          offset: offset
+        };
+      };
+      // event handlers for popup context menu
+      $(document).mousemove(function(e) {
+        mouseX = e.clientX;
+        mouseY = e.clientY;
+      });
+      $(document).mousemove();
+      $(document).click(function() {
+        $scope.contextNode = null;
+        $('.contextMenu').fadeOut(200);
+      });
+
+      const radii = {
+        'inter-router': 25,
+        'normal': 15,
+        'on-demand': 15,
+        'route-container': 15,
+      };
+      let svg, lsvg;
+      let force;
+      let animate = false; // should the force graph organize itself when it is displayed
+      let path, circle;
+      let savedKeys = {};
+      let width = 0;
+      let height = 0;
+
+      var getSizes = function() {
+        let legendWidth = 143;
+        let display = $('#topo_svg_legend').css('display');
+        if (display === 'none')
+          legendWidth = 0;
+        const gap = 5;
+        let width = $('#topology').width() - gap - legendWidth;
+        let top = $('#topology').offset().top;
+        let tpformHeight = $('#topologyForm').height();
+        let height = Math.max(window.innerHeight, tpformHeight + top) - top - gap;
+        if (width < 10) {
+          QDR.log.info('page width and height are abnormal w:' + width + ' height:' + height);
+          return [0, 0];
+        }
+        return [width, height];
+      };
+      var resize = function() {
+        if (!svg)
+          return;
+        let sizes = getSizes();
+        width = sizes[0];
+        height = sizes[1];
+        if (width > 0) {
+          // set attrs and 'resume' force
+          svg.attr('width', width);
+          svg.attr('height', height);
+          force.size(sizes).resume();
+        }
+      };
+
+      $scope.$on('panel-resized', function () {
+        resize();
+      });
+      window.addEventListener('resize', resize);
+      let sizes = getSizes();
+      width = sizes[0];
+      height = sizes[1];
+      if (width <= 0 || height <= 0)
+        return;
+
+      var nodeExists = function (connectionContainer) {
+        return nodes.findIndex( function (node) {
+          return node.container === connectionContainer;
+        });
+      };
+      var normalExists = function (connectionContainer) {
+        let normalInfo = {};
+        for (let i=0; i<nodes.length; ++i) {
+          if (nodes[i].normals) {
+            if (nodes[i].normals.some(function (normal, j) {
+              if (normal.container === connectionContainer && i !== j) {
+                normalInfo = {nodesIndex: i, normalsIndex: j};
+                return true;
+              }
+              return false;
+            }))
+              break;
+          }
+        }
+        return normalInfo;
+      };
+      var getLinkSource = function (nodesIndex) {
+        for (let i=0; i<links.length; ++i) {
+          if (links[i].target === nodesIndex)
+            return i;
+        }
+        return -1;
+      };
+      var aNode = function(id, name, nodeType, nodeInfo, nodeIndex, x, y, connectionContainer, resultIndex, fixed, properties) {
+        properties = properties || {};
+        for (let i=0; i<nodes.length; ++i) {
+          if (nodes[i].name === name || nodes[i].container === connectionContainer) {
+            if (properties.product)
+              nodes[i].properties = properties;
+            return nodes[i];
+          }
+        }
+        let routerId = QDRService.management.topology.nameFromId(id);
+        return {
+          key: id,
+          name: name,
+          nodeType: nodeType,
+          properties: properties,
+          routerId: routerId,
+          x: x,
+          y: y,
+          id: nodeIndex,
+          resultIndex: resultIndex,
+          fixed: !!+fixed,
+          cls: '',
+          container: connectionContainer
+        };
+      };
+
+      var getLinkDir = function (id, connection, onode) {
+        let links = onode['router.link'];
+        if (!links) {
+          return 'unknown';
+        }
+        let inCount = 0, outCount = 0;
+        links.results.forEach( function (linkResult) {
+          let link = QDRService.utilities.flatten(links.attributeNames, linkResult);
+          if (link.linkType === 'endpoint' && link.connectionId === connection.identity)
+            if (link.linkDir === 'in')
+              ++inCount;
+            else
+              ++outCount;
+        });
+        if (inCount > 0 && outCount > 0)
+          return 'both';
+        if (inCount > 0)
+          return 'in';
+        if (outCount > 0)
+          return 'out';
+        return 'unknown';
+      };
+
+      var savePositions = function () {
+        nodes.forEach( function (d) {
+          localStorage[d.name] = angular.toJson({
+            x: Math.round(d.x),
+            y: Math.round(d.y),
+            fixed: (d.fixed & 1) ? 1 : 0,
+          });
+        });
+      };
+
+      var initializeNodes = function (nodeInfo) {
+        let nodeCount = Object.keys(nodeInfo).length;
+        let yInit = 50;
+        forceData.nodes = nodes = [];
+        for (let id in nodeInfo) {
+          let name = QDRService.management.topology.nameFromId(id);
+          // if we have any new nodes, animate the force graph to position them
+          let position = angular.fromJson(localStorage[name]);
+          if (!angular.isDefined(position)) {
+            animate = true;
+            position = {
+              x: Math.round(width / 4 + ((width / 2) / nodeCount) * nodes.length),
+              y: Math.round(height / 2 + Math.sin(nodes.length / (Math.PI*2.0)) * height / 4),
+              fixed: false,
+            };
+            //QDR.log.debug("new node pos (" + position.x + ", " + position.y + ")")
+          }
+          if (position.y > height) {
+            position.y = 200 - yInit;
+            yInit *= -1;
+          }
+          nodes.push(aNode(id, name, 'inter-router', nodeInfo, nodes.length, position.x, position.y, name, undefined, position.fixed));
+        }
+      };
+
+      var initializeLinks = function (nodeInfo, unknowns) {
+        forceData.links = links = [];
+        let source = 0;
+        let client = 1.0;
+        for (let id in nodeInfo) {
+          let onode = nodeInfo[id];
+          if (!onode['connection'])
+            continue;
+          let conns = onode['connection'].results;
+          let attrs = onode['connection'].attributeNames;
+          //QDR.log.debug("external client parent is " + parent);
+          let normalsParent = {}; // 1st normal node for this parent
+
+          for (let j = 0; j < conns.length; j++) {
+            let connection = QDRService.utilities.flatten(attrs, conns[j]);
+            let role = connection.role;
+            let properties = connection.properties || {};
+            let dir = connection.dir;
+            if (role == 'inter-router') {
+              let connId = connection.container;
+              let target = getContainerIndex(connId, nodeInfo);
+              if (target >= 0) {
+                getLink(source, target, dir, '', source + '-' + target);
+              }
+            } /* else if (role == "normal" || role == "on-demand" || role === "route-container")*/ {
+              // not an connection between routers, but an external connection
+              let name = QDRService.management.topology.nameFromId(id) + '.' + connection.identity;
+
+              // if we have any new clients, animate the force graph to position them
+              let position = angular.fromJson(localStorage[name]);
+              if (!angular.isDefined(position)) {
+                animate = true;
+                position = {
+                  x: Math.round(nodes[source].x + 40 * Math.sin(client / (Math.PI * 2.0))),
+                  y: Math.round(nodes[source].y + 40 * Math.cos(client / (Math.PI * 2.0))),
+                  fixed: false
+                };
+                //QDR.log.debug("new client pos (" + position.x + ", " + position.y + ")")
+              }// else QDR.log.debug("using previous location")
+              if (position.y > height) {
+                position.y = Math.round(nodes[source].y + 40 + Math.cos(client / (Math.PI * 2.0)));
+              }
+              let existingNodeIndex = nodeExists(connection.container);
+              let normalInfo = normalExists(connection.container);
+              let node = aNode(id, name, role, nodeInfo, nodes.length, position.x, position.y, connection.container, j, position.fixed, properties);
+              let nodeType = QDRService.utilities.isAConsole(properties, connection.identity, role, node.key) ? 'console' : 'client';
+              let cdir = getLinkDir(id, connection, onode);
+              if (existingNodeIndex >= 0) {
+                // make a link between the current router (source) and the existing node
+                getLink(source, existingNodeIndex, dir, 'small', connection.name);
+              } else if (normalInfo.nodesIndex) {
+                // get node index of node that contained this connection in its normals array
+                let normalSource = getLinkSource(normalInfo.nodesIndex);
+                if (normalSource >= 0) {
+                  if (cdir === 'unknown')
+                    cdir = dir;
+                  node.cdir = cdir;
+                  nodes.push(node);
+                  // create link from original node to the new node
+                  getLink(links[normalSource].source, nodes.length-1, cdir, 'small', connection.name);
+                  // create link from this router to the new node
+                  getLink(source, nodes.length-1, cdir, 'small', connection.name);
+                  // remove the old node from the normals list
+                  nodes[normalInfo.nodesIndex].normals.splice(normalInfo.normalsIndex, 1);
+                }
+              } else if (role === 'normal') {
+              // normal nodes can be collapsed into a single node if they are all the same dir
+                if (cdir !== 'unknown') {
+                  node.user = connection.user;
+                  node.isEncrypted = connection.isEncrypted;
+                  node.host = connection.host;
+                  node.connectionId = connection.identity;
+                  node.cdir = cdir;
+                  // determine arrow direction by using the link directions
+                  if (!normalsParent[nodeType+cdir]) {
+                    normalsParent[nodeType+cdir] = node;
+                    nodes.push(node);
+                    node.normals = [node];
+                    // now add a link
+                    getLink(source, nodes.length - 1, cdir, 'small', connection.name);
+                    client++;
+                  } else {
+                    normalsParent[nodeType+cdir].normals.push(node);
+                  }
+                } else {
+                  node.id = nodes.length - 1 + unknowns.length;
+                  unknowns.push(node);
+                }
+              } else {
+                nodes.push(node);
+                // now add a link
+                getLink(source, nodes.length - 1, dir, 'small', connection.name);
+                client++;
+              }
+            }
+          }
+          source++;
+        }
+      };
+
+      // vary the following force graph attributes based on nodeCount
+      // <= 6 routers returns min, >= 80 routers returns max, interpolate linearly
+      var forceScale = function(nodeCount, min, max) {
+        let count = nodeCount;
+        if (nodeCount < 6) count = 6;
+        if (nodeCount > 80) count = 80;
+        let x = d3.scale.linear()
+          .domain([6,80])
+          .range([min, max]);
+        //QDR.log.debug("forceScale(" + nodeCount + ", " + min + ", " + max + "  returns " + x(count) + " " + x(nodeCount))
+        return x(count);
+      };
+      var linkDistance = function (d, nodeCount) {
+        if (d.target.nodeType === 'inter-router')
+          return forceScale(nodeCount, 150, 70);
+        return forceScale(nodeCount, 75, 40);
+      };
+      var charge = function (d, nodeCount) {
+        if (d.nodeType === 'inter-router')
+          return forceScale(nodeCount, -1800, -900);
+        return -900;
+      };
+      var gravity = function (d, nodeCount) {
+        return forceScale(nodeCount, 0.0001, 0.1);
+      };
+      // initialize the nodes and links array from the QDRService.topology._nodeInfo object
+      var initForceGraph = function() {
+        forceData.nodes = nodes = [];
+        forceData.links = links = [];
+        let nodeInfo = QDRService.management.topology.nodeInfo();
+        let nodeCount = Object.keys(nodeInfo).length;
+
+        let oldSelectedNode = selected_node;
+        let oldMouseoverNode = mouseover_node;
+        mouseover_node = null;
+        selected_node = null;
+        selected_link = null;
+
+        savePositions();
+        d3.select('#SVG_ID').remove();
+        svg = d3.select('#topology')
+          .append('svg')
+          .attr('id', 'SVG_ID')
+          .attr('width', width)
+          .attr('height', height)
+          .on('click', function() {
+            removeCrosssection();
+          });
+
+        $(document).keyup(function(e) {
+          if (e.keyCode === 27) {
+            removeCrosssection();
+          }
+        });
+
+        // the legend
+        d3.select('#topo_svg_legend svg').remove();
+        lsvg = d3.select('#topo_svg_legend')
+          .append('svg')
+          .attr('id', 'svglegend');
+        lsvg = lsvg.append('svg:g')
+          .attr('transform', 'translate(' + (radii['inter-router'] + 2) + ',' + (radii['inter-router'] + 2) + ')')
+          .selectAll('g');
+
+        // mouse event vars
+        mousedown_link = null;
+        mousedown_node = null;
+        mouseup_node = null;
+
+        // initialize the list of nodes
+        initializeNodes(nodeInfo);
+        savePositions();
+
+        // initialize the list of links
+        let unknowns = [];
+        initializeLinks(nodeInfo, unknowns);
+        $scope.schema = QDRService.management.schema();
+        // init D3 force layout
+        force = d3.layout.force()
+          .nodes(nodes)
+          .links(links)
+          .size([width, height])
+          .linkDistance(function(d) { return linkDistance(d, nodeCount); })
+          .charge(function(d) { return charge(d, nodeCount); })
+          .friction(.10)
+          .gravity(function(d) { return gravity(d, nodeCount); })
+          .on('tick', tick)
+          .on('end', function () {savePositions();})
+          .start();
+
+        svg.append('svg:defs').selectAll('marker')
+          .data(['end-arrow', 'end-arrow-selected', 'end-arrow-small', 'end-arrow-highlighted']) // Different link/path types can be defined here
+          .enter().append('svg:marker') // This section adds in the arrows
+          .attr('id', String)
+          .attr('viewBox', '0 -5 10 10')
+          .attr('refX', 24)
+          .attr('markerWidth', 4)
+          .attr('markerHeight', 4)
+          .attr('orient', 'auto')
+          .classed('small', function (d) {return d.indexOf('small') > -1;})
+          .append('svg:path')
+          .attr('d', 'M 0 -5 L 10 0 L 0 5 z');
+
+        svg.append('svg:defs').selectAll('marker')
+          .data(['start-arrow', 'start-arrow-selected', 'start-arrow-small', 'start-arrow-highlighted']) // Different link/path types can be defined here
+          .enter().append('svg:marker') // This section adds in the arrows
+          .attr('id', String)
+          .attr('viewBox', '0 -5 10 10')
+          .attr('refX', function (d) { return d !== 'start-arrow-small' ? -14 : -24;})
+          .attr('markerWidth', 4)
+          .attr('markerHeight', 4)
+          .attr('orient', 'auto')
+          .append('svg:path')
+          .attr('d', 'M 10 -5 L 0 0 L 10 5 z');
+
+        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');
+
+        // handles to link and node element groups
+        path = svg.append('svg:g').selectAll('path'),
+        circle = svg.append('svg:g').selectAll('g');
+
+        // app starts here
+        restart(false);
+        force.start();
+        if (oldSelectedNode) {
+          d3.selectAll('circle.inter-router').classed('selected', function (d) {
+            if (d.key === oldSelectedNode.key) {
+              selected_node = d;
+              return true;
+            }
+            return false;
+          });
+        }
+        if (oldMouseoverNode && selected_node) {
+          d3.selectAll('circle.inter-router').each(function (d) {
+            if (d.key === oldMouseoverNode.key) {
+              mouseover_node = d;
+              QDRService.management.topology.ensureAllEntities([{entity: 'router.node', attrs: ['id','nextHop']}], function () {
+                nextHop(selected_node, d);
+                restart();
+              });
+            }
+          });
+        }
+        setTimeout(function () {
+          updateForm(Object.keys(QDRService.management.topology.nodeInfo())[0], 'router', 0);
+        });
+
+        // if any clients don't yet have link directions, get the links for those nodes and restart the graph
+        if (unknowns.length > 0)
+          setTimeout(resolveUnknowns, 10, nodeInfo, unknowns);
+
+        var continueForce = function (extra) {
+          if (extra > 0) {
+            --extra;
+            force.start();
+            setTimeout(continueForce, 100, extra);
+          }
+        };
+        continueForce(forceScale(nodeCount, 0, 200));  // give graph time to settle down
+      };
+
+      var resolveUnknowns = function (nodeInfo, unknowns) {
+        let unknownNodes = {};
+        // collapse the unknown node.keys using an object
+        for (let i=0; i<unknowns.length; ++i) {
+          unknownNodes[unknowns[i].key] = 1;
+        }
+        unknownNodes = Object.keys(unknownNodes);
+        //QDR.log.info("-- resolveUnknowns: ensuring .connection and .router.link are present for each node")
+        QDRService.management.topology.ensureEntities(unknownNodes, [{entity: 'connection', force: true}, {entity: 'router.link', attrs: ['linkType','connectionId','linkDir'], force: true}], function () {
+          nodeInfo = QDRService.management.topology.nodeInfo();
+          initializeLinks(nodeInfo, []);
+          // collapse any router-container nodes that are duplicates
+          animate = true;
+          force.nodes(nodes).links(links).start();
+          restart(false);
+        });
+      };
+
+      function updateForm(key, entity, resultIndex) {
+        if (!angular.isDefined(resultIndex))
+          return;
+        let nodeList = QDRService.management.topology.nodeIdList();
+        if (nodeList.indexOf(key) > -1) {
+          QDRService.management.topology.fetchEntities(key, [
+            {entity: entity},
+            {entity: 'listener', attrs: ['role', 'port']}], function (results) {
+            let onode = results[key];
+            if (!onode[entity]) {
+              console.log('requested ' + entity + ' but didn\'t get it');
+              return;
+            }
+            let nodeResults = onode[entity].results[resultIndex];
+            let nodeAttributes = onode[entity].attributeNames;
+            let attributes = nodeResults.map(function(row, i) {
+              return {
+                attributeName: nodeAttributes[i],
+                attributeValue: row
+              };
+            });
+            // sort by attributeName
+            attributes.sort(function(a, b) {
+              return a.attributeName.localeCompare(b.attributeName);
+            });
+
+            // move the Name first
+            let nameIndex = attributes.findIndex(function(attr) {
+              return attr.attributeName === 'name';
+            });
+            if (nameIndex >= 0)
+              attributes.splice(0, 0, attributes.splice(nameIndex, 1)[0]);
+
+            // get the list of ports this router is listening on
+            if (entity === 'router') {
+              let listeners = onode['listener'].results;
+              let listenerAttributes = onode['listener'].attributeNames;
+              let normals = listeners.filter(function(listener) {
+                return QDRService.utilities.valFor(listenerAttributes, listener, 'role') === 'normal';
+              });
+              let ports = [];
+              normals.forEach(function(normalListener) {
+                ports.push(QDRService.utilities.valFor(listenerAttributes, normalListener, 'port'));
+              });
+              // add as 2nd row
+              if (ports.length) {
+                attributes.splice(1, 0, {
+                  attributeName: 'Listening on',
+                  attributeValue: ports,
+                  description: 'The port(s) on which this router is listening for connections'
+                });
+              }
+            }
+            $rootScope.$broadcast('showEntityForm', {
+              entity: entity,
+              attributes: attributes
+            });
+            if (!$scope.$$phase) $scope.$apply();
+          });
+        }
+      }
+
+      function getContainerIndex(_id, nodeInfo) {
+        let nodeIndex = 0;
+        for (let id in nodeInfo) {
+          if (QDRService.management.topology.nameFromId(id) === _id)
+            return nodeIndex;
+          ++nodeIndex;
+        }
+        return -1;
+      }
+
+      function getLink(_source, _target, dir, cls, uid) {
+        for (let i = 0; i < links.length; i++) {
+          let s = links[i].source,
+            t = links[i].target;
+          if (typeof 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;
+          }
+        }
+        //QDR.log.debug("creating new link (" + (links.length) + ") between " + nodes[_source].name + " and " + nodes[_target].name);
+        if (links.some( function (l) { return l.uid === uid;}))
+          uid = uid + '.' + links.length;
+        let link = {
+          source: _source,
+          target: _target,
+          left: dir != 'out',
+          right: (dir == 'out' || dir == 'both'),
+          cls: cls,
+          uid: uid,
+        };
+        return links.push(link) - 1;
+      }
+
+
+      function resetMouseVars() {
+        mousedown_node = null;
+        mouseover_node = null;
+        mouseup_node = null;
+        mousedown_link = null;
+      }
+
+      // update force layout (called automatically each iteration)
+      function tick() {
+        circle.attr('transform', function(d) {
+          let cradius;
+          if (d.nodeType == 'inter-router') {
+            cradius = d.left ? radius + 8 : radius;
+          } else {
+            cradius = d.left ? radiusNormal + 18 : radiusNormal;
+          }
+          d.x = Math.max(d.x, radiusNormal * 2);
+          d.y = Math.max(d.y, radiusNormal * 2);
+          d.x = Math.max(0, Math.min(width - cradius, d.x));
+          d.y = Math.max(0, Math.min(height - cradius, d.y));
+          return 'translate(' + d.x + ',' + d.y + ')';
+        });
+
+        // draw directed edges with proper padding from node centers
+        path.attr('d', function(d) {
+          let sourcePadding, targetPadding, r;
+
+          r = d.target.nodeType === 'inter-router' ? radius : radiusNormal - 18;
+          sourcePadding = targetPadding = 0;
+          let dtx = Math.max(targetPadding, Math.min(width - r, d.target.x)),
+            dty = Math.max(targetPadding, Math.min(height - r, d.target.y)),
+            dsx = Math.max(sourcePadding, Math.min(width - r, d.source.x)),
+            dsy = Math.max(sourcePadding, Math.min(height - r, d.source.y));
+
+          let deltaX = dtx - dsx,
+            deltaY = dty - dsy,
+            dist = Math.sqrt(deltaX * deltaX + deltaY * deltaY);
+          if (dist == 0)
+            dist = 0.001;
+          let normX = deltaX / dist,
+            normY = deltaY / dist;
+          let sourceX = dsx + (sourcePadding * normX),
+            sourceY = dsy + (sourcePadding * normY),
+            targetX = dtx - (targetPadding * normX),
+            targetY = dty - (targetPadding * normY);
+          sourceX = Math.max(0, sourceX);
+          sourceY = Math.max(0, sourceY);
+          targetX = Math.max(0, targetX);
+          targetY = Math.max(0, targetY);
+
+          return 'M' + sourceX + ',' + sourceY + 'L' + targetX + ',' + targetY;
+        })
+          .attr('id', function (d) {
+            return ['path', d.source.index, d.target.index].join('-');
+          });
+
+        if (!animate) {
+          animate = true;
+          force.stop();
+        }
+      }
+
+      // highlight the paths between the selected node and the hovered node
+      function findNextHopNode(from, d) {
+        // d is the node that the mouse is over
+        // from is the selected_node ....
+        if (!from)
+          return null;
+
+        if (from == d)
+          return selected_node;
+
+        //QDR.log.debug("finding nextHop from: " + from.name + " to " + d.name);
+        let sInfo = QDRService.management.topology.nodeInfo()[from.key];
+
+        if (!sInfo) {
+          QDR.log.warn('unable to find topology node info for ' + from.key);
+          return null;
+        }
+
+        // find the hovered name in the selected name's .router.node results
+        if (!sInfo['router.node'])
+          return null;
+        let aAr = sInfo['router.node'].attributeNames;
+        let vAr = sInfo['router.node'].results;
+        for (let hIdx = 0; hIdx < vAr.length; ++hIdx) {
+          let addrT = QDRService.utilities.valFor(aAr, vAr[hIdx], 'id');
+          if (addrT == d.name) {
+            //QDR.log.debug("found " + d.name + " at " + hIdx);
+            let nextHop = QDRService.utilities.valFor(aAr, vAr[hIdx], 'nextHop');
+            //QDR.log.debug("nextHop was " + nextHop);
+            return (nextHop == null) ? nodeFor(addrT) : nodeFor(nextHop);
+          }
+        }
+        return null;
+      }
+
+      function nodeFor(name) {
+        for (let i = 0; i < nodes.length; ++i) {
+          if (nodes[i].name == name)
+            return nodes[i];
+        }
+        return null;
+      }
+
+      function linkFor(source, target) {
+        for (let i = 0; i < links.length; ++i) {
+          if ((links[i].source == source) && (links[i].target == target))
+            return links[i];
+          if ((links[i].source == target) && (links[i].target == source))
+            return links[i];
+        }
+        // the selected node was a client/broker
+        //QDR.log.debug("failed to find a link between ");
+        //console.dump(source);
+        //QDR.log.debug(" and ");
+        //console.dump(target);
+        return null;
+      }
+
+      function clearPopups() {
+        d3.select('#crosssection').style('display', 'none');
+        $('.hastip').empty();
+        d3.select('#multiple_details').style('display', 'none');
+        d3.select('#link_details').style('display', 'none');
+        d3.select('#node_context_menu').style('display', 'none');
+
+      }
+
+      function removeCrosssection() {
+        d3.select('#crosssection svg g').transition()
+          .duration(1000)
+          .attr('transform', 'scale(0)')
+          .style('opacity', 0)
+          .each('end', function () {
+            d3.select('#crosssection svg').remove();
+            d3.select('#crosssection').style('display','none');
+          });
+        d3.select('#multiple_details').transition()
+          .duration(500)
+          .style('opacity', 0)
+          .each('end', function() {
+            d3.select('#multiple_details').style('display', 'none');
+            stopUpdateConnectionsGrid();
+          });
+        hideLinkDetails();
+      }
+
+      function hideLinkDetails() {
+        d3.select('#link_details').transition()
+          .duration(500)
+          .style('opacity', 0)
+          .each('end', function() {
+            d3.select('#link_details').style('display', 'none');
+          });
+      }
+
+      function clerAllHighlights() {
+        for (let i = 0; i < links.length; ++i) {
+          links[i]['highlighted'] = false;
+        }
+        for (let i = 0; i<nodes.length; ++i) {
+          nodes[i]['highlighted'] = false;
+        }
+      }
+      // takes the nodes and links array of objects and adds svg elements for everything that hasn't already
+      // been added
+      function restart(start) {
+        circle.call(force.drag);
+
+        // path (link) group
+        path = path.data(links, function(d) {return d.uid;});
+
+        // update existing links
+        path.classed('selected', function(d) {
+          return d === selected_link;
+        })
+          .classed('highlighted', function(d) {
+            return d.highlighted;
+          })
+          .attr('marker-start', function(d) {
+            let sel = d === selected_link ? '-selected' : (d.cls === 'small' ? '-small' : '');
+            if (d.highlighted)
+              sel = '-highlighted';
+            return d.left ? 'url(' + urlPrefix + '#start-arrow' + sel + ')' : '';
+          })
+          .attr('marker-end', function(d) {
+            let sel = d === selected_link ? '-selected' : (d.cls === 'small' ? '-small' : '');
+            if (d.highlighted)
+              sel = '-highlighted';
+            return d.right ? 'url(' + urlPrefix + '#end-arrow' + sel + ')' : '';
+          });
+        // add new links. if a link with a new uid is found in the data, add a new path
+        path.enter().append('svg:path')
+          .attr('class', 'link')
+          .attr('marker-start', function(d) {
+            let sel = d === selected_link ? '-selected' : (d.cls === 'small' ? '-small' : '');
+            return d.left ? 'url(' + urlPrefix + '#start-arrow' + sel + ')' : '';
+          })
+          .attr('marker-end', function(d) {
+            let sel = d === selected_link ? '-selected' : (d.cls === 'small' ? '-small' : '');
+            return d.right ? 'url(' + urlPrefix + '#end-arrow' + sel + ')' : '';
+          })
+          .classed('small', function(d) {
+            return d.cls == 'small';
+          })
+          .on('mouseover', function(d) { // mouse over a path
+            let resultIndex = 0; // the connection to use
+            let left = d.left ? d.target : d.source;
+            // right is the node that the arrow points to, left is the other node
+            let right = d.left ? d.source : d.target;
+            let onode = QDRService.management.topology.nodeInfo()[left.key];
+            // loop through all the connections for left, and find the one for right
+            if (!onode || !onode['connection'])
+              return;
+            // update the info dialog for the link the mouse is over
+            if (!selected_node && !selected_link) {
+              for (resultIndex = 0; resultIndex < onode['connection'].results.length; ++resultIndex) {
+                let conn = onode['connection'].results[resultIndex];
+                /// find the connection whose container is the right's name
+                let name = QDRService.utilities.valFor(onode['connection'].attributeNames, conn, 'container');
+                if (name == right.routerId) {
+                  break;
+                }
+              }
+              // did not find connection. this is a connection to a non-interrouter node
+              if (resultIndex === onode['connection'].results.length) {
+                // use the non-interrouter node's connection info
+                left = d.target;
+                resultIndex = left.resultIndex;
+              }
+              updateForm(left.key, 'connection', resultIndex);
+            }
+
+            mousedown_link = d;
+            selected_link = mousedown_link;
+            restart();
+          })
+          .on('mousemove', function (d) {
+            let top = $('#topology').offset().top - 5;
+            $timeout(function () {
+              $scope.trustedpopoverContent = $sce.trustAsHtml(connectionPopupHTML(d));
+            });
+            d3.select('#popover-div')
+              .style('display', 'block')
+              .style('left', (d3.event.pageX+5)+'px')
+              .style('top', (d3.event.pageY-top)+'px');
+          })
+          .on('mouseout', function() { // mouse out of a path
+            d3.select('#popover-div')
+              .style('display', 'none');
+            selected_link = null;
+            restart();
+          })
+          // left click a path
+          .on('click', function (d) {
+            let clickPos = d3.mouse(this);
+            d3.event.stopPropagation();
+            clearPopups();
+            var showCrossSection = function() {
+              const diameter = 400;
+              let pack = d3.layout.pack()
+                .size([diameter - 4, diameter - 4])
+                .padding(-10)
+                .value(function(d) { return d.size; });
+
+              d3.select('#crosssection svg').remove();
+              let svg = d3.select('#crosssection').append('svg')
+                .attr('width', diameter)
+                .attr('height', diameter);
+
+              let rg = svg.append('svg:defs')
+                .append('radialGradient')
+                .attr('id', 'cross-gradient')
+                .attr('gradientTransform', 'scale(2.0) translate(-0.5,-0.5)');
+
+              rg
+                .append('stop')
+                .attr('offset', '0%')
+                .attr('stop-color', '#feffff');
+              rg
+                .append('stop')
+                .attr('offset', '40%')
+                .attr('stop-color', '#cfe2f3');
+
+              let svgg = svg.append('g')
+                .attr('transform', 'translate(2,2)');
+
+              svgg
+                .append('rect')
+                .attr('x', 0)
+                .attr('y', 0)
+                .attr('width', 200)
+                .attr('height', 200)
+                .attr('class', 'cross-rect')
+                .attr('fill', 'url('+urlPrefix+'#cross-gradient)');
+
+              svgg
+                .append('line')
+                .attr('class', 'cross-line')
+                .attr({x1: 2, y1: 0, x2: 200, y2: 0});
+              svgg
+                .append('line')
+                .attr('class', 'cross-line')
+                .attr({x1: 2, y1: 0, x2: 0, y2: 200});
+
+              /*
+              let simpleLine = d3.svg.line();
+              svgg
+                .append('path')
+                .attr({
+                  d: simpleLine([[0,0],[0,200]]),
+                  stroke: '#000',
+                  'stroke-width': '4px'
+                });
+              svgg
+                .append('path')
+                .attr({
+                  d: simpleLine([[0,0],[200,0]]),
+                  stroke: '#000',
+                  'stroke-width': '4px'
+                });
+*/
+              let root = {
+                name: ' Links between ' + d.source.name + ' and ' + d.target.name,
+                children: []
+              };
+              let nodeInfo = QDRService.management.topology.nodeInfo();
+              let connections = nodeInfo[d.source.key]['connection'];
+              let containerIndex = connections.attributeNames.indexOf('container');
+              connections.results.some ( function (connection) {
+                if (connection[containerIndex] == d.target.routerId) {
+                  root.attributeNames = connections.attributeNames;
+                  root.obj = connection;
+                  root.desc = 'Connection';
+                  return true;    // stop looping after 1 match
+                }
+                return false;
+              });
+
+              // find router.links where link.remoteContainer is d.source.name
+              let links = nodeInfo[d.source.key]['router.link'];
+              let identityIndex = connections.attributeNames.indexOf('identity');
+              let roleIndex = connections.attributeNames.indexOf('role');
+              let connectionIdIndex = links.attributeNames.indexOf('connectionId');
+              let linkTypeIndex = links.attributeNames.indexOf('linkType');
+              let nameIndex = links.attributeNames.indexOf('name');
+              let linkDirIndex = links.attributeNames.indexOf('linkDir');
+
+              if (roleIndex < 0 || identityIndex < 0 || connectionIdIndex < 0
+                || linkTypeIndex < 0 || nameIndex < 0 || linkDirIndex < 0)
+                return;
+              links.results.forEach ( function (link) {
+                if (root.obj && link[connectionIdIndex] == root.obj[identityIndex] && link[linkTypeIndex] == root.obj[roleIndex])
+                  root.children.push (
+                    { name: ' ' + link[linkDirIndex] + ' ',
+                      size: 100,
+                      obj: link,
+                      desc: 'Link',
+                      attributeNames: links.attributeNames
+                    });
+              });
+              if (root.children.length == 0)
+                return;
+              let node = svgg.datum(root).selectAll('.node')
+                .data(pack.nodes)
+                .enter().append('g')
+                .attr('class', function(d) { return d.children ? 'parent node hastip' : 'leaf node hastip'; })
+                .attr('transform', function(d) { return 'translate(' + d.x + ',' + d.y + ')' + (!d.children ? 'scale(0.9)' : ''); });
+              node.append('circle')
+                .attr('r', function(d) { return d.r; });
+
+              node.on('mouseenter', function (d) {
+                let title = '<h4>' + d.desc + '</h4><table class=\'tiptable\'><tbody>';
+                if (d.attributeNames)
+                  d.attributeNames.forEach( function (n, i) {
+                    title += '<tr><td>' + n + '</td><td>';
+                    title += d.obj[i] != null ? d.obj[i] : '';
+                    title += '</td></tr>';
+                  });
+                title += '</tbody></table>';
+                $timeout( (function () {
+                  $scope.crosshtml = $sce.trustAsHtml(title);
+                  $('#crosshtml').show();
+                  let parent = $('#crosssection');
+                  let ppos = parent.position();
+                  let mleft = ppos.left + parent.width();
+                  $('#crosshtml').css({left: mleft, top: ppos.top});
+                }).bind(this));
+              });
+              node.on('mouseout', function () {
+                $('#crosshtml').hide();
+              });
+
+              node.append('text')
+                .attr('dy', function (d) { return d.children ? '-10em' : '.5em';})
+                .style('text-anchor', 'middle')
+                .text(function(d) {
+                  return d.name.substring(0, d.r / 3);
+                });
+              svgg.attr('transform', 'translate(2,2) scale(0.01)');
+
+              let bounds = $('#topology').position();
+              d3.select('#crosssection')
+                .style('display', 'block')
+                .style('left', (clickPos[0] + bounds.left) + 'px')
+                .style('top', (clickPos[1] + bounds.top) + 'px');
+
+              svgg.transition()
+                .attr('transform', 'translate(2,2) scale(1)')
+                .each('end', function ()  {
+                  d3.selectAll('#crosssection g.leaf text').attr('dy', '.3em');
+                });
+            };
+            QDRService.management.topology.ensureEntities(d.source.key, {entity: 'router.link', force: true}, showCrossSection);
+          });
+        // remove old links
+        path.exit().remove();
+
+
+        // circle (node) group
+        // nodes are known by id
+        circle = circle.data(nodes, function(d) {
+          return d.name;
+        });
+
+        // update existing nodes visual states
+        circle.selectAll('circle')
+          .classed('highlighted', function(d) {
+            return d.highlighted;
+          })
+          .classed('selected', function(d) {
+            return (d === selected_node);
+          })
+          .classed('fixed', function(d) {
+            return d.fixed & 1;
+          });
+
+        // add new circle nodes. if nodes[] is longer than the existing paths, add a new path for each new element
+        let g = circle.enter().append('svg:g')
+          .classed('multiple', function(d) {
+            return (d.normals && d.normals.length > 1);
+          })
+          .attr('id', function (d) { return (d.nodeType !== 'normal' ? 'router' : 'client') + '-' + d.index; });
+
+        appendCircle(g)
+          .on('mouseover', function(d) {  // mouseover a circle
+            if (!selected_node && !mousedown_node) {
+              if (d.nodeType === 'inter-router') {
+                //QDR.log.debug("showing general form");
+                updateForm(d.key, 'router', 0);
+              } else if (d.nodeType === 'normal' || d.nodeType === 'on-demand' || d.nodeType === 'route-container') {
+                //QDR.log.debug("showing connections form");
+                updateForm(d.key, 'connection', d.resultIndex);
+              }
+            }
+
+            if (d === mousedown_node)
+              return;
+            //if (d === selected_node)
+            //    return;
+            // enlarge target node
+            d3.select(this).attr('transform', 'scale(1.1)');
+            // highlight the next-hop route from the selected node to this node
+            //mousedown_node = null;
+
+            if (!selected_node) {
+              return;
+            }
+            clerAllHighlights();
+            // we need .router.node info to highlight hops
+            QDRService.management.topology.ensureAllEntities([{entity: 'router.node', attrs: ['id','nextHop']}], function () {
+              mouseover_node = d;  // save this node in case the topology changes so we can restore the highlights
+              nextHop(selected_node, d);
+              restart();
+            });
+          })
+          .on('mouseout', function() { // mouse out for a circle
+            // unenlarge target node
+            d3.select(this).attr('transform', '');
+            clerAllHighlights();
+            mouseover_node = null;
+            restart();
+          })
+          .on('mousedown', function(d) { // mouse down for circle
+            if (d3.event.button !== 0) { // ignore all but left button
+              return;
+            }
+            mousedown_node = d;
+            // mouse position relative to svg
+            initial_mouse_down_position = d3.mouse(this.parentNode.parentNode.parentNode).slice();
+          })
+          .on('mouseup', function(d) {  // mouse up for circle
+            if (!mousedown_node)
+              return;
+
+            selected_link = null;
+            // unenlarge target node
+            d3.select(this).attr('transform', '');
+
+            // check for drag
+            mouseup_node = d;
+
+            let mySvg = this.parentNode.parentNode.parentNode;
+            // if we dragged the node, make it fixed
+            let cur_mouse = d3.mouse(mySvg);
+            if (cur_mouse[0] != initial_mouse_down_position[0] ||
+              cur_mouse[1] != initial_mouse_down_position[1]) {
+              d.fixed = true;
+              setNodesFixed(d.name, true);
+              resetMouseVars();
+              restart();
+              return;
+            }
+
+            // if this node was selected, unselect it
+            if (mousedown_node === selected_node) {
+              selected_node = null;
+            } else {
+              if (d.nodeType !== 'normal' && d.nodeType !== 'on-demand')
+                selected_node = mousedown_node;
+            }
+            clerAllHighlights();
+            mousedown_node = null;
+            if (!$scope.$$phase) $scope.$apply();
+            restart(false);
+
+          })
+          .on('dblclick', function(d) { // circle
+            if (d.fixed) {
+              d.fixed = false;
+              setNodesFixed(d.name, false);
+              restart(); // redraw the node without a dashed line
+              force.start(); // let the nodes move to a new position
+            }
+          })
+          .on('contextmenu', function(d) {  // circle
+            $(document).click();
+            d3.event.preventDefault();
+            let rm = relativeMouse();
+            d3.select('#node_context_menu')
+              .style({
+                display: 'block',
+                left: rm.left + 'px',
+                top: (rm.top - rm.offset.top) + 'px'
+              });
+            $timeout( function () {
+              $scope.contextNode = d;
+            });
+          })
+          .on('click', function(d) {  // circle
+            if (!mouseup_node)
+              return;
+            // clicked on a circle
+            clearPopups();
+            if (!d.normals) {
+              // circle was a router or a broker
+              if (QDRService.utilities.isArtemis(d)) {
+                const artemisPath = '/jmx/attributes?tab=artemis&con=Artemis';
+                if (QDR.isStandalone)
+                  window.location = $location.protocol() + '://localhost:8161/hawtio' + artemisPath;
+                else
+                  $location.path(artemisPath);
+              }
+              return;
+            }
+            d3.event.stopPropagation();
+            startUpdateConnectionsGrid(d);
+          });
+
+        appendContent(g);
+        appendTitle(g);
+
+        // remove old nodes
+        circle.exit().remove();
+
+        // add subcircles
+        svg.selectAll('.subcircle').remove();
+        let multiples = svg.selectAll('.multiple');
+        multiples.each(function(d) {
+          d.normals.forEach(function(n, i) {
+            if (i < d.normals.length - 1 && i < 3) // only show a few shadow circles
+              this.insert('svg:circle', ':first-child')
+                .attr('class', 'subcircle node')
+                .attr('r', 15 - i)
+                .attr('transform', 'translate(' + 4 * (i + 1) + ', 0)');
+          }, d3.select(this));
+        });
+        // call createLegend in timeout because:
+        // If we create the legend right away, then it will be destroyed when the accordian
+        // gets initialized as the page loads.
+        $timeout(createLegend);
+
+        if (!mousedown_node || !selected_node)
+          return;
+
+        if (!start)
+          return;
+        // set the graph in motion
+        //QDR.log.debug("mousedown_node is " + mousedown_node);
+        force.start();
+
+      }
+      let createLegend = function () {
+        // dynamically create the legend based on which node types are present
+        // the legend
+        d3.select('#topo_svg_legend svg').remove();
+        lsvg = d3.select('#topo_svg_legend')
+          .append('svg')
+          .attr('id', 'svglegend');
+        lsvg = lsvg.append('svg:g')
+          .attr('transform', 'translate(' + (radii['inter-router'] + 2) + ',' + (radii['inter-router'] + 2) + ')')
+          .selectAll('g');
+        let legendNodes = [];
+        legendNodes.push(aNode('Router', '', 'inter-router', '', undefined, 0, 0, 0, 0, false, {}));
+
+        if (!svg.selectAll('circle.console').empty()) {
+          legendNodes.push(aNode('Console', '', 'normal', '', undefined, 1, 0, 0, 0, false, {
+            console_identifier: 'Dispatch console'
+          }));
+        }
+        if (!svg.selectAll('circle.client.in').empty()) {
+          let node = aNode('Sender', '', 'normal', '', undefined, 2, 0, 0, 0, false, {});
+          node.cdir = 'in';
+          legendNodes.push(node);
+        }
+        if (!svg.selectAll('circle.client.out').empty()) {
+          let node = aNode('Receiver', '', 'normal', '', undefined, 3, 0, 0, 0, false, {});
+          node.cdir = 'out';
+          legendNodes.push(node);
+        }
+        if (!svg.selectAll('circle.client.inout').empty()) {
+          let node = aNode('Sender/Receiver', '', 'normal', '', undefined, 4, 0, 0, 0, false, {});
+          node.cdir = 'both';
+          legendNodes.push(node);
+        }
+        if (!svg.selectAll('circle.qpid-cpp').empty()) {
+          legendNodes.push(aNode('Qpid broker', '', 'route-container', '', undefined, 5, 0, 0, 0, false, {
+            product: 'qpid-cpp'
+          }));
+        }
+        if (!svg.selectAll('circle.artemis').empty()) {
+          legendNodes.push(aNode('Artemis broker', '', 'route-container', '', undefined, 6, 0, 0, 0, false,
+            {product: 'apache-activemq-artemis'}));
+        }
+        if (!svg.selectAll('circle.route-container').empty()) {
+          legendNodes.push(aNode('Service', '', 'route-container', 'external-service', undefined, 7, 0, 0, 0, false,
+            {product: ' External Service'}));
+        }
+        lsvg = lsvg.data(legendNodes, function(d) {
+          return d.key;
+        });
+        let lg = lsvg.enter().append('svg:g')
+          .attr('transform', function(d, i) {
+            // 45px between lines and add 10px space after 1st line
+            return 'translate(0, ' + (45 * i + (i > 0 ? 10 : 0)) + ')';
+          });
+
+        appendCircle(lg);
+        appendContent(lg);
+        appendTitle(lg);
+        lg.append('svg:text')
+          .attr('x', 35)
+          .attr('y', 6)
+          .attr('class', 'label')
+          .text(function(d) {
+            return d.key;
+          });
+        lsvg.exit().remove();
+        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';
+        }
+      };
+      let appendCircle = function(g) {
+        // add new circles and set their attr/class/behavior
+        return g.append('svg:circle')
+          .attr('class', 'node')
+          .attr('r', function(d) {
+            return radii[d.nodeType];
+          })
+          .attr('fill', function (d) {
+            if (d.cdir === 'both' && !QDRService.utilities.isConsole(d)) {
+              return 'url(' + urlPrefix + '#half-circle)';
+            }
+            return null;
+          })
+          .classed('fixed', function(d) {
+            return d.fixed & 1;
+          })
+          .classed('normal', function(d) {
+            return d.nodeType == 'normal' || QDRService.utilities.isConsole(d);
+          })
+          .classed('in', function(d) {
+            return d.cdir == 'in';
+          })
+          .classed('out', function(d) {
+            return d.cdir == 'out';
+          })
+          .classed('inout', function(d) {
+            return d.cdir == 'both';
+          })
+          .classed('inter-router', function(d) {
+            return d.nodeType == 'inter-router';
+          })
+          .classed('on-demand', function(d) {
+            return d.nodeType == 'on-demand';
+          })
+          .classed('console', function(d) {
+            return QDRService.utilities.isConsole(d);
+          })
+          .classed('artemis', function(d) {
+            return QDRService.utilities.isArtemis(d);
+          })
+          .classed('qpid-cpp', function(d) {
+            return QDRService.utilities.isQpid(d);
+          })
+          .classed('route-container', function (d) {
+            return (!QDRService.utilities.isArtemis(d) && !QDRService.utilities.isQpid(d) && d.nodeType === 'route-container');
+          })
+          .classed('client', function(d) {
+            return d.nodeType === 'normal' && !d.properties.console_identifier;
+          });
+      };
+      let appendContent = function(g) {
+        // show node IDs
+        g.append('svg:text')
+          .attr('x', 0)
+          .attr('y', function(d) {
+            let y = 7;
+            if (QDRService.utilities.isArtemis(d))
+              y = 8;
+            else if (QDRService.utilities.isQpid(d))
+              y = 9;
+            else if (d.nodeType === 'inter-router')
+              y = 4;
+            else if (d.nodeType === 'route-container')
+              y = 5;
+            return y;
+          })
+          .attr('class', 'id')
+          .classed('console', function(d) {
+            return QDRService.utilities.isConsole(d);
+          })
+          .classed('normal', function(d) {
+            return d.nodeType === 'normal';
+          })
+          .classed('on-demand', function(d) {
+            return d.nodeType === 'on-demand';
+          })
+          .classed('artemis', function(d) {
+            return QDRService.utilities.isArtemis(d);
+          })
+          .classed('qpid-cpp', function(d) {
+            return QDRService.utilities.isQpid(d);
+          })
+          .text(function(d) {
+            if (QDRService.utilities.isConsole(d)) {
+              return '\uf108'; // icon-desktop for this console
+            } else if (QDRService.utilities.isArtemis(d)) {
+              return '\ue900';
+            } else if (QDRService.utilities.isQpid(d)) {
+              return '\ue901';
+            } else if (d.nodeType === 'route-container') {
+              return d.properties.product ? d.properties.product[0].toUpperCase() : 'S';
+            } else if (d.nodeType === 'normal')
+              return '\uf109'; // icon-laptop for clients
+            return d.name.length > 7 ? d.name.substr(0, 6) + '...' : d.name;
+          });
+      };
+      let appendTitle = function(g) {
+        g.append('svg:title').text(function(d) {
+          let x = '';
+          if (d.normals && d.normals.length > 1)
+            x = ' x ' + d.normals.length;
+          if (QDRService.utilities.isConsole(d)) {
+            return 'Dispatch console' + x;
+          } else if (QDRService.utilities.isArtemis(d)) {
+            return 'Broker - Artemis' + x;
+          } else if (d.properties.product == 'qpid-cpp') {
+            return 'Broker - qpid-cpp' + x;
+          } else if (d.properties.product) {
+            return d.properties.product;
+          } else if (d.cdir === 'in')
+            return 'Sender' + x;
+          else if (d.cdir === 'out')
+            return 'Receiver' + x;
+          else if (d.cdir === 'both')
+            return 'Sender/Receiver' + x;
+          return d.nodeType == 'normal' ? 'client' + x : (d.nodeType == 'on-demand' ? 'broker' : 'Router ' + d.name);
+        });
+      };
+
+      var startUpdateConnectionsGrid = function(d) {
+        // called after each topology update
+        var extendConnections = function() {
+          // force a fetch of the links for this node
+          QDRService.management.topology.ensureEntities(d.key, {entity: 'router.link', force: true}, function () {
+            // the links for this node are now available
+            $scope.multiData = [];
+            let normals = d.normals;
+            // find updated normals for d
+            d3.selectAll('.normal')
+              .each(function(newd) {
+                if (newd.id == d.id && newd.name == d.name) {
+                  normals = newd.normals;
+                }
+              });
+            if (normals) {
+              normals.forEach(function(n) {
+                let nodeInfo = QDRService.management.topology.nodeInfo();
+                let links = nodeInfo[n.key]['router.link'];
+                let linkTypeIndex = links.attributeNames.indexOf('linkType');
+                let connectionIdIndex = links.attributeNames.indexOf('connectionId');
+                n.linkData = [];
+                links.results.forEach(function(link) {
+                  if (link[linkTypeIndex] === 'endpoint' && link[connectionIdIndex] === n.connectionId) {
+                    let l = {};
+                    let ll = QDRService.utilities.flatten(links.attributeNames, link);
+                    l.owningAddr = ll.owningAddr;
+                    l.dir = ll.linkDir;
+                    if (l.owningAddr && l.owningAddr.length > 2)
+                      if (l.owningAddr[0] === 'M')
+                        l.owningAddr = l.owningAddr.substr(2);
+                      else
+                        l.owningAddr = l.owningAddr.substr(1);
+
+                    l.deliveryCount = ll.deliveryCount;
+                    l.uncounts = QDRService.utilities.pretty(ll.undeliveredCount + ll.unsettledCount);
+                    l.adminStatus = ll.adminStatus;
+                    l.operStatus = ll.operStatus;
+                    l.identity = ll.identity;
+                    l.connectionId = ll.connectionId;
+                    l.nodeId = n.key;
+                    l.type = ll.type;
+                    l.name = ll.name;
+
+                    // TODO: remove this fake quiescing/reviving logic when the routers do the work
+                    initConnState(n.connectionId);
+                    if ($scope.quiesceState[n.connectionId].linkStates[l.identity])
+                      l.adminStatus = $scope.quiesceState[n.connectionId].linkStates[l.identity];
+                    if ($scope.quiesceState[n.connectionId].state == 'quiescing') {
+                      if (l.adminStatus === 'enabled') {
+                        // 25% chance of switching
+                        let chance = Math.floor(Math.random() * 2);
+                        if (chance == 1) {
+                          l.adminStatus = 'disabled';
+                          $scope.quiesceState[n.connectionId].linkStates[l.identity] = 'disabled';
+                        }
+                      }
+                    }
+                    if ($scope.quiesceState[n.connectionId].state == 'reviving') {
+                      if (l.adminStatus === 'disabled') {
+                        // 25% chance of switching
+                        let chance = Math.floor(Math.random() * 2);
+                        if (chance == 1) {
+                          l.adminStatus = 'enabled';
+                          $scope.quiesceState[n.connectionId].linkStates[l.identity] = 'enabled';
+                        }
+                      }
+                    }
+                    QDR.log.debug('pushing link state for ' + l.owningAddr + ' status: ' + l.adminStatus);
+
+                    n.linkData.push(l);
+                  }
+                });
+                $scope.multiData.push(n);
+                if (n.connectionId == $scope.connectionId)
+                  $scope.linkData = n.linkData;
+                initConnState(n.connectionId);
+                $scope.multiDetails.updateState(n);
+              });
+            }
+            $scope.$apply();
+
+            d3.select('#multiple_details')
+              .style({
+                height: ((normals.length + 1) * 30) + 40 + 'px',
+                'overflow-y': normals.length > 10 ? 'scroll' : 'hidden'
+              });
+          });
+        };
+        $scope.connectionsStyle = function () {
+          return {
+            height: ($scope.multiData.length * 30 + 40) + 'px'
+          };
+        };
+        $scope.linksStyle = function () {
+          return {
+            height: ($scope.linkData.length * 30 + 40) + 'px'
+          };
+        };
+        // register a notification function for when the topology is updated
+        QDRService.management.topology.addUpdatedAction('normalsStats', extendConnections);
+        // call the function that gets the links right now
+        extendConnections();
+        clearPopups();
+        let display = 'block';
+        if (d.normals.length === 1) {
+          display = 'none';
+          mouseY = mouseY - 20;
+        }
+        let rm = relativeMouse();
+        d3.select('#multiple_details')
+          .style({
+            display: display,
+            opacity: 1,
+            left: rm.left + 'px',
+            top: (rm.top - rm.offset.top) + 'px'
+          });
+        if (d.normals.length === 1) {
+          // simulate a click on the connection to popup the link details
+          QDRService.management.topology.ensureEntities(d.key, {entity: 'router.link', force: true}, function () {
+            $scope.multiDetails.showLinksList({entity: d});
+          });
+        }
+      };
+      var stopUpdateConnectionsGrid = function() {
+        QDRService.management.topology.delUpdatedAction('normalsStats');
+      };
+
+      var initConnState = function(id) {
+        if (!angular.isDefined($scope.quiesceState[id])) {
+          $scope.quiesceState[id] = {
+            state: 'enabled',
+            buttonText: 'Quiesce',
+            buttonDisabled: false,
+            linkStates: {}
+          };
+        }
+      };
+
+      function nextHop(thisNode, d, cb) {
+        if ((thisNode) && (thisNode != d)) {
+          let target = findNextHopNode(thisNode, d);
+          //QDR.log.debug("highlight link from node ");
+          //console.dump(nodeFor(selected_node.name));
+          //console.dump(target);
+          if (target) {
+            let hnode = nodeFor(thisNode.name);
+            let hlLink = linkFor(hnode, target);
+            //QDR.log.debug("need to highlight");
+            //console.dump(hlLink);
+            if (hlLink) {
+              if (cb) {
+                cb(hlLink, hnode, target);
+              } else {
+                hlLink['highlighted'] = true;
+                hnode['highlighted'] = true;
+              }
+            }
+            else
+              target = null;
+          }
+          nextHop(target, d, cb);
+        }
+        if (thisNode == d && !cb) {
+          let hnode = nodeFor(thisNode.name);
+          hnode['highlighted'] = true;
+        }
+      }
+
+      function hasChanged() {
+        // Don't update the underlying topology diagram if we are adding a new node.
+        // Once adding is completed, the topology will update automatically if it has changed
+        let nodeInfo = QDRService.management.topology.nodeInfo();
+        // don't count the nodes without connection info
+        let cnodes = Object.keys(nodeInfo).filter ( function (node) {
+          return (nodeInfo[node]['connection']);
+        });
+        let routers = nodes.filter( function (node) {
+          return node.nodeType === 'inter-router';
+        });
+        if (routers.length > cnodes.length) {
+          return -1;
+        }
+
+
+        if (cnodes.length != Object.keys(savedKeys).length) {
+          return cnodes.length > Object.keys(savedKeys).length ? 1 : -1;
+        }
+        // we may have dropped a node and added a different node in the same update cycle
+        for (let i=0; i<cnodes.length; i++) {
+          let key = cnodes[i];
+          // if this node isn't in the saved node list
+          if (!savedKeys.hasOwnProperty(key))
+            return 1;
+          // if the number of connections for this node chaanged
+          if (!nodeInfo[key]['connection'])
+            return -1;
+          if (nodeInfo[key]['connection'].results.length != savedKeys[key]) {
+            return -1;
+          }
+        }
+        return 0;
+      }
+
+      function saveChanged() {
+        savedKeys = {};
+        let nodeInfo = QDRService.management.topology.nodeInfo();
+        // save the number of connections per node
+        for (let key in nodeInfo) {
+          if (nodeInfo[key]['connection'])
+            savedKeys[key] = nodeInfo[key]['connection'].results.length;
+        }
+      }
+      // we are about to leave the page, save the node positions
+      $rootScope.$on('$locationChangeStart', function() {
+        //QDR.log.debug("locationChangeStart");
+        savePositions();
+      });
+      // When the DOM element is removed from the page,
+      // AngularJS will trigger the $destroy event on
+      // the scope
+      $scope.$on('$destroy', function() {
+        //QDR.log.debug("scope on destroy");
+        savePositions();
+        QDRService.management.topology.setUpdateEntities([]);
+        QDRService.management.topology.stopUpdating();
+        QDRService.management.topology.delUpdatedAction('normalsStats');
+        QDRService.management.topology.delUpdatedAction('topology');
+
+        d3.select('#SVG_ID').remove();
+        window.removeEventListener('resize', resize);
+        traffic.stop();
+      });
+
+      function handleInitialUpdate() {
+        // we only need to update connections during steady-state
+        QDRService.management.topology.setUpdateEntities(['connection']);
+        // we currently have all entities available on all routers
+        saveChanged();
+        initForceGraph();
+        // after the graph is displayed fetch all .router.node info. This is done so highlighting between nodes
+        // doesn't incur a delay
+        QDRService.management.topology.addUpdateEntities({entity: 'router.node', attrs: ['id','nextHop']});
+        // call this function every time a background update is done
+        QDRService.management.topology.addUpdatedAction('topology', function() {
+          let changed = hasChanged();
+          // there is a new node, we need to get all of it's entities before drawing the graph
+          if (changed > 0) {
+            QDRService.management.topology.delUpdatedAction('topology');
+            animate = true;
+            setupInitialUpdate();
+          } else if (changed === -1) {
+            // we lost a node (or a client), we can draw the new svg immediately
+            animate = false;
+            saveChanged();
+            let nodeInfo = QDRService.management.topology.nodeInfo();
+            initializeNodes(nodeInfo);
+
+            let unknowns = [];
+            initializeLinks(nodeInfo, unknowns);
+            if (unknowns.length > 0) {
+              resolveUnknowns(nodeInfo, unknowns);
+            }
+            else {
+              force.nodes(nodes).links(links).start();
+              restart();
+            }
+
+            //initForceGraph();
+          } else {
+            //QDR.log.debug("topology didn't change")
+          }
+
+        });
+      }
+
+      function setupInitialUpdate() {
+        // make sure all router nodes have .connection info. if not then fetch any missing info
+        QDRService.management.topology.ensureAllEntities(
+          //          [{entity: ".connection"}, {entity: ".router.lin.router.link", attrs: ["linkType","connectionId","linkDir"]}],
+          [{entity: 'connection'}],
+          //[{entity: ".connection"}],
+          handleInitialUpdate);
+      }
+      if (!QDRService.management.connection.is_connected()) {
+        // we are not connected. we probably got here from a bookmark or manual page reload
+        QDR.redirectWhenConnected($location, 'topology');
+        return;
+      }
+
+      let connectionPopupHTML = function (d) {
+        let left = d.left ? d.source : d.target;
+        // left is the connection with dir 'in'
+        let right = d.left ? d.target : d.source;
+        let onode = QDRService.management.topology.nodeInfo()[left.key];
+        let connSecurity = function (conn) {
+          if (!conn.isEncrypted)
+            return 'no-security';
+          if (conn.sasl === 'GSSAPI')
+            return 'Kerberos';
+          return conn.sslProto + '(' + conn.sslCipher + ')';
+        };
+        let 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 + ')';
+        };
+        let connTenant = function (conn) {
+          if (!conn.tenant) {
+            return '';
+          }
+          if (conn.tenant.length > 1)
+            return conn.tenant.replace(/\/$/, '');
+        };
+        // loop through all the connections for left, and find the one for right
+        let rightIndex = onode['connection'].results.findIndex( function (conn) {
+          return QDRService.utilities.valFor(onode['connection'].attributeNames, conn, 'container') === right.routerId;
+        });
+        if (rightIndex < 0) {
+          // we have a connection to a client/service
+          rightIndex = +left.connectionId;
+        }
+        if (isNaN(rightIndex)) {
+          // we have a connection to a console
+          rightIndex = +right.connectionId;
+        }
+        let HTML = '';
+        if (rightIndex >= 0) {
+          let conn = onode['connection'].results[rightIndex];
+          conn = QDRService.utilities.flatten(onode['connection'].attributeNames, conn);
+          HTML += '<table class="popupTable">';
+          HTML += ('<tr><td>Security</td><td>' + connSecurity(conn) + '</td></tr>');
+          HTML += ('<tr><td>Authentication</td><td>' + connAuth(conn) + '</td></tr>');
+          HTML += ('<tr><td>Tenant</td><td>' + connTenant(conn) + '</td></tr>');
+          HTML += '</table>';
+        }
+        return HTML;
+      };
+
+      animate = true;
+      setupInitialUpdate();
+      QDRService.management.topology.startUpdating(false);
+
+    }
+  ]);
+
+  return QDR;
+
+}(QDR || {}));
\ No newline at end of file


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


[4/4] qpid-dispatch git commit: DISPATCH-1002 Added optional message flow animation to topology page

Posted by ea...@apache.org.
DISPATCH-1002 Added optional message flow animation to topology page


Project: http://git-wip-us.apache.org/repos/asf/qpid-dispatch/repo
Commit: http://git-wip-us.apache.org/repos/asf/qpid-dispatch/commit/61df4890
Tree: http://git-wip-us.apache.org/repos/asf/qpid-dispatch/tree/61df4890
Diff: http://git-wip-us.apache.org/repos/asf/qpid-dispatch/diff/61df4890

Branch: refs/heads/master
Commit: 61df4890ba6b1ed240b0c00accf57170115c219c
Parents: e9f8a85
Author: Ernest Allen <ea...@redhat.com>
Authored: Thu May 17 16:54:52 2018 -0400
Committer: Ernest Allen <ea...@redhat.com>
Committed: Thu May 17 16:54:52 2018 -0400

----------------------------------------------------------------------
 console/stand-alone/plugin/css/dispatch.css     |    3 -
 console/stand-alone/plugin/html/qdrSchema.html  |    6 +-
 .../stand-alone/plugin/html/qdrTopology.html    |  107 +-
 console/stand-alone/plugin/js/chord/matrix.js   |   19 +-
 console/stand-alone/plugin/js/chord/qdrChord.js |    2 +-
 console/stand-alone/plugin/js/qdrList.js        |    2 +
 console/stand-alone/plugin/js/qdrTopology.js    | 2098 -----------------
 .../plugin/js/topology/qdrTopology.js           | 2125 ++++++++++++++++++
 .../stand-alone/plugin/js/topology/traffic.js   |  278 +++
 9 files changed, 2527 insertions(+), 2113 deletions(-)
----------------------------------------------------------------------


http://git-wip-us.apache.org/repos/asf/qpid-dispatch/blob/61df4890/console/stand-alone/plugin/css/dispatch.css
----------------------------------------------------------------------
diff --git a/console/stand-alone/plugin/css/dispatch.css b/console/stand-alone/plugin/css/dispatch.css
index 0096f95..a815ca1 100644
--- a/console/stand-alone/plugin/css/dispatch.css
+++ b/console/stand-alone/plugin/css/dispatch.css
@@ -263,9 +263,6 @@ li.currentStep {
   font-weight: bold;
 }
 
-.qdrTopology div.panel {
-  position: absolute;
-}
 /*
 .ui-dialog-titlebar {
     border: 0;

http://git-wip-us.apache.org/repos/asf/qpid-dispatch/blob/61df4890/console/stand-alone/plugin/html/qdrSchema.html
----------------------------------------------------------------------
diff --git a/console/stand-alone/plugin/html/qdrSchema.html b/console/stand-alone/plugin/html/qdrSchema.html
index 5bd9207..6b86689 100644
--- a/console/stand-alone/plugin/html/qdrSchema.html
+++ b/console/stand-alone/plugin/html/qdrSchema.html
@@ -24,10 +24,8 @@ under the License.
 
 span.fancytree-title {
   color: black;
-  overflow: hidden;
-  white-space: nowrap;
-  text-overflow: ellipsis;
-  width: 16em;
+  width: auto;
+  white-space: normal;
 }
 
 span.fancytree-node:hover {

http://git-wip-us.apache.org/repos/asf/qpid-dispatch/blob/61df4890/console/stand-alone/plugin/html/qdrTopology.html
----------------------------------------------------------------------
diff --git a/console/stand-alone/plugin/html/qdrTopology.html b/console/stand-alone/plugin/html/qdrTopology.html
index 2b82e34..e355413 100644
--- a/console/stand-alone/plugin/html/qdrTopology.html
+++ b/console/stand-alone/plugin/html/qdrTopology.html
@@ -93,15 +93,88 @@ button.page-menu-button {
   .page-menu {
       width: 300px;
   }
-  
+
+  .legend-container {
+      position: absolute;
+      top: 1em;
+      right: 1em;
+  }
+
+  .legend-container ul {
+    list-style: none;
+    padding-left: 0;
+    margin-bottom: 0.5em;
+  }
+
+  span.legend-text {
+    padding-left: 0.25em;
+    font-weight: bold;
+  }
+
+  span.legend-text:disabled {
+    color: darkgray;
+  }
+
+  li:disabled * {
+      color: darkgray;
+  }
+  circle.flow {
+    opacity: 1;
+    pointer-events: none;
+    /*fill: green; */
+  }
+
+  circle.flow.fade {
+    opacity: 0.1;
+  }
+
+  #topo_legend ul.addresses li, #topo_legend ul.options li {
+    margin-top: 0.5em;
+    margin-bottom: 1em;
+  }
+
+#topo_legend ul.addresses {
+  margin-bottom: 1.5em;
+}
+
+  /* the checkboxes for the addresses */
+  #topo_legend ul li input[type=checkbox]:checked + label::before {
+    content:'\2713';
+    font-weight: bold;
+    font-size: 16px;
+    display:inline-block;
+    /* padding:0 6px 0 0; */
+    color: black;
+    position: absolute;
+    top: -8px;
+    left: -1px;
+  }
+  /* The aggregate addresses need a black checkbox on the white background */
+  #topo_legend ul li input[type=checkbox]:checked + label.aggregate::before {
+    color: black;
+    /* left: 1px; */
+  }
+#topo_legend ul.addresses button.btn-default {
+    background-image: none;
+    color: white;
+    border-radius: 10px;
+}
+
+#topo_legend li.legend-sublist ul {
+        margin-bottom: 0.5em;
+    }
+
+#topo_legend li.legend-sublist ul.addresses{
+    max-height: 11.6em;   /* show up to 4 addresses */
+    overflow-y: auto;
+}
 </style>
-<div class="qdrTopology row" ng-controller="QDR.TopologyController">
+<div class="qdrTopology" ng-controller="QDR.TopologyController">
     <div  ng-controller="QDR.TopologyFormController">
         <div class="page-menu navbar-collapse collapse">
             <div id="topologyForm">
                 <div>
                     <h4>{{form}} Info</h4>
-                    <!-- <div id="routerInfo" class="grid" ui-grid="topoGridRouter" ng-style="infoStyle()"></div> -->
                     <div id="formInfo"></div>
                 </div>
             </div>
@@ -110,7 +183,32 @@ button.page-menu-button {
         <button ng-if="!panelVisible" ng-click="showLeftPane()" class="showLeft" title="Show"><i class="icon-step-forward"></i></button>
     </div>
 
-    <div class="diagram col-xs-12">
+    <div class="legend-container hidden-xs">
+        <uib-accordion id="topo_legend" close-others="false">
+            <div uib-accordion-group class="panel-default" is-open="legend.status.optionsOpen" heading="Options">
+              <ul class="options">
+                  <li class="legend-line">
+                    <checkbox title="Show message traffic" ng-model="legendOptions.showTraffic"></checkbox>
+                    <span class="legend-text" title="Select to show message traffic">Show traffic</span>
+                  </li>
+                  <li class="legend-sublist" ng-hide="!legendOptions.showTraffic">
+                    <ul class="addresses">
+                        <li ng-repeat="(address, color) in addresses" class="legend-line">
+                          <checkbox style="background-color: {{addressColors[address]}};"
+                            title="{{address}}" ng-change="addressFilterChanged()"
+                            ng-model="addresses[address]"></checkbox>
+                          <span class="legend-text" ng-mouseenter="enterLegend(address)" ng-mouseleave="leaveLegend()" ng-click="addressClick(address)" title="{{address}}">{{address | limitTo : 15}}{{address.length>15 ? '…' : ''}}</span>
+                        </li>
+                    </ul>
+                  </li>
+              </ul>
+            </div>
+            <div uib-accordion-group class="panel-default" is-open="legend.status.legendOpen" heading="Legend">
+                <div id="topo_svg_legend"></div>
+            </div>
+          </uib-accordion>
+    </div>
+    <div class="diagram">
         <div id="topology"><!-- d3 toplogy here --></div>
         <div id="crosssection"></div><div id="crosshtml" ng-bind-html="crosshtml"></div>
 
@@ -120,7 +218,6 @@ button.page-menu-button {
                 <li class="na" ng-class="{'force-display': isFixed()}" ng-click="setFixed(false)">Unfreeze</li>
             </ul>
         </div>
-        <div id="svg_legend" class="hidden-xs"></div>
         <div id="multiple_details">
             <h4 class="grid-title">Connections</h4>
             <div class="grid" ui-grid="multiDetails" ui-grid-selection ui-grid-auto-resize ng-style="connectionsStyle()"></div>

http://git-wip-us.apache.org/repos/asf/qpid-dispatch/blob/61df4890/console/stand-alone/plugin/js/chord/matrix.js
----------------------------------------------------------------------
diff --git a/console/stand-alone/plugin/js/chord/matrix.js b/console/stand-alone/plugin/js/chord/matrix.js
index d8b9456..3f10ddc 100644
--- a/console/stand-alone/plugin/js/chord/matrix.js
+++ b/console/stand-alone/plugin/js/chord/matrix.js
@@ -71,7 +71,18 @@ valuesMatrix.prototype.hasValues = function () {
     });
   });
 };
-
+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);
@@ -130,7 +141,11 @@ valuesMatrix.prototype.getAddresses = function (r) {
 let getAttribute = function (self, attr, i) {
   if (self.aggregate)
     return self.rows[i][attr];
-  return self.rows[self.getGroupBy().indexOf(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;

http://git-wip-us.apache.org/repos/asf/qpid-dispatch/blob/61df4890/console/stand-alone/plugin/js/chord/qdrChord.js
----------------------------------------------------------------------
diff --git a/console/stand-alone/plugin/js/chord/qdrChord.js b/console/stand-alone/plugin/js/chord/qdrChord.js
index 5ce7aa1..91ee96a 100644
--- a/console/stand-alone/plugin/js/chord/qdrChord.js
+++ b/console/stand-alone/plugin/js/chord/qdrChord.js
@@ -520,7 +520,7 @@ var QDR = (function (QDR) {
         .transition()
         .duration(duration/2)
         .attrTween('d', arcTweenExit)
-        .each('end', function (d) {d3.select(this).node().parentNode.remove();});
+        .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);

http://git-wip-us.apache.org/repos/asf/qpid-dispatch/blob/61df4890/console/stand-alone/plugin/js/qdrList.js
----------------------------------------------------------------------
diff --git a/console/stand-alone/plugin/js/qdrList.js b/console/stand-alone/plugin/js/qdrList.js
index 056e686..8044400 100644
--- a/console/stand-alone/plugin/js/qdrList.js
+++ b/console/stand-alone/plugin/js/qdrList.js
@@ -376,6 +376,7 @@ var QDR = (function(QDR) {
         });
         let h = $scope.detailFields.length * 30 + 46;
         $('.ui-grid-viewport').height(h);
+        $scope.details.excessRows = $scope.detailFields.length;
       };
       $(window).resize(resizer);
 
@@ -731,6 +732,7 @@ var QDR = (function(QDR) {
         enableVerticalScrollbar: 0,
         multiSelect: false,
         jqueryUIDraggable: true,
+        excessRows: 20,
         onRegisterApi: function(gridApi) {
           $scope.gridApi = gridApi;
         }


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