mirror of
https://github.com/opennetworkinglab/onos.git
synced 2025-10-24 13:51:27 +02:00
- augmented ESC key handling to cancel affinity display before deslecting nodes. - augmented setRadioButtons to return buttonset api, so we can query what is currently selected. Change-Id: I17532bae7ea5fa639ce5d600c67e6c44728ff67f
1860 lines
50 KiB
JavaScript
1860 lines
50 KiB
JavaScript
/*
|
|
* Copyright 2014 Open Networking Laboratory
|
|
*
|
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
* you may not use this file except in compliance with the License.
|
|
* You may obtain a copy of the License at
|
|
*
|
|
* http://www.apache.org/licenses/LICENSE-2.0
|
|
*
|
|
* Unless required by applicable law or agreed to in writing, software
|
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
* See the License for the specific language governing permissions and
|
|
* limitations under the License.
|
|
*/
|
|
|
|
/*
|
|
ONOS network topology viewer - version 1.1
|
|
|
|
@author Simon Hunt
|
|
*/
|
|
|
|
(function (onos) {
|
|
'use strict';
|
|
|
|
// shorter names for library APIs
|
|
var d3u = onos.lib.d3util,
|
|
trace;
|
|
|
|
// configuration data
|
|
var config = {
|
|
useLiveData: true,
|
|
fnTrace: true,
|
|
debugOn: false,
|
|
debug: {
|
|
showNodeXY: true,
|
|
showKeyHandler: false
|
|
},
|
|
options: {
|
|
layering: true,
|
|
collisionPrevention: true,
|
|
showBackground: true
|
|
},
|
|
backgroundUrl: 'img/us-map.png',
|
|
webSockUrl: 'ws/topology',
|
|
data: {
|
|
live: {
|
|
jsonUrl: 'rs/topology/graph',
|
|
detailPrefix: 'rs/topology/graph/',
|
|
detailSuffix: ''
|
|
},
|
|
fake: {
|
|
jsonUrl: 'json/network2.json',
|
|
detailPrefix: 'json/',
|
|
detailSuffix: '.json'
|
|
}
|
|
},
|
|
labels: {
|
|
imgPad: 16,
|
|
padLR: 4,
|
|
padTB: 3,
|
|
marginLR: 3,
|
|
marginTB: 2,
|
|
port: {
|
|
gap: 3,
|
|
width: 18,
|
|
height: 14
|
|
}
|
|
},
|
|
topo: {
|
|
linkInColor: '#66f',
|
|
linkInWidth: 14
|
|
},
|
|
icons: {
|
|
w: 28,
|
|
h: 28,
|
|
xoff: -12,
|
|
yoff: -8
|
|
},
|
|
iconUrl: {
|
|
device: 'img/device.png',
|
|
host: 'img/host.png',
|
|
pkt: 'img/pkt.png',
|
|
opt: 'img/opt.png'
|
|
},
|
|
force: {
|
|
note_for_links: 'link.type is used to differentiate',
|
|
linkDistance: {
|
|
direct: 100,
|
|
optical: 120,
|
|
hostLink: 3
|
|
},
|
|
linkStrength: {
|
|
direct: 1.0,
|
|
optical: 1.0,
|
|
hostLink: 1.0
|
|
},
|
|
note_for_nodes: 'node.class is used to differentiate',
|
|
charge: {
|
|
device: -8000,
|
|
host: -5000
|
|
},
|
|
pad: 20,
|
|
translate: function() {
|
|
return 'translate(' +
|
|
config.force.pad + ',' +
|
|
config.force.pad + ')';
|
|
}
|
|
},
|
|
// see below in creation of viewBox on main svg
|
|
logicalSize: 1000
|
|
};
|
|
|
|
// radio buttons
|
|
var layerButtons = [
|
|
{ text: 'All Layers', id: 'all', cb: showAllLayers },
|
|
{ text: 'Packet Only', id: 'pkt', cb: showPacketLayer },
|
|
{ text: 'Optical Only', id: 'opt', cb: showOpticalLayer }
|
|
],
|
|
layerBtnSet,
|
|
layerBtnDispatch = {
|
|
all: showAllLayers,
|
|
pkt: showPacketLayer,
|
|
opt: showOpticalLayer
|
|
};
|
|
|
|
// key bindings
|
|
var keyDispatch = {
|
|
M: testMe, // TODO: remove (testing only)
|
|
S: injectStartupEvents, // TODO: remove (testing only)
|
|
space: injectTestEvent, // TODO: remove (testing only)
|
|
|
|
B: toggleBg,
|
|
L: cycleLabels,
|
|
P: togglePorts,
|
|
U: unpin,
|
|
R: resetZoomPan,
|
|
esc: handleEscape
|
|
};
|
|
|
|
// state variables
|
|
var network = {
|
|
view: null, // view token reference
|
|
nodes: [],
|
|
links: [],
|
|
lookup: {}
|
|
},
|
|
scenario = {
|
|
evDir: 'json/ev/',
|
|
evScenario: '/scenario.json',
|
|
evPrefix: '/ev_',
|
|
evOnos: '_onos.json',
|
|
evUi: '_ui.json',
|
|
ctx: null,
|
|
params: {},
|
|
evNumber: 0,
|
|
view: null,
|
|
debug: false
|
|
},
|
|
webSock,
|
|
sid = 0,
|
|
deviceLabelIndex = 0,
|
|
hostLabelIndex = 0,
|
|
selections = {},
|
|
selectOrder = [],
|
|
hovered = null,
|
|
detailPane,
|
|
antTimer = null,
|
|
onosInstances = {},
|
|
onosOrder = [],
|
|
oiBox,
|
|
oiShowMaster = false,
|
|
|
|
portLabelsOn = false;
|
|
|
|
// D3 selections
|
|
var svg,
|
|
zoomPanContainer,
|
|
bgImg,
|
|
topoG,
|
|
nodeG,
|
|
linkG,
|
|
node,
|
|
link,
|
|
mask;
|
|
|
|
// the projection for the map background
|
|
var geoMapProjection;
|
|
|
|
// the zoom function
|
|
var zoom;
|
|
|
|
// ==============================
|
|
// For Debugging / Development
|
|
|
|
function note(label, msg) {
|
|
console.log('NOTE: ' + label + ': ' + msg);
|
|
}
|
|
|
|
function debug(what) {
|
|
return config.debugOn && config.debug[what];
|
|
}
|
|
|
|
function fnTrace(msg, id) {
|
|
if (config.fnTrace) {
|
|
console.log('FN: ' + msg + ' [' + id + ']');
|
|
}
|
|
}
|
|
|
|
function evTrace(data) {
|
|
fnTrace(data.event, data.payload.id);
|
|
}
|
|
|
|
// ==============================
|
|
// Key Callbacks
|
|
|
|
function testMe(view) {
|
|
//view.alert('test');
|
|
detailPane.show();
|
|
setTimeout(detailPane.hide, 2000);
|
|
oiBox.show();
|
|
setTimeout(oiBox.hide, 2000);
|
|
}
|
|
|
|
function abortIfLive() {
|
|
if (config.useLiveData) {
|
|
network.view.alert("Sorry, currently using live data..");
|
|
return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
function testDebug(msg) {
|
|
if (scenario.debug) {
|
|
scenario.view.alert(msg);
|
|
}
|
|
}
|
|
|
|
function injectTestEvent(view) {
|
|
if (abortIfLive()) { return; }
|
|
var sc = scenario,
|
|
evn = ++sc.evNumber,
|
|
pfx = sc.evDir + sc.ctx + sc.evPrefix + evn,
|
|
onosUrl = pfx + sc.evOnos,
|
|
uiUrl = pfx + sc.evUi,
|
|
stack = [
|
|
{ url: onosUrl, cb: handleServerEvent },
|
|
{ url: uiUrl, cb: handleUiEvent }
|
|
];
|
|
recurseFetchEvent(stack, evn);
|
|
}
|
|
|
|
function recurseFetchEvent(stack, evn) {
|
|
var v = scenario.view,
|
|
frame;
|
|
if (stack.length === 0) {
|
|
v.alert('Oops!\n\nNo event #' + evn + ' found.');
|
|
return;
|
|
}
|
|
frame = stack.shift();
|
|
|
|
d3.json(frame.url, function (err, data) {
|
|
if (err) {
|
|
if (err.status === 404) {
|
|
// if we didn't find the data, try the next stack frame
|
|
recurseFetchEvent(stack, evn);
|
|
} else {
|
|
v.alert('non-404 error:\n\n' + frame.url + '\n\n' + err);
|
|
}
|
|
} else {
|
|
testDebug('loaded: ' + frame.url);
|
|
frame.cb(data);
|
|
}
|
|
});
|
|
|
|
}
|
|
|
|
function handleUiEvent(data) {
|
|
scenario.view.alert('UI Tx: ' + data.event + '\n\n' +
|
|
JSON.stringify(data));
|
|
}
|
|
|
|
function injectStartupEvents(view) {
|
|
var last = scenario.params.lastAuto || 0;
|
|
if (abortIfLive()) { return; }
|
|
|
|
while (scenario.evNumber < last) {
|
|
injectTestEvent(view);
|
|
}
|
|
}
|
|
|
|
function toggleBg() {
|
|
var vis = bgImg.style('visibility');
|
|
bgImg.style('visibility', (vis === 'hidden') ? 'visible' : 'hidden');
|
|
}
|
|
|
|
function cycleLabels() {
|
|
deviceLabelIndex = (deviceLabelIndex === network.deviceLabelCount - 1)
|
|
? 0 : deviceLabelIndex + 1;
|
|
|
|
network.nodes.forEach(function (d) {
|
|
if (d.class === 'device') {
|
|
updateDeviceLabel(d);
|
|
}
|
|
});
|
|
}
|
|
|
|
function togglePorts(view) {
|
|
view.alert('togglePorts() callback')
|
|
}
|
|
|
|
function unpin() {
|
|
if (hovered) {
|
|
hovered.fixed = false;
|
|
hovered.el.classed('fixed', false);
|
|
network.force.resume();
|
|
}
|
|
}
|
|
|
|
function handleEscape(view) {
|
|
if (oiShowMaster) {
|
|
cancelAffinity();
|
|
} else {
|
|
deselectAll();
|
|
}
|
|
}
|
|
|
|
// ==============================
|
|
// Radio Button Callbacks
|
|
|
|
var layerLookup = {
|
|
host: {
|
|
endstation: 'pkt', // default, if host event does not define type
|
|
bgpSpeaker: 'pkt'
|
|
},
|
|
device: {
|
|
switch: 'pkt',
|
|
roadm: 'opt'
|
|
},
|
|
link: {
|
|
hostLink: 'pkt',
|
|
direct: 'pkt',
|
|
optical: 'opt'
|
|
}
|
|
};
|
|
|
|
function inLayer(d, layer) {
|
|
var look = layerLookup[d.class],
|
|
lyr = look && look[d.type];
|
|
return lyr === layer;
|
|
}
|
|
|
|
function unsuppressLayer(which) {
|
|
node.each(function (d) {
|
|
var node = d.el;
|
|
if (inLayer(d, which)) {
|
|
node.classed('suppressed', false);
|
|
}
|
|
});
|
|
|
|
link.each(function (d) {
|
|
var link = d.el;
|
|
if (inLayer(d, which)) {
|
|
link.classed('suppressed', false);
|
|
}
|
|
});
|
|
}
|
|
|
|
function suppressLayers(b) {
|
|
node.classed('suppressed', b);
|
|
link.classed('suppressed', b);
|
|
// d3.selectAll('svg .port').classed('inactive', false);
|
|
// d3.selectAll('svg .portText').classed('inactive', false);
|
|
}
|
|
|
|
function showAllLayers() {
|
|
suppressLayers(false);
|
|
}
|
|
|
|
function showPacketLayer() {
|
|
node.classed('suppressed', true);
|
|
link.classed('suppressed', true);
|
|
unsuppressLayer('pkt');
|
|
}
|
|
|
|
function showOpticalLayer() {
|
|
node.classed('suppressed', true);
|
|
link.classed('suppressed', true);
|
|
unsuppressLayer('opt');
|
|
}
|
|
|
|
function restoreLayerState() {
|
|
layerBtnDispatch[layerBtnSet.selected()]();
|
|
}
|
|
|
|
// ==============================
|
|
// Private functions
|
|
|
|
function safeId(s) {
|
|
return s.replace(/[^a-z0-9]/gi, '-');
|
|
}
|
|
|
|
// set the size of the given element to that of the view (reduced if padded)
|
|
function setSize(el, view, pad) {
|
|
var padding = pad ? pad * 2 : 0;
|
|
el.attr({
|
|
width: view.width() - padding,
|
|
height: view.height() - padding
|
|
});
|
|
}
|
|
|
|
|
|
// ==============================
|
|
// Event handlers for server-pushed events
|
|
|
|
function logicError(msg) {
|
|
// TODO, report logic error to server, via websock, so it can be logged
|
|
network.view.alert('Logic Error:\n\n' + msg);
|
|
console.warn(msg);
|
|
}
|
|
|
|
var eventDispatch = {
|
|
addInstance: addInstance,
|
|
addDevice: addDevice,
|
|
addLink: addLink,
|
|
addHost: addHost,
|
|
|
|
updateInstance: stillToImplement,
|
|
updateDevice: updateDevice,
|
|
updateLink: updateLink,
|
|
updateHost: updateHost,
|
|
|
|
removeInstance: stillToImplement,
|
|
removeDevice: stillToImplement,
|
|
removeLink: removeLink,
|
|
removeHost: removeHost,
|
|
|
|
showDetails: showDetails,
|
|
showPath: showPath,
|
|
showTraffic: showTraffic
|
|
};
|
|
|
|
function addInstance(data) {
|
|
evTrace(data);
|
|
var inst = data.payload,
|
|
id = inst.id;
|
|
if (onosInstances[id]) {
|
|
logicError('ONOS instance already added: ' + id);
|
|
return;
|
|
}
|
|
onosInstances[id] = inst;
|
|
onosOrder.push(inst);
|
|
updateInstances();
|
|
}
|
|
|
|
function addDevice(data) {
|
|
evTrace(data);
|
|
var device = data.payload,
|
|
nodeData = createDeviceNode(device);
|
|
network.nodes.push(nodeData);
|
|
network.lookup[nodeData.id] = nodeData;
|
|
updateNodes();
|
|
network.force.start();
|
|
}
|
|
|
|
function addLink(data) {
|
|
evTrace(data);
|
|
var link = data.payload,
|
|
lnk = createLink(link);
|
|
if (lnk) {
|
|
network.links.push(lnk);
|
|
network.lookup[lnk.id] = lnk;
|
|
updateLinks();
|
|
network.force.start();
|
|
}
|
|
}
|
|
|
|
function addHost(data) {
|
|
evTrace(data);
|
|
var host = data.payload,
|
|
node = createHostNode(host),
|
|
lnk;
|
|
network.nodes.push(node);
|
|
network.lookup[host.id] = node;
|
|
updateNodes();
|
|
|
|
lnk = createHostLink(host);
|
|
if (lnk) {
|
|
node.linkData = lnk; // cache ref on its host
|
|
network.links.push(lnk);
|
|
network.lookup[host.ingress] = lnk;
|
|
network.lookup[host.egress] = lnk;
|
|
updateLinks();
|
|
}
|
|
network.force.start();
|
|
}
|
|
|
|
// TODO: fold updateX(...) methods into one base method; remove duplication
|
|
function updateDevice(data) {
|
|
evTrace(data);
|
|
var device = data.payload,
|
|
id = device.id,
|
|
nodeData = network.lookup[id];
|
|
if (nodeData) {
|
|
$.extend(nodeData, device);
|
|
updateDeviceState(nodeData);
|
|
} else {
|
|
logicError('updateDevice lookup fail. ID = "' + id + '"');
|
|
}
|
|
}
|
|
|
|
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 + '"');
|
|
}
|
|
}
|
|
|
|
function updateHost(data) {
|
|
evTrace(data);
|
|
var host = data.payload,
|
|
id = host.id,
|
|
hostData = network.lookup[id];
|
|
if (hostData) {
|
|
$.extend(hostData, host);
|
|
updateHostState(hostData);
|
|
} else {
|
|
logicError('updateHost lookup fail. ID = "' + id + '"');
|
|
}
|
|
}
|
|
|
|
// TODO: fold removeX(...) methods into base method - remove dup code
|
|
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 + '"');
|
|
}
|
|
}
|
|
|
|
function removeHost(data) {
|
|
evTrace(data);
|
|
var host = data.payload,
|
|
id = host.id,
|
|
hostData = network.lookup[id];
|
|
if (hostData) {
|
|
removeHostElement(hostData);
|
|
} else {
|
|
logicError('removeHost lookup fail. ID = "' + id + '"');
|
|
}
|
|
}
|
|
|
|
function showDetails(data) {
|
|
evTrace(data);
|
|
populateDetails(data.payload);
|
|
detailPane.show();
|
|
}
|
|
|
|
function showPath(data) {
|
|
// TODO: review - making sure we are handling the payload correctly.
|
|
evTrace(data);
|
|
var links = data.payload.links,
|
|
s = [ data.event + "\n" + links.length ];
|
|
links.forEach(function (d, i) {
|
|
s.push(d);
|
|
});
|
|
network.view.alert(s.join('\n'));
|
|
|
|
links.forEach(function (d, i) {
|
|
var link = network.lookup[d];
|
|
if (link) {
|
|
link.el.classed('showPath', true);
|
|
}
|
|
});
|
|
}
|
|
|
|
function showTraffic(data) {
|
|
evTrace(data);
|
|
var paths = data.payload.paths;
|
|
|
|
// Revert any links hilighted previously.
|
|
link.classed('primary secondary animated optical', false);
|
|
|
|
// Now hilight all links in the paths payload.
|
|
paths.forEach(function (p) {
|
|
var cls = p.class;
|
|
p.links.forEach(function (id) {
|
|
var lnk = network.lookup[id];
|
|
if (lnk) {
|
|
lnk.el.classed(cls, true);
|
|
}
|
|
});
|
|
});
|
|
}
|
|
|
|
// ...............................
|
|
|
|
function stillToImplement(data) {
|
|
var p = data.payload;
|
|
note(data.event, p.id);
|
|
network.view.alert('Not yet implemented: "' + data.event + '"');
|
|
}
|
|
|
|
function unknownEvent(data) {
|
|
network.view.alert('Unknown event type: "' + data.event + '"');
|
|
}
|
|
|
|
function handleServerEvent(data) {
|
|
var fn = eventDispatch[data.event] || unknownEvent;
|
|
fn(data);
|
|
}
|
|
|
|
// ==============================
|
|
// Out-going messages...
|
|
|
|
function userFeedback(msg) {
|
|
// for now, use the alert pane as is. Maybe different alert style in
|
|
// the future (centered on view; dismiss button?)
|
|
network.view.alert(msg);
|
|
}
|
|
|
|
function nSel() {
|
|
return selectOrder.length;
|
|
}
|
|
function getSel(idx) {
|
|
return selections[selectOrder[idx]];
|
|
}
|
|
function getSelId(idx) {
|
|
return getSel(idx).obj.id;
|
|
}
|
|
function allSelectionsClass(cls) {
|
|
for (var i=0, n=nSel(); i<n; i++) {
|
|
if (getSel(i).obj.class !== cls) {
|
|
return false;
|
|
}
|
|
}
|
|
return true;
|
|
}
|
|
|
|
// request details for the selected element
|
|
// invoked from selection of a single node.
|
|
function requestDetails() {
|
|
var data = getSel(0).obj,
|
|
payload = {
|
|
id: data.id,
|
|
class: data.class
|
|
};
|
|
sendMessage('requestDetails', payload);
|
|
}
|
|
|
|
function addIntentAction() {
|
|
sendMessage('addHostIntent', {
|
|
one: getSelId(0),
|
|
two: getSelId(1)
|
|
});
|
|
}
|
|
|
|
function showTrafficAction() {
|
|
// if nothing is hovered over, and nothing selected, send cancel request
|
|
if (!hovered && nSel() === 0) {
|
|
sendMessage('cancelTraffic', {});
|
|
return;
|
|
}
|
|
|
|
// NOTE: hover is only populated if "show traffic on hover" is
|
|
// toggled on, and the item hovered is a host...
|
|
var hoverId = (trafficHover() && hovered && hovered.class === 'host')
|
|
? hovered.id : '';
|
|
sendMessage('requestTraffic', {
|
|
ids: selectOrder,
|
|
hover: hoverId
|
|
});
|
|
}
|
|
|
|
|
|
// ==============================
|
|
// onos instance panel functions
|
|
|
|
function updateInstances() {
|
|
var onoses = oiBox.el.selectAll('.onosInst')
|
|
.data(onosOrder, function (d) { return d.id; });
|
|
|
|
// operate on existing onoses if necessary
|
|
|
|
var entering = onoses.enter()
|
|
.append('div')
|
|
.attr('class', 'onosInst')
|
|
.classed('online', function (d) { return d.online; })
|
|
.on('click', clickInst)
|
|
.text(function (d) { return d.id; });
|
|
|
|
// operate on existing + new onoses here
|
|
|
|
// the departed...
|
|
var exiting = onoses.exit()
|
|
.transition()
|
|
.style('opacity', 0)
|
|
.remove();
|
|
}
|
|
|
|
function clickInst(d) {
|
|
var el = d3.select(this),
|
|
aff = el.classed('affinity');
|
|
if (!aff) {
|
|
setAffinity(el, d);
|
|
} else {
|
|
cancelAffinity();
|
|
}
|
|
}
|
|
|
|
function setAffinity(el, d) {
|
|
d3.selectAll('.onosInst')
|
|
.classed('mastership', true)
|
|
.classed('affinity', false);
|
|
el.classed('affinity', true);
|
|
|
|
suppressLayers(true);
|
|
node.each(function (n) {
|
|
if (n.master === d.id) {
|
|
n.el.classed('suppressed', false);
|
|
}
|
|
});
|
|
oiShowMaster = true;
|
|
}
|
|
|
|
function cancelAffinity() {
|
|
d3.selectAll('.onosInst')
|
|
.classed('mastership affinity', false);
|
|
restoreLayerState();
|
|
oiShowMaster = false;
|
|
}
|
|
|
|
// ==============================
|
|
// force layout modification functions
|
|
|
|
function translate(x, y) {
|
|
return 'translate(' + x + ',' + y + ')';
|
|
}
|
|
|
|
function missMsg(what, id) {
|
|
return '\n[' + what + '] "' + id + '" missing ';
|
|
}
|
|
|
|
function linkEndPoints(srcId, dstId) {
|
|
var srcNode = network.lookup[srcId],
|
|
dstNode = network.lookup[dstId],
|
|
sMiss = !srcNode ? missMsg('src', srcId) : '',
|
|
dMiss = !dstNode ? missMsg('dst', dstId) : '';
|
|
|
|
if (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 createHostLink(host) {
|
|
var src = host.id,
|
|
dst = host.cp.device,
|
|
id = host.ingress,
|
|
lnk = linkEndPoints(src, dst);
|
|
|
|
if (!lnk) {
|
|
return null;
|
|
}
|
|
|
|
// Synthesize link ...
|
|
$.extend(lnk, {
|
|
id: id,
|
|
class: 'link',
|
|
type: 'hostLink',
|
|
svgClass: 'link hostLink',
|
|
linkWidth: 1
|
|
});
|
|
return lnk;
|
|
}
|
|
|
|
function createLink(link) {
|
|
var lnk = linkEndPoints(link.src, link.dst),
|
|
type = link.type;
|
|
|
|
if (!lnk) {
|
|
return null;
|
|
}
|
|
|
|
// merge in remaining data
|
|
$.extend(lnk, link, {
|
|
class: 'link',
|
|
svgClass: type ? 'link ' + type : 'link'
|
|
});
|
|
return lnk;
|
|
}
|
|
|
|
var widthRatio = 1.4,
|
|
linkScale = d3.scale.linear()
|
|
.domain([1, 12])
|
|
.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; });
|
|
|
|
// operate on existing links, if necessary
|
|
// link .foo() .bar() ...
|
|
|
|
// operate on entering links:
|
|
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
|
|
})
|
|
.transition().duration(1000)
|
|
.attr({
|
|
'stroke-width': function (d) { return linkScale(d.linkWidth); },
|
|
stroke: '#666' // TODO: remove explicit stroke, rather...
|
|
});
|
|
|
|
// augment links
|
|
entering.each(function (d) {
|
|
var link = d3.select(this);
|
|
// provide ref to element selection from backing data....
|
|
d.el = link;
|
|
|
|
// TODO: add src/dst port labels etc.
|
|
});
|
|
|
|
// operate on both existing and new links, if necessary
|
|
//link .foo() .bar() ...
|
|
|
|
// operate on exiting links:
|
|
link.exit()
|
|
.attr({
|
|
'stroke-dasharray': '3, 3'
|
|
})
|
|
.style('opacity', 0.4)
|
|
.transition()
|
|
.duration(1500)
|
|
.attr({
|
|
'stroke-dasharray': '3, 12'
|
|
})
|
|
.transition()
|
|
.duration(500)
|
|
.style('opacity', 0.0)
|
|
.remove();
|
|
}
|
|
|
|
function createDeviceNode(device) {
|
|
// start with the object as is
|
|
var node = device,
|
|
type = device.type,
|
|
svgCls = type ? 'node device ' + type : 'node device';
|
|
|
|
// Augment as needed...
|
|
node.class = 'device';
|
|
node.svgClass = device.online ? svgCls + ' online' : svgCls;
|
|
positionNode(node);
|
|
|
|
// cache label array length
|
|
network.deviceLabelCount = device.labels.length;
|
|
return node;
|
|
}
|
|
|
|
function createHostNode(host) {
|
|
// start with the object as is
|
|
var node = host;
|
|
|
|
// Augment as needed...
|
|
node.class = 'host';
|
|
if (!node.type) {
|
|
node.type = 'endstation';
|
|
}
|
|
node.svgClass = 'node host';
|
|
positionNode(node);
|
|
|
|
// cache label array length
|
|
network.hostLabelCount = host.labels.length;
|
|
return node;
|
|
}
|
|
|
|
function positionNode(node) {
|
|
var meta = node.metaUi,
|
|
x = meta && meta.x,
|
|
y = meta && meta.y,
|
|
xy;
|
|
|
|
// If we have [x,y] already, use that...
|
|
if (x && y) {
|
|
node.fixed = true;
|
|
node.x = x;
|
|
node.y = y;
|
|
return;
|
|
}
|
|
|
|
var location = node.location;
|
|
if (location && location.type === 'latlng') {
|
|
var coord = geoMapProjection([location.lng, location.lat]);
|
|
node.fixed = true;
|
|
node.x = coord[0];
|
|
node.y = coord[1];
|
|
return;
|
|
}
|
|
|
|
// Note: Placing incoming unpinned nodes at exactly the same point
|
|
// (center of the view) causes them to explode outwards when
|
|
// the force layout kicks in. So, we spread them out a bit
|
|
// initially, to provide a more serene layout convergence.
|
|
// Additionally, if the node is a host, we place it near
|
|
// the device it is connected to.
|
|
|
|
function spread(s) {
|
|
return Math.floor((Math.random() * s) - s/2);
|
|
}
|
|
|
|
function randDim(dim) {
|
|
return dim / 2 + spread(dim * 0.7071);
|
|
}
|
|
|
|
function rand() {
|
|
return {
|
|
x: randDim(network.view.width()),
|
|
y: randDim(network.view.height())
|
|
};
|
|
}
|
|
|
|
function near(node) {
|
|
var min = 12,
|
|
dx = spread(12),
|
|
dy = spread(12);
|
|
return {
|
|
x: node.x + min + dx,
|
|
y: node.y + min + dy
|
|
};
|
|
}
|
|
|
|
function getDevice(cp) {
|
|
var d = network.lookup[cp.device];
|
|
return d || rand();
|
|
}
|
|
|
|
xy = (node.class === 'host') ? near(getDevice(node.cp)) : rand();
|
|
$.extend(node, xy);
|
|
}
|
|
|
|
function iconUrl(d) {
|
|
return 'img/' + d.type + '.png';
|
|
}
|
|
|
|
// returns the newly computed bounding box of the rectangle
|
|
function adjustRectToFitText(n) {
|
|
var text = n.select('text'),
|
|
box = text.node().getBBox(),
|
|
lab = config.labels;
|
|
|
|
text.attr('text-anchor', 'middle')
|
|
.attr('y', '-0.8em')
|
|
.attr('x', lab.imgPad/2);
|
|
|
|
// 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 -= (lab.padLR + lab.imgPad/2);
|
|
box.width += lab.padLR * 2 + lab.imgPad;
|
|
box.y -= lab.padTB;
|
|
box.height += lab.padTB * 2;
|
|
|
|
return box;
|
|
}
|
|
|
|
function mkSvgClass(d) {
|
|
return d.fixed ? d.svgClass + ' fixed' : d.svgClass;
|
|
}
|
|
|
|
function hostLabel(d) {
|
|
var idx = (hostLabelIndex < d.labels.length) ? hostLabelIndex : 0;
|
|
return d.labels[idx];
|
|
}
|
|
function deviceLabel(d) {
|
|
var idx = (deviceLabelIndex < d.labels.length) ? deviceLabelIndex : 0;
|
|
return d.labels[idx];
|
|
}
|
|
function niceLabel(label) {
|
|
return (label && label.trim()) ? label : '.';
|
|
}
|
|
|
|
function updateDeviceLabel(d) {
|
|
var label = niceLabel(deviceLabel(d)),
|
|
node = d.el,
|
|
box;
|
|
|
|
node.select('text')
|
|
.text(label)
|
|
.style('opacity', 0)
|
|
.transition()
|
|
.style('opacity', 1);
|
|
|
|
box = adjustRectToFitText(node);
|
|
|
|
node.select('rect')
|
|
.transition()
|
|
.attr(box);
|
|
|
|
node.select('image')
|
|
.transition()
|
|
.attr('x', box.x + config.icons.xoff)
|
|
.attr('y', box.y + config.icons.yoff);
|
|
}
|
|
|
|
function updateHostLabel(d) {
|
|
var label = hostLabel(d),
|
|
host = d.el;
|
|
|
|
host.select('text').text(label);
|
|
}
|
|
|
|
function updateDeviceState(nodeData) {
|
|
nodeData.el.classed('online', nodeData.online);
|
|
updateDeviceLabel(nodeData);
|
|
// TODO: review what else might need to be updated
|
|
}
|
|
|
|
function updateLinkState(linkData) {
|
|
updateLinkWidth(linkData);
|
|
// TODO: review what else might need to be updated
|
|
// update label, if showing
|
|
}
|
|
|
|
function updateHostState(hostData) {
|
|
updateHostLabel(hostData);
|
|
// TODO: review what else might need to be updated
|
|
}
|
|
|
|
function nodeMouseOver(d) {
|
|
hovered = d;
|
|
if (trafficHover() && d.class === 'host') {
|
|
showTrafficAction();
|
|
}
|
|
}
|
|
|
|
function nodeMouseOut(d) {
|
|
hovered = null;
|
|
if (trafficHover() && d.class === 'host') {
|
|
showTrafficAction();
|
|
}
|
|
}
|
|
|
|
function updateNodes() {
|
|
node = nodeG.selectAll('.node')
|
|
.data(network.nodes, function (d) { return d.id; });
|
|
|
|
// operate on existing nodes, if necessary
|
|
// update host labels
|
|
//node .foo() .bar() ...
|
|
|
|
// operate on entering nodes:
|
|
var entering = node.enter()
|
|
.append('g')
|
|
.attr({
|
|
id: function (d) { return safeId(d.id); },
|
|
class: mkSvgClass,
|
|
transform: function (d) { return translate(d.x, d.y); },
|
|
opacity: 0
|
|
})
|
|
.call(network.drag)
|
|
.on('mouseover', nodeMouseOver)
|
|
.on('mouseout', nodeMouseOut)
|
|
.transition()
|
|
.attr('opacity', 1);
|
|
|
|
// augment device nodes...
|
|
entering.filter('.device').each(function (d) {
|
|
var node = d3.select(this),
|
|
icon = iconUrl(d),
|
|
label = niceLabel(deviceLabel(d)),
|
|
box;
|
|
|
|
// provide ref to element from backing data....
|
|
d.el = node;
|
|
|
|
node.append('rect')
|
|
.attr({
|
|
'rx': 5,
|
|
'ry': 5
|
|
});
|
|
|
|
node.append('text')
|
|
.text(label)
|
|
.attr('dy', '1.1em');
|
|
|
|
box = adjustRectToFitText(node);
|
|
|
|
node.select('rect')
|
|
.attr(box);
|
|
|
|
if (icon) {
|
|
var cfg = config.icons;
|
|
node.append('svg:image')
|
|
.attr({
|
|
x: box.x + config.icons.xoff,
|
|
y: box.y + config.icons.yoff,
|
|
width: cfg.w,
|
|
height: cfg.h,
|
|
'xlink:href': icon
|
|
});
|
|
}
|
|
|
|
// debug function to show the modelled x,y coordinates of nodes...
|
|
if (debug('showNodeXY')) {
|
|
node.select('rect').attr('fill-opacity', 0.5);
|
|
node.append('circle')
|
|
.attr({
|
|
class: 'debug',
|
|
cx: 0,
|
|
cy: 0,
|
|
r: '3px'
|
|
});
|
|
}
|
|
});
|
|
|
|
// augment host nodes...
|
|
entering.filter('.host').each(function (d) {
|
|
var node = d3.select(this),
|
|
box;
|
|
|
|
// provide ref to element from backing data....
|
|
d.el = node;
|
|
|
|
node.append('circle')
|
|
.attr('r', 8); // TODO: define host circle radius
|
|
|
|
node.append('text')
|
|
.text(hostLabel)
|
|
.attr('dy', '1.3em')
|
|
.attr('text-anchor', 'middle');
|
|
|
|
// debug function to show the modelled x,y coordinates of nodes...
|
|
if (debug('showNodeXY')) {
|
|
node.select('circle').attr('fill-opacity', 0.5);
|
|
node.append('circle')
|
|
.attr({
|
|
class: 'debug',
|
|
cx: 0,
|
|
cy: 0,
|
|
r: '3px'
|
|
});
|
|
}
|
|
});
|
|
|
|
// operate on both existing and new nodes, if necessary
|
|
//node .foo() .bar() ...
|
|
|
|
// operate on exiting nodes:
|
|
// Note that the node is removed after 2 seconds.
|
|
// Sub element animations should be shorter than 2 seconds.
|
|
var exiting = node.exit()
|
|
.transition()
|
|
.duration(2000)
|
|
.style('opacity', 0)
|
|
.remove();
|
|
|
|
// host node exits....
|
|
exiting.filter('.host').each(function (d) {
|
|
var node = d3.select(this);
|
|
|
|
node.select('text')
|
|
.style('opacity', 0.5)
|
|
.transition()
|
|
.duration(1000)
|
|
.style('opacity', 0);
|
|
// note, leave <g>.remove to remove this element
|
|
|
|
node.select('circle')
|
|
.style('stroke-fill', '#555')
|
|
.style('fill', '#888')
|
|
.style('opacity', 0.5)
|
|
.transition()
|
|
.duration(1500)
|
|
.attr('r', 0);
|
|
// note, leave <g>.remove to remove this element
|
|
|
|
});
|
|
|
|
// TODO: device node exits
|
|
}
|
|
|
|
function find(id, array) {
|
|
for (var idx = 0, n = array.length; idx < n; idx++) {
|
|
if (array[idx].id === id) {
|
|
return idx;
|
|
}
|
|
}
|
|
return -1;
|
|
}
|
|
|
|
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();
|
|
}
|
|
|
|
function removeHostElement(hostData) {
|
|
// first, remove associated hostLink...
|
|
removeLinkElement(hostData.linkData);
|
|
|
|
// remove from lookup cache
|
|
delete network.lookup[hostData.id];
|
|
// remove from nodes array
|
|
var idx = find(hostData.id, network.nodes);
|
|
network.nodes.splice(idx, 1);
|
|
// remove from SVG
|
|
updateNodes();
|
|
network.force.resume();
|
|
}
|
|
|
|
|
|
function tick() {
|
|
node.attr({
|
|
transform: function (d) { return translate(d.x, d.y); }
|
|
});
|
|
|
|
link.attr({
|
|
x1: function (d) { return d.source.x; },
|
|
y1: function (d) { return d.source.y; },
|
|
x2: function (d) { return d.target.x; },
|
|
y2: function (d) { return d.target.y; }
|
|
});
|
|
}
|
|
|
|
// ==============================
|
|
// Web-Socket for live data
|
|
|
|
function webSockUrl() {
|
|
return document.location.toString()
|
|
.replace(/\#.*/, '')
|
|
.replace('http://', 'ws://')
|
|
.replace('https://', 'wss://')
|
|
.replace('index2.html', config.webSockUrl);
|
|
}
|
|
|
|
webSock = {
|
|
ws : null,
|
|
|
|
connect : function() {
|
|
webSock.ws = new WebSocket(webSockUrl());
|
|
|
|
webSock.ws.onopen = function() {
|
|
noWebSock(false);
|
|
};
|
|
|
|
webSock.ws.onmessage = function(m) {
|
|
if (m.data) {
|
|
wsTraceRx(m.data);
|
|
handleServerEvent(JSON.parse(m.data));
|
|
}
|
|
};
|
|
|
|
webSock.ws.onclose = function(m) {
|
|
webSock.ws = null;
|
|
noWebSock(true);
|
|
};
|
|
},
|
|
|
|
send : function(text) {
|
|
if (text != null) {
|
|
webSock._send(text);
|
|
}
|
|
},
|
|
|
|
_send : function(message) {
|
|
if (webSock.ws) {
|
|
webSock.ws.send(message);
|
|
} else if (config.useLiveData) {
|
|
network.view.alert('no web socket open\n\n' + message);
|
|
} else {
|
|
console.log('WS Send: ' + JSON.stringify(message));
|
|
}
|
|
}
|
|
|
|
};
|
|
|
|
function noWebSock(b) {
|
|
mask.style('display',b ? 'block' : 'none');
|
|
}
|
|
|
|
// TODO: use cache of pending messages (key = sid) to reconcile responses
|
|
|
|
function sendMessage(evType, payload) {
|
|
var toSend = {
|
|
event: evType,
|
|
sid: ++sid,
|
|
payload: payload
|
|
},
|
|
asText = JSON.stringify(toSend);
|
|
wsTraceTx(asText);
|
|
webSock.send(asText);
|
|
}
|
|
|
|
function wsTraceTx(msg) {
|
|
wsTrace('tx', msg);
|
|
}
|
|
function wsTraceRx(msg) {
|
|
wsTrace('rx', msg);
|
|
}
|
|
function wsTrace(rxtx, msg) {
|
|
console.log('[' + rxtx + '] ' + msg);
|
|
// TODO: integrate with trace view
|
|
//if (trace) {
|
|
// trace.output(rxtx, msg);
|
|
//}
|
|
}
|
|
|
|
|
|
// ==============================
|
|
// Selection stuff
|
|
|
|
function selectObject(obj, el) {
|
|
var n,
|
|
srcEv = d3.event.sourceEvent,
|
|
meta = srcEv.metaKey,
|
|
shift = srcEv.shiftKey;
|
|
|
|
if ((panZoom() && !meta) || (!panZoom() && meta)) {
|
|
return;
|
|
}
|
|
|
|
if (el) {
|
|
n = d3.select(el);
|
|
} else {
|
|
node.each(function(d) {
|
|
if (d == obj) {
|
|
n = d3.select(el = this);
|
|
}
|
|
});
|
|
}
|
|
if (!n) return;
|
|
|
|
if (shift && n.classed('selected')) {
|
|
deselectObject(obj.id);
|
|
updateDetailPane();
|
|
return;
|
|
}
|
|
|
|
if (!shift) {
|
|
deselectAll();
|
|
}
|
|
|
|
selections[obj.id] = { obj: obj, el: el };
|
|
selectOrder.push(obj.id);
|
|
|
|
n.classed('selected', true);
|
|
updateDetailPane();
|
|
}
|
|
|
|
function deselectObject(id) {
|
|
var obj = selections[id],
|
|
idx;
|
|
if (obj) {
|
|
d3.select(obj.el).classed('selected', false);
|
|
delete selections[id];
|
|
idx = $.inArray(id, selectOrder);
|
|
if (idx >= 0) {
|
|
selectOrder.splice(idx, 1);
|
|
}
|
|
}
|
|
}
|
|
|
|
function deselectAll() {
|
|
// deselect all nodes in the network...
|
|
node.classed('selected', false);
|
|
selections = {};
|
|
selectOrder = [];
|
|
updateDetailPane();
|
|
}
|
|
|
|
// update the state of the detail pane, based on current selections
|
|
function updateDetailPane() {
|
|
var nSel = selectOrder.length;
|
|
if (!nSel) {
|
|
detailPane.hide();
|
|
showTrafficAction(); // sends cancelTraffic event
|
|
} else if (nSel === 1) {
|
|
singleSelect();
|
|
} else {
|
|
multiSelect();
|
|
}
|
|
}
|
|
|
|
function singleSelect() {
|
|
requestDetails();
|
|
// NOTE: detail pane will be shown from showDetails event callback
|
|
}
|
|
|
|
function multiSelect() {
|
|
populateMultiSelect();
|
|
}
|
|
|
|
function addSep(tbody) {
|
|
var tr = tbody.append('tr');
|
|
$('<hr>').appendTo(tr.append('td').attr('colspan', 2));
|
|
}
|
|
|
|
function addProp(tbody, label, value) {
|
|
var tr = tbody.append('tr');
|
|
|
|
tr.append('td')
|
|
.attr('class', 'label')
|
|
.text(label + ' :');
|
|
|
|
tr.append('td')
|
|
.attr('class', 'value')
|
|
.text(value);
|
|
}
|
|
|
|
function populateMultiSelect() {
|
|
detailPane.empty();
|
|
|
|
var title = detailPane.append("h2"),
|
|
table = detailPane.append("table"),
|
|
tbody = table.append("tbody");
|
|
|
|
title.text('Multi-Select...');
|
|
|
|
selectOrder.forEach(function (d, i) {
|
|
addProp(tbody, i+1, d);
|
|
});
|
|
|
|
addMultiSelectActions();
|
|
}
|
|
|
|
function populateDetails(data) {
|
|
detailPane.empty();
|
|
|
|
var title = detailPane.append("h2"),
|
|
table = detailPane.append("table"),
|
|
tbody = table.append("tbody");
|
|
|
|
$('<img src="img/' + data.type + '.png">').appendTo(title);
|
|
$('<span>').attr('class', 'icon').text(data.id).appendTo(title);
|
|
|
|
data.propOrder.forEach(function(p) {
|
|
if (p === '-') {
|
|
addSep(tbody);
|
|
} else {
|
|
addProp(tbody, p, data.props[p]);
|
|
}
|
|
});
|
|
|
|
addSingleSelectActions();
|
|
}
|
|
|
|
function addSingleSelectActions() {
|
|
detailPane.append('hr');
|
|
// always want to allow 'show traffic'
|
|
addAction(detailPane, 'Show Traffic', showTrafficAction);
|
|
}
|
|
|
|
function addMultiSelectActions() {
|
|
detailPane.append('hr');
|
|
// always want to allow 'show traffic'
|
|
addAction(detailPane, 'Show Traffic', showTrafficAction);
|
|
// if exactly two hosts are selected, also want 'add host intent'
|
|
if (nSel() === 2 && allSelectionsClass('host')) {
|
|
addAction(detailPane, 'Add Host Intent', addIntentAction);
|
|
}
|
|
}
|
|
|
|
function addAction(panel, text, cb) {
|
|
panel.append('div')
|
|
.classed('actionBtn', true)
|
|
.text(text)
|
|
.on('click', cb);
|
|
}
|
|
|
|
|
|
function zoomPan(scale, translate) {
|
|
zoomPanContainer.attr("transform", "translate(" + translate + ")scale(" + scale + ")");
|
|
// keep the map lines constant width while zooming
|
|
bgImg.style("stroke-width", 2.0 / scale + "px");
|
|
}
|
|
|
|
function resetZoomPan() {
|
|
zoomPan(1, [0,0]);
|
|
zoom.scale(1).translate([0,0]);
|
|
}
|
|
|
|
function setupZoomPan() {
|
|
function zoomed() {
|
|
if (!panZoom() ^ !d3.event.sourceEvent.metaKey) {
|
|
zoomPan(d3.event.scale, d3.event.translate);
|
|
}
|
|
}
|
|
|
|
zoom = d3.behavior.zoom()
|
|
.translate([0, 0])
|
|
.scale(1)
|
|
.scaleExtent([1, 8])
|
|
.on("zoom", zoomed);
|
|
|
|
svg.call(zoom);
|
|
}
|
|
|
|
// ==============================
|
|
// Test harness code
|
|
|
|
function prepareScenario(view, ctx, dbg) {
|
|
var sc = scenario,
|
|
urlSc = sc.evDir + ctx + sc.evScenario;
|
|
|
|
if (!ctx) {
|
|
view.alert("No scenario specified (null ctx)");
|
|
return;
|
|
}
|
|
|
|
sc.view = view;
|
|
sc.ctx = ctx;
|
|
sc.debug = dbg;
|
|
sc.evNumber = 0;
|
|
|
|
d3.json(urlSc, function(err, data) {
|
|
var p = data && data.params || {},
|
|
desc = data && data.description || null,
|
|
intro = data && data.title;
|
|
|
|
if (err) {
|
|
view.alert('No scenario found:\n\n' + urlSc + '\n\n' + err);
|
|
} else {
|
|
sc.params = p;
|
|
if (desc) {
|
|
intro += '\n\n ' + desc.join('\n ');
|
|
}
|
|
view.alert(intro);
|
|
}
|
|
});
|
|
|
|
}
|
|
|
|
// ==============================
|
|
// Toggle Buttons in masthead
|
|
|
|
// TODO: toggle button (and other widgets in the masthead) should be provided
|
|
// by the framework; not generated by the view.
|
|
|
|
var showInstances,
|
|
doPanZoom,
|
|
showTrafficOnHover;
|
|
|
|
function addButtonBar(view) {
|
|
var bb = d3.select('#mast')
|
|
.append('span').classed('right', true).attr('id', 'bb');
|
|
|
|
function mkTogBtn(text, cb) {
|
|
return bb.append('span')
|
|
.classed('btn', true)
|
|
.text(text)
|
|
.on('click', cb);
|
|
}
|
|
|
|
showInstances = mkTogBtn('Show Instances', toggleInst);
|
|
doPanZoom = mkTogBtn('Pan/Zoom', togglePanZoom);
|
|
showTrafficOnHover = mkTogBtn('Show traffic on hover', toggleTrafficHover);
|
|
}
|
|
|
|
function instShown() {
|
|
return showInstances.classed('active');
|
|
}
|
|
function toggleInst() {
|
|
showInstances.classed('active', !instShown());
|
|
if (instShown()) {
|
|
oiBox.show();
|
|
} else {
|
|
oiBox.hide();
|
|
}
|
|
}
|
|
|
|
function panZoom() {
|
|
return doPanZoom.classed('active');
|
|
}
|
|
function togglePanZoom() {
|
|
doPanZoom.classed('active', !panZoom());
|
|
}
|
|
|
|
function trafficHover() {
|
|
return showTrafficOnHover.classed('active');
|
|
}
|
|
function toggleTrafficHover() {
|
|
showTrafficOnHover.classed('active', !trafficHover());
|
|
}
|
|
|
|
|
|
// ==============================
|
|
// View life-cycle callbacks
|
|
|
|
function preload(view, ctx, flags) {
|
|
var w = view.width(),
|
|
h = view.height(),
|
|
fcfg = config.force,
|
|
fpad = fcfg.pad,
|
|
forceDim = [w - 2*fpad, h - 2*fpad];
|
|
|
|
// NOTE: view.$div is a D3 selection of the view's div
|
|
var viewBox = '0 0 ' + config.logicalSize + ' ' + config.logicalSize;
|
|
svg = view.$div.append('svg').attr('viewBox', viewBox);
|
|
setSize(svg, view);
|
|
|
|
zoomPanContainer = svg.append('g').attr('id', 'zoomPanContainer');
|
|
setupZoomPan();
|
|
|
|
// add blue glow filter to svg layer
|
|
d3u.appendGlow(zoomPanContainer);
|
|
|
|
// group for the topology
|
|
topoG = zoomPanContainer.append('g')
|
|
.attr('id', 'topo-G')
|
|
.attr('transform', fcfg.translate());
|
|
|
|
// subgroups for links and nodes
|
|
linkG = topoG.append('g').attr('id', 'links');
|
|
nodeG = topoG.append('g').attr('id', 'nodes');
|
|
|
|
// selection of nodes and links
|
|
link = linkG.selectAll('.link');
|
|
node = nodeG.selectAll('.node');
|
|
|
|
function chrg(d) {
|
|
return fcfg.charge[d.class] || -12000;
|
|
}
|
|
function ldist(d) {
|
|
return fcfg.linkDistance[d.type] || 50;
|
|
}
|
|
function lstrg(d) {
|
|
// 0.0 - 1.0
|
|
return fcfg.linkStrength[d.type] || 1.0;
|
|
}
|
|
|
|
function selectCb(d, self) {
|
|
selectObject(d, self);
|
|
}
|
|
|
|
function atDragEnd(d, self) {
|
|
// once we've finished moving, pin the node in position
|
|
d.fixed = true;
|
|
d3.select(self).classed('fixed', true);
|
|
if (config.useLiveData) {
|
|
sendUpdateMeta(d);
|
|
} else {
|
|
console.log('Moving node ' + d.id + ' to [' + d.x + ',' + d.y + ']');
|
|
}
|
|
}
|
|
|
|
function sendUpdateMeta(d) {
|
|
sendMessage('updateMeta', {
|
|
id: d.id,
|
|
'class': d.class,
|
|
'memento': {
|
|
x: d.x,
|
|
y: d.y
|
|
}
|
|
});
|
|
}
|
|
|
|
// set up the force layout
|
|
network.force = d3.layout.force()
|
|
.size(forceDim)
|
|
.nodes(network.nodes)
|
|
.links(network.links)
|
|
.gravity(0.4)
|
|
.friction(0.7)
|
|
.charge(chrg)
|
|
.linkDistance(ldist)
|
|
.linkStrength(lstrg)
|
|
.on('tick', tick);
|
|
|
|
network.drag = d3u.createDragBehavior(network.force,
|
|
selectCb, atDragEnd, panZoom);
|
|
|
|
// create mask layer for when we lose connection to server.
|
|
// TODO: this should be part of the framework
|
|
mask = view.$div.append('div').attr('id','topo-mask');
|
|
para(mask, 'Oops!');
|
|
para(mask, 'Web-socket connection to server closed...');
|
|
para(mask, 'Try refreshing the page.');
|
|
}
|
|
|
|
function para(sel, text) {
|
|
sel.append('p').text(text);
|
|
}
|
|
|
|
|
|
function load(view, ctx, flags) {
|
|
// resize, in case the window was resized while we were not loaded
|
|
resize(view, ctx, flags);
|
|
|
|
// cache the view token, so network topo functions can access it
|
|
network.view = view;
|
|
config.useLiveData = !flags.local;
|
|
|
|
if (!config.useLiveData) {
|
|
prepareScenario(view, ctx, flags.debug);
|
|
}
|
|
|
|
// set our radio buttons and key bindings
|
|
layerBtnSet = view.setRadio(layerButtons);
|
|
view.setKeys(keyDispatch);
|
|
|
|
// patch in our "button bar" for now
|
|
// TODO: implement a more official frameworky way of doing this..
|
|
addButtonBar(view);
|
|
|
|
// Load map data asynchronously; complete startup after that..
|
|
loadGeoJsonData();
|
|
|
|
// start the and timer
|
|
var dashIdx = 0;
|
|
antTimer = setInterval(function () {
|
|
// TODO: figure out how to choose Src-->Dst and Dst-->Src, per link
|
|
dashIdx = dashIdx === 0 ? 14 : dashIdx - 2;
|
|
d3.selectAll('.animated').style('stroke-dashoffset', dashIdx);
|
|
}, 35);
|
|
}
|
|
|
|
function unload(view, ctx, flags) {
|
|
if (antTimer) {
|
|
clearInterval(antTimer);
|
|
antTimer = null;
|
|
}
|
|
}
|
|
|
|
// TODO: move these to config/state portion of script
|
|
var geoJsonUrl = 'json/map/continental_us.json', // TODO: Paul
|
|
geoJson;
|
|
|
|
function loadGeoJsonData() {
|
|
d3.json(geoJsonUrl, function (err, data) {
|
|
if (err) {
|
|
// fall back to USA map background
|
|
loadStaticMap();
|
|
} else {
|
|
geoJson = data;
|
|
loadGeoMap();
|
|
}
|
|
|
|
// finally, connect to the server...
|
|
if (config.useLiveData) {
|
|
webSock.connect();
|
|
}
|
|
});
|
|
}
|
|
|
|
function showBg() {
|
|
return config.options.showBackground ? 'visible' : 'hidden';
|
|
}
|
|
|
|
function loadStaticMap() {
|
|
fnTrace('loadStaticMap', config.backgroundUrl);
|
|
var w = network.view.width(),
|
|
h = network.view.height();
|
|
|
|
// load the background image
|
|
bgImg = svg.insert('svg:image', '#topo-G')
|
|
.attr({
|
|
id: 'topo-bg',
|
|
width: w,
|
|
height: h,
|
|
'xlink:href': config.backgroundUrl
|
|
})
|
|
.style({
|
|
visibility: showBg()
|
|
});
|
|
}
|
|
|
|
function loadGeoMap() {
|
|
fnTrace('loadGeoMap', geoJsonUrl);
|
|
|
|
// extracts the topojson data into geocoordinate-based geometry
|
|
var topoData = topojson.feature(geoJson, geoJson.objects.states);
|
|
|
|
// see: http://bl.ocks.org/mbostock/4707858
|
|
geoMapProjection = d3.geo.mercator();
|
|
var path = d3.geo.path().projection(geoMapProjection);
|
|
|
|
geoMapProjection
|
|
.scale(1)
|
|
.translate([0, 0]);
|
|
|
|
// [[x1,y1],[x2,y2]]
|
|
var b = path.bounds(topoData);
|
|
// size map to 95% of minimum dimension to fill space
|
|
var s = .95 / Math.min((b[1][0] - b[0][0]) / config.logicalSize, (b[1][1] - b[0][1]) / config.logicalSize);
|
|
var t = [(config.logicalSize - s * (b[1][0] + b[0][0])) / 2, (config.logicalSize - s * (b[1][1] + b[0][1])) / 2];
|
|
|
|
geoMapProjection
|
|
.scale(s)
|
|
.translate(t);
|
|
|
|
bgImg = zoomPanContainer.insert("g", '#topo-G');
|
|
bgImg.attr('id', 'map').selectAll('path')
|
|
.data(topoData.features)
|
|
.enter()
|
|
.append('path')
|
|
.attr('d', path);
|
|
}
|
|
|
|
function resize(view, ctx, flags) {
|
|
setSize(svg, view);
|
|
}
|
|
|
|
|
|
// ==============================
|
|
// View registration
|
|
|
|
onos.ui.addView('topo', {
|
|
preload: preload,
|
|
load: load,
|
|
unload: unload,
|
|
resize: resize
|
|
});
|
|
|
|
detailPane = onos.ui.addFloatingPanel('topo-detail');
|
|
oiBox = onos.ui.addFloatingPanel('topo-oibox', 'TL');
|
|
|
|
}(ONOS));
|