-
-
+ @if (loading) {
+
+
+ @if (!hideSpinnerOnly) {
+
+ }
+
-
+ }
diff --git a/webapp/apps/gateway-ui/src/client/app/modules/web-client/vnc/web-client-vnc.component.scss b/webapp/apps/gateway-ui/src/client/app/modules/web-client/vnc/web-client-vnc.component.scss
index 949b3119f..7429526ba 100644
--- a/webapp/apps/gateway-ui/src/client/app/modules/web-client/vnc/web-client-vnc.component.scss
+++ b/webapp/apps/gateway-ui/src/client/app/modules/web-client/vnc/web-client-vnc.component.scss
@@ -1,11 +1,27 @@
:host {
background: #F9F9F9 url('../../../../../assets/images/session_background.png');
display: flex;
- height: 100%;
+ flex: 1;
+ min-height: 0;
width: 100%;
flex-direction: column;
}
+.session-vnc-container {
+ position: relative;
+ display: flex;
+ flex-direction: column;
+ flex: 1;
+ min-height: 0;
+ overflow: hidden;
+
+ iron-remote-desktop {
+ flex: 1;
+ min-height: 0;
+ overflow: hidden;
+ }
+}
+
:host ::ng-deep .separator {
border: solid 1px var(--border-secondary-color);
width: 0;
@@ -17,70 +33,27 @@
display: flex;
justify-content: center;
align-items: center;
- height: 100vh;
+ position: absolute;
+ inset: 0;
+ z-index: 1;
+ min-width: 0;
+ min-height: 0;
}
.loading-info {
display: grid;
place-items: center;
- height: 100vh;
- width: 100vw;
-}
-
-.session-toolbar {
- display: flex;
+ height: 100%;
width: 100%;
- height: 44px;
- padding: 8px;
- justify-content: space-between;
- align-items: center;
- background: var(--bg-secondary-color);
+ min-width: 0;
+ min-height: 0;
}
-.session-toolbar-middle-group, .session-toolbar-left-group, .session-toolbar-right-group {
- display: flex;
- align-items: center;
-}
-.session-toolbar ::ng-deep .p-button {
- border: none;
- border-radius: 4px;
- background-color: transparent;
+.toolbar-overlay-anchor {
+ position: absolute;
+ inset: 0;
+ z-index: 20;
+ pointer-events: none; // anchor is transparent — the toolbar children opt-in via pointer-events: auto
}
-.session-toolbar ::ng-deep .p-button:hover {
- background: #0068C31A;
-}
-
-.session-toolbar ::ng-deep .p-button .p-button-label {
- color: #000000CC;
- font-family: Open Sans;
- font-size: 12px;
- font-style: normal;
- font-weight: 400;
- line-height: normal;
-}
-
-.session-toolbar ::ng-deep .p-button .p-button-icon-left {
- display: flex;
- align-items: center;
- color: #0068C3;
- margin-right: 8px;
- height: 16px;
- width: 16px;
-}
-
-.session-toolbar-layer {
- position: fixed;
- top: 0;
- left: 0;
- width: 100%;
- z-index: 1000;
- box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
-}
-
-.session-form {
- display: flex;
- flex-direction: column;
- height: 100%;
-}
diff --git a/webapp/apps/gateway-ui/src/client/app/modules/web-client/vnc/web-client-vnc.component.ts b/webapp/apps/gateway-ui/src/client/app/modules/web-client/vnc/web-client-vnc.component.ts
index 27fb26d6d..d98ec16ae 100644
--- a/webapp/apps/gateway-ui/src/client/app/modules/web-client/vnc/web-client-vnc.component.ts
+++ b/webapp/apps/gateway-ui/src/client/app/modules/web-client/vnc/web-client-vnc.component.ts
@@ -1,32 +1,17 @@
-import {
- AfterViewInit,
- Component,
- ElementRef,
- EventEmitter,
- HostListener,
- Input,
- OnDestroy,
- OnInit,
- Output,
- Renderer2,
- ViewChild,
-} from '@angular/core';
-import { IronError, SessionTerminationInfo, UserInteraction } from '@devolutions/iron-remote-desktop';
-import { WebClientBaseComponent } from '@shared/bases/base-web-client.component';
+import { AfterViewInit, Component, ElementRef, OnDestroy, OnInit, Renderer2, ViewChild } from '@angular/core';
+import { DesktopWebClientBaseComponent } from '@shared/bases/desktop-web-client-base.component';
import { GatewayAlertMessageService } from '@shared/components/gateway-alert-message/gateway-alert-message.service';
import { ScreenScale } from '@shared/enums/screen-scale.enum';
import { ScreenSize } from '@shared/enums/screen-size.enum';
import { IronVNCConnectionParameters } from '@shared/interfaces/connection-params.interfaces';
import { VncFormDataInput } from '@shared/interfaces/forms.interfaces';
-import { ComponentStatus } from '@shared/models/component-status.model';
import { DesktopSize } from '@shared/models/desktop-size';
import { UtilsService } from '@shared/services/utils.service';
import { DefaultVncPort, WebClientService } from '@shared/services/web-client.service';
import { WebSessionService } from '@shared/services/web-session.service';
-import type { ToastMessageOptions } from 'primeng/api';
import { MessageService } from 'primeng/api';
-import { debounceTime, EMPTY, from, Observable, of, Subject, Subscription } from 'rxjs';
-import { catchError, map, switchMap, takeUntil } from 'rxjs/operators';
+import { debounceTime, EMPTY, from, Observable, of, Subscription } from 'rxjs';
+import { catchError, map, switchMap, takeUntil, tap } from 'rxjs/operators';
import '@devolutions/iron-remote-desktop/iron-remote-desktop.js';
import {
Backend,
@@ -39,7 +24,7 @@ import {
ultraVirtualDisplay,
wheelSpeedFactor,
} from '@devolutions/iron-remote-desktop-vnc';
-import { DVL_VNC_ICON, DVL_WARNING_ICON, JET_VNC_URL } from '@gateway/app.constants';
+import { DVL_VNC_ICON, JET_VNC_URL } from '@gateway/app.constants';
import { AnalyticService, ProtocolString } from '@gateway/shared/services/analytic.service';
import { Encoding } from '@shared/enums/encoding.enum';
import { WebSession } from '@shared/models/web-session.model';
@@ -47,42 +32,22 @@ import { ComponentResizeObserverService } from '@shared/services/component-resiz
import { ExtractedHostnamePort } from '@shared/services/utils/string.service';
import { v4 as uuidv4 } from 'uuid';
-enum UserIronRdpErrorKind {
- General = 0,
- WrongPassword = 1,
- LogonFailure = 2,
- AccessDenied = 3,
- RDCleanPath = 4,
- ProxyConnect = 5,
-}
-
@Component({
standalone: false,
templateUrl: 'web-client-vnc.component.html',
styleUrls: ['web-client-vnc.component.scss'],
providers: [MessageService],
})
-export class WebClientVncComponent extends WebClientBaseComponent implements OnInit, AfterViewInit, OnDestroy {
- @Input() webSessionId: string;
- @Input() sessionsContainerElement: ElementRef;
- @Output() componentStatus: EventEmitter
= new EventEmitter();
- @Output() sizeChange: EventEmitter = new EventEmitter();
-
+export class WebClientVncComponent
+ extends DesktopWebClientBaseComponent
+ implements OnInit, AfterViewInit, OnDestroy
+{
@ViewChild('sessionVncContainer') sessionContainerElement: ElementRef;
- @ViewChild('ironRemoteDesktopElement') ironRemoteDesktopElement: ElementRef;
backendRef = Backend;
-
- formData: VncFormDataInput;
- sessionTerminationMessage: ToastMessageOptions;
- isFullScreenMode = false;
- cursorOverrideActive = false;
-
dynamicResizeSupported = false;
dynamicResizeEnabled = false;
- saveRemoteClipboardButtonEnabled = false;
-
leftToolbarButtons = [
{
label: 'Start',
@@ -152,85 +117,30 @@ export class WebClientVncComponent extends WebClientBaseComponent implements OnI
},
];
- clipboardActionButtons: {
- label: string;
- tooltip: string;
- icon: string;
- action: () => Promise;
- enabled: () => boolean;
- }[] = [];
-
- private setupClipboardHandling(): void {
- // Clipboard API is available only in secure contexts (HTTPS).
- if (!window.isSecureContext) {
- return;
- }
-
- if (this.formData.autoClipboard === true) {
- return;
- }
-
- // We don't check for clipboard write support, as all recent browser versions support it.
- this.clipboardActionButtons.push({
- label: 'Save Clipboard',
- tooltip: 'Copy received clipboard content to your local clipboard.',
- icon: 'dvl-icon dvl-icon-save',
- action: () => this.saveRemoteClipboard(),
- enabled: () => this.saveRemoteClipboardButtonEnabled,
- });
-
- // Check if the browser supports reading local clipboard.
- if (navigator.clipboard.readText) {
- this.clipboardActionButtons.push({
- label: 'Send Clipboard',
- tooltip: 'Send your local clipboard content to the remote server.',
- icon: 'dvl-icon dvl-icon-send',
- action: () => this.sendClipboard(),
- enabled: () => true,
- });
- }
- }
-
- protected removeElement: Subject = new Subject();
- private remoteClient: UserInteraction;
- private remoteClientEventListener: (event: Event) => void;
-
private componentResizeObserverDisconnect?: () => void;
private dynamicComponentResizeSubscription?: Subscription;
constructor(
- private renderer: Renderer2,
+ protected renderer: Renderer2,
protected utils: UtilsService,
protected gatewayAlertMessageService: GatewayAlertMessageService,
- private webSessionService: WebSessionService,
+ protected webSessionService: WebSessionService,
private webClientService: WebClientService,
private componentResizeService: ComponentResizeObserverService,
protected analyticService: AnalyticService,
) {
- super(gatewayAlertMessageService, analyticService);
- }
-
- @HostListener('document:fullscreenchange')
- onFullScreenChange(): void {
- this.handleOnFullScreenEvent();
+ super(renderer, webSessionService, gatewayAlertMessageService, analyticService);
}
ngOnInit(): void {
- this.removeWebClientGuiElement();
- this.setupClipboardHandling();
- }
+ this.webSessionIcon = DVL_VNC_ICON;
- ngAfterViewInit(): void {
- this.initiateRemoteClientListener();
+ super.ngOnInit();
}
ngOnDestroy(): void {
- this.removeRemoteClientListener();
- this.removeWebClientGuiElement();
-
this.dynamicComponentResizeSubscription?.unsubscribe();
this.componentResizeObserverDisconnect?.();
-
super.ngOnDestroy();
}
@@ -242,60 +152,6 @@ export class WebClientVncComponent extends WebClientBaseComponent implements OnI
this.remoteClient.ctrlAltDel();
}
- startTerminationProcess(): void {
- this.sendTerminateSessionCmd();
- this.currentStatus.isDisabledByUser = true;
- this.disableComponentStatus();
- }
-
- sendTerminateSessionCmd(): void {
- if (!this.currentStatus.isInitialized) {
- return;
- }
- this.currentStatus.isInitialized = false;
- // shutdowns the session, not the server. Jan 2024 KAH.
- this.remoteClient.shutdown();
- }
-
- scaleTo(scale: ScreenScale): void {
- this.remoteClient.setScale(scale.valueOf());
- }
-
- setKeyboardUnicodeMode(useUnicode: boolean): void {
- this.remoteClient.setKeyboardUnicodeMode(useUnicode);
- }
-
- async saveRemoteClipboard(): Promise {
- try {
- await this.remoteClient.saveRemoteClipboardData();
-
- super.webClientSuccess('Clipboard content has been copied to your clipboard!');
- this.saveRemoteClipboardButtonEnabled = false;
- } catch (err) {
- this.handleSessionError(err);
- }
- }
-
- async sendClipboard(): Promise {
- try {
- await this.remoteClient.sendClipboardData();
-
- super.webClientSuccess('Clipboard content has been sent to the remote server!');
- } catch (err) {
- this.handleSessionError(err);
- }
- }
-
- toggleCursorKind(): void {
- if (this.cursorOverrideActive) {
- this.remoteClient.setCursorStyleOverride(null);
- } else {
- this.remoteClient.setCursorStyleOverride('url("assets/images/crosshair.png") 7 7, default');
- }
-
- this.cursorOverrideActive = !this.cursorOverrideActive;
- }
-
setWheelSpeedFactor(factor: number): void {
this.remoteClient.invokeExtension(wheelSpeedFactor(factor));
}
@@ -324,69 +180,7 @@ export class WebClientVncComponent extends WebClientBaseComponent implements OnI
}
}
- removeWebClientGuiElement(): void {
- this.removeElement.pipe(takeUntil(this.destroyed$)).subscribe({
- next: (): void => {
- if (this.ironRemoteDesktopElement?.nativeElement) {
- this.ironRemoteDesktopElement.nativeElement.remove();
- }
- },
- error: (err): void => {
- console.error('Error while removing element:', err);
- },
- });
- }
-
- private initializeStatus(): void {
- this.currentStatus = {
- id: this.webSessionId,
- isInitialized: true,
- isDisabled: false,
- isDisabledByUser: false,
- };
- }
-
- private disableComponentStatus(): void {
- this.currentStatus.isDisabled = true;
- this.componentStatus.emit(this.currentStatus);
- }
-
- private handleOnFullScreenEvent(): void {
- if (!document.fullscreenElement) {
- this.handleExitFullScreenEvent();
- }
- }
-
- private toggleFullscreen(): void {
- this.isFullScreenMode = !this.isFullScreenMode;
- !document.fullscreenElement ? this.enterFullScreen() : this.exitFullScreen();
-
- this.scaleTo(ScreenScale.Full);
- }
-
- private async enterFullScreen(): Promise {
- if (document.fullscreenElement) {
- return;
- }
-
- try {
- const sessionsContainerElement = this.sessionsContainerElement.nativeElement;
- await sessionsContainerElement.requestFullscreen();
- } catch (err) {
- this.isFullScreenMode = false;
- console.error(`Error attempting to enable fullscreen mode: ${err.message} (${err.name})`);
- }
- }
-
- private exitFullScreen(): void {
- if (document.fullscreenElement) {
- document.exitFullscreen().catch((err) => {
- console.error(`Error attempting to exit fullscreen: ${err}`);
- });
- }
- }
-
- private handleExitFullScreenEvent(): void {
+ protected handleExitFullScreenEvent(): void {
this.isFullScreenMode = false;
const sessionContainerElement = this.sessionContainerElement.nativeElement;
@@ -399,40 +193,11 @@ export class WebClientVncComponent extends WebClientBaseComponent implements OnI
this.scaleTo(ScreenScale.Fit);
}
- private initiateRemoteClientListener(): void {
- this.remoteClientEventListener = (event: Event) => this.readyRemoteClientEventListener(event);
- this.renderer.listen(this.ironRemoteDesktopElement.nativeElement, 'ready', this.remoteClientEventListener);
- }
-
- private removeRemoteClientListener(): void {
- if (this.ironRemoteDesktopElement && this.remoteClientEventListener) {
- this.renderer.destroy();
- }
- }
-
- private readyRemoteClientEventListener(event: Event): void {
- const customEvent = event as CustomEvent;
- this.remoteClient = customEvent.detail.irgUserInteraction;
-
- if (this.formData.autoClipboard !== true) {
- this.remoteClient.setEnableAutoClipboard(false);
- }
-
- // Register callbacks for events.
- this.remoteClient.onWarningCallback((data: string) => {
- this.webClientWarning(data);
- });
- this.remoteClient.onClipboardRemoteUpdateCallback(() => {
- this.saveRemoteClipboardButtonEnabled = true;
- });
-
- this.startConnectionProcess();
- }
-
- private startConnectionProcess(): void {
+ protected startConnectionProcess(): void {
this.getFormData()
.pipe(
takeUntil(this.destroyed$),
+ tap(() => this.setupClipboardHandling(this.formData.autoClipboard)),
switchMap(() => this.setScreenSizeScale(this.formData.screenSize)),
switchMap(() => this.fetchParameters(this.formData)),
switchMap((params) => this.fetchTokens(params)),
@@ -497,7 +262,7 @@ export class WebClientVncComponent extends WebClientBaseComponent implements OnI
return of(connectionParameters);
}
- fetchTokens(params: IronVNCConnectionParameters): Observable {
+ private fetchTokens(params: IronVNCConnectionParameters): Observable {
return this.webClientService.fetchVncToken(params);
}
@@ -508,7 +273,7 @@ export class WebClientVncComponent extends WebClientBaseComponent implements OnI
return of(undefined);
}
- private callConnect(connectionParameters: IronVNCConnectionParameters): void {
+ protected callConnect(connectionParameters: IronVNCConnectionParameters): void {
const configBuilder = this.remoteClient
.configBuilder()
.withDestination(connectionParameters.host)
@@ -567,99 +332,6 @@ export class WebClientVncComponent extends WebClientBaseComponent implements OnI
});
}
- private handleSessionStarted(): void {
- this.loading = false;
- this.remoteClient.setVisibility(true);
- void this.webSessionService.updateWebSessionIcon(this.webSessionId, DVL_VNC_ICON);
- this.webClientConnectionSuccess();
- this.initializeStatus();
- }
-
- private handleSessionTerminatedGracefully(sessionTerminationInfo: SessionTerminationInfo): void {
- this.sessionTerminationMessage = {
- summary: 'Session terminated gracefully',
- detail: sessionTerminationInfo.reason(),
- severity: 'success',
- };
-
- this.handleSessionTerminated();
- }
-
- private handleSessionTerminatedWithError(error: unknown): void {
- if (this.isIronError(error)) {
- this.sessionTerminationMessage = {
- summary: this.getIronErrorMessageTitle(error),
- detail: error.backtrace(),
- severity: 'error',
- };
- } else {
- this.sessionTerminationMessage = {
- summary: 'Unexpected error occurred',
- detail: `${error}`,
- severity: 'error',
- };
- }
-
- void this.webSessionService.updateWebSessionIcon(this.webSessionId, DVL_WARNING_ICON);
-
- this.handleSessionTerminated();
- }
-
- private handleSessionTerminated(): void {
- if (document.fullscreenElement) {
- this.exitFullScreen();
- }
-
- this.disableComponentStatus();
- super.webClientConnectionClosed();
- }
-
- private handleSessionError(err: unknown): void {
- if (this.isIronError(err)) {
- this.webClientError(err.backtrace());
- } else {
- this.webClientError(`${err}`);
- }
- }
-
- private isIronError(error: unknown): error is IronError {
- return (
- typeof error === 'object' &&
- error !== null &&
- typeof (error as IronError).backtrace === 'function' &&
- typeof (error as IronError).kind === 'function'
- );
- }
-
- private handleError(error: string): void {
- this.sessionTerminationMessage = {
- summary: 'Unexpected error occurred',
- detail: error,
- severity: 'error',
- };
- this.disableComponentStatus();
- }
-
- private getIronErrorMessageTitle(error: IronError): string {
- const errorKind: UserIronRdpErrorKind = error.kind().valueOf();
-
- //For translation 'UnknownError'
- //For translation 'ConnectionErrorPleaseVerifyYourConnectionSettings'
- //For translation 'AccessDenied'
- //For translation 'ConnectionErrorPleaseVerifyYourConnectionSettings'
- switch (errorKind) {
- case UserIronRdpErrorKind.General:
- return 'Unknown Error';
- case UserIronRdpErrorKind.WrongPassword:
- case UserIronRdpErrorKind.LogonFailure:
- return 'Connection error: Please verify your connection settings.';
- case UserIronRdpErrorKind.AccessDenied:
- return 'Access denied';
- default:
- return 'Connection error: Please verify your connection settings.';
- }
- }
-
protected getProtocol(): ProtocolString {
return 'VNC';
}
diff --git a/webapp/apps/gateway-ui/src/client/app/modules/web-client/web-client.module.ts b/webapp/apps/gateway-ui/src/client/app/modules/web-client/web-client.module.ts
index 3ab8f41f6..8bdcb19df 100644
--- a/webapp/apps/gateway-ui/src/client/app/modules/web-client/web-client.module.ts
+++ b/webapp/apps/gateway-ui/src/client/app/modules/web-client/web-client.module.ts
@@ -23,6 +23,8 @@ import { DynamicTabComponent } from '@shared/components/dynamic-tab/dynamic-tab.
import { MainPanelComponent } from '@shared/components/main-panel/main-panel.component';
import { SessionToolbarComponent } from '@shared/components/session-toolbar/session-toolbar.component';
import { TabViewComponent } from '@shared/components/tab-view/tab-view.component';
+import { Protocol } from '@shared/enums/web-client-protocol.enum';
+import { WebSessionService } from '@shared/services/web-session.service';
import { SharedModule } from '@shared/shared.module';
import { CheckboxModule } from 'primeng/checkbox';
import { KeyFilterModule } from 'primeng/keyfilter';
@@ -108,4 +110,14 @@ const routes: Routes = [
exports: [DynamicTabComponent, WebClientFormComponent, NetScanComponent],
providers: [],
})
-export class WebClientModule {}
+export class WebClientModule {
+ constructor(webSessionService: WebSessionService) {
+ webSessionService.registerProtocolComponentMap({
+ [Protocol.RDP]: WebClientRdpComponent,
+ [Protocol.Telnet]: WebClientTelnetComponent,
+ [Protocol.SSH]: WebClientSshComponent,
+ [Protocol.VNC]: WebClientVncComponent,
+ [Protocol.ARD]: WebClientArdComponent,
+ });
+ }
+}
diff --git a/webapp/apps/gateway-ui/src/client/app/shared/bases/base-web-client.component.ts b/webapp/apps/gateway-ui/src/client/app/shared/bases/base-web-client.component.ts
index 0b9dc91ef..f7e459172 100644
--- a/webapp/apps/gateway-ui/src/client/app/shared/bases/base-web-client.component.ts
+++ b/webapp/apps/gateway-ui/src/client/app/shared/bases/base-web-client.component.ts
@@ -1,6 +1,7 @@
import { Directive } from '@angular/core';
import { GatewayAlertMessageService } from '@shared/components/gateway-alert-message/gateway-alert-message.service';
import { ComponentStatus } from '@shared/models/component-status.model';
+import { ToastMessageOptions } from 'primeng/api';
import { BaseSessionComponent } from '../models/web-session.model';
import { AnalyticService, ConnectionIdentifier, ProtocolString } from '../services/analytic.service';
@@ -13,6 +14,7 @@ export abstract class WebClientBaseComponent extends BaseSessionComponent {
hideSpinnerOnly = false;
error: string;
loading = true;
+ sessionTerminationMessage: ToastMessageOptions;
analyticHandle: ConnectionIdentifier;
@@ -22,7 +24,6 @@ export abstract class WebClientBaseComponent extends BaseSessionComponent {
isDisabled: false,
isDisabledByUser: false,
};
-
protected constructor(
protected gatewayAlertMessageService: GatewayAlertMessageService,
protected analyticService: AnalyticService,
@@ -30,7 +31,7 @@ export abstract class WebClientBaseComponent extends BaseSessionComponent {
super();
}
- abstract removeWebClientGuiElement(): void;
+ // ── Session lifecycle helpers ──────────────────────────────────────────────
//For translation 'ConnectionSuccessful
protected webClientConnectionSuccess(message = 'Connection successful'): void {
@@ -59,5 +60,14 @@ export abstract class WebClientBaseComponent extends BaseSessionComponent {
}
}
+ protected getGatewayWebSocketUrl(baseUrl: string, sessionId?: string): string {
+ const normalizedBasePath = baseUrl.replace(/\/+$/, '');
+ const path = sessionId ? `${normalizedBasePath}/${sessionId}` : normalizedBasePath;
+ const gatewayUrl: URL = new URL(path, window.location.href);
+
+ gatewayUrl.protocol = gatewayUrl.protocol === 'https:' ? 'wss:' : 'ws:';
+ return gatewayUrl.toString();
+ }
+
protected abstract getProtocol(): ProtocolString;
}
diff --git a/webapp/apps/gateway-ui/src/client/app/shared/bases/desktop-web-client-base.component.ts b/webapp/apps/gateway-ui/src/client/app/shared/bases/desktop-web-client-base.component.ts
new file mode 100644
index 000000000..4628d3c33
--- /dev/null
+++ b/webapp/apps/gateway-ui/src/client/app/shared/bases/desktop-web-client-base.component.ts
@@ -0,0 +1,400 @@
+import {
+ Directive,
+ ElementRef,
+ EventEmitter,
+ HostListener,
+ Input,
+ OnDestroy,
+ Output,
+ Renderer2,
+ ViewChild,
+} from '@angular/core';
+import { IronError, SessionTerminationInfo, UserInteraction } from '@devolutions/iron-remote-desktop';
+import { DVL_WARNING_ICON } from '@gateway/app.constants';
+import { GatewayAlertMessageService } from '@shared/components/gateway-alert-message/gateway-alert-message.service';
+import { ScreenScale } from '@shared/enums/screen-scale.enum';
+import {
+ IronARDConnectionParameters,
+ IronRDPConnectionParameters,
+ IronVNCConnectionParameters,
+} from '@shared/interfaces/connection-params.interfaces';
+import { DesktopFormDataInput } from '@shared/interfaces/forms.interfaces';
+import { ComponentStatus } from '@shared/models/component-status.model';
+import { WebSessionService } from '@shared/services/web-session.service';
+import { Subject } from 'rxjs';
+import { takeUntil } from 'rxjs/operators';
+import { UAParser } from 'ua-parser-js';
+import { AnalyticService } from '../services/analytic.service';
+import { WebClientBaseComponent } from './base-web-client.component';
+
+enum IronErrorKind {
+ General = 0,
+ WrongPassword = 1,
+ LogonFailure = 2,
+ AccessDenied = 3,
+ RDCleanPath = 4,
+ ProxyConnect = 5,
+}
+
+@Directive()
+export abstract class DesktopWebClientBaseComponent
+ extends WebClientBaseComponent
+ implements OnDestroy
+{
+ // ── Clipboard state — shared by desktop protocol components only ──────────
+ formData: TFormData;
+
+ protected removeElement: Subject = new Subject();
+ private remoteClientEventListener: (event: Event) => void;
+ // unlistenRemoteClient and removeRemoteClientListener() live in DesktopWebClientBaseComponent
+
+ protected remoteClient: UserInteraction;
+ saveRemoteClipboardButtonEnabled = false;
+
+ protected webSessionIcon: string;
+
+ clipboardActionButtons: {
+ label: string;
+ tooltip: string;
+ icon: string;
+ action: () => Promise;
+ enabled: () => boolean;
+ }[] = [];
+
+ isFullScreenMode = false;
+ cursorOverrideActive = false;
+
+ @Input() webSessionId: string;
+ @Input() sessionsContainerElement: ElementRef;
+
+ @Output() readonly componentStatus = new EventEmitter();
+ @Output() readonly sizeChange = new EventEmitter();
+
+ @ViewChild('ironRemoteDesktopElement') ironRemoteDesktopElement: ElementRef;
+
+ /** Stored so it can be called to remove the 'ready' event listener on destroy. */
+ protected unlistenRemoteClient: (() => void) | null = null;
+
+ protected abstract startConnectionProcess(): void;
+ protected abstract handleExitFullScreenEvent(): void;
+ protected abstract callConnect(
+ connectionParameters: IronVNCConnectionParameters | IronARDConnectionParameters | IronRDPConnectionParameters,
+ ): void;
+
+ protected constructor(
+ protected renderer: Renderer2,
+ protected webSessionService: WebSessionService,
+ protected gatewayAlertMessageService: GatewayAlertMessageService,
+ protected analyticService: AnalyticService,
+ ) {
+ super(gatewayAlertMessageService, analyticService);
+ }
+
+ @HostListener('document:fullscreenchange')
+ onFullScreenChange(): void {
+ this.handleOnFullScreenEvent();
+ }
+
+ ngOnInit(): void {
+ this.removeWebClientGuiElement();
+ }
+
+ ngAfterViewInit(): void {
+ this.initiateRemoteClientListener();
+ }
+
+ ngOnDestroy(): void {
+ this.removeRemoteClientListener();
+ this.removeWebClientGuiElement();
+ // Break the reference cycle:
+ // protocol component → this.remoteClient → UserInteraction → callback closures → protocol component
+ this.remoteClient = null;
+ super.ngOnDestroy();
+ }
+
+ protected handleOnFullScreenEvent(): void {
+ if (!document.fullscreenElement) {
+ this.handleExitFullScreenEvent();
+ }
+ }
+
+ protected toggleFullscreen(): void {
+ this.isFullScreenMode = !this.isFullScreenMode;
+ !document.fullscreenElement ? this.enterFullScreen() : this.exitFullScreen();
+
+ this.scaleTo(ScreenScale.Full);
+ }
+
+ protected async enterFullScreen(): Promise {
+ if (document.fullscreenElement) {
+ return;
+ }
+
+ try {
+ const sessionsContainerElement = this.sessionsContainerElement.nativeElement;
+ await sessionsContainerElement.requestFullscreen();
+ } catch (err) {
+ this.isFullScreenMode = false;
+ console.error(`Error attempting to enable fullscreen mode: ${err.message} (${err.name})`);
+ }
+ }
+
+ protected exitFullScreen(): void {
+ if (document.fullscreenElement) {
+ document.exitFullscreen().catch((err) => {
+ console.error(`Error attempting to exit fullscreen: ${err}`);
+ });
+ }
+ }
+
+ /** True when the remote client should handle clipboard automatically.
+ * For form-based connections the explicit autoClipboard field is used.
+ * When autoClipboard is undefined (URL/config-based RDP connections) the
+ * Blink engine is used as a reliable heuristic. */
+ protected isAutoClipboardMode(autoClipboard?: boolean): boolean {
+ if (autoClipboard !== undefined) return autoClipboard;
+ return new UAParser().getEngine().name === 'Blink';
+ }
+
+ /** Populates clipboardActionButtons for manual clipboard workflows.
+ * Call after the component knows whether auto-clipboard is enabled.
+ * No-ops when in a non-secure context or when auto-clipboard is active. */
+ protected setupClipboardHandling(autoClipboard?: boolean): void {
+ if (!window.isSecureContext || this.isAutoClipboardMode(autoClipboard)) {
+ return;
+ }
+
+ // We don't check for clipboard write support, as all recent browser versions support it.
+ this.clipboardActionButtons.push({
+ label: 'Save Clipboard',
+ tooltip: 'Copy received clipboard content to your local clipboard.',
+ icon: 'dvl-icon dvl-icon-save',
+ action: () => this.saveRemoteClipboard(),
+ enabled: () => this.saveRemoteClipboardButtonEnabled,
+ });
+
+ // Check if the browser supports reading local clipboard.
+ if (typeof navigator.clipboard?.readText === 'function') {
+ this.clipboardActionButtons.push({
+ label: 'Send Clipboard',
+ tooltip: 'Send your local clipboard content to the remote server.',
+ icon: 'dvl-icon dvl-icon-send',
+ action: () => this.sendClipboard(),
+ enabled: () => true,
+ });
+ }
+ }
+
+ async saveRemoteClipboard(): Promise {
+ try {
+ await this.remoteClient.saveRemoteClipboardData();
+ this.webClientSuccess('Clipboard content has been copied to your clipboard!');
+ this.saveRemoteClipboardButtonEnabled = false;
+ } catch (err) {
+ this.handleSessionError(err);
+ }
+ }
+
+ async sendClipboard(): Promise {
+ try {
+ await this.remoteClient.sendClipboardData();
+ this.webClientSuccess('Clipboard content has been sent to the remote server!');
+ } catch (err) {
+ this.handleSessionError(err);
+ }
+ }
+
+ protected handleSessionError(err: unknown): void {
+ if (this.isIronError(err)) {
+ this.webClientError(err.backtrace());
+ } else {
+ this.webClientError(`${err}`);
+ }
+ }
+
+ protected isIronError(error: unknown): error is IronError {
+ return (
+ typeof error === 'object' &&
+ error !== null &&
+ typeof (error as IronError).backtrace === 'function' &&
+ typeof (error as IronError).kind === 'function'
+ );
+ }
+
+ protected getIronErrorMessageTitle(error: IronError): string {
+ //For translation 'UnknownError'
+ //For translation 'ConnectionErrorPleaseVerifyYourConnectionSettings'
+ //For translation 'AccessDenied'
+ const errorKind: IronErrorKind = error.kind().valueOf();
+ switch (errorKind) {
+ case IronErrorKind.General:
+ return 'Unknown Error';
+ case IronErrorKind.WrongPassword:
+ case IronErrorKind.LogonFailure:
+ return 'Connection error: Please verify your connection settings.';
+ case IronErrorKind.AccessDenied:
+ return 'Access denied';
+ default:
+ return 'Connection error: Please verify your connection settings.';
+ }
+ }
+
+ protected initializeStatus(): void {
+ this.currentStatus = {
+ id: this.webSessionId,
+ isInitialized: true,
+ isDisabled: false,
+ isDisabledByUser: false,
+ };
+ }
+
+ protected disableComponentStatus(): void {
+ // Pre-connect close/error paths can run before initializeStatus().
+ // Backfill id so dynamic tab removal receives a valid session id.
+ this.currentStatus.id ??= this.webSessionId;
+ this.currentStatus.isDisabled = true;
+ if (!this.currentStatus.id) {
+ return;
+ }
+ this.currentStatus.terminationMessage = this.sessionTerminationMessage;
+ this.componentStatus.emit(this.currentStatus);
+ }
+
+ /** Removes the 'ready' event listener added by the subclass. */
+ protected removeRemoteClientListener(): void {
+ if (this.unlistenRemoteClient) {
+ this.unlistenRemoteClient();
+ this.unlistenRemoteClient = null;
+ }
+ }
+
+ protected removeWebClientGuiElement(): void {
+ this.removeElement.pipe(takeUntil(this.destroyed$)).subscribe({
+ next: (): void => {
+ if (this.ironRemoteDesktopElement?.nativeElement) {
+ this.ironRemoteDesktopElement.nativeElement.remove();
+ }
+ },
+ error: (err): void => {
+ console.error('Error while removing element:', err);
+ },
+ });
+ }
+
+ protected initiateRemoteClientListener(): void {
+ this.remoteClientEventListener = (event: Event) => this.readyRemoteClientEventListener(event);
+ this.unlistenRemoteClient = this.renderer.listen(
+ this.ironRemoteDesktopElement.nativeElement,
+ 'ready',
+ this.remoteClientEventListener,
+ );
+ }
+
+ protected startTerminationProcess(): void {
+ this.sendTerminateSessionCmd();
+ this.currentStatus.isDisabledByUser = true;
+ this.disableComponentStatus();
+ }
+
+ sendTerminateSessionCmd(): void {
+ if (!this.currentStatus.isInitialized || !this.remoteClient) {
+ return;
+ }
+ this.currentStatus.isInitialized = false;
+ this.remoteClient.shutdown();
+ }
+
+ scaleTo(scale: ScreenScale): void {
+ this.remoteClient.setScale(scale.valueOf());
+ }
+
+ setKeyboardUnicodeMode(useUnicode: boolean): void {
+ this.remoteClient.setKeyboardUnicodeMode(useUnicode);
+ }
+
+ toggleCursorKind(): void {
+ if (this.cursorOverrideActive) {
+ this.remoteClient.setCursorStyleOverride(null);
+ } else {
+ this.remoteClient.setCursorStyleOverride('url("assets/images/crosshair.png") 7 7, default');
+ }
+
+ this.cursorOverrideActive = !this.cursorOverrideActive;
+ }
+
+ protected readyRemoteClientEventListener(event: Event): void {
+ const customEvent = event as CustomEvent;
+ this.remoteClient = customEvent.detail.irgUserInteraction;
+
+ this.remoteClient.setEnableAutoClipboard(this.isAutoClipboardMode(this.formData?.autoClipboard));
+
+ // Register callbacks for events.
+ this.remoteClient.onWarningCallback((data: string) => {
+ this.webClientWarning(data);
+ });
+ this.remoteClient.onClipboardRemoteUpdateCallback(() => {
+ this.saveRemoteClipboardButtonEnabled = true;
+ });
+
+ this.startConnectionProcess();
+ }
+
+ protected handleSessionStarted(): void {
+ this.loading = false;
+ this.remoteClient.setVisibility(true);
+ void this.webSessionService.updateWebSessionIcon(this.webSessionId, this.webSessionIcon);
+ this.webClientConnectionSuccess();
+ this.initializeStatus();
+ }
+
+ protected handleSessionTerminatedWithError(error: unknown): void {
+ if (this.isIronError(error)) {
+ this.sessionTerminationMessage = {
+ summary: this.getIronErrorMessageTitle(error),
+ detail: error.backtrace(),
+ severity: 'error',
+ };
+ } else {
+ this.sessionTerminationMessage = {
+ summary: 'Unexpected error occurred',
+ detail: `${error}`,
+ severity: 'error',
+ };
+ }
+
+ void this.webSessionService.updateWebSessionIcon(this.webSessionId, DVL_WARNING_ICON);
+
+ this.handleSessionTerminated();
+ }
+
+ protected handleError(error: string): void {
+ this.loading = false;
+ this.sessionTerminationMessage = {
+ summary: 'Unexpected error occurred',
+ detail: error,
+ severity: 'error',
+ };
+
+ this.disableComponentStatus();
+ }
+
+ protected handleSessionTerminatedGracefully(sessionTerminationInfo: SessionTerminationInfo): void {
+ this.sessionTerminationMessage = {
+ summary: 'Session terminated gracefully',
+ detail: sessionTerminationInfo.reason(),
+ severity: 'success',
+ };
+
+ this.handleSessionTerminated();
+ }
+
+ protected handleSessionTerminated(): void {
+ this.loading = false;
+ if (document.fullscreenElement) {
+ this.exitFullScreen();
+ }
+
+ this.disableComponentStatus();
+ super.webClientConnectionClosed();
+ }
+}
diff --git a/webapp/apps/gateway-ui/src/client/app/shared/bases/terminal-web-client-base.component.ts b/webapp/apps/gateway-ui/src/client/app/shared/bases/terminal-web-client-base.component.ts
new file mode 100644
index 000000000..727b9b265
--- /dev/null
+++ b/webapp/apps/gateway-ui/src/client/app/shared/bases/terminal-web-client-base.component.ts
@@ -0,0 +1,142 @@
+import { Directive, EventEmitter, OnDestroy, Output } from '@angular/core';
+import { DVL_WARNING_ICON } from '@gateway/app.constants';
+import { GatewayAlertMessageService } from '@shared/components/gateway-alert-message/gateway-alert-message.service';
+import { ComponentStatus } from '@shared/models/component-status.model';
+import { ToastMessageOptions } from 'primeng/api';
+import { Subject } from 'rxjs';
+import { AnalyticService } from '../services/analytic.service';
+import { WebSessionService } from '../services/web-session.service';
+import { WebClientBaseComponent } from './base-web-client.component';
+
+@Directive()
+export abstract class TerminalWebClientBaseComponent extends WebClientBaseComponent implements OnDestroy {
+ rightToolbarButtons = [
+ { label: 'Close Session', icon: 'dvl-icon dvl-icon-close', action: () => this.startTerminationProcess() },
+ ];
+
+ // ── Terminal session state ──────────────────────────────────────────────
+ clientError: string;
+ protected removeElement = new Subject();
+
+ /** Unsubscribe function for the lifecycle onStatusChange handler. */
+ protected unsubscribeTerminalEvent: (() => void) | null = null;
+ /** Unsubscribe function for the connecting-message onStatusChange handler. */
+ protected unsubscribeConnectionListener: (() => void) | null = null;
+
+ @Output() readonly componentStatus = new EventEmitter();
+ @Output() readonly sizeChange = new EventEmitter();
+
+ protected constructor(
+ protected override gatewayAlertMessageService: GatewayAlertMessageService,
+ protected webSessionService: WebSessionService,
+ protected override analyticService: AnalyticService,
+ ) {
+ super(gatewayAlertMessageService, analyticService);
+ }
+
+ ngOnDestroy(): void {
+ this.removeRemoteTerminalListener();
+ this.removeWebClientGuiElement();
+ if (this.currentStatus.isInitialized && !this.currentStatus.isDisabled) {
+ this.startTerminationProcess();
+ }
+ // Break the reference cycle: component → remoteTerminal → onStatusChange closures → component
+ this.teardownTerminalClient();
+ super.ngOnDestroy();
+ }
+
+ /** Icon shown on the session tab when the terminal connects successfully. */
+ protected abstract getSuccessIcon(): string;
+ protected abstract startTerminationProcess(): void;
+ abstract sendTerminateSessionCmd(): void;
+ protected abstract removeWebClientGuiElement(): void;
+ /**
+ * Null out the terminal client reference to break the reference cycle that
+ * would prevent GC of the component tree after the session is closed.
+ * Called by the base ngOnDestroy after the terminal has been closed.
+ */
+ protected abstract teardownTerminalClient(): void;
+
+ /** Cancels all onStatusChange subscriptions registered by the subclass. */
+ protected removeRemoteTerminalListener(): void {
+ this.unsubscribeTerminalEvent?.();
+ this.unsubscribeTerminalEvent = null;
+ this.unsubscribeConnectionListener?.();
+ this.unsubscribeConnectionListener = null;
+ }
+
+ protected initializeStatus(): void {
+ this.currentStatus = {
+ id: this.webSessionId,
+ isInitialized: true,
+ isDisabled: false,
+ isDisabledByUser: false,
+ };
+ }
+
+ protected disableComponentStatus(): void {
+ if (this.currentStatus.isDisabled) {
+ return;
+ }
+
+ // Pre-connect close/error paths can run before initializeStatus().
+ // Backfill id so dynamic tab removal receives a valid session id.
+ this.currentStatus.id ??= this.webSessionId;
+ this.currentStatus.isDisabled = true;
+ if (!this.currentStatus.id) {
+ return;
+ }
+ this.componentStatus.emit(this.currentStatus);
+ }
+
+ /** Called when the terminal GUI signals a successful connection. */
+ protected handleClientConnectStarted(): void {
+ this.loading = false;
+ void this.webSessionService.updateWebSessionIcon(this.webSessionId, this.getSuccessIcon());
+ this.webClientConnectionSuccess();
+ }
+
+ /**
+ * Called when the session ends for any reason (graceful close, error, timeout).
+ * Exits fullscreen, updates the tab icon, stores the error message, and
+ * disables the component status.
+ *
+ * @param errorMessage Human-readable message stored in `clientError`.
+ * @param isError When true, the tab icon is updated to the warning icon;
+ * when false, the success icon is used instead.
+ */
+ protected handleSessionEnded(errorMessage: string, isError = true): void {
+ if (document.fullscreenElement) {
+ document.exitFullscreen().catch((err) => {
+ console.error(`Error attempting to exit fullscreen: ${err}`);
+ });
+ }
+
+ this.clientError = errorMessage;
+ void this.webSessionService.updateWebSessionIcon(
+ this.webSessionId,
+ isError ? DVL_WARNING_ICON : this.getSuccessIcon(),
+ );
+ this.currentStatus.terminationMessage = {
+ summary: errorMessage,
+ severity: isError ? 'error' : 'success',
+ } as ToastMessageOptions;
+ this.disableComponentStatus();
+ this.webClientConnectionClosed();
+ }
+
+ /**
+ * Called when a connection-phase error occurs (e.g. fetch-token failure).
+ * Sets `clientError`, logs to console, and disables the component.
+ */
+ protected handleConnectionError(message: string): void {
+ this.clientError = message;
+ console.error(message);
+ void this.webSessionService.updateWebSessionIcon(this.webSessionId, DVL_WARNING_ICON);
+ this.currentStatus.terminationMessage = {
+ summary: message,
+ severity: 'error',
+ } as ToastMessageOptions;
+ this.disableComponentStatus();
+ }
+}
diff --git a/webapp/apps/gateway-ui/src/client/app/shared/components/dynamic-tab/dynamic-tab.component.scss b/webapp/apps/gateway-ui/src/client/app/shared/components/dynamic-tab/dynamic-tab.component.scss
index 8b1378917..0c950035d 100644
--- a/webapp/apps/gateway-ui/src/client/app/shared/components/dynamic-tab/dynamic-tab.component.scss
+++ b/webapp/apps/gateway-ui/src/client/app/shared/components/dynamic-tab/dynamic-tab.component.scss
@@ -1 +1,7 @@
-
+:host {
+ display: flex;
+ flex: 1;
+ min-height: 0;
+ flex-direction: column;
+ background: #F9F9F9 url('../../../../../assets/images/session_background.png');
+}
diff --git a/webapp/apps/gateway-ui/src/client/app/shared/components/dynamic-tab/dynamic-tab.component.ts b/webapp/apps/gateway-ui/src/client/app/shared/components/dynamic-tab/dynamic-tab.component.ts
index 20e928283..8cf034dfc 100644
--- a/webapp/apps/gateway-ui/src/client/app/shared/components/dynamic-tab/dynamic-tab.component.ts
+++ b/webapp/apps/gateway-ui/src/client/app/shared/components/dynamic-tab/dynamic-tab.component.ts
@@ -2,15 +2,19 @@ import {
AfterViewInit,
ChangeDetectorRef,
Component,
+ ComponentRef,
ElementRef,
EventEmitter,
Input,
+ OnChanges,
OnDestroy,
Output,
+ SimpleChanges,
ViewChild,
ViewContainerRef,
} from '@angular/core';
+import { WebClientFormComponent } from '@gateway/modules/web-client/form/web-client-form.component';
import { WebClientSshComponent } from '@gateway/modules/web-client/ssh/web-client-ssh.component';
import { WebClientTelnetComponent } from '@gateway/modules/web-client/telnet/web-client-telnet.component';
import { BaseComponent } from '@shared/bases/base.component';
@@ -20,6 +24,7 @@ import { SessionType, WebSession } from '@shared/models/web-session.model';
import { ComponentListenerService } from '@shared/services/component-listener.service';
import { DynamicComponentService } from '@shared/services/dynamic-component.service';
import { WebSessionService } from '@shared/services/web-session.service';
+import { Subject } from 'rxjs';
import { distinctUntilChanged, take, takeUntil } from 'rxjs/operators';
@Component({
@@ -28,13 +33,24 @@ import { distinctUntilChanged, take, takeUntil } from 'rxjs/operators';
templateUrl: './dynamic-tab.component.html',
styleUrls: ['./dynamic-tab.component.scss'],
})
-export class DynamicTabComponent extends BaseComponent implements AfterViewInit, OnDestroy {
+export class DynamicTabComponent
+ extends BaseComponent
+ implements OnChanges, AfterViewInit, OnDestroy
+{
@Input() webSessionTab: WebSession;
@Input() sessionsContainerElement: ElementRef;
@ViewChild('dynamicComponentContainer', { read: ViewContainerRef }) container: ViewContainerRef;
@Output() componentRefSizeChange: EventEmitter = new EventEmitter();
+ /** Non-null while the reconnect form occupies the container slot. */
+ private formRef: ComponentRef | null = null;
+
+ /** Cancelled and re-created on every initializeDynamicComponent() call to
+ * ensure subscriptions to the previous component instance are torn down
+ * before subscribing to the replacement. */
+ private componentInstanceDestroyed$ = new Subject();
+
constructor(
private cdr: ChangeDetectorRef,
private webSessionService: WebSessionService,
@@ -49,9 +65,27 @@ export class DynamicTabComponent extends BaseComponent im
}
ngOnDestroy(): void {
+ this.componentInstanceDestroyed$.next();
+ this.componentInstanceDestroyed$.complete();
super.ngOnDestroy();
}
+ ngOnChanges(changes: SimpleChanges): void {
+ if (!changes.webSessionTab || !this.container) {
+ return;
+ }
+
+ // If the reconnect form was showing, the user submitted it and a new
+ // session arrived via updateSession(). Clear the tracking ref so
+ // initializeDynamicComponent() proceeds to create the protocol component
+ // (which replaces the form inside createComponent's create-before-remove).
+ if (this.formRef) {
+ this.formRef = null;
+ }
+
+ this.initializeDynamicComponent();
+ }
+
initializeDynamicComponent(): void {
if (!this.webSessionTab?.component) {
return;
@@ -61,6 +95,10 @@ export class DynamicTabComponent extends BaseComponent im
return;
}
+ // Cancel any subscriptions bound to the previous component instance before
+ // creating a replacement (e.g. on reconnect after a disconnect).
+ this.componentInstanceDestroyed$.next();
+
const componentRef = this.dynamicComponentService.createComponent(
this.container,
this.sessionsContainerElement,
@@ -71,14 +109,14 @@ export class DynamicTabComponent extends BaseComponent im
if (componentRef.instance instanceof WebClientTelnetComponent) {
this.componentListenerService
.onTelnetInitialized()
- .pipe(takeUntil(this.destroyed$), take(1))
+ .pipe(takeUntil(this.componentInstanceDestroyed$), take(1))
.subscribe((event) => {
(componentRef.instance as WebComponentReady).webComponentReady(event as CustomEvent, this.webSessionTab.id);
});
} else if (componentRef.instance instanceof WebClientSshComponent) {
this.componentListenerService
.onSshInitialized()
- .pipe(takeUntil(this.destroyed$), take(1))
+ .pipe(takeUntil(this.componentInstanceDestroyed$), take(1))
.subscribe((event) => {
(componentRef.instance as WebComponentReady).webComponentReady(event as CustomEvent, this.webSessionTab.id);
});
@@ -88,7 +126,7 @@ export class DynamicTabComponent extends BaseComponent im
this.cdr.detectChanges();
componentRef.instance.componentStatus
- .pipe(takeUntil(this.destroyed$), distinctUntilChanged())
+ .pipe(takeUntil(this.componentInstanceDestroyed$), distinctUntilChanged())
.subscribe((status: ComponentStatus) => {
this.webSessionTab.status = status;
if (status.isDisabled) {
@@ -97,15 +135,46 @@ export class DynamicTabComponent extends BaseComponent im
});
componentRef.instance?.sizeChange
- ?.pipe(takeUntil(this.destroyed$))
+ ?.pipe(takeUntil(this.componentInstanceDestroyed$))
.subscribe(() => this.componentRefSizeChange.emit());
this.webSessionTab.componentRef = componentRef;
}
+ /**
+ * Swap the protocol component out and put the reconnect form in its place.
+ * Creates the form FIRST so the container slot is never empty, then removes
+ * the old protocol component (mirrors the create-before-remove pattern used
+ * in DynamicComponentService).
+ */
+ private swapToForm(status: ComponentStatus): void {
+ const formRef = this.container.createComponent(WebClientFormComponent);
+ formRef.instance.isFormExists = true;
+ formRef.instance.webSessionId = this.webSessionTab.id;
+ formRef.instance.inputFormData = this.webSessionTab.data;
+ formRef.instance.sessionTerminationMessage = status.terminationMessage;
+
+ // Remove the protocol component after the form is live.
+ const previousCount = this.container.length - 1;
+ for (let i = 0; i < previousCount; i++) {
+ this.container.remove(0);
+ }
+
+ this.formRef = formRef;
+ // Clear componentRef so initializeDynamicComponent() will recreate the
+ // protocol component once the form is submitted.
+ this.webSessionTab.componentRef = null;
+ this.cdr.detectChanges();
+ }
+
private onComponentDisabled(status: ComponentStatus): void {
if (status.isDisabledByUser) {
+ // User clicked Disconnect — close the tab entirely.
void this.webSessionService.removeSession(status.id);
+ return;
}
+
+ // Connection failed or dropped — swap to the reconnect form.
+ this.swapToForm(status);
}
}
diff --git a/webapp/apps/gateway-ui/src/client/app/shared/components/gateway-alert-message/gateway-alert-message.component.html b/webapp/apps/gateway-ui/src/client/app/shared/components/gateway-alert-message/gateway-alert-message.component.html
index ddb7892fe..e6e559ead 100644
--- a/webapp/apps/gateway-ui/src/client/app/shared/components/gateway-alert-message/gateway-alert-message.component.html
+++ b/webapp/apps/gateway-ui/src/client/app/shared/components/gateway-alert-message/gateway-alert-message.component.html
@@ -3,8 +3,12 @@
-
-
{{message.summary}}
+ @if (message.icon) {
+
+ }
+ @if (message.summary) {
+ {{message.summary}}
+ }
diff --git a/webapp/apps/gateway-ui/src/client/app/shared/components/main-panel/main-panel.component.scss b/webapp/apps/gateway-ui/src/client/app/shared/components/main-panel/main-panel.component.scss
index 997912e5b..202286596 100644
--- a/webapp/apps/gateway-ui/src/client/app/shared/components/main-panel/main-panel.component.scss
+++ b/webapp/apps/gateway-ui/src/client/app/shared/components/main-panel/main-panel.component.scss
@@ -1,8 +1,18 @@
+:host {
+ display: flex;
+ flex: 1;
+ min-height: 0;
+ width: 100%;
+ flex-direction: column;
+}
.container {
display: flex;
+ flex-direction: row;
+ flex: 1;
+ min-height: 0;
+ overflow: hidden;
+ background-color: #ECECEC;
justify-content: center;
align-items: center;
- height: 100vh;
- background-color: #ECECEC;
-}
\ No newline at end of file
+}
diff --git a/webapp/apps/gateway-ui/src/client/app/shared/components/session-toolbar/session-toolbar.component.html b/webapp/apps/gateway-ui/src/client/app/shared/components/session-toolbar/session-toolbar.component.html
index 788bdba2b..e54a5ad61 100644
--- a/webapp/apps/gateway-ui/src/client/app/shared/components/session-toolbar/session-toolbar.component.html
+++ b/webapp/apps/gateway-ui/src/client/app/shared/components/session-toolbar/session-toolbar.component.html
@@ -6,9 +6,7 @@
class="session-toolbar"
>
diff --git a/webapp/apps/gateway-ui/src/client/app/shared/components/tab-view/tab-view.component.html b/webapp/apps/gateway-ui/src/client/app/shared/components/tab-view/tab-view.component.html
index 33e6abff8..1a8e9b9d0 100644
--- a/webapp/apps/gateway-ui/src/client/app/shared/components/tab-view/tab-view.component.html
+++ b/webapp/apps/gateway-ui/src/client/app/shared/components/tab-view/tab-view.component.html
@@ -1,11 +1,21 @@
diff --git a/webapp/apps/gateway-ui/src/client/app/shared/components/tab-view/tab-view.component.scss b/webapp/apps/gateway-ui/src/client/app/shared/components/tab-view/tab-view.component.scss
index 93c6d8807..bfa51b3c5 100644
--- a/webapp/apps/gateway-ui/src/client/app/shared/components/tab-view/tab-view.component.scss
+++ b/webapp/apps/gateway-ui/src/client/app/shared/components/tab-view/tab-view.component.scss
@@ -16,7 +16,30 @@
background: #F9F9F9 url('../../../../../assets/images/session_background.png');
}
+:host ::ng-deep .p-tabs {
+ flex: 1; // fill tabview-container (flex column parent)
+ min-height: 0; // prevent flex overflow blowout
+ display: flex;
+ flex-direction: column;
+}
+
:host ::ng-deep .p-tabs .p-tabpanels {
+ flex: 1;
+ min-height: 0;
padding: 0;
+ overflow: auto;
+ background: unset;
+ display: flex;
+ flex-direction: column;
}
+:host ::ng-deep .p-tabs .p-tabpanel {
+ flex: 1;
+ min-height: 0;
+ display: flex;
+ flex-direction: column;
+}
+
+.hidden-tablist {
+ display: none;
+}
diff --git a/webapp/apps/gateway-ui/src/client/app/shared/components/tab-view/tab-view.component.ts b/webapp/apps/gateway-ui/src/client/app/shared/components/tab-view/tab-view.component.ts
index 355c8f83d..db6557637 100644
--- a/webapp/apps/gateway-ui/src/client/app/shared/components/tab-view/tab-view.component.ts
+++ b/webapp/apps/gateway-ui/src/client/app/shared/components/tab-view/tab-view.component.ts
@@ -28,7 +28,21 @@ export class TabViewComponent extends BaseComponent implements OnDestroy, AfterV
@ViewChild('sessionsContainer') sessionsContainerRef: ElementRef;
webSessionTabs: WebSession