From b8ef1315cf5b7539a086f0e2a8d07cbb5c75565e Mon Sep 17 00:00:00 2001 From: Philip Prinz Date: Fri, 25 Oct 2024 10:58:53 +0200 Subject: [PATCH] add webinterfaces and shared vms to step component, refactor dashboard shared vm access --- src/app/app.module.ts | 15 +- .../course-wizard/course-wizard.component.ts | 1 - src/app/dashboards/dashboards.component.ts | 4 +- .../shared-vm-dashboard.component.html | 30 +-- .../shared-vm-dashboard.component.ts | 48 +++- .../vm-dashboard/vm-dashboard.component.html | 13 +- .../vm-dashboard/vm-dashboard.component.ts | 4 +- src/app/data/Session.ts | 1 + src/app/data/admin-vm.service.ts | 51 ++++ src/app/data/scheduledevent.service.ts | 1 + src/app/data/scheduledevent.ts | 2 +- src/app/data/sharedvm.ts | 4 +- src/app/data/vm.service.ts | 57 ---- .../new-scheduled-event.component.html | 26 +- .../new-scheduled-event.component.ts | 126 ++++++--- .../session-statistics.component.ts | 2 - .../session-time-statistics.component.ts | 2 - .../step/step-component/step.component.html | 90 ++++++- src/app/step/step-component/step.component.ts | 249 +++++++++++++++++- .../terminal/terminal-view.component.html | 12 +- .../terminal/terminal-view.component.scss | 4 - .../step/terminal/terminal-view.component.ts | 43 ++- src/app/step/terminal/terminal.component.scss | 2 +- .../webinterface-window.component.html | 28 ++ .../webinterface-window.component.scss | 16 ++ .../webinterface-window.component.ts | 120 +++++++++ src/app/step/vm.service.ts | 23 +- 27 files changed, 770 insertions(+), 204 deletions(-) create mode 100644 src/app/data/admin-vm.service.ts delete mode 100644 src/app/data/vm.service.ts create mode 100644 src/app/step/terminal/webinterface-window/webinterface-window.component.html create mode 100644 src/app/step/terminal/webinterface-window/webinterface-window.component.scss create mode 100644 src/app/step/terminal/webinterface-window/webinterface-window.component.ts diff --git a/src/app/app.module.ts b/src/app/app.module.ts index d3f3215c..7ff689b3 100644 --- a/src/app/app.module.ts +++ b/src/app/app.module.ts @@ -156,6 +156,7 @@ import { } from '@cds/core/icon'; import { ReadonlyTaskComponent } from './scenario/task/readonly-task/readonly-task.component'; import { TerminalViewComponent } from './step/terminal/terminal-view.component'; +import { WebinterfaceWindowComponent } from './step/terminal/webinterface-window/webinterface-window.component'; ClarityIcons.addIcons( plusIcon, @@ -204,12 +205,23 @@ const appInitializerFn = (appConfig: AppConfigService) => { }; }; +export const jwtAllowedDomains = [ + environment.server.replace(/(^\w+:|^)\/\//, ''), +]; + +export function addJwtAllowedDomain(domain: string) { + const newDomain = domain.replace(/(^\w+:|^)\/\//, ''); + if (!jwtAllowedDomains.includes(newDomain)) { + jwtAllowedDomains.push(newDomain); + } +} + export function jwtOptionsFactory(): JwtConfig { return { tokenGetter: () => { return localStorage.getItem('hobbyfarm_admin_token'); }, - allowedDomains: [environment.server.match(/.*\:\/\/?([^\/]+)/)[1]], + allowedDomains: jwtAllowedDomains, disallowedRoutes: [ environment.server.match(/.*\:\/\/?([^\/]+)/)[1] + '/auth/authenticate', ], @@ -296,6 +308,7 @@ export function jwtOptionsFactory(): JwtConfig { TaskFormComponent, ReadonlyTaskComponent, SingleTaskVerificationMarkdownComponent, + WebinterfaceWindowComponent, ], imports: [ BrowserModule, diff --git a/src/app/course/course-wizard/course-wizard.component.ts b/src/app/course/course-wizard/course-wizard.component.ts index 24ed38a0..46db8dfd 100644 --- a/src/app/course/course-wizard/course-wizard.component.ts +++ b/src/app/course/course-wizard/course-wizard.component.ts @@ -154,7 +154,6 @@ export class CourseWizardComponent implements OnChanges, OnInit { } courseHasValidVMCConfiguration(): boolean { - console.log(this.editVirtualMachines); if (this.editVirtualMachines.length > 0) { const validVMSets = this.editVirtualMachines.filter( (virtualmachine, i) => { diff --git a/src/app/dashboards/dashboards.component.ts b/src/app/dashboards/dashboards.component.ts index 2fc02e1f..da3d85bd 100644 --- a/src/app/dashboards/dashboards.component.ts +++ b/src/app/dashboards/dashboards.component.ts @@ -6,7 +6,7 @@ import { UserService } from '../data/user.service'; import { RbacService } from '../data/rbac.service'; import { ProgressCount } from '../data/progress'; import { ProgressService } from '../data/progress.service'; -import { VmService } from '../data/vm.service'; +import { AdminVmService } from '../data/admin-vm.service'; import { Router } from '@angular/router'; @Component({ @@ -33,7 +33,7 @@ export class DashboardsComponent implements OnInit, OnDestroy { private userService: UserService, private rbacService: RbacService, private progressService: ProgressService, - private vmService: VmService, + private vmService: AdminVmService, private router: Router ) {} diff --git a/src/app/dashboards/shared-vm-dashboard/shared-vm-dashboard.component.html b/src/app/dashboards/shared-vm-dashboard/shared-vm-dashboard.component.html index dd191835..5a4603c7 100644 --- a/src/app/dashboards/shared-vm-dashboard/shared-vm-dashboard.component.html +++ b/src/app/dashboards/shared-vm-dashboard/shared-vm-dashboard.component.html @@ -7,8 +7,8 @@ (click)="setStepOpen(set)" > - Environment: {{ set.environment }}   - Count: {{ set.count }} + Environment: {{ set.environment }}   Count: {{ set.count }}
@@ -22,18 +22,15 @@ 'scenarios.get', 'virtualmachineclaims.get' ]" - >Join SessionAccess Terminal Status + Name IP VM-Template - User - Allocated - Tainted + VM Id Hostname {{ vm.status }} {{ vm.status }} + tainted + {{ vm.name }} {{ vm.public_ip }} {{ vm.vm_template_id }} - {{ vm.user }} - {{ - vm.allocated - }} - {{ - vm.tainted - }} {{ vm.id }} {{ vm.hostname diff --git a/src/app/dashboards/shared-vm-dashboard/shared-vm-dashboard.component.ts b/src/app/dashboards/shared-vm-dashboard/shared-vm-dashboard.component.ts index 5db6376e..c9e5b62a 100644 --- a/src/app/dashboards/shared-vm-dashboard/shared-vm-dashboard.component.ts +++ b/src/app/dashboards/shared-vm-dashboard/shared-vm-dashboard.component.ts @@ -5,11 +5,15 @@ import { VirtualMachine, VirtualMachineTypeShared, } from 'src/app/data/virtualmachine'; -import { VmService } from 'src/app/data/vm.service'; +import { AdminVmService } from 'src/app/data/admin-vm.service'; import { VmSet } from 'src/app/data/vmset'; +interface DashboardVm extends VirtualMachine { + name?: string; +} + interface dashboardVmSet extends VmSet { - setVMs?: VirtualMachine[]; + setVMs?: DashboardVm[]; stepOpen?: boolean; dynamic: boolean; } @@ -24,7 +28,7 @@ export class SharedVmDashboardComponent implements OnChanges { selectedEvent: ScheduledEvent; constructor( - public vmService: VmService, + public vmService: AdminVmService, private router: Router, private cd: ChangeDetectorRef ) {} @@ -50,13 +54,11 @@ export class SharedVmDashboardComponent implements OnChanges { .listByScheduledEvent(this.selectedEvent.id) .subscribe((vmList) => { this.vms = vmList - .filter((vm) => vm.vm_type == VirtualMachineTypeShared) // vm.vm_type!="Shared" && vm.user=='' + .filter((vm) => vm.vm_type == VirtualMachineTypeShared) .map((vm) => ({ ...vm, })); - if ( - this.vms.length > 0 - ) { + if (this.vms.length > 0) { this.loadVmsFromScheduledEvent(); } this.cd.detectChanges(); @@ -72,6 +74,7 @@ export class SharedVmDashboardComponent implements OnChanges { ); // (shared) vms grouped by environment groupedVms.forEach((element, environment) => { + element.forEach((vm) => this.setVmName(vm)); let vmSet: dashboardVmSet = { ...new VmSet(), base_name: environment, @@ -87,12 +90,35 @@ export class SharedVmDashboardComponent implements OnChanges { } } - openTerminal(vm: VirtualMachine) { - const url = this.router.serializeUrl( - this.router.createUrlTree(['/terminal', vm.id, vm.ws_endpoint]) + setVmName(vm: DashboardVm) { + vm.name = + this.selectedEvent.shared_vms.find((sVM) => sVM.vm_id == vm.id)?.name ?? + ''; + } + + openTerminal(vm: DashboardVm) { + //build url with params, then use router to navigate to it + if (!vm.name) this.setVmName(vm) + const queryParams = { + vmName: vm.name, + vmId: vm.id, + wsEndpoint: vm.ws_endpoint, + }; + + const url = this.router.createUrlTree( + ['/terminal', vm.id, vm.ws_endpoint], + { queryParams } ); - window.open(url, '_blank'); + const serializedUrl = this.router.serializeUrl(url); + + window.open(serializedUrl, '_blank'); return; + + // const url = this.router.serializeUrl( + // this.router.createUrlTree(['/terminal', vm.id, vm.ws_endpoint]) + // ); + // window.open(url, '_blank'); + // return; } groupByEnvironment(vms: VirtualMachine[]) { diff --git a/src/app/dashboards/vm-dashboard/vm-dashboard.component.html b/src/app/dashboards/vm-dashboard/vm-dashboard.component.html index c9396f39..12605ec7 100644 --- a/src/app/dashboards/vm-dashboard/vm-dashboard.component.html +++ b/src/app/dashboards/vm-dashboard/vm-dashboard.component.html @@ -45,7 +45,6 @@ Allocated - Tainted VM Id Hostname {{ vm.status }} {{ vm.status }} + tainted {{ vm.public_ip @@ -91,9 +95,6 @@ {{ vm.allocated }} - {{ - vm.tainted - }} {{ vm.id }} {{ vm.hostname diff --git a/src/app/dashboards/vm-dashboard/vm-dashboard.component.ts b/src/app/dashboards/vm-dashboard/vm-dashboard.component.ts index 55ebd6b9..ecc9c9a1 100644 --- a/src/app/dashboards/vm-dashboard/vm-dashboard.component.ts +++ b/src/app/dashboards/vm-dashboard/vm-dashboard.component.ts @@ -6,7 +6,7 @@ import { ProgressService } from 'src/app/data/progress.service'; import { ScheduledEvent } from 'src/app/data/scheduledevent'; import { UserService } from 'src/app/data/user.service'; import { VirtualMachine, VirtualMachineTypeShared } from 'src/app/data/virtualmachine'; -import { VmService } from 'src/app/data/vm.service'; +import { AdminVmService } from 'src/app/data/admin-vm.service'; import { VmSet } from 'src/app/data/vmset'; import { VmSetService } from 'src/app/data/vmset.service'; @@ -26,7 +26,7 @@ export class VmDashboardComponent implements OnChanges { selectedEvent: ScheduledEvent; constructor( - public vmService: VmService, + public vmService: AdminVmService, public vmSetService: VmSetService, public userService: UserService, public progressService: ProgressService, diff --git a/src/app/data/Session.ts b/src/app/data/Session.ts index f4dd9f6b..8d15515b 100644 --- a/src/app/data/Session.ts +++ b/src/app/data/Session.ts @@ -5,4 +5,5 @@ export class Session { keep_course_vm: boolean; user: string; vm_claim: string[]; + access_code: string; } diff --git a/src/app/data/admin-vm.service.ts b/src/app/data/admin-vm.service.ts new file mode 100644 index 00000000..fa76af22 --- /dev/null +++ b/src/app/data/admin-vm.service.ts @@ -0,0 +1,51 @@ +import { Injectable } from '@angular/core'; +import { HttpErrorResponse } from '@angular/common/http'; +import { catchError, switchMap } from 'rxjs/operators'; +import { ServerResponse } from './serverresponse'; +import { of, throwError } from 'rxjs'; +import { atou } from '../unicode'; +import { ResourceClient, GargantuaClientFactory } from './gargantua.service'; +import { VirtualMachine } from './virtualmachine'; + +@Injectable({ + providedIn: 'root', +}) +export class AdminVmService extends ResourceClient { + constructor(gcf: GargantuaClientFactory) { + super(gcf.scopedClient('/a/vm')); + } + + public list() { + return this.garg.get('/list').pipe( + catchError((e: HttpErrorResponse) => { + return throwError(() => e.error); + }), + switchMap((s: ServerResponse) => { + return of(JSON.parse(atou(s.content))); + }) + ); + } + public listByScheduledEvent(id: String) { + return this.garg + .get('/scheduledevent/' + id) + .pipe( + catchError((e: HttpErrorResponse) => { + return throwError(() => e.error); + }), + switchMap((s: ServerResponse) => { + return of(JSON.parse(atou(s.content))); + }) + ); + } + + public count() { + return this.garg.get('/count').pipe( + catchError((e: HttpErrorResponse) => { + return throwError(() => e.error); + }), + switchMap((s: ServerResponse) => { + return of(JSON.parse(atou(s.content))); + }) + ); + } +} diff --git a/src/app/data/scheduledevent.service.ts b/src/app/data/scheduledevent.service.ts index 02015b33..52f0d750 100644 --- a/src/app/data/scheduledevent.service.ts +++ b/src/app/data/scheduledevent.service.ts @@ -63,6 +63,7 @@ export class ScheduledeventService extends ListableResourceClient { - return of(JSON.parse(atou(s.content))) - }) - ) - } - public listByScheduledEvent(id: String) { - - return this.http.get(environment.server + '/a/vm/scheduledevent/' + id ) - .pipe( - switchMap((s: ServerResponse) => { - return of(JSON.parse(atou(s.content))) - }) - ) - } - - public getVmById(id: number) { - return this.http.get(environment.server + '/vm?' + id ) - .pipe( - switchMap((s: ServerResponse) => { - return of(JSON.parse(atou(s.content))) - }) - ) - } - - public count() { - return this.http.get(environment.server + "/a/vm/count") - .pipe( - switchMap((s : ServerResponse) => { - return of(JSON.parse(atou(s.content))) - }) - ) - } -} \ No newline at end of file diff --git a/src/app/event/new-scheduled-event/new-scheduled-event.component.html b/src/app/event/new-scheduled-event/new-scheduled-event.component.html index 71531fc1..2cbc6785 100644 --- a/src/app/event/new-scheduled-event/new-scheduled-event.component.html +++ b/src/app/event/new-scheduled-event/new-scheduled-event.component.html @@ -33,6 +33,9 @@ Select Virtual Machines + + Select Shared Virtual Machines + Finalize @@ -600,7 +603,7 @@

Simple Mode Compatibility

Name Environment - Virtual Machines + Virtual Machine Template @@ -614,20 +617,24 @@

Simple Mode Compatibility

VM name must be unique + VM name must not contain whitespace + + VM Template not available in Environment + @@ -638,8 +645,9 @@

Simple Mode Compatibility

class="btn btn-success btn-sm" (click)="addSharedVM()" [disabled]="!sharedVmForm.valid" + style="float: right;" > - New SharedVM + Add @@ -649,7 +657,7 @@

SharedVM Information

Name Environment - Virtual Machines + Virtual Machine Template @@ -660,11 +668,11 @@

SharedVM Information

{{ sharedVm.name }} {{ sharedVm.environment }} - {{ sharedVm.vmTemplate }} - + {{ sharedVm.vm_template }} + - + @@ -974,7 +982,7 @@

SharedVM Information

{{ sharedVm.name }} {{ sharedVm.environment }} - {{ sharedVm.vmTemplate }} + {{ sharedVm.vm_template }} diff --git a/src/app/event/new-scheduled-event/new-scheduled-event.component.ts b/src/app/event/new-scheduled-event/new-scheduled-event.component.ts index acf8422e..02f5a84c 100644 --- a/src/app/event/new-scheduled-event/new-scheduled-event.component.ts +++ b/src/app/event/new-scheduled-event/new-scheduled-event.component.ts @@ -49,8 +49,6 @@ import { QuickSetEndTimeFormGroup } from 'src/app/data/forms'; import { VMTemplate } from 'src/app/data/vmtemplate'; import { VmtemplateService } from 'src/app/data/vmtemplate.service'; - - // This object type maps VMTemplate names to the number of requested VMs // The key specifies the template name // The FormControl holds the number of requested VMs @@ -132,12 +130,39 @@ export class NewScheduledEventComponent public newSharedVM: Record>; public sharedVmForm = new FormGroup({ - "vm_name": new FormControl(""), - "vm_env": new FormControl(""), - "vm_template": new FormControl(""), - }) + vm_name: new FormControl('', { + validators: [ + Validators.required, + Validators.minLength(4), + this.noWhitespace(), + this.uniqueSharedVMName(), + ], + nonNullable: true, + }), + vm_env: new FormControl(''), + vm_template: new FormControl('', { + validators: [ + Validators.required, + this.templateMatchesEnv(), + ] + }), + }); - // public vmtc: VmtemplatesComponent; + templateMatchesEnv(): ValidatorFn { + + return (control: FormControl): { matchEnv: boolean } | null => { + if ( + !control.value || + !this.sharedVmForm.controls.vm_env || + !(this.getTemplates(this.sharedVmForm.controls.vm_env.value).includes(control.value)) + ) { + return { + matchEnv: true, + }; + } + return null; + }; + } constructor( private _fb: NonNullableFormBuilder, @@ -154,13 +179,11 @@ export class NewScheduledEventComponent if (!allowVMTemplateList) { return; } - vmTemplateService - .list() - .subscribe((list: VMTemplate[]) => - list.forEach((v) => - this.virtualMachineTemplateList.set(v.id, v.name) - ) - ); + vmTemplateService.list().subscribe((list: VMTemplate[]) => + list.forEach((v) => { + this.virtualMachineTemplateList.set(v.id, v.name); + }) + ); }); } ngOnDestroy(): void { @@ -169,13 +192,26 @@ export class NewScheduledEventComponent ngAfterViewInit(): void { this.wizardSubscription = this.wizardPages.changes - .pipe(filter((wizardPages: QueryList) => wizardPages.length != 0 && !this.checkingEnvironments)) + .pipe( + filter( + (wizardPages: QueryList) => + wizardPages.length != 0 && !this.checkingEnvironments + ) + ) .subscribe((wizardPages: QueryList) => { setTimeout(() => { this.wizard.navService.goTo(wizardPages.last, true); wizardPages.first.makeCurrent(); }); }); + + this.sharedVmForm.controls.vm_env.valueChanges.subscribe((env) => { + this.sharedVmForm.controls.vm_template.setValue(this.getTemplates(env)[0] ?? "") + }) + + this.sharedVmForm.valueChanges.subscribe(() => { + this.sharedVmForm.controls.vm_template.updateValueAndValidity() + }) } public eventDetails: FormGroup<{ @@ -309,6 +345,33 @@ export class NewScheduledEventComponent }; } + public uniqueSharedVMName(): ValidatorFn { + return (control: FormControl): { notUnique: boolean } | null => { + if ( + !control.value || + this.scheduledEvents.filter((el) => + el.shared_vms.map((vm) => vm.name).includes(control.value) + ).length > 0 + ) { + return { + notUnique: true, + }; + } + return null; + }; + } + + public noWhitespace(): ValidatorFn { + return (control: FormControl): { whitespace: boolean } | null => { + if (control.value.includes(' ')) { + return { + whitespace: true, + }; + } + return null; + }; + } + @ViewChild('wizard', { static: true }) wizard: ClrWizard; @ViewChildren(ClrWizardPage) wizardPages: QueryList; @ViewChild('startTimeSignpost') startTimeSignpost: ClrSignpostContent; @@ -342,6 +405,7 @@ export class NewScheduledEventComponent } public getTemplates(env: string) { + if (!this.keyedEnvironments || this.keyedEnvironments.size == 0 || !this.keyedEnvironments.has(env)) return [] return Object.keys(this.keyedEnvironments.get(env).template_mapping); } @@ -666,7 +730,6 @@ export class NewScheduledEventComponent this.se = new ScheduledEvent(); this.se.required_vms = {}; } - console.log(this.se) } public simpleUserTotal() { @@ -1123,27 +1186,28 @@ export class NewScheduledEventComponent ); } + getTemplatesForEnv() { + const templates = this.getTemplates(this.sharedVmForm.controls.vm_env.value) + let availableTemplates = new Map() + this.virtualMachineTemplateList.forEach((k, v) => { + if (templates.includes(k)) availableTemplates.set(k, v) + }) + return availableTemplates; + } + public addSharedVM() { - if(this.se.shared_vms==null) {this.se.shared_vms=[]} + if (this.se.shared_vms == null) { + this.se.shared_vms = []; + } this.se.shared_vms.push({ - vmId: "", + vm_id: '', name: this.sharedVmForm.controls.vm_name.value, environment: this.sharedVmForm.controls.vm_env.value, - vmTemplate: this.sharedVmForm.controls.vm_template.value, - }) - } - - public addNewSharedVM(name: string,environment: string, vmtemplate: string ) { - if(this.se.shared_vms==null) {this.se.shared_vms=[]} - this.se.shared_vms.push({ - vmId: "", - environment: environment, - name: name, - vmTemplate: vmtemplate, - }) + vm_template: this.sharedVmForm.controls.vm_template.value, + }); } deleteSharedVm(index: number) { - this.se.shared_vms.splice(index, 1) + this.se.shared_vms.splice(index, 1); } } diff --git a/src/app/session-statistics/session-statistics.component.ts b/src/app/session-statistics/session-statistics.component.ts index ef7b9d73..18f781f9 100644 --- a/src/app/session-statistics/session-statistics.component.ts +++ b/src/app/session-statistics/session-statistics.component.ts @@ -423,7 +423,6 @@ export class SessionStatisticsComponent implements OnInit, OnChanges { event?: ChartEvent; active?: {}[]; }): void { - // console.log(event, active); } public chartHovered({ @@ -433,7 +432,6 @@ export class SessionStatisticsComponent implements OnInit, OnChanges { event?: ChartEvent; active?: {}[]; }): void { - // console.log(event, active); } private setupScenariosWithSessions(progressData: Progress[]) { diff --git a/src/app/session-statistics/session-time-statistics/session-time-statistics.component.ts b/src/app/session-statistics/session-time-statistics/session-time-statistics.component.ts index f06f96c1..9e4a6289 100644 --- a/src/app/session-statistics/session-time-statistics/session-time-statistics.component.ts +++ b/src/app/session-statistics/session-time-statistics/session-time-statistics.component.ts @@ -248,7 +248,6 @@ export class SessionTimeStatisticsComponent implements OnInit { event?: ChartEvent; active?: {}[]; }): void { - // console.log(event, active); } public chartHovered({ @@ -258,7 +257,6 @@ export class SessionTimeStatisticsComponent implements OnInit { event?: ChartEvent; active?: {}[]; }): void { - // console.log(event, active); } private prepareBarchartDatasets() { diff --git a/src/app/step/step-component/step.component.html b/src/app/step/step-component/step.component.html index 6643e08d..6f70af98 100644 --- a/src/app/step/step-component/step.component.html +++ b/src/app/step/step-component/step.component.html @@ -1,6 +1,6 @@
- - + + - + +
- + + - + @@ -89,6 +91,84 @@

Admin access to terminals only possible on machines that support ssh-connections

+ + + + +
Public IP: {{ v.value.public_ip }}Public IP: {{ v.value?.public_ip }} Private IP: {{ v.value.private_ip }} Hostname: {{ v.value.hostname }} Shell Status: {{ getShellStatus(v.key) }}
+ + + + + + + +
Webinterface: {{ webinterface.name }}Node: {{ v.key }}Port: {{ webinterface.port }}Path: {{ webinterface.path }} + + +
+ + +
+
+ + + + +
+
diff --git a/src/app/step/step-component/step.component.ts b/src/app/step/step-component/step.component.ts index 9c0c2af7..492935eb 100644 --- a/src/app/step/step-component/step.component.ts +++ b/src/app/step/step-component/step.component.ts @@ -7,18 +7,32 @@ import { ElementRef, AfterViewInit, OnDestroy, + Input, } from '@angular/core'; import { ActivatedRoute, Router } from '@angular/router'; import { Step } from '../../data/step'; -import { switchMap, concatMap, tap, map, toArray } from 'rxjs/operators'; +import { + switchMap, + concatMap, + tap, + map, + toArray, + first, + mergeMap, + withLatestFrom, + catchError, +} from 'rxjs/operators'; import { TerminalComponent } from '../terminal/terminal.component'; import { ClrTabContent, ClrTab, ClrModal } from '@clr/angular'; import { Scenario } from '../../data/scenario'; import { Session } from '../../data/Session'; -import { from } from 'rxjs'; +import { forkJoin, from, Observable, of, Subject } from 'rxjs'; import { VMClaim } from '../../data/vmclaim'; import { VMClaimVM } from '../../data/vmclaimvm'; -import { VirtualMachine as VM } from '../../data/virtualmachine'; +import { + VirtualMachineTypeShared, + VirtualMachine as VM, +} from '../../data/virtualmachine'; import { CtrService } from '../../data/ctr.service'; import { CodeExec } from '../CodeExec'; import { SessionService } from '../../data/session.service'; @@ -31,6 +45,29 @@ import { atou } from '../../unicode'; import { HfMarkdownRenderContext } from '../hf-markdown.component'; import { UserService } from '../../data/user.service'; import { CourseService } from '../../data/course.service'; +import { JwtHelperService } from '@auth0/angular-jwt'; +import { addJwtAllowedDomain } from 'src/app/app.module'; +import { ScheduledeventService } from 'src/app/data/scheduledevent.service'; + +type Service = { + name: string; + port: number; + path: string; + hasOwnTab: boolean; + hasWebinterface: boolean; + disallowIFrame: boolean; + active: boolean; +}; + +interface stepVM extends VM { + webinterfaces?: Service[]; + name?: string; +} + +export type webinterfaceTabIdentifier = { + vmId: string; + port: number; +}; @Component({ selector: 'app-step', @@ -49,7 +86,7 @@ export class StepComponent implements OnInit, AfterViewInit, OnDestroy { public session: Session = new Session(); public sessionExpired = false; - public vms: Map = new Map(); + public vms: Map = new Map(); mdContext: HfMarkdownRenderContext = { vmInfo: {}, session: '' }; @@ -60,7 +97,20 @@ export class StepComponent implements OnInit, AfterViewInit, OnDestroy { public username: String = ''; public courseName: String = ''; + @Input() public isUserSession: boolean = true; + @Input() public vmId?: string; + @Input() public vmName?: string; + + public maxInterfaceTabs: number = 10; + private activeWebinterface: Service; + + private reloadTabSubject: Subject = + new Subject(); + public reloadTabObservable: Observable = + this.reloadTabSubject.asObservable(); + public checkInterval: any; + public sharedVMs: stepVM[] = []; @ViewChildren('term') private terms: QueryList = new QueryList(); @@ -81,7 +131,9 @@ export class StepComponent implements OnInit, AfterViewInit, OnDestroy { private vmService: VMService, private shellService: ShellService, private userService: UserService, - private courseService: CourseService + private courseService: CourseService, + private jwtHelper: JwtHelperService, + private scheduledEventService: ScheduledeventService ) {} handleStepContentClick(e: MouseEvent) { @@ -105,10 +157,39 @@ export class StepComponent implements OnInit, AfterViewInit, OnDestroy { } ngOnInit() { + if (this.isUserSession) { + this.initForUserSession(); + } else { + this.initForSharedVM(); + } + } + + initForSharedVM() { + this.vmService + .get(this.vmId) + .pipe( + tap((res: VM) => this.vms.set(this.vmName, res)), + switchMap((vm: stepVM) => { + return this.vmService.getWebinterfaces(vm.id); + }) + ) + .subscribe((res) => { + this.vms.get(this.vmName).webinterfaces = JSON.parse( + JSON.parse(atob(res.content)) + ); + }); + } + + initForUserSession() { const { paramMap } = this.route.snapshot; const sessionId = paramMap.get('session')!; this.stepnumber = Number(paramMap.get('step') ?? 0); + if (!sessionId) { + // Something went wrong ... the route snapshot should always contain the sessionId + return; + } + this.checkInterval = setInterval(() => { this.ssService.getStatus(sessionId).subscribe({ error: () => { @@ -121,9 +202,14 @@ export class StepComponent implements OnInit, AfterViewInit, OnDestroy { this.ssService .get(sessionId) .pipe( + switchMap((sess: Session) => { + return this.getSharedVMs(sess); + }), + switchMap((sess: Session) => { + return this.getSharedVMNameFromEvent(sess); + }), switchMap((s: Session) => { - this.session = s; - return this.scenarioService.get(s.scenario); + return this.getScenario(s); }), tap((s: Scenario) => { this.scenario = s; @@ -141,16 +227,23 @@ export class StepComponent implements OnInit, AfterViewInit, OnDestroy { return from(v.vm); }), concatMap(([k, v]: [string, VMClaimVM]) => { - return this.vmService - .get(v.vm_id) - .pipe(map((vm) => [k, vm] as const)); + return this.vmService.get(v.vm_id).pipe( + first(), + tap((vm) => addJwtAllowedDomain(vm.ws_endpoint)), //Allow JwtModule to intercept and add the JWT on shell-server requests + map((vm) => [k, vm] as const) + ); }), - toArray() + toArray(), + mergeMap((entries: (readonly [string, VM])[]) => { + this.buildVMSMapWithSharedVMs(entries); + + const vmObservables = this.getWebinterfaces(); + // Using forkJoin to ensure that all inner observables complete, before we return their combined output + return forkJoin(vmObservables); + }) ) .subscribe({ - next: (entries: (readonly [string, VM])[]) => { - this.vms = new Map(entries); - + next: () => { const vmInfo: HfMarkdownRenderContext['vmInfo'] = {}; for (const [k, v] of this.vms) { vmInfo[k.toLowerCase()] = v; @@ -177,6 +270,82 @@ export class StepComponent implements OnInit, AfterViewInit, OnDestroy { }); } + private buildVMSMapWithSharedVMs(entries: (readonly [string, VM])[]) { + this.vms = new Map(entries); + // Adding shared VMs to the vms map in order to render Tabs for their Webinterfaces. + this.sharedVMs.forEach((svm) => { + if (svm.name) { + if (!this.vms.has(svm.name)) { + this.vms.set(svm.name, svm); + } else { + this.vms.set('shared-' + svm.name, svm); + } + } + }); + } + + private getWebinterfaces() { + return Array.from(this.vms.values()).map((vm) => + this.vmService.getWebinterfaces(vm.id).pipe( + map((res) => { + const stringContent: string = atou(res.content); + const services = JSON.parse(JSON.parse(stringContent)); // Consider revising double parse if possible + services.forEach((service: Service) => { + if (service.hasWebinterface) { + const webinterface = { + name: service.name ?? 'Service', + port: service.port ?? 80, + path: service.path ?? '/', + hasOwnTab: !!service.hasOwnTab, + hasWebinterface: true, + disallowIFrame: !!service.disallowIFrame, + active: false, + }; + vm.webinterfaces + ? vm.webinterfaces.push(webinterface) + : (vm.webinterfaces = [webinterface]); + } + }); + return vm; + }), + catchError(() => { + vm.webinterfaces = []; + return of(vm); + }) + ) + ); + } + + private getScenario(s: Session) { + this.session = s; + return this.scenarioService.get(s.scenario); + } + + private getSharedVMNameFromEvent(sess: Session): Observable { + return this.scheduledEventService.list().pipe( + withLatestFrom(of(sess)), + tap(([seList, sess]) => { + seList + .find((se) => se.access_code === sess.access_code) + ?.shared_vms.forEach((vm) => { + let matchingVM = this.sharedVMs.find((sVM) => sVM.id === vm.vm_id); + matchingVM.name = vm.name; + }); + }), + switchMap(([se, sess]) => of(sess)) + ); + } + + private getSharedVMs(sess: Session): Observable { + return this.vmService.getSharedVMs(sess.access_code).pipe( + withLatestFrom(of(sess)), + tap(([sVMs]) => { + this.sharedVMs = sVMs; + }), + switchMap(([sVMs, sess]) => of(sess)) + ); + } + ngAfterViewInit() { const sub = this.tabs.changes.subscribe((tabs: QueryList) => { if (tabs.first) { @@ -277,4 +446,56 @@ export class StepComponent implements OnInit, AfterViewInit, OnDestroy { } }); } + + showTerminalTab(vm: stepVM) { + if (this.isUserSession && vm.vm_type == VirtualMachineTypeShared) + return false; // Users do not see the Terminal of a shared VM + return true; + } + + setTabActive(webinterface: Service, vmName: string) { + // Find our Webinterface and set it active, save currently active webinterface to set it unactive on change without having to iterate through all of them again. + const webi = this.vms + .get(vmName) + ?.webinterfaces?.find((wi) => wi.name == webinterface.name); + if (webi) { + if (this.activeWebinterface) { + this.activeWebinterface.active = false; + } + webi.active = true; + this.activeWebinterface = webi; + } + // Find the corresponding clrTab and call activate on that. Background discussion on why this workaround has to be used can be found here: https://github.com/vmware-archive/clarity/issues/2112 + const tabLinkSelector = vmName + webinterface.name; + setTimeout(() => { + const tabLink = this.tabs + .map((x) => x.tabLink) + .find((x) => x.tabLinkId == tabLinkSelector); + if (tabLink) tabLink.activate(); + }, 1); + } + + reloadWebinterface(vmId: string, webinterface: Service) { + this.reloadTabSubject.next({ + vmId: vmId, + port: webinterface.port, + } as webinterfaceTabIdentifier); + } + + openWebinterfaceInNewTab(vm: stepVM, wi: Service) { + // we always load our token synchronously from local storage + // for symplicity we are using type assertion to string here, avoiding to handle promises we're not expecting + const token = this.jwtHelper.tokenGetter() as string; + const url: string = + 'https://' + + vm.ws_endpoint + + '/auth/' + + token + + '/p/' + + vm.id + + '/' + + wi.port + + wi.path; + window.open(url, '_blank'); + } } diff --git a/src/app/step/terminal/terminal-view.component.html b/src/app/step/terminal/terminal-view.component.html index a09052ff..e217c3c2 100644 --- a/src/app/step/terminal/terminal-view.component.html +++ b/src/app/step/terminal/terminal-view.component.html @@ -1,11 +1 @@ -
- - - - - - -
vmname: {{ vmname }}endpoint: {{ endpoint }}vmid: {{ vmid }}
- -
- + \ No newline at end of file diff --git a/src/app/step/terminal/terminal-view.component.scss b/src/app/step/terminal/terminal-view.component.scss index 3842460d..e69de29b 100644 --- a/src/app/step/terminal/terminal-view.component.scss +++ b/src/app/step/terminal/terminal-view.component.scss @@ -1,4 +0,0 @@ -.test { - width: 100%; - height: 100%; -} \ No newline at end of file diff --git a/src/app/step/terminal/terminal-view.component.ts b/src/app/step/terminal/terminal-view.component.ts index eea777f4..2a317cfa 100644 --- a/src/app/step/terminal/terminal-view.component.ts +++ b/src/app/step/terminal/terminal-view.component.ts @@ -1,29 +1,24 @@ -import { Component, OnInit } from "@angular/core"; -import { ActivatedRoute, Params } from "@angular/router"; -import { tap } from "rxjs"; +import { Component } from '@angular/core'; +import { ActivatedRoute } from '@angular/router'; - @Component({ - selector: 'app-terminal-view', - templateUrl: './terminal-view.component.html', - styleUrls: ['terminal-view.component.scss'], + selector: 'app-terminal-view', + templateUrl: './terminal-view.component.html', + styleUrls: ['terminal-view.component.scss'], }) -export class TerminalViewComponent { - public vmid = '' - public endpoint = '' - public vmname = '' - - constructor(public route: ActivatedRoute) {} +export class TerminalViewComponent { + public vmid = ''; + public vmname = ''; - ngOnInit() { - this.route.params.pipe( - tap((params: Params) => { - console.log("tapping params...") - this.vmname = params['vmName']; - this.vmid = params['vmId']; - this.endpoint = params['wsEndpoint']; - console.log("params: ", this.vmid, " ", this.endpoint," ", this.vmname," ", params) - })).subscribe(); - } -} \ No newline at end of file + constructor( + public route: ActivatedRoute + ) {} + + ngOnInit() { + //get the query params from the route + const queryParams = this.route.snapshot.queryParams; + this.vmname = queryParams['vmName']; + this.vmid = queryParams['vmId']; + } +} diff --git a/src/app/step/terminal/terminal.component.scss b/src/app/step/terminal/terminal.component.scss index 787e6b4f..fba1a6fc 100644 --- a/src/app/step/terminal/terminal.component.scss +++ b/src/app/step/terminal/terminal.component.scss @@ -1,5 +1,5 @@ .terminal-div { - height: calc(100% - 1.8rem); + height: 96dvh; //calc(100% - 1.8rem); width: 100%; } diff --git a/src/app/step/terminal/webinterface-window/webinterface-window.component.html b/src/app/step/terminal/webinterface-window/webinterface-window.component.html new file mode 100644 index 00000000..5f74f705 --- /dev/null +++ b/src/app/step/terminal/webinterface-window/webinterface-window.component.html @@ -0,0 +1,28 @@ +
+
+ This Service does not allow iFrames. It has to be opened in a new Tab: + +
+
Loading
+
+

An Error has occurred

+ +
+ +
+ \ No newline at end of file diff --git a/src/app/step/terminal/webinterface-window/webinterface-window.component.scss b/src/app/step/terminal/webinterface-window/webinterface-window.component.scss new file mode 100644 index 00000000..736f29bd --- /dev/null +++ b/src/app/step/terminal/webinterface-window/webinterface-window.component.scss @@ -0,0 +1,16 @@ +.ideHidden { + visibility: hidden; + } + + .ide { + height: 94%; + width: 100%; + } + + .info { + height: 94%; + width: 100%; + text-align: center; + padding-top: 40%; + } + \ No newline at end of file diff --git a/src/app/step/terminal/webinterface-window/webinterface-window.component.ts b/src/app/step/terminal/webinterface-window/webinterface-window.component.ts new file mode 100644 index 00000000..1c9c7f90 --- /dev/null +++ b/src/app/step/terminal/webinterface-window/webinterface-window.component.ts @@ -0,0 +1,120 @@ +import { HttpClient } from '@angular/common/http'; +import { + Component, + ElementRef, + EventEmitter, + Input, + OnInit, + Output, + ViewChild, +} from '@angular/core'; +import { JwtHelperService } from '@auth0/angular-jwt'; +import { Observable, timer } from 'rxjs'; +import { RetryConfig, retry } from 'rxjs/operators'; + + +export type webinterfaceTabIdentifier = { + vmId: string; + port: number; +}; + +@Component({ + selector: 'app-webinterface-window', + templateUrl: './webinterface-window.component.html', + styleUrls: ['./webinterface-window.component.scss'] +}) +export class WebinterfaceWindowComponent implements OnInit { + private token: string; + public isOK = false; + private url: string; + public isLoading = true; + public isConnError = false; + + @Input() + vmid: string; + + @Input() + endpoint: string; + + @Input() + port = 80; + + @Input() + path = '/'; + + @Input() + disallowIFrame = false; + + @Output() + openWebinterfaceFn: EventEmitter = new EventEmitter(false); + + @Input() + reloadEvent: Observable; + + @ViewChild('ideIframe', { static: true }) ideIframe: ElementRef; + + constructor(private jwtHelper: JwtHelperService, private http: HttpClient) {} + + ngOnInit() { + if (this.disallowIFrame) { + return; + } + // we always load our token synchronously from local storage + // for symplicity we are using type assertion to string here, avoiding to handle promises we're not expecting + this.token = this.jwtHelper.tokenGetter() as string; + this.reloadEvent.subscribe((data: webinterfaceTabIdentifier) => { + if (this.vmid == data.vmId && this.port == data.port) { + this.callEndpoint(); + } + }); + this.url = + 'https://' + + this.endpoint + + '/pa/' + + this.token + + '/' + + this.vmid + + '/' + + this.port + + this.path; + this.callEndpoint(); + } + + callEndpoint() { + this.isOK = false; + this.isLoading = true; + this.isConnError = false; + + const req = this.http + .get(this.url, { observe: 'response', responseType: 'text' }) + .pipe(retry(retryConfig)); + + req.subscribe({ + next: (res) => { + if (res.status == 200) { + this.isOK = true; + this.isLoading = false; + this.ideIframe.nativeElement.src = this.url; + } else { + this.isLoading = false; + this.isOK = false; + this.isConnError = true; + } + }, + error: () => { + // This only Errors if the Proxy in gargantua-shell throws an Error, not if the Service on the VM fails + this.isLoading = false; + this.isOK = false; + this.isConnError = true; + }, + }); + } +} + +export const retryConfig: RetryConfig = { + count: 7, + delay: (_error: any, retryCount: number) => { + const scalingDuration = 1000; + return timer(retryCount * scalingDuration); + }, +}; diff --git a/src/app/step/vm.service.ts b/src/app/step/vm.service.ts index 91909241..073e5b4d 100644 --- a/src/app/step/vm.service.ts +++ b/src/app/step/vm.service.ts @@ -1,6 +1,8 @@ import { Injectable } from '@angular/core'; import { ResourceClient, GargantuaClientFactory } from '../data/gargantua.service'; import { VirtualMachine as VM } from '../data/virtualmachine'; +import { catchError, map, throwError } from 'rxjs'; +import { HttpErrorResponse } from '@angular/common/http'; @Injectable() export class VMService extends ResourceClient { @@ -11,7 +13,26 @@ export class VMService extends ResourceClient { get(id: string) { // Do not use cached responses this.cache.clear(); - return super.get(id); } + + getWebinterfaces(id: string) { + return this.garg.get('/getwebinterfaces/' + id).pipe( + catchError((e: HttpErrorResponse) => { + return throwError(() => e.error); + }), + ); + } + + getSharedVMs(acc: string) { + return this.garg.get('/shared/' + acc).pipe( + map( + (res) => + [...JSON.parse(atob(res.content))] as unknown as VM[], + ), + catchError((e: HttpErrorResponse) => { + return throwError(() => e.error); + }), + ); + } }