diff --git a/app/controllers/ApiRouter.scala b/app/controllers/ApiRouter.scala index a55ab693..3e51482d 100644 --- a/app/controllers/ApiRouter.scala +++ b/app/controllers/ApiRouter.scala @@ -43,6 +43,6 @@ class ApiRouter @Inject()(irController: InstanceRegistryController, sysControlle case POST(p"/pauseInstance" ? q"instanceID=$instanceID") => irController.handleRequest(action="/pause", instanceID) case POST(p"/resumeInstance" ? q"instanceID=$instanceID") => irController.handleRequest(action="/resume", instanceID) case POST(p"/deleteInstance" ? q"instanceID=$instanceID") => irController.handleRequest(action="/delete", instanceID) - + case POST(p"/reconnectInstance" ? q"from=$from"& q"to=$to") => irController.reconnect(from.toInt, to.toInt) } } diff --git a/app/controllers/InstanceRegistryController.scala b/app/controllers/InstanceRegistryController.scala index e464a81c..c56105c9 100644 --- a/app/controllers/InstanceRegistryController.scala +++ b/app/controllers/InstanceRegistryController.scala @@ -146,6 +146,21 @@ class InstanceRegistryController @Inject()(implicit system: ActorSystem, mat: Ma }(myExecutionContext) } + def reconnect(from: Int, to: Int): Action[AnyContent] = Action.async { request => + + ws.url(instanceRegistryUri + "/instances/" + from + "/assignInstance" + ) + .withHttpHeaders(("Authorization", s"Bearer ${AuthProvider.generateJwt()}")) + .post(Json.obj("AssignedInstanceId" -> to)) + .map { response => + response.status match { + case 200 => + Ok(response.body) + case x => + new Status(x) + } + }(myExecutionContext) + } /** * This function is for handling an POST request for adding an instance to the Scala web server * (E.g. .../instances/deploy diff --git a/client/package-lock.json b/client/package-lock.json index ee9b6bb5..b054b4ca 100644 --- a/client/package-lock.json +++ b/client/package-lock.json @@ -2940,6 +2940,15 @@ "lodash.debounce": "^4.0.8" } }, + "cytoscape-edgehandles": { + "version": "3.5.0", + "resolved": "https://registry.npmjs.org/cytoscape-edgehandles/-/cytoscape-edgehandles-3.5.0.tgz", + "integrity": "sha512-PKuuCLKvgVPPiR0PLY51FbtHFS2w9mRGRjd5kwWJqARQrKqVm8yJqElbyjFHCs9DhuDMDlOgdZcCWm+mBvaCBA==", + "requires": { + "lodash.memoize": "^4.1.2", + "lodash.throttle": "^4.1.1" + } + }, "dashdash": { "version": "1.14.1", "resolved": "https://registry.npmjs.org/dashdash/-/dashdash-1.14.1.tgz", @@ -6520,6 +6529,11 @@ "resolved": "https://registry.npmjs.org/lodash.debounce/-/lodash.debounce-4.0.8.tgz", "integrity": "sha1-gteb/zCmfEAF/9XiUVMArZyk168=" }, + "lodash.memoize": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/lodash.memoize/-/lodash.memoize-4.1.2.tgz", + "integrity": "sha1-vMbEmkKihA7Zl/Mj6tpezRguC/4=" + }, "lodash.mergewith": { "version": "4.6.1", "resolved": "https://registry.npmjs.org/lodash.mergewith/-/lodash.mergewith-4.6.1.tgz", @@ -6533,6 +6547,11 @@ "integrity": "sha1-0jM6NtnncXyK0vfKyv7HwytERmQ=", "dev": true }, + "lodash.throttle": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/lodash.throttle/-/lodash.throttle-4.1.1.tgz", + "integrity": "sha1-wj6RtxAkKscMN/HhzaknTMOb8vQ=" + }, "log4js": { "version": "3.0.6", "resolved": "https://registry.npmjs.org/log4js/-/log4js-3.0.6.tgz", diff --git a/client/package.json b/client/package.json index 9c58aa96..b7cb934a 100644 --- a/client/package.json +++ b/client/package.json @@ -25,6 +25,7 @@ "bootstrap": "^4.3.1", "core-js": "^2.6.4", "cytoscape": "^3.4.1", + "cytoscape-edgehandles": "^3.5.0", "font-awesome": "^4.7.0", "hammerjs": "^2.0.8", "jquery": "^3.3.1", diff --git a/client/src/app/api/api/api.service.ts b/client/src/app/api/api/api.service.ts index cf23ab41..919c7e51 100644 --- a/client/src/app/api/api/api.service.ts +++ b/client/src/app/api/api/api.service.ts @@ -35,7 +35,8 @@ import { PAUSE_INSTANCE, RESUME_INSTANCE, DELETE_INSTANCE, - INSTANCE_NETWORK + INSTANCE_NETWORK, + RECONNECT } from '../variables'; @@ -69,6 +70,19 @@ export class ApiService { return this.get>(INSTANCE_NETWORK); } + public postReconnect(from: number, to: number) { + + let queryParam = new HttpParams({ encoder: new CustomHttpUrlEncodingCodec() }); + + if (from === null || to === undefined) { + throw new Error('Parameter to or from not given'); + } else { + queryParam = queryParam.set('from', from); + queryParam = queryParam.set('to', to); + } + + return this.commonConf(RECONNECT, queryParam); + } /** * Find number of running instances * How many instances per type are running diff --git a/client/src/app/api/variables.ts b/client/src/app/api/variables.ts index a618d11f..b328d20c 100644 --- a/client/src/app/api/variables.ts +++ b/client/src/app/api/variables.ts @@ -29,6 +29,7 @@ export const STOP_INSTANCE = 'api/stopInstance'; export const PAUSE_INSTANCE = 'api/pauseInstance'; export const RESUME_INSTANCE = 'api/resumeInstance'; export const DELETE_INSTANCE = 'api/deleteInstance'; +export const RECONNECT = 'api/reconnectInstance'; export const COLLECTION_FORMATS = { 'csv': ',', 'tsv': ' ', diff --git a/client/src/app/dashboard/graph-view/GraphConfig.ts b/client/src/app/dashboard/graph-view/GraphConfig.ts new file mode 100644 index 00000000..91b0f2cf --- /dev/null +++ b/client/src/app/dashboard/graph-view/GraphConfig.ts @@ -0,0 +1,106 @@ +export class GraphConfig { + + + public readonly layout = { + name: 'circle', + + fit: true, // whether to fit the viewport to the graph + padding: 30, // the padding on fit + boundingBox: undefined, // constrain layout bounds; { x1, y1, x2, y2 } or { x1, y1, w, h } + avoidOverlap: true, // prevents node overlap, may overflow boundingBox and radius if not enough space + nodeDimensionsIncludeLabels: false, // Excludes the label when calculating node bounding boxes for the layout algorithm + spacingFactor: undefined, // Applies a multiplicative factor (>0) to expand or compress the overall area that the nodes take up + radius: undefined, // the radius of the circle + startAngle: 3 / 2 * Math.PI, // where nodes start in radians + sweep: undefined, // how many radians should be between the first and last node (defaults to full circle) + clockwise: true, // whether the layout should go clockwise (true) or counterclockwise/anticlockwise (false) + sort: undefined, // a sorting function to order the nodes; e.g. function(a, b){ return a.data('weight') - b.data('weight') } + animate: false, // whether to transition the node positions + animationDuration: 500, // duration of animation in ms if enabled + animationEasing: undefined, // easing of animation if enabled + animateFilter: function ( node, i ) { return true; }, + ready: undefined, // callback on layoutready + stop: undefined, // callback on layoutstop + transform: function (node, position ) { return position; } + }; + + public readonly cytoscapeConfig = { + container: null, // container to render in + elements: [ // list of graph elements to start with + ], + layout: this.layout, + style: [{ + selector: 'node[name][image]', + style: { + label: 'data(name)', + 'background-image': 'data(image)', + 'width': '70%', + 'height': '70%', + 'background-opacity': 0, + 'background-fit': 'contain', + 'background-clip': 'none', + } + }, { + selector: '.eh-handle', + style: { + 'background-image': '../../../assets/images/EdgeConnector.png', + 'width': '60%', + 'height': '60%', + 'background-opacity': 0, + 'border-width': 12, // makes the handle easier to hit + 'background-fit': 'contain', + 'background-clip': 'none', + 'border-opacity': 0 + } + }, + { + selector: '.eh-hover', + style: { + 'background-color': 'red' + } + }] + }; + + public readonly edgeDragConfig = { + preview: true, // whether to show added edges preview before releasing selection + hoverDelay: 150, // time spent hovering over a target node before it is considered selected + handleNodes: 'node[type != "ElasticSearch"]', // selector/filter function for whether edges can be made from a given node + snap: false, // when enabled, the edge can be drawn by just moving close to a target node (can be confusing on compound graphs) + snapThreshold: 50, // the target node must be less than or equal to this many pixels away from the cursor/finger + snapFrequency: 15, // the number of times per second (Hz) that snap checks done (lower is less expensive) + noEdgeEventsInDraw: false, // set events:no to edges during draws, prevents mouseouts on compounds + disableBrowserGestures: true, + handlePosition: function( node ) { + return 'middle top'; // sets the position of the handle in the format of "X-AXIS Y-AXIS" such as "left top", "middle top" + }, + handleInDrawMode: false, // whether to show the handle in draw mode + edgeType: function( sourceNode, targetNode ) { + // can return 'flat' for flat edges between nodes or 'node' for intermediate node between them + // returning null/undefined means an edge can't be added between the two nodes + return 'flat'; + }, + loopAllowed: function( node ) { + // for the specified node, return whether edges from itself to itself are allowed + return false; + }, + nodeLoopOffset: -50, // offset for edgeType: 'node' loops + nodeParams: function( sourceNode, targetNode ) { + // for edges between the specified source and target + // return element object to be passed to cy.add() for intermediary node + return {}; + }, + edgeParams: function( sourceNode, targetNode, i ) { + // for edges between the specified source and target + // return element object to be passed to cy.add() for edge + // NB: i indicates edge index in case of edgeType: 'node' + return {}; + }, + ghostEdgeParams: function() { + // return element object to be passed to cy.add() for the ghost edge + // (default classes are always added for you) + return {}; + } + }; + + constructor() {} +} diff --git a/client/src/app/dashboard/graph-view/connect-dialog/connect-dialog.component.css b/client/src/app/dashboard/graph-view/connect-dialog/connect-dialog.component.css new file mode 100644 index 00000000..e69de29b diff --git a/client/src/app/dashboard/graph-view/connect-dialog/connect-dialog.component.html b/client/src/app/dashboard/graph-view/connect-dialog/connect-dialog.component.html new file mode 100644 index 00000000..fa78d476 --- /dev/null +++ b/client/src/app/dashboard/graph-view/connect-dialog/connect-dialog.component.html @@ -0,0 +1,8 @@ +

Reconnect Components

+
+

Do you really want to disconnect {{data.nameOne}} from {{data.nameTwo}} and connect it to {{data.nameThree}}?

+
+
+ + +
\ No newline at end of file diff --git a/client/src/app/dashboard/graph-view/connect-dialog/connect-dialog.component.spec.ts b/client/src/app/dashboard/graph-view/connect-dialog/connect-dialog.component.spec.ts new file mode 100644 index 00000000..a34a2571 --- /dev/null +++ b/client/src/app/dashboard/graph-view/connect-dialog/connect-dialog.component.spec.ts @@ -0,0 +1,32 @@ +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; + +import { ConnectDialogComponent } from './connect-dialog.component'; +import { MaterialModule } from 'src/app/material-module/material.module'; +import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material'; + +describe('ConnectDialogComponent', () => { + let component: ConnectDialogComponent; + let fixture: ComponentFixture; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + declarations: [ ConnectDialogComponent ], + imports: [MaterialModule], + providers: [ { + provide: MAT_DIALOG_DATA, + useValue: {} + }] + }) + .compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(ConnectDialogComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/client/src/app/dashboard/graph-view/connect-dialog/connect-dialog.component.ts b/client/src/app/dashboard/graph-view/connect-dialog/connect-dialog.component.ts new file mode 100644 index 00000000..26959f47 --- /dev/null +++ b/client/src/app/dashboard/graph-view/connect-dialog/connect-dialog.component.ts @@ -0,0 +1,16 @@ +import { Component, OnInit, Inject } from '@angular/core'; +import { MAT_DIALOG_DATA } from '@angular/material'; + +@Component({ + selector: 'app-connect-dialog', + templateUrl: './connect-dialog.component.html', + styleUrls: ['./connect-dialog.component.css'] +}) +export class ConnectDialogComponent implements OnInit { + + constructor(@Inject(MAT_DIALOG_DATA) public data: {nameOne: string, nameTwo: string, nameThree: string}) { } + + ngOnInit() { + } + +} diff --git a/client/src/app/dashboard/graph-view/graph-view.module.ts b/client/src/app/dashboard/graph-view/graph-view.module.ts index 2b52bf76..59e8e455 100755 --- a/client/src/app/dashboard/graph-view/graph-view.module.ts +++ b/client/src/app/dashboard/graph-view/graph-view.module.ts @@ -3,15 +3,21 @@ import {CommonModule} from '@angular/common'; import {GraphViewComponent} from './graph-view/graph-view.component'; import {GraphViewService} from './graph-view.service'; import {ModelModule} from '../../model/model.module'; +import { ConnectDialogComponent } from './connect-dialog/connect-dialog.component'; +import { MaterialModule } from 'src/app/material-module/material.module'; @NgModule({ - declarations: [GraphViewComponent], + declarations: [GraphViewComponent, ConnectDialogComponent], imports: [ CommonModule, + MaterialModule, ModelModule ], exports: [GraphViewComponent], - providers: [GraphViewService] + providers: [GraphViewService], + entryComponents: [ + ConnectDialogComponent + ] }) export class GraphViewModule { } diff --git a/client/src/app/dashboard/graph-view/graph-view.service.ts b/client/src/app/dashboard/graph-view/graph-view.service.ts index 906d44f0..b643647e 100755 --- a/client/src/app/dashboard/graph-view/graph-view.service.ts +++ b/client/src/app/dashboard/graph-view/graph-view.service.ts @@ -4,7 +4,9 @@ import { Instance } from 'src/app/model/models/instance'; import { BehaviorSubject, Observable, Subject} from 'rxjs'; import * as cytoscape from 'cytoscape'; import { InstanceLink } from 'src/app/model/models/instanceLink'; -import { Actions } from 'src/app/model/store.service'; +import { Actions, StoreService } from 'src/app/model/store.service'; +import { GraphConfig } from './GraphConfig'; +import { ApiService } from 'src/app/api/api/api.service'; interface NodeEdgeMap { nodes: Array; @@ -21,26 +23,33 @@ const TYPE_TO_IMG = { 'Failed' : '../../../assets/images/crawler-failed.png', 'Stopped' : '../../../assets/images/crawler-stopped.png', 'Paused' : '../../../assets/images/crawler-paused.png', - 'NotReachable' : '../../../assets/images/crawler-failed.png' }, + 'NotReachable' : '../../../assets/images/crawler-failed.png', + 'Unknown' : '../../../assets/images/crawler.png', + }, 'WebApp': { 'Running': '../../../assets/images/webapp-running.png', 'Failed' : '../../../assets/images/webapp-failed.png', 'Stopped' : '../../../assets/images/webapp-stopped.png', 'Paused' : '../../../assets/images/webapp-paused.png', - 'NotReachable' : '../../../assets/images/webapp-failed.png' }, + 'NotReachable' : '../../../assets/images/webapp-failed.png', + 'Unknown' : '../../../assets/images/webapp.png', }, 'WebApi': { 'Running': '../../../assets/images/webapi-running.png', 'Failed' : '../../../assets/images/webapi-failed.png', 'Stopped' : '../../../assets/images/webapi-stopped.png', 'Paused' : '../../../assets/images/webapi-paused.png', - 'NotReachable' : '../../../assets/images/webapi-failed.png' }, + 'NotReachable' : '../../../assets/images/webapi-failed.png', + 'Unknown' : '../../../assets/images/webapi.png', }, 'ElasticSearch': { 'Running': '../../../assets/images/elasticsearch-running.png', 'Failed' : '../../../assets/images/elasticsearch-failed.png', 'Stopped' : '../../../assets/images/elasticsearch-stopped.png', 'Paused' : '../../../assets/images/elasticsearch-paused.png', - 'NotReachable' : '../../../assets/images/elasticsearch-failed.png' }, + 'NotReachable' : '../../../assets/images/elasticsearch-failed.png', + 'Unknown' : '../../../assets/images/elasticsearch.png'} , }; + + @Injectable({ providedIn: 'root' }) @@ -48,7 +57,7 @@ export class GraphViewService { private elementProvider: BehaviorSubject; private elementRemover: Subject>; - constructor(private modelService: ModelService) { + constructor(private modelService: ModelService, private apiService: ApiService, private storeService: StoreService) { this.elementProvider = new BehaviorSubject({type: Actions.NONE, elements: []}); this.elementRemover = new BehaviorSubject>([]); @@ -64,6 +73,13 @@ export class GraphViewService { }); } + public reconnect(from: string, to: string) { + console.log('trying to reconnect', from, to); + this.apiService.postReconnect(Number(from), Number(to)).subscribe((res) => { + console.log('reconnect returned with result', res); + }); + } + private removeElements(instances: Array) { const ids = instances.map((value: Instance) => '' + value.id); this.elementRemover.next(ids); @@ -79,13 +95,17 @@ export class GraphViewService { private createCytoscapeElements(instances: Array): Array { const newElements = instances.reduce( ( accumulator: NodeEdgeMap, value: Instance) => { - + let img = TYPE_TO_IMG[value.componentType][value.instanceState]; + if (img === undefined) { + img = TYPE_TO_IMG[value.componentType]['Unknown']; + } accumulator.nodes.push({ group: 'nodes', data: { id: '' + value.id, + type: value.componentType, name: value.name, - image: TYPE_TO_IMG[value.componentType][value.instanceState], + image: img, status: value.instanceState } }); @@ -107,16 +127,22 @@ export class GraphViewService { return edges; } + public getGraphConfig() { + return new GraphConfig(); + } + public getElementObservable(): Observable { return new Observable((observer) => { + // calculate init value + const allInstances = Object.values(this.storeService.getState().instances); + console.log('all instances', allInstances); + const cyElements = this.createCytoscapeElements(allInstances); + observer.next({type: Actions.ADD, elements: cyElements}); this.elementProvider.subscribe(observer); - observer.next(this.elementProvider.value); }); } public getElementRemoveObservable(): Observable> { - return new Observable((observer) => { - this.elementRemover.subscribe(observer); - }); + return this.elementRemover.asObservable(); } } diff --git a/client/src/app/dashboard/graph-view/graph-view/graph-view.component.spec.ts b/client/src/app/dashboard/graph-view/graph-view/graph-view.component.spec.ts index a672c428..faf40d1f 100755 --- a/client/src/app/dashboard/graph-view/graph-view/graph-view.component.spec.ts +++ b/client/src/app/dashboard/graph-view/graph-view/graph-view.component.spec.ts @@ -4,6 +4,9 @@ import {GraphViewComponent} from './graph-view.component'; import { ApiModule } from 'src/app/api/api.module'; import { HttpClientModule } from '@angular/common/http'; import { HttpClientTestingModule } from '@angular/common/http/testing'; +import { ConnectDialogComponent } from '../connect-dialog/connect-dialog.component'; +import { BrowserDynamicTestingModule } from '@angular/platform-browser-dynamic/testing'; +import { MaterialModule } from 'src/app/material-module/material.module'; describe('GraphViewComponent', () => { let component: GraphViewComponent; @@ -12,9 +15,15 @@ describe('GraphViewComponent', () => { beforeEach(async(() => { TestBed.configureTestingModule({ declarations: [GraphViewComponent], - imports: [HttpClientTestingModule, HttpClientModule, ApiModule] + imports: [HttpClientTestingModule, HttpClientModule, ApiModule, MaterialModule] }) .compileComponents(); + + TestBed.overrideModule(BrowserDynamicTestingModule, { + set: { + entryComponents: [ConnectDialogComponent], + } + }); })); beforeEach(() => { diff --git a/client/src/app/dashboard/graph-view/graph-view/graph-view.component.ts b/client/src/app/dashboard/graph-view/graph-view/graph-view.component.ts index c5090ebc..ab0966b1 100755 --- a/client/src/app/dashboard/graph-view/graph-view/graph-view.component.ts +++ b/client/src/app/dashboard/graph-view/graph-view/graph-view.component.ts @@ -1,65 +1,51 @@ -import {Component, ElementRef, OnInit, ViewChild} from '@angular/core'; +import {Component, ElementRef, OnInit, ViewChild, OnDestroy} from '@angular/core'; import * as cytoscape from 'cytoscape'; +import edgehandles from 'cytoscape-edgehandles'; import {GraphViewService, ElementUpdate} from '../graph-view.service'; import { Actions } from 'src/app/model/store.service'; import { LinkStateEnum } from 'src/app/model/models/instanceLink'; +import { MatDialog } from '@angular/material'; +import { ConnectDialogComponent } from '../connect-dialog/connect-dialog.component'; +import { ComponentTypeEnum, ComponentType } from 'src/app/model/models/instance'; +import { GraphConfig } from '../GraphConfig'; +import { Subscription } from 'rxjs'; @Component({ selector: 'app-graph-view', templateUrl: './graph-view.component.html', styleUrls: ['./graph-view.component.css'] }) -export class GraphViewComponent implements OnInit { +export class GraphViewComponent implements OnInit, OnDestroy { @ViewChild('cy') cyDiv: ElementRef; private cy: cytoscape.Core; - private readonly layout: cytoscape.LayoutOptions; - - constructor(private graphViewService: GraphViewService) { - this.layout = { - name: 'grid', - fit: true, // whether to fit the viewport to the graph - padding: 30, // padding used on fit - boundingBox: undefined, // constrain layout bounds; { x1, y1, x2, y2 } or { x1, y1, w, h } - avoidOverlap: true, // prevents node overlap, may overflow boundingBox if not enough space - avoidOverlapPadding: 10, // extra spacing around nodes when avoidOverlap: true - nodeDimensionsIncludeLabels: false, // Excludes the label when calculating node bounding boxes for the layout algorithm - spacingFactor: undefined, // Applies a multiplicative factor (>0) to expand or compress the overall area that the nodes take up - condense: false, // uses all available space on false, uses minimal space on true - rows: undefined, // force num of rows in the grid - cols: undefined, // force num of columns in the grid - position: function( node ) {}, // returns { row, col } for element - sort: undefined, // a sorting function to order the nodes; e.g. function(a, b){ return a.data('weight') - b.data('weight') } - animate: false, // whether to transition the node positions - animationDuration: 500, // duration of animation in ms if enabled - animationEasing: undefined, // easing of animation if enabled - ready: undefined, // callback on layoutready - stop: undefined, // callback on layoutstop - }; + private config: GraphConfig; + private elementSubscription: Subscription; + private elementRemoveSubscription: Subscription; + + constructor(private graphViewService: GraphViewService, public dialog: MatDialog) { + } ngOnInit() { - this.cy = cytoscape({ - container: this.cyDiv.nativeElement, // container to render in - elements: [ // list of graph elements to start with - ], - layout: this.layout, - style: [{ - selector: 'node', - style: { - label: 'data(name)', - 'background-image': 'data(image)', - 'width': '70%', - 'height': '70%', - 'background-opacity': 0, - 'background-fit': 'contain', - 'background-clip': 'none', - } - }] - }); + this.configureCytoscape(); + this.addEdgeDragListener(); + this.addChangeListener(); + } - this.graphViewService.getElementObservable().subscribe((update: ElementUpdate) => { + ngOnDestroy() { + this.cy.destroy(); + this.elementSubscription.unsubscribe(); + this.elementRemoveSubscription.unsubscribe(); + } + + /** + * Adds listeners to the graphViewService and describe how the + * view should be updated if it receives any changes / updates. + */ + private addChangeListener() { + this.elementSubscription = this.graphViewService.getElementObservable().subscribe((update: ElementUpdate) => { if (update.elements) { if (update.type === Actions.ADD) { this.cy.add(update.elements); @@ -68,7 +54,7 @@ export class GraphViewComponent implements OnInit { this.updateElements(update.elements); } } - this.cy.edges().style('line-color', function (ele: any) { + this.cy.edges().style('line-color', function (ele: cytoscape.EdgeSingular) { const status = ele.data('status'); switch (status) { case LinkStateEnum.Assigned: return 'green'; @@ -77,12 +63,12 @@ export class GraphViewComponent implements OnInit { default: return 'orange'; } }); - const layout = this.cy.layout(this.layout); + const layout = this.cy.layout(this.config.layout); layout.run(); } }); - this.graphViewService.getElementRemoveObservable().subscribe((ids: Array) => { + this.elementRemoveSubscription = this.graphViewService.getElementRemoveObservable().subscribe((ids: Array) => { if (ids) { for (let i = 0; i < ids.length; i++) { this.cy.remove(this.cy.getElementById(ids[i])); @@ -91,13 +77,98 @@ export class GraphViewComponent implements OnInit { }); } + /** + * Configures the components behavior when an edge is draged from one + * node to another. + */ + private addEdgeDragListener() { + let removedElements: cytoscape.CollectionReturnValue; + + (this.cy as any).on('ehstop', (event: any, sourceNode: cytoscape.NodeSingular) => { + this.cy.add(removedElements); + }); + + (this.cy as any).on('ehcomplete', + (event: any, sourceNode: cytoscape.NodeSingular, targetNode: cytoscape.NodeSingular, addedEles: any) => { + + const edgesSource = sourceNode.connectedEdges(); + + const nodeToDisconnect = this.getNodeToDisconnect(edgesSource, sourceNode, targetNode); + const nodeName = nodeToDisconnect.reduce((prevVal, ele) => { + return ele.data('name'); + }, ''); + const dialogRef = this.dialog.open(ConnectDialogComponent, { data: { + nameOne: sourceNode.data('name'), + nameTwo: nodeName, + nameThree: targetNode.data('name')}}); + dialogRef.afterClosed().subscribe((reconnect: boolean) => { + if (reconnect) { + this.graphViewService.reconnect(sourceNode.data('id'), targetNode.data('id')); + } + this.cy.remove(addedEles); + }); + }); + + (this.cy as any).on('ehstart', (event: any, sourceNode: cytoscape.NodeSingular) => { + + const allElesToHide = this.getCorrespondingEles(sourceNode); + console.log('all eles to hide', allElesToHide); + // we want to show the source node. + if (allElesToHide.length > 0) { + const elesToHide = allElesToHide.symmetricDifference(sourceNode); + console.log('eles to actually hide', elesToHide); + removedElements = elesToHide.remove(); + } + + }); + } + + private getNodeToDisconnect(edgeList: cytoscape.EdgeCollection, sourceNode: cytoscape.NodeSingular, + targetNode: cytoscape.NodeSingular ): cytoscape.NodeSingular { + console.log('edge list', edgeList); + const nodes = edgeList.connectedNodes(); + console.log('connected nodes', nodes); + const correspondingEles = this.getCorrespondingEles(sourceNode, nodes); + + const actualElement = nodes.symmetricDifference(correspondingEles).symmetricDifference(targetNode); + if (actualElement.length > 1) { + console.log('invalid element:', actualElement); + throw new Error('Invalid node collection returned'); + } + + return actualElement; + } + + private getCorrespondingEles(node: cytoscape.NodeSingular, eles?: cytoscape.NodeCollection): cytoscape.NodeCollection { + const type = node.data('type'); + let result: cytoscape.NodeCollection = this.cy.collection(); + switch (type) { + case ComponentTypeEnum.WebApi: + result = this.getElementsWithDifferentType(ComponentTypeEnum.ElasticSearch, eles); + break; + case ComponentTypeEnum.WebApp: + result = this.getElementsWithDifferentType(ComponentTypeEnum.WebApi, eles); + break; + case ComponentTypeEnum.Crawler: + result = this.getElementsWithDifferentType(ComponentTypeEnum.ElasticSearch, eles); + break; + } + return result; + } + /** + * Returns all elements to hide except those + * of the given @param type. + */ + private getElementsWithDifferentType(type: ComponentType, eles?: cytoscape.NodeCollection): cytoscape.NodeCollection { + console.log('type', type); + console.log('eles', eles); + return eles ? eles.nodes('node[type !="' + type + '"]') : this.cy.nodes('node[type !="' + type + '"]'); + } + private updateElements(elements: Array) { - console.log('trying to update elements', elements); for (const element of elements) { - console.log('element', element); // if element with id is not in cytoscape just add it const cyElement = this.cy.getElementById(element.data.id); - console.log('cyElement', cyElement); if (cyElement.length === 0) { this.cy.add(element); } else { // else get the element and udpate it's data field @@ -106,4 +177,19 @@ export class GraphViewComponent implements OnInit { } } + + /** + * Initializes cytoscape and registers the edge drag and drop extension. + */ + private configureCytoscape() { + this.config = this.graphViewService.getGraphConfig(); + this.config.cytoscapeConfig.container = this.cyDiv.nativeElement; + + this.cy = cytoscape(this.config.cytoscapeConfig); + if (!Object.getPrototypeOf(this.cy).edgehandles) { + cytoscape.use(edgehandles); + } + + (this.cy as any).edgehandles(this.config.edgeDragConfig); + } } diff --git a/client/src/assets/images/EdgeConnector.png b/client/src/assets/images/EdgeConnector.png new file mode 100644 index 00000000..b4dc48a5 Binary files /dev/null and b/client/src/assets/images/EdgeConnector.png differ