blob: b759997f732f482f57c4d97c3e1a09e38d1f4cd2 [file] [log] [blame]
/*
* Copyright 2019-present Open Networking Foundation
*
* 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.
*/
import {
ChangeDetectionStrategy,
ChangeDetectorRef,
Component,
EventEmitter,
HostListener,
Input,
OnChanges,
OnInit,
Output,
QueryList,
SimpleChange,
SimpleChanges,
ViewChildren
} from '@angular/core';
import {
LocMeta,
LogService,
MetaUi,
WebSocketService,
ZoomUtils
} from 'gui2-fw-lib';
import {
Device, DeviceProps,
ForceDirectedGraph,
Host,
HostLabelToggle,
LabelToggle,
LayerType,
Link,
LinkHighlight,
Location,
ModelEventMemo,
ModelEventType,
Node,
Options,
Region,
RegionLink,
SubRegion,
UiElement
} from './models';
import {LocationType} from '../backgroundsvg/backgroundsvg.component';
import {DeviceNodeSvgComponent} from './visuals/devicenodesvg/devicenodesvg.component';
import { HostNodeSvgComponent} from './visuals/hostnodesvg/hostnodesvg.component';
import { LinkSvgComponent} from './visuals/linksvg/linksvg.component';
import {SelectedEvent} from './visuals/nodevisual';
interface UpdateMeta {
id: string;
class: string;
memento: MetaUi;
}
const SVGCANVAS = <Options>{
width: 1000,
height: 1000
};
interface ChangeSummary {
numChanges: number;
locationChanged: boolean;
}
/**
* ONOS GUI -- Topology Forces Graph Layer View.
*
* The regionData is set by Topology Service on WebSocket topo2CurrentRegion callback
* This drives the whole Force graph
*/
@Component({
selector: '[onos-forcesvg]',
templateUrl: './forcesvg.component.html',
styleUrls: ['./forcesvg.component.css'],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class ForceSvgComponent implements OnInit, OnChanges {
@Input() deviceLabelToggle: LabelToggle.Enum = LabelToggle.Enum.NONE;
@Input() hostLabelToggle: HostLabelToggle.Enum = HostLabelToggle.Enum.NONE;
@Input() showHosts: boolean = false;
@Input() highlightPorts: boolean = true;
@Input() onosInstMastership: string = '';
@Input() visibleLayer: LayerType = LayerType.LAYER_DEFAULT;
@Input() selectedLink: RegionLink = null;
@Input() scale: number = 1;
@Input() regionData: Region = <Region>{devices: [ [], [], [] ], hosts: [ [], [], [] ], links: []};
@Output() linkSelected = new EventEmitter<RegionLink>();
@Output() selectedNodeEvent = new EventEmitter<UiElement[]>();
public graph: ForceDirectedGraph;
private selectedNodes: UiElement[] = [];
// References to the children of this component - these are created in the
// template view with the *ngFor and we get them by a query here
@ViewChildren(DeviceNodeSvgComponent) devices: QueryList<DeviceNodeSvgComponent>;
@ViewChildren(HostNodeSvgComponent) hosts: QueryList<HostNodeSvgComponent>;
@ViewChildren(LinkSvgComponent) links: QueryList<LinkSvgComponent>;
constructor(
protected log: LogService,
private ref: ChangeDetectorRef,
protected wss: WebSocketService
) {
this.selectedLink = null;
this.log.debug('ForceSvgComponent constructed');
}
/**
* Utility for extracting a node name from an endpoint string
* In some cases - have to remove the port number from the end of a device
* name
* @param endPtStr The end point name
*/
private static extractNodeName(endPtStr: string): string {
const slash: number = endPtStr.indexOf('/');
if (slash === -1) {
return endPtStr;
} else {
const afterSlash = endPtStr.substr(slash + 1);
if (afterSlash === 'None') {
return endPtStr;
} else {
return endPtStr.substr(0, slash);
}
}
}
/**
* Recursive method to compare 2 objects attribute by attribute and update
* the first where a change is detected
* @param existingNode 1st object
* @param updatedNode 2nd object
*/
private static updateObject(existingNode: Object, updatedNode: Object): ChangeSummary {
const changed = <ChangeSummary>{numChanges: 0, locationChanged: false};
for (const key of Object.keys(updatedNode)) {
const o = updatedNode[key];
if (['id', 'x', 'y', 'fx', 'fy', 'vx', 'vy', 'index'].some(k => k === key)) {
continue;
} else if (o && typeof o === 'object' && o.constructor === Object) {
const subChanged = ForceSvgComponent.updateObject(existingNode[key], updatedNode[key]);
changed.numChanges += subChanged.numChanges;
changed.locationChanged = subChanged.locationChanged ? true : changed.locationChanged;
} else if (existingNode === undefined) {
// Copy the whole object
existingNode = updatedNode;
changed.locationChanged = true;
changed.numChanges++;
} else if (existingNode[key] !== updatedNode[key]) {
if (['locType', 'latOrY', 'longOrX', 'latitude', 'longitude', 'gridX', 'gridY'].some(k => k === key)) {
changed.locationChanged = true;
}
changed.numChanges++;
existingNode[key] = updatedNode[key];
}
}
return changed;
}
@HostListener('window:resize', ['$event'])
onResize(event) {
this.graph.restartSimulation();
this.log.debug('Simulation restart after resize', event);
}
/**
* After the component is initialized create the Force simulation
* The list of devices, hosts and links will not have been receieved back
* from the WebSocket yet as this time - they will be updated later through
* ngOnChanges()
*/
ngOnInit() {
// Receiving an initialized simulated graph from our custom d3 service
this.graph = new ForceDirectedGraph(SVGCANVAS, this.log);
/** Binding change detection check on each tick
* This along with an onPush change detection strategy should enforce
* checking only when relevant! This improves scripting computation
* duration in a couple of tests I've made, consistently. Also, it makes
* sense to avoid unnecessary checks when we are dealing only with
* simulations data binding.
*/
this.graph.ticker.subscribe((simulation) => {
// this.log.debug("Force simulation has ticked. Alpha",
// Math.round(simulation.alpha() * 1000) / 1000);
this.ref.markForCheck();
});
this.log.debug('ForceSvgComponent initialized - waiting for nodes and links');
}
/**
* When any one of the inputs get changed by a containing component, this
* gets called automatically. In addition this is called manually by
* topology.service when a response is received from the WebSocket from the
* server
*
* The Devices, Hosts and SubRegions are all added to the Node list for the simulation
* The Links are added to the Link list of the simulation.
* Before they are added the Links are associated with Nodes based on their endPt
*
* @param changes - a list of changed @Input(s)
*/
ngOnChanges(changes: SimpleChanges) {
if (changes['regionData']) {
const devices: Device[] =
changes['regionData'].currentValue.devices[this.visibleLayerIdx()];
const hosts: Host[] =
changes['regionData'].currentValue.hosts[this.visibleLayerIdx()];
const subRegions: SubRegion[] = changes['regionData'].currentValue.subRegion;
this.graph.nodes = [];
if (devices) {
this.graph.nodes = devices;
}
if (hosts) {
this.graph.nodes = this.graph.nodes.concat(hosts);
}
if (subRegions) {
this.graph.nodes = this.graph.nodes.concat(subRegions);
}
this.graph.nodes.forEach((n) => this.fixPosition(n));
// Associate the endpoints of each link with a real node
this.graph.links = [];
for (const linkIdx of Object.keys(this.regionData.links)) {
const epA = ForceSvgComponent.extractNodeName(
this.regionData.links[linkIdx].epA);
this.regionData.links[linkIdx].source =
this.graph.nodes.find((node) =>
node.id === epA);
const epB = ForceSvgComponent.extractNodeName(
this.regionData.links[linkIdx].epB);
this.regionData.links[linkIdx].target =
this.graph.nodes.find((node) =>
node.id === epB);
this.regionData.links[linkIdx].index = Number(linkIdx);
}
this.graph.links = this.regionData.links;
if (this.graph.nodes.length > 0) {
this.graph.reinitSimulation();
}
this.log.debug('ForceSvgComponent input changed',
this.graph.nodes.length, 'nodes,', this.graph.links.length, 'links');
}
}
/**
* If a node has a fixed location then assign it to fx and fy so
* that it doesn't get affected by forces
* @param graphNode The node whose location should be processed
*/
private fixPosition(graphNode: Node): void {
const loc: Location = <Location>graphNode['location'];
const props: DeviceProps = <DeviceProps>graphNode['props'];
const metaUi = <MetaUi>graphNode['metaUi'];
if (loc && loc.locType === LocationType.GEO) {
const position: MetaUi =
ZoomUtils.convertGeoToCanvas(
<LocMeta>{lng: loc.longOrX, lat: loc.latOrY});
graphNode.fx = position.x;
graphNode.fy = position.y;
this.log.debug('Found node', graphNode.id, 'with', loc.locType);
} else if (loc && loc.locType === LocationType.GRID) {
graphNode.fx = loc.longOrX;
graphNode.fy = loc.latOrY;
this.log.debug('Found node', graphNode.id, 'with', loc.locType);
} else if (props && props.locType === LocationType.NONE && metaUi) {
graphNode.fx = metaUi.x;
graphNode.fy = metaUi.y;
this.log.debug('Found node', graphNode.id, 'with locType=none and metaUi');
} else {
graphNode.fx = null;
graphNode.fy = null;
}
}
/**
* Get the index of LayerType so it can drive the visibility of nodes and
* hosts on layers
*/
visibleLayerIdx(): number {
const layerKeys: string[] = Object.keys(LayerType);
for (const idx in layerKeys) {
if (LayerType[layerKeys[idx]] === this.visibleLayer) {
return Number(idx);
}
}
return -1;
}
selectLink(link: RegionLink): void {
this.selectedLink = link;
this.linkSelected.emit(link);
}
/**
* Iterate through all hosts and devices and links to deselect the previously selected
* node. The emit an event to the parent that lets it know the selection has
* changed.
*
* This function collates all of the nodes that have been selected and passes
* a collection of nodes up to the topology component
*
* @param selectedNode the newly selected node
*/
updateSelected(selectedNode: SelectedEvent): void {
this.log.debug('Node or link ',
selectedNode.uiElement,
selectedNode.deselecting ? 'deselected' : 'selected',
selectedNode.isShift ? 'Multiple' : '');
if (selectedNode.isShift && selectedNode.deselecting) {
const idx = this.selectedNodes.findIndex((n) =>
n.id === selectedNode.uiElement.id
);
this.selectedNodes.splice(idx, 1);
this.log.debug('Removed node', idx);
} else if (selectedNode.isShift) {
this.selectedNodes.push(selectedNode.uiElement);
} else if (selectedNode.deselecting) {
this.selectedNodes = [];
} else {
const selNodeId = selectedNode.uiElement.id;
// Otherwise if shift was not pressed deselect previous
this.devices
.filter((d) => d.device.id !== selNodeId)
.forEach((d) => d.deselect());
this.hosts
.filter((h) => h.host.id !== selNodeId)
.forEach((h) => h.deselect());
this.links
.filter((l) => l.link.id !== selNodeId)
.forEach((l) => l.deselect());
this.selectedNodes = [selectedNode.uiElement];
}
// Push the changes back up to parent (Topology Component)
this.selectedNodeEvent.emit(this.selectedNodes);
}
/**
* We want to filter links to show only those not related to hosts if the
* 'showHosts' flag has been switched off. If 'showHosts' is true, then
* display all links.
*/
filteredLinks(): Link[] {
return this.regionData.links.filter((h) =>
this.showHosts ||
((<Host>h.source).nodeType !== 'host' &&
(<Host>h.target).nodeType !== 'host'));
}
/**
* When changes happen in the model, then model events are sent up through the
* Web Socket
* @param type - the type of the change
* @param memo - a qualifier on the type
* @param subject - the item that the update is for
* @param data - the new definition of the item
*/
handleModelEvent(type: ModelEventType, memo: ModelEventMemo, subject: string, data: UiElement): void {
switch (type) {
case ModelEventType.DEVICE_ADDED_OR_UPDATED:
if (memo === ModelEventMemo.ADDED) {
this.fixPosition(<Device>data);
this.graph.nodes.push(<Device>data);
this.regionData.devices[this.visibleLayerIdx()].push(<Device>data);
this.log.debug('Device added', (<Device>data).id);
} else if (memo === ModelEventMemo.UPDATED) {
const oldDevice: Device =
this.regionData.devices[this.visibleLayerIdx()]
.find((d) => d.id === subject);
const changes = ForceSvgComponent.updateObject(oldDevice, <Device>data);
if (changes.numChanges > 0) {
this.log.debug('Device ', oldDevice.id, memo, ' - ', changes, 'changes');
if (changes.locationChanged) {
this.fixPosition(oldDevice);
}
}
} else {
this.log.warn('Device ', memo, ' - not yet implemented', data);
}
break;
case ModelEventType.HOST_ADDED_OR_UPDATED:
if (memo === ModelEventMemo.ADDED) {
this.fixPosition(<Host>data);
this.graph.nodes.push(<Host>data);
this.regionData.hosts[this.visibleLayerIdx()].push(<Host>data);
this.log.debug('Host added', (<Host>data).id);
} else if (memo === ModelEventMemo.UPDATED) {
const oldHost: Host = this.regionData.hosts[this.visibleLayerIdx()]
.find((h) => h.id === subject);
const changes = ForceSvgComponent.updateObject(oldHost, <Host>data);
if (changes.numChanges > 0) {
this.log.debug('Host ', oldHost.id, memo, ' - ', changes, 'changes');
if (changes.locationChanged) {
this.fixPosition(oldHost);
}
}
} else {
this.log.warn('Host change', memo, ' - unexpected');
}
break;
case ModelEventType.DEVICE_REMOVED:
if (memo === ModelEventMemo.REMOVED || memo === undefined) {
const removeIdx: number =
this.regionData.devices[this.visibleLayerIdx()]
.findIndex((d) => d.id === subject);
this.regionData.devices[this.visibleLayerIdx()].splice(removeIdx, 1);
this.removeRelatedLinks(subject);
this.log.debug('Device ', subject, 'removed. Links', this.regionData.links);
} else {
this.log.warn('Device removed - unexpected memo', memo);
}
break;
case ModelEventType.HOST_REMOVED:
if (memo === ModelEventMemo.REMOVED || memo === undefined) {
const removeIdx: number =
this.regionData.hosts[this.visibleLayerIdx()]
.findIndex((h) => h.id === subject);
this.regionData.hosts[this.visibleLayerIdx()].splice(removeIdx, 1);
this.removeRelatedLinks(subject);
this.log.warn('Host ', subject, 'removed');
} else {
this.log.warn('Host removed - unexpected memo', memo);
}
break;
case ModelEventType.LINK_ADDED_OR_UPDATED:
if (memo === ModelEventMemo.ADDED &&
this.regionData.links.findIndex((l) => l.id === subject) === -1) {
const listLen = this.regionData.links.push(<RegionLink>data);
const epA = ForceSvgComponent.extractNodeName(
this.regionData.links[listLen - 1].epA);
this.regionData.links[listLen - 1].source =
this.graph.nodes.find((node) =>
node.id === epA);
const epB = ForceSvgComponent.extractNodeName(
this.regionData.links[listLen - 1].epB);
this.regionData.links[listLen - 1].target =
this.graph.nodes.find((node) =>
node.id === epB);
this.log.debug('Link added', subject);
} else if (memo === ModelEventMemo.UPDATED) {
const oldLink = this.regionData.links.find((l) => l.id === subject);
const changes = ForceSvgComponent.updateObject(oldLink, <RegionLink>data);
this.log.debug('Link ', subject, '. Updated', changes, 'items');
} else {
this.log.warn('Link added or updated - unexpected memo', memo);
}
break;
default:
this.log.error('Unexpected model event', type, 'for', subject);
}
this.graph.links = this.regionData.links;
this.graph.reinitSimulation();
}
private removeRelatedLinks(subject: string) {
const len = this.regionData.links.length;
for (let i = 0; i < len; i++) {
const linkIdx = this.regionData.links.findIndex((l) =>
(ForceSvgComponent.extractNodeName(l.epA) === subject ||
ForceSvgComponent.extractNodeName(l.epB) === subject));
if (linkIdx >= 0) {
this.regionData.links.splice(linkIdx, 1);
this.log.debug('Link ', linkIdx, 'removed on attempt', i);
}
}
}
/**
* When traffic monitoring is turned on (A key) highlights will be sent back
* from the WebSocket through the Traffic Service
* @param devices - an array of device highlights
* @param hosts - an array of host highlights
* @param links - an array of link highlights
*/
handleHighlights(devices: Device[], hosts: Host[], links: LinkHighlight[]): void {
if (devices.length > 0) {
this.log.debug(devices.length, 'Devices highlighted');
devices.forEach((dh) => {
const deviceComponent: DeviceNodeSvgComponent = this.devices.find((d) => d.device.id === dh.id );
if (deviceComponent) {
deviceComponent.ngOnChanges(
{'deviceHighlight': new SimpleChange(<Device>{}, dh, true)}
);
this.log.debug('Highlighting device', deviceComponent.device.id);
} else {
this.log.warn('Device component not found', dh.id);
}
});
}
if (hosts.length > 0) {
this.log.debug(hosts.length, 'Hosts highlighted');
hosts.forEach((hh) => {
const hostComponent: HostNodeSvgComponent = this.hosts.find((h) => h.host.id === hh.id );
if (hostComponent) {
hostComponent.ngOnChanges(
{'hostHighlight': new SimpleChange(<Host>{}, hh, true)}
);
this.log.debug('Highlighting host', hostComponent.host.id);
}
});
}
if (links.length > 0) {
this.log.debug(links.length, 'Links highlighted');
links.forEach((lh) => {
const linkComponent: LinkSvgComponent = this.links.find((l) => l.link.id === lh.id );
if (linkComponent) { // A link might not be present is hosts viewing is switched off
linkComponent.ngOnChanges(
{'linkHighlight': new SimpleChange(<LinkHighlight>{}, lh, true)}
);
// this.log.debug('Highlighting link', linkComponent.link.id, lh.css, lh.label);
}
});
}
}
/**
* As nodes are dragged around the graph, their new location should be sent
* back to server
* @param klass The class of node e.g. 'host' or 'device'
* @param id - the ID of the node
* @param newLocation - the new Location of the node
*/
nodeMoved(klass: string, id: string, newLocation: MetaUi) {
this.wss.sendEvent('updateMeta2', <UpdateMeta>{
id: id,
class: klass,
memento: newLocation
});
this.log.debug(klass, id, 'has been moved to', newLocation);
}
/**
* If any nodes with fixed positions had been dragged out of place
* then put back where they belong
* If there are some devices selected reset only these
*/
resetNodeLocations(): number {
let numbernodes = 0;
if (this.selectedNodes.length > 0) {
this.devices
.filter((d) => this.selectedNodes.some((s) => s.id === d.device.id))
.forEach((dev) => {
Node.resetNodeLocation(<Node>dev.device);
numbernodes++;
});
this.hosts
.filter((h) => this.selectedNodes.some((s) => s.id === h.host.id))
.forEach((h) => {
Host.resetNodeLocation(<Host>h.host);
numbernodes++;
});
} else {
this.devices.forEach((dev) => {
Node.resetNodeLocation(<Node>dev.device);
numbernodes++;
});
this.hosts.forEach((h) => {
Host.resetNodeLocation(<Host>h.host);
numbernodes++;
});
}
this.graph.reinitSimulation();
return numbernodes;
}
/**
* Toggle floating nodes between unpinned and frozen
* There may be frozen and unpinned in the selection
*
* If there are nodes selected toggle only these
*/
unpinOrFreezeNodes(freeze: boolean): number {
let numbernodes = 0;
if (this.selectedNodes.length > 0) {
this.devices
.filter((d) => this.selectedNodes.some((s) => s.id === d.device.id))
.forEach((d) => {
Node.unpinOrFreezeNode(<Node>d.device, freeze);
numbernodes++;
});
this.hosts
.filter((h) => this.selectedNodes.some((s) => s.id === h.host.id))
.forEach((h) => {
Node.unpinOrFreezeNode(<Node>h.host, freeze);
numbernodes++;
});
} else {
this.devices.forEach((d) => {
Node.unpinOrFreezeNode(<Node>d.device, freeze);
numbernodes++;
});
this.hosts.forEach((h) => {
Node.unpinOrFreezeNode(<Node>h.host, freeze);
numbernodes++;
});
}
this.graph.reinitSimulation();
return numbernodes;
}
}