fixed some issues when adding/deleting instance networks

This commit is contained in:
Dragos 2021-05-31 11:12:37 +03:00
parent 861ada1aa7
commit 96c76c845b
12 changed files with 201 additions and 74 deletions

View File

@ -61,6 +61,40 @@ export class InstancesService
);
}
// ----------------------------------------------------------------------------------------------------------------
getInstanceUntilNicRemoved(instance: any, networkName: string, callbackFn?: InstanceCallbackFunction, maxRetries = 30): Observable<Instance>
{
networkName = networkName.toLocaleLowerCase();
// Keep polling the instance until it reaches the expected state
return this.httpClient.get<Instance>(`/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<Instance>
{
@ -230,6 +264,12 @@ export class InstancesService
{
return this.httpClient.get(`./assets/data/packages.json`);
}
// ----------------------------------------------------------------------------------------------------------------
clearCache()
{
instancesCacheBuster$.next();
}
}
export type InstanceCallbackFunction = ((instance: Instance) => void);

View File

@ -4,7 +4,7 @@
<b>{{ instance.id }}</b>
</li>
<ng-container *ngIf="instance.dnsList">
<ng-container *ngIf="dnsCount">
<li class="dropdown-header">DNS list</li>
<li class="list-group-item text-uppercase px-0 dns d-flex justify-content-between align-items-center"
*ngFor="let keyValue of instance.dnsList | keyvalue; let index = index">

View File

@ -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()
{

View File

@ -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();
}
});
}

View File

@ -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;

View File

@ -108,7 +108,7 @@
<div *ngIf="!instance.loading && instance.imageDetails"
class="text-truncate small text-info text-faded mb-1" [tooltip]="instance.imageDetails.description"
container="body" placement="top" [adaptivePosition]="false">
container="body" placement="top left" [adaptivePosition]="false">
{{ instance.imageDetails.name }}, v{{ instance.imageDetails.version }}
</div>
@ -149,14 +149,14 @@
</div>
<div class="d-flex flex-nowrap justify-content-between align-items-center">
<a href="javascript:void(0)" class="badge text-uppercase"
<button class="badge text-uppercase" [disabled]="instance.state !== 'running' && instance.state !== 'stopped'"
[class.bg-light]="instance.state !== 'running' && instance.state !== 'stopped'"
[class.bg-danger]="instance.state === 'stopped'" [class.bg-success]="instance.state === 'running'"
(click)="showMachineHistory(instance)" tooltip="{{ 'dashboard.listItem.history' | translate }}"
container="body" placement="top" [adaptivePosition]="false">
<fa-icon icon="history" [fixedWidth]="true"></fa-icon>
{{ instance.state }}
</a>
</button>
<div class="btn-group btn-group-sm" dropdown placement="bottom right" *ngIf="!instance.loading">
<button class="btn btn-link text-success" (click)="startMachine(instance)"
@ -178,20 +178,20 @@
<div class="col mt-sm-0 mt-3 no-overflow-sm" *ngIf="showMachineDetails">
<div class="card-header p-0 h-100">
<tabset class="dashboard-tabs" *ngIf="!instance.loading">
<tab customClass="dashboard-tab" (selectTab)="tabChanged($event, instance)"
<tab customClass="dashboard-tab" [disabled]="instance.working" (selectTab)="tabChanged($event, instance)"
id="{{ instance.id }}-info">
<ng-template tabHeading>
<fa-icon icon="info-circle" class="d-sm-none"></fa-icon>
<span class="d-none d-sm-inline-block ms-1">Info</span>
</ng-template>
<div class="card-body p-2 h-100">
<app-instance-info [instance]="instance" [loadInfo]="instance.shouldLoadInfo"
<app-instance-info [instance]="instance" [loadInfo]="instance.shouldLoadInfo" [refresh]="instance.refreshInfo"
(load)="setInstanceInfo(instance, $event)" (processing)="instance.working = true"
(finishedProcessing)="instance.working = false">
</app-instance-info>
</div>
</tab>
<tab customClass="dashboard-tab" (selectTab)="tabChanged($event, instance)"
<tab customClass="dashboard-tab" [disabled]="instance.working" (selectTab)="tabChanged($event, instance)"
id="{{ instance.id }}-networks">
<ng-template tabHeading>
<fa-icon icon="network-wired" class="d-sm-none"></fa-icon>
@ -200,13 +200,13 @@
<div class="card-body p-2 h-100">
<app-instance-networks [instance]="instance" [loadNetworks]="instance.shouldLoadNetworks"
(load)="setInstanceNetworks(instance, $event)" (processing)="instance.working = true"
(finishedProcessing)="instance.working = false"
(finishedProcessing)="refreshInstanceDnsList(instance)"
(instanceReboot)="watchInstanceState(instance)"
(instanceStateUpdate)="updateInstance(instance, $event)">
</app-instance-networks>
</div>
</tab>
<tab customClass="dashboard-tab" (selectTab)="tabChanged($event, instance)"
<tab customClass="dashboard-tab" [disabled]="instance.working" (selectTab)="tabChanged($event, instance)"
id="{{ instance.id }}-snapshots" *ngIf="instance.brand !== 'kvm'">
<ng-template tabHeading>
<fa-icon icon="history" class="d-sm-none"></fa-icon>
@ -220,7 +220,7 @@
</app-instance-snapshots>
</div>
</tab>
<tab *ngIf="false" customClass="dashboard-tab" (selectTab)="tabChanged($event, instance)"
<tab *ngIf="false" customClass="dashboard-tab" [disabled]="instance.working" (selectTab)="tabChanged($event, instance)"
id="{{ instance.id }}-migrations">
<ng-template tabHeading>
<fa-icon icon="coins" class="d-sm-none"></fa-icon>
@ -230,7 +230,7 @@
<button class="btn btn-outline-info w-100">Move to another node</button>
</div>
</tab>
<tab customClass="dashboard-tab" (selectTab)="tabChanged($event, instance)"
<tab customClass="dashboard-tab" [disabled]="instance.working" (selectTab)="tabChanged($event, instance)"
id="{{ instance.id }}-volumes" *ngIf="instance.volumes && instance.volumes.length">
<ng-template tabHeading>
<fa-icon icon="database" class="d-sm-none"></fa-icon>

View File

@ -209,7 +209,7 @@
font-weight: 400;
}
a.badge
button.badge
{
text-decoration: none;
border: none;
}

View File

@ -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)
{

View File

@ -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;

View File

@ -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<void>();
@ -145,12 +147,22 @@ export class NetworkingService
}
// ----------------------------------------------------------------------------------------------------------------
getNicUntilExpectedState(instance: any, nic: Nic, expectedStates: string[], callbackFn?: NicCallbackFunction, maxRetries = 30): Observable<Nic>
getNicUntilAvailable(instance: any, nic: Nic, networkName: string, callbackFn?: NicCallbackFunction, maxRetries = 30): Observable<Nic>
{
// 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<Instance>(`/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;
})
)
)
);
}

View File

@ -41,7 +41,7 @@
<ul *dropdownMenu class="dropdown-menu dropdown-menu-right" role="menu">
<li role="menuitem">
<button class="dropdown-item" (click)="showPolicyEditor(policy)">
<fa-icon [fixedWidth]="true" icon="pen-nib"></fa-icon>
<fa-icon [fixedWidth]="true" icon="pen"></fa-icon>
{{ 'security.editPolicy' | translate }}
</button>
</li>
@ -114,7 +114,7 @@
<ul *dropdownMenu class="dropdown-menu dropdown-menu-right" role="menu">
<li role="menuitem">
<button class="dropdown-item" (click)="showRoleEditor(role)">
<fa-icon [fixedWidth]="true" icon="pen-nib"></fa-icon>
<fa-icon [fixedWidth]="true" icon="pen"></fa-icon>
{{ 'security.editRole' | translate }}
</button>
</li>
@ -195,13 +195,13 @@
<ul *dropdownMenu class="dropdown-menu dropdown-menu-right" role="menu">
<li role="menuitem">
<button class="dropdown-item" (click)="showUserEditor(user)">
<fa-icon [fixedWidth]="true" icon="pen-nib"></fa-icon>
<fa-icon [fixedWidth]="true" icon="pen"></fa-icon>
{{ 'security.editUser' | translate }}
</button>
</li>
<li role="menuitem">
<button class="dropdown-item" (click)="showUserEditor(user, true)">
<fa-icon [fixedWidth]="true" icon="pen-nib"></fa-icon>
<fa-icon [fixedWidth]="true" icon="pen"></fa-icon>
{{ 'security.changePassword' | translate }}
</button>
</li>

View File

@ -172,24 +172,27 @@ body, div, virtual-scroller
.dropdown-item
{
padding: .5rem 1rem;
}
}
&:focus, &:hover
{
color: #1e2125;
background-color: rgba(255,255,255,.75);
}
.dropdown-item
{
&:focus, &:hover
{
color: #1e2125;
background-color: rgba(255,255,255,.75);
}
&.active, &:active
{
color: #0dcaf0;
background-color: #101a30;
}
&.active, &:active
{
color: #0dcaf0;
background-color: #101a30;
}
.ng-fa-icon
{
font-size: 1rem;
vertical-align: middle;
}
.ng-fa-icon
{
font-size: 1rem;
vertical-align: middle;
}
}