ONOS-1479 -- GUI - augmenting topology view for extensibility: WIP.

Change-Id: I11820a9ff8f446c0d10a0311cee5ce448c15f402
diff --git a/web/gui/src/main/webapp/app/view/topo/topo.css b/web/gui/src/main/webapp/app/view/topo/topo.css
index d1d4b4c..f26f478 100644
--- a/web/gui/src/main/webapp/app/view/topo/topo.css
+++ b/web/gui/src/main/webapp/app/view/topo/topo.css
@@ -305,6 +305,21 @@
     filter: url("data:image/svg+xml;utf8, <svg xmlns = \'http://www.w3.org/2000/svg\'><filter x=\"-50%\" y=\"-50%\" width=\"200%\" height=\"200%\" id=\"yellow-glow\"><feColorMatrix type=\"matrix\" values=\"0 0 0 0  1.0 0 0 0 0  1.0 0 0 0 0  0.3 0 0 0 1  0 \"></feColorMatrix><feGaussianBlur stdDeviation=\"3\" result=\"coloredBlur\"></feGaussianBlur><feMerge><feMergeNode in=\"coloredBlur\"></feMergeNode><feMergeNode in=\"SourceGraphic\"></feMergeNode></feMerge></filter></svg>#yellow-glow");
 }
 
+
+/* --- Toolbar --- */
+
+#toolbar-topo-tbar .tbar-row.right {
+    width: 100%;
+}
+
+#toolbar-topo-tbar .tbar-row-text {
+    height: 21px;
+    text-align: right;
+    padding: 8px 60px 0 0;
+    font-style: italic;
+}
+
+
 /* --- Topo Nodes --- */
 
 #ov-topo svg .suppressed {
diff --git a/web/gui/src/main/webapp/app/view/topo/topo.js b/web/gui/src/main/webapp/app/view/topo/topo.js
index f46645e..313673f 100644
--- a/web/gui/src/main/webapp/app/view/topo/topo.js
+++ b/web/gui/src/main/webapp/app/view/topo/topo.js
@@ -40,7 +40,7 @@
 
     // --- Short Cut Keys ------------------------------------------------
 
-    function setUpKeys() {
+    function setUpKeys(overlayKeys) {
         // key bindings need to be made after the services have been injected
         // thus, deferred to here...
         actionMap = {
@@ -63,14 +63,6 @@
             R: [resetZoom, 'Reset pan / zoom'],
             dot: [ttbs.toggleToolbar, 'Toggle Toolbar'],
 
-            V: [tts.showRelatedIntentsAction, 'Show all related intents'],
-            rightArrow: [tts.showNextIntentAction, 'Show next related intent'],
-            leftArrow: [tts.showPrevIntentAction, 'Show previous related intent'],
-            W: [tts.showSelectedIntentTrafficAction, 'Monitor traffic of selected intent'],
-            A: [tts.showAllFlowTrafficAction, 'Monitor all traffic using flow stats'],
-            Q: [tts.showAllPortTrafficAction, 'Monitor all traffic using port stats'],
-            F: [tts.showDeviceLinkFlowsAction, 'Show device link flows'],
-
             E: [equalizeMasters, 'Equalize mastership roles'],
 
             esc: handleEscape,
@@ -78,12 +70,16 @@
             _keyListener: ttbs.keyListener,
 
             _helpFormat: [
-                ['I', 'O', 'D', '-', 'H', 'M', 'P', 'dash', 'B' ],
-                ['X', 'Z', 'N', 'L', 'U', 'R', '-', 'dot'],
-                ['V', 'rightArrow', 'leftArrow', 'W', 'A', 'F', '-', 'E' ]
+                ['I', 'O', 'D', 'H', 'M', 'P', 'dash', 'B', 'S' ],
+                ['X', 'Z', 'N', 'L', 'U', 'R', '-', 'E', '-', 'dot'],
+                []   // this column reserved for overlay actions
             ]
         };
 
+        if (fs.isO(overlayKeys)) {
+            mergeKeys(overlayKeys);
+        }
+
         ks.keyBindings(actionMap);
 
         ks.gestureNotes([
@@ -95,6 +91,22 @@
         ]);
     }
 
+    // when a topology overlay is activated, we need to bind their keystrokes
+    // and include them in the quick-help panel
+    function mergeKeys(extra) {
+        var _hf = actionMap._helpFormat[2];
+        extra._keyOrder.forEach(function (k) {
+            var d = extra[k],
+                cb = d && d.cb,
+                tt = d && d.tt;
+            // NOTE: ignore keys that are already defined
+            if (d && !actionMap[k]) {
+                actionMap[k] = [cb, tt];
+                _hf.push(k);
+            }
+        });
+    }
+
     // --- Keystroke functions -------------------------------------------
 
     function toggleInstances(x) {
@@ -153,6 +165,10 @@
             // if an instance is selected, cancel the affinity mapping
             tis.cancelAffinity()
 
+        } else if (tov.hooks.escape()) {
+            // else if the overlay consumed the ESC event...
+            // (work already done)
+
         } else if (tss.deselectAll()) {
             // else if we have node selections, deselect them all
             // (work already done)
@@ -169,19 +185,15 @@
         } else if (tps.summaryVisible()) {
             // else if the Summary Panel is visible, hide it
             tps.hideSummaryPanel();
-
-        } else {
-            // TODO: set hover mode to hoverModeNone
-            // talk to Thomas about this: shouldn't it be done
-            // when we deselect the node (if tss.haveDetails()...)
         }
     }
 
     // --- Toolbar Functions ---------------------------------------------
 
     function notValid(what) {
-        $log.warn('Topo.js getActionEntry(): Not a valid ' + what);
+        $log.warn('topo.js getActionEntry(): Not a valid ' + what);
     }
+
     function getActionEntry(key) {
         var entry;
 
@@ -201,7 +213,8 @@
 
     function setUpToolbar() {
         ttbs.init({
-            getActionEntry: getActionEntry
+            getActionEntry: getActionEntry,
+            setUpKeys: setUpKeys
         });
         ttbs.createToolbar();
     }
@@ -503,7 +516,6 @@
             restoreConfigFromPrefs();
 
             $log.debug('registered overlays...', tov.list());
-
             $log.log('OvTopoCtrl has been created');
         }]);
 }());
diff --git a/web/gui/src/main/webapp/app/view/topo/topoEvent.js b/web/gui/src/main/webapp/app/view/topo/topoEvent.js
index e6c943d..5fd38bf 100644
--- a/web/gui/src/main/webapp/app/view/topo/topoEvent.js
+++ b/web/gui/src/main/webapp/app/view/topo/topoEvent.js
@@ -27,14 +27,14 @@
     'use strict';
 
     // injected refs
-    var $log, $interval, wss, tps, tis, tfs, tss, tts, tspr;
+    var $log, $interval, wss, tps, tis, tfs, tss, tov, tspr;
 
     // internal state
     var handlerMap,
         openListener,
         heartbeatTimer;
 
-    var heartbeatPeriod = 5000; // 5 seconds
+    var heartbeatPeriod = 9000; // 9 seconds
 
     // ==========================
 
@@ -44,7 +44,7 @@
 
             showDetails: tss,
 
-            showTraffic: tts,
+            showHighlights: tov,
 
             addInstance: tis,
             updateInstance: tis,
@@ -90,10 +90,10 @@
     .factory('TopoEventService',
         ['$log', '$interval', 'WebSocketService',
             'TopoPanelService', 'TopoInstService', 'TopoForceService',
-            'TopoSelectService', 'TopoTrafficService', 'TopoSpriteService',
+            'TopoSelectService', 'TopoOverlayService', 'TopoSpriteService',
 
         function (_$log_,  _$interval_, _wss_,
-                  _tps_, _tis_, _tfs_, _tss_, _tts_, _tspr_) {
+                  _tps_, _tis_, _tfs_, _tss_, _tov_, _tspr_) {
             $log = _$log_;
             $interval = _$interval_;
             wss = _wss_;
@@ -101,7 +101,7 @@
             tis = _tis_;
             tfs = _tfs_;
             tss = _tss_;
-            tts = _tts_;
+            tov = _tov_;
             tspr = _tspr_;
 
             createHandlerMap();
diff --git a/web/gui/src/main/webapp/app/view/topo/topoForce.js b/web/gui/src/main/webapp/app/view/topo/topoForce.js
index 963a370..0595393 100644
--- a/web/gui/src/main/webapp/app/view/topo/topoForce.js
+++ b/web/gui/src/main/webapp/app/view/topo/topoForce.js
@@ -23,7 +23,7 @@
     'use strict';
 
     // injected refs
-    var $log, $timeout, fs, sus, is, ts, flash, wss,
+    var $log, $timeout, fs, sus, ts, flash, wss, tov,
         tis, tms, td3, tss, tts, tos, fltr, tls, uplink, svg;
 
     // configuration
@@ -797,9 +797,10 @@
         return true;
     }
 
-    // ==========================
-    // function entry points for traffic module
+    // =============================================
+    // function entry points for overlay module
 
+    // TODO: find an automatic way of tracking via the "showHighlights" events
     var allTrafficClasses = 'primary secondary optical animated ' +
         'port-traffic-Kbps port-traffic-Mbps port-traffic-Gbps ' +
         'port-traffic-Gbps-choked';
@@ -845,7 +846,7 @@
         };
     }
 
-    function mkD3Api(uplink) {
+    function mkD3Api() {
         return {
             node: function () { return node; },
             link: function () { return link; },
@@ -859,7 +860,7 @@
         };
     }
 
-    function mkSelectApi(uplink) {
+    function mkSelectApi() {
         return {
             node: function () { return node; },
             zoomingOrPanning: zoomingOrPanning,
@@ -868,15 +869,20 @@
         };
     }
 
-    function mkTrafficApi(uplink) {
+    function mkTrafficApi() {
+        return {
+            hovered: tss.hovered,
+            somethingSelected: tss.somethingSelected,
+            selectOrder: tss.selectOrder
+        };
+    }
+
+    function mkOverlayApi() {
         return {
             clearLinkTrafficStyle: clearLinkTrafficStyle,
             removeLinkLabels: removeLinkLabels,
             updateLinks: updateLinks,
-            findLinkById: tms.findLinkById,
-            hovered: tss.hovered,
-            validateSelectionContext: tss.validateSelectionContext,
-            selectOrder: tss.selectOrder
+            findLinkById: tms.findLinkById
         };
     }
 
@@ -904,7 +910,7 @@
         };
     }
 
-    function mkFilterApi(uplink) {
+    function mkFilterApi() {
         return {
             node: function () { return node; },
             link: function () { return link; }
@@ -925,11 +931,11 @@
     .factory('TopoForceService',
         ['$log', '$timeout', 'FnService', 'SvgUtilService',
             'ThemeService', 'FlashService', 'WebSocketService',
-            'TopoInstService', 'TopoModelService',
+            'TopoOverlayService', 'TopoInstService', 'TopoModelService',
             'TopoD3Service', 'TopoSelectService', 'TopoTrafficService',
             'TopoObliqueService', 'TopoFilterService', 'TopoLinkService',
 
-        function (_$log_, _$timeout_, _fs_, _sus_, _ts_, _flash_, _wss_,
+        function (_$log_, _$timeout_, _fs_, _sus_, _ts_, _flash_, _wss_, _tov_,
                   _tis_, _tms_, _td3_, _tss_, _tts_, _tos_, _fltr_, _tls_) {
             $log = _$log_;
             $timeout = _$timeout_;
@@ -938,6 +944,7 @@
             ts = _ts_;
             flash = _flash_;
             wss = _wss_;
+            tov = _tov_;
             tis = _tis_;
             tms = _tms_;
             td3 = _td3_;
@@ -966,12 +973,13 @@
 
                 $log.debug('initForce().. dim = ' + dim);
 
+                tov.setApi(mkOverlayApi(), tss);
                 tms.initModel(mkModelApi(uplink), dim);
-                td3.initD3(mkD3Api(uplink));
-                tss.initSelect(mkSelectApi(uplink));
-                tts.initTraffic(mkTrafficApi(uplink));
+                td3.initD3(mkD3Api());
+                tss.initSelect(mkSelectApi());
+                tts.initTraffic(mkTrafficApi());
                 tos.initOblique(mkObliqueApi(uplink, fltr));
-                fltr.initFilter(mkFilterApi(uplink));
+                fltr.initFilter(mkFilterApi());
                 tls.initLink(mkLinkApi(svg, uplink), td3);
 
                 settings = angular.extend({}, defaultSettings, opts);
@@ -1016,6 +1024,7 @@
                 tss.destroySelect();
                 td3.destroyD3();
                 tms.destroyModel();
+                // note: no need to destroy overlay service
                 ts.removeListener(themeListener);
                 themeListener = null;
 
diff --git a/web/gui/src/main/webapp/app/view/topo/topoOverlay.js b/web/gui/src/main/webapp/app/view/topo/topoOverlay.js
index f2b81f5..41c8e1e 100644
--- a/web/gui/src/main/webapp/app/view/topo/topoOverlay.js
+++ b/web/gui/src/main/webapp/app/view/topo/topoOverlay.js
@@ -30,7 +30,7 @@
     var tos = 'TopoOverlayService: ';
 
     // injected refs
-    var $log, fs, gs, wss, ns;
+    var $log, fs, gs, wss, ns, tss, tps, api;
 
     // internal state
     var overlays = {},
@@ -80,6 +80,7 @@
     function register(overlay) {
         var r = 'register',
             over = fs.isO(overlay),
+            kb = over ? fs.isO(overlay.keyBindings) : null,
             id = over ? over.overlayId : '';
 
         if (!id) {
@@ -90,11 +91,26 @@
         }
         overlays[id] = overlay;
         handleGlyphs(overlay);
+
+        if (kb) {
+            if (!fs.isA(kb._keyOrder)) {
+                warn(r, 'no _keyOrder array defined on keyBindings');
+            } else {
+                kb._keyOrder.forEach(function (k) {
+                    if (k !== '-' && !kb[k]) {
+                        warn(r, 'no "' + k + '" property defined on keyBindings');
+                    }
+                });
+            }
+        }
+
         $log.debug(tos + 'registered overlay: ' + id, overlay);
     }
 
+    // TODO: remove this redundant code.......
     // NOTE: unregister needs to be called if an app is ever
     //       deactivated/uninstalled via the applications view
+/*
     function unregister(overlay) {
         var u = 'unregister',
             over = fs.isO(overlay),
@@ -108,21 +124,33 @@
         }
         delete overlays[id];
         $log.debug(tos + 'unregistered overlay: ' + id);
-        // TODO: rebuild the toolbar overlay radio button set
     }
+*/
 
+
+    // returns the list of overlay identifiers
     function list() {
         return d3.map(overlays).keys();
     }
 
-    function overlay(id) {
-        return overlays[id];
+    // add a radio button for each registered overlay
+    function augmentRbset(rset, switchFn) {
+        angular.forEach(overlays, function (ov) {
+            rset.push({
+                gid: ov._glyphId,
+                tooltip: (ov.tooltip || '(no tooltip)'),
+                cb: function () {
+                    tbSelection(ov.overlayId, switchFn);
+                }
+            });
+        });
     }
 
     // an overlay was selected via toolbar radio button press from user
-    function tbSelection(id) {
+    function tbSelection(id, switchFn) {
         var same = current && current.overlayId === id,
-            payload = {};
+            payload = {},
+            actions;
 
         function doop(op) {
             var oid = current.overlayId;
@@ -133,70 +161,211 @@
 
         if (!same) {
             current && doop('deactivate');
-            current = overlay(id);
+            current = overlays[id];
             current && doop('activate');
+            actions = current && fs.isO(current.keyBindings);
+            switchFn(id, actions);
+
             wss.sendEvent('topoSelectOverlay', payload);
 
-            // TODO: refactor to emit "flush on overlay change" messages
+            // Ensure summary and details panels are updated immediately..
             wss.sendEvent('requestSummary');
+            tss.updateDetail();
         }
     }
 
-    var coreButtonPath = {
-        showDeviceView: 'device',
-        showFlowView: 'flow',
-        showPortView: 'port',
-        showGroupView: 'group'
+    var coreButtons = {
+        showDeviceView: {
+            gid: 'switch',
+            tt: 'Show Device View',
+            path: 'device'
+        },
+        showFlowView: {
+            gid: 'flowTable',
+            tt: 'Show Flow View for this Device',
+            path: 'flow'
+        },
+        showPortView: {
+            gid: 'portTable',
+            tt: 'Show Port View for this Device',
+            path: 'port'
+        },
+        showGroupView: {
+            gid: 'groupTable',
+            tt: 'Show Group View for this Device',
+            path: 'group'
+        }
     };
 
+    // retrieves a button definition from the current overlay and generates
+    //  a button descriptor to be added to the panel, with the data baked in
+    function _getButtonDef(id, data) {
+        var btns = current && current.buttons,
+            b = btns && btns[id],
+            cb = fs.isF(b.cb),
+            f = cb ? function () { cb(data); } : function () {};
+
+        return b ? {
+            id: current.mkId(id),
+            gid: current.mkGid(b.gid),
+            tt: b.tt,
+            cb: f
+        } : null;
+    }
+
     // install core buttons, and include any additional from the current overlay
-    function installButtons(buttons, addFn, data, devId) {
+    function installButtons(buttons, data, devId) {
+        buttons.forEach(function (id) {
+            var btn = coreButtons[id],
+                gid = btn && btn.gid,
+                tt = btn && btn.tt,
+                path = btn && btn.path;
 
-        angular.forEach(buttons, function (btn) {
-            var path = coreButtonPath[btn.id],
-                _id,
-                _gid,
-                _cb,
-                action;
-
-            if (path) {
-                // core callback function
-                _id = btn.id;
-                _gid = btn.gid;
-                action = function () {
-                    ns.navTo(path, { devId: devId });
-                };
-            } else if (current) {
-                _id = current.mkId(btn.id);
-                _gid = current.mkGid(btn.gid);
-                action = current.buttonActions[btn.id] || function () {};
+            if (btn) {
+                tps.addAction({
+                    id: 'core-' + id,
+                    gid: gid,
+                    tt: tt,
+                    cb: function () { ns.navTo(path, {devId: devId }); }
+                });
+            } else if (btn = _getButtonDef(id, data)) {
+                tps.addAction(btn);
             }
+        });
+    }
 
-            _cb = function () { action(data); };
+    function addDetailButton(id) {
+        var b = _getButtonDef(id);
+        if (b) {
+            tps.addAction({
+                id: current.mkId(id),
+                gid: current.mkGid(b.gid),
+                cb: b.cb,
+                tt: b.tt
+            });
+        }
+    }
 
-            addFn({ id: _id, gid: _gid, cb: _cb, tt: btn.tt});
+
+    // === -----------------------------------------------------
+    //  Hooks for overlays
+
+    function _hook(x) {
+        var h = current && current.hooks;
+        return h && fs.isF(h[x]);
+    }
+
+    function escapeHook() {
+        var eh = _hook('escape');
+        return eh ? eh() : false;
+    }
+
+    function emptySelectHook() {
+        var cb = _hook('empty');
+        cb && cb();
+    }
+
+    function singleSelectHook(data) {
+        var cb = _hook('single');
+        cb && cb(data);
+    }
+
+    function multiSelectHook(selectOrder) {
+        var cb = _hook('multi');
+        cb && cb(selectOrder);
+    }
+
+    // === -----------------------------------------------------
+    //  Event (from server) Handlers
+
+    function setApi(_api_, _tss_) {
+        api = _api_;
+        tss = _tss_;
+    }
+
+    // TODO: refactor this (currently using showTraffic data structure)
+    function showHighlights(data) {
+        /*
+           API to topoForce
+             clearLinkTrafficStyle()
+             removeLinkLabels()
+             updateLinks()
+             findLinkById( id )
+         */
+
+        var paths = data.paths;
+
+        api.clearLinkTrafficStyle();
+        api.removeLinkLabels();
+
+        // Now highlight all links in the paths payload, and attach
+        //  labels to them, if they are defined.
+        paths.forEach(function (p) {
+            var n = p.links.length,
+                i, ldata, lab, units, magnitude, portcls;
+
+            for (i=0; i<n; i++) {
+                ldata = api.findLinkById(p.links[i]);
+                lab = p.labels[i];
+
+                if (ldata && !ldata.el.empty()) {
+                    ldata.el.classed(p.class, true);
+                    ldata.label = lab;
+
+                    if (fs.endsWith(lab, 'bps')) {
+                        // inject additional styling for port-based traffic
+                        units = lab.substring(lab.length-4);
+                        portcls = 'port-traffic-' + units;
+
+                        // for GBps
+                        if (units.substring(0,1) === 'G') {
+                            magnitude = fs.parseBitRate(lab);
+                            if (magnitude >= 9) {
+                                portcls += '-choked'
+                            }
+                        }
+                        ldata.el.classed(portcls, true);
+                    }
+                }
+            }
         });
 
+        api.updateLinks();
     }
 
+    // ========================================================================
+
     angular.module('ovTopo')
     .factory('TopoOverlayService',
         ['$log', 'FnService', 'GlyphService', 'WebSocketService', 'NavService',
+            'TopoPanelService',
 
-        function (_$log_, _fs_, _gs_, _wss_, _ns_) {
+        function (_$log_, _fs_, _gs_, _wss_, _ns_, _tps_) {
             $log = _$log_;
             fs = _fs_;
             gs = _gs_;
             wss = _wss_;
             ns = _ns_;
+            tps = _tps_;
 
             return {
                 register: register,
-                unregister: unregister,
+                //unregister: unregister,
+                setApi: setApi,
                 list: list,
-                overlay: overlay,
+                augmentRbset: augmentRbset,
+                mkGlyphId: mkGlyphId,
                 tbSelection: tbSelection,
-                installButtons: installButtons
+                installButtons: installButtons,
+                addDetailButton: addDetailButton,
+                hooks: {
+                    escape: escapeHook,
+                    emptySelect: emptySelectHook,
+                    singleSelect: singleSelectHook,
+                    multiSelect: multiSelectHook
+                },
+
+                showHighlights: showHighlights
             }
         }]);
 
diff --git a/web/gui/src/main/webapp/app/view/topo/topoSelect.js b/web/gui/src/main/webapp/app/view/topo/topoSelect.js
index fe2f7a1..2e73ea2 100644
--- a/web/gui/src/main/webapp/app/view/topo/topoSelect.js
+++ b/web/gui/src/main/webapp/app/view/topo/topoSelect.js
@@ -40,12 +40,6 @@
         selectOrder = [],       // the order in which we made selections
         consumeClick = false;   // used to coordinate with SVG click handler
 
-    // constants
-    var devPath = 'device',
-        flowPath = 'flow',
-        portPath ='port',
-        groupPath = 'group';
-
     // ==========================
 
     function nSel() {
@@ -157,8 +151,7 @@
 
     // === -----------------------------------------------------
 
-    function requestDetails() {
-        var data = getSel(0).obj;
+    function requestDetails(data) {
         wss.sendEvent('requestDetails', {
             id: data.id,
             class: data.class
@@ -179,91 +172,62 @@
     }
 
     function emptySelect() {
-        tts.cancelTraffic();
+        tov.hooks.emptySelect();
         tps.displayNothing();
     }
 
     function singleSelect() {
-        // NOTE: detail is shown from 'showDetails' event callback
-        requestDetails();
-        tts.cancelTraffic();
-        tts.requestTrafficForMode();
+        var data = getSel(0).obj;
+        requestDetails(data);
+        // NOTE: detail panel is shown as a response to receiving
+        //       a 'showDetails' event from the server. See 'showDetails'
+        //       callback function below...
     }
 
     function multiSelect() {
         // display the selected nodes in the detail panel
         tps.displayMulti(selectOrder);
-
-        // always add the 'show traffic' action
-        tps.addAction({
-            id: '-mult-rel-traf-btn',
-            gid: 'allTraffic',
-            cb:  tts.showRelatedIntentsAction,
-            tt: 'Show Related Traffic'
-        });
-
-        // add other actions, based on what is selected...
-        if (nSel() === 2 && allSelectionsClass('host')) {
-            tps.addAction({
-                id: 'host-flow-btn',
-                gid: 'endstation',
-                cb: tts.addHostIntentAction,
-                tt: 'Create Host-to-Host Flow'
-            });
-        } else if (nSel() >= 2 && allSelectionsClass('host')) {
-            tps.addAction({
-                id: 'mult-src-flow-btn',
-                gid: 'flows',
-                cb: tts.addMultiSourceIntentAction,
-                tt: 'Create Multi-Source Flow'
-            });
-        }
-
-        tts.cancelTraffic();
-        tts.requestTrafficForMode();
+        addHostSelectionActions();
+        tov.hooks.multiSelect(selectOrder);
         tps.displaySomething();
     }
 
+    function addHostSelectionActions() {
+        if (allSelectionsClass('host')) {
+            if (nSel() === 2) {
+                tps.addAction({
+                    id: 'host-flow-btn',
+                    gid: 'endstation',
+                    cb: tts.addHostIntent,
+                    tt: 'Create Host-to-Host Flow'
+                });
+            } else if (nSel() >= 2) {
+                tps.addAction({
+                    id: 'mult-src-flow-btn',
+                    gid: 'flows',
+                    cb: tts.addMultiSourceIntent,
+                    tt: 'Create Multi-Source Flow'
+                });
+            }
+        }
+    }
+
 
     // === -----------------------------------------------------
     //  Event Handlers
 
+    // display the data for the single selected node
     function showDetails(data) {
         var buttons = fs.isA(data.buttons) || [];
-
-        // display the data for the single selected node
         tps.displaySingle(data);
-
-        tov.installButtons(buttons, tps.addAction, data, data.props['URI']);
-
-        // TODO: MOVE traffic buttons to the traffic overlay
-        // always add the 'show traffic' action
-        tps.addAction({
-            id: '-sin-rel-traf-btn',
-            gid: 'intentTraffic',
-            cb: tts.showRelatedIntentsAction,
-            tt: 'Show Related Traffic'
-        });
-
-        // add other actions, based on what is selected...
-        if (data.type === 'switch') {
-            tps.addAction({
-                id: 'sin-dev-flows-btn',
-                gid: 'flows',
-                cb: tts.showDeviceLinkFlowsAction,
-                tt: 'Show Device Flows'
-            });
-        }
-
+        tov.installButtons(buttons, data, data.props['URI']);
+        tov.hooks.singleSelect(data);
         tps.displaySomething();
     }
 
-    function validateSelectionContext() {
-        if (!hovered && !nSel()) {
-            tts.cancelTraffic();
-            return false;
-        }
-        return true;
+    // returns true if we are hovering over a node, or any nodes are selected
+    function somethingSelected() {
+        return hovered || nSel();
     }
 
     function clickConsumed(x) {
@@ -306,10 +270,11 @@
                 selectObject: selectObject,
                 deselectObject: deselectObject,
                 deselectAll: deselectAll,
+                updateDetail: updateDetail,
 
                 hovered: function () { return hovered; },
                 selectOrder: function () { return selectOrder; },
-                validateSelectionContext: validateSelectionContext,
+                somethingSelected: somethingSelected,
 
                 clickConsumed: clickConsumed
             };
diff --git a/web/gui/src/main/webapp/app/view/topo/topoToolbar.js b/web/gui/src/main/webapp/app/view/topo/topoToolbar.js
index cbf443a..84de261 100644
--- a/web/gui/src/main/webapp/app/view/topo/topoToolbar.js
+++ b/web/gui/src/main/webapp/app/view/topo/topoToolbar.js
@@ -25,19 +25,25 @@
     // injected references
     var $log, fs, tbs, ps, tov, api;
 
+    // API:
+    //  getActionEntry
+    //  setUpKeys
+
     // internal state
-    var toolbar, keyData, cachedState;
+    var toolbar, keyData, cachedState, thirdRow;
 
     // constants
     var name = 'topo-tbar',
-        cooktag = 'topo_prefs';
+        cooktag = 'topo_prefs',
+        soa = 'switchOverlayActions: ',
+        selOver = 'Select overlay here &#x21e7;';
+
 
     // key to button mapping data
     var k2b = {
         O: { id: 'summary-tog', gid: 'summary', isel: true},
         I: { id: 'instance-tog', gid: 'uiAttached', isel: true },
         D: { id: 'details-tog', gid: 'details', isel: true },
-
         H: { id: 'hosts-tog', gid: 'endstation', isel: false },
         M: { id: 'offline-tog', gid: 'switch', isel: true },
         P: { id: 'ports-tog', gid: 'ports', isel: true },
@@ -50,16 +56,16 @@
         L: { id: 'cycleLabels-btn', gid: 'cycleLabels' },
         R: { id: 'resetZoom-btn', gid: 'resetZoom' },
 
-        E: { id: 'eqMaster-btn', gid: 'eqMaster' },
-
-        V: { id: 'relatedIntents-btn', gid: 'relatedIntents' },
-        leftArrow: { id: 'prevIntent-btn', gid: 'prevIntent' },
-        rightArrow: { id: 'nextIntent-btn', gid: 'nextIntent' },
-        W: { id: 'intentTraffic-btn', gid: 'intentTraffic' },
-        A: { id: 'allTraffic-btn', gid: 'allTraffic' },
-        F: { id: 'flows-btn', gid: 'flows' }
+        E: { id: 'eqMaster-btn', gid: 'eqMaster' }
     };
 
+    var prohibited = [
+        'T', 'backSlash', 'slash',
+        'X' // needed until we re-instate X above.
+    ];
+    prohibited = prohibited.concat(d3.map(k2b).keys());
+
+
     // initial toggle state: default settings and tag to key mapping
     var defaultPrefsState = {
             summary: 1,
@@ -112,6 +118,7 @@
     }
 
     function initKeyData() {
+        // TODO: use angular forEach instead of d3.map
         keyData = d3.map(k2b);
         keyData.forEach(function(key, value) {
             var data = api.getActionEntry(key);
@@ -124,6 +131,7 @@
         var v = keyData.get(key);
         v.btn = toolbar.addButton(v.id, v.gid, v.cb, v.tt);
     }
+
     function addToggle(key, suppressIfMobile) {
         var v = keyData.get(key);
         if (suppressIfMobile && fs.isMobile()) { return; }
@@ -158,36 +166,60 @@
 
         // generate radio button set for overlays; start with 'none'
         var rset = [{
-                gid: 'unknown',
+                gid: 'topo',
                 tooltip: 'No Overlay',
                 cb: function () {
-                    tov.tbSelection(null);
+                    tov.tbSelection(null, switchOverlayActions);
                 }
             }];
-
-        tov.list().forEach(function (key) {
-            var ov = tov.overlay(key);
-            rset.push({
-                gid: ov._glyphId,
-                tooltip: (ov.tooltip || '(no tooltip)'),
-                cb: function () {
-                    tov.tbSelection(ov.overlayId);
-                }
-            });
-        });
-
+        tov.augmentRbset(rset, switchOverlayActions);
         toolbar.addRadioSet('topo-overlays', rset);
     }
 
-    // TODO: 3rd row needs to be swapped in/out based on selected overlay
-    // NOTE: This particular row of buttons is for the traffic overlay
-    function addThirdRow() {
-        addButton('V');
-        addButton('leftArrow');
-        addButton('rightArrow');
-        addButton('W');
-        addButton('A');
-        addButton('F');
+    // invoked by overlay service to switch out old buttons and switch in new
+    function switchOverlayActions(oid, keyBindings) {
+        var prohibits = [],
+            kb = fs.isO(keyBindings) || {},
+            order = fs.isA(kb._keyOrder) || [];
+
+        if (keyBindings && !keyBindings._keyOrder) {
+            $log.warn(soa + 'no _keyOrder property defined');
+        } else {
+            // sanity removal of reserved property names
+            ['esc', '_keyListener', '_helpFormat'].forEach(function (k) {
+                fs.removeFromArray(k, order);
+            });
+        }
+
+        thirdRow.clear();
+
+        if (!order.length) {
+            thirdRow.setText(selOver);
+            thirdRow.classed('right', true);
+            api.setUpKeys(); // clear previous overlay key bindings
+
+        } else {
+            thirdRow.classed('right', false);
+            angular.forEach(order, function (key) {
+                var value, bid, gid, tt;
+
+                if (prohibited.indexOf(key) > -1) {
+                    prohibits.push(key);
+
+                } else {
+                    value = keyBindings[key];
+                    bid = oid + '-' + key;
+                    gid = tov.mkGlyphId(oid, value.gid);
+                    tt = value.tt + ' (' + key + ')';
+                    thirdRow.addButton(bid, gid, value.cb, tt);
+                }
+            });
+            api.setUpKeys(keyBindings); // add overlay key bindings
+        }
+
+        if (prohibits.length) {
+            $log.warn(soa + 'Prohibited key bindings ignored:', prohibits);
+        }
     }
 
     function createToolbar() {
@@ -197,8 +229,9 @@
         toolbar.addRow();
         addSecondRow();
         addOverlays();
-        toolbar.addRow();
-        addThirdRow();
+        thirdRow = toolbar.addRow();
+        thirdRow.setText(selOver);
+        thirdRow.classed('right', true);
 
         if (cachedState.toolbar) {
             toolbar.show();
diff --git a/web/gui/src/main/webapp/app/view/topo/topoTraffic.js b/web/gui/src/main/webapp/app/view/topo/topoTraffic.js
index 7332ad0..27ec979 100644
--- a/web/gui/src/main/webapp/app/view/topo/topoTraffic.js
+++ b/web/gui/src/main/webapp/app/view/topo/topoTraffic.js
@@ -23,85 +23,44 @@
     'use strict';
 
     // injected refs
-    var $log, fs, flash, wss;
+    var $log, fs, flash, wss, api;
 
-    // api to topoForce
-    var api;
     /*
-     clearLinkTrafficStyle()
-     removeLinkLabels()
-     updateLinks()
-     findLinkById( id )
-     hovered()
-     validateSelectionContext()
+       API to topoForce
+         hovered()
+         somethingSelected()
+         selectOrder()
      */
 
-    // constants
-    var hoverModeNone = 0,
-        hoverModeAll = 1,
-        hoverModeFlows = 2,
-        hoverModeIntents = 3;
-
     // internal state
-    var hoverMode = hoverModeNone;
+    var trafficMode = null,
+        hoverMode = null;
 
 
     // === -----------------------------------------------------
-    //  Event Handlers
-
-    function showTraffic(data) {
-        var paths = data.paths;
-
-        api.clearLinkTrafficStyle();
-        api.removeLinkLabels();
-
-        // Now highlight all links in the paths payload, and attach
-        //  labels to them, if they are defined.
-        paths.forEach(function (p) {
-            var n = p.links.length,
-                i, ldata, lab, units, magnitude, portcls;
-
-            for (i=0; i<n; i++) {
-                ldata = api.findLinkById(p.links[i]);
-                lab = p.labels[i];
-
-                if (ldata && !ldata.el.empty()) {
-                    ldata.el.classed(p.class, true);
-                    ldata.label = lab;
-
-                    if (fs.endsWith(lab, 'bps')) {
-                        // inject additional styling for port-based traffic
-                        units = lab.substring(lab.length-4);
-                        portcls = 'port-traffic-' + units;
-
-                        // for GBps
-                        if (units.substring(0,1) === 'G') {
-                            magnitude = fs.parseBitRate(lab);
-                            if (magnitude >= 9) {
-                                portcls += '-choked'
-                            }
-                        }
-                        ldata.el.classed(portcls, true);
-                    }
-                }
-            }
-        });
-
-        api.updateLinks();
-    }
-
-    // === -----------------------------------------------------
     //  Helper functions
 
+    // invoked in response to change in selection and/or mouseover/out:
+    function requestTrafficForMode() {
+        if (hoverMode === 'flows') {
+            requestDeviceLinkFlows();
+        } else if (hoverMode === 'intents') {
+            requestRelatedIntents();
+        } else {
+            cancelTraffic();
+        }
+    }
+
     function requestDeviceLinkFlows() {
+        // generates payload based on current hover-state
         var hov = api.hovered();
 
         function hoverValid() {
-            return hoverMode === hoverModeFlows &&
+            return hoverMode === 'flows' &&
                 hov && (hov.class === 'device');
         }
 
-        if (api.validateSelectionContext()) {
+        if (api.somethingSelected()) {
             wss.sendEvent('requestDeviceLinkFlows', {
                 ids: api.selectOrder(),
                 hover: hoverValid() ? hov.id : ''
@@ -110,14 +69,15 @@
     }
 
     function requestRelatedIntents() {
+        // generates payload based on current hover-state
         var hov = api.hovered();
 
         function hoverValid() {
-            return hoverMode === hoverModeIntents &&
+            return hoverMode === 'intents' &&
                 hov && (hov.class === 'host' || hov.class === 'device');
         }
 
-        if (api.validateSelectionContext()) {
+        if (api.somethingSelected()) {
             wss.sendEvent('requestRelatedIntents', {
                 ids: api.selectOrder(),
                 hover: hoverValid() ? hov.id : ''
@@ -126,71 +86,75 @@
     }
 
 
-    // === -----------------------------------------------------
-    //  Traffic requests
+    // === -------------------------------------------------------------
+    //  Traffic requests invoked from keystrokes or toolbar buttons...
 
     function cancelTraffic() {
-        wss.sendEvent('cancelTraffic');
-    }
-
-    // invoked in response to change in selection and/or mouseover/out:
-    function requestTrafficForMode() {
-        if (hoverMode === hoverModeFlows) {
-            requestDeviceLinkFlows();
-        } else if (hoverMode === hoverModeIntents) {
-            requestRelatedIntents();
+        if (!trafficMode) {
+            return false;
         }
+
+        trafficMode = hoverMode = null;
+        wss.sendEvent('cancelTraffic');
+        flash.flash('Traffic monitoring canceled');
+        return true;
     }
 
-    // === -----------------------------
-    // keystroke commands
-
-    // keystroke-right-arrow (see topo.js)
-    function showNextIntentAction() {
-        hoverMode = hoverModeNone;
-        wss.sendEvent('requestNextRelatedIntent');
-        flash.flash('Next related intent');
-    }
-
-    // keystroke-left-arrow (see topo.js)
-    function showPrevIntentAction() {
-        hoverMode = hoverModeNone;
-        wss.sendEvent('requestPrevRelatedIntent');
-        flash.flash('Previous related intent');
-    }
-
-    // keystroke-W (see topo.js)
-    function showSelectedIntentTrafficAction() {
-        hoverMode = hoverModeNone;
-        wss.sendEvent('requestSelectedIntentTraffic');
-        flash.flash('Traffic on Selected Path');
-    }
-
-    // keystroke-A (see topo.js)
-    function showAllFlowTrafficAction() {
-        hoverMode = hoverModeAll;
+    function showAllFlowTraffic() {
+        trafficMode = 'allFlow';
+        hoverMode = 'all';
         wss.sendEvent('requestAllFlowTraffic');
         flash.flash('All Flow Traffic');
     }
 
-    // keystroke-A (see topo.js)
-    function showAllPortTrafficAction() {
-        hoverMode = hoverModeAll;
+    function showAllPortTraffic() {
+        trafficMode = 'allPort';
+        hoverMode = 'all';
         wss.sendEvent('requestAllPortTraffic');
         flash.flash('All Port Traffic');
     }
 
-    // === -----------------------------
-    // action buttons on detail panel
+    function showDeviceLinkFlows () {
+        trafficMode = hoverMode = 'flows';
+        requestDeviceLinkFlows();
+        flash.flash('Device Flows');
+    }
 
-    // also, keystroke-V (see topo.js)
-    function showRelatedIntentsAction () {
-        hoverMode = hoverModeIntents;
+    function showRelatedIntents () {
+        trafficMode = hoverMode = 'intents';
         requestRelatedIntents();
         flash.flash('Related Paths');
     }
 
-    function addHostIntentAction () {
+    function showPrevIntent() {
+        if (trafficMode === 'intents') {
+            hoverMode = null;
+            wss.sendEvent('requestPrevRelatedIntent');
+            flash.flash('Previous related intent');
+        }
+    }
+
+    function showNextIntent() {
+        if (trafficMode === 'intents') {
+            hoverMode = null;
+            wss.sendEvent('requestNextRelatedIntent');
+            flash.flash('Next related intent');
+        }
+    }
+
+    function showSelectedIntentTraffic() {
+        if (trafficMode === 'intents') {
+            hoverMode = null;
+            wss.sendEvent('requestSelectedIntentTraffic');
+            flash.flash('Traffic on Selected Path');
+        }
+    }
+
+
+    // === ------------------------------------------------------
+    // action buttons on detail panel (multiple selection)
+
+    function addHostIntent () {
         var so = api.selectOrder();
         wss.sendEvent('addHostIntent', {
             one: so[0],
@@ -200,7 +164,7 @@
         flash.flash('Host-to-Host flow added');
     }
 
-    function addMultiSourceIntentAction () {
+    function addMultiSourceIntent () {
         var so = api.selectOrder();
         wss.sendEvent('addMultiSourceIntent', {
             src: so.slice(0, so.length - 1),
@@ -210,12 +174,6 @@
         flash.flash('Multi-Source flow added');
     }
 
-    // also, keystroke-F (see topo.js)
-    function showDeviceLinkFlowsAction () {
-        hoverMode = hoverModeFlows;
-        requestDeviceLinkFlows();
-        flash.flash('Device Flows');
-    }
 
 
     // === -----------------------------------------------------
@@ -231,29 +189,26 @@
             flash = _flash_;
             wss = _wss_;
 
-            function initTraffic(_api_) {
-                api = _api_;
-            }
-
-            function destroyTraffic() { }
-
             return {
-                initTraffic: initTraffic,
-                destroyTraffic: destroyTraffic,
+                initTraffic: function (_api_) { api = _api_; },
+                destroyTraffic: function () { },
 
-                showTraffic: showTraffic,
-
+                // invoked from toolbar overlay buttons or keystrokes
                 cancelTraffic: cancelTraffic,
+                showAllFlowTraffic: showAllFlowTraffic,
+                showAllPortTraffic: showAllPortTraffic,
+                showDeviceLinkFlows: showDeviceLinkFlows,
+                showRelatedIntents: showRelatedIntents,
+                showPrevIntent: showPrevIntent,
+                showNextIntent: showNextIntent,
+                showSelectedIntentTraffic: showSelectedIntentTraffic,
+
+                // invoked from mouseover/mouseout and selection change
                 requestTrafficForMode: requestTrafficForMode,
-                showRelatedIntentsAction: showRelatedIntentsAction,
-                addHostIntentAction: addHostIntentAction,
-                addMultiSourceIntentAction: addMultiSourceIntentAction,
-                showDeviceLinkFlowsAction: showDeviceLinkFlowsAction,
-                showNextIntentAction: showNextIntentAction,
-                showPrevIntentAction: showPrevIntentAction,
-                showSelectedIntentTrafficAction: showSelectedIntentTrafficAction,
-                showAllFlowTrafficAction: showAllFlowTrafficAction,
-                showAllPortTrafficAction: showAllPortTrafficAction
+
+                // invoked from buttons on detail (multi-select) panel
+                addHostIntent: addHostIntent,
+                addMultiSourceIntent: addMultiSourceIntent
             };
         }]);
 }());
diff --git a/web/gui/src/main/webapp/app/view/topo/topoTrafficNew.js b/web/gui/src/main/webapp/app/view/topo/topoTrafficNew.js
index 71cb94c..be91f0c 100644
--- a/web/gui/src/main/webapp/app/view/topo/topoTrafficNew.js
+++ b/web/gui/src/main/webapp/app/view/topo/topoTrafficNew.js
@@ -16,7 +16,7 @@
  */
 
 /*
- ONOS GUI -- Topology Traffic Module.
+ ONOS GUI -- Topology Traffic Overlay Module.
  Defines behavior for viewing different traffic modes.
  Installed as a Topology Overlay.
  */
@@ -24,7 +24,13 @@
     'use strict';
 
     // injected refs
-    var $log;
+    var $log, tov, tts;
+
+    // NOTE: no internal state here -- see TopoTrafficService for that
+
+    // NOTE: providing button disabling requires too big a refactoring of
+    //       the button factory etc. Will have to be done another time.
+
 
     // traffic overlay definition
     var overlay = {
@@ -32,26 +38,112 @@
         glyphId: 'allTraffic',
         tooltip: 'Traffic Overlay',
 
-        activate: activateTraffic,
-        deactivate: deactivateTraffic
+        // NOTE: Traffic glyphs already installed as part of the base ONOS set.
+
+        activate: function () {
+            $log.debug("Traffic overlay ACTIVATED");
+        },
+
+        deactivate: function () {
+            tts.cancelTraffic();
+            $log.debug("Traffic overlay DEACTIVATED");
+        },
+
+        // detail panel button definitions
+        // (keys match button identifiers, also defined in TrafficOverlay.java)
+        buttons: {
+            showDeviceFlows: {
+                gid: 'flows',
+                tt: 'Show Device Flows',
+                cb: function (data) { tts.showDeviceLinkFlows(); }
+            },
+
+            showRelatedTraffic: {
+                gid: 'relatedIntents',
+                tt: 'Show Related Traffic',
+                cb: function (data) { tts.showRelatedIntents(); }
+            }
+        },
+
+        // key bindings for traffic overlay toolbar buttons
+        // NOTE: fully qual. button ID is derived from overlay-id and key-name
+        keyBindings: {
+            0: {
+                cb: function () { tts.cancelTraffic(); },
+                tt: 'Cancel traffic monitoring',
+                gid: 'xMark'
+            },
+
+            A: {
+                cb: function () { tts.showAllFlowTraffic(); },
+                tt: 'Monitor all traffic using flow stats',
+                gid: 'allTraffic'
+            },
+            Q: {
+                cb: function () { tts.showAllPortTraffic(); },
+                tt: 'Monitor all traffic using port stats',
+                gid: 'allTraffic'
+            },
+            F: {
+                cb: function () { tts.showDeviceLinkFlows(); },
+                tt: 'Show device link flows',
+                gid: 'flows'
+            },
+            V: {
+                cb: function () { tts.showRelatedIntents(); },
+                tt: 'Show all related intents',
+                gid: 'relatedIntents'
+            },
+            leftArrow: {
+                cb: function () { tts.showPrevIntent(); },
+                tt: 'Show previous related intent',
+                gid: 'prevIntent'
+            },
+            rightArrow: {
+                cb: function () { tts.showNextIntent(); },
+                tt: 'Show next related intent',
+                gid: 'nextIntent'
+            },
+            W: {
+                cb: function () { tts.showSelectedIntentTraffic(); },
+                tt: 'Monitor traffic of selected intent',
+                gid: 'intentTraffic'
+            },
+
+            _keyOrder: [
+                '0', 'A', 'Q', 'F', 'V', 'leftArrow', 'rightArrow', 'W'
+            ]
+        },
+
+        hooks: {
+            // hook for handling escape key
+            escape: function () {
+                // Must return true to consume ESC, false otherwise.
+                return tts.cancelTraffic();
+            },
+
+            // hooks for when the selection changes...
+            empty: function () {
+                tts.cancelTraffic();
+            },
+            single: function (data) {
+                tts.requestTrafficForMode();
+            },
+            multi: function (selectOrder) {
+                tts.requestTrafficForMode();
+                tov.addDetailButton('showRelatedTraffic');
+            }
+        }
     };
 
-    // === implementation of overlay API (essentially callbacks)
-    function activateTraffic() {
-        $log.debug("Topology traffic overlay ACTIVATED");
-    }
-
-    function deactivateTraffic() {
-        $log.debug("Topology traffic overlay DEACTIVATED");
-    }
-
-
     // invoke code to register with the overlay service
     angular.module('ovTopo')
-        .run(['$log', 'TopoOverlayService',
+        .run(['$log', 'TopoOverlayService', 'TopoTrafficService',
 
-        function (_$log_, tov) {
+        function (_$log_, _tov_, _tts_) {
             $log = _$log_;
+            tov = _tov_;
+            tts = _tts_;
             tov.register(overlay);
         }]);