Merge pull request #90 from GNS3/selection

Select nodes on topology, Ref. #16
This commit is contained in:
ziajka 2018-03-26 10:27:43 +02:00 committed by GitHub
commit b033b7b9b3
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
63 changed files with 1507 additions and 299 deletions

View File

@ -36,10 +36,10 @@ jobs:
brew --config
brew upgrade python
python -V
pip install -r scripts/requirements.txt
python scripts/build.py download
python scripts/build.py build_exe -b dist/exe.gns3server -s
python scripts/build.py validate -b dist
pip3 install -r scripts/requirements.txt
python3 scripts/build.py download
python3 scripts/build.py build_exe -b dist/exe.gns3server -s
python3 scripts/build.py validate -b dist
- run:
name: Dist project

View File

@ -53,6 +53,12 @@ import { ApplianceListDialogComponent } from './appliance/appliance-list-dialog/
import { NodeSelectInterfaceComponent } from './shared/node-select-interface/node-select-interface.component';
import { CartographyModule } from './cartography/cartography.module';
import { ToasterService } from './shared/services/toaster.service';
import {ProjectWebServiceHandler} from "./shared/handlers/project-web-service-handler";
import {LinksDataSource} from "./cartography/shared/datasources/links-datasource";
import {NodesDataSource} from "./cartography/shared/datasources/nodes-datasource";
import {SymbolsDataSource} from "./cartography/shared/datasources/symbols-datasource";
import {SelectionManager} from "./cartography/shared/managers/selection-manager";
import {InRectangleHelper} from "./cartography/map/helpers/in-rectangle-helper";
@NgModule({
@ -108,7 +114,13 @@ import { ToasterService } from './shared/services/toaster.service';
HttpServer,
SnapshotService,
ProgressDialogService,
ToasterService
ToasterService,
ProjectWebServiceHandler,
LinksDataSource,
NodesDataSource,
SymbolsDataSource,
SelectionManager,
InRectangleHelper
],
entryComponents: [
AddServerDialogComponent,

View File

@ -68,9 +68,11 @@ export class ApplianceDatabase {
}
constructor(private server: Server, private applianceService: ApplianceService) {
this.applianceService.list(this.server).subscribe((appliances: Appliance[]) => {
this.dataChange.next(appliances);
});
this.applianceService
.list(this.server)
.subscribe((appliances) => {
this.dataChange.next(appliances);
});
}
};

View File

@ -3,6 +3,7 @@ import {MatDialog} from "@angular/material";
import {ApplianceListDialogComponent} from "./appliance-list-dialog/appliance-list-dialog.component";
import {Server} from "../shared/models/server";
import {Appliance} from "../shared/models/appliance";
@Component({
selector: 'app-appliance',
@ -26,7 +27,7 @@ export class ApplianceComponent implements OnInit {
}
});
dialogRef.afterClosed().subscribe((appliance: AppendMode) => {
dialogRef.afterClosed().subscribe((appliance: Appliance) => {
if (appliance !== null) {
this.onNodeCreation.emit(appliance);
}

View File

@ -0,0 +1,11 @@
import {Selectable} from "../../shared/managers/selection-manager";
import {Rectangle} from "../../shared/models/rectangle";
import {Injectable} from "@angular/core";
@Injectable()
export class InRectangleHelper {
public inRectangle(item: Selectable, rectangle: Rectangle): boolean {
return (rectangle.x <= item.x && item.x < (rectangle.x + rectangle.width)
&& rectangle.y <= item.y && item.y < (rectangle.y + rectangle.height));
}
}

View File

@ -1,4 +1,4 @@
import {Link} from "../../shared/models/link.model";
import {Link} from "../../shared/models/link";
export class MultiLinkCalculatorHelper {
LINK_WIDTH = 2;

View File

@ -2,7 +2,4 @@ svg {
display: block;
}
image.over {
fill: #000;
}

View File

@ -4,13 +4,13 @@ import {
import { D3, D3Service } from 'd3-ng2-service';
import {select, Selection} from 'd3-selection';
import { Node } from "../shared/models/node.model";
import { Link } from "../shared/models/link.model";
import { Node } from "../shared/models/node";
import { Link } from "../shared/models/link";
import { GraphLayout } from "../shared/widgets/graph.widget";
import { Context } from "../../map/models/context";
import { Size } from "../shared/models/size.model";
import { Drawing } from "../shared/models/drawing.model";
import {Symbol} from "../../shared/models/symbol";
import { Context } from "../shared/models/context";
import { Size } from "../shared/models/size";
import { Drawing } from "../shared/models/drawing";
import {Symbol} from "../shared/models/symbol";
@Component({
@ -68,9 +68,7 @@ export class MapComponent implements OnInit, OnChanges, OnDestroy {
}
ngOnDestroy() {
if (this.svg.empty && !this.svg.empty()) {
this.svg.selectAll('*').remove();
}
this.graphLayout.disconnect(this.svg);
}
ngOnInit() {
@ -78,14 +76,12 @@ export class MapComponent implements OnInit, OnChanges, OnDestroy {
let rootElement: Selection<HTMLElement, any, null, undefined>;
const self = this;
if (this.parentNativeElement !== null) {
rootElement = d3.select(this.parentNativeElement);
this.svg = rootElement.select<SVGSVGElement>('svg');
this.graphContext = new Context(this.svg);
this.graphContext = new Context(true);
if (this.windowFullSize) {
this.graphContext.setSize(this.getSize());
@ -94,6 +90,7 @@ export class MapComponent implements OnInit, OnChanges, OnDestroy {
}
this.graphLayout = new GraphLayout();
this.graphLayout.connect(this.svg, this.graphContext);
this.graphLayout.getNodesWidget().addOnNodeDraggingCallback((event: any, n: Node) => {
const linksWidget = this.graphLayout.getLinksWidget();
@ -131,8 +128,6 @@ export class MapComponent implements OnInit, OnChanges, OnDestroy {
.attr('height', this.graphContext.getSize().height);
}
this.graphLayout.setNodes(this.nodes);
this.graphLayout.setLinks(this.links);
this.graphLayout.setDrawings(this.drawings);
@ -155,6 +150,11 @@ export class MapComponent implements OnInit, OnChanges, OnDestroy {
if (target_id in nodes_by_id) {
link.target = nodes_by_id[target_id];
}
if (link.source && link.target) {
link.x = link.source.x + (link.target.x - link.source.x) * 0.5;
link.y = link.source.y + (link.target.y - link.source.y) * 0.5;
}
});
}

View File

@ -0,0 +1,73 @@
import {DataSource} from "./datasource";
class Item {
constructor(public id: string, public property1?: string, public property2?: string) {}
}
class TestDataSource extends DataSource<Item> {
protected findIndex(item: Item) {
return this.data.findIndex((i: Item) => i.id === item.id);
}
};
describe('TestDataSource', () => {
let dataSource: TestDataSource;
let data: Item[];
beforeEach(() => {
dataSource = new TestDataSource();
dataSource.connect().subscribe((updated: Item[]) => {
data = updated;
});
});
describe('Item can be added', () => {
beforeEach(() => {
dataSource.add(new Item("test1", "property1"));
});
it('item should be in data', () => {
expect(data).toEqual([new Item("test1", "property1")]);
});
});
describe('Items can be set', () => {
beforeEach(() => {
dataSource.set([new Item("test1", "property1"), new Item("test2", "property2")]);
});
it('items should be in data', () => {
expect(data).toEqual([new Item("test1", "property1"), new Item("test2", "property2")]);
});
});
describe('Items can be removed', () => {
beforeEach(() => {
dataSource.set([new Item("test1", "property1"), new Item("test2", "property2")]);
dataSource.remove(new Item("test1", "property1"));
});
it('item should not be in data', () => {
expect(data).toEqual([new Item("test2", "property2")]);
});
});
describe('Item can be updated', () => {
beforeEach(() => {
dataSource.set([new Item("test1", "property1", "another"), new Item("test2", "property2")]);
dataSource.update(new Item("test1", "property3"));
});
it('item should be updated', () => {
expect(data).toEqual([
new Item("test1", "property3"),
new Item("test2", "property2")
]);
});
});
});

View File

@ -0,0 +1,42 @@
import {BehaviorSubject} from "rxjs/BehaviorSubject";
export abstract class DataSource<T> {
protected data: T[] = [];
protected dataChange: BehaviorSubject<T[]> = new BehaviorSubject<T[]>([]);
public getItems(): T[] {
return this.data;
}
public add(item: T) {
this.data.push(item);
this.dataChange.next(this.data);
}
public set(data: T[]) {
this.data = data;
this.dataChange.next(this.data);
}
public update(item: T) {
const index = this.findIndex(item);
if (index >= 0) {
this.data[index] = Object.assign(this.data[index], item);
this.dataChange.next(this.data);
}
}
public remove(item: T) {
const index = this.findIndex(item);
if (index >= 0) {
this.data.splice(index, 1);
this.dataChange.next(this.data);
}
}
public connect() {
return this.dataChange;
}
protected abstract findIndex(item: T): number;
}

View File

@ -0,0 +1,33 @@
import {LinksDataSource} from "./links-datasource";
import {Link} from "../models/link";
describe('LinksDataSource', () => {
let dataSource: LinksDataSource;
let data: Link[];
beforeEach(() => {
dataSource = new LinksDataSource();
dataSource.connect().subscribe((links: Link[]) => {
data = links;
});
});
describe('Link can be updated', () => {
beforeEach(() => {
const link = new Link();
link.link_id = "1";
link.project_id = "project-1";
dataSource.add(link);
link.project_id = "project-2";
dataSource.update(link);
});
it('project_id should change', () => {
expect(data[0].link_id).toEqual("1");
expect(data[0].project_id).toEqual("project-2");
});
});
});

View File

@ -0,0 +1,12 @@
import {Injectable} from "@angular/core";
import {DataSource} from "./datasource";
import {Link} from "../models/link";
@Injectable()
export class LinksDataSource extends DataSource<Link> {
protected findIndex(link: Link) {
return this.data.findIndex((l: Link) => l.link_id === link.link_id);
}
}

View File

@ -0,0 +1,33 @@
import {NodesDataSource} from "./nodes-datasource";
import {Node} from "../models/node";
describe('NodesDataSource', () => {
let dataSource: NodesDataSource;
let data: Node[];
beforeEach(() => {
dataSource = new NodesDataSource();
dataSource.connect().subscribe((nodes: Node[]) => {
data = nodes;
});
});
describe('Node can be updated', () => {
beforeEach(() => {
const node = new Node();
node.node_id = "1";
node.name = "Node 1";
dataSource.add(node);
node.name = "Node 2";
dataSource.update(node);
});
it('name should change', () => {
expect(data[0].node_id).toEqual("1");
expect(data[0].name).toEqual("Node 2");
});
});
});

View File

@ -0,0 +1,11 @@
import {Node} from "../models/node";
import {DataSource} from "./datasource";
import {Injectable} from "@angular/core";
@Injectable()
export class NodesDataSource extends DataSource<Node> {
protected findIndex(node: Node) {
return this.data.findIndex((n: Node) => n.node_id === node.node_id);
}
}

View File

@ -0,0 +1,33 @@
import {SymbolsDataSource} from "./symbols-datasource";
import {Symbol} from "../models/symbol";
describe('SymbolsDataSource', () => {
let dataSource: SymbolsDataSource;
let data: Symbol[];
beforeEach(() => {
dataSource = new SymbolsDataSource();
dataSource.connect().subscribe((symbols: Symbol[]) => {
data = symbols;
});
});
describe('Symbol can be updated', () => {
beforeEach(() => {
const symbol = new Symbol();
symbol.symbol_id = "1";
symbol.filename = "test-1";
dataSource.add(symbol);
symbol.filename = "test-2";
dataSource.update(symbol);
});
it('filename should change', () => {
expect(data[0].symbol_id).toEqual("1");
expect(data[0].filename).toEqual("test-2");
});
});
});

View File

@ -0,0 +1,12 @@
import {Node} from "../models/node";
import {DataSource} from "./datasource";
import {Injectable} from "@angular/core";
import {Symbol} from "../models/symbol";
@Injectable()
export class SymbolsDataSource extends DataSource<Symbol> {
protected findIndex(symbol: Symbol) {
return this.data.findIndex((s: Symbol) => s.symbol_id === symbol.symbol_id);
}
}

View File

@ -0,0 +1,76 @@
import { Subject} from "rxjs/Subject";
import { Node } from "../models/node";
import { Link } from "../models/link";
import { Rectangle } from "../models/rectangle";
import { SelectionManager } from "./selection-manager";
import { NodesDataSource } from "../datasources/nodes-datasource";
import { LinksDataSource } from "../datasources/links-datasource";
import { InRectangleHelper } from "../../map/helpers/in-rectangle-helper";
describe('SelectionManager', () => {
let manager: SelectionManager;
let selectedRectangleSubject: Subject<Rectangle>;
let nodesDataSource: NodesDataSource;
beforeEach(() => {
const linksDataSource = new LinksDataSource();
const inRectangleHelper = new InRectangleHelper();
selectedRectangleSubject = new Subject<Rectangle>();
nodesDataSource = new NodesDataSource();
manager = new SelectionManager(nodesDataSource, linksDataSource, inRectangleHelper);
manager.subscribe(selectedRectangleSubject);
const node_1 = new Node();
node_1.node_id = "test1";
node_1.name = "Node 1";
node_1.x = 150;
node_1.y = 150;
nodesDataSource.add(node_1);
const node_2 = new Node();
node_2.node_id = "test2";
node_2.name = "Node 2";
node_2.x = 300;
node_2.y = 300;
nodesDataSource.add(node_2);
const link_1 = new Link();
link_1.link_id = "test1";
linksDataSource.add(link_1);
});
it('node should be selected', () => {
selectedRectangleSubject.next(new Rectangle(100, 100, 100, 100));
expect(nodesDataSource.getItems()[0].is_selected).toEqual(true);
expect(manager.getSelectedNodes().length).toEqual(1);
expect(manager.getSelectedLinks().length).toEqual(0);
});
it('node should be selected and deselected', () => {
selectedRectangleSubject.next(new Rectangle(100, 100, 100, 100));
selectedRectangleSubject.next(new Rectangle(350, 350, 100, 100));
expect(nodesDataSource.getItems()[0].is_selected).toEqual(false);
expect(manager.getSelectedNodes().length).toEqual(0);
expect(manager.getSelectedLinks().length).toEqual(0);
});
it('nodes should be manually selected', () => {
const node = new Node();
node.node_id = "test1";
manager.setSelectedNodes([node]);
expect(manager.getSelectedNodes().length).toEqual(1);
});
it('links should be manually selected', () => {
const link = new Link();
link.link_id = "test1";
manager.setSelectedLinks([link]);
expect(manager.getSelectedLinks().length).toEqual(1);
});
});

View File

@ -0,0 +1,84 @@
import { Injectable } from "@angular/core";
import { Subject } from "rxjs/Subject";
import { Subscription } from "rxjs/Subscription";
import { NodesDataSource } from "../datasources/nodes-datasource";
import { LinksDataSource } from "../datasources/links-datasource";
import { Node } from "../models/node";
import { InRectangleHelper } from "../../map/helpers/in-rectangle-helper";
import { Rectangle } from "../models/rectangle";
import { Link} from "../models/link";
import { DataSource } from "../datasources/datasource";
export interface Selectable {
x: number;
y: number;
is_selected: boolean;
}
@Injectable()
export class SelectionManager {
private selectedNodes: Node[] = [];
private selectedLinks: Link[] = [];
private subscription: Subscription;
constructor(private nodesDataSource: NodesDataSource,
private linksDataSource: LinksDataSource,
private inRectangleHelper: InRectangleHelper) {}
public subscribe(subject: Subject<Rectangle>) {
this.subscription = subject.subscribe((rectangle: Rectangle) => {
this.selectedNodes = this.getSelectedItemsInRectangle<Node>(this.nodesDataSource, rectangle);
this.selectedLinks = this.getSelectedItemsInRectangle<Link>(this.linksDataSource, rectangle);
});
}
public getSelectedNodes() {
return this.selectedNodes;
}
public getSelectedLinks() {
return this.selectedLinks;
}
public setSelectedNodes(nodes: Node[]) {
this.selectedNodes = this.setSelectedItems<Node>(this.nodesDataSource, (node: Node) => {
return !!nodes.find((n: Node) => node.node_id === n.node_id);
});
}
public setSelectedLinks(links: Link[]) {
this.selectedLinks = this.setSelectedItems<Link>(this.linksDataSource, (link: Link) => {
return !!links.find((l: Link) => link.link_id === l.link_id);
});
}
private getSelectedItemsInRectangle<T extends Selectable>(dataSource: DataSource<T>, rectangle: Rectangle) {
return this.setSelectedItems<T>(dataSource, (item: T) => {
return this.inRectangleHelper.inRectangle(item, rectangle);
});
}
private setSelected<T extends Selectable>(item: T, isSelected: boolean, dataSource: DataSource<T>): boolean {
if (item.is_selected !== isSelected) {
item.is_selected = isSelected;
dataSource.update(item);
}
return item.is_selected;
}
private setSelectedItems<T extends Selectable>(dataSource: DataSource<T>, discriminator: (item: T) => boolean) {
const selected: T[] = [];
dataSource.getItems().forEach((item: T) => {
const isSelected = discriminator(item);
this.setSelected<T>(item, isSelected, dataSource);
if (isSelected) {
selected.push(item);
}
});
return selected;
}
}

View File

@ -0,0 +1,26 @@
import {Size} from "./size";
import {Point} from "./point";
export class Context {
private size: Size;
constructor(private centerZeroZeroPoint = false) {
this.size = new Size(0, 0);
}
public getSize(): Size {
return this.size;
}
public setSize(size: Size): void {
this.size = size;
}
public getZeroZeroTransformationPoint() {
if (this.centerZeroZeroPoint) {
return new Point(this.getSize().width / 2., this.getSize().height / 2.);
}
return new Point(0, 0);
}
}

View File

@ -1,4 +1,4 @@
import {Point} from "./point.model";
import {Point} from "./point";
export class DrawingLine {
start: Point;

View File

@ -1,6 +1,7 @@
import {Node} from "./node.model";
import {Node} from "./node";
import {Selectable} from "../managers/selection-manager";
export class Link {
export class Link implements Selectable {
capture_file_name: string;
capture_file_path: string;
capturing: boolean;
@ -12,4 +13,8 @@ export class Link {
length: number; // this is not from server
source: Node; // this is not from server
target: Node; // this is not from server
is_selected = false;
x: number;
y: number;
}

View File

@ -1,7 +1,8 @@
import {Label} from "./label.model";
import {Label} from "./label";
import {Port} from "../../../shared/models/port";
import {Selectable} from "../managers/selection-manager";
export class Node {
export class Node implements Selectable {
command_line: string;
compute_id: string;
console: number;
@ -24,4 +25,5 @@ export class Node {
x: number;
y: number;
z: number;
is_selected = false;
}

View File

@ -0,0 +1,8 @@
export class Rectangle {
constructor(
public x?: number,
public y?: number,
public width?: number,
public height?: number
) {}
}

View File

@ -0,0 +1,7 @@
import {SVGSelection} from "./models/types";
export interface Tool {
connect(selection: SVGSelection);
activate();
deactivate();
}

View File

@ -0,0 +1,75 @@
import { select } from "d3-selection";
import { Context } from "../models/context";
import { SVGSelection } from "../models/types";
import { MovingTool } from "./moving-tool";
describe('MovingTool', () => {
let tool: MovingTool;
let svg: SVGSelection;
let context: Context;
let node: SVGSelection;
let canvas: SVGSelection;
beforeEach(() => {
tool = new MovingTool();
svg = select('body')
.append<SVGSVGElement>('svg')
.attr('width', 1000)
.attr('height', 1000);
canvas = svg.append<SVGGElement>('g').attr('class', 'canvas');
node = canvas
.append<SVGGElement>('g')
.attr('class', 'node')
.attr('x', 10)
.attr('y', 20);
context = new Context();
tool.connect(svg, context);
tool.draw(svg, context);
tool.activate();
});
describe('MovingTool can move canvas', () => {
beforeEach(() => {
svg.node().dispatchEvent(
new MouseEvent('mousedown', {
clientX: 100, clientY: 100, relatedTarget: svg.node(),
screenY: 1024, screenX: 1024, view: window
})
);
window.dispatchEvent(new MouseEvent('mousemove', {clientX: 200, clientY: 200}));
window.dispatchEvent(new MouseEvent('mouseup', {clientX: 200, clientY: 200, view: window}));
});
it('canvas should transformed', () => {
expect(canvas.attr('transform')).toEqual('translate(100, 100) scale(1)');
});
});
describe('MovingTool can be deactivated', () => {
beforeEach(() => {
tool.deactivate();
svg.node().dispatchEvent(
new MouseEvent('mousedown', {
clientX: 100, clientY: 100, relatedTarget: svg.node(),
screenY: 1024, screenX: 1024, view: window
})
);
window.dispatchEvent(new MouseEvent('mousemove', {clientX: 200, clientY: 200}));
});
it('canvas cannot be transformed', () => {
expect(canvas.attr('transform')).toBeNull();
});
});
});

View File

@ -0,0 +1,49 @@
import {SVGSelection} from "../models/types";
import {Context} from "../models/context";
import {D3ZoomEvent, zoom, ZoomBehavior} from "d3-zoom";
import { event } from "d3-selection";
export class MovingTool {
private selection: SVGSelection;
private context: Context;
private zoom: ZoomBehavior<SVGSVGElement, any>;
constructor() {
this.zoom = zoom<SVGSVGElement, any>()
.scaleExtent([1 / 2, 8]);
}
public connect(selection: SVGSelection, context: Context) {
this.selection = selection;
this.context = context;
}
public draw(selection: SVGSelection, context: Context) {
this.selection = selection;
this.context = context;
}
public activate() {
const self = this;
const onZoom = function(this: SVGSVGElement) {
const canvas = self.selection.select<SVGGElement>("g.canvas");
const e: D3ZoomEvent<SVGSVGElement, any> = event;
canvas.attr(
'transform',
`translate(${self.context.getSize().width / 2 + e.transform.x}, ` +
`${self.context.getSize().height / 2 + e.transform.y}) scale(${e.transform.k})`);
};
this.zoom.on('zoom', onZoom);
this.selection.call(this.zoom);
}
public deactivate() {
// d3.js preserves event `mousedown.zoom` and blocks selection
this.selection.on('mousedown.zoom', null);
this.zoom.on('zoom', null);
}
}

View File

@ -0,0 +1,120 @@
import { select } from "d3-selection";
import { SelectionTool } from "./selection-tool";
import { Context } from "../models/context";
import { SVGSelection } from "../models/types";
import { Rectangle } from "../models/rectangle";
describe('SelectionTool', () => {
let tool: SelectionTool;
let svg: SVGSelection;
let context: Context;
let selection_line_tool: SVGSelection;
let path_selection: SVGSelection;
let selected_rectangle: Rectangle;
beforeEach(() => {
tool = new SelectionTool();
tool.rectangleSelected.subscribe((rectangle: Rectangle) => {
selected_rectangle = rectangle;
});
svg = select('body')
.append<SVGSVGElement>('svg')
.attr('width', 1000)
.attr('height', 1000);
svg.append<SVGGElement>('g').attr('class', 'canvas');
context = new Context();
tool.connect(svg, context);
tool.draw(svg, context);
tool.activate();
selection_line_tool = svg.select('g.selection-line-tool');
path_selection = selection_line_tool.select('path.selection');
});
it('creates selection-line-tool container with path', () => {
expect(selection_line_tool.node()).not.toBeNull();
expect(selection_line_tool.select('path')).not.toBeNull();
expect(path_selection.attr('visibility')).toEqual('hidden');
});
describe('SelectionTool can handle start of selection', () => {
beforeEach(() => {
svg.node().dispatchEvent(new MouseEvent('mousedown', {clientX: 100, clientY: 100}));
});
it('path should be visible and have parameters', () => {
expect(path_selection.attr('visibility')).toEqual('visible');
expect(path_selection.attr('d')).toEqual('M95,86 l0,0 l0,0 l0,0z');
});
});
describe('SelectionTool can handle move of selection', () => {
beforeEach(() => {
svg.node().dispatchEvent(new MouseEvent('mousedown', {clientX: 100, clientY: 100}));
window.dispatchEvent(new MouseEvent('mousemove', {clientX: 300, clientY: 300}));
});
it('path should have got changed parameters', () => {
expect(path_selection.attr('d')).toEqual('M95,86 l200,0 l0,200 l-200,0z');
});
});
describe('SelectionTool can handle end of selection', () => {
beforeEach(() => {
svg.node().dispatchEvent(new MouseEvent('mousedown', {clientX: 100, clientY: 100}));
window.dispatchEvent(new MouseEvent('mousemove', {clientX: 200, clientY: 200}));
window.dispatchEvent(new MouseEvent('mouseup', {clientX: 200, clientY: 200}));
});
it('path should be hidden', () => {
expect(path_selection.attr('visibility')).toEqual('hidden');
});
it('rectangle should be selected', () => {
expect(selected_rectangle).toEqual(new Rectangle(95, 86, 100, 100));
});
describe('SelectionTool can deselect after click outside', () => {
beforeEach(() => {
svg.node().dispatchEvent(new MouseEvent('mousedown', {clientX: 300, clientY: 300}));
window.dispatchEvent(new MouseEvent('mouseup', {clientX: 300, clientY: 300}));
});
it('rectangle should be selected', () => {
expect(selected_rectangle).toEqual(new Rectangle(295, 286, 0, 0));
});
});
});
describe('SelectionTool can handle end of selection in reverse direction', () => {
beforeEach(() => {
svg.node().dispatchEvent(new MouseEvent('mousedown', {clientX: 200, clientY: 200}));
window.dispatchEvent(new MouseEvent('mousemove', {clientX: 100, clientY: 100}));
window.dispatchEvent(new MouseEvent('mouseup', {clientX: 100, clientY: 100}));
});
it('rectangle should be selected', () => {
expect(selected_rectangle).toEqual(new Rectangle(95, 86, 100, 100));
});
});
describe('SelectionTool can be deactivated', () => {
beforeEach(() => {
tool.deactivate();
svg.node().dispatchEvent(new MouseEvent('mousedown', {clientX: 100, clientY: 100}));
});
it('path should be still hiden', () => {
expect(path_selection.attr('visibility')).toEqual('hidden');
});
});
});

View File

@ -0,0 +1,112 @@
import { Injectable } from "@angular/core";
import { mouse, select } from "d3-selection";
import { Subject } from "rxjs/Subject";
import { SVGSelection } from "../models/types";
import { Context } from "../models/context";
import { Rectangle } from "../models/rectangle";
@Injectable()
export class SelectionTool {
static readonly SELECTABLE_CLASS = '.selectable';
public rectangleSelected: Subject<Rectangle>;
private selection: SVGSelection;
private path;
private context: Context;
public constructor() {
this.rectangleSelected = new Subject<Rectangle>();
}
public connect(selection: SVGSelection, context: Context) {
this.selection = selection;
this.context = context;
}
public activate() {
const self = this;
this.selection.on("mousedown", function() {
const subject = select(window);
const parent = this.parentElement;
const start = self.transformation(mouse(parent));
self.startSelection(start);
// clear selection
self.selection
.selectAll(SelectionTool.SELECTABLE_CLASS)
.classed("selected", false);
subject
.on("mousemove.selection", function() {
const end = self.transformation(mouse(parent));
self.moveSelection(start, end);
}).on("mouseup.selection", function() {
const end = self.transformation(mouse(parent));
self.endSelection(start, end);
subject
.on("mousemove.selection", null)
.on("mouseup.selection", null);
});
});
}
public deactivate() {
this.selection.on('mousedown', null);
}
public draw(selection: SVGSelection, context: Context) {
const canvas = selection.select<SVGGElement>("g.canvas");
if (!canvas.select<SVGGElement>("g.selection-line-tool").node()) {
const g = canvas.append<SVGGElement>('g');
g.attr("class", "selection-line-tool");
this.path = g.append("path");
this.path
.attr("class", "selection")
.attr("visibility", "hidden");
}
this.selection = selection;
}
private startSelection(start) {
this.path
.attr("d", this.rect(start[0], start[1], 0, 0))
.attr("visibility", "visible");
}
private moveSelection(start, move) {
this.path.attr("d", this.rect(start[0], start[1], move[0] - start[0], move[1] - start[1]));
this.selectedEvent(start, move);
}
private endSelection(start, end) {
this.path.attr("visibility", "hidden");
this.selectedEvent(start, end);
}
private selectedEvent(start, end) {
const x = Math.min(start[0], end[0]);
const y = Math.min(start[1], end[1]);
const width = Math.abs(start[0] - end[0]);
const height = Math.abs(start[1] - end[1]);
this.rectangleSelected.next(new Rectangle(x, y, width, height));
}
private rect(x: number, y: number, w: number, h: number) {
return "M" + [x, y] + " l" + [w, 0] + " l" + [0, h] + " l" + [-w, 0] + "z";
}
private transformation(point) {
const transformation_point = this.context.getZeroZeroTransformationPoint();
return [point[0] - transformation_point.x, point[1] - transformation_point.y];
}
}

View File

@ -1,8 +1,9 @@
import {DrawingLine} from "../models/drawing-line.model";
import {SVGSelection} from "../../../map/models/types";
import {Point} from "../models/point.model";
import {DrawingLine} from "../models/drawing-line";
import {SVGSelection} from "../models/types";
import {Point} from "../models/point";
import {line} from "d3-shape";
import {event, mouse, select} from "d3-selection";
import {mouse} from "d3-selection";
import {Context} from "../models/context";
export class DrawingLineWidget {
private drawingLine: DrawingLine = new DrawingLine();
@ -23,11 +24,11 @@ export class DrawingLineWidget {
const coordinates = mouse(node);
self.drawingLine.end.x = coordinates[0];
self.drawingLine.end.y = coordinates[1];
self.draw();
self.draw(null, null);
};
this.selection.on('mousemove', over);
this.draw();
this.draw(null, null);
}
public isDrawing() {
@ -37,19 +38,20 @@ export class DrawingLineWidget {
public stop() {
this.drawing = false;
this.selection.on('mousemove', null);
this.draw();
this.draw(null, null);
return this.data;
}
public connect(selection: SVGSelection) {
public connect(selection: SVGSelection, context: Context) {
this.selection = selection;
}
public draw(selection: SVGSelection, context: Context) {
const canvas = this.selection.select<SVGGElement>("g.canvas");
if (!canvas.select<SVGGElement>("g.drawing-line-tool").node()) {
canvas.append<SVGGElement>('g').attr("class", "drawing-line-tool");
}
}
public draw() {
let link_data = [];
if (this.drawing) {

View File

@ -1,6 +1,6 @@
import {Widget} from "./widget";
import {Drawing} from "../models/drawing.model";
import {SVGSelection} from "../../../map/models/types";
import {Drawing} from "../models/drawing";
import {SVGSelection} from "../models/types";
export class DrawingsWidget implements Widget {

View File

@ -1,8 +1,8 @@
import {Widget} from "./widget";
import {SVGSelection} from "../../../map/models/types";
import {SVGSelection} from "../models/types";
import { line } from "d3-shape";
import {Link} from "../models/link.model";
import {Link} from "../models/link";
export class EthernetLinkWidget implements Widget {
@ -15,6 +15,7 @@ export class EthernetLinkWidget implements Widget {
const value_line = line();
let link_path = view.select<SVGPathElement>('path');
link_path.classed('selected', (l: Link) => l.is_selected);
if (!link_path.node()) {
link_path = view.append<SVGPathElement>('path');

View File

@ -1,15 +1,15 @@
import { Context } from "../../../map/models/context";
import { Node } from "../models/node.model";
import { Link } from "../models/link.model";
import { Context } from "../models/context";
import { Node } from "../models/node";
import { Link } from "../models/link";
import { NodesWidget } from "./nodes.widget";
import { Widget } from "./widget";
import { SVGSelection } from "../../../map/models/types";
import { SVGSelection } from "../models/types";
import { LinksWidget } from "./links.widget";
import { D3ZoomEvent, zoom } from "d3-zoom";
import { event } from "d3-selection";
import { Drawing } from "../models/drawing.model";
import { Drawing } from "../models/drawing";
import { DrawingsWidget } from "./drawings.widget";
import { DrawingLineWidget } from "./drawing-line.widget";
import {SelectionTool} from "../tools/selection-tool";
import {MovingTool} from "../tools/moving-tool";
export class GraphLayout implements Widget {
private nodes: Node[] = [];
@ -20,6 +20,8 @@ export class GraphLayout implements Widget {
private nodesWidget: NodesWidget;
private drawingsWidget: DrawingsWidget;
private drawingLineTool: DrawingLineWidget;
private selectionTool: SelectionTool;
private movingTool: MovingTool;
private centerZeroZeroPoint = true;
@ -28,6 +30,8 @@ export class GraphLayout implements Widget {
this.nodesWidget = new NodesWidget();
this.drawingsWidget = new DrawingsWidget();
this.drawingLineTool = new DrawingLineWidget();
this.selectionTool = new SelectionTool();
this.movingTool = new MovingTool();
}
public setNodes(nodes: Node[]) {
@ -54,9 +58,23 @@ export class GraphLayout implements Widget {
return this.drawingLineTool;
}
draw(view: SVGSelection, context: Context) {
const self = this;
public getMovingTool() {
return this.movingTool;
}
public getSelectionTool() {
return this.selectionTool;
}
connect(view: SVGSelection, context: Context) {
this.drawingLineTool.connect(view, context);
this.selectionTool.connect(view, context);
this.movingTool.connect(view, context);
this.selectionTool.activate();
}
draw(view: SVGSelection, context: Context) {
const canvas = view
.selectAll<SVGGElement, Context>('g.canvas')
.data([context]);
@ -71,29 +89,18 @@ export class GraphLayout implements Widget {
(ctx: Context) => `translate(${ctx.getSize().width / 2}, ${ctx.getSize().height / 2})`);
}
this.linksWidget.draw(canvas, this.links);
this.nodesWidget.draw(canvas, this.nodes);
this.drawingsWidget.draw(canvas, this.drawings);
this.drawingLineTool.connect(view);
const onZoom = function(this: SVGSVGElement) {
const e: D3ZoomEvent<SVGSVGElement, any> = event;
if (self.centerZeroZeroPoint) {
canvas.attr(
'transform',
`translate(${context.getSize().width / 2 + e.transform.x}, ` +
`${context.getSize().height / 2 + e.transform.y}) scale(${e.transform.k})`);
} else {
canvas.attr('transform', e.transform.toString());
}
};
view.call(zoom<SVGSVGElement, any>()
.scaleExtent([1 / 2, 8])
.on('zoom', onZoom));
this.drawingLineTool.draw(view, context);
this.selectionTool.draw(view, context);
this.movingTool.draw(view, context);
}
disconnect(view: SVGSelection) {
if (view.empty && !view.empty()) {
view.selectAll('*').remove();
}
}
}

View File

@ -1,9 +1,9 @@
import {BaseType, select, Selection} from "d3-selection";
import { Widget } from "./widget";
import { SVGSelection } from "../../../map/models/types";
import { Link } from "../models/link.model";
import { LinkStatus } from "../models/link-status.model";
import { SVGSelection } from "../models/types";
import { Link } from "../models/link";
import { LinkStatus } from "../models/link-status";
import { MultiLinkCalculatorHelper } from "../../map/helpers/multi-link-calculator-helper";
import {SerialLinkWidget} from "./serial-link.widget";
import {EthernetLinkWidget} from "./ethernet-link.widget";

View File

@ -1,9 +1,9 @@
import { Widget } from "./widget";
import { Node } from "../models/node.model";
import { SVGSelection } from "../../../map/models/types";
import { Node } from "../models/node";
import { SVGSelection } from "../models/types";
import {event, select} from "d3-selection";
import {D3DragEvent, drag} from "d3-drag";
import {Symbol} from "../../../shared/models/symbol";
import {Symbol} from "../models/symbol";
export class NodesWidget implements Widget {
@ -136,6 +136,7 @@ export class NodesWidget implements Widget {
const node_merge = node
.merge(node_enter)
.classed('selected', (n: Node) => n.is_selected)
.on("contextmenu", function (n: Node, i: number) {
event.preventDefault();
if (self.onContextMenuCallback !== null) {

View File

@ -1,6 +1,6 @@
import {Widget} from "./widget";
import {SVGSelection} from "../../../map/models/types";
import {Link} from "../models/link.model";
import {SVGSelection} from "../models/types";
import {Link} from "../models/link";
import { path } from "d3-path";

View File

@ -1,19 +0,0 @@
import {Size} from "../../cartography/shared/models/size.model";
import {Selection} from "d3-selection";
export class Context {
private size: Size;
private root: Selection<SVGSVGElement, any, null, undefined>;
constructor(root: Selection<SVGSVGElement, any, null, undefined>) {
this.root = root;
}
public getSize(): Size {
return this.size;
}
public setSize(size: Size): void {
this.size = size;
}
}

View File

@ -43,6 +43,37 @@ g.node text {
}
svg image:hover, svg image.chosen {
svg image:hover, svg image.chosen, g.selected {
filter: grayscale(100%);
}
path.selected {
stroke: darkred;
}
.selection-line-tool .selection {
fill: #7ccbe1;
stroke: #66aec2 ;
fill-opacity: 0.3;
stroke-opacity: 0.7;
stroke-width: 1;
stroke-dasharray: 5, 5;
}
g.node text,
.noselect {
-webkit-touch-callout: none;
-webkit-user-select: none;
-khtml-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
user-select: none;
}
/* Disable outline after button click */
.project-toolbar button {
outline: 0;
border: none;
-moz-outline-style: none
}

View File

@ -9,7 +9,7 @@
<mat-icon svgIcon="gns3"></mat-icon>
</button>
</mat-toolbar-row>
<mat-menu #mainMenu="matMenu" [overlapTrigger]="false">
<button mat-menu-item [routerLink]="['/server', server.id, 'projects']">
<mat-icon>work</mat-icon>
@ -22,12 +22,14 @@
</mat-menu>
<mat-toolbar-row>
<button mat-icon-button (click)="turnOnDrawLineMode()" *ngIf="!drawLineMode">
<button mat-icon-button [color]="drawLineMode ? 'primary': 'basic'" (click)="toggleDrawLineMode()">
<mat-icon>timeline</mat-icon>
</button>
</mat-toolbar-row>
<button mat-icon-button color="primary" (click)="turnOffDrawLineMode()" *ngIf="drawLineMode">
<mat-icon>timeline</mat-icon>
<mat-toolbar-row>
<button mat-icon-button [color]="movingMode ? 'primary': 'basic'" (click)="toggleMovingMode()">
<mat-icon>zoom_out_map</mat-icon>
</button>
</mat-toolbar-row>

View File

@ -12,27 +12,33 @@ import 'rxjs/add/observable/dom/webSocket';
import { Project } from '../shared/models/project';
import { Node } from '../cartography/shared/models/node.model';
import { Node } from '../cartography/shared/models/node';
import { SymbolService } from '../shared/services/symbol.service';
import { Link } from "../cartography/shared/models/link.model";
import { Link } from "../cartography/shared/models/link";
import { MapComponent } from "../cartography/map/map.component";
import { ServerService } from "../shared/services/server.service";
import { ProjectService } from '../shared/services/project.service';
import { Server } from "../shared/models/server";
import { MAT_DIALOG_DATA, MatDialog, MatDialogRef, MatSnackBar } from "@angular/material";
import { MAT_DIALOG_DATA, MatDialog, MatDialogRef} from "@angular/material";
import { SnapshotService } from "../shared/services/snapshot.service";
import { Snapshot } from "../shared/models/snapshot";
import { ProgressDialogService } from "../shared/progress-dialog/progress-dialog.service";
import { ProgressDialogComponent } from "../shared/progress-dialog/progress-dialog.component";
import { Drawing } from "../cartography/shared/models/drawing.model";
import { Drawing } from "../cartography/shared/models/drawing";
import { NodeContextMenuComponent } from "../shared/node-context-menu/node-context-menu.component";
import { Appliance } from "../shared/models/appliance";
import { NodeService } from "../shared/services/node.service";
import { Symbol } from "../shared/models/symbol";
import { Symbol } from "../cartography/shared/models/symbol";
import { NodeSelectInterfaceComponent } from "../shared/node-select-interface/node-select-interface.component";
import { Port } from "../shared/models/port";
import { LinkService } from "../shared/services/link.service";
import { ToasterService } from '../shared/services/toaster.service';
import { NodesDataSource } from "../cartography/shared/datasources/nodes-datasource";
import { LinksDataSource } from "../cartography/shared/datasources/links-datasource";
import { ProjectWebServiceHandler } from "../shared/handlers/project-web-service-handler";
import { Rectangle } from "../cartography/shared/models/rectangle";
import { SelectionManager } from "../cartography/shared/managers/selection-manager";
import { InRectangleHelper } from "../cartography/map/helpers/in-rectangle-helper";
@Component({
@ -52,6 +58,8 @@ export class ProjectMapComponent implements OnInit {
private ws: Subject<any>;
private drawLineMode = false;
private movingMode = false;
public isLoading = true;
@ViewChild(MapComponent) mapChild: MapComponent;
@ -69,7 +77,11 @@ export class ProjectMapComponent implements OnInit {
private linkService: LinkService,
private dialog: MatDialog,
private progressDialogService: ProgressDialogService,
private toaster: ToasterService) {
private toaster: ToasterService,
private projectWebServiceHandler: ProjectWebServiceHandler,
protected nodesDataSource: NodesDataSource,
protected linksDataSource: LinksDataSource,
) {
}
ngOnInit() {
@ -102,6 +114,19 @@ export class ProjectMapComponent implements OnInit {
this.symbols = symbols;
});
this.nodesDataSource.connect().subscribe((nodes: Node[]) => {
this.nodes = nodes;
if (this.mapChild) {
this.mapChild.reload();
}
});
this.linksDataSource.connect().subscribe((links: Link[]) => {
this.links = links;
if (this.mapChild) {
this.mapChild.reload();
}
});
}
onProjectLoad(project: Project) {
@ -115,11 +140,11 @@ export class ProjectMapComponent implements OnInit {
return this.projectService.links(this.server, project.project_id);
})
.flatMap((links: Link[]) => {
this.links = links;
this.linksDataSource.set(links);
return this.projectService.nodes(this.server, project.project_id);
})
.subscribe((nodes: Node[]) => {
this.nodes = nodes;
this.nodesDataSource.set(nodes);
this.setUpMapCallbacks(project);
this.setUpWS(project);
@ -132,85 +157,33 @@ export class ProjectMapComponent implements OnInit {
setUpWS(project: Project) {
this.ws = Observable.webSocket(
this.projectService.notificationsPath(this.server, project.project_id));
this.ws.subscribe((o: any) => {
if (o.action === 'node.updated') {
const node: Node = o.event;
const index = this.nodes.findIndex((n: Node) => n.node_id === node.node_id);
if (index >= 0) {
this.nodes[index] = node;
this.mapChild.reload(); // temporary invocation
}
}
if (o.action === 'node.created') {
const node: Node = o.event;
const index = this.nodes.findIndex((n: Node) => n.node_id === node.node_id);
if (index === -1) {
this.nodes.push(node);
this.mapChild.reload(); // temporary invocation
}
}
if (o.action === 'node.deleted') {
const node: Node = o.event;
const index = this.nodes.findIndex((n: Node) => n.node_id === node.node_id);
if (index >= 0) {
this.nodes.splice(index, 1);
this.mapChild.reload(); // temporary invocation
}
}
if (o.action === 'link.created') {
const link: Link = o.event;
const index = this.links.findIndex((l: Link) => l.link_id === link.link_id);
if (index === -1) {
this.links.push(link);
this.mapChild.reload(); // temporary invocation
}
}
if (o.action === 'link.updated') {
const link: Link = o.event;
const index = this.links.findIndex((l: Link) => l.link_id === link.link_id);
if (index >= 0) {
this.links[index] = link;
this.mapChild.reload(); // temporary invocation
}
}
if (o.action === 'link.deleted') {
const link: Link = o.event;
const index = this.links.findIndex((l: Link) => l.link_id === link.link_id);
if (index >= 0) {
this.links.splice(index, 1);
this.mapChild.reload(); // temporary invocation
}
}
});
this.projectWebServiceHandler.connect(this.ws);
}
setUpMapCallbacks(project: Project) {
const selectionManager = new SelectionManager(this.nodesDataSource, this.linksDataSource, new InRectangleHelper());
this.mapChild.graphLayout.getNodesWidget().setOnContextMenuCallback((event: any, node: Node) => {
this.nodeContextMenu.open(node, event.clientY, event.clientX);
});
this.mapChild.graphLayout.getNodesWidget().setOnNodeClickedCallback((event: any, node: Node) => {
selectionManager.setSelectedNodes([node]);
if (this.drawLineMode) {
this.nodeSelectInterfaceMenu.open(node, event.clientY, event.clientX);
}
});
this.mapChild.graphLayout.getNodesWidget().setOnNodeDraggedCallback((event: any, node: Node) => {
const index = this.nodes.findIndex((n: Node) => n.node_id === node.node_id);
if (index >= 0) {
this.nodes[index] = node;
this.mapChild.reload(); // temporary invocation
this.nodeService
.updatePosition(this.server, node, node.x, node.y)
.subscribe((n: Node) => {
this.nodes[index] = node;
this.mapChild.reload(); // temporary invocation
});
}
this.nodesDataSource.update(node);
this.nodeService
.updatePosition(this.server, node, node.x, node.y)
.subscribe((n: Node) => {
this.nodesDataSource.update(n);
});
});
selectionManager.subscribe(this.mapChild.graphLayout.getSelectionTool().rectangleSelected);
}
onNodeCreation(appliance: Appliance) {
@ -220,8 +193,7 @@ export class ProjectMapComponent implements OnInit {
this.projectService
.nodes(this.server, this.project.project_id)
.subscribe((nodes: Node[]) => {
this.nodes = nodes;
this.mapChild.reload();
this.nodesDataSource.set(nodes);
});
});
}
@ -255,13 +227,22 @@ export class ProjectMapComponent implements OnInit {
});
}
public turnOnDrawLineMode() {
this.drawLineMode = true;
public toggleDrawLineMode() {
this.drawLineMode = !this.drawLineMode;
if (!this.drawLineMode) {
this.mapChild.graphLayout.getDrawingLineTool().stop();
}
}
public turnOffDrawLineMode() {
this.drawLineMode = false;
this.mapChild.graphLayout.getDrawingLineTool().stop();
public toggleMovingMode() {
this.movingMode = !this.movingMode;
if (this.movingMode) {
this.mapChild.graphLayout.getSelectionTool().deactivate();
this.mapChild.graphLayout.getMovingTool().activate();
} else {
this.mapChild.graphLayout.getMovingTool().deactivate();
this.mapChild.graphLayout.getSelectionTool().activate();
}
}
public onChooseInterface(event) {
@ -284,8 +265,7 @@ export class ProjectMapComponent implements OnInit {
.createLink(this.server, source_node, source_port, target_node, target_port)
.subscribe(() => {
this.projectService.links(this.server, this.project.project_id).subscribe((links: Link[]) => {
this.links = links;
this.mapChild.reload();
this.linksDataSource.set(links);
});
});
}

View File

@ -1,9 +0,0 @@
import {Node} from "../../cartography/shared/models/node.model";
export class Database<T> {
}
export class NodeDatabase extends Database<Node> {
}

View File

@ -0,0 +1,123 @@
import {ProjectWebServiceHandler, WebServiceMessage} from "./project-web-service-handler";
import {Subject} from "rxjs/Subject";
import {inject, TestBed} from "@angular/core/testing";
import {NodesDataSource} from "../../cartography/shared/datasources/nodes-datasource";
import {LinksDataSource} from "../../cartography/shared/datasources/links-datasource";
import {Node} from "../../cartography/shared/models/node";
import {Link} from "../../cartography/shared/models/link";
describe('ProjectWebServiceHandler', () => {
let ws: Subject<WebServiceMessage>;
beforeEach(() => {
TestBed.configureTestingModule({
providers: [ProjectWebServiceHandler, NodesDataSource, LinksDataSource]
});
ws = new Subject<WebServiceMessage>();
});
it('should be created', inject([ProjectWebServiceHandler], (service: ProjectWebServiceHandler) => {
expect(service).toBeTruthy();
}));
it('node should be added', inject([ProjectWebServiceHandler, NodesDataSource],
(service: ProjectWebServiceHandler, nodesDataSource: NodesDataSource) => {
spyOn(nodesDataSource, 'add');
service.connect(ws);
const message = new WebServiceMessage();
message.action = "node.created";
message.event = new Node();
ws.next(message);
expect(service).toBeTruthy();
expect(nodesDataSource.add).toHaveBeenCalledWith(message.event);
}));
it('node should be updated', inject([ProjectWebServiceHandler, NodesDataSource],
(service: ProjectWebServiceHandler, nodesDataSource: NodesDataSource) => {
spyOn(nodesDataSource, 'update');
service.connect(ws);
const message = new WebServiceMessage();
message.action = "node.updated";
message.event = new Node();
ws.next(message);
expect(service).toBeTruthy();
expect(nodesDataSource.update).toHaveBeenCalledWith(message.event);
}));
it('node should be removed', inject([ProjectWebServiceHandler, NodesDataSource],
(service: ProjectWebServiceHandler, nodesDataSource: NodesDataSource) => {
spyOn(nodesDataSource, 'remove');
service.connect(ws);
const message = new WebServiceMessage();
message.action = "node.deleted";
message.event = new Node();
ws.next(message);
expect(service).toBeTruthy();
expect(nodesDataSource.remove).toHaveBeenCalledWith(message.event);
}));
it('link should be added', inject([ProjectWebServiceHandler, LinksDataSource],
(service: ProjectWebServiceHandler, linksDataSource: LinksDataSource) => {
spyOn(linksDataSource, 'add');
service.connect(ws);
const message = new WebServiceMessage();
message.action = "link.created";
message.event = new Link();
ws.next(message);
expect(service).toBeTruthy();
expect(linksDataSource.add).toHaveBeenCalledWith(message.event);
}));
it('link should be updated', inject([ProjectWebServiceHandler, LinksDataSource],
(service: ProjectWebServiceHandler, linksDataSource: LinksDataSource) => {
spyOn(linksDataSource, 'update');
service.connect(ws);
const message = new WebServiceMessage();
message.action = "link.updated";
message.event = new Link();
ws.next(message);
expect(service).toBeTruthy();
expect(linksDataSource.update).toHaveBeenCalledWith(message.event);
}));
it('link should be removed', inject([ProjectWebServiceHandler, LinksDataSource],
(service: ProjectWebServiceHandler, linksDataSource: LinksDataSource) => {
spyOn(linksDataSource, 'remove');
service.connect(ws);
const message = new WebServiceMessage();
message.action = "link.deleted";
message.event = new Link();
ws.next(message);
expect(service).toBeTruthy();
expect(linksDataSource.remove).toHaveBeenCalledWith(message.event);
}));
});

View File

@ -0,0 +1,41 @@
import {Injectable} from "@angular/core";
import {NodesDataSource} from "../../cartography/shared/datasources/nodes-datasource";
import {LinksDataSource} from "../../cartography/shared/datasources/links-datasource";
import {Subject} from "rxjs/Subject";
import {Link} from "../../cartography/shared/models/link";
import {Node} from "../../cartography/shared/models/node";
export class WebServiceMessage {
action: string;
event: Node | Link;
}
@Injectable()
export class ProjectWebServiceHandler {
constructor(private nodesDataSource: NodesDataSource,
private linksDataSource: LinksDataSource) {}
public connect(ws: Subject<WebServiceMessage>) {
ws.subscribe((message: WebServiceMessage) => {
if (message.action === 'node.updated') {
this.nodesDataSource.update(message.event as Node);
}
if (message.action === 'node.created') {
this.nodesDataSource.add(message.event as Node);
}
if (message.action === 'node.deleted') {
this.nodesDataSource.remove(message.event as Node);
}
if (message.action === 'link.created') {
this.linksDataSource.add(message.event as Link);
}
if (message.action === 'link.updated') {
this.linksDataSource.update(message.event as Link);
}
if (message.action === 'link.deleted') {
this.linksDataSource.remove(message.event as Link);
}
});
}
}

View File

@ -1,7 +1,7 @@
import {Component, Input, OnInit} from '@angular/core';
import {Server} from "../../../models/server";
import {NodeService} from "../../../services/node.service";
import {Node} from "../../../../cartography/shared/models/node.model";
import {Node} from "../../../../cartography/shared/models/node";
@Component({

View File

@ -1,7 +1,7 @@
import {Component, Input, OnInit} from '@angular/core';
import {Server} from "../../../models/server";
import {NodeService} from "../../../services/node.service";
import {Node} from "../../../../cartography/shared/models/node.model";
import {Node} from "../../../../cartography/shared/models/node";
@Component({

View File

@ -1,7 +1,7 @@
import {ChangeDetectorRef, Component, Input, OnInit, ViewChild} from '@angular/core';
import {MatMenuTrigger} from "@angular/material";
import {DomSanitizer} from "@angular/platform-browser";
import {Node} from "../../cartography/shared/models/node.model";
import {Node} from "../../cartography/shared/models/node";
import {Server} from "../models/server";

View File

@ -1,7 +1,7 @@
import {ChangeDetectorRef, Component, EventEmitter, Input, OnInit, Output, ViewChild} from '@angular/core';
import {MatMenuTrigger} from "@angular/material";
import {DomSanitizer} from "@angular/platform-browser";
import {Node} from "../../cartography/shared/models/node.model";
import {Node} from "../../cartography/shared/models/node";
import {Port} from "../models/port";

View File

@ -1,15 +1,47 @@
import { TestBed, inject } from '@angular/core/testing';
import { TestBed, } from '@angular/core/testing';
import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing';
import { HttpClient } from "@angular/common/http";
import { ApplianceService } from './appliance.service';
import { Server } from '../models/server';
import { HttpServer } from './http-server.service';
describe('ApplianceService', () => {
let httpClient: HttpClient;
let httpTestingController: HttpTestingController;
let service: ApplianceService;
beforeEach(() => {
TestBed.configureTestingModule({
providers: [ApplianceService]
imports: [
HttpClientTestingModule
],
providers: [
ApplianceService,
HttpServer
]
});
httpClient = TestBed.get(HttpClient);
httpTestingController = TestBed.get(HttpTestingController);
service = TestBed.get(ApplianceService);
});
// it('should be created', inject([ApplianceService], (service: ApplianceService) => {
// expect(service).toBeTruthy();
// }));
afterEach(() => {
httpTestingController.verify();
});
it('should ask for the list from server', () => {
const server = new Server();
server.ip = "127.0.0.1";
server.port = 3080;
server.authorization = "none";
service.list(server).subscribe();
httpTestingController.expectOne('http://127.0.0.1:3080/v2/appliances');
});
});

View File

@ -1,10 +1,10 @@
import { Injectable } from '@angular/core';
import { Observable } from 'rxjs/Observable';
import 'rxjs/add/operator/map';
import { Server } from "../models/server";
import { HttpServer } from "./http-server.service";
import {Appliance} from "../models/appliance";
import {Observable} from "rxjs/Observable";
@Injectable()
export class ApplianceService {
@ -13,8 +13,7 @@ export class ApplianceService {
list(server: Server): Observable<Appliance[]> {
return this.httpServer
.get(server, '/appliances')
.map(response => response.json() as Appliance[]);
.get<Appliance[]>(server, '/appliances') as Observable<Appliance[]>;
}
}

View File

@ -1,15 +1,146 @@
import { TestBed, inject } from '@angular/core/testing';
import { TestBed, } from '@angular/core/testing';
import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing';
import { HttpClient } from "@angular/common/http";
import { Server } from '../models/server';
import { HttpServer } from './http-server.service';
describe('HttpServer', () => {
let httpClient: HttpClient;
let httpTestingController: HttpTestingController;
let service: HttpServer;
let server: Server;
beforeEach(() => {
TestBed.configureTestingModule({
providers: [HttpServer]
imports: [
HttpClientTestingModule
],
providers: [
HttpServer
]
});
httpClient = TestBed.get(HttpClient);
httpTestingController = TestBed.get(HttpTestingController);
service = TestBed.get(HttpServer);
server = new Server();
server.ip = "127.0.0.1";
server.port = 3080;
server.authorization = "none";
});
// it('should be created', inject([HttpServer], (service: HttpServer) => {
// expect(service).toBeTruthy();
// }));
afterEach(() => {
httpTestingController.verify();
});
it('should make GET query for get method', () => {
service.get(server, '/test').subscribe();
const req = httpTestingController.expectOne('http://127.0.0.1:3080/v2/test');
expect(req.request.method).toEqual("GET");
expect(req.request.responseType).toEqual("json");
});
it('should make GET query for getText method', () => {
service.getText(server, '/test').subscribe();
const req = httpTestingController.expectOne('http://127.0.0.1:3080/v2/test');
expect(req.request.method).toEqual("GET");
expect(req.request.responseType).toEqual("text");
});
it('should make GET query for getText method and preserve options', () => {
service.getText(server, '/test', {
headers: {
'CustomHeader': 'value'
},
responseType: 'text'
}).subscribe();
const req = httpTestingController.expectOne('http://127.0.0.1:3080/v2/test');
expect(req.request.method).toEqual("GET");
expect(req.request.responseType).toEqual("text");
});
it('should make POST query for post method', () => {
service.post(server, '/test', {test: "1"}).subscribe();
const req = httpTestingController.expectOne('http://127.0.0.1:3080/v2/test');
expect(req.request.method).toEqual("POST");
expect(req.request.responseType).toEqual("json");
});
it('should make PUT query for put method', () => {
service.put(server, '/test', {test: "1"}).subscribe();
const req = httpTestingController.expectOne('http://127.0.0.1:3080/v2/test');
expect(req.request.method).toEqual("PUT");
expect(req.request.responseType).toEqual("json");
});
it('should make DELETE query for delete method', () => {
service.delete(server, '/test').subscribe();
const req = httpTestingController.expectOne('http://127.0.0.1:3080/v2/test');
expect(req.request.method).toEqual("DELETE");
expect(req.request.responseType).toEqual("json");
});
it('should make PATCH query for patch method', () => {
service.patch(server, '/test', {test: "1"}).subscribe();
const req = httpTestingController.expectOne('http://127.0.0.1:3080/v2/test');
expect(req.request.method).toEqual("PATCH");
expect(req.request.responseType).toEqual("json");
});
it('should make HEAD query for head method', () => {
service.head(server, '/test').subscribe();
const req = httpTestingController.expectOne('http://127.0.0.1:3080/v2/test');
expect(req.request.method).toEqual("HEAD");
expect(req.request.responseType).toEqual("json");
});
it('should make OPTIONS query for options method', () => {
service.options(server, '/test').subscribe();
const req = httpTestingController.expectOne('http://127.0.0.1:3080/v2/test');
expect(req.request.method).toEqual("OPTIONS");
expect(req.request.responseType).toEqual("json");
});
it('should add headers for `basic` authorization', () => {
server.authorization = "basic";
server.login = "login";
server.password = "password";
service.get(server, '/test').subscribe();
const req = httpTestingController.expectOne('http://127.0.0.1:3080/v2/test');
expect(req.request.method).toEqual("GET");
expect(req.request.responseType).toEqual("json");
expect(req.request.headers.get('Authorization')).toEqual('Basic bG9naW46cGFzc3dvcmQ=');
});
it('should add headers for `basic` authorization and preserve headers', () => {
server.authorization = "basic";
server.login = "login";
server.password = "password";
service.get(server, '/test', {
headers: {
'CustomHeader': 'value'
}
}).subscribe();
const req = httpTestingController.expectOne('http://127.0.0.1:3080/v2/test');
expect(req.request.method).toEqual("GET");
expect(req.request.responseType).toEqual("json");
expect(req.request.headers.get('Authorization')).toEqual('Basic bG9naW46cGFzc3dvcmQ=');
expect(req.request.headers.get('CustomHeader')).toEqual('value');
});
});

View File

@ -1,64 +1,132 @@
import { Injectable } from '@angular/core';
import {HttpHeaders, HttpClient, HttpParams} from '@angular/common/http';
import { Observable } from 'rxjs/Observable';
import {Headers, Http, RequestOptions, RequestOptionsArgs, Response} from "@angular/http";
import {Server} from "../models/server";
/* tslint:disable:interface-over-type-literal */
export type JsonOptions = {
headers?: HttpHeaders | {
[header: string]: string | string[];
};
observe?: 'body';
params?: HttpParams | {
[param: string]: string | string[];
};
reportProgress?: boolean;
responseType?: 'json';
withCredentials?: boolean;
};
export type TextOptions = {
headers?: HttpHeaders | {
[header: string]: string | string[];
};
observe?: 'body';
params?: HttpParams | {
[param: string]: string | string[];
};
reportProgress?: boolean;
responseType: 'text';
withCredentials?: boolean;
};
export type HeadersOptions = {
headers?: HttpHeaders | {
[header: string]: string | string[];
};
};
/* tslint:enable:interface-over-type-literal */
@Injectable()
export class HttpServer {
constructor(private http: Http) { }
constructor(private http: HttpClient) { }
get(server: Server, url: string, options?: RequestOptionsArgs): Observable<Response> {
options = this.getOptionsForServer(server, url, options);
return this.http.get(url, options);
get<T>(server: Server, url: string, options?: JsonOptions): Observable<T> {
options = this.getJsonOptions(options);
const intercepted = this.getOptionsForServer<JsonOptions>(server, url, options);
return this.http.get<T>(intercepted.url, intercepted.options as JsonOptions);
}
post(server: Server, url: string, body: any, options?: RequestOptionsArgs): Observable<Response> {
options = this.getOptionsForServer(server, url, options);
return this.http.post(url, body, options);
getText(server: Server, url: string, options?: TextOptions): Observable<string> {
options = this.getTextOptions(options);
const intercepted = this.getOptionsForServer<TextOptions>(server, url, options);
return this.http.get(intercepted.url, intercepted.options as TextOptions);
}
put(server: Server, url: string, body: any, options?: RequestOptionsArgs): Observable<Response> {
options = this.getOptionsForServer(server, url, options);
return this.http.put(url, body, options);
post<T>(server: Server, url: string, body: any | null, options?: JsonOptions): Observable<T> {
options = this.getJsonOptions(options);
const intercepted = this.getOptionsForServer(server, url, options);
return this.http.post<T>(intercepted.url, body, intercepted.options);
}
delete(server: Server, url: string, options?: RequestOptionsArgs): Observable<Response> {
options = this.getOptionsForServer(server, url, options);
return this.http.delete(url, options);
put<T>(server: Server, url: string, body: any, options?: JsonOptions): Observable<T> {
options = this.getJsonOptions(options);
const intercepted = this.getOptionsForServer(server, url, options);
return this.http.put<T>(intercepted.url, body, intercepted.options);
}
patch(server: Server, url: string, body: any, options?: RequestOptionsArgs): Observable<Response> {
options = this.getOptionsForServer(server, url, options);
return this.http.patch(url, body, options);
delete<T>(server: Server, url: string, options?: JsonOptions): Observable<T> {
options = this.getJsonOptions(options);
const intercepted = this.getOptionsForServer(server, url, options);
return this.http.delete<T>(intercepted.url, intercepted.options);
}
head(server: Server, url: string, options?: RequestOptionsArgs): Observable<Response> {
options = this.getOptionsForServer(server, url, options);
return this.http.patch(url, options);
patch<T>(server: Server, url: string, body: any, options?: JsonOptions): Observable<T> {
options = this.getJsonOptions(options);
const intercepted = this.getOptionsForServer(server, url, options);
return this.http.patch<T>(intercepted.url, body, intercepted.options);
}
options(server: Server, url: string, options?: RequestOptionsArgs): Observable<Response> {
options = this.getOptionsForServer(server, url, options);
return this.http.options(url, options);
head<T>(server: Server, url: string, options?: JsonOptions): Observable<T> {
options = this.getJsonOptions(options);
const intercepted = this.getOptionsForServer(server, url, options);
return this.http.head<T>(intercepted.url, intercepted.options);
}
private getOptionsForServer(server: Server, url: string, options) {
if (options === undefined) {
options = new RequestOptions();
options<T>(server: Server, url: string, options?: JsonOptions): Observable<T> {
options = this.getJsonOptions(options);
const intercepted = this.getOptionsForServer(server, url, options);
return this.http.options<T>(intercepted.url, intercepted.options);
}
private getJsonOptions(options: JsonOptions): JsonOptions {
if (!options) {
return {
responseType: "json"
};
}
options.url = `http://${server.ip}:${server.port}/v2${url}`;
return options;
}
if (options.headers === null) {
options.headers = new Headers();
private getTextOptions(options: TextOptions): TextOptions {
if (!options) {
return {
responseType: "text"
};
}
return options;
}
private getOptionsForServer<T extends HeadersOptions>(server: Server, url: string, options: T) {
url = `http://${server.ip}:${server.port}/v2${url}`;
if (!options.headers) {
options.headers = {};
}
if (server.authorization === "basic") {
const credentials = btoa(`${server.login}:${server.password}`);
options.headers.append('Authorization', `Basic ${credentials}`);
options.headers['Authorization'] = `Basic ${credentials}`;
}
return options;
return {
url: url,
options: options
};
}
}

View File

@ -1,11 +1,9 @@
import { Injectable } from '@angular/core';
import { Node } from '../../cartography/shared/models/node.model';
import { Observable } from 'rxjs/Observable';
import { Node } from '../../cartography/shared/models/node';
import 'rxjs/add/operator/map';
import { Server } from "../models/server";
import { HttpServer } from "./http-server.service";
import {Response} from "@angular/http";
import {Port} from "../models/port";
@Injectable()
@ -15,7 +13,7 @@ export class LinkService {
constructor(private httpServer: HttpServer) { }
createLink(
server: Server, source_node: Node, source_port: Port, target_node: Node, target_port: Port): Observable<Response> {
server: Server, source_node: Node, source_port: Port, target_node: Node, target_port: Port) {
return this.httpServer
.post(
server,

View File

@ -1,13 +1,12 @@
import { Injectable } from '@angular/core';
import { Project } from '../models/project';
import { Node } from '../../cartography/shared/models/node.model';
import { Node } from '../../cartography/shared/models/node';
import { Observable } from 'rxjs/Observable';
import 'rxjs/add/operator/map';
import { Server } from "../models/server";
import { HttpServer } from "./http-server.service";
import {Appliance} from "../models/appliance";
import {Response} from "@angular/http";
@Injectable()
@ -15,21 +14,19 @@ export class NodeService {
constructor(private httpServer: HttpServer) { }
start(server: Server, node: Node): Observable<Node> {
start(server: Server, node: Node) {
return this.httpServer
.post(server, `/projects/${node.project_id}/nodes/${node.node_id}/start`, {})
.map(response => response.json() as Node);
.post<Node>(server, `/projects/${node.project_id}/nodes/${node.node_id}/start`, {});
}
stop(server: Server, node: Node): Observable<Node> {
stop(server: Server, node: Node) {
return this.httpServer
.post(server, `/projects/${node.project_id}/nodes/${node.node_id}/stop`, {})
.map(response => response.json() as Node);
.post<Node>(server, `/projects/${node.project_id}/nodes/${node.node_id}/stop`, {});
}
createFromAppliance(
server: Server, project: Project, appliance: Appliance,
x: number, y: number, compute_id: string): Observable<Response> {
x: number, y: number, compute_id: string) {
return this.httpServer
.post(
server,
@ -39,10 +36,9 @@ export class NodeService {
updatePosition(server: Server, node: Node, x: number, y: number): Observable<Node> {
return this.httpServer
.put(server, `/projects/${node.project_id}/nodes/${node.node_id}`, {
.put<Node>(server, `/projects/${node.project_id}/nodes/${node.node_id}`, {
'x': x,
'y': y
})
.map(response => response.json() as Node);
});
}
}

View File

@ -1,53 +1,47 @@
import { Injectable } from '@angular/core';
import { Project } from '../models/project';
import { Node } from '../../cartography/shared/models/node.model';
import { Node } from '../../cartography/shared/models/node';
import { Observable } from 'rxjs/Observable';
import 'rxjs/add/operator/map';
import { Link } from "../../cartography/shared/models/link.model";
import { Link } from "../../cartography/shared/models/link";
import { Server } from "../models/server";
import { HttpServer } from "./http-server.service";
import {Drawing} from "../../cartography/shared/models/drawing.model";
import {Drawing} from "../../cartography/shared/models/drawing";
@Injectable()
export class ProjectService {
constructor(private httpServer: HttpServer) { }
get(server: Server, project_id: string): Observable<Project> {
get(server: Server, project_id: string) {
return this.httpServer
.get(server, `/projects/${project_id}`)
.map(response => response.json() as Project);
.get<Project>(server, `/projects/${project_id}`);
}
open(server: Server, project_id: string): Observable<Project> {
open(server: Server, project_id: string) {
return this.httpServer
.post(server, `/projects/${project_id}/open`, {})
.map(response => response.json() as Project);
.post<Project>(server, `/projects/${project_id}/open`, {});
}
list(server: Server): Observable<Project[]> {
list(server: Server) {
return this.httpServer
.get(server, '/projects')
.map(response => response.json() as Project[]);
.get<Project[]>(server, '/projects');
}
nodes(server: Server, project_id: string): Observable<Node[]> {
nodes(server: Server, project_id: string) {
return this.httpServer
.get(server, `/projects/${project_id}/nodes`)
.map(response => response.json() as Node[]);
.get<Node[]>(server, `/projects/${project_id}/nodes`);
}
links(server: Server, project_id: string): Observable<Link[]> {
links(server: Server, project_id: string) {
return this.httpServer
.get(server, `/projects/${project_id}/links`)
.map(response => response.json() as Link[]);
.get<Link[]>(server, `/projects/${project_id}/links`);
}
drawings(server: Server, project_id: string): Observable<Drawing[]> {
drawings(server: Server, project_id: string) {
return this.httpServer
.get(server, `/projects/${project_id}/drawings`)
.map(response => response.json() as Drawing[]);
.get<Drawing[]>(server, `/projects/${project_id}/drawings`);
}
delete(server: Server, project_id: string): Observable<any> {

View File

@ -10,16 +10,14 @@ export class SnapshotService {
constructor(private httpServer: HttpServer) { }
create(server: Server, project_id: string, snapshot: Snapshot): Observable<Snapshot> {
create(server: Server, project_id: string, snapshot: Snapshot) {
return this.httpServer
.post(server, `/projects/${project_id}/snapshots`, snapshot)
.map(response => response.json() as Snapshot);
.post<Snapshot>(server, `/projects/${project_id}/snapshots`, snapshot);
}
list(server: Server, project_id: string): Observable<Snapshot[]> {
list(server: Server, project_id: string) {
return this.httpServer
.get(server, `/projects/${project_id}/snapshots`)
.map(response => response.json() as Snapshot[]);
.get<Snapshot[]>(server, `/projects/${project_id}/snapshots`);
}
}

View File

@ -6,7 +6,7 @@ import 'rxjs/add/operator/map';
import 'rxjs/add/observable/forkJoin';
import 'rxjs/add/observable/of';
import { Symbol } from '../models/symbol';
import { Symbol } from '../../cartography/shared/models/symbol';
import { Server } from "../models/server";
import { HttpServer } from "./http-server.service";
@ -26,7 +26,7 @@ export class SymbolService {
load(server: Server): Observable<Symbol[]> {
this.list(server).subscribe((symbols: Symbol[]) => {
const streams = symbols.map(symbol => this.raw(server, symbol.symbol_id));
Observable.forkJoin(streams).subscribe((results: string[]) => {
Observable.forkJoin(streams).subscribe((results) => {
symbols.forEach((symbol: Symbol, i: number) => {
symbol.raw = results[i];
});
@ -36,16 +36,14 @@ export class SymbolService {
return this.symbols.asObservable();
}
list(server: Server): Observable<Symbol[]> {
list(server: Server) {
return this.httpServer
.get(server, '/symbols')
.map(response => response.json() as Symbol[]);
.get<Symbol[]>(server, '/symbols');
}
raw(server: Server, symbol_id: string): Observable<string> {
raw(server: Server, symbol_id: string) {
const encoded_uri = encodeURI(symbol_id);
return this.httpServer
.get(server, `/symbols/${encoded_uri}/raw`)
.map(response => response.text() as string);
.getText(server, `/symbols/${encoded_uri}/raw`);
}
}

View File

@ -1,5 +1,4 @@
import { Injectable } from '@angular/core';
import { Observable } from 'rxjs/Observable';
import 'rxjs/add/operator/map';
@ -13,9 +12,8 @@ export class VersionService {
constructor(private httpServer: HttpServer) { }
get(server: Server): Observable<Version> {
get(server: Server) {
return this.httpServer
.get(server, '/version')
.map(response => response.json() as Version);
.get<Version>(server, '/version');
}
}