import { Component, OnInit, OnDestroy, ViewChild } from '@angular/core'; import { MachinesService } from './helpers/machines.service'; import { BsModalService } from 'ngx-bootstrap/modal'; import { debounceTime, delay, distinctUntilChanged, first, map, switchMap, takeUntil, tap } from 'rxjs/operators'; import { MachineWizardComponent } from './machine-wizard/machine-wizard.component'; import { Machine } from './models/machine'; import { forkJoin, Subject } from 'rxjs'; import { ToastrService } from 'ngx-toastr'; import { CatalogService } from '../catalog/helpers/catalog.service'; import { PackageSelectorComponent } from './package-selector/package-selector.component'; import { PromptDialogComponent } from '../components/prompt-dialog/prompt-dialog.component'; import { MachineTagEditorComponent } from './machine-tag-editor/machine-tag-editor.component'; import { ConfirmationDialogComponent } from '../components/confirmation-dialog/confirmation-dialog.component'; import { MachineHistoryComponent } from './machine-history/machine-history.component'; import { CustomImageEditorComponent } from '../catalog/custom-image-editor/custom-image-editor.component'; import { VirtualScrollerComponent } from 'ngx-virtual-scroller'; 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'; import { sortArray } from '../helpers/utils.service'; import { VolumesService } from '../volumes/helpers/volumes.service'; import { Title } from '@angular/platform-browser'; import { TranslateService } from '@ngx-translate/core'; @Component({ selector: 'app-machines', templateUrl: './machines.component.html', styleUrls: ['./machines.component.scss'] }) export class MachinesComponent implements OnInit, OnDestroy { @ViewChild(VirtualScrollerComponent) private virtualScroller: VirtualScrollerComponent; loadingIndicator = true; machines: Machine[] = []; listItems: Machine[]; images = []; packages = []; volumes = []; lazyLoadDelay: number; canPrepareForLoading: boolean; editorForm: FormGroup; showMachineDetails: boolean; fullDetailsTwoColumns: boolean; runningMachineCount = 0; stoppedMachineCount = 0; machineStateArray: string[] = []; memoryFilterOptions: Options = { animate: false, stepsArray: [], draggableRange: true, showTicks: true, translate: this.translateBytes.bind(this) }; diskFilterOptions: Options = { animate: false, stepsArray: [], draggableRange: true, showTicks: true, translate: this.translateBytes.bind(this) }; private destroy$ = new Subject(); private stableStates = ['running', 'stopped']; private minimumLazyLoadDelay = 1000; private readonly fuseJsOptions: {}; // ---------------------------------------------------------------------------------------------------------------- constructor(private readonly machinesService: MachinesService, private readonly catalogService: CatalogService, private readonly volumesService: VolumesService, private readonly modalService: BsModalService, private readonly toastr: ToastrService, private readonly fb: FormBuilder, private readonly fileSizePipe: FileSizePipe, private readonly titleService: Title, private readonly translationService: TranslateService) { translationService.get('machines.title').pipe(first()).subscribe(x => titleService.setTitle(`Spearhead - ${x}`)); this.lazyLoadDelay = this.minimumLazyLoadDelay; // Configure FuseJs this.fuseJsOptions = { includeScore: false, minMatchCharLength: 2, includeMatches: true, shouldSort: false, threshold: .3, // Lower value means a more exact search keys: [ { name: 'name', weight: .9 }, { name: 'metadataKeys', weight: .7 }, { name: 'tagKeys', weight: .7 }, { name: 'os', weight: .5 }, { name: 'brand', weight: .5 } ] }; this.showMachineDetails = !!JSON.parse(localStorage.getItem('showMachineDetails') || '0'); this.fullDetailsTwoColumns = !!JSON.parse(localStorage.getItem('fullDetailsTwoColumns') || '1'); this.createForm(); } // ---------------------------------------------------------------------------------------------------------------- private translateBytes(value: number, label: LabelType): string { const formattedValue = this.fileSizePipe.transform(value * 1024 * 1024); if (this.machines.length === 1) return formattedValue; switch (label) { case LabelType.Low: return `Between ${formattedValue}`; case LabelType.High: return `and ${formattedValue}`; default: return formattedValue; } } // ---------------------------------------------------------------------------------------------------------------- private getMachines() { this.machinesService.get() .subscribe(machines => { //// DEMO ONLY !!!!! //const arr = new Array(200); //for (let j = 0; j < 200; j++) //{ // const el = { ...machines[0] }; // el.name = this.dummyNames[j]; // arr[j] = el; //}/**/ //// DEMO ONLY !!!!! this.machines = machines.map(machine => { machine.metadataKeys = Object.keys(machine.metadata); machine.tagKeys = Object.keys(machine.tags); machine.loading = true; // Required for improved scrolling experience return machine; }); this.getImagesPackagesAndVolumes(); this.computeFiltersOptions(); this.applyFiltersAndSort(); this.loadingIndicator = false; }); } // ---------------------------------------------------------------------------------------------------------------- private getImagesPackagesAndVolumes() { forkJoin({ images: this.catalogService.getImages(), packages: this.catalogService.getPackages(), volumes: this.volumesService.getVolumes() }) .subscribe(response => { this.images = response.images; this.packages = response.packages; this.volumes = response.volumes; for (const machine of this.machines) this.fillInMachineDetails(machine); }); } // ---------------------------------------------------------------------------------------------------------------- private createForm() { this.editorForm = this.fb.group( { searchTerm: [''], sortProperty: ['name'], filters: this.fb.group( { stateFilter: [], brandFilter: [], typeFilter: [], memoryFilter: [[0, 0]], diskFilter: [[0, 0]], imageFilter: [], // machines provisioned with a certain image }), filtersActive: [false], showMachineDetails: [this.showMachineDetails], fullDetailsTwoColumns: [{ value: this.fullDetailsTwoColumns, disabled: !this.showMachineDetails }] }); this.editorForm.get('searchTerm').valueChanges .pipe( debounceTime(300), distinctUntilChanged(), takeUntil(this.destroy$) ) .subscribe(() => this.applyFiltersAndSort()); this.editorForm.get('sortProperty').valueChanges .pipe( debounceTime(300), distinctUntilChanged(), takeUntil(this.destroy$) ) .subscribe(() => this.applyFiltersAndSort()); this.editorForm.get('filters').valueChanges .pipe( debounceTime(300), distinctUntilChanged(), takeUntil(this.destroy$) ) .subscribe(() => this.applyFiltersAndSort()); this.editorForm.get('showMachineDetails').valueChanges .pipe( debounceTime(300), distinctUntilChanged(), takeUntil(this.destroy$) ) .subscribe(showMachineDetails => { this.editorForm.get('showMachineDetails').disable(); // Performance hack setTimeout(() => this.showMachineDetails = !!showMachineDetails, 0); this.updateList(); // Store this setting in the local storage localStorage.setItem('showMachineDetails', JSON.stringify(showMachineDetails)); setTimeout(() => { this.editorForm.get('showMachineDetails').enable(); if (showMachineDetails) this.editorForm.get('fullDetailsTwoColumns').enable(); else this.editorForm.get('fullDetailsTwoColumns').disable(); }, 300); }); } // ---------------------------------------------------------------------------------------------------------------- private applyFiltersAndSort() { let listItems: Machine[] = null; const searchTerm = this.editorForm.get('searchTerm').value; if (searchTerm.length >= 2) { const fuse = new Fuse(this.machines, this.fuseJsOptions); const fuseResults = fuse.search(searchTerm); listItems = fuseResults.map(x => x.item); } if (!listItems) listItems = [...this.machines]; const stateFilter = this.editorForm.get(['filters', 'stateFilter']).value; if (stateFilter) { listItems = listItems.filter(x => x.state === stateFilter); this.editorForm.get('filtersActive').setValue(true); } const memoryFilter = this.editorForm.get(['filters', 'memoryFilter']).value; if (memoryFilter.every(x => !!x)) { listItems = listItems.filter(x => x.memory >= memoryFilter[0] && x.memory <= memoryFilter[1]); //this.editorForm.get('filtersActive').setValue(true); } const diskFilter = this.editorForm.get(['filters', 'diskFilter']).value; if (memoryFilter.every(x => !!x)) { listItems = listItems.filter(x => x.disk >= diskFilter[0] && x.disk <= diskFilter[1]); //this.editorForm.get('filtersActive').setValue(true); } this.listItems = sortArray(listItems, this.editorForm.get('sortProperty').value); } // ---------------------------------------------------------------------------------------------------------------- clearFilters() { this.editorForm.get('filters').reset(); this.editorForm.get('filtersActive').setValue(false); this.computeFiltersOptions(); } // ---------------------------------------------------------------------------------------------------------------- setSortProperty(propertyName: string) { this.editorForm.get('sortProperty').setValue(propertyName); } // ---------------------------------------------------------------------------------------------------------------- setStateFilter(state?: string) { this.editorForm.get(['filters', 'stateFilter']).setValue(state); } // ---------------------------------------------------------------------------------------------------------------- clearSearch() { this.editorForm.get('searchTerm').setValue(''); } // ---------------------------------------------------------------------------------------------------------------- updateList() { this.virtualScroller.refresh(); } // ---------------------------------------------------------------------------------------------------------------- prepareForLoading(machines: Machine[]) { for (const machine of machines) machine.loading = true; return machines; } // ---------------------------------------------------------------------------------------------------------------- trackByFunction = (index: number, machine: Machine) => machine.name; // ---------------------------------------------------------------------------------------------------------------- private computeFiltersOptions(computeOnlyState = false) { this.runningMachineCount = 0; this.stoppedMachineCount = 0; this.machineStateArray = []; const memoryValues = {}; const diskValues = {}; for (const machine of this.machines) { if (machine.state === 'running') this.runningMachineCount++; if (machine.state === 'stopped') this.stoppedMachineCount++; if (!~this.machineStateArray.indexOf(machine.state)) this.machineStateArray.push(machine.state); if (!computeOnlyState && !memoryValues[machine.memory]) memoryValues[machine.memory] = true; if (!computeOnlyState && !diskValues[machine.disk]) diskValues[machine.disk] = true; } if (computeOnlyState) return; const memoryValuesArray = Object.keys(memoryValues); this.memoryFilterOptions.stepsArray = memoryValuesArray.map(x => ({ legend: '', value: parseInt(x) })); if (this.memoryFilterOptions.stepsArray.length) this.editorForm.get(['filters', 'memoryFilter']).setValue([ this.memoryFilterOptions.stepsArray[0].value, this.memoryFilterOptions.stepsArray[this.memoryFilterOptions.stepsArray.length - 1].value ]); const diskValuesArray = Object.keys(diskValues); this.diskFilterOptions.stepsArray = diskValuesArray.map(x => ({ legend: '', value: parseInt(x) })); if (this.diskFilterOptions.stepsArray.length) this.editorForm.get(['filters', 'diskFilter']).setValue([ this.diskFilterOptions.stepsArray[0].value, this.diskFilterOptions.stepsArray[this.diskFilterOptions.stepsArray.length - 1].value ]); } // ---------------------------------------------------------------------------------------------------------------- startMachine(machine: Machine) { if (machine.state !== 'stopped') return; machine.working = true; this.toastr.info(`Starting machine "${machine.name}"...`); this.machinesService.start(machine.id) .pipe( delay(1000), switchMap(() => this.machinesService.getMachineUntilExpectedState(machine, ['running'], x => { machine.state = x.state; this.computeFiltersOptions(); }) .pipe(takeUntil(this.destroy$)) ) ) .subscribe(() => { this.computeFiltersOptions(); machine.working = false; this.toastr.info(`The machine "${machine.name}" has been started`); }, err => { this.computeFiltersOptions(); machine.working = false; this.toastr.error(`Machine "${machine.name}" error: ${err.error.message}`); }); } // ---------------------------------------------------------------------------------------------------------------- restartMachine(machine: Machine) { if (machine.state !== 'running') return; machine.working = true; this.toastr.info(`Restarting machine "${machine.name}"...`); this.machinesService.reboot(machine.id) .pipe( delay(1000), switchMap(() => this.machinesService.getMachineUntilExpectedState(machine, ['running'], x => { machine.state = x.state; this.computeFiltersOptions(); }) .pipe(takeUntil(this.destroy$)) ) ) .subscribe(() => { this.computeFiltersOptions(); machine.working = false; this.toastr.info(`The machine "${machine.name}" has been restarted`); }, err => { this.computeFiltersOptions(); machine.working = false; this.toastr.error(`Machine "${machine.name}" error: ${err.error.message}`); }); } // ---------------------------------------------------------------------------------------------------------------- stopMachine(machine: Machine) { if (machine.state !== 'running') return; machine.working = true; this.toastr.info(`Stopping machine "${machine.name}"`); this.machinesService.stop(machine.id) .pipe( delay(1000), switchMap(() => this.machinesService.getMachineUntilExpectedState(machine, ['stopped'], x => { machine.state = x.state; this.computeFiltersOptions(); }) .pipe(takeUntil(this.destroy$)) ) ) .subscribe(() => { this.computeFiltersOptions(); machine.working = false; this.toastr.info(`The machine "${machine.name}" has been stopped`); }, err => { this.computeFiltersOptions(); machine.working = false; this.toastr.error(`Machine "${machine.name}" error: ${err.error.message}`); }); } // ---------------------------------------------------------------------------------------------------------------- resizeMachine(machine: Machine) { const modalConfig = { ignoreBackdropClick: true, keyboard: false, animated: true, initialState: { machine } }; const modalRef = this.modalService.show(PackageSelectorComponent, modalConfig); modalRef.setClass('modal-lg'); modalRef.content.save .pipe( tap(() => { this.toastr.info(`Changing specifications for machine "${machine.name}"...`); machine.working = true; }), first(), switchMap(pkg => this.machinesService.resize(machine.id, pkg.id).pipe(map(() => pkg))) ) .subscribe(pkg => { machine.package = pkg.name; machine.memory = pkg.memory; machine.disk = pkg.disk; this.fillInMachineDetails(machine); this.computeFiltersOptions(); machine.working = false; this.toastr.info(`The specifications for machine "${machine.name}" have been changed`); }, err => { machine.working = false; const errorDetails = err.error?.message ? `(${err.error.message})` : ''; this.toastr.error(`Couldn't change the specifications for machine "${machine.name}" ${errorDetails}`); }); } // ---------------------------------------------------------------------------------------------------------------- renameMachine(machine: Machine) { const machineName = machine.name; const modalConfig = { ignoreBackdropClick: true, keyboard: false, animated: true, initialState: { value: machineName, required: true, title: 'Rename machine', prompt: 'Type in the new name for your machine', placeholder: 'New machine name', saveButtonText: 'Change machine name' } }; const modalRef = this.modalService.show(PromptDialogComponent, modalConfig); modalRef.content.save.pipe(first()).subscribe(name => { if (name === machineName) { this.toastr.warning(`You provided the same name for machine "${machineName}"`); return; } machine.working = true; this.machinesService.rename(machine.id, name) .subscribe(() => { machine.name = name; this.applyFiltersAndSort(); this.toastr.info(`The "${machineName}" machine has been renamed to "${machine.name}"`); machine.working = false; }, err => { const errorDetails = err.error?.message ? `(${err.error.message})` : ''; this.toastr.error(`Couldn't rename the "${machineName}" machine ${errorDetails}`); machine.working = false; }); }); } // ---------------------------------------------------------------------------------------------------------------- showTagEditor(machine: Machine, showMetadata = false) { const modalConfig = { ignoreBackdropClick: true, keyboard: false, animated: true, initialState: { machine, showMetadata } }; const modalRef = this.modalService.show(MachineTagEditorComponent, modalConfig); modalRef.setClass('modal-lg'); modalRef.content.save.pipe(first()).subscribe(x => { machine[showMetadata ? 'metadata' : 'tags'] = x; }); } // ---------------------------------------------------------------------------------------------------------------- createImageFromMachine(machine: Machine) { const modalConfig = { ignoreBackdropClick: true, keyboard: false, animated: true, initialState: { machine } }; const modalRef = this.modalService.show(CustomImageEditorComponent, modalConfig); modalRef.content.save.pipe(first()).subscribe(x => { this.toastr.info(`Creating a new image based on the "${machine.name}" machine...`); this.catalogService.createImage(machine.id, x.name, x.version, x.description) .pipe( delay(1000), switchMap(image => this.catalogService.getImageUntilExpectedState(image, ['active', 'failed']) .pipe(takeUntil(this.destroy$)) ) ) .subscribe(image => { if (image.state === 'active') this.toastr.info(`A new image "${x.name}" based on the "${machine.name}" machine has been created`); else this.toastr.error(`Failed to create an image based on the "${machine.name}" machine`); }, err => { const errorDetails = err.error?.message ? `(${err.error.message})` : ''; this.toastr.error(`Failed to create an image based on the "${machine.name}" machine ${errorDetails}`); }); }); } // ---------------------------------------------------------------------------------------------------------------- createMachine(machine?: Machine) { const modalConfig = { ignoreBackdropClick: true, keyboard: false, animated: true, initialState: { machine } }; const modalRef = this.modalService.show(MachineWizardComponent, modalConfig); modalRef.setClass('modal-xl'); modalRef.content.save.pipe(first()).subscribe(x => { if (!x) return; x.working = true; this.fillInMachineDetails(x); this.machines.push(x); this.computeFiltersOptions(); }); } // ---------------------------------------------------------------------------------------------------------------- deleteMachine(machine: Machine) { const modalConfig = { ignoreBackdropClick: true, keyboard: false, animated: true, initialState: { prompt: `Are you sure you wish to permanently delete the "${machine.name}" machine?`, confirmButtonText: 'Yes, delete this machine', declineButtonText: 'No, keep it', confirmByDefault: false } }; const modalRef = this.modalService.show(ConfirmationDialogComponent, modalConfig); modalRef.content.confirm.pipe(first()).subscribe(() => { machine.working = true; this.toastr.info(`Removing machine "${machine.name}"...`); this.machinesService.delete(machine.id) .subscribe(() => { const index = this.machines.findIndex(i => i.id === machine.id); if (index < 0) return; this.machines.splice(index, 1); this.computeFiltersOptions(); this.toastr.info(`The machine "${machine.name}" has been removed`); }, err => { machine.working = false; this.toastr.error(`Machine "${machine.name}" error: ${err.error.message}`); }); }); } // ---------------------------------------------------------------------------------------------------------------- showMachineHistory(machine: Machine) { const modalConfig = { ignoreBackdropClick: true, keyboard: false, animated: true, initialState: { machine } }; const modalRef = this.modalService.show(MachineHistoryComponent, modalConfig); modalRef.setClass('modal-lg'); } // ---------------------------------------------------------------------------------------------------------------- tabChanged(event, machine: Machine) { if (event.id.endsWith('info')) machine.shouldLoadInfo = this.editorForm.get('showMachineDetails').value; else if (event.id.endsWith('snapshots')) machine.shouldLoadSnapshots = this.editorForm.get('showMachineDetails').value; else if (event.id.endsWith('networks')) machine.shouldLoadNetworks = this.editorForm.get('showMachineDetails').value; else if (event.id.endsWith('volumes')) { //machine.shouldLoadVolumes = this.editorForm.get('showMachineDetails').value; } else if (event.id.endsWith('migrations')) { //machine.shouldLoadMigrations = this.editorForm.get('showMachineDetails').value; } } // ---------------------------------------------------------------------------------------------------------------- loadMachineDetails(machine: Machine): any { machine.loading = false; machine.working = !this.stableStates.includes(machine.state); // Keep polling the machines that are not in a "stable" state if (machine.working) this.machinesService.getMachineUntilExpectedState(machine, this.stableStates, x => { machine.state = x.state; // This allows us to trigger later on when the state changes to a something stable machine.shouldLoadInfo = false; this.computeFiltersOptions(true); }) .pipe(takeUntil(this.destroy$)) .subscribe(x => { machine.working = false; // Update the machine with what we got from the server Object.assign(machine, x); machine.shouldLoadInfo = this.editorForm.get('showMachineDetails').value; this.computeFiltersOptions(); }, err => { if (err.status === 410) { const index = this.machines.findIndex(i => i.id === machine.id); if (index >= 0) { this.machines.splice(index, 1); this.computeFiltersOptions(); this.toastr.error(`The machine "${machine.name}" has been removed`); } } else this.toastr.error(`Machine "${machine.name}" error: ${err.error.message}`); machine.working = false; }); machine.shouldLoadInfo = this.editorForm.get('showMachineDetails').value; } // ---------------------------------------------------------------------------------------------------------------- watchMachineState(machine: Machine) { machine.working = true; this.machinesService.getMachineUntilExpectedState(machine, ['running'], x => { machine.state = x.state; this.computeFiltersOptions(true); }) .pipe( delay(3000), takeUntil(this.destroy$) ).subscribe(() => { }, err => { machine.working = false; }); } // ---------------------------------------------------------------------------------------------------------------- updateMachine(machine: Machine, updates: Machine) { machine.refreshInfo = machine.state !== updates.state; machine.state = updates.state; } // ---------------------------------------------------------------------------------------------------------------- setMachineInfo(machine: Machine, dnsList) { // Update the machine as a result of the info 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. machine.dnsList = dnsList; machine.infoLoaded = true; } // ---------------------------------------------------------------------------------------------------------------- setMachineNetworks(machine: Machine, nics) { // Update the machine as a result of the networks 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. machine.nics = nics; machine.networksLoaded = true; } // ---------------------------------------------------------------------------------------------------------------- setMachineSnapshots(machine: Machine, snapshots) { // Update the machine 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. machine.snapshots = snapshots; machine.snapshotsLoaded = true; } // ---------------------------------------------------------------------------------------------------------------- refreshMachineDnsList(machine: Machine) { machine.working = false; machine.refreshInfo = true; } // ---------------------------------------------------------------------------------------------------------------- toggleMachineDetails() { this.showMachineDetails = !this.showMachineDetails; this.editorForm.get('showMachineDetails').setValue(this.showMachineDetails); } // ---------------------------------------------------------------------------------------------------------------- private fillInMachineDetails(machine: Machine) { const imageDetails = this.images.find(i => i.id === machine.image); if (imageDetails) machine.imageDetails = imageDetails; const packageDetails = this.packages.find(p => p.name === machine.package); if (packageDetails) machine.packageDetails = packageDetails; machine.volumes = this.volumes.filter(i => i.refs && i.refs.includes(machine.id)); } // ---------------------------------------------------------------------------------------------------------------- private randomIntFromInterval(min, max) { // min and max included return Math.floor(Math.random() * (max - min + 1) + min); } // ---------------------------------------------------------------------------------------------------------------- ngOnInit(): void { this.getMachines(); } // ---------------------------------------------------------------------------------------------------------------- ngOnDestroy() { this.destroy$.next(); } }