diff --git a/local-server.js b/local-server.js new file mode 100644 index 00000000..2f01fdcc --- /dev/null +++ b/local-server.js @@ -0,0 +1,223 @@ +const { spawn } = require('child_process'); +const kill = require('tree-kill'); +const path = require('path'); +const fs = require('fs'); +const { ipcMain } = require('electron') + +const isWin = /^win/.test(process.platform); + +let runningServers = {}; + +exports.getLocalServerPath = async () => { + const distDirectory = path.join(__dirname, 'dist'); + if (!fs.existsSync(distDirectory)) { + return; + } + + const files = fs.readdirSync(distDirectory); + + let serverPath = null; + + files.forEach((directory) => { + if(directory.startsWith('exe.')) { + if (isWin) { + serverPath = path.join(__dirname, 'dist', directory, 'gns3server.exe'); + } + else { + serverPath = path.join(__dirname, 'dist', directory, 'gns3server'); + } + } + }); + + if(serverPath !== null && fs.existsSync(serverPath)) { + return serverPath; + } + + return; +} + +exports.startLocalServer = async (server) => { + return await run(server, { + logStdout: true + }); +} + +exports.stopLocalServer = async (server) => { + return await stop(server.name); +} + +exports.getRunningServers = () => { + return Object.keys(runningServers); +} + +exports.stopAllLocalServers = async () => { + return await stopAll(); +} + +function getServerArguments(server, overrides) { + let serverArguments = []; + return serverArguments; +} + +function getChannelForServer(server) { + return `local-server-run-${server.name}`; +} + +function notifyStatus(status) { + ipcMain.emit('local-server-status-events', status); +} + +function filterOutput(line) { + const index = line.search('CRITICAL'); + if(index > -1) { + return { + isCritical: true, + errorMessage: line.substr(index) + }; + } + return { + isCritical: false + } +} + +async function stopAll() { + for(var serverName in runningServers) { + let result, error = await stop(serverName); + } + console.log(`Stopped all servers`); +} + +async function stop(serverName) { + let pid = undefined; + + const runningServer = runningServers[serverName]; + + if(runningServer !== undefined && runningServer.process) { + pid = runningServer.process.pid; + } + + console.log(`Stopping '${serverName}' with PID='${pid}'`); + + const stopped = new Promise((resolve, reject) => { + if(pid === undefined) { + resolve(`Server '${serverName} is already stopped`); + delete runningServers[serverName]; + return; + } + + kill(pid, (error) => { + if(error) { + console.error(`Error occured during stopping '${serverName}' with PID='${pid}'`); + reject(error); + } + else { + delete runningServers[serverName]; + console.log(`Stopped '${serverName}' with PID='${pid}'`); + resolve(`Stopped '${serverName}' with PID='${pid}'`); + + notifyStatus({ + serverName: serverName, + status: 'stopped', + message: `Server '${serverName}' stopped'` + }); + } + }); + }); + + return stopped; +} + +async function run(server, options) { + if(!options) { + options = {}; + } + + const logStdout = options.logStdout || false; + const logSterr = options.logSterr || false; + + console.log(`Running '${server.path}'`); + + let serverProcess = spawn(server.path, getServerArguments(server)); + + notifyStatus({ + serverName: server.name, + status: 'started', + message: `Server '${server.name}' started'` + }); + + runningServers[server.name] = { + process: serverProcess + }; + + serverProcess.stdout.on('data', function(data) { + const line = data.toString(); + const { isCritical, errorMessage } = filterOutput(line); + if(isCritical) { + notifyStatus({ + serverName: server.name, + status: 'stderr', + message: `Server reported error: '${errorMessage}` + }); + } + + if(logStdout) { + console.log(data.toString()); + } + }); + + serverProcess.stderr.on('data', function(data) { + if(logSterr) { + console.log(data.toString()); + } + }); + + serverProcess.on('exit', (code, signal) => { + notifyStatus({ + serverName: server.name, + status: 'errored', + message: `Server '${server.name}' has exited with status='${code}'` + }); + }); + + serverProcess.on('error', (err) => { + notifyStatus({ + serverName: server.name, + status: 'errored', + message: `Server errored: '${errorMessage}` + }); + }); + +} + +async function main() { + await run({ + name: 'my-local', + path: 'c:\\Program Files\\GNS3\\gns3server.EXE', + port: 3080 + }, { + logStdout: true + }); +} + +ipcMain.on('local-server-run', async function (event, server) { + const responseChannel = getChannelForServer(); + await run(server); + event.sender.send(responseChannel, { + success: true + }); +}); + + +if (require.main === module) { + process.on('SIGINT', function() { + console.log("Caught interrupt signal"); + stopAll(); + }); + + process.on('unhandledRejection', (reason, promise) => { + console.log(`UnhandledRejection occured '${reason}'`); + process.exit(1); + }); + + main(); +} \ No newline at end of file diff --git a/main.js b/main.js index 74f42f20..d111a956 100644 --- a/main.js +++ b/main.js @@ -1,18 +1,13 @@ const electron = require('electron'); -const fs = require('fs'); const app = electron.app; const BrowserWindow = electron.BrowserWindow; const path = require('path'); const url = require('url'); const yargs = require('yargs'); -const { ipcMain } = require('electron') - // Keep a global reference of the window object, if you don't, the window will // be closed automatically when the JavaScript object is garbage collected. let mainWindow; -let serverProc = null; -let isWin = /^win/.test(process.platform); let isDev = false; const argv = yargs @@ -29,49 +24,6 @@ if (argv.e == 'dev') { } -const createServerProc = () => { - const directory = path.join(__dirname, 'dist'); - - if (!fs.existsSync(directory)) { - return; - } - - fs.readdir(path.join(__dirname, 'dist'), (err, files) => { - var serverPath = null; - - files.forEach((filename) => { - if(filename.startsWith('exe.')) { - if (isWin) { - serverPath = path.join(__dirname, 'dist', filename, 'gns3server.exe'); - } - else { - serverPath = path.join(__dirname, 'dist', filename, 'gns3server'); - } - } - }); - - if (serverPath == null) { - console.error('gns3server cannot be found'); - } - - if (serverPath != null) { - serverProc = require('child_process').execFile(serverPath, []); - - if (serverProc != null) { - console.log('gns3server started from path: ' + serverPath); - } - } - }); -} - -const exitServerProc = () => { - if(serverProc) { - serverProc.kill(); - serverProc = null; - } -} - - function createWindow () { // Create the browser window. mainWindow = new BrowserWindow({ @@ -107,11 +59,17 @@ function createWindow () { } // Emitted when the window is closed. - mainWindow.on('closed', function () { + mainWindow.on('closed',async function () { // Dereference the window object, usually you would store windows // in an array if your app supports multi windows, this is the time // when you should delete the corresponding element. - mainWindow = null + mainWindow = null; + }); + + + // forward event to renderer + electron.ipcMain.on('local-server-status-events', (event) => { + mainWindow.webContents.send('local-server-status-events', event); }); } @@ -136,7 +94,7 @@ app.on('activate', function () { // On OS X it's common to re-create a window in the app when the // dock icon is clicked and there are no other windows open. if (mainWindow === null) { - createWindow() + createWindow(); } }); diff --git a/package.json b/package.json index 2b4533c1..27bd66c4 100644 --- a/package.json +++ b/package.json @@ -61,14 +61,14 @@ "material-design-icons": "^3.0.1", "ng2-file-upload": "^1.3.0", "ngx-electron": "^2.0.0", + "node-fetch": "^2.3.0", "notosans-fontface": "^1.1.0", "raven-js": "^3.27.0", "rxjs": "^6.3.3", "rxjs-compat": "^6.3.3", "typeface-roboto": "^0.0.54", "yargs": "^12.0.5", - "zone.js": "^0.8.26", - "node-fetch": "^2.3.0" + "zone.js": "^0.8.26" }, "devDependencies": { "@angular-devkit/build-angular": "~0.11.4", @@ -97,6 +97,7 @@ "prettier": "^1.15.2", "protractor": "~5.4.2", "replace": "^1.0.1", + "tree-kill": "^1.2.1", "ts-mockito": "^2.3.1", "ts-node": "~7.0.1", "tslint": "~5.12.0", diff --git a/src/app/app.module.ts b/src/app/app.module.ts index fdab8b00..ecca4863 100644 --- a/src/app/app.module.ts +++ b/src/app/app.module.ts @@ -95,6 +95,7 @@ import { TextEditorDialogComponent } from './components/project-map/drawings-edi import { InstalledSoftwareService } from './services/installed-software.service'; import { ExternalSoftwareDefinitionService } from './services/external-software-definition.service'; import { PlatformService } from './services/platform.service'; +import { ServerManagementService } from './services/server-management.service'; if (environment.production) { Raven.config('https://b2b1cfd9b043491eb6b566fd8acee358@sentry.io/842726', { @@ -197,7 +198,8 @@ if (environment.production) { ToolsService, InstalledSoftwareService, ExternalSoftwareDefinitionService, - PlatformService + PlatformService, + ServerManagementService ], entryComponents: [ AddServerDialogComponent, diff --git a/src/app/components/project-map/project-map.component.ts b/src/app/components/project-map/project-map.component.ts index 8dffbcf4..075d0150 100644 --- a/src/app/components/project-map/project-map.component.ts +++ b/src/app/components/project-map/project-map.component.ts @@ -158,7 +158,7 @@ export class ProjectMapComponent implements OnInit, OnDestroy { this.subscriptions.push( this.nodesDataSource.changes.subscribe((nodes: Node[]) => { nodes.forEach((node: Node) => { - node.symbol_url = `http://${this.server.ip}:${this.server.port}/v2/symbols/${node.symbol}/raw`; + node.symbol_url = `http://${this.server.host}:${this.server.port}/v2/symbols/${node.symbol}/raw`; }); this.nodes = nodes; diff --git a/src/app/components/projects/add-blank-project-dialog/add-blank-project-dialog.component.spec.ts b/src/app/components/projects/add-blank-project-dialog/add-blank-project-dialog.component.spec.ts index 22ee843e..3ed7d6fd 100644 --- a/src/app/components/projects/add-blank-project-dialog/add-blank-project-dialog.component.spec.ts +++ b/src/app/components/projects/add-blank-project-dialog/add-blank-project-dialog.component.spec.ts @@ -87,7 +87,7 @@ describe('AddBlankProjectDialogComponent', () => { }).compileComponents(); server = new Server(); - server.ip = 'localhost'; + server.host = 'localhost'; server.port = 80; })); diff --git a/src/app/components/projects/import-project-dialog/import-project-dialog.component.spec.ts b/src/app/components/projects/import-project-dialog/import-project-dialog.component.spec.ts index 528c0549..bef5f1b9 100644 --- a/src/app/components/projects/import-project-dialog/import-project-dialog.component.spec.ts +++ b/src/app/components/projects/import-project-dialog/import-project-dialog.component.spec.ts @@ -80,7 +80,7 @@ describe('ImportProjectDialogComponent', () => { }).compileComponents(); server = new Server(); - server.ip = 'localhost'; + server.host = 'localhost'; server.port = 80; })); 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 f8165314..59ad09ee 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 @@ -138,6 +138,6 @@ export class ImportProjectDialogComponent implements OnInit { prepareUploadPath(): string { const projectName = this.projectNameForm.controls['projectName'].value; - return `http://${this.server.ip}:${this.server.port}/v2/projects/${uuid()}/import?name=${projectName}`; + return `http://${this.server.host}:${this.server.port}/v2/projects/${uuid()}/import?name=${projectName}`; } } diff --git a/src/app/components/servers/add-server-dialog.html b/src/app/components/servers/add-server-dialog.html index e4ee5378..489531be 100644 --- a/src/app/components/servers/add-server-dialog.html +++ b/src/app/components/servers/add-server-dialog.html @@ -1,7 +1,18 @@

Add server

- + + + + {{ location.name }} + + + + + + + + diff --git a/src/app/components/servers/server-discovery/server-discovery.component.html b/src/app/components/servers/server-discovery/server-discovery.component.html index 95343b8f..22a35723 100644 --- a/src/app/components/servers/server-discovery/server-discovery.component.html +++ b/src/app/components/servers/server-discovery/server-discovery.component.html @@ -1,6 +1,6 @@ - We've discovered GNS3 server on {{ discoveredServer.ip }}:{{ discoveredServer.port }}{{ discoveredServer.host }}:{{ discoveredServer.port }}, would you like to add to the list? diff --git a/src/app/components/servers/server-discovery/server-discovery.component.spec.ts b/src/app/components/servers/server-discovery/server-discovery.component.spec.ts index 4a9fdf43..0564bcfc 100644 --- a/src/app/components/servers/server-discovery/server-discovery.component.spec.ts +++ b/src/app/components/servers/server-discovery/server-discovery.component.spec.ts @@ -55,12 +55,12 @@ describe('ServerDiscoveryComponent', () => { const getVersionSpy = spyOn(mockedVersionService, 'get').and.returnValue(Observable.of(version)); component.isServerAvailable('127.0.0.1', 3080).subscribe(s => { - expect(s.ip).toEqual('127.0.0.1'); + expect(s.host).toEqual('127.0.0.1'); expect(s.port).toEqual(3080); }); const server = new Server(); - server.ip = '127.0.0.1'; + server.host = '127.0.0.1'; server.port = 3080; expect(getVersionSpy).toHaveBeenCalledWith(server); @@ -68,7 +68,7 @@ describe('ServerDiscoveryComponent', () => { it('should throw error once server is not available', () => { const server = new Server(); - server.ip = '127.0.0.1'; + server.host = '127.0.0.1'; server.port = 3080; const getVersionSpy = spyOn(mockedVersionService, 'get').and.returnValue( @@ -96,13 +96,13 @@ describe('ServerDiscoveryComponent', () => { spyOn(component, 'isServerAvailable').and.callFake((ip, port) => { const server = new Server(); - server.ip = ip; + server.host = ip; server.port = port; return Observable.of(server); }); component.discovery().subscribe(discovered => { - expect(discovered[0].ip).toEqual('127.0.0.1'); + expect(discovered[0].host).toEqual('127.0.0.1'); expect(discovered[0].port).toEqual(3080); expect(discovered.length).toEqual(1); @@ -117,7 +117,7 @@ describe('ServerDiscoveryComponent', () => { beforeEach(function() { server = new Server(); - (server.ip = '199.111.111.1'), (server.port = 3333); + (server.host = '199.111.111.1'), (server.port = 3333); spyOn(component, 'discovery').and.callFake(() => { return Observable.of([server]); @@ -128,7 +128,7 @@ describe('ServerDiscoveryComponent', () => { expect(component.discoveredServer).toBeUndefined(); component.discoverFirstAvailableServer(); tick(); - expect(component.discoveredServer.ip).toEqual('199.111.111.1'); + expect(component.discoveredServer.host).toEqual('199.111.111.1'); expect(component.discoveredServer.port).toEqual(3333); })); @@ -146,7 +146,7 @@ describe('ServerDiscoveryComponent', () => { let server: Server; beforeEach(() => { server = new Server(); - (server.ip = '199.111.111.1'), (server.port = 3333); + (server.host = '199.111.111.1'), (server.port = 3333); component.discoveredServer = server; }); @@ -155,8 +155,9 @@ describe('ServerDiscoveryComponent', () => { component.accept(server); tick(); expect(component.discoveredServer).toBeNull(); - expect(mockedServerService.servers[0].ip).toEqual('199.111.111.1'); + expect(mockedServerService.servers[0].host).toEqual('199.111.111.1'); expect(mockedServerService.servers[0].name).toEqual('199.111.111.1'); + expect(mockedServerService.servers[0].location).toEqual('remote'); })); }); diff --git a/src/app/components/servers/server-discovery/server-discovery.component.ts b/src/app/components/servers/server-discovery/server-discovery.component.ts index 9e5965a3..da682fca 100644 --- a/src/app/components/servers/server-discovery/server-discovery.component.ts +++ b/src/app/components/servers/server-discovery/server-discovery.component.ts @@ -18,7 +18,7 @@ import { ServerDatabase } from '../../../services/server.database'; export class ServerDiscoveryComponent implements OnInit { private defaultServers = [ { - ip: '127.0.0.1', + host: '127.0.0.1', port: 3080 } ]; @@ -42,7 +42,7 @@ export class ServerDiscoveryComponent implements OnInit { ).subscribe(([local, discovered]) => { local.forEach(added => { discovered = discovered.filter(server => { - return !(server.ip == added.ip && server.port == added.port); + return !(server.host == added.host && server.port == added.port); }); }); if (discovered.length > 0) { @@ -56,7 +56,7 @@ export class ServerDiscoveryComponent implements OnInit { this.defaultServers.forEach(testServer => { queries.push( - this.isServerAvailable(testServer.ip, testServer.port).catch(err => { + this.isServerAvailable(testServer.host, testServer.port).catch(err => { return Observable.of(null); }) ); @@ -72,7 +72,7 @@ export class ServerDiscoveryComponent implements OnInit { isServerAvailable(ip: string, port: number): Observable { const server = new Server(); - server.ip = ip; + server.host = ip; server.port = port; return this.versionService.get(server).flatMap((version: Version) => Observable.of(server)); } @@ -83,9 +83,11 @@ export class ServerDiscoveryComponent implements OnInit { accept(server: Server) { if (server.name == null) { - server.name = server.ip; + server.name = server.host; } + server.location = 'remote'; + this.serverService.create(server).then((created: Server) => { this.serverDatabase.addServer(created); this.discoveredServer = null; diff --git a/src/app/components/servers/servers.component.html b/src/app/components/servers/servers.component.html index 2a6d868e..8f4ea9f9 100644 --- a/src/app/components/servers/servers.component.html +++ b/src/app/components/servers/servers.component.html @@ -17,9 +17,14 @@ > + + Location + {{ row.location }} + + - IP - {{ row.ip }} + Host + {{ row.host }} @@ -30,6 +35,14 @@ Actions + + + + diff --git a/src/app/components/servers/servers.component.ts b/src/app/components/servers/servers.component.ts index 3bce6ae0..23d295ad 100644 --- a/src/app/components/servers/servers.component.ts +++ b/src/app/components/servers/servers.component.ts @@ -1,35 +1,70 @@ -import { Component, Inject, OnInit } from '@angular/core'; +import { Component, Inject, OnInit, OnDestroy, ChangeDetectorRef } from '@angular/core'; import { DataSource } from '@angular/cdk/collections'; import { MatDialog, MatDialogRef, MAT_DIALOG_DATA } from '@angular/material'; -import { Observable, merge } from 'rxjs'; +import { Observable, merge, Subscription } from 'rxjs'; import { map } from 'rxjs/operators'; import { Server } from '../../models/server'; import { ServerService } from '../../services/server.service'; import { ServerDatabase } from '../../services/server.database'; +import { ElectronService } from 'ngx-electron'; +import { ServerManagementService } from '../../services/server-management.service'; @Component({ selector: 'app-server-list', templateUrl: './servers.component.html', styleUrls: ['./servers.component.css'] }) -export class ServersComponent implements OnInit { +export class ServersComponent implements OnInit, OnDestroy { dataSource: ServerDataSource; - displayedColumns = ['id', 'name', 'ip', 'port', 'actions']; + displayedColumns = ['id', 'name', 'location', 'ip', 'port', 'actions']; + serverStatusSubscription: Subscription; constructor( private dialog: MatDialog, private serverService: ServerService, - private serverDatabase: ServerDatabase + private serverDatabase: ServerDatabase, + private serverManagement: ServerManagementService, + private changeDetector: ChangeDetectorRef ) {} ngOnInit() { + const runningServersNames = this.serverManagement.getRunningServers(); + this.serverService.findAll().then((servers: Server[]) => { + servers.forEach((server) => { + const serverIndex = runningServersNames.findIndex((serverName) => server.name === serverName); + if(serverIndex >= 0) { + server.status = 'running'; + } + }); this.serverDatabase.addServers(servers); }); this.dataSource = new ServerDataSource(this.serverDatabase); + + this.serverStatusSubscription = this.serverManagement.serverStatusChanged.subscribe((serverStatus) => { + const server = this.serverDatabase.find(serverStatus.serverName); + if(!server) { + return; + } + if(serverStatus.status === 'stopped') { + server.status = 'stopped'; + } + if(serverStatus.status === 'errored') { + server.status = 'stopped'; + } + if(serverStatus.status === 'started') { + server.status = 'running'; + } + this.serverDatabase.update(server); + this.changeDetector.detectChanges(); + }); + } + + ngOnDestroy() { + this.serverStatusSubscription.unsubscribe(); } createModal() { @@ -46,11 +81,28 @@ export class ServersComponent implements OnInit { }); } + getServerStatus(server: Server) { + if(server.location === 'local') { + if(server.status === undefined) { + return 'stopped'; + } + return server.status; + } + } + deleteServer(server: Server) { this.serverService.delete(server).then(() => { this.serverDatabase.remove(server); }); } + + async startServer(server: Server) { + await this.serverManagement.start(server); + } + + async stopServer(server: Server) { + await this.serverManagement.stop(server); + } } @Component({ @@ -61,14 +113,49 @@ export class AddServerDialogComponent implements OnInit { server: Server = new Server(); authorizations = [{ key: 'none', name: 'No authorization' }, { key: 'basic', name: 'Basic authorization' }]; + locations = []; - constructor(public dialogRef: MatDialogRef, @Inject(MAT_DIALOG_DATA) public data: any) {} + constructor( + public dialogRef: MatDialogRef, + private electronService: ElectronService, + @Inject(MAT_DIALOG_DATA) public data: any, + ) {} + + getLocations() { + let locations = []; + if(this.electronService.isElectronApp) { + locations.push({ key: 'local', name: 'Local' }); + } + locations.push({ key: 'remote', name: 'Remote' }); + return locations + } + + getDefaultLocation() { + if(this.electronService.isElectronApp) { + return 'local'; + } + return 'remote'; + } + + getDefaultLocalServerPath() { + if(this.electronService.isElectronApp) { + return this.electronService.remote.require('./local-server.js').getLocalServerPath(); + } + return; + } ngOnInit() { + this.locations = this.getLocations(); this.server.authorization = 'none'; + this.server.location = this.getDefaultLocation(); + this.server.path = this.getDefaultLocalServerPath(); } onAddClick(): void { + // clear path if not local server + if(this.server.location !== 'local') { + this.server.path = null; + } this.dialogRef.close(this.server); } diff --git a/src/app/layouts/default-layout/default-layout.component.spec.ts b/src/app/layouts/default-layout/default-layout.component.spec.ts index 47f00002..db56d793 100644 --- a/src/app/layouts/default-layout/default-layout.component.spec.ts +++ b/src/app/layouts/default-layout/default-layout.component.spec.ts @@ -6,19 +6,27 @@ import { MatIconModule, MatMenuModule, MatToolbarModule, MatProgressSpinnerModul import { RouterTestingModule } from '@angular/router/testing'; import { ProgressComponent } from '../../common/progress/progress.component'; import { ProgressService } from '../../common/progress/progress.service'; +import { ServerManagementService, ServerStateEvent } from '../../services/server-management.service'; +import { ToasterService } from '../../services/toaster.service'; +import { MockedToasterService } from '../../services/toaster.service.spec'; +import { Subject } from 'rxjs'; class ElectronServiceMock { public isElectronApp: boolean; } -describe('DefaultLayoutComponent', () => { +fdescribe('DefaultLayoutComponent', () => { let component: DefaultLayoutComponent; let fixture: ComponentFixture; let electronServiceMock: ElectronServiceMock; + let serverManagementService; beforeEach(async(() => { electronServiceMock = new ElectronServiceMock(); + serverManagementService = { + serverStatusChanged: new Subject() + }; TestBed.configureTestingModule({ declarations: [DefaultLayoutComponent, ProgressComponent], @@ -28,6 +36,14 @@ describe('DefaultLayoutComponent', () => { provide: ElectronService, useValue: electronServiceMock }, + { + provide: ServerManagementService, + useValue: serverManagementService + }, + { + provide: ToasterService, + useClass: MockedToasterService + }, ProgressService ] }).compileComponents(); @@ -54,4 +70,44 @@ describe('DefaultLayoutComponent', () => { component.ngOnInit(); expect(component.isInstalledSoftwareAvailable).toBeFalsy(); }); + + it('should show error when server management service throw event', () => { + const toaster: MockedToasterService = TestBed.get(ToasterService); + serverManagementService.serverStatusChanged.next({ + status: 'errored', + message: 'Message' + }); + expect(toaster.errors).toEqual(['Message']); + }); + + it('should not show error when server management service throw event', () => { + component.ngOnDestroy(); + const toaster: MockedToasterService = TestBed.get(ToasterService); + serverManagementService.serverStatusChanged.next({ + status: 'errored', + message: 'Message' + }); + expect(toaster.errors).toEqual([]); + }); + + describe('auto stopping servers', () => { + let event; + beforeEach(() => { + event = new Event('onbeforeunload'); + }); + + it('should close window with no action when not in electron', async () => { + component.shouldStopServersOnClosing = false; + const isClosed = await component.onBeforeUnload(event); + expect(isClosed).toBeUndefined(); + }); + + it('should stop all servers and close window', () => { + component.shouldStopServersOnClosing = true; + const isClosed = component.onBeforeUnload(event); + expect(isClosed).toBeTruthy(); + }); + }); + + }); diff --git a/src/app/layouts/default-layout/default-layout.component.ts b/src/app/layouts/default-layout/default-layout.component.ts index 162f83c6..32edf9b7 100644 --- a/src/app/layouts/default-layout/default-layout.component.ts +++ b/src/app/layouts/default-layout/default-layout.component.ts @@ -1,5 +1,9 @@ import { ElectronService } from 'ngx-electron'; -import { Component, OnInit, ViewEncapsulation } from '@angular/core'; +import { Component, OnInit, ViewEncapsulation, OnDestroy, HostListener } from '@angular/core'; +import { ServerManagementService } from '../../services/server-management.service'; +import { Subscription } from 'rxjs'; +import { ToasterService } from '../../services/toaster.service'; +import { ProgressService } from '../../common/progress/progress.service'; @Component({ selector: 'app-default-layout', @@ -7,15 +11,53 @@ import { Component, OnInit, ViewEncapsulation } from '@angular/core'; templateUrl: './default-layout.component.html', styleUrls: ['./default-layout.component.css'] }) -export class DefaultLayoutComponent implements OnInit { +export class DefaultLayoutComponent implements OnInit, OnDestroy { public isInstalledSoftwareAvailable = false; + + serverStatusSubscription: Subscription; + shouldStopServersOnClosing = true; constructor( - private electronService: ElectronService + private electronService: ElectronService, + private serverManagement: ServerManagementService, + private toasterService: ToasterService, + private progressService: ProgressService ) {} ngOnInit() { this.isInstalledSoftwareAvailable = this.electronService.isElectronApp; + + // attach to notification stream when any of running local servers experienced issues + this.serverStatusSubscription = this.serverManagement.serverStatusChanged.subscribe((serverStatus) => { + if(serverStatus.status === 'errored') { + this.toasterService.error(serverStatus.message); + } + if(serverStatus.status === 'stderr') { + this.toasterService.error(serverStatus.message); + } + }); + + // stop servers only when in Electron + this.shouldStopServersOnClosing = this.electronService.isElectronApp; + } + + @HostListener('window:beforeunload', ['$event']) + async onBeforeUnload($event) { + if(!this.shouldStopServersOnClosing) { + return; + } + $event.preventDefault() + $event.returnValue = false; + this.progressService.activate(); + await this.serverManagement.stopAll(); + this.shouldStopServersOnClosing = false; + this.progressService.deactivate(); + window.close(); + return false; + } + + ngOnDestroy() { + this.serverStatusSubscription.unsubscribe(); } } diff --git a/src/app/models/server.ts b/src/app/models/server.ts index d3ef061b..2ad4ccd1 100644 --- a/src/app/models/server.ts +++ b/src/app/models/server.ts @@ -1,12 +1,17 @@ export type ServerAuthorization = 'basic' | 'none'; +export type ServerLocation = 'local' | 'remote'; +export type ServerStatus = 'stopped' | 'starting' | 'running'; export class Server { id: number; name: string; - ip: string; + location: ServerLocation; + host: string; port: number; + path: string; authorization: ServerAuthorization; login: string; password: string; is_local: boolean; + status: ServerStatus; } diff --git a/src/app/services/http-server.service.spec.ts b/src/app/services/http-server.service.spec.ts index 9eaccc3f..52f8d0b4 100644 --- a/src/app/services/http-server.service.spec.ts +++ b/src/app/services/http-server.service.spec.ts @@ -218,7 +218,7 @@ describe('HttpServer', () => { }); it('should make local call when ip and port is not defined', () => { - server.ip = null; + server.host = null; server.port = null; service diff --git a/src/app/services/http-server.service.ts b/src/app/services/http-server.service.ts index 9a04024b..a32df1e8 100644 --- a/src/app/services/http-server.service.ts +++ b/src/app/services/http-server.service.ts @@ -164,8 +164,8 @@ export class HttpServer { } private getOptionsForServer(server: Server, url: string, options: T) { - if (server.ip && server.port) { - url = `http://${server.ip}:${server.port}/v2${url}`; + if (server.host && server.port) { + url = `http://${server.host}:${server.port}/v2${url}`; } else { url = `/v2${url}`; } diff --git a/src/app/services/project.service.ts b/src/app/services/project.service.ts index 0724b726..4ca87f98 100644 --- a/src/app/services/project.service.ts +++ b/src/app/services/project.service.ts @@ -50,7 +50,7 @@ export class ProjectService { } notificationsPath(server: Server, project_id: string): string { - return `ws://${server.ip}:${server.port}/v2/projects/${project_id}/notifications/ws`; + return `ws://${server.host}:${server.port}/v2/projects/${project_id}/notifications/ws`; } isReadOnly(project: Project) { diff --git a/src/app/services/server-management.service.spec.ts b/src/app/services/server-management.service.spec.ts new file mode 100644 index 00000000..68036d60 --- /dev/null +++ b/src/app/services/server-management.service.spec.ts @@ -0,0 +1,93 @@ +import { TestBed } from '@angular/core/testing'; + +import { ServerManagementService } from './server-management.service'; +import { ElectronService } from 'ngx-electron'; +import { Server } from '../models/server'; + +describe('ServerManagementService', () => { + let electronService; + let callbacks + let removed; + let server; + + beforeEach(() => { + callbacks = []; + removed = []; + server = undefined; + electronService = { + isElectronApp: true, + ipcRenderer: { + on: (channel, callback) => { + callbacks.push({ + channel: channel, + callback: callback + }); + }, + removeAllListeners: (name) => { + removed.push(name); + } + }, + remote: { + require: (file) => { + return { + startLocalServer: (serv) => { + server = serv; + }, + stopLocalServer: (serv) => { + server = serv; + } + } + } + } + }; + }) + + beforeEach(() => TestBed.configureTestingModule({ + providers: [ + { provide: ElectronService, useValue: electronService}, + ServerManagementService + ] + })); + + it('should be created', () => { + const service: ServerManagementService = TestBed.get(ServerManagementService); + expect(service).toBeTruthy(); + }); + + it('should attach when running as electron app', () => { + TestBed.get(ServerManagementService); + expect(callbacks.length).toEqual(1); + expect(callbacks[0].channel).toEqual('local-server-status-events'); + }); + + it('should not attach when running as not electron app', () => { + electronService.isElectronApp = false; + TestBed.get(ServerManagementService); + expect(callbacks.length).toEqual(0); + }); + + it('should deattach when running as electron app', () => { + const service: ServerManagementService = TestBed.get(ServerManagementService); + service.ngOnDestroy(); + expect(removed).toEqual(['local-server-status-events']); + }); + + it('should not deattach when running as not electron app', () => { + electronService.isElectronApp = false; + const service: ServerManagementService = TestBed.get(ServerManagementService); + service.ngOnDestroy(); + expect(removed).toEqual([]); + }); + + it('should start local server', async () => { + const service: ServerManagementService = TestBed.get(ServerManagementService); + await service.start({ name: 'test'} as Server); + expect(server).toEqual({ name: 'test'}); + }); + + it('should stop local server', async () => { + const service: ServerManagementService = TestBed.get(ServerManagementService); + await service.stop({ name: 'test2'} as Server); + expect(server).toEqual({ name: 'test2'}); + }); +}); diff --git a/src/app/services/server-management.service.ts b/src/app/services/server-management.service.ts new file mode 100644 index 00000000..5dbecd87 --- /dev/null +++ b/src/app/services/server-management.service.ts @@ -0,0 +1,55 @@ +import { Injectable, OnDestroy } from '@angular/core'; +import { Server } from '../models/server'; +import { ElectronService } from 'ngx-electron'; +import { Subject } from 'rxjs'; + +export interface ServerStateEvent { + serverName: string; + status: "started" | "errored" | "stopped" | "stderr"; + message: string; +} + +@Injectable() +export class ServerManagementService implements OnDestroy { + + serverStatusChanged = new Subject(); + + constructor( + private electronService: ElectronService + ) { + if(this.electronService.isElectronApp) { + this.electronService.ipcRenderer.on(this.statusChannel, (event, data) => { + this.serverStatusChanged.next(data); + }); + } + } + + get statusChannel() { + return 'local-server-status-events'; + } + + async start(server: Server) { + return await this.electronService.remote.require('./local-server.js').startLocalServer(server); + } + + async stop(server: Server) { + return await this.electronService.remote.require('./local-server.js').stopLocalServer(server); + } + + async stopAll() { + return await this.electronService.remote.require('./local-server.js').stopAllLocalServers(); + } + + getRunningServers() { + if(this.electronService.isElectronApp) { + return this.electronService.remote.require('./local-server.js').getRunningServers(); + } + return []; + } + + ngOnDestroy() { + if(this.electronService.isElectronApp) { + this.electronService.ipcRenderer.removeAllListeners(this.statusChannel); + } + } +} diff --git a/src/app/services/server.database.ts b/src/app/services/server.database.ts index 4d8af007..cd3abaea 100644 --- a/src/app/services/server.database.ts +++ b/src/app/services/server.database.ts @@ -29,4 +29,20 @@ export class ServerDatabase { this.dataChange.next(this.data.slice()); } } + + public find(serverName: string) { + return this.data.find((server) => server.name === serverName); + } + + public findIndex(serverName: string) { + return this.data.findIndex((server) => server.name === serverName); + } + + public update(server: Server) { + const index = this.findIndex(server.name); + if (index >= 0) { + this.data[index] = server; + this.dataChange.next(this.data.slice()); + } + } } diff --git a/src/app/services/server.service.spec.ts b/src/app/services/server.service.spec.ts index 28e4bd56..2b19f976 100644 --- a/src/app/services/server.service.spec.ts +++ b/src/app/services/server.service.spec.ts @@ -149,7 +149,7 @@ describe('ServerService', () => { const expectedServer = new Server(); expectedServer.name = 'local'; - expectedServer.ip = 'hostname'; + expectedServer.host = 'hostname'; expectedServer.port = 9999; expectedServer.is_local = true; @@ -162,7 +162,7 @@ describe('ServerService', () => { it('should update local server when found', done => { const server = new Server(); server.name = 'local'; - server.ip = 'hostname'; + server.host = 'hostname'; server.port = 9999; server.is_local = true; @@ -170,7 +170,7 @@ describe('ServerService', () => { spyOn(service, 'update').and.returnValue(Promise.resolve(new Server())); service.getLocalServer('hostname-2', 11111).then(() => { - server.ip = 'hostname-2'; + server.host = 'hostname-2'; server.port = 11111; expect(service.update).toHaveBeenCalledWith(server); diff --git a/src/app/services/server.service.ts b/src/app/services/server.service.ts index e1ac7e2e..2aed4eeb 100644 --- a/src/app/services/server.service.ts +++ b/src/app/services/server.service.ts @@ -55,12 +55,12 @@ export class ServerService { return this.onReady(() => this.indexedDbService.get().delete(this.tablename, server.id)); } - public getLocalServer(ip: string, port: number) { + public getLocalServer(host: string, port: number) { const promise = new Promise((resolve, reject) => { this.findAll().then((servers: Server[]) => { const local = servers.find(server => server.is_local); if (local) { - local.ip = ip; + local.host = host; local.port = port; this.update(local).then(updated => { resolve(updated); @@ -68,7 +68,7 @@ export class ServerService { } else { const server = new Server(); server.name = 'local'; - server.ip = ip; + server.host = host; server.port = port; server.is_local = true; this.create(server).then(created => { diff --git a/src/app/services/template.service.spec.ts b/src/app/services/template.service.spec.ts index ac767d33..af1357ff 100644 --- a/src/app/services/template.service.spec.ts +++ b/src/app/services/template.service.spec.ts @@ -29,7 +29,7 @@ describe('TemplateService', () => { it('should ask for the list from server', () => { const server = new Server(); - server.ip = '127.0.0.1'; + server.host = '127.0.0.1'; server.port = 3080; server.authorization = 'none'; diff --git a/src/app/services/testing.ts b/src/app/services/testing.ts index 14b4e03f..8fb0792c 100644 --- a/src/app/services/testing.ts +++ b/src/app/services/testing.ts @@ -2,7 +2,7 @@ import { Server } from '../models/server'; export function getTestServer(): Server { const server = new Server(); - server.ip = '127.0.0.1'; + server.host = '127.0.0.1'; server.port = 3080; server.authorization = 'none'; return server; diff --git a/src/app/services/toaster.service.spec.ts b/src/app/services/toaster.service.spec.ts index df5edbb7..f88476ed 100644 --- a/src/app/services/toaster.service.spec.ts +++ b/src/app/services/toaster.service.spec.ts @@ -2,6 +2,7 @@ import { TestBed, inject } from '@angular/core/testing'; import { MatSnackBar } from '@angular/material'; import { ToasterService } from './toaster.service'; +import { NgZone } from '@angular/core'; export class MockedToasterService { public errors: string[]; @@ -40,7 +41,9 @@ class MockedSnackBar { describe('ToasterService', () => { beforeEach(() => { TestBed.configureTestingModule({ - providers: [ToasterService, { provide: MatSnackBar, useClass: MockedSnackBar }] + providers: [ + ToasterService, + { provide: MatSnackBar, useClass: MockedSnackBar }] }); }); diff --git a/src/app/services/toaster.service.ts b/src/app/services/toaster.service.ts index 24d26ae0..0c579428 100644 --- a/src/app/services/toaster.service.ts +++ b/src/app/services/toaster.service.ts @@ -1,4 +1,4 @@ -import { Injectable } from '@angular/core'; +import { Injectable, NgZone } from '@angular/core'; import { MatSnackBar } from '@angular/material'; @Injectable() @@ -15,13 +15,19 @@ export class ToasterService { MatSnackBarHorizontalPosition: 'center', MatSnackBarVerticalPosition: 'bottom' }; - constructor(private snackbar: MatSnackBar) {} + constructor( + private snackbar: MatSnackBar, + private zone: NgZone) {} public error(message: string) { - this.snackbar.open(message, 'Close', this.snackBarConfigForError); + this.zone.run(() => { + this.snackbar.open(message, 'Close', this.snackBarConfigForError); + }); } public success(message: string) { - this.snackbar.open(message, 'Close', this.snackBarConfigForSuccess); + this.zone.run(() => { + this.snackbar.open(message, 'Close', this.snackBarConfigForSuccess); + }); } }