diff --git a/app/src/app/instances/helpers/instances.service.ts b/app/src/app/instances/helpers/instances.service.ts index e7cc605..7b93cae 100644 --- a/app/src/app/instances/helpers/instances.service.ts +++ b/app/src/app/instances/helpers/instances.service.ts @@ -61,6 +61,40 @@ export class InstancesService ); } + // ---------------------------------------------------------------------------------------------------------------- + getInstanceUntilNicRemoved(instance: any, networkName: string, callbackFn?: InstanceCallbackFunction, maxRetries = 30): Observable + { + networkName = networkName.toLocaleLowerCase(); + + // Keep polling the instance until it reaches the expected state + return this.httpClient.get(`/api/my/machines/${instance.id}`) + .pipe( + tap(instance => callbackFn && callbackFn(instance)), + repeatWhen(x => + { + let retries = 0; + + return x.pipe( + delay(3000), + map(() => + { + if (retries++ === maxRetries) + throw { error: `Failed to retrieve the current status for machine "${instance.name}"` }; + }) + ); + }), + filter(x => x.state === 'running' && !x.dns_names.some(d => d.toLocaleLowerCase().indexOf(networkName) >= 0)), + take(1), // needed to stop the repeatWhen loop + map(x => + { + if (callbackFn) + callbackFn(x); + + return instance; + }) + ); + } + // ---------------------------------------------------------------------------------------------------------------- add(instance: InstanceRequest): Observable { @@ -230,6 +264,12 @@ export class InstancesService { return this.httpClient.get(`./assets/data/packages.json`); } + + // ---------------------------------------------------------------------------------------------------------------- + clearCache() + { + instancesCacheBuster$.next(); + } } export type InstanceCallbackFunction = ((instance: Instance) => void); diff --git a/app/src/app/instances/instance-info/instance-info.component.html b/app/src/app/instances/instance-info/instance-info.component.html index e81fcbe..f7e3cd6 100644 --- a/app/src/app/instances/instance-info/instance-info.component.html +++ b/app/src/app/instances/instance-info/instance-info.component.html @@ -4,7 +4,7 @@ {{ instance.id }} - +
  • diff --git a/app/src/app/instances/instance-info/instance-info.component.ts b/app/src/app/instances/instance-info/instance-info.component.ts index a3272bb..0b1a8e1 100644 --- a/app/src/app/instances/instance-info/instance-info.component.ts +++ b/app/src/app/instances/instance-info/instance-info.component.ts @@ -20,6 +20,9 @@ export class InstanceInfoComponent implements OnInit, OnDestroy, OnChanges @Input() loadInfo: boolean; + @Input() + refresh: boolean; + @Output() processing = new EventEmitter(); @@ -30,7 +33,8 @@ export class InstanceInfoComponent implements OnInit, OnDestroy, OnChanges load = new EventEmitter(); loading: boolean; - + dnsCount: number; + private finishedLoading: boolean; private destroy$ = new Subject(); private onChanges$ = new ReplaySubject(); @@ -70,10 +74,13 @@ export class InstanceInfoComponent implements OnInit, OnDestroy, OnChanges // ---------------------------------------------------------------------------------------------------------------- private getInfo() { - if (this.finishedLoading) return; + if (this.finishedLoading || this.instance.state === 'provisioning') return; this.loading = true; + if (this.refresh) + this.instancesService.clearCache(); + this.instancesService.getById(this.instance.id) .subscribe(x => { @@ -81,6 +88,8 @@ export class InstanceInfoComponent implements OnInit, OnDestroy, OnChanges for (const dns of x.dns_names.sort((a, b) => b.localeCompare(a))) dnsList[dns] = this.getParsedDns(dns); + this.dnsCount = Object.keys(dnsList).length; + this.instance.dnsList = dnsList; this.loading = false; @@ -112,7 +121,13 @@ export class InstanceInfoComponent implements OnInit, OnDestroy, OnChanges { this.onChanges$.pipe(takeUntil(this.destroy$)).subscribe(() => { - if (!this.finishedLoading && this.loadInfo && !this.instance?.infoLoaded) + if (this.refresh) + { + this.finishedLoading = false; + this.loadInfo = true; + } + + if (!this.finishedLoading && this.loadInfo && !this.instance?.infoLoaded || this.refresh) this.getInfo(); }); } @@ -124,7 +139,6 @@ export class InstanceInfoComponent implements OnInit, OnDestroy, OnChanges this.onChanges$.next(changes); } - // ---------------------------------------------------------------------------------------------------------------- ngOnDestroy() { diff --git a/app/src/app/instances/instance-networks/instance-networks.component.ts b/app/src/app/instances/instance-networks/instance-networks.component.ts index c87b2cb..2ecc139 100644 --- a/app/src/app/instances/instance-networks/instance-networks.component.ts +++ b/app/src/app/instances/instance-networks/instance-networks.component.ts @@ -39,13 +39,14 @@ export class InstanceNetworksComponent implements OnInit, OnDestroy, OnChanges instanceStateUpdate = new EventEmitter(); loading: boolean; - nics: Nic[]; + nics: Nic[] = []; publicNetworks: Network[] = []; fabricNetworks: Network[] = []; finishedLoading: boolean; private destroy$ = new Subject(); private onChanges$ = new ReplaySubject(); + private networks: Network[]; // ---------------------------------------------------------------------------------------------------------------- constructor(private readonly networkingService: NetworkingService, @@ -71,17 +72,19 @@ export class InstanceNetworksComponent implements OnInit, OnDestroy, OnChanges } // ---------------------------------------------------------------------------------------------------------------- - private getNetworks() + private getNetworks(force = false) { - if (this.finishedLoading) return; - - this.loading = true; + if ((this.finishedLoading || this.instance.state === 'provisioning') && !force) return; const observables = this.nics.map(x => this.networkingService.getNetwork(x.network)); + this.loading = observables.length > 0; + forkJoin(observables) .subscribe(networks => { + this.networks = networks; + for (const nic of this.nics) { nic.networkDetails = networks.find(x => x.id === nic.network); @@ -147,7 +150,7 @@ export class InstanceNetworksComponent implements OnInit, OnDestroy, OnChanges { // Keep polling the newly created NIC until it reaches its "running"/"stopped" state return this.networkingService - .getNicUntilExpectedState(this.instance, response.nic, ['running', 'stopped'], n => this.nics[0].state = n.state) + .getNicUntilAvailable(this.instance, response.nic, network.name, n => this.nics[0].state = n.state) .pipe( takeUntil(this.destroy$), map(y => ({ network: response.network, nic: y })) @@ -224,8 +227,8 @@ export class InstanceNetworksComponent implements OnInit, OnDestroy, OnChanges // If the machine is currently running, keep polling until it finishes restarting return this.instance.state === 'running' ? this.instancesService - .getInstanceUntilExpectedState(this.instance, ['running'], x => this.instanceStateUpdate.emit(x)) - .pipe(takeUntil(this.destroy$)) + .getInstanceUntilNicRemoved(this.instance, nic.networkName, x => this.instanceStateUpdate.emit(x)) + .pipe(delay(1000), takeUntil(this.destroy$)) : of(nic); }) ); @@ -285,15 +288,17 @@ export class InstanceNetworksComponent implements OnInit, OnDestroy, OnChanges // ---------------------------------------------------------------------------------------------------------------- ngOnInit(): void { - this.nics = this.instance?.nics || []; - - if (this.instance.networksLoaded) + if (!this.instance.nics?.length || this.instance.networksLoaded) this.finishedLoading = true; this.onChanges$.pipe(takeUntil(this.destroy$)).subscribe(() => { if (!this.finishedLoading && this.loadNetworks && !this.instance?.networksLoaded) + { + this.nics = this.instance?.nics || []; + this.getNetworks(); + } }); } diff --git a/app/src/app/instances/instance-snapshots/instance-snapshots.component.ts b/app/src/app/instances/instance-snapshots/instance-snapshots.component.ts index c7e96f1..595648c 100644 --- a/app/src/app/instances/instance-snapshots/instance-snapshots.component.ts +++ b/app/src/app/instances/instance-snapshots/instance-snapshots.component.ts @@ -1,6 +1,5 @@ import { Component, OnInit, OnDestroy, OnChanges, Input, Output, EventEmitter, SimpleChanges } from '@angular/core'; import { ToastrService } from 'ngx-toastr'; -import { CatalogService } from '../../catalog/helpers/catalog.service'; import { InstancesService } from '../helpers/instances.service'; import { ReplaySubject, Subject } from 'rxjs'; import { delay, first, switchMap, takeUntil, tap } from 'rxjs/operators'; @@ -27,7 +26,7 @@ export class InstanceSnapshotsComponent implements OnInit, OnDestroy, OnChanges processing = new EventEmitter(); @Output() - processingFinished = new EventEmitter(); + finishedProcessing = new EventEmitter(); @Output() load = new EventEmitter(); @@ -51,7 +50,6 @@ export class InstanceSnapshotsComponent implements OnInit, OnDestroy, OnChanges // ---------------------------------------------------------------------------------------------------------------- constructor(private readonly instancesService: InstancesService, private readonly snapshotsService: SnapshotsService, - private readonly catalogService: CatalogService, private readonly modalService: BsModalService, private readonly toastr: ToastrService) { @@ -97,7 +95,7 @@ export class InstanceSnapshotsComponent implements OnInit, OnDestroy, OnChanges if (index >= 0) this.snapshots[index] = x; - this.processingFinished.emit(); + this.finishedProcessing.emit(); this.toastr.info(`A new snapshot "${snapshotName}" has been created for machine "${this.instance.name}"`); }, err => @@ -108,7 +106,7 @@ export class InstanceSnapshotsComponent implements OnInit, OnDestroy, OnChanges if (index >= 0) this.snapshots.splice(index, 1); - this.processingFinished.emit(); + this.finishedProcessing.emit(); this.toastr.error(`Machine "${this.instance.name}" error: ${err.error.message}`); }); } @@ -133,11 +131,15 @@ export class InstanceSnapshotsComponent implements OnInit, OnDestroy, OnChanges switchMap(() => this.instancesService.getInstanceUntilExpectedState(this.instance, ['stopped'], x => this.instanceStateUpdate.emit(x)) .pipe(takeUntil(this.destroy$)) ) - ).subscribe(() => this.startMachineFromSnapshot(snapshot), + ).subscribe(() => + { + snapshot.working = false; + this.startMachineFromSnapshot(snapshot); + }, err => { snapshot.working = false; - this.processingFinished.emit(); + this.finishedProcessing.emit(); this.toastr.error(`Machine "${this.instance.name}" error: ${err.error.message}`); }); @@ -184,14 +186,14 @@ export class InstanceSnapshotsComponent implements OnInit, OnDestroy, OnChanges { snapshot.working = false; - this.processingFinished.emit(); + this.finishedProcessing.emit(); this.toastr.info(`The machine "${this.instance.name}" has been started from the "${snapshot.name}" snapshot`); }, err => { snapshot.working = false; - this.processingFinished.emit(); + this.finishedProcessing.emit(); this.toastr.error(`Machine "${this.instance.name}" error: ${err.error.message}`); }); @@ -225,12 +227,12 @@ export class InstanceSnapshotsComponent implements OnInit, OnDestroy, OnChanges if (index >= 0) this.snapshots.splice(index, 1); - this.processingFinished.emit(); + this.finishedProcessing.emit(); this.toastr.info(`The "${snapshot.name}" snapshot has been deleted`); }, err => { - this.processingFinished.emit(); + this.finishedProcessing.emit(); this.toastr.error(`The "${snapshot.name}" snapshot couldn't be deleted: ${err.error.message}`); }); @@ -240,7 +242,7 @@ export class InstanceSnapshotsComponent implements OnInit, OnDestroy, OnChanges // ---------------------------------------------------------------------------------------------------------------- private getSnapshots() { - if (this.snapshotsLoaded) return + if (this.snapshotsLoaded || this.instance.state === 'provisioning') return this.loadingSnapshots = true; diff --git a/app/src/app/instances/instances.component.html b/app/src/app/instances/instances.component.html index 7233108..0064b05 100644 --- a/app/src/app/instances/instances.component.html +++ b/app/src/app/instances/instances.component.html @@ -108,7 +108,7 @@
    + container="body" placement="top left" [adaptivePosition]="false"> {{ instance.imageDetails.name }}, v{{ instance.imageDetails.version }}
    @@ -149,14 +149,14 @@
    - {{ instance.state }} - +
    - diff --git a/app/src/app/instances/instances.component.scss b/app/src/app/instances/instances.component.scss index 082fdfa..7285ab6 100644 --- a/app/src/app/instances/instances.component.scss +++ b/app/src/app/instances/instances.component.scss @@ -209,7 +209,7 @@ font-weight: 400; } -a.badge +button.badge { - text-decoration: none; + border: none; } diff --git a/app/src/app/instances/instances.component.ts b/app/src/app/instances/instances.component.ts index 27f9088..2e018e7 100644 --- a/app/src/app/instances/instances.component.ts +++ b/app/src/app/instances/instances.component.ts @@ -14,7 +14,7 @@ import { ConfirmationDialogComponent } from '../components/confirmation-dialog/c import { InstanceHistoryComponent } from './instance-history/instance-history.component'; import { CustomImageEditorComponent } from '../catalog/custom-image-editor/custom-image-editor.component'; import { VirtualScrollerComponent } from 'ngx-virtual-scroller'; -import { FormGroup, FormBuilder, Validators, AbstractControl, FormArray } from '@angular/forms'; +import { FormGroup, FormBuilder } from '@angular/forms'; import Fuse from 'fuse.js'; import { LabelType, Options } from '@angular-slider/ngx-slider'; import { FileSizePipe } from '../pipes/file-size.pipe'; @@ -660,6 +660,8 @@ export class InstancesComponent implements OnInit, OnDestroy { if (!x) return; + x.working = true; + this.fillInInstanceDetails(x); this.instances.push(x); @@ -757,6 +759,9 @@ export class InstancesComponent implements OnInit, OnDestroy { instance.state = x.state; + // This allows us to trigger later on when the state changes to a something stable + instance.shouldLoadInfo = false; + this.computeFiltersOptions(true); }) .pipe(takeUntil(this.destroy$)) @@ -765,13 +770,11 @@ export class InstancesComponent implements OnInit, OnDestroy instance.working = false; // Update the instance with what we got from the server - const index = this.instances.findIndex(i => i.id === instance.id); - if (index >= 0) - { - this.instances.splice(index, 1, x); + Object.assign(instance, x); - this.computeFiltersOptions(); - } + instance.shouldLoadInfo = this.editorForm.get('showMachineDetails').value; + + this.computeFiltersOptions(); }, err => { if (err.status === 410) @@ -792,7 +795,7 @@ export class InstancesComponent implements OnInit, OnDestroy instance.working = false; }); - instance.shouldLoadInfo = true; + instance.shouldLoadInfo = this.editorForm.get('showMachineDetails').value; } // ---------------------------------------------------------------------------------------------------------------- @@ -818,6 +821,7 @@ export class InstancesComponent implements OnInit, OnDestroy // ---------------------------------------------------------------------------------------------------------------- updateInstance(instance: Instance, updates: Instance) { + instance.refreshInfo = instance.state !== updates.state; instance.state = updates.state; } @@ -840,7 +844,7 @@ export class InstancesComponent implements OnInit, OnDestroy } // ---------------------------------------------------------------------------------------------------------------- - setInstanceSnapshot(instance: Instance, snapshots) + setInstanceSnapshots(instance: Instance, snapshots) { // Update the instance as a result of the snapshots panel's "load" event. We do this because the intances are (un)loaded // from the viewport as the user scrolls through the page, to optimize memory consumption. @@ -848,6 +852,13 @@ export class InstancesComponent implements OnInit, OnDestroy instance.snapshotsLoaded = true; } + // ---------------------------------------------------------------------------------------------------------------- + refreshInstanceDnsList(instance: Instance) + { + instance.working = false; + instance.refreshInfo = true; + } + // ---------------------------------------------------------------------------------------------------------------- private fillInInstanceDetails(instance: Instance) { diff --git a/app/src/app/instances/models/instance.ts b/app/src/app/instances/models/instance.ts index 85b6ee9..0943f20 100644 --- a/app/src/app/instances/models/instance.ts +++ b/app/src/app/instances/models/instance.ts @@ -42,8 +42,10 @@ export class Instance extends InstanceRequest working: boolean; shouldLoadInfo: boolean; infoLoaded: boolean; + refreshInfo: boolean; shouldLoadNetworks: boolean; networksLoaded: boolean; + refreshNetworks: boolean; shouldLoadSnapshots: boolean; snapshotsLoaded: boolean; volumesEnabled: boolean; diff --git a/app/src/app/networking/helpers/networking.service.ts b/app/src/app/networking/helpers/networking.service.ts index b304456..2604e43 100644 --- a/app/src/app/networking/helpers/networking.service.ts +++ b/app/src/app/networking/helpers/networking.service.ts @@ -1,7 +1,7 @@ import { Injectable } from '@angular/core'; import { Observable, Subject } from 'rxjs'; import { HttpClient } from '@angular/common/http'; -import { delay, filter, first, flatMap, map, mergeMapTo, repeatWhen, switchMap, switchMapTo, take, takeUntil, tap } from 'rxjs/operators'; +import { concatMap, delay, filter, first, flatMap, map, mergeMapTo, repeatWhen, switchMap, switchMapTo, take, takeUntil, tap } from 'rxjs/operators'; import { concat, empty, of, range, throwError, zip } from 'rxjs'; import { Cacheable } from 'ts-cacheable'; import { Network } from '../models/network'; @@ -10,6 +10,8 @@ import { VirtualAreaNetwork } from '../models/vlan'; import { VirtualAreaNetworkRequest } from '../models/vlan'; import { EditNetworkRequest } from '../models/network'; import { AddNetworkRequest } from '../models/network'; +import { Instance } from 'src/app/instances/models/instance'; +import { InstanceCallbackFunction } from 'src/app/instances/helpers/instances.service'; const networksCacheBuster$ = new Subject(); @@ -145,12 +147,22 @@ export class NetworkingService } // ---------------------------------------------------------------------------------------------------------------- - getNicUntilExpectedState(instance: any, nic: Nic, expectedStates: string[], callbackFn?: NicCallbackFunction, maxRetries = 30): Observable + getNicUntilAvailable(instance: any, nic: Nic, networkName: string, callbackFn?: NicCallbackFunction, maxRetries = 30): Observable { - // Keep polling the snapshot until it reaches the expected state + networkName = networkName.toLocaleLowerCase(); + + // Keep polling the instance until it reaches the expected state return this.getNic(instance.id, nic.mac) .pipe( - tap(x => callbackFn && callbackFn(x)), + tap(x => + { + // We create our own state while the instance reboots + if (x.state === 'running') + x.state = 'starting'; + + if (callbackFn) + callbackFn(x); + }), repeatWhen(x => { let retries = 0; @@ -164,8 +176,46 @@ export class NetworkingService }) ); }), - filter(x => expectedStates.includes(x.state)), - take(1) // needed to stop the repeatWhen loop + filter(x => x.state === 'running' || x.state === 'starting'), + take(1), // needed to stop the repeatWhen loop + concatMap(nic => + this.httpClient.get(`/api/my/machines/${instance.id}`) + .pipe( + tap(() => + { + // We create our own state while the instance reboots + nic.state = 'starting'; + + if (callbackFn) + callbackFn(nic); + }), + repeatWhen(x => + { + let retries = 0; + + return x.pipe( + delay(3000), + map(() => + { + if (retries++ === maxRetries) + throw { error: { message: `Failed to retrieve the current status for network interface "${nic.mac}"` } }; + }) + ); + }), + filter(x => x.state === 'running' && x.dns_names.some(d => d.toLocaleLowerCase().indexOf(networkName) >= 0)), + take(1), // needed to stop the repeatWhen loop + map(() => + { + // We manually set the state as "running" now that the instance has rebooted + nic.state = 'running'; + + if (callbackFn) + callbackFn(nic); + + return nic; + }) + ) + ) ); } diff --git a/app/src/app/security/security.component.html b/app/src/app/security/security.component.html index d610484..69b2832 100644 --- a/app/src/app/security/security.component.html +++ b/app/src/app/security/security.component.html @@ -41,7 +41,7 @@