GUI -- Major rework to link processing so that we consolidate links A->B and B->A into a single backing object.
- added blue glow to ONOS instance when showing switch affinity.

Change-Id: Ia2a52d9d0571bc8c5eed964c85862f5798c7c5db
diff --git a/web/gui/src/main/webapp/json/ev/links/ev_10_onos.json b/web/gui/src/main/webapp/json/ev/links/ev_10_onos.json
new file mode 100644
index 0000000..5775e43
--- /dev/null
+++ b/web/gui/src/main/webapp/json/ev/links/ev_10_onos.json
@@ -0,0 +1,15 @@
+{
+  "event": "addLink",
+  "payload": {
+    "id": "of:0000ffffffff0008/10-of:0000ffffffff0003/20",
+    "type": "direct",
+    "linkWidth": 2,
+    "src": "of:0000ffffffff0008",
+    "srcPort": "10",
+    "dst": "of:0000ffffffff0003",
+    "dstPort": "20",
+    "props" : {
+      "BW": "90 Gb"
+    }
+  }
+}
diff --git a/web/gui/src/main/webapp/json/ev/links/ev_11_onos.json b/web/gui/src/main/webapp/json/ev/links/ev_11_onos.json
new file mode 100644
index 0000000..f0d0b4d
--- /dev/null
+++ b/web/gui/src/main/webapp/json/ev/links/ev_11_onos.json
@@ -0,0 +1,17 @@
+{
+  "event": "addHost",
+  "payload": {
+    "id": "0E:2A:69:30:13:88/-1",
+    "ingress": "0E:2A:69:30:13:88/-1/0-of:0000ffffffff0003/1",
+    "egress": "of:0000ffffffff0003/1-0E:2A:69:30:13:88/-1/0",
+    "cp": {
+      "device": "of:0000ffffffff0003",
+      "port": 1
+    },
+    "labels": [
+      "Host-A",
+      "0E:2A:69:30:13:88"
+    ],
+    "props": {}
+  }
+}
diff --git a/web/gui/src/main/webapp/json/ev/links/ev_12_onos.json b/web/gui/src/main/webapp/json/ev/links/ev_12_onos.json
new file mode 100644
index 0000000..d977343
--- /dev/null
+++ b/web/gui/src/main/webapp/json/ev/links/ev_12_onos.json
@@ -0,0 +1,17 @@
+{
+  "event": "addHost",
+  "payload": {
+    "id": "0E:2A:69:30:13:89/-1",
+    "ingress": "0E:2A:69:30:13:89/-1/0-of:0000ffffffff0007/1",
+    "egress": "of:0000ffffffff0007/1-0E:2A:69:30:13:89/-1/0",
+    "cp": {
+      "device": "of:0000ffffffff0007",
+      "port": 1
+    },
+    "labels": [
+      "Host-B",
+      "0E:2A:69:30:13:89"
+    ],
+    "props": {}
+  }
+}
diff --git a/web/gui/src/main/webapp/json/ev/links/ev_13_onos.json b/web/gui/src/main/webapp/json/ev/links/ev_13_onos.json
new file mode 100644
index 0000000..8f643ba
--- /dev/null
+++ b/web/gui/src/main/webapp/json/ev/links/ev_13_onos.json
@@ -0,0 +1,17 @@
+{
+  "event": "addHost",
+  "payload": {
+    "id": "0E:2A:69:30:13:8A/-1",
+    "ingress": "0E:2A:69:30:13:8A/-1/0-of:0000ffffffff0008/1",
+    "egress": "of:0000ffffffff0008/1-0E:2A:69:30:13:8A/-1/0",
+    "cp": {
+      "device": "of:0000ffffffff0008",
+      "port": 1
+    },
+    "labels": [
+      "Host-C",
+      "0E:2A:69:30:13:8A"
+    ],
+    "props": {}
+  }
+}
diff --git a/web/gui/src/main/webapp/json/ev/links/ev_14_onos.json b/web/gui/src/main/webapp/json/ev/links/ev_14_onos.json
new file mode 100644
index 0000000..d761019
--- /dev/null
+++ b/web/gui/src/main/webapp/json/ev/links/ev_14_onos.json
@@ -0,0 +1,17 @@
+{
+  "event": "updateLink",
+  "payload": {
+    "id": "of:0000ffffffff0007/10-of:0000ffffffff0008/20",
+    "src": "of:0000ffffffff0007",
+    "srcPort": "10",
+    "dst": "of:0000ffffffff0008",
+    "dstPort": "20",
+
+    "type": "direct",
+    "linkWidth": 2,
+    "online": true,
+    "props" : {
+      "BW": "90 Gb"
+    }
+  }
+}
diff --git a/web/gui/src/main/webapp/json/ev/links/ev_15_onos.json b/web/gui/src/main/webapp/json/ev/links/ev_15_onos.json
new file mode 100644
index 0000000..dbdfb3a
--- /dev/null
+++ b/web/gui/src/main/webapp/json/ev/links/ev_15_onos.json
@@ -0,0 +1,17 @@
+{
+  "event": "updateLink",
+  "payload": {
+    "id": "of:0000ffffffff0007/20-of:0000ffffffff0003/10",
+    "src": "of:0000ffffffff0007",
+    "srcPort": "20",
+    "dst": "of:0000ffffffff0003",
+    "dstPort": "10",
+
+    "type": "direct",
+    "linkWidth": 6,
+    "online": true,
+    "props" : {
+      "BW": "90 Gb"
+    }
+  }
+}
diff --git a/web/gui/src/main/webapp/json/ev/links/ev_16_onos.json b/web/gui/src/main/webapp/json/ev/links/ev_16_onos.json
new file mode 100644
index 0000000..b7783c1
--- /dev/null
+++ b/web/gui/src/main/webapp/json/ev/links/ev_16_onos.json
@@ -0,0 +1,15 @@
+{
+  "event": "removeLink",
+  "payload": {
+    "id": "of:0000ffffffff0007/20-of:0000ffffffff0003/10",
+    "type": "direct",
+    "linkWidth": 2,
+    "src": "of:0000ffffffff0007",
+    "srcPort": "20",
+    "dst": "of:0000ffffffff0003",
+    "dstPort": "10",
+    "props" : {
+      "BW": "90 Gb"
+    }
+  }
+}
diff --git a/web/gui/src/main/webapp/json/ev/links/ev_17_onos.json b/web/gui/src/main/webapp/json/ev/links/ev_17_onos.json
new file mode 100644
index 0000000..daf926e
--- /dev/null
+++ b/web/gui/src/main/webapp/json/ev/links/ev_17_onos.json
@@ -0,0 +1,15 @@
+{
+  "event": "removeLink",
+  "payload": {
+    "id": "of:0000ffffffff0003/10-of:0000ffffffff0007/20",
+    "type": "direct",
+    "linkWidth": 2,
+    "src": "of:0000ffffffff0003",
+    "srcPort": "10",
+    "dst": "of:0000ffffffff0007",
+    "dstPort": "20",
+    "props" : {
+      "BW": "90 Gb"
+    }
+  }
+}
diff --git a/web/gui/src/main/webapp/json/ev/links/ev_1_onos.json b/web/gui/src/main/webapp/json/ev/links/ev_1_onos.json
new file mode 100644
index 0000000..d7f69d3
--- /dev/null
+++ b/web/gui/src/main/webapp/json/ev/links/ev_1_onos.json
@@ -0,0 +1,11 @@
+{
+  "event": "addInstance",
+  "payload": {
+    "id": "local",
+    "online": true,
+    "labels": [
+      "local",
+      "127.0.0.1"
+    ]
+  }
+}
diff --git a/web/gui/src/main/webapp/json/ev/links/ev_2_onos.json b/web/gui/src/main/webapp/json/ev/links/ev_2_onos.json
new file mode 100644
index 0000000..352a835
--- /dev/null
+++ b/web/gui/src/main/webapp/json/ev/links/ev_2_onos.json
@@ -0,0 +1,18 @@
+{
+  "event": "addDevice",
+  "payload": {
+    "id": "of:0000ffffffff0003",
+    "type": "switch",
+    "online": true,
+    "master": "local",
+    "labels": [
+      "0000ffffffff0003",
+      "FF:FF:FF:FF:00:03",
+      "sw-3"
+    ],
+    "metaUi": {
+      "x": 282,
+      "y": 503
+    }
+  }
+}
diff --git a/web/gui/src/main/webapp/json/ev/links/ev_3_onos.json b/web/gui/src/main/webapp/json/ev/links/ev_3_onos.json
new file mode 100644
index 0000000..d52db4e
--- /dev/null
+++ b/web/gui/src/main/webapp/json/ev/links/ev_3_onos.json
@@ -0,0 +1,18 @@
+{
+  "event": "addDevice",
+  "payload": {
+    "id": "of:0000ffffffff0007",
+    "type": "switch",
+    "online": true,
+    "master": "local",
+    "labels": [
+      "0000ffffffff0007",
+      "FF:FF:FF:FF:00:07",
+      "sw-7"
+    ],
+    "metaUi": {
+      "x": 530,
+      "y": 330
+    }
+  }
+}
diff --git a/web/gui/src/main/webapp/json/ev/links/ev_4_onos.json b/web/gui/src/main/webapp/json/ev/links/ev_4_onos.json
new file mode 100644
index 0000000..9f2c260
--- /dev/null
+++ b/web/gui/src/main/webapp/json/ev/links/ev_4_onos.json
@@ -0,0 +1,18 @@
+{
+  "event": "addDevice",
+  "payload": {
+    "id": "of:0000ffffffff0008",
+    "type": "switch",
+    "online": true,
+    "master": "local",
+    "labels": [
+      "0000ffffffff0008",
+      "FF:FF:FF:FF:00:08",
+      "sw-8"
+    ],
+    "metaUi": {
+      "x": 734,
+      "y": 477
+    }
+  }
+}
diff --git a/web/gui/src/main/webapp/json/ev/links/ev_5_onos.json b/web/gui/src/main/webapp/json/ev/links/ev_5_onos.json
new file mode 100644
index 0000000..771c332
--- /dev/null
+++ b/web/gui/src/main/webapp/json/ev/links/ev_5_onos.json
@@ -0,0 +1,15 @@
+{
+  "event": "addLink",
+  "payload": {
+    "id": "of:0000ffffffff0007/10-of:0000ffffffff0008/20",
+    "type": "direct",
+    "linkWidth": 2,
+    "src": "of:0000ffffffff0007",
+    "srcPort": "10",
+    "dst": "of:0000ffffffff0008",
+    "dstPort": "20",
+    "props" : {
+      "BW": "90 Gb"
+    }
+  }
+}
diff --git a/web/gui/src/main/webapp/json/ev/links/ev_6_onos.json b/web/gui/src/main/webapp/json/ev/links/ev_6_onos.json
new file mode 100644
index 0000000..6eea869
--- /dev/null
+++ b/web/gui/src/main/webapp/json/ev/links/ev_6_onos.json
@@ -0,0 +1,15 @@
+{
+  "event": "addLink",
+  "payload": {
+    "id": "of:0000ffffffff0008/20-of:0000ffffffff0007/10",
+    "type": "direct",
+    "linkWidth": 2,
+    "src": "of:0000ffffffff0008",
+    "srcPort": "20",
+    "dst": "of:0000ffffffff0007",
+    "dstPort": "10",
+    "props" : {
+      "BW": "90 Gb"
+    }
+  }
+}
diff --git a/web/gui/src/main/webapp/json/ev/links/ev_7_onos.json b/web/gui/src/main/webapp/json/ev/links/ev_7_onos.json
new file mode 100644
index 0000000..cff94a5
--- /dev/null
+++ b/web/gui/src/main/webapp/json/ev/links/ev_7_onos.json
@@ -0,0 +1,15 @@
+{
+  "event": "addLink",
+  "payload": {
+    "id": "of:0000ffffffff0003/10-of:0000ffffffff0007/20",
+    "type": "direct",
+    "linkWidth": 2,
+    "src": "of:0000ffffffff0003",
+    "srcPort": "10",
+    "dst": "of:0000ffffffff0007",
+    "dstPort": "20",
+    "props" : {
+      "BW": "90 Gb"
+    }
+  }
+}
diff --git a/web/gui/src/main/webapp/json/ev/links/ev_8_onos.json b/web/gui/src/main/webapp/json/ev/links/ev_8_onos.json
new file mode 100644
index 0000000..0a5a314
--- /dev/null
+++ b/web/gui/src/main/webapp/json/ev/links/ev_8_onos.json
@@ -0,0 +1,15 @@
+{
+  "event": "addLink",
+  "payload": {
+    "id": "of:0000ffffffff0007/20-of:0000ffffffff0003/10",
+    "type": "direct",
+    "linkWidth": 2,
+    "src": "of:0000ffffffff0007",
+    "srcPort": "20",
+    "dst": "of:0000ffffffff0003",
+    "dstPort": "10",
+    "props" : {
+      "BW": "90 Gb"
+    }
+  }
+}
diff --git a/web/gui/src/main/webapp/json/ev/links/ev_9_onos.json b/web/gui/src/main/webapp/json/ev/links/ev_9_onos.json
new file mode 100644
index 0000000..0b6b67b
--- /dev/null
+++ b/web/gui/src/main/webapp/json/ev/links/ev_9_onos.json
@@ -0,0 +1,15 @@
+{
+  "event": "addLink",
+  "payload": {
+    "id": "of:0000ffffffff0003/20-of:0000ffffffff0008/10",
+    "type": "direct",
+    "linkWidth": 2,
+    "src": "of:0000ffffffff0003",
+    "srcPort": "20",
+    "dst": "of:0000ffffffff0008",
+    "dstPort": "10",
+    "props" : {
+      "BW": "90 Gb"
+    }
+  }
+}
diff --git a/web/gui/src/main/webapp/json/ev/links/scenario.json b/web/gui/src/main/webapp/json/ev/links/scenario.json
new file mode 100644
index 0000000..b1988ca
--- /dev/null
+++ b/web/gui/src/main/webapp/json/ev/links/scenario.json
@@ -0,0 +1,16 @@
+{
+  "comments": [
+    "Stepping through link events"
+  ],
+  "title": "Process Link Events Scenario",
+  "params": {
+    "lastAuto": 13
+  },
+  "description": [
+    "Develop link event handling.",
+    "",
+    "Press 'S' to load initial events.",
+    "",
+    "Press spacebar to complete the scenario..."
+  ]
+}
diff --git a/web/gui/src/main/webapp/topo2.css b/web/gui/src/main/webapp/topo2.css
index 2eb6a8e..35ddc5a 100644
--- a/web/gui/src/main/webapp/topo2.css
+++ b/web/gui/src/main/webapp/topo2.css
@@ -262,6 +262,7 @@
 }
 #topo-oibox .onosInst.mastership.affinity {
     opacity: 1.0;
+    box-shadow: 0px 2px 8px #33e;
 }
 
 
diff --git a/web/gui/src/main/webapp/topo2.js b/web/gui/src/main/webapp/topo2.js
index 37e098e..0d84125 100644
--- a/web/gui/src/main/webapp/topo2.js
+++ b/web/gui/src/main/webapp/topo2.js
@@ -339,13 +339,16 @@
         link: {
             hostLink: 'pkt',
             direct: 'pkt',
+            indirect: '',
+            tunnel: '',
             optical: 'opt'
         }
     };
 
     function inLayer(d, layer) {
-        var look = layerLookup[d.class],
-            lyr = look && look[d.type];
+        var type = d.class === 'link' ? d.type() : d.type,
+            look = layerLookup[d.class],
+            lyr = look && look[type];
         return lyr === layer;
     }
 
@@ -408,6 +411,115 @@
         });
     }
 
+    function makeNodeKey(d, what) {
+        var port = what + 'Port';
+        return d[what] + '/' + d[port];
+    }
+
+    function makeLinkKey(d, flipped) {
+        var one = flipped ? makeNodeKey(d, 'dst') : makeNodeKey(d, 'src'),
+            two = flipped ? makeNodeKey(d, 'src') : makeNodeKey(d, 'dst');
+        return one + '-' + two;
+    }
+
+    function findLink(linkData, op) {
+        var key = makeLinkKey(linkData),
+            keyrev = makeLinkKey(linkData, 1),
+            link = network.lookup[key],
+            linkRev = network.lookup[keyrev],
+            result = {},
+            ldata = link || linkRev,
+            rawLink;
+
+        if (op === 'add') {
+            if (link) {
+                // trying to add a link that we already know about
+                result.ldata = link;
+                result.badLogic = 'addLink: link already added';
+
+            } else if (linkRev) {
+                // we found the reverse of the link to be added
+                result.ldata = linkRev;
+                if (linkRev.fromTarget) {
+                    result.badLogic = 'addLink: link already added';
+                }
+            }
+        } else if (op === 'update') {
+            if (!ldata) {
+                result.badLogic = 'updateLink: link not found';
+            } else {
+                rawLink = link ? ldata.fromSource : ldata.fromTarget;
+                result.updateWith = function (data) {
+                    $.extend(rawLink, data);
+                    restyleLinkElement(ldata);
+                }
+            }
+        } else if (op === 'remove') {
+            if (!ldata) {
+                result.badLogic = 'removeLink: link not found';
+            } else {
+                rawLink = link ? ldata.fromSource : ldata.fromTarget;
+
+                if (!rawLink) {
+                    result.badLogic = 'removeLink: link not found';
+
+                } else {
+                    result.removeRawLink = function () {
+                        if (link) {
+                            // remove fromSource
+                            ldata.fromSource = null;
+                            if (ldata.fromTarget) {
+                                // promote target into source position
+                                ldata.fromSource = ldata.fromTarget;
+                                ldata.fromTarget = null;
+                                ldata.key = keyrev;
+                                delete network.lookup[key];
+                                network.lookup[keyrev] = ldata;
+                            }
+                        } else {
+                            // remove fromTarget
+                            ldata.fromTarget = null;
+                        }
+                        if (ldata.fromSource) {
+                            restyleLinkElement(ldata);
+                        } else {
+                            removeLinkElement(ldata);
+                        }
+                    }
+                }
+            }
+        }
+        return result;
+    }
+
+    function addLinkUpdate(ldata, link) {
+        // add link event, but we already have the reverse link installed
+        ldata.fromTarget = link;
+        restyleLinkElement(ldata);
+    }
+
+    var allLinkTypes = 'direct indirect optical tunnel',
+        defaultLinkType = 'direct';
+
+    function restyleLinkElement(ldata) {
+        // this fn's job is to look at raw links and decide what svg classes
+        // need to be applied to the line element in the DOM
+        var el = ldata.el,
+            type = ldata.type(),
+            lw = ldata.linkWidth(),
+            online = ldata.online();
+
+        el.classed('link', true);
+        el.classed('inactive', !online);
+        el.classed(allLinkTypes, false);
+        if (type) {
+            el.classed(type, true);
+        }
+        el.transition()
+            .duration(1000)
+            .attr('stroke-width', linkScale(lw))
+            .attr('stroke', '#666');  // TODO: remove explicit stroke (use CSS)
+    }
 
     // ==============================
     // Event handlers for server-pushed events
@@ -465,10 +577,26 @@
     function addLink(data) {
         evTrace(data);
         var link = data.payload,
-            lnk = createLink(link);
-        if (lnk) {
-            network.links.push(lnk);
-            network.lookup[lnk.id] = lnk;
+            result = findLink(link, 'add'),
+            bad = result.badLogic,
+            ldata = result.ldata;
+
+        if (bad) {
+            logicError(bad + ': ' + link.id);
+            return;
+        }
+
+        if (ldata) {
+            // we already have a backing store link for src/dst nodes
+            addLinkUpdate(ldata, link);
+            return;
+        }
+
+        // no backing store link yet
+        ldata = createLink(link);
+        if (ldata) {
+            network.links.push(ldata);
+            network.lookup[ldata.key] = ldata;
             updateLinks();
             network.force.start();
         }
@@ -511,14 +639,13 @@
     function updateLink(data) {
         evTrace(data);
         var link = data.payload,
-            id = link.id,
-            linkData = network.lookup[id];
-        if (linkData) {
-            $.extend(linkData, link);
-            updateLinkState(linkData);
-        } else {
-            logicError('updateLink lookup fail. ID = "' + id + '"');
+            result = findLink(link, 'update'),
+            bad = result.badLogic;
+        if (bad) {
+            logicError(bad + ': ' + link.id);
+            return;
         }
+        result.updateWith(link);
     }
 
     function updateHost(data) {
@@ -538,13 +665,13 @@
     function removeLink(data) {
         evTrace(data);
         var link = data.payload,
-            id = link.id,
-            linkData = network.lookup[id];
-        if (linkData) {
-            removeLinkElement(linkData);
-        } else {
-            logicError('removeLink lookup fail. ID = "' + id + '"');
+            result = findLink(link, 'remove'),
+            bad = result.badLogic;
+        if (bad) {
+            logicError(bad + ': ' + link.id);
+            return;
         }
+        result.removeRawLink();
     }
 
     function removeHost(data) {
@@ -805,11 +932,13 @@
 
         // Synthesize link ...
         $.extend(lnk, {
-            id: id,
+            key: id,
             class: 'link',
-            type: 'hostLink',
-            svgClass: 'link hostLink',
-            linkWidth: 1
+
+            type: function () { return 'hostLink'; },
+            // TODO: ideally, we should see if our edge switch is online...
+            online: function () { return true; },
+            linkWidth: function () { return 1; }
         });
         return lnk;
     }
@@ -822,10 +951,29 @@
             return null;
         }
 
-        // merge in remaining data
-        $.extend(lnk, link, {
+        $.extend(lnk, {
+            key: link.id,
             class: 'link',
-            svgClass: (type ? 'link ' + type : 'link')
+            fromSource: link,
+
+            // functions to aggregate dual link state
+            type: function () {
+                var s = lnk.fromSource,
+                    t = lnk.fromTarget;
+                return (s && s.type) || (t && t.type) || defaultLinkType;
+            },
+            online: function () {
+                var s = lnk.fromSource,
+                    t = lnk.fromTarget;
+                return (s && s.online) || (t && t.online);
+            },
+            linkWidth: function () {
+                var s = lnk.fromSource,
+                    t = lnk.fromTarget,
+                    ws = (s && s.linkWidth) || 0,
+                    wt = (t && t.linkWidth) || 0;
+                return Math.max(ws, wt);
+            }
         });
         return lnk;
     }
@@ -836,17 +984,9 @@
             .range([widthRatio, 12 * widthRatio])
             .clamp(true);
 
-    function updateLinkWidth (d) {
-        // TODO: watch out for .showPath/.showTraffic classes
-        d.el.transition()
-            .duration(1000)
-            .attr('stroke-width', linkScale(d.linkWidth));
-    }
-
-
     function updateLinks() {
         link = linkG.selectAll('.link')
-            .data(network.links, function (d) { return d.id; });
+            .data(network.links, function (d) { return d.key; });
 
         // operate on existing links, if necessary
         // link .foo() .bar() ...
@@ -855,19 +995,12 @@
         var entering = link.enter()
             .append('line')
             .attr({
-                class: function (d) { return d.svgClass; },
                 x1: function (d) { return d.x1; },
                 y1: function (d) { return d.y1; },
                 x2: function (d) { return d.x2; },
                 y2: function (d) { return d.y2; },
                 stroke: config.topo.linkInColor,
                 'stroke-width': config.topo.linkInWidth
-            })
-            .classed('inactive', function(d) { return !d.online; })
-            .transition().duration(1000)
-            .attr({
-                'stroke-width': function (d) { return linkScale(d.linkWidth); },
-                stroke: '#666'      // TODO: remove explicit stroke, rather...
             });
 
         // augment links
@@ -875,6 +1008,7 @@
             var link = d3.select(this);
             // provide ref to element selection from backing data....
             d.el = link;
+            restyleLinkElement(d);
 
             // TODO: add src/dst port labels etc.
         });
@@ -1240,9 +1374,9 @@
         // TODO: device node exits
     }
 
-    function find(id, array) {
+    function find(key, array) {
         for (var idx = 0, n = array.length; idx < n; idx++) {
-            if (array[idx].id === id) {
+            if (array[idx].key === key) {
                 return idx;
             }
         }
@@ -1250,14 +1384,16 @@
     }
 
     function removeLinkElement(linkData) {
-        // remove from lookup cache
-        delete network.lookup[linkData.id];
-        // remove from links array
-        var idx = find(linkData.id, network.links);
-        network.links.splice(idx, 1);
-        // remove from SVG
-        updateLinks();
-        network.force.resume();
+        var idx = find(linkData.key, network.links),
+            removed;
+        if (idx >=0) {
+            // remove from links array
+            removed = network.links.splice(idx, 1);
+            // remove from lookup cache
+            delete network.lookup[removed[0].key];
+            updateLinks();
+            network.force.resume();
+        }
     }
 
     function removeHostElement(hostData) {