diff --git a/package.json b/package.json index ff4897de..4eb5a7dc 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "gns3-web-ui", - "version": "2019.2.0-alpha.6dev", + "version": "2019.2.0-alpha.7dev", "author": { "name": "GNS3 Technology Inc.", "email": "developers@gns3.com" diff --git a/src/ReleaseNotes.txt b/src/ReleaseNotes.txt index 9c0fe032..e796ac23 100644 --- a/src/ReleaseNotes.txt +++ b/src/ReleaseNotes.txt @@ -3,14 +3,17 @@ GNS3 WebUI is web implementation of user interface for GNS3 software. Current version: 2019.2.0 What's New -- Help section added with information about third party components -- Showing progress when server starting -- Possibility to edit interface & node labels by using context menu -- Enhancements in moving elements on map -- Context menu extended with option to duplicate -- Main menu extended with option to lock all items on map +- Editing interface labels on double click +- Support for keyboard shortcuts +- Menu extended with option to delete currently opened project, export & import project +- Possibility to save current state of project +- Ability to duplicate project from projects page +- Node information dialog available from context menu +- Topology summary widget on map view +- Improvements in dialog styles Bug Fixes -- Removing issues with positioning interface labels while adding link between nodes on map +- Removing issues with opening console - Context menu now is correctly placed -- Entered text in text & style editor is now validated +- Text validation in dialogs +- Removing errors with creating WebSockets diff --git a/src/app/app.component.css b/src/app/app.component.css index e69de29b..1bc4310f 100644 --- a/src/app/app.component.css +++ b/src/app/app.component.css @@ -0,0 +1,3 @@ +mat-menu-panel { + min-height: 0px; +} diff --git a/src/app/app.module.ts b/src/app/app.module.ts index 475862b3..7e43e027 100644 --- a/src/app/app.module.ts +++ b/src/app/app.module.ts @@ -188,11 +188,17 @@ import { NodeCreatedLabelStylesFixer } from './components/project-map/helpers/no import { NonNegativeValidator } from './validators/non-negative-validator'; import { RotationValidator } from './validators/rotation-validator'; import { DuplicateActionComponent } from './components/project-map/context-menu/actions/duplicate-action/duplicate-action.component'; -import { MapSettingService } from './services/mapsettings.service'; +import { MapSettingsService } from './services/mapsettings.service'; import { ProjectMapMenuComponent } from './components/project-map/project-map-menu/project-map-menu.component'; import { HelpComponent } from './components/help/help.component'; import { ConfigEditorDialogComponent } from './components/project-map/node-editors/config-editor/config-editor.component'; import { EditConfigActionComponent } from './components/project-map/context-menu/actions/edit-config/edit-config-action.component'; +import { SaveProjectDialogComponent } from './components/projects/save-project-dialog/save-project-dialog.component'; +import { TopologySummaryComponent } from './components/topology-summary/topology-summary.component'; +import { ShowNodeActionComponent } from './components/project-map/context-menu/actions/show-node-action/show-node-action.component'; +import { InfoDialogComponent } from './components/project-map/info-dialog/info-dialog.component'; +import { InfoService } from './services/info.service'; +import { BringToFrontActionComponent } from './components/project-map/context-menu/actions/bring-to-front-action/bring-to-front-action.component'; if (environment.production) { Raven.config('https://b2b1cfd9b043491eb6b566fd8acee358@sentry.io/842726', { @@ -311,12 +317,17 @@ if (environment.production) { NodesMenuComponent, AdbutlerComponent, ConsoleDeviceActionComponent, + ShowNodeActionComponent, ConsoleComponent, NodesMenuComponent, ProjectMapMenuComponent, HelpComponent, ConfigEditorDialogComponent, - EditConfigActionComponent + EditConfigActionComponent, + SaveProjectDialogComponent, + TopologySummaryComponent, + InfoDialogComponent, + BringToFrontActionComponent ], imports: [ BrowserModule, @@ -392,7 +403,8 @@ if (environment.production) { NodeCreatedLabelStylesFixer, NonNegativeValidator, RotationValidator, - MapSettingService + MapSettingsService, + InfoService ], entryComponents: [ AddServerDialogComponent, @@ -409,7 +421,9 @@ if (environment.production) { DeleteConfirmationDialogComponent, HelpDialogComponent, StartCaptureDialogComponent, - ConfigEditorDialogComponent + ConfigEditorDialogComponent, + SaveProjectDialogComponent, + InfoDialogComponent ], bootstrap: [AppComponent] }) diff --git a/src/app/cartography/components/draggable-selection/draggable-selection.component.spec.ts b/src/app/cartography/components/draggable-selection/draggable-selection.component.spec.ts index fb362b7e..2bb40d57 100644 --- a/src/app/cartography/components/draggable-selection/draggable-selection.component.spec.ts +++ b/src/app/cartography/components/draggable-selection/draggable-selection.component.spec.ts @@ -21,7 +21,7 @@ import { MapLabel } from '../../models/map/map-label'; import { MapLinkNode } from '../../models/map/map-link-node'; import { select } from 'd3-selection'; import { MapLink } from '../../models/map/map-link'; -import { MapSettingService } from '../../../services/mapsettings.service'; +import { MapSettingsService } from '../../../services/mapsettings.service'; describe('DraggableSelectionComponent', () => { let component: DraggableSelectionComponent; @@ -123,7 +123,7 @@ describe('DraggableSelectionComponent', () => { { provide: DrawingsEventSource, useValue: drawingsEventSourceStub }, { provide: GraphDataManager, useValue: mockedGraphDataManager }, { provide: LinksEventSource, useValue: linksEventSourceStub }, - { provide: MapSettingService, useClass: MapSettingService } + { provide: MapSettingsService, useClass: MapSettingsService } ], declarations: [DraggableSelectionComponent] }).compileComponents(); diff --git a/src/app/cartography/components/draggable-selection/draggable-selection.component.ts b/src/app/cartography/components/draggable-selection/draggable-selection.component.ts index 858a1c8c..867d2e40 100644 --- a/src/app/cartography/components/draggable-selection/draggable-selection.component.ts +++ b/src/app/cartography/components/draggable-selection/draggable-selection.component.ts @@ -17,7 +17,7 @@ import { LabelWidget } from '../../widgets/label'; import { InterfaceLabelWidget } from '../../widgets/interface-label'; import { MapLinkNode } from '../../models/map/map-link-node'; import { LinksEventSource } from '../../events/links-event-source'; -import { MapSettingService } from '../../../services/mapsettings.service'; +import { MapSettingsService } from '../../../services/mapsettings.service'; @Component({ selector: 'app-draggable-selection', @@ -44,7 +44,7 @@ export class DraggableSelectionComponent implements OnInit, OnDestroy { private drawingsEventSource: DrawingsEventSource, private graphDataManager: GraphDataManager, private linksEventSource: LinksEventSource, - private mapSettingsService: MapSettingService + private mapSettingsService: MapSettingsService ) {} ngOnInit() { diff --git a/src/app/common/progress/progress.component.spec.ts b/src/app/common/progress/progress.component.spec.ts index e5c8f4bb..735354d2 100644 --- a/src/app/common/progress/progress.component.spec.ts +++ b/src/app/common/progress/progress.component.spec.ts @@ -7,7 +7,7 @@ import { RouterTestingModule } from '@angular/router/testing'; import { Router } from '@angular/router'; import { BehaviorSubject, Observable } from 'rxjs'; -class MockedRouter { +export class MockedRouter { events: BehaviorSubject; constructor() { diff --git a/src/app/components/help/help.component.html b/src/app/components/help/help.component.html index 0eda8204..eab6611f 100644 --- a/src/app/components/help/help.component.html +++ b/src/app/components/help/help.component.html @@ -3,6 +3,19 @@
+ + + Useful shortcuts + + + ctrl + + to zoom in + ctrl + - to zoom out + ctrl + 0 to reset zoom + ctrl + a to select all items on map + ctrl + shift + a to deselect all items on map + ctrl + shift + s to go to preferences + + Third party components diff --git a/src/app/components/project-map/context-menu/actions/bring-to-front-action/bring-to-front-action.component.html b/src/app/components/project-map/context-menu/actions/bring-to-front-action/bring-to-front-action.component.html new file mode 100644 index 00000000..2a3518ab --- /dev/null +++ b/src/app/components/project-map/context-menu/actions/bring-to-front-action/bring-to-front-action.component.html @@ -0,0 +1,4 @@ + diff --git a/src/app/components/project-map/context-menu/actions/bring-to-front-action/bring-to-front-action.component.spec.ts b/src/app/components/project-map/context-menu/actions/bring-to-front-action/bring-to-front-action.component.spec.ts new file mode 100644 index 00000000..74a264f5 --- /dev/null +++ b/src/app/components/project-map/context-menu/actions/bring-to-front-action/bring-to-front-action.component.spec.ts @@ -0,0 +1,66 @@ +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; +import { BringToFrontActionComponent } from './bring-to-front-action.component'; +import { MatIconModule, MatToolbarModule, MatMenuModule, MatCheckboxModule } from '@angular/material'; +import { CommonModule } from '@angular/common'; +import { NoopAnimationsModule } from '@angular/platform-browser/animations'; +import { MockedDrawingService, MockedDrawingsDataSource, MockedNodesDataSource, MockedNodeService } from '../../../project-map.component.spec'; +import { DrawingService } from '../../../../../services/drawing.service'; +import { NodesDataSource } from '../../../../../cartography/datasources/nodes-datasource'; +import { DrawingsDataSource } from '../../../../../cartography/datasources/drawings-datasource'; +import { NodeService } from '../../../../../services/node.service'; +import { Node } from '../../../../../cartography/models/node'; +import { of } from 'rxjs'; +import { ComponentFactoryResolver } from '@angular/core'; +import { Drawing } from '../../../../../cartography/models/drawing'; + +describe('BringToFrontActionComponent', () => { + let component: BringToFrontActionComponent; + let fixture: ComponentFixture; + let drawingService = new MockedDrawingService(); + let drawingsDataSource = new MockedDrawingsDataSource(); + let nodeService = new MockedNodeService(); + let nodesDataSource = new MockedNodesDataSource(); + + beforeEach(async(() => { + TestBed.configureTestingModule({ + imports: [MatIconModule, MatToolbarModule, MatMenuModule, MatCheckboxModule, CommonModule, NoopAnimationsModule], + providers: [ + { provide: DrawingService, useValue: drawingService }, + { provide: DrawingsDataSource, useValue: drawingsDataSource }, + { provide: NodeService, useValue: nodeService }, + { provide: NodesDataSource, useValue: nodesDataSource }, + ], + declarations: [BringToFrontActionComponent] + }).compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(BringToFrontActionComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should call node service when bring to front action called', () => { + spyOn(nodeService, 'update').and.returnValue(of()); + component.nodes = [{z: 0} as Node]; + component.drawings = []; + + component.bringToFront(); + + expect(nodeService.update).toHaveBeenCalled(); + }); + + it('should call drawing service when bring to front action called', () => { + spyOn(drawingService, 'update').and.returnValue(of()); + component.nodes = []; + component.drawings = [{z: 0} as Drawing]; + + component.bringToFront(); + + expect(drawingService.update).toHaveBeenCalled(); + }); +}); diff --git a/src/app/components/project-map/context-menu/actions/bring-to-front-action/bring-to-front-action.component.ts b/src/app/components/project-map/context-menu/actions/bring-to-front-action/bring-to-front-action.component.ts new file mode 100644 index 00000000..b72848fb --- /dev/null +++ b/src/app/components/project-map/context-menu/actions/bring-to-front-action/bring-to-front-action.component.ts @@ -0,0 +1,43 @@ +import { Component, OnInit, Input } from '@angular/core'; +import { Server } from '../../../../../models/server'; +import { Node } from '../../../../../cartography/models/node'; +import { NodesDataSource } from '../../../../../cartography/datasources/nodes-datasource'; +import { NodeService } from '../../../../../services/node.service'; +import { Drawing } from '../../../../../cartography/models/drawing'; +import { DrawingsDataSource } from '../../../../../cartography/datasources/drawings-datasource'; +import { DrawingService } from '../../../../../services/drawing.service'; + +@Component({ + selector: 'app-bring-to-front-action', + templateUrl: './bring-to-front-action.component.html' +}) +export class BringToFrontActionComponent implements OnInit { + @Input() server: Server; + @Input() nodes: Node[]; + @Input() drawings: Drawing[]; + + constructor( + private nodesDataSource: NodesDataSource, + private drawingsDataSource: DrawingsDataSource, + private nodeService: NodeService, + private drawingService: DrawingService + ) {} + + ngOnInit() {} + + bringToFront() { + this.nodes.forEach((node) => { + node.z = 100; + this.nodesDataSource.update(node); + + this.nodeService.update(this.server, node).subscribe((node: Node) => {}); + }); + + this.drawings.forEach((drawing) => { + drawing.z = 100; + this.drawingsDataSource.update(drawing); + + this.drawingService.update(this.server, drawing).subscribe((drawing: Drawing) => {}); + }); + } +} diff --git a/src/app/components/project-map/context-menu/actions/show-node-action/show-node-action.component.html b/src/app/components/project-map/context-menu/actions/show-node-action/show-node-action.component.html new file mode 100644 index 00000000..b92a0071 --- /dev/null +++ b/src/app/components/project-map/context-menu/actions/show-node-action/show-node-action.component.html @@ -0,0 +1,4 @@ + diff --git a/src/app/components/project-map/context-menu/actions/show-node-action/show-node-action.component.ts b/src/app/components/project-map/context-menu/actions/show-node-action/show-node-action.component.ts new file mode 100644 index 00000000..3f70d051 --- /dev/null +++ b/src/app/components/project-map/context-menu/actions/show-node-action/show-node-action.component.ts @@ -0,0 +1,26 @@ +import { Component, Input, OnInit, OnChanges } from '@angular/core'; +import { Node } from '../../../../../cartography/models/node'; +import { MatDialog } from '@angular/material'; +import { InfoDialogComponent } from '../../../info-dialog/info-dialog.component'; +import { Server } from '../../../../../models/server'; + +@Component({ + selector: 'app-show-node-action', + templateUrl: './show-node-action.component.html' +}) +export class ShowNodeActionComponent { + @Input() node: Node; + @Input() server: Server + + constructor(private dialog: MatDialog) {} + + showNode() { + const dialogRef = this.dialog.open(InfoDialogComponent, { + width: '600px', + autoFocus: false + }); + let instance = dialogRef.componentInstance; + instance.node = this.node; + instance.server = this.server; + } +} diff --git a/src/app/components/project-map/context-menu/context-menu.component.html b/src/app/components/project-map/context-menu/context-menu.component.html index 35ed76d8..69dd1ffa 100644 --- a/src/app/components/project-map/context-menu/context-menu.component.html +++ b/src/app/components/project-map/context-menu/context-menu.component.html @@ -1,6 +1,10 @@
+ + {{node.name}} + + + +
+ +
diff --git a/src/app/components/project-map/info-dialog/info-dialog.component.scss b/src/app/components/project-map/info-dialog/info-dialog.component.scss new file mode 100644 index 00000000..e28aaee8 --- /dev/null +++ b/src/app/components/project-map/info-dialog/info-dialog.component.scss @@ -0,0 +1,3 @@ +.textBox { + margin-top: 10px; +} diff --git a/src/app/components/project-map/info-dialog/info-dialog.component.ts b/src/app/components/project-map/info-dialog/info-dialog.component.ts new file mode 100644 index 00000000..11968f47 --- /dev/null +++ b/src/app/components/project-map/info-dialog/info-dialog.component.ts @@ -0,0 +1,31 @@ +import { Component, Input, OnInit } from '@angular/core'; +import { MatDialogRef } from '@angular/material'; +import { Node } from '../../../cartography/models/node'; +import { InfoService } from '../../../services/info.service'; +import { Server } from '../../../models/server'; + +@Component({ + selector: 'app-info-dialog', + templateUrl: './info-dialog.component.html', + styleUrls: ['./info-dialog.component.scss'] +}) +export class InfoDialogComponent implements OnInit { + @Input() server: Server; + @Input() node: Node; + infoList: string[] = []; + commandLine: string = ''; + + constructor( + public dialogRef: MatDialogRef, + private infoService: InfoService + ) {} + + ngOnInit() { + this.infoList = this.infoService.getInfoAboutNode(this.node, this.server); + this.commandLine = this.infoService.getCommandLine(this.node); + } + + onCloseClick() { + this.dialogRef.close(); + } +} diff --git a/src/app/components/project-map/node-select-interface/node-select-interface.component.html b/src/app/components/project-map/node-select-interface/node-select-interface.component.html index 33d07113..23c5bab7 100644 --- a/src/app/components/project-map/node-select-interface/node-select-interface.component.html +++ b/src/app/components/project-map/node-select-interface/node-select-interface.component.html @@ -1,6 +1,6 @@
- + + + + + + - + @@ -57,6 +77,9 @@ Show interface labels + + Show topology summary +
@@ -120,3 +143,6 @@ +
+ +
diff --git a/src/app/components/project-map/project-map.component.scss b/src/app/components/project-map/project-map.component.scss index 49f1556a..42b3476d 100644 --- a/src/app/components/project-map/project-map.component.scss +++ b/src/app/components/project-map/project-map.component.scss @@ -230,3 +230,11 @@ g.node text, .context-menu-items .mat-menu-item:focus { background: none; } + +.visible { + display: none; +} + +mat-menu-panel { + min-height: 0px; +} diff --git a/src/app/components/project-map/project-map.component.spec.ts b/src/app/components/project-map/project-map.component.spec.ts index 9482b832..bb084782 100644 --- a/src/app/components/project-map/project-map.component.spec.ts +++ b/src/app/components/project-map/project-map.component.spec.ts @@ -1,7 +1,7 @@ import { async, ComponentFixture, TestBed } from '@angular/core/testing'; import { ProjectMapComponent } from './project-map.component'; -import { MatIconModule, MatToolbarModule, MatMenuModule, MatCheckboxModule } from '@angular/material'; +import { MatIconModule, MatToolbarModule, MatMenuModule, MatCheckboxModule, MatDialogModule } from '@angular/material'; import { NoopAnimationsModule } from '@angular/platform-browser/animations'; import { ServerService } from '../../services/server.service'; import { ProjectService } from '../../services/project.service'; @@ -20,14 +20,14 @@ import { DrawingsDataSource } from '../../cartography/datasources/drawings-datas import { CommonModule } from '@angular/common'; import { ANGULAR_MAP_DECLARATIONS } from '../../cartography/angular-map.imports'; import { NO_ERRORS_SCHEMA } from '@angular/core'; -import { ActivatedRoute } from '@angular/router'; +import { ActivatedRoute, Router } from '@angular/router'; import { MockedSettingsService } from '../../services/settings.service.spec'; import { MockedServerService } from '../../services/server.service.spec'; import { MockedProjectService } from '../../services/project.service.spec'; import { Observable } from 'rxjs/Rx'; import { Drawing } from '../../cartography/models/drawing'; import { D3MapComponent } from '../../cartography/components/d3-map/d3-map.component'; -import { of } from 'rxjs'; +import { of, BehaviorSubject } from 'rxjs'; import { Server } from '../../models/server'; import { Node } from '../../cartography/models/node'; import { ToolsService } from '../../services/tools.service'; @@ -48,10 +48,12 @@ import { NodeCreatedLabelStylesFixer } from './helpers/node-created-label-styles import { LabelWidget } from '../../cartography/widgets/label'; import { InterfaceLabelWidget } from '../../cartography/widgets/interface-label'; import { MapLinkNodeToLinkNodeConverter } from '../../cartography/converters/map/map-link-node-to-link-node-converter'; -import { MapSettingService } from '../../services/mapsettings.service'; +import { MapSettingsService } from '../../services/mapsettings.service'; import { ProjectMapMenuComponent } from './project-map-menu/project-map-menu.component'; import { MockedToasterService } from '../../services/toaster.service.spec'; import { ToasterService } from '../../services/toaster.service'; +import { MockedActivatedRoute } from '../snapshots/list-of-snapshots/list-of-snaphshots.component.spec'; +import { MapNodesDataSource, MapLinksDataSource, MapDrawingsDataSource, MapSymbolsDataSource } from '../../cartography/datasources/map-datasource'; export class MockedProgressService { public activate() {} @@ -102,6 +104,10 @@ export class MockedNodeService { saveConfiguration(server: Server, node: Node, configuration: string) { return of(configuration); } + + update(server: Server, node: Node) { + return of(node); + } } export class MockedDrawingService { @@ -195,6 +201,10 @@ export class MockedNodesDataSource { update() { return of({}); } + + public get changes() { + return new BehaviorSubject<[]>([]); + } } export class MockedLinksDataSource { @@ -210,6 +220,7 @@ describe('ProjectMapComponent', () => { let linksDataSource = new MockedLinksDataSource(); let mockedToasterService = new MockedToasterService(); let nodeCreatedLabelStylesFixer; + let mockedRouter = new MockedActivatedRoute; beforeEach(async(() => { nodeCreatedLabelStylesFixer = { @@ -217,7 +228,7 @@ describe('ProjectMapComponent', () => { }; TestBed.configureTestingModule({ - imports: [MatIconModule, MatToolbarModule, MatMenuModule, MatCheckboxModule, CommonModule, NoopAnimationsModule], + imports: [MatIconModule, MatDialogModule, MatToolbarModule, MatMenuModule, MatCheckboxModule, CommonModule, NoopAnimationsModule], providers: [ { provide: ActivatedRoute }, { provide: ServerService, useClass: MockedServerService }, @@ -253,7 +264,13 @@ describe('ProjectMapComponent', () => { { provide: NodeCreatedLabelStylesFixer, useValue: nodeCreatedLabelStylesFixer}, { provide: MapScaleService }, { provide: NodeCreatedLabelStylesFixer, useValue: nodeCreatedLabelStylesFixer}, - { provide: ToasterService, useValue: mockedToasterService } + { provide: ToasterService, useValue: mockedToasterService }, + { provide: Router, useValue: mockedRouter }, + { provide: MapNodesDataSource, useClass: MapNodesDataSource }, + { provide: MapLinksDataSource, useClass: LinksDataSource }, + { provide: MapDrawingsDataSource, useClass: MapDrawingsDataSource }, + { provide: MapSymbolsDataSource, useClass: MapSymbolsDataSource }, + { provide: MapSettingsService, useClass: MapSettingsService } ], declarations: [ProjectMapComponent, ProjectMapMenuComponent, D3MapComponent, ...ANGULAR_MAP_DECLARATIONS], schemas: [NO_ERRORS_SCHEMA] diff --git a/src/app/components/project-map/project-map.component.ts b/src/app/components/project-map/project-map.component.ts index 002d167b..78713a83 100644 --- a/src/app/components/project-map/project-map.component.ts +++ b/src/app/components/project-map/project-map.component.ts @@ -1,5 +1,5 @@ import { Component, OnDestroy, OnInit, ViewChild, ViewEncapsulation } from '@angular/core'; -import { ActivatedRoute, ParamMap } from '@angular/router'; +import { ActivatedRoute, ParamMap, Router } from '@angular/router'; import { Observable, Subject, Subscription, from } from 'rxjs'; import { webSocket } from 'rxjs/webSocket'; @@ -51,6 +51,12 @@ import { LabelWidget } from '../../cartography/widgets/label'; import { MapLinkNodeToLinkNodeConverter } from '../../cartography/converters/map/map-link-node-to-link-node-converter'; import { ProjectMapMenuComponent } from './project-map-menu/project-map-menu.component'; import { ToasterService } from '../../services/toaster.service'; +import { ImportProjectDialogComponent } from '../projects/import-project-dialog/import-project-dialog.component'; +import { MatDialog } from '@angular/material'; +import { AddBlankProjectDialogComponent } from '../projects/add-blank-project-dialog/add-blank-project-dialog.component'; +import { SaveProjectDialogComponent } from '../projects/save-project-dialog/save-project-dialog.component'; +import { MapNodesDataSource, MapLinksDataSource, MapDrawingsDataSource, MapSymbolsDataSource, Indexed } from '../../cartography/datasources/map-datasource'; +import { MapSettingsService } from '../../services/mapsettings.service'; @Component({ @@ -68,6 +74,7 @@ export class ProjectMapComponent implements OnInit, OnDestroy { public server: Server; public ws: WebSocket; public isProjectMapMenuVisible: boolean = false; + public isTopologySummaryVisible: boolean = false; tools = { selection: true, @@ -115,11 +122,19 @@ export class ProjectMapComponent implements OnInit, OnDestroy { private movingEventSource: MovingEventSource, private mapScaleService: MapScaleService, private nodeCreatedLabelStylesFixer: NodeCreatedLabelStylesFixer, - private toasterService: ToasterService + private toasterService: ToasterService, + private dialog: MatDialog, + private router: Router, + private mapNodesDataSource: MapNodesDataSource, + private mapLinksDataSource: MapLinksDataSource, + private mapDrawingsDataSource: MapDrawingsDataSource, + private mapSymbolsDataSource: MapSymbolsDataSource, + private mapSettingsService: MapSettingsService ) {} ngOnInit() { this.settings = this.settingsService.getAll(); + this.isTopologySummaryVisible = this.mapSettingsService.isTopologySummaryVisible; this.progressService.activate(); const routeSub = this.route.paramMap.subscribe((paramMap: ParamMap) => { @@ -196,11 +211,37 @@ export class ProjectMapComponent implements OnInit, OnDestroy { addKeyboardListeners() { Mousetrap.bind('ctrl++', (event: Event) => { event.preventDefault(); + this.zoomIn(); }); Mousetrap.bind('ctrl+-', (event: Event) => { event.preventDefault(); - });; + this.zoomOut(); + }); + + Mousetrap.bind('ctrl+0', (event: Event) => { + event.preventDefault(); + this.resetZoom(); + }); + + Mousetrap.bind('ctrl+a', (event: Event) => { + event.preventDefault(); + let allNodes: Indexed[] = this.mapNodesDataSource.getItems(); + let allDrawings: Indexed[] = this.mapDrawingsDataSource.getItems(); + let allLinks: Indexed[] = this.mapLinksDataSource.getItems(); + let allSymbols: Indexed[] = this.mapSymbolsDataSource.getItems(); + this.selectionManager.setSelected(allNodes.concat(allDrawings).concat(allLinks).concat(allSymbols)); + }); + + Mousetrap.bind('ctrl+shift+a', (event: Event) => { + event.preventDefault(); + this.selectionManager.setSelected([]); + }); + + Mousetrap.bind('ctrl+shift+s', (event: Event) => { + event.preventDefault(); + this.router.navigate(['/server', this.server.id, 'preferences']); + }); } onProjectLoad(project: Project) { @@ -362,6 +403,11 @@ export class ProjectMapComponent implements OnInit, OnDestroy { this.project.show_interface_labels = enabled; } + public toggleShowTopologySummary(visible: boolean) { + this.isTopologySummaryVisible = visible; + this.mapSettingsService.toggleTopologySummary(this.isTopologySummaryVisible); + } + public hideMenu() { this.projectMapMenuComponent.resetDrawToolChoice() this.isProjectMapMenuVisible = false; @@ -386,6 +432,60 @@ export class ProjectMapComponent implements OnInit, OnDestroy { resetZoom() { this.mapScaleService.resetToDefault(); } + + addNewProject() { + const dialogRef = this.dialog.open(AddBlankProjectDialogComponent, { + width: '400px', + autoFocus: false + }); + let instance = dialogRef.componentInstance; + instance.server = this.server; + } + + saveProject() { + const dialogRef = this.dialog.open(SaveProjectDialogComponent, { + width: '400px', + autoFocus: false + }); + let instance = dialogRef.componentInstance; + instance.server = this.server; + instance.project = this.project; + } + + importProject() { + let uuid: string = ''; + const dialogRef = this.dialog.open(ImportProjectDialogComponent, { + width: '400px', + autoFocus: false + }); + let instance = dialogRef.componentInstance; + instance.server = this.server; + const subscription = dialogRef.componentInstance.onImportProject.subscribe((projectId: string) => { + uuid = projectId; + }); + + dialogRef.afterClosed().subscribe(() => { + subscription.unsubscribe(); + if (uuid) { + this.projectService.open(this.server, uuid).subscribe(() => { + this.router.navigate(['/server', this.server.id, 'project', uuid]); + }); + } + }); + } + + exportProject() { + if (this.nodes.filter(node => node.node_type === 'virtualbox').length > 0) { + this.toasterService.error('Map with VirtualBox machines cannot be exported.') + } else if (this.nodes.filter(node => + (node.status === 'started' && node.node_type==='vpcs') || + (node.status === 'started' && node.node_type==='virtualbox') || + (node.status === 'started' && node.node_type==='vmware')).length > 0) { + this.toasterService.error('Project with running nodes cannot be exported.') + } else { + location.assign(this.projectService.getExportPath(this.server, this.project)); + } + } public uploadImageFile(event) { this.readImageFile(event.target); @@ -408,6 +508,12 @@ export class ProjectMapComponent implements OnInit, OnDestroy { imageToUpload.src = window.URL.createObjectURL(file); } + public deleteProject() { + this.projectService.delete(this.server, this.project.project_id).subscribe(() => { + this.router.navigate(['/server', this.server.id, 'projects']); + }); + } + public ngOnDestroy() { this.drawingsDataSource.clear(); this.nodesDataSource.clear(); diff --git a/src/app/components/projects/add-blank-project-dialog/add-blank-project-dialog.component.ts b/src/app/components/projects/add-blank-project-dialog/add-blank-project-dialog.component.ts index 523860fe..c7121539 100644 --- a/src/app/components/projects/add-blank-project-dialog/add-blank-project-dialog.component.ts +++ b/src/app/components/projects/add-blank-project-dialog/add-blank-project-dialog.component.ts @@ -1,4 +1,4 @@ -import { Component, OnInit } from '@angular/core'; +import { Component, OnInit, EventEmitter } from '@angular/core'; import { Router } from '@angular/router'; import { MatDialog, MatDialogRef } from '@angular/material'; import { FormBuilder, FormGroup, Validators, FormControl } from '@angular/forms'; @@ -20,6 +20,8 @@ import { ToasterService } from '../../../services/toaster.service'; export class AddBlankProjectDialogComponent implements OnInit { server: Server; projectNameForm: FormGroup; + uuid: string; + onAddProject = new EventEmitter(); constructor( public dialogRef: MatDialogRef, @@ -62,8 +64,9 @@ export class AddBlankProjectDialogComponent implements OnInit { } addProject(): void { + this.uuid = uuid(); this.projectService - .add(this.server, this.projectNameForm.controls['projectName'].value, uuid()) + .add(this.server, this.projectNameForm.controls['projectName'].value, this.uuid) .subscribe((project: Project) => { this.dialogRef.close(); this.toasterService.success(`Project ${project.name} added`); diff --git a/src/app/components/projects/import-project-dialog/import-project-dialog.component.css b/src/app/components/projects/import-project-dialog/import-project-dialog.component.css index 5485afaa..24be550b 100644 --- a/src/app/components/projects/import-project-dialog/import-project-dialog.component.css +++ b/src/app/components/projects/import-project-dialog/import-project-dialog.component.css @@ -15,6 +15,10 @@ } .file-name-form-field { + width: 90%; +} + +.empty { width: 100%; } diff --git a/src/app/components/projects/import-project-dialog/import-project-dialog.component.html b/src/app/components/projects/import-project-dialog/import-project-dialog.component.html index f06b11fa..277ed2de 100644 --- a/src/app/components/projects/import-project-dialog/import-project-dialog.component.html +++ b/src/app/components/projects/import-project-dialog/import-project-dialog.component.html @@ -11,7 +11,7 @@ [uploader]="uploader" /> - + { diff --git a/src/app/components/projects/import-project-dialog/import-project-dialog.component.ts b/src/app/components/projects/import-project-dialog/import-project-dialog.component.ts index 07a76027..cb996165 100644 --- a/src/app/components/projects/import-project-dialog/import-project-dialog.component.ts +++ b/src/app/components/projects/import-project-dialog/import-project-dialog.component.ts @@ -1,4 +1,4 @@ -import { Component, OnInit, Inject } from '@angular/core'; +import { Component, OnInit, Inject, EventEmitter } from '@angular/core'; import { MatDialogRef, MAT_DIALOG_DATA, MatDialog } from '@angular/material'; import { FileUploader, ParsedResponseHeaders, FileItem } from 'ng2-file-upload'; import { Server } from '../../../models/server'; @@ -26,6 +26,8 @@ export class ImportProjectDialogComponent implements OnInit { projectNameForm: FormGroup; submitted: boolean = false; isFirstStepCompleted: boolean = false; + uuid: string; + onImportProject = new EventEmitter(); constructor( private dialog: MatDialog, @@ -58,6 +60,7 @@ export class ImportProjectDialogComponent implements OnInit { status: number, headers: ParsedResponseHeaders ) => { + this.onImportProject.emit(this.uuid); this.resultMessage = 'Project was imported succesfully!'; this.isFinishEnabled = true; }; @@ -138,7 +141,8 @@ export class ImportProjectDialogComponent implements OnInit { } prepareUploadPath(): string { + this.uuid = uuid(); const projectName = this.projectNameForm.controls['projectName'].value; - return `http://${this.server.host}:${this.server.port}/v2/projects/${uuid()}/import?name=${projectName}`; + return this.projectService.getUploadPath(this.server, this.uuid, projectName); } } diff --git a/src/app/components/projects/projects.component.html b/src/app/components/projects/projects.component.html index 7f785447..90d28c2e 100644 --- a/src/app/components/projects/projects.component.html +++ b/src/app/components/projects/projects.component.html @@ -40,6 +40,9 @@ + diff --git a/src/app/components/projects/projects.component.spec.ts b/src/app/components/projects/projects.component.spec.ts index 29e82cec..3f3a4d8a 100644 --- a/src/app/components/projects/projects.component.spec.ts +++ b/src/app/components/projects/projects.component.spec.ts @@ -85,6 +85,28 @@ describe('ProjectsComponent', () => { expect(mockedProjectService.delete).toHaveBeenCalled(); }); + it('should call project service after duplicate action', () => { + spyOn(mockedProjectService, 'duplicate').and.returnValue(of()); + let project = new Project(); + project.project_id = '1'; + project.status = 'closed'; + + component.duplicate(project); + + expect(mockedProjectService.duplicate).toHaveBeenCalled(); + }); + + it('should call refresh after duplicate action', () => { + spyOn(component, 'refresh'); + let project = new Project(); + project.project_id = '1'; + project.status = 'closed'; + + component.duplicate(project); + + expect(component.refresh).toHaveBeenCalled(); + }); + describe('ProjectComponent open', () => { let project: Project; diff --git a/src/app/components/projects/projects.component.ts b/src/app/components/projects/projects.component.ts index ab50f90f..1c7fa38a 100644 --- a/src/app/components/projects/projects.component.ts +++ b/src/app/components/projects/projects.component.ts @@ -107,6 +107,12 @@ export class ProjectsComponent implements OnInit { ); } + duplicate(project: Project) { + this.projectService.duplicate(this.server, project.project_id, project.name).subscribe(() => { + this.refresh(); + }); + } + addBlankProject() { const dialogRef = this.dialog.open(AddBlankProjectDialogComponent, { width: '400px', diff --git a/src/app/components/projects/save-project-dialog/save-project-dialog.component.css b/src/app/components/projects/save-project-dialog/save-project-dialog.component.css new file mode 100644 index 00000000..acf081db --- /dev/null +++ b/src/app/components/projects/save-project-dialog/save-project-dialog.component.css @@ -0,0 +1,7 @@ +.file-name-form-field { + width: 100%; +} + +.project-snackbar { + background: #2196F3; +} diff --git a/src/app/components/projects/save-project-dialog/save-project-dialog.component.html b/src/app/components/projects/save-project-dialog/save-project-dialog.component.html new file mode 100644 index 00000000..c772cb96 --- /dev/null +++ b/src/app/components/projects/save-project-dialog/save-project-dialog.component.html @@ -0,0 +1,23 @@ +

Save project as

+
+ + + Project name is required + Project name is incorrect + +
+ + +
+
diff --git a/src/app/components/projects/save-project-dialog/save-project-dialog.component.ts b/src/app/components/projects/save-project-dialog/save-project-dialog.component.ts new file mode 100644 index 00000000..afb76ec0 --- /dev/null +++ b/src/app/components/projects/save-project-dialog/save-project-dialog.component.ts @@ -0,0 +1,77 @@ +import { Component, OnInit, EventEmitter } from '@angular/core'; +import { Router } from '@angular/router'; +import { MatDialog, MatDialogRef } from '@angular/material'; +import { FormBuilder, FormGroup, Validators, FormControl } from '@angular/forms'; +import { Project } from '../../../models/project'; +import { Server } from '../../../models/server'; +import { ProjectService } from '../../../services/project.service'; +import { v4 as uuid } from 'uuid'; +import { ProjectNameValidator } from '../models/projectNameValidator'; +import { ToasterService } from '../../../services/toaster.service'; + + +@Component({ + selector: 'app-save-project-dialog', + templateUrl: './save-project-dialog.component.html', + styleUrls: ['./save-project-dialog.component.css'], + providers: [ProjectNameValidator] +}) +export class SaveProjectDialogComponent implements OnInit { + server: Server; + project: Project; + projectNameForm: FormGroup; + onAddProject = new EventEmitter(); + + constructor( + public dialogRef: MatDialogRef, + private router: Router, + private dialog: MatDialog, + private projectService: ProjectService, + private toasterService: ToasterService, + private formBuilder: FormBuilder, + private projectNameValidator: ProjectNameValidator + ) { + this.projectNameForm = this.formBuilder.group({ + projectName: new FormControl(null, [Validators.required, projectNameValidator.get]) + }); + } + + ngOnInit() {} + + get form() { + return this.projectNameForm.controls; + } + + onAddClick(): void { + if (this.projectNameForm.invalid) { + return; + } + this.projectService.list(this.server).subscribe((projects: Project[]) => { + const projectName = this.projectNameForm.controls['projectName'].value; + let existingProject = projects.find(project => project.name === projectName); + + if (existingProject) { + this.toasterService.success(`Project with this name already exists.`); + } else { + this.addProject(); + } + }); + } + + onNoClick(): void { + this.dialogRef.close(); + } + + addProject(): void { + this.projectService.duplicate(this.server, this.project.project_id, this.projectNameForm.controls['projectName'].value).subscribe((project: Project) => { + this.dialogRef.close(); + this.toasterService.success(`Project ${project.name} added`); + }); + } + + onKeyDown(event) { + if (event.key === "Enter") { + this.onAddClick(); + } + } +} diff --git a/src/app/components/template/template-list-dialog/template-list-dialog.component.html b/src/app/components/template/template-list-dialog/template-list-dialog.component.html index 346b52d8..3eea74e5 100644 --- a/src/app/components/template/template-list-dialog/template-list-dialog.component.html +++ b/src/app/components/template/template-list-dialog/template-list-dialog.component.html @@ -1,4 +1,4 @@ -
+
diff --git a/src/app/components/template/template-list-dialog/template-list-dialog.component.scss b/src/app/components/template/template-list-dialog/template-list-dialog.component.scss index 6c455eef..6b2a2818 100644 --- a/src/app/components/template/template-list-dialog/template-list-dialog.component.scss +++ b/src/app/components/template/template-list-dialog/template-list-dialog.component.scss @@ -16,3 +16,26 @@ font-size: 16px; flex-grow: 1; } + +div { + scrollbar-color: darkgrey #263238; + scrollbar-width: thin; +} + +mat-table { + scrollbar-color: darkgrey #263238; + scrollbar-width: thin; +} + +::-webkit-scrollbar { + width: 0.5em; +} + +::-webkit-scrollbar-track { + -webkit-box-shadow: inset 0 0 6px rgba(0,0,0,0.3); +} + +::-webkit-scrollbar-thumb { +background-color: darkgrey; +outline: 1px solid #263238; +} diff --git a/src/app/components/template/template-list-dialog/template-list-dialog.component.ts b/src/app/components/template/template-list-dialog/template-list-dialog.component.ts index dce539e3..471305f3 100644 --- a/src/app/components/template/template-list-dialog/template-list-dialog.component.ts +++ b/src/app/components/template/template-list-dialog/template-list-dialog.component.ts @@ -20,7 +20,7 @@ export class TemplateListDialogComponent implements OnInit { dataSource: TemplateDataSource; displayedColumns = ['name']; - @ViewChild('filter', {static: false}) filter: ElementRef; + @ViewChild('filter', {static: true}) filter: ElementRef; constructor( public dialogRef: MatDialogRef, diff --git a/src/app/components/topology-summary/topology-summary.component.html b/src/app/components/topology-summary/topology-summary.component.html new file mode 100644 index 00000000..3ca33e54 --- /dev/null +++ b/src/app/components/topology-summary/topology-summary.component.html @@ -0,0 +1,44 @@ +
+
+ Topology summary ({{projectsStatistics.snapshots}} snapshots) + close +
+ +
+ Filter by status
+
+ Started + Suspended + Stopped +
+
+
+ Sorting
+
+ + By name ascending + By name descending + +
+
+ +
+
+
+ + + + + + + {{node.name}} +
+
+ {{node.console_type}} {{node.console_host}}:{{node.console}} +
+
+ none +
+
+
+
diff --git a/src/app/components/topology-summary/topology-summary.component.scss b/src/app/components/topology-summary/topology-summary.component.scss new file mode 100644 index 00000000..a9629c20 --- /dev/null +++ b/src/app/components/topology-summary/topology-summary.component.scss @@ -0,0 +1,99 @@ +.summaryWrapper { + box-shadow: 0 4px 8px 0 rgba(0, 0, 0, 0.2), 0 6px 20px 0 rgba(0, 0, 0, 0.19); + position: fixed; + top: 20px; + right: 20px; + height: 400px; + width: 300px; + background: #263238; + color: white; + overflow: hidden; + font-size: 12px; +} + +.summaryHeaderMenu { + height: 24px; +} + +.summaryHeader { + width: 100%; + height: 24px; + display: flex; + justify-content: space-between; + margin-right: 5px; +} + +.summaryFilters { + margin-left: 5px; + margin-right: 5px; +} + +.summarySorting { + margin-left: 5px; + margin-right: 5px; +} + +.summaryContent { + margin-left: 5px; + margin-right: 5px; + max-height: 240px; + overflow: auto; + scrollbar-color: darkgrey #263238; + scrollbar-width: thin; +} + +.title { + margin-left: 5px; + margin-top: 4px; +} + +.divider { + margin: 5px; + width: 290px; + height: 2px; +} + +.nodeRow { + width: 100%; + display: flex; + justify-content: space-between; + padding-right: 5px; +} + +mat-icon { + font-size: 18px; + width: 20px; + height: 20px; + margin-top: 4px; +} + +::-webkit-scrollbar { + width: 0.5em; +} + +::-webkit-scrollbar-track { + -webkit-box-shadow: inset 0 0 6px rgba(0,0,0,0.3); +} + +::-webkit-scrollbar-thumb { + background-color: darkgrey; + outline: 1px solid #263238; +} + +.radio-group-wrapper { + margin-top: 5px; +} + +.radio-group { + display: flex; + justify-content: space-between; +} + +.closeButton { + cursor: pointer; +} + +.filterBox { + display: flex; + justify-content: space-between; +} diff --git a/src/app/components/topology-summary/topology-summary.component.spec.ts b/src/app/components/topology-summary/topology-summary.component.spec.ts new file mode 100644 index 00000000..3cbffe52 --- /dev/null +++ b/src/app/components/topology-summary/topology-summary.component.spec.ts @@ -0,0 +1,148 @@ +import { TopologySummaryComponent } from "./topology-summary.component"; +import { ComponentFixture, async, TestBed } from '@angular/core/testing'; +import { ProjectService } from '../../services/project.service'; +import { MockedProjectService } from '../../services/project.service.spec'; +import { MatTableModule, MatTooltipModule, MatIconModule, MatSortModule, MatDialogModule } from '@angular/material'; +import { NoopAnimationsModule } from '@angular/platform-browser/animations'; +import { RouterTestingModule } from '@angular/router/testing'; +import { of } from 'rxjs'; +import { MockedNodesDataSource } from '../project-map/project-map.component.spec'; +import { NodesDataSource } from '../../cartography/datasources/nodes-datasource'; +import { NO_ERRORS_SCHEMA } from '@angular/core'; +import { Project } from '../../models/project'; +import { Node } from '../../cartography/models/node'; + + +describe('TopologySummaryComponent', () => { + let component: TopologySummaryComponent; + let fixture: ComponentFixture; + let mockedProjectService: MockedProjectService = new MockedProjectService(); + let mockedNodesDataSource: MockedNodesDataSource = new MockedNodesDataSource(); + + beforeEach(async(() => { + TestBed.configureTestingModule({ + imports: [ + MatTableModule, + MatTooltipModule, + MatIconModule, + MatSortModule, + MatDialogModule, + NoopAnimationsModule, + RouterTestingModule.withRoutes([]) + ], + providers: [ + { provide: ProjectService, useValue: mockedProjectService }, + { provide: NodesDataSource, useValue: mockedNodesDataSource } + ], + declarations: [TopologySummaryComponent], + schemas: [NO_ERRORS_SCHEMA] + }).compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(TopologySummaryComponent); + component = fixture.componentInstance; + component.project = { project_id: 1 } as unknown as Project; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should show only running nodes when filter started applied', () => { + component.nodes = [ + { status: 'started' } as Node, + { status: 'stopped' } as Node + ]; + + component.applyStatusFilter(true, 'started'); + + expect(component.filteredNodes.length).toBe(1); + expect(component.filteredNodes[0].status).toBe('started'); + }); + + it('should show only stopped nodes when filter stopped applied', () => { + component.nodes = [ + { status: 'started' } as Node, + { status: 'stopped' } as Node + ]; + + component.applyStatusFilter(true, 'stopped'); + + expect(component.filteredNodes.length).toBe(1); + expect(component.filteredNodes[0].status).toBe('stopped'); + }); + + it('should show only suspended nodes when filter suspended applied', () => { + component.nodes = [ + { status: 'started' } as Node, + { status: 'stopped' } as Node + ]; + + component.applyStatusFilter(true, 'suspended'); + + expect(component.filteredNodes.length).toBe(0); + }); + + it('should show all nodes when all filters applied', () => { + component.nodes = [ + { status: 'started' } as Node, + { status: 'stopped' } as Node + ]; + + component.applyStatusFilter(true, 'suspended'); + component.applyStatusFilter(true, 'started'); + component.applyStatusFilter(true, 'stopped'); + + expect(component.filteredNodes.length).toBe(2); + }); + + it('should show all nodes when no filters applied', () => { + component.nodes = [ + { status: 'started' } as Node, + { status: 'stopped' } as Node + ]; + + component.applyStatusFilter(true, 'suspended'); + component.applyStatusFilter(true, 'started'); + component.applyStatusFilter(true, 'stopped'); + + expect(component.filteredNodes.length).toBe(2); + + component.applyStatusFilter(false, 'stopped'); + + expect(component.filteredNodes.length).toBe(1); + + component.applyStatusFilter(false, 'suspended'); + component.applyStatusFilter(false, 'started'); + + expect(component.filteredNodes.length).toBe(2); + }); + + it('should sort nodes in correct order', () => { + component.nodes = [ + { status: 'started', name: 'A' } as Node, + { status: 'stopped', name: 'B' } as Node, + { status: 'stopped', name: 'D' } as Node, + ]; + + component.applyFilters(); + component.setSortingOrder('asc'); + + expect(component.filteredNodes[0].name).toBe('A'); + }); + + it('should sort filtered nodes in correct order', () => { + component.nodes = [ + { status: 'started', name: 'A' } as Node, + { status: 'stopped', name: 'B' } as Node, + { status: 'stopped', name: 'D' } as Node, + ]; + + component.applyStatusFilter(true, 'stopped'); + component.setSortingOrder('desc'); + + expect(component.filteredNodes[0].name).toBe('D'); + }); +}); diff --git a/src/app/components/topology-summary/topology-summary.component.ts b/src/app/components/topology-summary/topology-summary.component.ts new file mode 100644 index 00000000..a53402e1 --- /dev/null +++ b/src/app/components/topology-summary/topology-summary.component.ts @@ -0,0 +1,116 @@ +import { Component, OnInit, OnDestroy, Input, AfterViewInit, Output, EventEmitter } from '@angular/core'; +import { Project } from '../../models/project'; +import { Server } from '../../models/server'; +import { NodesDataSource } from '../../cartography/datasources/nodes-datasource'; +import { Node } from '../../cartography/models/node'; +import { Subscription } from 'rxjs'; +import { ProjectService } from '../../services/project.service'; +import { ProjectStatistics } from '../../models/project-statistics'; + + +@Component({ + selector: 'app-topology-summary', + templateUrl: './topology-summary.component.html', + styleUrls: ['./topology-summary.component.scss'] +}) +export class TopologySummaryComponent implements OnInit, OnDestroy { + @Input() server: Server; + @Input() project: Project; + + @Output() closeTopologySummary = new EventEmitter(); + + private subscriptions: Subscription[] = []; + projectsStatistics: ProjectStatistics; + nodes: Node[] = []; + filteredNodes: Node[] = []; + sortingOrder: string = 'asc'; + startedStatusFilterEnabled: boolean = false; + suspendedStatusFilterEnabled: boolean = false; + stoppedStatusFilterEnabled: boolean = false; + + constructor( + private nodesDataSource: NodesDataSource, + private projectService: ProjectService + ) {} + + ngOnInit() { + this.subscriptions.push( + this.nodesDataSource.changes.subscribe((nodes: Node[]) => { + this.nodes = nodes; + if (this.sortingOrder === 'asc') { + this.filteredNodes = nodes.sort(this.compareAsc); + } else { + this.filteredNodes = nodes.sort(this.compareDesc); + } + }) + ); + + this.projectService.getStatistics(this.server, this.project.project_id).subscribe((stats) => { + this.projectsStatistics = stats; + }); + } + + compareAsc(first: Node, second: Node) { + if (first.name < second.name) return -1; + return 1; + } + + compareDesc(first: Node, second: Node) { + if (first.name < second.name) return 1; + return -1; + } + + ngOnDestroy() { + this.subscriptions.forEach((subscription: Subscription) => subscription.unsubscribe()); + } + + setSortingOrder(order: string) { + this.sortingOrder = order; + if (this.sortingOrder === 'asc') { + this.filteredNodes = this.filteredNodes.sort(this.compareAsc); + } else { + this.filteredNodes = this.filteredNodes.sort(this.compareDesc); + } + } + + applyStatusFilter(value: boolean, filter: string) { + if (filter === 'started') { + this.startedStatusFilterEnabled = value; + } else if (filter === 'stopped') { + this.stoppedStatusFilterEnabled = value; + } else if (filter === 'suspended') { + this.suspendedStatusFilterEnabled = value; + } + this.applyFilters(); + } + + applyFilters() { + let nodes: Node[] = []; + + if (this.startedStatusFilterEnabled) { + nodes = nodes.concat(this.nodes.filter(n => n.status === 'started')); + } + + if (this.stoppedStatusFilterEnabled) { + nodes = nodes.concat(this.nodes.filter(n => n.status === 'stopped')); + } + + if (this.suspendedStatusFilterEnabled) { + nodes = nodes.concat(this.nodes.filter(n => n.status === 'suspended')); + } + + if (!this.startedStatusFilterEnabled && !this.stoppedStatusFilterEnabled && !this.suspendedStatusFilterEnabled) { + nodes = nodes.concat(this.nodes); + } + + if (this.sortingOrder === 'asc') { + this.filteredNodes = nodes.sort(this.compareAsc); + } else { + this.filteredNodes = nodes.sort(this.compareDesc); + } + } + + close() { + this.closeTopologySummary.emit(false); + } +} diff --git a/src/app/models/project-statistics.ts b/src/app/models/project-statistics.ts new file mode 100644 index 00000000..c26566ce --- /dev/null +++ b/src/app/models/project-statistics.ts @@ -0,0 +1,6 @@ +export class ProjectStatistics { + drawings: number; + links: number; + nodes: number; + snapshots: number +} diff --git a/src/app/services/info.service.ts b/src/app/services/info.service.ts new file mode 100644 index 00000000..ed3860f4 --- /dev/null +++ b/src/app/services/info.service.ts @@ -0,0 +1,77 @@ +import { Injectable } from "@angular/core"; +import { Node } from '../cartography/models/node'; +import { Port } from '../models/port'; +import { Server } from '../models/server'; + +@Injectable() +export class InfoService { + getInfoAboutNode(node: Node, server: Server): string[] { + let infoList: string[] = []; + if (node.node_type === 'cloud') { + infoList.push(`Cloud ${node.name} is always on.`); + } else if (node.node_type === 'nat') { + infoList.push(`NAT ${node.name} is always on.`); + } else if (node.node_type === 'ethernet-hub') { + infoList.push(`Ethernet hub ${node.name} is always on.`); + } else if (node.node_type === 'ethernet_switch') { + infoList.push(`Ethernet switch ${node.name} is always on.`); + } else if (node.node_type === 'frame_relay_switch') { + infoList.push(`Frame relay switch ${node.name} is always on.`); + } else if (node.node_type === 'atm_switch') { + infoList.push(`ATM switch ${node.name} is always on.`); + } else if (node.node_type === 'docker') { + infoList.push(`Docker ${node.name} is ${node.status}.`); + } else if (node.node_type === 'dynamips') { + infoList.push(`Dynamips ${node.name} is always on.`); + } else if (node.node_type === 'traceng') { + infoList.push(`TraceNG ${node.name} is always on.`); + } else if (node.node_type === 'virtualbox') { + infoList.push(`VirtualBox VM ${node.name} is ${node.status}.`); + } else if (node.node_type === 'vmware') { + infoList.push(`VMware VM ${node.name} is ${node.status}.`); + } else if (node.node_type === 'qemu') { + infoList.push(`QEMU VM ${node.name} is ${node.status}.`); + } else if (node.node_type === 'iou') { + infoList.push(`IOU ${node.name} is always on.`); + } else if (node.node_type === 'vpcs') { + infoList.push(`Node ${node.name} is ${node.status}.`); + } + infoList.push(`Running on server ${server.name} with port ${server.port}.`); + infoList.push(`Server ID is ${server.id}.`); + if (node.console_type !== 'none' && node.console_type !== 'null') { + infoList.push(`Console is on port ${node.console} and type is ${node.console_type}.`); + } + infoList = infoList.concat(this.getInfoAboutPorts(node.ports)); + return infoList; + } + + getInfoAboutPorts(ports: Port[]): string { + let response: string = `Ports: ` + ports.forEach(port => { + response += `link_type: ${port.link_type}, + name: ${port.name}; ` + }); + response = response.substring(0, response.length - 2); + return response; + } + + getCommandLine(node: Node): string { + if (node.node_type === "cloud" || + node.node_type === "nat" || + node.node_type === "ethernet_hub" || + node.node_type === "ethernet_switch" || + node.node_type === "frame_relay_switch" || + node.node_type === "atm_switch" || + node.node_type === "dynamips" || + node.node_type === "traceng" || + node.node_type === "iou") { + return 'Command line information is not supported for this type of node.'; + } else { + if (node.status === 'started') { + return node.command_line; + } else { + return 'Please start the node in order to get the command line information.'; + } + } + } +} diff --git a/src/app/services/mapsettings.service.ts b/src/app/services/mapsettings.service.ts index 640c5671..caf4a6c8 100644 --- a/src/app/services/mapsettings.service.ts +++ b/src/app/services/mapsettings.service.ts @@ -2,12 +2,17 @@ import { Injectable } from "@angular/core"; import { Subject } from 'rxjs'; @Injectable() -export class MapSettingService { +export class MapSettingsService { public isMapLocked = new Subject(); + public isTopologySummaryVisible: boolean = false; constructor() {} changeMapLockValue(value: boolean) { this.isMapLocked.next(value); } + + toggleTopologySummary(value: boolean) { + this.isTopologySummaryVisible = value; + } } diff --git a/src/app/services/project.service.spec.ts b/src/app/services/project.service.spec.ts index 2752923a..0bcb1230 100644 --- a/src/app/services/project.service.spec.ts +++ b/src/app/services/project.service.spec.ts @@ -41,6 +41,14 @@ export class MockedProjectService { delete(server: Server, project_id: string) { return of(project_id); } + + duplicate(server: Server, project_id: string) { + return of(project_id); + } + + getStatistics(server: Server, project_id: string) { + return of({}); + } } describe('ProjectService', () => { @@ -132,6 +140,13 @@ describe('ProjectService', () => { expect(req.request.method).toEqual('DELETE'); }); + it('should duplicate the project', () => { + service.duplicate(server, 'projectId', 'projectName').subscribe(); + + const req = httpTestingController.expectOne('http://127.0.0.1:3080/v2/projects/projectId/duplicate'); + expect(req.request.method).toEqual('POST'); + }); + it('should get notifications path of project', () => { const path = service.notificationsPath(server, 'myproject'); expect(path).toEqual('ws://127.0.0.1:3080/v2/projects/myproject/notifications/ws'); diff --git a/src/app/services/project.service.ts b/src/app/services/project.service.ts index 4ca87f98..193722d6 100644 --- a/src/app/services/project.service.ts +++ b/src/app/services/project.service.ts @@ -49,6 +49,26 @@ export class ProjectService { return this.httpServer.delete(server, `/projects/${project_id}`); } + getUploadPath(server: Server, uuid: string, project_name: string) { + return `http://${server.host}:${server.port}/v2/projects/${uuid}/import?name=${project_name}`; + } + + getExportPath(server: Server, project: Project) { + return `http://${server.host}:${server.port}/v2/projects/${project.project_id}/export`; + } + + export(server: Server, project_id: string): Observable { + return this.httpServer.get(server, `/projects/${project_id}/export`) + } + + getStatistics(server: Server, project_id: string): Observable { + return this.httpServer.get(server, `/projects/${project_id}/stats`); + } + + duplicate(server: Server, project_id: string, project_name): Observable { + return this.httpServer.post(server, `/projects/${project_id}/duplicate`, { name: project_name }); + } + notificationsPath(server: Server, project_id: string): string { return `ws://${server.host}:${server.port}/v2/projects/${project_id}/notifications/ws`; } diff --git a/src/styles.css b/src/styles.css index a10f8c16..4c521464 100644 --- a/src/styles.css +++ b/src/styles.css @@ -33,3 +33,7 @@ a.table-link { app-root { width: 100%; } + +mat-menu-panel { + min-height: 0px; +}