diff --git a/webapp/angular.json b/webapp/angular.json index 85ba4780a..12115cce2 100644 --- a/webapp/angular.json +++ b/webapp/angular.json @@ -70,7 +70,13 @@ "maximumWarning": "2kb" } ], - "outputHashing": "all" + "outputHashing": "all", + "fileReplacements": [ + { + "replace": "src/environments/environment.ts", + "with": "src/environments/environment.production.ts" + } + ] }, "development": { "buildOptimizer": false, @@ -78,7 +84,13 @@ "vendorChunk": true, "extractLicenses": false, "sourceMap": true, - "namedChunks": true + "namedChunks": true, + "fileReplacements": [ + { + "replace": "src/environments/environment.ts", + "with": "src/environments/environment.development.ts" + } + ] } }, "defaultConfiguration": "development" diff --git a/webapp/src/client/app/modules/web-client/ard/web-client-ard.component.ts b/webapp/src/client/app/modules/web-client/ard/web-client-ard.component.ts index 09e8ced2e..cfef14296 100644 --- a/webapp/src/client/app/modules/web-client/ard/web-client-ard.component.ts +++ b/webapp/src/client/app/modules/web-client/ard/web-client-ard.component.ts @@ -29,6 +29,7 @@ import {UserInteraction, SessionEvent, UserIronRdpError, DesktopSize} from '@dev import '@devolutions/iron-remote-gui-vnc/iron-remote-gui-vnc.umd.cjs'; import {v4 as uuidv4} from "uuid"; import {ExtractedHostnamePort} from "@shared/services/utils/string.service"; +import { AnalyticService, ProtocolString } from "@gateway/shared/services/analytic.service"; enum UserIronRdpErrorKind { General = 0, @@ -75,8 +76,10 @@ export class WebClientArdComponent extends WebClientBaseComponent implements On protected utils: UtilsService, protected gatewayAlertMessageService: GatewayAlertMessageService, private webSessionService: WebSessionService, - private webClientService: WebClientService) { - super(gatewayAlertMessageService); + private webClientService: WebClientService, + protected analyticService: AnalyticService + ) { + super(gatewayAlertMessageService, analyticService); } @HostListener('document:mousemove', ['$event']) onMouseMove(event: MouseEvent): void { @@ -349,6 +352,7 @@ export class WebClientArdComponent extends WebClientBaseComponent implements On this.notifyUser(event.type, event.data); this.disableComponentStatus(); + super.webClientConnectionClosed(); } private handleIronRDPConnectStarted(): void { @@ -413,4 +417,8 @@ export class WebClientArdComponent extends WebClientBaseComponent implements On return 'Connection error: Please verify your connection settings.'; } } + + protected getProtocol() : ProtocolString{ + return "ARD"; + } } diff --git a/webapp/src/client/app/modules/web-client/rdp/web-client-rdp.component.ts b/webapp/src/client/app/modules/web-client/rdp/web-client-rdp.component.ts index 7f1bb21e7..1d11ba6df 100644 --- a/webapp/src/client/app/modules/web-client/rdp/web-client-rdp.component.ts +++ b/webapp/src/client/app/modules/web-client/rdp/web-client-rdp.component.ts @@ -28,6 +28,7 @@ import {RdpFormDataInput} from "@shared/interfaces/forms.interfaces"; import {UserInteraction, SessionEvent, UserIronRdpError, DesktopSize} from '@devolutions/iron-remote-gui'; import '@devolutions/iron-remote-gui/iron-remote-gui.umd.cjs'; +import { AnalyticService, ProtocolString } from '@gateway/shared/services/analytic.service'; enum UserIronRdpErrorKind { @@ -76,8 +77,10 @@ export class WebClientRdpComponent extends WebClientBaseComponent implements On protected utils: UtilsService, protected gatewayAlertMessageService: GatewayAlertMessageService, private webSessionService: WebSessionService, - private webClientService: WebClientService) { - super(gatewayAlertMessageService); + private webClientService: WebClientService, + protected analyticService : AnalyticService + ) { + super(gatewayAlertMessageService,analyticService); } @HostListener('document:mousemove', ['$event']) @@ -353,6 +356,7 @@ export class WebClientRdpComponent extends WebClientBaseComponent implements On this.notifyUser(event.type, event.data); this.disableComponentStatus(); + super.webClientConnectionClosed(); } private handleIronRDPConnectStarted(): void { @@ -418,4 +422,8 @@ export class WebClientRdpComponent extends WebClientBaseComponent implements On return 'Connection error: Please verify your connection settings.'; } } + + protected getProtocol() : ProtocolString{ + return "RDP"; + } } diff --git a/webapp/src/client/app/modules/web-client/ssh/web-client-ssh.component.ts b/webapp/src/client/app/modules/web-client/ssh/web-client-ssh.component.ts index a2d51c7a8..60d5d9a78 100644 --- a/webapp/src/client/app/modules/web-client/ssh/web-client-ssh.component.ts +++ b/webapp/src/client/app/modules/web-client/ssh/web-client-ssh.component.ts @@ -12,7 +12,7 @@ import { import {v4 as uuidv4} from "uuid"; import {MessageService} from "primeng/api"; import {EMPTY, from, Observable, of, Subject, throwError} from "rxjs"; -import {catchError, map, switchMap, takeUntil} from "rxjs/operators"; +import {catchError, map, switchMap, takeUntil, tap} from "rxjs/operators"; import {WebClientBaseComponent} from "@shared/bases/base-web-client.component"; import {UtilsService} from "@shared/services/utils.service"; @@ -30,6 +30,7 @@ import { TerminalConnectionStatus } from "@devolutions/web-ssh-gui"; import {ExtractedHostnamePort} from "@shared/services/utils/string.service"; +import { AnalyticService, ProtocolString } from "@gateway/shared/services/analytic.service"; @Component({ templateUrl: 'web-client-ssh.component.html', @@ -68,8 +69,10 @@ export class WebClientSshComponent extends WebClientBaseComponent implements OnI protected utils: UtilsService, protected gatewayAlertMessageService: GatewayAlertMessageService, private webSessionService: WebSessionService, - private webClientService: WebClientService) { - super(gatewayAlertMessageService); + private webClientService: WebClientService, + protected analyticService: AnalyticService + ) { + super(gatewayAlertMessageService,analyticService); } ngOnInit(): void { @@ -191,7 +194,7 @@ export class WebClientSshComponent extends WebClientBaseComponent implements OnI connectionParameters.privateKey, ) ).pipe( - catchError(error => throwError(error)) + catchError(error => throwError(error)), ); } @@ -260,6 +263,7 @@ export class WebClientSshComponent extends WebClientBaseComponent implements OnI this.notifyUser(status); this.disableComponentStatus(); + super.webClientConnectionClosed(); } private notifyUser(status: TerminalConnectionStatus): void { @@ -279,6 +283,7 @@ export class WebClientSshComponent extends WebClientBaseComponent implements OnI private handleClientConnectStarted(): void { this.loading = false; this.webSessionService.updateWebSessionIcon(this.webSessionId, WebClientSshComponent.DVL_SSH_ICON); + super.webClientConnectionSuccess(); } private handleSshError(error: string): void { @@ -307,4 +312,8 @@ export class WebClientSshComponent extends WebClientBaseComponent implements OnI return 'Unknown Error'; } } + + protected getProtocol() : ProtocolString{ + return "SSH"; + } } diff --git a/webapp/src/client/app/modules/web-client/telnet/web-client-telnet.component.ts b/webapp/src/client/app/modules/web-client/telnet/web-client-telnet.component.ts index b4752068f..8f52c352c 100644 --- a/webapp/src/client/app/modules/web-client/telnet/web-client-telnet.component.ts +++ b/webapp/src/client/app/modules/web-client/telnet/web-client-telnet.component.ts @@ -30,6 +30,7 @@ import { TerminalConnectionStatus, } from '@devolutions/web-telnet-gui'; import {ExtractedHostnamePort} from "@shared/services/utils/string.service"; +import { AnalyticService, ProtocolString } from "@gateway/shared/services/analytic.service"; @Component({ @@ -70,8 +71,10 @@ export class WebClientTelnetComponent extends WebClientBaseComponent implements protected utils: UtilsService, protected gatewayAlertMessageService: GatewayAlertMessageService, private webSessionService: WebSessionService, - private webClientService: WebClientService) { - super(gatewayAlertMessageService); + private webClientService: WebClientService, + protected analyticService: AnalyticService + ) { + super(gatewayAlertMessageService, analyticService); } ngOnInit(): void { @@ -246,6 +249,7 @@ export class WebClientTelnetComponent extends WebClientBaseComponent implements private handleSessionStarted(): void { this.handleClientConnectStarted(); this.currentStatus.isInitialized = true; + super.webClientConnectionSuccess(); } private handleSessionEndedOrError(status: TerminalConnectionStatus): void { @@ -257,6 +261,7 @@ export class WebClientTelnetComponent extends WebClientBaseComponent implements this.notifyUser(status); this.disableComponentStatus(); + super.webClientConnectionClosed(); } private notifyUser(status: TerminalConnectionStatus): void { @@ -306,4 +311,8 @@ export class WebClientTelnetComponent extends WebClientBaseComponent implements return 'Unknown Error'; } } + + protected getProtocol() : ProtocolString{ + return "Telnet"; + } } diff --git a/webapp/src/client/app/modules/web-client/vnc/web-client-vnc.component.ts b/webapp/src/client/app/modules/web-client/vnc/web-client-vnc.component.ts index 0db4cba03..826e6f576 100644 --- a/webapp/src/client/app/modules/web-client/vnc/web-client-vnc.component.ts +++ b/webapp/src/client/app/modules/web-client/vnc/web-client-vnc.component.ts @@ -29,6 +29,7 @@ import {UserInteraction, SessionEvent, UserIronRdpError, DesktopSize} from '@dev import '@devolutions/iron-remote-gui-vnc/iron-remote-gui-vnc.umd.cjs'; import {v4 as uuidv4} from "uuid"; import {ExtractedHostnamePort} from "@shared/services/utils/string.service"; +import { AnalyticService, ProtocolString } from "@gateway/shared/services/analytic.service"; enum UserIronRdpErrorKind { General = 0, @@ -74,8 +75,10 @@ export class WebClientVncComponent extends WebClientBaseComponent implements On protected utils: UtilsService, protected gatewayAlertMessageService: GatewayAlertMessageService, private webSessionService: WebSessionService, - private webClientService: WebClientService) { - super(gatewayAlertMessageService); + private webClientService: WebClientService, + protected analyticService: AnalyticService + ) { + super(gatewayAlertMessageService,analyticService); } @HostListener('document:mousemove', ['$event']) onMouseMove(event: MouseEvent): void { @@ -349,6 +352,7 @@ export class WebClientVncComponent extends WebClientBaseComponent implements On this.notifyUser(event.type, event.data); this.disableComponentStatus(); + super.webClientConnectionClosed(); } private handleIronRDPConnectStarted(): void { @@ -414,4 +418,8 @@ export class WebClientVncComponent extends WebClientBaseComponent implements On return 'Connection error: Please verify your connection settings.'; } } + + protected getProtocol() : ProtocolString{ + return "VNC"; + } } diff --git a/webapp/src/client/app/shared/bases/base-web-client.component.ts b/webapp/src/client/app/shared/bases/base-web-client.component.ts index 3204817e4..819bcbde9 100644 --- a/webapp/src/client/app/shared/bases/base-web-client.component.ts +++ b/webapp/src/client/app/shared/bases/base-web-client.component.ts @@ -1,6 +1,7 @@ import { Directive } from '@angular/core'; import { BaseComponent } from '@shared/bases/base.component'; import {GatewayAlertMessageService} from "@shared/components/gateway-alert-message/gateway-alert-message.service"; +import { AnalyticService, ConnectionIdentifier, ProtocolString } from '../services/analytic.service'; @Directive() export abstract class WebClientBaseComponent extends BaseComponent { @@ -10,7 +11,9 @@ export abstract class WebClientBaseComponent extends BaseComponent { hideSpinnerOnly: boolean = false; error: string; - protected constructor(protected gatewayAlertMessageService: GatewayAlertMessageService) { + analyticHandle: ConnectionIdentifier + + protected constructor(protected gatewayAlertMessageService: GatewayAlertMessageService, protected analyticService:AnalyticService) { super(); } @@ -24,6 +27,7 @@ export abstract class WebClientBaseComponent extends BaseComponent { message = 'Connection successful'; } this.gatewayAlertMessageService.addSuccess(message); + this.analyticHandle = this.analyticService.sendOpenEvent(this.getProtocol()); } protected webClientConnectionFail(message?:string, trace?: string): void { @@ -39,14 +43,13 @@ export abstract class WebClientBaseComponent extends BaseComponent { if (trace) { console.error(trace); } + + this.analyticService.sendCloseEvent(this.analyticHandle); } - protected webClientConnectionClosed(message?:string): void { - if (!message) { - //For translation 'connection closed' - message = 'Connection error: Please verify your connection settings.'; - } - this.gatewayAlertMessageService.addInfo(message); - console.warn(message); + protected webClientConnectionClosed(): void { + this.analyticService.sendCloseEvent(this.analyticHandle); } + + protected abstract getProtocol(): ProtocolString; } diff --git a/webapp/src/client/app/shared/services/analytic.service.ts b/webapp/src/client/app/shared/services/analytic.service.ts new file mode 100644 index 000000000..79546591d --- /dev/null +++ b/webapp/src/client/app/shared/services/analytic.service.ts @@ -0,0 +1,146 @@ +import { Injectable, isDevMode } from '@angular/core'; +import { Protocol } from '../enums/web-client-protocol.enum'; +import { ApiService } from './api.service'; +import { environment } from 'src/environments/environment'; +import { v4 as uuidv4 } from 'uuid'; +import { from } from 'rxjs'; + +@Injectable({ + providedIn: 'root', +}) +export class AnalyticService { + private openedConnections: Map< + string, + { + startTime: Date; + sessionType: ProtocolString; + } + > = new Map(); + + constructor(private apiService: ApiService) { + window.addEventListener('beforeunload', () => { + this.sendCloseAllEvents(); + }); + } + + public sendOpenEvent(connectionType: ProtocolString): ConnectionIdentifier { + this.sendEvent({ + connectionType: connectionType, + }); + + let connectionId = uuidv4(); + this.openedConnections.set(connectionId, { + startTime: new Date(), + sessionType: connectionType, + }); + + return { + id: connectionId, + type: connectionType, + }; + } + + public sendCloseEvent(connectionId: ConnectionIdentifier): void { + let connection = this.openedConnections.get(connectionId.id); + if (!connection) { + return; + } + this.openedConnections.delete(connectionId.id); + let duration = new Date().getTime() - connection.startTime.getTime(); + let durationInSeconds = duration / 1000; + + this.sendEvent({ + connectionType: connection.sessionType, + duration: durationInSeconds, + }); + } + + sendCloseAllEvents(): void { + this.openedConnections.forEach((connection, id) => { + this.openedConnections.delete(id); + let duration = new Date().getTime() - connection.startTime.getTime(); + let durationInSeconds = duration / 1000; + + this.sendEvent({ + connectionType: connection.sessionType, + duration: durationInSeconds, + }); + }); + } + + private sendEvent( + connectinoEvent: OpenedConnectionEvent | ClosedConnectionEvent + ): void { + let host = environment.OpenSearchUrl; + let token = environment.OpenSearchToken; + let indexName = environment.OpenSearchIndex; + + let installId = localStorage.getItem('installId'); + if (!installId) { + installId = uuidv4(); + localStorage.setItem('installId', installId); + } + + this.apiService.getVersion().subscribe((version) => { + let event: AnalyticEvent = { + application: { + version: version.version, + }, + eventDate: new Date().toISOString(), + userAgent: navigator.userAgent, + installID: installId, + ...connectinoEvent, + }; + + const headers = new Headers(); + headers.append('Content-Type', 'application/json'); + headers.append('Authorization', `Basic ${token}`); + + let url = `${host}${indexName}/_doc`; + const requestOptions: RequestInit = { + method: 'POST', + headers: headers, + body: JSON.stringify(event), + // mode: 'no-cors' // Add this line to set the mode to 'no-cors' + }; + + fetch(url, requestOptions) + .then((response) => { + if (isDevMode()) { + console.log('Event sent', response); + } + }) + .catch((error) => { + if (isDevMode()) { + console.error('Error sending event', error); + } + }); + }); + } +} + +export interface ConnectionIdentifier { + id: string; + type: ProtocolString; +} + +export interface AnalyticEventBasic { + application: { + version: string; + }; + eventDate: string; + userAgent: string; + installID: string; +} +export type ProtocolString = keyof typeof Protocol; +interface OpenedConnectionEvent { + connectionType: ProtocolString; +} + +interface ClosedConnectionEvent { + connectionType: ProtocolString; + duration: number; +} + +export type AnalyticEvent = AnalyticEventBasic & + (OpenedConnectionEvent | ClosedConnectionEvent); diff --git a/webapp/src/client/app/shared/services/api.service.ts b/webapp/src/client/app/shared/services/api.service.ts index ffc8a0105..78c3306ba 100644 --- a/webapp/src/client/app/shared/services/api.service.ts +++ b/webapp/src/client/app/shared/services/api.service.ts @@ -1,7 +1,7 @@ import { Injectable } from '@angular/core'; import { HttpClient, HttpHeaders } from '@angular/common/http'; -import { Observable, throwError } from "rxjs"; -import { catchError, map } from 'rxjs/operators'; +import { Observable, of, throwError } from "rxjs"; +import { catchError, map, tap } from 'rxjs/operators'; interface VersionInfo { latestVersion?: string; @@ -15,6 +15,8 @@ export type GetVersionResult = { version:string } +let VersionCache : GetVersionResult = null; + @Injectable({ providedIn: 'root' }) @@ -59,11 +61,19 @@ export class ApiService { } getVersion(): Observable { + if (VersionCache) { + return of(VersionCache) + } + return this.http.get(this.healthApiURL,{ headers : { "accept" : "application/json" } - }) as Observable; + }).pipe( + tap((result: GetVersionResult) => { + VersionCache = result; + }) + ) as Observable; } getLatestVersion(keysToFetch: string[] = ['Gateway.Version', 'Gateway.Url']): Observable { diff --git a/webapp/src/environments/environment.development.ts b/webapp/src/environments/environment.development.ts new file mode 100644 index 000000000..0d72cf7e5 --- /dev/null +++ b/webapp/src/environments/environment.development.ts @@ -0,0 +1,5 @@ +export const environment = { + OpenSearchUrl: 'https://telemetry2.devolutions.net:9200/', + OpenSearchToken: 'REdXXzIwMjQuMTolJ1ltV0hoNGxxYnE=', + OpenSearchIndex: 'dev-gateway_general_events', +}; diff --git a/webapp/src/environments/environment.production.ts b/webapp/src/environments/environment.production.ts new file mode 100644 index 000000000..805fda991 --- /dev/null +++ b/webapp/src/environments/environment.production.ts @@ -0,0 +1,6 @@ +export const environment = { + OpenSearchUrl: 'https://telemetry2.devolutions.net:9200/', + OpenSearchToken: 'REdXXzIwMjQuMTolJ1ltV0hoNGxxYnE=', + OpenSearchIndex: 'gateway_general_events', +}; + diff --git a/webapp/src/environments/environment.ts b/webapp/src/environments/environment.ts new file mode 100644 index 000000000..6e4a8b653 --- /dev/null +++ b/webapp/src/environments/environment.ts @@ -0,0 +1,5 @@ +export const environment = { + OpenSearchUrl: '', + OpenSearchToken: '', + OpenSearchIndex: '', +};