| /* |
| * 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 |
| @author Thomas Vachuska |
| */ |
| |
| (function (onos) { |
| 'use strict'; |
| |
| // shorter names for library APIs |
| var d3u = onos.lib.d3util, |
| gly = onos.lib.glyphs; |
| |
| // configuration data |
| var config = { |
| useLiveData: true, |
| fnTrace: true, |
| debugOn: false, |
| debug: { |
| showNodeXY: true, |
| showKeyHandler: false |
| }, |
| birdDim: 400, |
| 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: { |
| linkBaseColor: '#666', |
| linkInColor: '#66f', |
| linkInWidth: 12, |
| linkOutColor: '#f00', |
| linkOutWidth: 10 |
| }, |
| icons: { |
| device: { |
| dim: 36, |
| rx: 4, |
| xoff: -20, |
| yoff: -18 |
| }, |
| host: { |
| defaultRadius: 9, |
| radius: { |
| endstation: 14, |
| bgpSpeaker: 14, |
| router: 14 |
| } |
| } |
| }, |
| 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 = { |
| // TODO: remove these "development only" bindings |
| 0: testMe, |
| equals: injectStartupEvents, |
| dash: injectTestEvent, |
| |
| O: [toggleSummary, 'Toggle ONOS summary pane'], |
| I: [toggleInstances, 'Toggle ONOS instances pane'], |
| B: [toggleBg, 'Toggle background image'], |
| L: [cycleLabels, 'Cycle Device labels'], |
| P: togglePorts, |
| U: [unpin, 'Unpin node'], |
| R: [resetZoomPan, 'Reset zoom/pan'], |
| V: [showTrafficAction, 'Show related traffic'], |
| A: [showAllTrafficAction, 'Show all traffic'], |
| F: [showDeviceLinkFlowsAction, 'Show device link flows'], |
| esc: handleEscape |
| }; |
| |
| // state variables |
| var network = { |
| view: null, // view token reference |
| nodes: [], |
| links: [], |
| lookup: {}, |
| revLinkToKey: {} |
| }, |
| 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, |
| deviceLabelCount = 3, |
| hostLabelCount = 2, |
| deviceLabelIndex = 0, |
| hostLabelIndex = 0, |
| selections = {}, |
| selectOrder = [], |
| hovered = null, |
| summaryPane, |
| detailPane, |
| antTimer = null, |
| onosInstances = {}, |
| onosOrder = [], |
| oiBox, |
| oiShowMaster = false, |
| portLabelsOn = false, |
| cat7 = d3u.cat7(), |
| colorAffinity = false; |
| |
| var hoverModeAll = 1, |
| hoverModeFlows = 2, |
| hoverModeIntents = 3, |
| hoverMode = hoverModeFlows; |
| |
| // D3 selections |
| var svg, |
| zoomPanContainer, |
| bgImg, |
| topoG, |
| nodeG, |
| linkG, |
| linkLabelG, |
| node, |
| link, |
| linkLabel, |
| 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('Theme is ' + view.getTheme()); |
| //view.flash('This is some text'); |
| cat7.testCard(svg); |
| } |
| |
| 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); |
| wsTrace('test', JSON.stringify(data)); |
| 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 === 2) |
| ? 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) { |
| sendUpdateMeta(hovered); |
| hovered.fixed = false; |
| hovered.el.classed('fixed', false); |
| network.force.resume(); |
| } |
| } |
| |
| function handleEscape(view) { |
| if (oiShowMaster) { |
| cancelAffinity(); |
| } else if (detailPane.isVisible()) { |
| deselectAll(); |
| } else if (oiBox.isVisible()) { |
| hideInstances(); |
| } else if (summaryPane.isVisible()) { |
| cancelSummary(); |
| } |
| } |
| |
| // ============================== |
| // Radio Button Callbacks |
| |
| var layerLookup = { |
| host: { |
| endstation: 'pkt', // default, if host event does not define type |
| router: 'pkt', |
| bgpSpeaker: 'pkt' |
| }, |
| device: { |
| switch: 'pkt', |
| roadm: 'opt' |
| }, |
| link: { |
| hostLink: 'pkt', |
| direct: 'pkt', |
| indirect: '', |
| tunnel: '', |
| optical: 'opt' |
| } |
| }; |
| |
| function inLayer(d, layer) { |
| var type = d.class === 'link' ? d.type() : d.type, |
| look = layerLookup[d.class], |
| lyr = look && look[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 |
| }); |
| } |
| |
| 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 findLinkById(id) { |
| // check to see if this is a reverse lookup, else default to given id |
| var key = network.revLinkToKey[id] || id; |
| return key && network.lookup[key]; |
| } |
| |
| 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; |
| 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 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); |
| } |
| |
| 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', config.topo.linkBaseColor); |
| } |
| |
| // ============================== |
| // 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: updateInstance, |
| updateDevice: updateDevice, |
| updateLink: updateLink, |
| updateHost: updateHost, |
| |
| removeInstance: removeInstance, |
| removeDevice: stillToImplement, |
| removeLink: removeLink, |
| removeHost: removeHost, |
| |
| showDetails: showDetails, |
| showSummary: showSummary, |
| 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, |
| 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(); |
| } |
| } |
| |
| 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 updateInstance(data) { |
| evTrace(data); |
| var inst = data.payload, |
| id = inst.id, |
| instData = onosInstances[id]; |
| if (instData) { |
| $.extend(instData, inst); |
| updateInstances(); |
| } else { |
| logicError('updateInstance lookup fail. ID = "' + id + '"'); |
| } |
| } |
| |
| function updateDevice(data) { |
| evTrace(data); |
| var device = data.payload, |
| id = device.id, |
| d = network.lookup[id]; |
| if (d) { |
| $.extend(d, device); |
| if (positionNode(d, true)) { |
| sendUpdateMeta(d, true); |
| } |
| updateNodes(); |
| } else { |
| logicError('updateDevice lookup fail. ID = "' + id + '"'); |
| } |
| } |
| |
| function updateLink(data) { |
| evTrace(data); |
| var link = data.payload, |
| result = findLink(link, 'update'), |
| bad = result.badLogic; |
| if (bad) { |
| logicError(bad + ': ' + link.id); |
| return; |
| } |
| result.updateWith(link); |
| } |
| |
| 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 removeInstance(data) { |
| evTrace(data); |
| var inst = data.payload, |
| id = inst.id, |
| instData = onosInstances[id]; |
| if (instData) { |
| var idx = find(id, onosOrder, 'id'); |
| if (idx >= 0) { |
| onosOrder.splice(idx, 1); |
| } |
| delete onosInstances[id]; |
| updateInstances(); |
| } else { |
| logicError('updateInstance lookup fail. ID = "' + id + '"'); |
| } |
| } |
| |
| function removeLink(data) { |
| evTrace(data); |
| var link = data.payload, |
| result = findLink(link, 'remove'), |
| bad = result.badLogic; |
| if (bad) { |
| logicError(bad + ': ' + link.id); |
| return; |
| } |
| result.removeRawLink(); |
| } |
| |
| 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 showSummary(data) { |
| evTrace(data); |
| populateSummary(data.payload); |
| summaryPane.show(); |
| } |
| |
| function showDetails(data) { |
| evTrace(data); |
| populateDetails(data.payload); |
| detailPane.show(); |
| } |
| |
| function showTraffic(data) { |
| evTrace(data); |
| var paths = data.payload.paths, |
| hasTraffic = false; |
| |
| // Revert any links hilighted previously. |
| link.style('stroke-width', null) |
| .classed('primary secondary animated optical', false); |
| // Remove all previous labels. |
| removeLinkLabels(); |
| |
| // Now hilight 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; |
| |
| hasTraffic = hasTraffic || p.traffic; |
| for (i=0; i<n; i++) { |
| ldata = findLinkById(p.links[i]); |
| if (ldata && ldata.el) { |
| ldata.el.classed(p.class, true); |
| ldata.label = p.labels[i]; |
| } |
| } |
| }); |
| |
| updateLinks(); |
| |
| if (hasTraffic && !antTimer) { |
| startAntTimer(); |
| } else if (!hasTraffic && antTimer) { |
| stopAntTimer(); |
| } |
| } |
| |
| // ............................... |
| |
| function stillToImplement(data) { |
| var p = data.payload; |
| note(data.event, p.id); |
| if (!config.useLiveData) { |
| 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 getSelIds(start, endOffset) { |
| var end = selectOrder.length - endOffset; |
| var ids = []; |
| selectOrder.slice(start, end).forEach(function (d) { |
| ids.push(getSelId(d)); |
| }); |
| return ids; |
| } |
| function allSelectionsClass(cls) { |
| for (var i=0, n=nSel(); i<n; i++) { |
| if (getSel(i).obj.class !== cls) { |
| return false; |
| } |
| } |
| return true; |
| } |
| |
| function toggleInstances() { |
| if (!oiBox.isVisible()) { |
| showInstances(); |
| } else { |
| hideInstances(); |
| } |
| } |
| |
| function showInstances() { |
| oiBox.show(); |
| colorAffinity = true; |
| updateDeviceColors(); |
| } |
| |
| function hideInstances() { |
| oiBox.hide(); |
| colorAffinity = false; |
| cancelAffinity(); |
| updateDeviceColors(); |
| } |
| |
| |
| function toggleSummary() { |
| if (!summaryPane.isVisible()) { |
| requestSummary(); |
| } else { |
| cancelSummary(); |
| } |
| } |
| |
| // request overall summary data |
| function requestSummary() { |
| sendMessage('requestSummary', {}); |
| } |
| |
| function cancelSummary() { |
| sendMessage('cancelSummary', {}); |
| summaryPane.hide(); |
| } |
| |
| // 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 addHostIntentAction() { |
| sendMessage('addHostIntent', { |
| one: selectOrder[0], |
| two: selectOrder[1], |
| ids: selectOrder |
| }); |
| network.view.flash('Host-to-Host flow added'); |
| } |
| |
| function addMultiSourceIntentAction() { |
| sendMessage('addMultiSourceIntent', { |
| src: selectOrder.slice(0, selectOrder.length - 1), |
| dst: selectOrder[selectOrder.length - 1], |
| ids: selectOrder |
| }); |
| network.view.flash('Multi-Source flow added'); |
| } |
| |
| |
| function cancelTraffic() { |
| sendMessage('cancelTraffic', {}); |
| } |
| |
| function requestTrafficForMode() { |
| if (hoverMode === hoverModeAll) { |
| requestAllTraffic(); |
| } else if (hoverMode === hoverModeFlows) { |
| requestDeviceLinkFlows(); |
| } else if (hoverMode === hoverModeIntents) { |
| requestSelectTraffic(); |
| } |
| } |
| |
| function showTrafficAction() { |
| hoverMode = hoverModeIntents; |
| requestSelectTraffic(); |
| network.view.flash('Related Traffic'); |
| } |
| |
| function requestSelectTraffic() { |
| if (validateSelectionContext()) { |
| var hoverId = (hoverMode === hoverModeIntents && hovered && |
| (hovered.class === 'host' || hovered.class === 'device')) |
| ? hovered.id : ''; |
| sendMessage('requestTraffic', { |
| ids: selectOrder, |
| hover: hoverId |
| }); |
| } |
| } |
| |
| |
| function showDeviceLinkFlowsAction() { |
| hoverMode = hoverModeFlows; |
| requestDeviceLinkFlows(); |
| network.view.flash('Device Flows'); |
| } |
| |
| function requestDeviceLinkFlows() { |
| if (validateSelectionContext()) { |
| var hoverId = (hoverMode === hoverModeFlows && hovered && |
| (hovered.class === 'device')) ? hovered.id : ''; |
| sendMessage('requestDeviceLinkFlows', { |
| ids: selectOrder, |
| hover: hoverId |
| }); |
| } |
| } |
| |
| |
| function showAllTrafficAction() { |
| hoverMode = hoverModeAll; |
| requestAllTraffic(); |
| network.view.flash('All Traffic'); |
| } |
| |
| function requestAllTraffic() { |
| sendMessage('requestAllTraffic', {}); |
| } |
| |
| function validateSelectionContext() { |
| if (!hovered && nSel() === 0) { |
| cancelTraffic(); |
| return false; |
| } |
| return true; |
| } |
| |
| |
| // ============================== |
| // onos instance panel functions |
| |
| var instCfg = { |
| rectPad: 8, |
| nodeOx: 9, |
| nodeOy: 9, |
| nodeDim: 40, |
| birdOx: 19, |
| birdOy: 21, |
| birdDim: 21, |
| uiDy: 45, |
| titleDy: 30, |
| textYOff: 20, |
| textYSpc: 15 |
| }; |
| |
| function viewBox(dim) { |
| return '0 0 ' + dim.w + ' ' + dim.h; |
| } |
| |
| function instRectAttr(dim) { |
| var pad = instCfg.rectPad; |
| return { |
| x: pad, |
| y: pad, |
| width: dim.w - pad*2, |
| height: dim.h - pad*2, |
| rx: 6 |
| }; |
| } |
| |
| function computeDim(self) { |
| var css = window.getComputedStyle(self); |
| return { |
| w: stripPx(css.width), |
| h: stripPx(css.height) |
| }; |
| } |
| |
| function updateInstances() { |
| var onoses = oiBox.el.selectAll('.onosInst') |
| .data(onosOrder, function (d) { return d.id; }), |
| instDim = {w:0,h:0}, |
| c = instCfg; |
| |
| function nSw(n) { |
| return '# Switches: ' + n; |
| } |
| |
| // operate on existing onos instances if necessary |
| onoses.each(function (d) { |
| var el = d3.select(this), |
| svg = el.select('svg'); |
| instDim = computeDim(this); |
| |
| // update online state |
| el.classed('online', d.online); |
| |
| // update ui-attached state |
| svg.select('use.uiBadge').remove(); |
| if (d.uiAttached) { |
| attachUiBadge(svg); |
| } |
| |
| function updAttr(id, value) { |
| svg.select('text.instLabel.'+id).text(value); |
| } |
| |
| updAttr('ip', d.ip); |
| updAttr('ns', nSw(d.switches)); |
| }); |
| |
| |
| // operate on new onos instances |
| var entering = onoses.enter() |
| .append('div') |
| .attr('class', 'onosInst') |
| .classed('online', function (d) { return d.online; }) |
| .on('click', clickInst); |
| |
| entering.each(function (d) { |
| var el = d3.select(this), |
| rectAttr, |
| svg; |
| instDim = computeDim(this); |
| rectAttr = instRectAttr(instDim); |
| |
| svg = el.append('svg').attr({ |
| width: instDim.w, |
| height: instDim.h, |
| viewBox: viewBox(instDim) |
| }); |
| |
| svg.append('rect').attr(rectAttr); |
| |
| appendGlyph(svg, c.nodeOx, c.nodeOy, c.nodeDim, '#node'); |
| appendBadge(svg, c.birdOx, c.birdOy, c.birdDim, '#bird'); |
| |
| if (d.uiAttached) { |
| attachUiBadge(svg); |
| } |
| |
| var left = c.nodeOx + c.nodeDim, |
| len = rectAttr.width - left, |
| hlen = len / 2, |
| midline = hlen + left; |
| |
| // title |
| svg.append('text') |
| .attr({ |
| class: 'instTitle', |
| x: midline, |
| y: c.titleDy |
| }) |
| .text(d.id); |
| |
| // a couple of attributes |
| var ty = c.titleDy + c.textYOff; |
| |
| function addAttr(id, label) { |
| svg.append('text').attr({ |
| class: 'instLabel ' + id, |
| x: midline, |
| y: ty |
| }).text(label); |
| ty += c.textYSpc; |
| } |
| |
| addAttr('ip', d.ip); |
| addAttr('ns', nSw(d.switches)); |
| }); |
| |
| // operate on existing + new onoses here |
| // set the affinity colors... |
| onoses.each(function (d) { |
| var el = d3.select(this), |
| rect = el.select('svg').select('rect'), |
| col = instColor(d.id, d.online); |
| rect.style('fill', col); |
| }); |
| |
| // adjust the panel size appropriately... |
| oiBox.width(instDim.w * onosOrder.length); |
| oiBox.height(instDim.h); |
| |
| // remove any outgoing instances |
| onoses.exit().remove(); |
| } |
| |
| function instColor(id, online) { |
| return cat7.get(id, !online, network.view.getTheme()); |
| } |
| |
| 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; |
| } |
| |
| // TODO: these should be moved out to utility module. |
| function stripPx(s) { |
| return s.replace(/px$/,''); |
| } |
| |
| function appendUse(svg, ox, oy, dim, iid, cls) { |
| var use = svg.append('use').attr({ |
| transform: translate(ox,oy), |
| 'xlink:href': iid, |
| width: dim, |
| height: dim |
| }); |
| if (cls) { |
| use.classed(cls, true); |
| } |
| return use; |
| } |
| |
| function appendGlyph(svg, ox, oy, dim, iid, cls) { |
| appendUse(svg, ox, oy, dim, iid, cls).classed('glyphIcon', true); |
| } |
| |
| function appendBadge(svg, ox, oy, dim, iid, cls) { |
| appendUse(svg, ox, oy, dim, iid,cls ).classed('badgeIcon', true); |
| } |
| |
| function attachUiBadge(svg) { |
| appendBadge(svg, 12, instCfg.uiDy, 30, '#uiAttached', 'uiBadge'); |
| } |
| |
| // ============================== |
| // force layout modification functions |
| |
| function translate(x, y) { |
| return 'translate(' + x + ',' + y + ')'; |
| } |
| |
| function rotate(deg) { |
| return 'rotate(' + deg + ')'; |
| } |
| |
| 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, { |
| key: id, |
| class: 'link', |
| |
| 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; |
| } |
| |
| function createLink(link) { |
| var lnk = linkEndPoints(link.src, link.dst); |
| |
| if (!lnk) { |
| return null; |
| } |
| |
| $.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; |
| 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; |
| } |
| |
| function removeLinkLabels() { |
| network.links.forEach(function (d) { |
| d.label = ''; |
| }); |
| } |
| |
| var widthRatio = 1.4, |
| linkScale = d3.scale.linear() |
| .domain([1, 12]) |
| .range([widthRatio, 12 * widthRatio]) |
| .clamp(true); |
| |
| function updateLinks() { |
| link = linkG.selectAll('.link') |
| .data(network.links, function (d) { return d.key; }); |
| |
| // operate on existing links, if necessary |
| // link .foo() .bar() ... |
| |
| // 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: config.topo.linkInColor, |
| 'stroke-width': config.topo.linkInWidth |
| }); |
| |
| // augment links |
| entering.each(function (d) { |
| var link = d3.select(this); |
| // provide ref to element selection from backing data.... |
| d.el = link; |
| restyleLinkElement(d); |
| }); |
| |
| // operate on both existing and new links, if necessary |
| //link .foo() .bar() ... |
| |
| // 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: config.topo.linkOutColor, |
| 'stroke-width': config.topo.linkOutWidth |
| }) |
| .style('opacity', 0.0) |
| .remove(); |
| |
| // NOTE: invoke a single tick to force the labels to position |
| // onto their links. |
| tick(); |
| } |
| |
| 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; |
| } |
| |
| 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 links 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 translate(xMid, yMid); |
| } |
| |
| function createDeviceNode(device) { |
| // start with the object as is |
| var node = device, |
| type = device.type, |
| svgCls = type ? 'node device ' + type : 'node device', |
| labels = device.labels || []; |
| |
| // Augment as needed... |
| node.class = 'device'; |
| node.svgClass = device.online ? svgCls + ' online' : svgCls; |
| positionNode(node); |
| 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 ' + node.type; |
| positionNode(node); |
| return node; |
| } |
| |
| function positionNode(node, forUpdate) { |
| 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.px = node.x = x; |
| node.py = node.y = y; |
| //node.px = x; |
| //node.py = y; |
| return; |
| } |
| |
| var location = node.location; |
| if (location && location.type === 'latlng') { |
| var coord = geoMapProjection([location.lng, location.lat]); |
| node.fixed = true; |
| node.px = node.x = coord[0]; |
| node.py = node.y = coord[1]; |
| //node.x = coord[0]; |
| //node.y = coord[1]; |
| return true; |
| } |
| |
| // if this is a node update (not a node add).. skip randomizer |
| if (forUpdate) { |
| 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 iconGlyphUrl(d) { |
| var which = d.type || 'unknown'; |
| return '#' + which; |
| } |
| |
| // 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 trimLabel(label) { |
| return (label && label.trim()) || ''; |
| } |
| |
| function emptyBox() { |
| return { |
| x: -2, |
| y: -2, |
| width: 4, |
| height: 4 |
| }; |
| } |
| |
| function updateDeviceLabel(d) { |
| var label = trimLabel(deviceLabel(d)), |
| noLabel = !label, |
| node = d.el, |
| box, |
| dx, |
| dy, |
| cfg = config.icons.device; |
| |
| node.select('text') |
| .text(label) |
| .style('opacity', 0) |
| .transition() |
| .style('opacity', 1); |
| |
| if (noLabel) { |
| box = emptyBox(); |
| dx = -cfg.dim/2; |
| dy = -cfg.dim/2; |
| } else { |
| box = adjustRectToFitText(node); |
| dx = box.x + cfg.xoff; |
| dy = box.y + cfg.yoff; |
| } |
| |
| node.select('rect') |
| .transition() |
| .attr(box); |
| |
| node.select('g.deviceIcon') |
| .transition() |
| .attr('transform', translate(dx, dy)); |
| } |
| |
| function updateHostLabel(d) { |
| var label = hostLabel(d), |
| host = d.el; |
| |
| host.select('text').text(label); |
| } |
| |
| // FIXME : fold this into updateNodes. |
| function updateHostState(hostData) { |
| updateHostLabel(hostData); |
| } |
| |
| function nodeMouseOver(d) { |
| hovered = d; |
| requestTrafficForMode(); |
| } |
| |
| function nodeMouseOut(d) { |
| hovered = null; |
| requestTrafficForMode(); |
| } |
| |
| function addHostIcon(node, radius, iid) { |
| var dim = radius * 1.5, |
| xlate = -dim / 2; |
| |
| node.append('use').attr({ |
| class: 'glyphIcon hostIcon', |
| transform: translate(xlate,xlate), |
| 'xlink:href': iid, |
| width: dim, |
| height: dim |
| }); |
| } |
| |
| function updateNodes() { |
| node = nodeG.selectAll('.node') |
| .data(network.nodes, function (d) { return d.id; }); |
| |
| // operate on existing nodes... |
| node.filter('.device').each(function (d) { |
| //var node = d3.select(this); |
| var node = d.el; |
| node.classed('online', d.online); |
| updateDeviceLabel(d); |
| positionNode(d, true); |
| }); |
| |
| node.filter('.host').each(function (d) { |
| var node = d.el; |
| // TODO: appropriate update of host visuals |
| }); |
| |
| // 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), |
| label = trimLabel(deviceLabel(d)), |
| noLabel = !label, |
| 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); |
| addDeviceIcon(node, box, noLabel, iconGlyphUrl(d)); |
| }); |
| |
| |
| // augment host nodes... |
| entering.filter('.host').each(function (d) { |
| var node = d3.select(this), |
| cfg = config.icons.host, |
| r = cfg.radius[d.type] || cfg.defaultRadius, |
| textDy = r + 10, |
| iid = iconGlyphUrl(d); |
| |
| // provide ref to element from backing data.... |
| d.el = node; |
| |
| node.append('circle').attr('r', r); |
| |
| if (iid) { |
| addHostIcon(node, r, iid); |
| } |
| |
| node.append('text') |
| .text(hostLabel) |
| .attr('dy', textDy) |
| .attr('text-anchor', 'middle'); |
| }); |
| |
| // operate on both existing and new nodes, if necessary |
| updateDeviceColors(); |
| |
| // 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 exit animation |
| |
| network.force.resume(); |
| } |
| |
| var dCol = { |
| black: '#000', |
| paleblue: '#acf', |
| offwhite: '#ddd', |
| midgrey: '#888', |
| lightgrey: '#bbb', |
| orange: '#f90' |
| }; |
| |
| // note: these are the device icon colors without affinity |
| var dColTheme = { |
| light: { |
| online: { |
| glyph: dCol.black, |
| rect: dCol.paleblue |
| }, |
| offline: { |
| glyph: dCol.midgrey, |
| rect: dCol.lightgrey |
| } |
| }, |
| // TODO: theme |
| dark: { |
| online: { |
| glyph: dCol.black, |
| rect: dCol.paleblue |
| }, |
| offline: { |
| glyph: dCol.midgrey, |
| rect: dCol.lightgrey |
| } |
| } |
| }; |
| |
| function devBaseColor(d) { |
| var t = network.view.getTheme(), |
| o = d.online ? 'online' : 'offline'; |
| return dColTheme[t][o]; |
| } |
| |
| function setDeviceColor(d) { |
| var o = d.online, |
| s = d.el.classed('selected'), |
| c = devBaseColor(d), |
| a = instColor(d.master, o), |
| g, r, |
| icon = d.el.select('g.deviceIcon'); |
| |
| if (s) { |
| g = c.glyph; |
| r = dColTheme.sel; |
| } else if (colorAffinity) { |
| g = o ? a : c.glyph; |
| r = o ? dCol.offwhite : a; |
| } else { |
| g = c.glyph; |
| r = c.rect; |
| } |
| |
| icon.select('use') |
| .style('fill', g); |
| icon.select('rect') |
| .style('fill', r); |
| } |
| |
| function addDeviceIcon(node, box, noLabel, iid) { |
| var cfg = config.icons.device, |
| dx, |
| dy, |
| g; |
| |
| if (noLabel) { |
| box = emptyBox(); |
| dx = -cfg.dim/2; |
| dy = -cfg.dim/2; |
| } else { |
| box = adjustRectToFitText(node); |
| dx = box.x + cfg.xoff; |
| dy = box.y + cfg.yoff; |
| } |
| |
| g = node.append('g') |
| .attr('class', 'glyphIcon deviceIcon') |
| .attr('transform', translate(dx, dy)); |
| |
| g.append('rect').attr({ |
| x: 0, |
| y: 0, |
| rx: cfg.rx, |
| width: cfg.dim, |
| height: cfg.dim |
| }); |
| |
| g.append('use').attr({ |
| 'xlink:href': iid, |
| width: cfg.dim, |
| height: cfg.dim |
| }); |
| |
| } |
| |
| function find(key, array, tag) { |
| var _tag = tag || 'key', |
| idx, n, d; |
| for (idx = 0, n = array.length; idx < n; idx++) { |
| d = array[idx]; |
| if (d[_tag] === key) { |
| return idx; |
| } |
| } |
| return -1; |
| } |
| |
| function removeLinkElement(linkData) { |
| 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) { |
| // 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; } |
| }); |
| |
| linkLabel.each(function (d) { |
| var el = d3.select(this); |
| var lnk = findLinkById(d.key); |
| |
| if (lnk) { |
| var parms = { |
| x1: lnk.source.x, |
| y1: lnk.source.y, |
| x2: lnk.target.x, |
| y2: lnk.target.y |
| }; |
| el.attr('transform', transformLabel(parms)); |
| } |
| }); |
| } |
| |
| // ============================== |
| // 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); |
| requestSummary(); |
| showInstances(); |
| }; |
| |
| 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'); |
| } |
| |
| function sendMessage(evType, payload) { |
| var toSend = { |
| event: evType, |
| sid: ++sid, |
| payload: payload |
| }, |
| asText = JSON.stringify(toSend); |
| wsTraceTx(asText); |
| webSock.send(asText); |
| |
| // Temporary measure for debugging UI behavior ... |
| if (!config.useLiveData) { |
| handleTestSend(toSend); |
| } |
| } |
| |
| function wsTraceTx(msg) { |
| wsTrace('tx', msg); |
| } |
| function wsTraceRx(msg) { |
| wsTrace('rx', msg); |
| } |
| function wsTrace(rxtx, msg) { |
| console.log('[' + rxtx + '] ' + msg); |
| } |
| |
| // NOTE: Temporary hardcoded example for showing detail pane |
| // while we fine- |
| // Probably should not merge this change... |
| function handleTestSend(msg) { |
| if (msg.event === 'requestDetails') { |
| showDetails({ |
| event: 'showDetails', |
| sid: 1001, |
| payload: { |
| "id": "of:0000ffffffffff09", |
| "type": "roadm", |
| "propOrder": [ |
| "Name", |
| "Vendor", |
| "H/W Version", |
| "S/W Version", |
| "-", |
| "Latitude", |
| "Longitude", |
| "Ports" |
| ], |
| "props": { |
| "Name": null, |
| "Vendor": "Linc", |
| "H/W Version": "OE", |
| "S/W Version": "?", |
| "-": "", |
| "Latitude": "40.8", |
| "Longitude": "73.1", |
| "Ports": "2" |
| } |
| } |
| }); |
| } |
| } |
| |
| // ============================== |
| // 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); |
| updateDeviceColors(obj); |
| 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); |
| } |
| updateDeviceColors(obj.obj); |
| } |
| } |
| |
| function deselectAll() { |
| // deselect all nodes in the network... |
| node.classed('selected', false); |
| selections = {}; |
| selectOrder = []; |
| updateDeviceColors(); |
| updateDetailPane(); |
| } |
| |
| function updateDeviceColors(d) { |
| if (d) { |
| setDeviceColor(d); |
| } else { |
| node.filter('.device').each(function (d) { |
| setDeviceColor(d); |
| }); |
| } |
| } |
| |
| // update the state of the detail pane, based on current selections |
| function updateDetailPane() { |
| var nSel = selectOrder.length; |
| if (!nSel) { |
| emptySelect(); |
| } else if (nSel === 1) { |
| singleSelect(); |
| requestTrafficForMode(); |
| } else { |
| multiSelect(); |
| } |
| } |
| |
| function emptySelect() { |
| detailPane.hide(); |
| cancelTraffic(); |
| } |
| |
| function singleSelect() { |
| // NOTE: detail is shown from showDetails event callback |
| requestDetails(); |
| requestTrafficForMode(); |
| } |
| |
| function multiSelect() { |
| populateMultiSelect(); |
| requestTrafficForMode(); |
| } |
| |
| 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('h3'), |
| table = detailPane.append('table'), |
| tbody = table.append('tbody'); |
| |
| title.text('Selected Nodes'); |
| |
| selectOrder.forEach(function (d, i) { |
| addProp(tbody, i+1, d); |
| }); |
| |
| addMultiSelectActions(); |
| } |
| |
| // TODO: refactor to consolidate with populateDetails |
| function populateSummary(data) { |
| summaryPane.empty(); |
| |
| var svg = summaryPane.append('svg'), |
| iid = iconGlyphUrl(data); |
| |
| var title = summaryPane.append('h2'), |
| table = summaryPane.append('table'), |
| tbody = table.append('tbody'); |
| |
| appendGlyph(svg, 0, 0, 40, iid); |
| |
| svg.append('use') |
| .attr({ |
| class: 'birdBadge', |
| transform: translate(8,12), |
| 'xlink:href': '#bird', |
| width: 24, |
| height: 24, |
| fill: '#fff' |
| }); |
| |
| title.text('ONOS Summary'); |
| |
| data.propOrder.forEach(function(p) { |
| if (p === '-') { |
| addSep(tbody); |
| } else { |
| addProp(tbody, p, data.props[p]); |
| } |
| }); |
| } |
| |
| function populateDetails(data) { |
| detailPane.empty(); |
| |
| var svg = detailPane.append('svg'), |
| iid = iconGlyphUrl(data); |
| |
| var title = detailPane.append('h2'), |
| table = detailPane.append('table'), |
| tbody = table.append('tbody'); |
| |
| appendGlyph(svg, 0, 0, 40, iid); |
| title.text(data.id); |
| |
| data.propOrder.forEach(function(p) { |
| if (p === '-') { |
| addSep(tbody); |
| } else { |
| addProp(tbody, p, data.props[p]); |
| } |
| }); |
| |
| addSingleSelectActions(data); |
| } |
| |
| function addSingleSelectActions(data) { |
| detailPane.append('hr'); |
| // always want to allow 'show traffic' |
| addAction(detailPane, 'Show Related Traffic', showTrafficAction); |
| |
| if (data.type === 'switch') { |
| addAction(detailPane, 'Show Device Flows', showDeviceLinkFlowsAction); |
| } |
| } |
| |
| function addMultiSelectActions() { |
| detailPane.append('hr'); |
| // always want to allow 'show traffic' |
| addAction(detailPane, 'Show Related Traffic', showTrafficAction); |
| // if exactly two hosts are selected, also want 'add host intent' |
| if (nSel() === 2 && allSelectionsClass('host')) { |
| addAction(detailPane, 'Create Host-to-Host Flow', addHostIntentAction); |
| } else if (nSel() >= 2 && allSelectionsClass('host')) { |
| addAction(detailPane, 'Create Multi-Source Flow', addMultiSourceIntentAction); |
| } |
| } |
| |
| 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; |
| |
| 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); |
| } |
| |
| function panZoom() { |
| return false; |
| } |
| |
| function loadGlyphs(svg) { |
| var defs = svg.append('defs'); |
| gly.defBird(defs); |
| gly.defGlyphs(defs); |
| gly.defBadges(defs); |
| } |
| |
| function sendUpdateMeta(d, store) { |
| var metaUi = {}, |
| ll; |
| |
| if (store) { |
| ll = geoMapProjection.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 |
| }); |
| } |
| |
| // ============================== |
| // 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); |
| |
| loadGlyphs(svg); |
| |
| 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, link labels, and nodes |
| linkG = topoG.append('g').attr('id', 'links'); |
| linkLabelG = topoG.append('g').attr('id', 'linkLabels'); |
| nodeG = topoG.append('g').attr('id', 'nodes'); |
| |
| // selection of links, linkLabels, and nodes |
| link = linkG.selectAll('.link'); |
| linkLabel = linkLabelG.selectAll('.linkLabel'); |
| 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, true); |
| } else { |
| console.log('Moving node ' + d.id + ' to [' + d.x + ',' + 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.'); |
| |
| mask.append('svg') |
| .attr({ |
| id: 'mask-bird', |
| width: w, |
| height: h |
| }) |
| .append('g') |
| .attr('transform', birdTranslate(w, h)) |
| .style('opacity', 0.3) |
| .append('use') |
| .attr({ |
| 'xlink:href': '#bird', |
| width: config.birdDim, |
| height: config.birdDim, |
| fill: '#111' |
| }) |
| } |
| |
| 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(); |
| } |
| |
| function startAntTimer() { |
| if (!antTimer) { |
| var pulses = [5, 3, 1.2, 3], |
| pulse = 0; |
| antTimer = setInterval(function () { |
| pulse = pulse + 1; |
| pulse = pulse === pulses.length ? 0 : pulse; |
| d3.selectAll('.animated').style('stroke-width', pulses[pulse]); |
| }, 200); |
| } |
| } |
| |
| function stopAntTimer() { |
| if (antTimer) { |
| clearInterval(antTimer); |
| antTimer = null; |
| } |
| } |
| |
| function unload(view, ctx, flags) { |
| stopAntTimer(); |
| } |
| |
| // TODO: move these to config/state portion of script |
| var geoJsonUrl = 'json/map/continental_us.json', |
| 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) { |
| var w = view.width(), |
| h = view.height(); |
| |
| setSize(svg, view); |
| |
| d3.select('#mask-bird').attr({ width: w, height: h}) |
| .select('g').attr('transform', birdTranslate(w, h)); |
| } |
| |
| function theme(view, ctx, flags) { |
| updateInstances(); |
| updateDeviceColors(); |
| } |
| |
| function birdTranslate(w, h) { |
| var bdim = config.birdDim; |
| return 'translate('+((w-bdim)*.4)+','+((h-bdim)*.1)+')'; |
| } |
| |
| // ============================== |
| // View registration |
| |
| onos.ui.addView('topo', { |
| preload: preload, |
| load: load, |
| unload: unload, |
| resize: resize, |
| theme: theme |
| }); |
| |
| summaryPane = onos.ui.addFloatingPanel('topo-summary'); |
| detailPane = onos.ui.addFloatingPanel('topo-detail'); |
| oiBox = onos.ui.addFloatingPanel('topo-oibox', 'TL'); |
| oiBox.width(20); |
| |
| }(ONOS)); |