GUI -- Added handling of hosts and links. (Still WIP).
Change-Id: I0ad3b16d47b264b6812f732f220230a2ae92de02
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 73cf9bf..01b719e 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,9 @@
'use strict';
// injected refs
- var $log, sus, is, ts, tis, xlink;
+ var $log, fs, sus, is, ts, tis, uplink;
+
+ var icfg;
// configuration
var labelConfig = {
@@ -44,6 +46,21 @@
yoff: -18
};
+ var linkConfig = {
+ light: {
+ baseColor: '#666',
+ inColor: '#66f',
+ outColor: '#f00',
+ },
+ dark: {
+ baseColor: '#666',
+ inColor: '#66f',
+ outColor: '#f00',
+ },
+ inWidth: 12,
+ outWidth: 10
+ };
+
// internal state
var settings, // merged default settings and options
force, // force layout object
@@ -54,9 +71,11 @@
lookup: {},
revLinkToKey: {}
},
- projection, // background map projection
+ lu = network.lookup, // shorthand
deviceLabelIndex = 0, // for device label cycling
- hostLabelIndex = 0; // for host label cycling
+ hostLabelIndex = 0, // for host label cycling
+ showHosts = 1, // whether hosts are displayed
+ width, height;
// SVG elements;
var linkG, linkLabelG, nodeG;
@@ -99,18 +118,18 @@
var id = data.id,
d;
- xlink.showNoDevs(false);
+ uplink.showNoDevs(false);
// although this is an add device event, if we already have the
// device, treat it as an update instead..
- if (network.lookup[id]) {
+ if (lu[id]) {
updateDevice(data);
return;
}
d = createDeviceNode(data);
network.nodes.push(d);
- network.lookup[id] = d;
+ lu[id] = d;
$log.debug("Created new device.. ", d.id, d.x, d.y);
@@ -120,7 +139,7 @@
function updateDevice(data) {
var id = data.id,
- d = network.lookup[id],
+ d = lu[id],
wasOnline;
if (d) {
@@ -141,26 +160,379 @@
}
}
+ function removeDevice(data) {
+ var id = data.id,
+ d = lu[id];
+ if (d) {
+ removeDeviceElement(d);
+ } else {
+ // TODO: decide whether we want to capture logic errors
+ //logicError('removeDevice lookup fail. ID = "' + id + '"');
+ }
+ }
+
+ function addHost(data) {
+ var id = data.id,
+ d, lnk;
+
+ // although this is an add host event, if we already have the
+ // host, treat it as an update instead..
+ if (lu[id]) {
+ updateHost(data);
+ return;
+ }
+
+ d = createHostNode(data);
+ network.nodes.push(d);
+ lu[id] = d;
+
+ $log.debug("Created new host.. ", d.id, d.x, d.y);
+
+ updateNodes();
+
+ lnk = createHostLink(data);
+ if (lnk) {
+
+ $log.debug("Created new host-link.. ", lnk.key);
+
+ d.linkData = lnk; // cache ref on its host
+ network.links.push(lnk);
+ lu[d.ingress] = lnk;
+ lu[d.egress] = lnk;
+ updateLinks();
+ }
+
+ fStart();
+ }
+
+ function updateHost(data) {
+ var id = data.id,
+ d = lu[id];
+ if (d) {
+ angular.extend(d, data);
+ if (positionNode(d, true)) {
+ sendUpdateMeta(d, true);
+ }
+ updateNodes();
+ } else {
+ // TODO: decide whether we want to capture logic errors
+ //logicError('updateHost lookup fail. ID = "' + id + '"');
+ }
+ }
+
+ function removeHost(data) {
+ var id = data.id,
+ d = lu[id];
+ if (d) {
+ removeHostElement(d, true);
+ } else {
+ // may have already removed host, if attached to removed device
+ //console.warn('removeHost lookup fail. ID = "' + id + '"');
+ }
+ }
+
+ function addLink(data) {
+ var result = findLink(data, 'add'),
+ bad = result.badLogic,
+ d = result.ldata;
+
+ if (bad) {
+ //logicError(bad + ': ' + link.id);
+ return;
+ }
+
+ if (d) {
+ // we already have a backing store link for src/dst nodes
+ addLinkUpdate(d, data);
+ return;
+ }
+
+ // no backing store link yet
+ d = createLink(data);
+ if (d) {
+ network.links.push(d);
+ lu[d.key] = d;
+ updateLinks();
+ fStart();
+ }
+ }
+
+ function updateLink(data) {
+ var result = findLink(data, 'update'),
+ bad = result.badLogic;
+ if (bad) {
+ //logicError(bad + ': ' + link.id);
+ return;
+ }
+ result.updateWith(link);
+ }
+
+ function removeLink(data) {
+ var result = findLink(data, 'remove'),
+ bad = result.badLogic;
+ if (bad) {
+ // may have already removed link, if attached to removed device
+ //console.warn(bad + ': ' + link.id);
+ return;
+ }
+ result.removeRawLink();
+ }
+
+ // ========================
+
+ function addLinkUpdate(ldata, link) {
+ // add link event, but we already have the reverse link installed
+ ldata.fromTarget = link;
+ network.revLinkToKey[link.id] = ldata.key;
+ restyleLinkElement(ldata);
+ }
+
+ function createLink(link) {
+ var lnk = linkEndPoints(link.src, link.dst);
+
+ if (!lnk) {
+ return null;
+ }
+
+ angular.extend(lnk, {
+ key: link.id,
+ class: '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,
+ both = lnk.source.online && lnk.target.online;
+ return both && ((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;
+ }
+
+
+ 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;
+ }
+
+ var widthRatio = 1.4,
+ linkScale = d3.scale.linear()
+ .domain([1, 12])
+ .range([widthRatio, 12 * widthRatio])
+ .clamp(true);
+
+ 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 th = ts.theme(),
+ 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', linkConfig[th].baseColor);
+ }
+
+ function findLink(linkData, op) {
+ var key = makeLinkKey(linkData),
+ keyrev = makeLinkKey(linkData, 1),
+ link = lu[key],
+ linkRev = lu[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) {
+ angular.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;
+ delete network.revLinkToKey[keyrev];
+ }
+ } else {
+ // remove fromTarget
+ ldata.fromTarget = null;
+ delete network.revLinkToKey[keyrev];
+ }
+ if (ldata.fromSource) {
+ restyleLinkElement(ldata);
+ } else {
+ removeLinkElement(ldata);
+ }
+ }
+ }
+ }
+ }
+ return result;
+ }
+
+
+ function findAttachedHosts(devId) {
+ var hosts = [];
+ network.nodes.forEach(function (d) {
+ if (d.class === 'host' && d.cp.device === devId) {
+ hosts.push(d);
+ }
+ });
+ return hosts;
+ }
+
+ function findAttachedLinks(devId) {
+ var links = [];
+ network.links.forEach(function (d) {
+ if (d.source.id === devId || d.target.id === devId) {
+ links.push(d);
+ }
+ });
+ return links;
+ }
+
+ function removeLinkElement(d) {
+ var idx = fs.find(d.key, network.links, 'key'),
+ removed;
+ if (idx >=0) {
+ // remove from links array
+ removed = network.links.splice(idx, 1);
+ // remove from lookup cache
+ delete lu[removed[0].key];
+ updateLinks();
+ fResume();
+ }
+ }
+
+ function removeHostElement(d, upd) {
+ // first, remove associated hostLink...
+ removeLinkElement(d.linkData);
+
+ // remove hostLink bindings
+ delete lu[d.ingress];
+ delete lu[d.egress];
+
+ // remove from lookup cache
+ delete lu[d.id];
+ // remove from nodes array
+ var idx = fs.find(d.id, network.nodes);
+ network.nodes.splice(idx, 1);
+
+ // remove from SVG
+ // NOTE: upd is false if we were called from removeDeviceElement()
+ if (upd) {
+ updateNodes();
+ fResume();
+ }
+ }
+
+ function removeDeviceElement(d) {
+ var id = d.id;
+ // first, remove associated hosts and links..
+ findAttachedHosts(id).forEach(removeHostElement);
+ findAttachedLinks(id).forEach(removeLinkElement);
+
+ // remove from lookup cache
+ delete lu[id];
+ // remove from nodes array
+ var idx = fs.find(id, network.nodes);
+ network.nodes.splice(idx, 1);
+
+ if (!network.nodes.length) {
+ xlink.showNoDevs(true);
+ }
+
+ // remove from SVG
+ updateNodes();
+ fResume();
+ }
+
+
function sendUpdateMeta(d, store) {
var metaUi = {},
ll;
- // TODO: fix this code to send event to server...
- //if (store) {
- // ll = geoMapProj.invert([d.x, d.y]);
- // metaUi = {
- // x: d.x,
- // y: d.y,
- // lng: ll[0],
- // lat: ll[1]
- // };
- //}
- //d.metaUi = metaUi;
- //sendMessage('updateMeta', {
- // id: d.id,
- // 'class': d.class,
- // memento: metaUi
- //});
+ if (store) {
+ ll = lngLatFromCoord([d.x, d.y]);
+ metaUi = {
+ x: d.x,
+ y: d.y,
+ lng: ll[0],
+ lat: ll[1]
+ };
+ }
+ d.metaUi = metaUi;
+ uplink.sendEvent('updateMeta', {
+ id: d.id,
+ 'class': d.class,
+ memento: metaUi
+ });
}
@@ -178,9 +550,13 @@
// === Devices and hosts - helper functions
function coordFromLngLat(loc) {
- // Our hope is that the projection is installed before we start
- // handling incoming nodes. But if not, we'll just return the origin.
- return projection ? projection([loc.lng, loc.lat]) : [0, 0];
+ var p = uplink.projection();
+ return p ? p([loc.lng, loc.lat]) : [0, 0];
+ }
+
+ function lngLatFromCoord(coord) {
+ var p = uplink.projection();
+ return p ? p.invert([coord.x, coord.y]) : [0, 0];
}
function positionNode(node, forUpdate) {
@@ -230,8 +606,8 @@
function rand() {
return {
- x: randDim(network.view.width()),
- y: randDim(network.view.height())
+ x: randDim(width),
+ y: randDim(height)
};
}
@@ -246,7 +622,7 @@
}
function getDevice(cp) {
- var d = network.lookup[cp.device];
+ var d = lu[cp.device];
return d || rand();
}
@@ -267,9 +643,83 @@
return node;
}
+ function createHostNode(host) {
+ var node = host;
+
+ // Augment as needed...
+ node.class = 'host';
+ if (!node.type) {
+ node.type = 'endstation';
+ }
+ node.svgClass = 'node host ' + node.type;
+ positionNode(node);
+ return node;
+ }
+
+ function createHostLink(host) {
+ var src = host.id,
+ dst = host.cp.device,
+ id = host.ingress,
+ lnk = linkEndPoints(src, dst);
+
+ if (!lnk) {
+ return null;
+ }
+
+ // Synthesize link ...
+ angular.extend(lnk, {
+ key: id,
+ class: 'link',
+
+ type: function () { return 'hostLink'; },
+ online: function () {
+ // hostlink target is edge switch
+ return lnk.target.online;
+ },
+ linkWidth: function () { return 1; }
+ });
+ return lnk;
+ }
+
+ function linkEndPoints(srcId, dstId) {
+ var srcNode = lu[srcId],
+ dstNode = lu[dstId],
+ sMiss = !srcNode ? missMsg('src', srcId) : '',
+ dMiss = !dstNode ? missMsg('dst', dstId) : '';
+
+ if (sMiss || dMiss) {
+ $log.error('Node(s) not on map for link:\n' + sMiss + dMiss);
+ //logicError('Node(s) not on map for link:\n' + sMiss + dMiss);
+ return null;
+ }
+ return {
+ source: srcNode,
+ target: dstNode,
+ x1: srcNode.x,
+ y1: srcNode.y,
+ x2: dstNode.x,
+ y2: dstNode.y
+ };
+ }
+
+ function missMsg(what, id) {
+ return '\n[' + what + '] "' + id + '" missing ';
+ }
+
// ==========================
// === Devices and hosts - D3 rendering
+ function nodeMouseOver(m) {
+ // TODO
+ $log.debug("TODO nodeMouseOver()...", m);
+ }
+
+ function nodeMouseOut(m) {
+ // TODO
+ $log.debug("TODO nodeMouseOut()...", m);
+ }
+
+
// Returns the newly computed bounding box of the rectangle
function adjustRectToFitText(n) {
var text = n.select('text'),
@@ -323,7 +773,7 @@
var label = trimLabel(deviceLabel(d)),
noLabel = !label,
node = d.el,
- dim = is.iconConfig().device.dim,
+ dim = icfg.device.dim,
devCfg = deviceIconConfig,
box, dx, dy;
@@ -357,16 +807,6 @@
d.el.select('text').text(label);
}
- function nodeMouseOver(m) {
- // TODO
- $log.debug("TODO nodeMouseOver()...", m);
- }
-
- function nodeMouseOut(m) {
- // TODO
- $log.debug("TODO nodeMouseOut()...", m);
- }
-
function updateDeviceColors(d) {
if (d) {
setDeviceColor(d);
@@ -445,13 +885,14 @@
return sus.cat7().getColor(id, !online, ts.theme());
}
- //============
+ // ==========================
function updateNodes() {
+ // select all the nodes in the layout:
node = nodeG.selectAll('.node')
.data(network.nodes, function (d) { return d.id; });
- // operate on existing nodes...
+ // operate on existing nodes:
node.filter('.device').each(deviceExisting);
node.filter('.host').each(hostExisting);
@@ -470,7 +911,7 @@
.transition()
.attr('opacity', 1);
- // augment nodes...
+ // augment entering nodes:
entering.filter('.device').each(deviceEnter);
entering.filter('.host').each(hostEnter);
@@ -486,7 +927,7 @@
.style('opacity', 0)
.remove();
- // node specific....
+ // exiting node specifics:
exiting.filter('.host').each(hostExit);
exiting.filter('.device').each(deviceExit);
@@ -539,25 +980,20 @@
}
function hostEnter(d) {
- var node = d3.select(this);
-
- //cfg = config.icons.host,
- //r = cfg.radius[d.type] || cfg.defaultRadius,
- //textDy = r + 10,
- //TODO: iid = iconGlyphUrl(d),
- // _dummy;
+ var node = d3.select(this),
+ gid = d.type || 'unknown',
+ rad = icfg.host.radius,
+ r = d.type ? rad.withGlyph : rad.noGlyph,
+ textDy = r + 10;
d.el = node;
+ sus.makeVisible(node, showHosts);
- //TODO: showHostVis(node);
+ is.addHostIcon(node, r, gid);
- node.append('circle').attr('r', r);
- //if (iid) {
- //TODO: addHostIcon(node, r, iid);
- //}
node.append('text')
.text(hostLabel)
- //.attr('dy', textDy)
+ .attr('dy', textDy)
.attr('text-anchor', 'middle');
}
@@ -598,6 +1034,160 @@
.style('opacity', 0.5);
}
+ // ==========================
+
+ function updateLinks() {
+ var th = ts.theme();
+
+ link = linkG.selectAll('.link')
+ .data(network.links, function (d) { return d.key; });
+
+ // operate on existing links:
+ //link.each(linkExisting);
+
+ // operate on entering links:
+ var entering = link.enter()
+ .append('line')
+ .attr({
+ 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: linkConfig[th].inColor,
+ 'stroke-width': linkConfig.inWidth
+ });
+
+ // augment links
+ entering.each(linkEntering);
+
+ // operate on both existing and new links:
+ //link.each(...)
+
+ // apply or remove labels
+ var labelData = getLabelData();
+ applyLinkLabels(labelData);
+
+ // operate on exiting links:
+ link.exit()
+ .attr('stroke-dasharray', '3 3')
+ .style('opacity', 0.5)
+ .transition()
+ .duration(1500)
+ .attr({
+ 'stroke-dasharray': '3 12',
+ stroke: linkConfig[th].outColor,
+ 'stroke-width': linkConfig.outWidth
+ })
+ .style('opacity', 0.0)
+ .remove();
+
+ // NOTE: invoke a single tick to force the labels to position
+ // onto their links.
+ tick();
+ // FIXME: this is a bug when in oblique view
+ // It causes the nodes to jump into "overhead" view positions, even
+ // though the oblique planes are still showing...
+ }
+
+ // ==========================
+ // updateLinks - subfunctions
+
+ function getLabelData() {
+ // create the backing data for showing labels..
+ var data = [];
+ link.each(function (d) {
+ if (d.label) {
+ data.push({
+ id: 'lab-' + d.key,
+ key: d.key,
+ label: d.label,
+ ldata: d
+ });
+ }
+ });
+ return data;
+ }
+
+ //function linkExisting(d) { }
+
+ function linkEntering(d) {
+ var link = d3.select(this);
+ d.el = link;
+ restyleLinkElement(d);
+ if (d.type() === 'hostLink') {
+ sus.makeVisible(link, showHosts);
+ }
+ }
+
+ //function linkExiting(d) { }
+
+ var linkLabelOffset = '0.3em';
+
+ function applyLinkLabels(data) {
+ var entering;
+
+ linkLabel = linkLabelG.selectAll('.linkLabel')
+ .data(data, function (d) { return d.id; });
+
+ // for elements already existing, we need to update the text
+ // and adjust the rectangle size to fit
+ linkLabel.each(function (d) {
+ var el = d3.select(this),
+ rect = el.select('rect'),
+ text = el.select('text');
+ text.text(d.label);
+ rect.attr(rectAroundText(el));
+ });
+
+ entering = linkLabel.enter().append('g')
+ .classed('linkLabel', true)
+ .attr('id', function (d) { return d.id; });
+
+ entering.each(function (d) {
+ var el = d3.select(this),
+ rect,
+ text,
+ parms = {
+ x1: d.ldata.x1,
+ y1: d.ldata.y1,
+ x2: d.ldata.x2,
+ y2: d.ldata.y2
+ };
+
+ d.el = el;
+ rect = el.append('rect');
+ text = el.append('text').text(d.label);
+ rect.attr(rectAroundText(el));
+ text.attr('dy', linkLabelOffset);
+
+ el.attr('transform', transformLabel(parms));
+ });
+
+ // Remove any labels that are no longer required.
+ linkLabel.exit().remove();
+ }
+
+ function rectAroundText(el) {
+ var text = el.select('text'),
+ box = text.node().getBBox();
+
+ // translate the bbox so that it is centered on [x,y]
+ box.x = -box.width / 2;
+ box.y = -box.height / 2;
+
+ // add padding
+ box.x -= 1;
+ box.width += 2;
+ return box;
+ }
+
+ function transformLabel(p) {
+ var dx = p.x2 - p.x1,
+ dy = p.y2 - p.y1,
+ xMid = dx/2 + p.x1,
+ yMid = dy/2 + p.y1;
+ return sus.translate(xMid, yMid);
+ }
// ==========================
// force layout tick function
@@ -620,34 +1210,31 @@
angular.module('ovTopo')
.factory('TopoForceService',
- ['$log', 'SvgUtilService', 'IconService', 'ThemeService',
+ ['$log', 'FnService', 'SvgUtilService', 'IconService', 'ThemeService',
'TopoInstService',
- function (_$log_, _sus_, _is_, _ts_, _tis_) {
+ function (_$log_, _fs_, _sus_, _is_, _ts_, _tis_) {
$log = _$log_;
+ fs = _fs_;
sus = _sus_;
is = _is_;
ts = _ts_;
tis = _tis_;
+ icfg = is.iconConfig();
+
// forceG is the SVG group to display the force layout in
// xlink is the cross-link api from the main topo source file
// w, h are the initial dimensions of the SVG
// opts are, well, optional :)
- function initForce(forceG, _xlink_, w, h, opts) {
+ function initForce(forceG, _uplink_, w, h, opts) {
$log.debug('initForce().. WxH = ' + w + 'x' + h);
- xlink = _xlink_;
+ uplink = _uplink_;
+ width = w;
+ height = h;
settings = angular.extend({}, defaultSettings, opts);
- // when the projection promise is resolved, cache the projection
- xlink.projectionPromise.then(
- function (proj) {
- projection = proj;
- $log.debug('** We installed the projection: ', proj);
- }
- );
-
linkG = forceG.append('g').attr('id', 'topo-links');
linkLabelG = forceG.append('g').attr('id', 'topo-linkLabels');
nodeG = forceG.append('g').attr('id', 'topo-nodes');
@@ -672,7 +1259,9 @@
}
function resize(dim) {
- force.size([dim.width, dim.height]);
+ width = dim.width;
+ height = dim.height;
+ force.size([width, height]);
// Review -- do we need to nudge the layout ?
}
@@ -683,7 +1272,14 @@
updateDeviceColors: updateDeviceColors,
addDevice: addDevice,
- updateDevice: updateDevice
+ updateDevice: updateDevice,
+ removeDevice: removeDevice,
+ addHost: addHost,
+ updateHost: updateHost,
+ removeHost: removeHost,
+ addLink: addLink,
+ updateLink: updateLink,
+ removeLink: removeLink
};
}]);
}());