import { $q } from 'ngimport'; import React from 'react'; import type { HandlerRendererResult, MenuRendererProps, Option, OptionValues, ReactSelectProps } from 'react-select'; import { BehaviorSubject, combineLatest as observableCombineLatest, from as observableFrom, of as observableOf, Subject, } from 'rxjs'; import { catchError, debounceTime, distinctUntilChanged, map, switchMap, takeUntil, tap } from 'rxjs/operators'; import type { Application } from '@spinnaker/core'; import { HelpField, TetheredSelect, ValidationMessage } from '@spinnaker/core'; import type { IAmazonImage } from '../../image'; import { AwsImageReader } from '../../image'; export interface IAmazonImageSelectorProps { onChange: (value: IAmazonImage) => void; value: IAmazonImage; application: Application; credentials: string; region: string; } export interface IAmazonImageSelectorState { errorMessage?: string; selectionMode: 'packageImages' | 'searchAllImages'; searchString: string; searchResults: IAmazonImage[]; isSearching: boolean; packageImages: IAmazonImage[]; isLoadingPackageImages: boolean; } type sortImagesByOptions = 'name' | 'ts'; export class AmazonImageSelectInput extends React.Component { public state: IAmazonImageSelectorState = { errorMessage: null, selectionMode: 'packageImages', searchString: '', searchResults: null, isSearching: false, packageImages: null, isLoadingPackageImages: true, }; private awsImageReader = new AwsImageReader(); private props$ = new Subject(); private searchInput$ = new Subject(); private destroy$ = new Subject(); private sortImagesBy$ = new BehaviorSubject('ts'); public static makeFakeImage(imageName: string, imageId: string, region: string): IAmazonImage { if (!imageName && !imageId) { return null; } // assume that the specific image exists in the selected region const amis = { [region]: [imageId] }; const attributes = { virtualizationType: '*', architecture: '*', creationDate: new Date().toISOString() }; return { imageName, amis, attributes } as IAmazonImage; } private loadImagesFromApplicationName(application: Application): PromiseLike { const query = application.name.replace(/_/g, '[_\\-]') + '*'; return this.awsImageReader.findImages({ q: query }); } private buildQueryForSimilarImages(imageName: string) { let addDashToQuery = false; let packageBase = imageName.split('_')[0]; const parts = packageBase.split('-'); if (parts.length > 3) { packageBase = parts.slice(0, -3).join('-'); addDashToQuery = true; } const tooShort = !packageBase || packageBase.length < 3; return tooShort ? null : packageBase + (addDashToQuery ? '-*' : '*'); } private loadImageById(imageId: string, region: string, credentials: string): PromiseLike { return !imageId ? $q.when(null) : this.awsImageReader.getImage(imageId, region, credentials).catch(() => null); } private searchForImages(query: string): PromiseLike { const hasMinLength = query && query.length >= 3; return hasMinLength ? this.awsImageReader.findImages({ q: query }) : $q.when([]); } private fetchPackageImages( value: IAmazonImage, region: string, credentials: string, application: Application, ): PromiseLike { const imageId = value && value.amis && value.amis[region] && value.amis[region][0]; return this.loadImageById(imageId, region, credentials).then((image) => { if (!image) { return this.loadImagesFromApplicationName(application); } return this.searchForImages(this.buildQueryForSimilarImages(image.imageName)).then((similarImages) => { if (!similarImages.find((img) => img.imageName === image.imageName)) { // findImages has a limit of 1000 and may not always include the current image, which is confusing return similarImages.concat(image); } return similarImages; }); }); } private selectImage(selectedImage: IAmazonImage) { if (this.props.value !== selectedImage) { this.props.onChange(selectedImage); } } private findMatchingImage(images: IAmazonImage[], selectedImage: IAmazonImage) { return images.find((img) => selectedImage && selectedImage.imageName === img.imageName); } public componentDidMount() { const region$ = this.props$.pipe( map((x) => x.region), distinctUntilChanged(), ); const { value, region, credentials, application } = this.props; this.setState({ isLoadingPackageImages: true }); const fetchPromise = this.fetchPackageImages(value, region, credentials, application); const packageImages$ = observableFrom(fetchPromise).pipe( catchError((err) => { console.error(err); this.setState({ errorMessage: 'Unable to load package images' }); return observableOf([] as IAmazonImage[]); }), tap(() => this.setState({ isLoadingPackageImages: false })), ); const packageImagesInRegion$ = observableCombineLatest([packageImages$, region$, this.sortImagesBy$]).pipe( map(([packageImages, latestRegion, sortImagesBy]) => { const images = packageImages.filter((img) => !!img.amis[latestRegion]); return this.sortImages(images, sortImagesBy); }), ); const searchString$ = this.searchInput$.pipe( tap((searchString) => this.setState({ searchString })), distinctUntilChanged(), debounceTime(250), ); const searchImages$ = searchString$.pipe( tap(() => this.setState({ isSearching: true })), switchMap((searchString) => this.searchForImages(searchString)), catchError((err) => { console.error(err); this.setState({ errorMessage: 'Unable to search for images' }); return observableOf([] as IAmazonImage[]); }), tap(() => this.setState({ isSearching: false })), ); const searchImagesInRegion$ = observableCombineLatest([searchImages$, region$, this.sortImagesBy$]).pipe( map(([searchResults, latestRegion, sortImagesBy]) => { const { searchString } = this.state; // allow 'advanced' users to continue with just an ami id (backing image may not have been indexed yet) if (searchResults.length === 0 && !!/ami-[0-9a-f]{8,17}/.exec(searchString)) { const fakeImage = AmazonImageSelectInput.makeFakeImage(searchString, searchString, latestRegion); return [fakeImage].filter((x) => !!x); } // Filter down to only images which have an ami in the currently selected region const images = searchResults.filter((img) => !!img.amis[latestRegion]); return this.sortImages(images, sortImagesBy); }), ); searchImagesInRegion$.pipe(takeUntil(this.destroy$)).subscribe((searchResults) => this.setState({ searchResults })); packageImagesInRegion$.pipe(takeUntil(this.destroy$)).subscribe((packageImages) => { this.setState({ packageImages }); this.selectImage(this.findMatchingImage(packageImages, this.props.value)); }); // Clear out the selected image if the region changes and the image is not found in the new region region$ .pipe( switchMap((selectedRegion) => { const image = this.props.value; if (this.state.selectionMode === 'packageImages') { // in packageImages mode, wait for the packageImages to load then find the matching one, or undefined return packageImagesInRegion$.pipe(map((images) => this.findMatchingImage(images, image))); } else { // in searchImages mode, return undefined if the selected image is not found in the new region const hasAmiInRegion = !!(image && image.amis && image.amis[selectedRegion]); return observableOf(hasAmiInRegion ? image : undefined); } }), takeUntil(this.destroy$), ) .subscribe((image) => this.selectImage(image)); } private setSortImagesBy(sortImagesBy: sortImagesByOptions) { this.sortImagesBy$.next(sortImagesBy); } private buildImageMenu = (params: MenuRendererProps): HandlerRendererResult => { const { ImageMenuHeading, ImageLabel } = this; const { options } = params; return (
{options.length > 0 && } {options.map((o) => ( ))}
); }; private ImageMenuHeading = () => { const sortImagesBy = this.sortImagesBy$.value; return ( ); }; private sortImages(images: IAmazonImage[], sortImagesBy: sortImagesByOptions): IAmazonImage[] { return images.slice().sort((a, b) => { if (sortImagesBy === 'ts') { return b.attributes.creationDate.localeCompare(a.attributes.creationDate); } return a.imageName.localeCompare(b.imageName); }); } private ImageLabel = (imageLabelProps: { option: Option; params: MenuRendererProps }) => { const { credentials, region } = this.props; const { option, params } = imageLabelProps; const amiLabel = option.amis[region] && option.amis[region][0] ? option.amis[region][0] : ` - not found in ${credentials}/${region}`; return (
params.selectValue(option)} onMouseOver={() => params.focusOption(option)} className={`Select-option ${ params.focusedOption && params.focusedOption.imageName === option.imageName ? 'is-focused' : '' }`} role="option" >
{option.imageName}
Created: {option.attributes.creationDate} AMI: {amiLabel}
); }; public componentDidUpdate() { this.props$.next(this.props); } public componentWillUnmount() { this.destroy$.next(); } public render() { const { value, credentials, region, onChange } = this.props; const { isLoadingPackageImages, isSearching, selectionMode, packageImages, searchResults, searchString, } = this.state; const isPackageImagesLoaded = !!packageImages; const ImageOptionRenderer = (image: IAmazonImage) => { const amis = image.amis || {}; const imageIdForSelectedRegion = amis[region] && amis[region][0]; const message = imageIdForSelectedRegion ? `(${imageIdForSelectedRegion})` : ` - not found in ${credentials}/${region}`; return ( <> {image.imageName} {message} ); }; const commonReactSelectProps: ReactSelectProps = { clearable: false, required: true, valueKey: 'imageName', optionRenderer: ImageOptionRenderer, valueRenderer: ImageOptionRenderer, onSelectResetsInput: false, onBlurResetsInput: false, onCloseResetsInput: false, value, }; const error = this.state.errorMessage ? : null; const noResultsText = `No results found in ${credentials}/${region}`; if (selectionMode === 'searchAllImages') { // User can search for any image using the typeahead // Results are streamed from the back end as the user types const lessThanThreeChars = !searchString || searchString.length < 3; const searchNoResultsText = lessThanThreeChars ? 'Please enter at least 3 characters' : isSearching ? 'Searching...' : noResultsText; return (
{/* @ts-ignore */} { this.searchInput$.next(searchInput); return searchInput; }} onChange={onChange} /> {error}
); } else if (isPackageImagesLoaded) { // User can pick an image from the preloaded 'packageImages' using the typeahead return (
{/* @ts-ignore */} {error} {' '}
); } else { // Show a disabled react-select while waiting for 'packageImages' to load return (
{/* @ts-ignore */} !!x)} /> {error} {' '}
); } } }